|
|
|
|
|
import json
|
|
|
import random
|
|
|
import string
|
|
|
|
|
|
import sqlalchemy as sa
|
|
|
from redis.asyncio.client import Redis
|
|
|
|
|
|
from app.common.enums import RedisInitKeyConfig
|
|
|
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
|
|
from app.core.dependencies import require_superadmin
|
|
|
from app.core.exceptions import CustomException
|
|
|
from app.core.logger import logger
|
|
|
from app.core.redis_crud import RedisCURD
|
|
|
from app.utils.hash_bcrpy_util import PwdUtil
|
|
|
|
|
|
from .crud import TenantCRUD
|
|
|
from .model import TenantModel, TenantUserModel
|
|
|
from .schema import (
|
|
|
PackageChangePreviewOut,
|
|
|
TenantConfigOutSchema,
|
|
|
TenantCreateSchema,
|
|
|
TenantOutSchema,
|
|
|
TenantQueryParam,
|
|
|
TenantUpdateSchema,
|
|
|
TenantUserAddSchema,
|
|
|
TenantUserOutSchema,
|
|
|
)
|
|
|
|
|
|
|
|
|
class TenantService:
|
|
|
"""
|
|
|
租户管理服务(查询操作租户可见,写操作仅超级管理员可操作)
|
|
|
|
|
|
设计:实例方法承载「当前用户上下文 (auth)」,``redis`` 仍是方法参数。
|
|
|
内部跨方法调用从 ``cls.xxx(auth, ...)`` 改为 ``self.xxx(...)``。
|
|
|
定时任务方法与静态工具方法保持 ``@staticmethod``(无 auth)。
|
|
|
"""
|
|
|
|
|
|
def __init__(self, auth: AuthSchema) -> None:
|
|
|
self.auth = auth
|
|
|
|
|
|
async def detail(self, id: int) -> TenantOutSchema:
|
|
|
"""
|
|
|
租户详情
|
|
|
|
|
|
参数:
|
|
|
- id (int): 租户ID
|
|
|
|
|
|
返回:
|
|
|
- TenantOutSchema: 租户详情
|
|
|
"""
|
|
|
return await TenantCRUD(self.auth).get_or_404(id=id, out_schema=TenantOutSchema)
|
|
|
|
|
|
async def page(
|
|
|
self,
|
|
|
page_no: int,
|
|
|
page_size: int,
|
|
|
search: TenantQueryParam | None = None,
|
|
|
order_by: list[dict[str, str]] | None = None,
|
|
|
) -> dict:
|
|
|
return await TenantCRUD(self.auth).page(
|
|
|
offset=(page_no - 1) * page_size,
|
|
|
limit=page_size,
|
|
|
order_by=order_by or [{"id": "asc"}],
|
|
|
search=vars(search) if search else None,
|
|
|
out_schema=TenantOutSchema,
|
|
|
)
|
|
|
|
|
|
@require_superadmin
|
|
|
async def create(self, data: TenantCreateSchema) -> TenantOutSchema:
|
|
|
if await TenantCRUD(self.auth).get(name=data.name):
|
|
|
raise CustomException(msg="创建失败,名称已存在")
|
|
|
if await TenantCRUD(self.auth).get(code=data.code):
|
|
|
raise CustomException(msg="创建失败,编码已存在")
|
|
|
|
|
|
tenant_obj = await TenantCRUD(self.auth).create(data=data)
|
|
|
if not tenant_obj:
|
|
|
raise CustomException(msg="创建租户失败")
|
|
|
|
|
|
# 创建租户初始管理员
|
|
|
# 1. 生成初始管理员用户名
|
|
|
# 2. 检查用户名是否已存在
|
|
|
# 3. 创建初始管理员用户
|
|
|
username = f"{tenant_obj.code}_admin"
|
|
|
from app.api.v1.module_system.user.crud import UserCRUD
|
|
|
|
|
|
if await UserCRUD(self.auth).get(username=username):
|
|
|
raise CustomException(msg=f"初始管理员用户名已存在: {username},请更换租户编码后重试")
|
|
|
|
|
|
password_length = 12
|
|
|
characters = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
|
password = "".join(random.choice(characters) for _ in range(password_length))
|
|
|
admin_data = {
|
|
|
"username": username,
|
|
|
"password": PwdUtil.hash_password(password=password),
|
|
|
"name": f"{tenant_obj.name}管理员",
|
|
|
"tenant_id": tenant_obj.id,
|
|
|
"status": 0,
|
|
|
"is_superuser": False,
|
|
|
}
|
|
|
try:
|
|
|
user_obj = await UserCRUD(self.auth).create(data=admin_data)
|
|
|
if not user_obj:
|
|
|
raise CustomException(msg="创建租户初始管理员失败")
|
|
|
except CustomException:
|
|
|
raise
|
|
|
except Exception as e:
|
|
|
logger.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}")
|
|
|
raise CustomException(msg="创建租户初始管理员失败") from e
|
|
|
|
|
|
logger.info(f"为租户[{tenant_obj.name}]创建初始管理员成功,用户名: {username},临时密码: {password}")
|
|
|
|
|
|
await self.auth.db.refresh(tenant_obj)
|
|
|
result = TenantOutSchema.model_validate(tenant_obj)
|
|
|
|
|
|
return result
|
|
|
|
|
|
@require_superadmin
|
|
|
async def update(self, id: int, data: TenantUpdateSchema) -> TenantOutSchema:
|
|
|
"""
|
|
|
更新租户
|
|
|
|
|
|
参数:
|
|
|
- id (int): 租户ID
|
|
|
- data (TenantUpdateSchema): 租户更新模型
|
|
|
|
|
|
返回:
|
|
|
- TenantOutSchema: 租户详情
|
|
|
"""
|
|
|
obj = await TenantCRUD(self.auth).get_or_404(id=id)
|
|
|
|
|
|
old_package_id = obj.package_id
|
|
|
|
|
|
if id == 1:
|
|
|
if data.code is not None and data.code != obj.code:
|
|
|
raise CustomException(msg="系统租户编码不可修改")
|
|
|
if data.status is not None and data.status == 1:
|
|
|
raise CustomException(msg="系统租户不允许禁用")
|
|
|
|
|
|
# 套餐变更:仅超管可操作,防止租户管理员自行升级/降级套餐
|
|
|
if data.package_id is not None and data.package_id != old_package_id:
|
|
|
if not self.auth.user or not self.auth.user.is_superuser:
|
|
|
raise CustomException(msg="仅平台管理员可变更租户套餐")
|
|
|
|
|
|
if data.name is not None:
|
|
|
exist = await TenantCRUD(self.auth).get(name=data.name)
|
|
|
if exist and exist.id != id:
|
|
|
raise CustomException(msg="更新失败,名称重复")
|
|
|
if data.code is not None:
|
|
|
exist = await TenantCRUD(self.auth).get(code=data.code)
|
|
|
if exist and exist.id != id:
|
|
|
raise CustomException(msg="更新失败,编码重复")
|
|
|
|
|
|
updated = await TenantCRUD(self.auth).update(id=id, data=data)
|
|
|
if not updated:
|
|
|
raise CustomException(msg="更新失败")
|
|
|
|
|
|
# 套餐变更后:清理角色中不再可用的菜单关联,防止用户看到空白菜单
|
|
|
if data.package_id is not None and data.package_id != old_package_id:
|
|
|
from sqlalchemy import delete as sa_delete
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from app.api.v1.module_platform.package.service import PackageService
|
|
|
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
|
|
|
|
|
available_ids = await PackageService(self.auth).get_tenant_available_menu_ids(id)
|
|
|
if available_ids:
|
|
|
role_ids_stmt = select(RoleModel.id).where(RoleModel.tenant_id == id)
|
|
|
result = await self.auth.db.execute(role_ids_stmt)
|
|
|
tenant_role_ids = [row[0] for row in result.all()]
|
|
|
if tenant_role_ids:
|
|
|
await self.auth.db.execute(
|
|
|
sa_delete(RoleMenusModel).where(
|
|
|
RoleMenusModel.role_id.in_(tenant_role_ids),
|
|
|
RoleMenusModel.menu_id.notin_(available_ids),
|
|
|
)
|
|
|
)
|
|
|
await self.auth.db.flush()
|
|
|
logger.info(
|
|
|
f"租户[{id}]套餐变更:已清理角色中不再可用的菜单关联, "
|
|
|
f"available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}"
|
|
|
)
|
|
|
|
|
|
result = TenantOutSchema.model_validate(updated)
|
|
|
return result
|
|
|
|
|
|
@require_superadmin
|
|
|
async def delete(self, ids: list[int]) -> None:
|
|
|
"""
|
|
|
批量删除租户(含级联资源检查:用户/部门/角色/岗位)
|
|
|
|
|
|
参数:
|
|
|
- ids (list[int]): 租户ID列表
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
if not ids:
|
|
|
raise CustomException(msg="删除失败,删除对象不能为空")
|
|
|
if 1 in ids:
|
|
|
raise CustomException(msg="系统租户不允许删除")
|
|
|
from app.api.v1.module_system.dept.crud import DeptCRUD
|
|
|
from app.api.v1.module_system.position.crud import PositionCRUD
|
|
|
from app.api.v1.module_system.role.crud import RoleCRUD
|
|
|
from app.api.v1.module_system.user.crud import UserCRUD
|
|
|
|
|
|
for tid in ids:
|
|
|
reasons: list[str] = []
|
|
|
if await UserCRUD(self.auth).get_list(search={"tenant_id": tid}):
|
|
|
reasons.append("用户")
|
|
|
if await DeptCRUD(self.auth).get_list(search={"tenant_id": tid}):
|
|
|
reasons.append("部门")
|
|
|
if await RoleCRUD(self.auth).get_list(search={"tenant_id": tid}):
|
|
|
reasons.append("角色")
|
|
|
if await PositionCRUD(self.auth).get_list(search={"tenant_id": tid}):
|
|
|
reasons.append("岗位")
|
|
|
if reasons:
|
|
|
raise CustomException(msg=f"租户下已存在{'/'.join(reasons)},操作失败")
|
|
|
|
|
|
await TenantCRUD(self.auth).delete(ids=ids)
|
|
|
|
|
|
async def set_available(self, data: BatchSetAvailable) -> None:
|
|
|
"""
|
|
|
批量设置租户状态
|
|
|
|
|
|
参数:
|
|
|
- data (BatchSetAvailable): 批量状态设置
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
if data.status == 1 and 1 in data.ids:
|
|
|
raise CustomException(msg="系统租户不允许禁用")
|
|
|
await TenantCRUD(self.auth).set(ids=data.ids, status=data.status)
|
|
|
|
|
|
async def toggle_status(self, id: int) -> None:
|
|
|
"""
|
|
|
切换单个租户的启用/禁用状态
|
|
|
|
|
|
参数:
|
|
|
- id (int): 租户ID
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
obj = await TenantCRUD(self.auth).get_or_404(id=id)
|
|
|
if id == 1:
|
|
|
raise CustomException(msg="系统租户不允许禁用")
|
|
|
new_status = 0 if obj.status == 1 else 1
|
|
|
await TenantCRUD(self.auth).set(ids=[id], status=new_status)
|
|
|
|
|
|
async def get_tenant_users(self, tenant_id: int) -> list[TenantUserOutSchema]:
|
|
|
"""获取租户下的用户列表"""
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from app.api.v1.module_system.user.model import UserModel
|
|
|
|
|
|
stmt = (
|
|
|
select(TenantUserModel, UserModel)
|
|
|
.join(UserModel, UserModel.id == TenantUserModel.user_id)
|
|
|
.where(TenantUserModel.tenant_id == tenant_id)
|
|
|
.order_by(TenantUserModel.is_default.desc(), TenantUserModel.id)
|
|
|
)
|
|
|
result = await self.auth.db.execute(stmt)
|
|
|
rows = result.all()
|
|
|
|
|
|
users = []
|
|
|
for tu, u in rows:
|
|
|
users.append(
|
|
|
TenantUserOutSchema(
|
|
|
id=tu.id,
|
|
|
user_id=tu.user_id,
|
|
|
tenant_id=tu.tenant_id,
|
|
|
role=tu.role,
|
|
|
is_default=tu.is_default,
|
|
|
create_time=tu.create_time,
|
|
|
username=u.username,
|
|
|
name=u.name,
|
|
|
)
|
|
|
)
|
|
|
return users
|
|
|
|
|
|
async def add_tenant_user(self, tenant_id: int, data: TenantUserAddSchema) -> None:
|
|
|
"""
|
|
|
向租户添加用户
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
- data (TenantUserAddSchema): 用户添加参数
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
# 验证租户存在
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
# 验证用户存在
|
|
|
from app.api.v1.module_system.user.crud import UserCRUD
|
|
|
|
|
|
user = await UserCRUD(self.auth).get(id=data.user_id)
|
|
|
if not user:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
# 检查是否已关联
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
exist_stmt = (
|
|
|
select(TenantUserModel)
|
|
|
.where(
|
|
|
TenantUserModel.user_id == data.user_id,
|
|
|
TenantUserModel.tenant_id == tenant_id,
|
|
|
)
|
|
|
.limit(1)
|
|
|
)
|
|
|
result = await self.auth.db.execute(exist_stmt)
|
|
|
if result.scalar_one_or_none():
|
|
|
raise CustomException(msg="该用户已关联此租户")
|
|
|
|
|
|
# 如果设为默认租户,先取消其他默认
|
|
|
if data.is_default == 1:
|
|
|
await self.auth.db.execute(
|
|
|
sa.update(TenantUserModel).where(TenantUserModel.user_id == data.user_id).values(is_default=0)
|
|
|
)
|
|
|
elif data.is_default == 0:
|
|
|
# 检查是否是该用户的第一个租户关联
|
|
|
count_result = await self.auth.db.execute(
|
|
|
select(sa.func.count()).select_from(TenantUserModel).where(TenantUserModel.user_id == data.user_id)
|
|
|
)
|
|
|
count = count_result.scalar()
|
|
|
if count == 0:
|
|
|
# 第一个租户自动设为默认
|
|
|
data.is_default = 1
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
tu = TenantUserModel(
|
|
|
user_id=data.user_id,
|
|
|
tenant_id=tenant_id,
|
|
|
role=data.role,
|
|
|
is_default=data.is_default,
|
|
|
create_time=datetime.now(),
|
|
|
)
|
|
|
self.auth.db.add(tu)
|
|
|
await self.auth.db.flush()
|
|
|
|
|
|
logger.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}")
|
|
|
|
|
|
async def remove_tenant_user(self, tenant_id: int, user_id: int) -> None:
|
|
|
"""
|
|
|
从租户移除用户
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
- user_id (int): 用户ID
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
# 查找关联记录
|
|
|
exist_stmt = (
|
|
|
select(TenantUserModel)
|
|
|
.where(
|
|
|
TenantUserModel.user_id == user_id,
|
|
|
TenantUserModel.tenant_id == tenant_id,
|
|
|
)
|
|
|
.limit(1)
|
|
|
)
|
|
|
result = await self.auth.db.execute(exist_stmt)
|
|
|
tu = result.scalar_one_or_none()
|
|
|
if not tu:
|
|
|
raise CustomException(msg="该用户未关联此租户")
|
|
|
|
|
|
# 不允许移除租户最后一个 owner
|
|
|
if tu.role == "owner":
|
|
|
count_result = await self.auth.db.execute(
|
|
|
select(sa.func.count())
|
|
|
.select_from(TenantUserModel)
|
|
|
.where(
|
|
|
TenantUserModel.tenant_id == tenant_id,
|
|
|
TenantUserModel.role == "owner",
|
|
|
)
|
|
|
)
|
|
|
owner_count = count_result.scalar()
|
|
|
if owner_count <= 1:
|
|
|
raise CustomException(msg="租户至少需要保留一个拥有者(owner)")
|
|
|
|
|
|
await self.auth.db.delete(tu)
|
|
|
await self.auth.db.flush()
|
|
|
|
|
|
logger.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功")
|
|
|
|
|
|
async def get_quota(self, tenant_id: int) -> dict:
|
|
|
"""
|
|
|
获取租户配额(从关联套餐读取,系统租户返回无限配额)
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
|
|
|
返回:
|
|
|
- dict: 配额信息
|
|
|
"""
|
|
|
if tenant_id == 1:
|
|
|
return {
|
|
|
"tenant_id": 1,
|
|
|
"max_users": 999999,
|
|
|
"max_roles": 999999,
|
|
|
"max_storage_mb": 999999,
|
|
|
"max_depts": 999999,
|
|
|
"package_name": "系统租户(无限)",
|
|
|
}
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
if not tenant.package_id:
|
|
|
return {
|
|
|
"tenant_id": tenant.id,
|
|
|
"max_users": 0,
|
|
|
"max_roles": 0,
|
|
|
"max_storage_mb": 0,
|
|
|
"max_depts": 0,
|
|
|
"package_name": "未绑定套餐",
|
|
|
}
|
|
|
from app.api.v1.module_platform.package.crud import PackageCRUD
|
|
|
|
|
|
pkg = await PackageCRUD(self.auth).get(id=tenant.package_id)
|
|
|
if not pkg:
|
|
|
return {
|
|
|
"tenant_id": tenant.id,
|
|
|
"max_users": 0,
|
|
|
"max_roles": 0,
|
|
|
"max_storage_mb": 0,
|
|
|
"max_depts": 0,
|
|
|
"package_name": "套餐已删除",
|
|
|
}
|
|
|
return {
|
|
|
"tenant_id": tenant.id,
|
|
|
"max_users": pkg.max_users,
|
|
|
"max_roles": pkg.max_roles,
|
|
|
"max_storage_mb": getattr(pkg, "max_storage_mb", 0),
|
|
|
"max_depts": pkg.max_depts,
|
|
|
"package_name": pkg.name,
|
|
|
}
|
|
|
|
|
|
async def check_quota(self, tenant_id: int, resource_type: str) -> None:
|
|
|
"""检查租户配额是否充足,不足时抛出异常(系统租户跳过检查)"""
|
|
|
if tenant_id == 1:
|
|
|
return
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant or not tenant.package_id:
|
|
|
return
|
|
|
|
|
|
from app.api.v1.module_platform.package.crud import PackageCRUD
|
|
|
|
|
|
pkg = await PackageCRUD(self.auth).get(id=tenant.package_id)
|
|
|
if not pkg:
|
|
|
return
|
|
|
|
|
|
field_map = {
|
|
|
"user": "max_users",
|
|
|
"role": "max_roles",
|
|
|
"dept": "max_depts",
|
|
|
"storage": "max_storage_mb",
|
|
|
}
|
|
|
if resource_type not in field_map:
|
|
|
return
|
|
|
|
|
|
max_field = field_map[resource_type]
|
|
|
max_limit = getattr(pkg, max_field, None)
|
|
|
if max_limit is None or max_limit == 0:
|
|
|
return
|
|
|
|
|
|
if resource_type == "user":
|
|
|
from app.api.v1.module_system.user.model import UserModel
|
|
|
|
|
|
count_stmt = (
|
|
|
select(func.count())
|
|
|
.select_from(UserModel)
|
|
|
.where(
|
|
|
UserModel.tenant_id == tenant_id,
|
|
|
UserModel.is_deleted.is_(False),
|
|
|
)
|
|
|
)
|
|
|
elif resource_type == "role":
|
|
|
from app.api.v1.module_system.role.model import RoleModel
|
|
|
|
|
|
count_stmt = (
|
|
|
select(func.count())
|
|
|
.select_from(RoleModel)
|
|
|
.where(
|
|
|
RoleModel.tenant_id == tenant_id,
|
|
|
RoleModel.is_deleted.is_(False),
|
|
|
)
|
|
|
)
|
|
|
elif resource_type == "dept":
|
|
|
from app.api.v1.module_system.dept.model import DeptModel
|
|
|
|
|
|
count_stmt = (
|
|
|
select(func.count())
|
|
|
.select_from(DeptModel)
|
|
|
.where(
|
|
|
DeptModel.tenant_id == tenant_id,
|
|
|
DeptModel.is_deleted.is_(False),
|
|
|
)
|
|
|
)
|
|
|
elif resource_type == "storage":
|
|
|
# storage 的实际容量校验在文件上传时进行,此处仅检查是否有配额
|
|
|
return
|
|
|
|
|
|
result = await self.auth.db.execute(count_stmt)
|
|
|
current_count = result.scalar() or 0
|
|
|
|
|
|
if current_count >= max_limit:
|
|
|
resource_labels = {"user": "用户", "role": "角色", "dept": "部门"}
|
|
|
raise CustomException(
|
|
|
msg=f"租户{resource_labels.get(resource_type, resource_type)}数量已达套餐上限({max_limit}),无法继续创建"
|
|
|
)
|
|
|
|
|
|
async def get_config(self, tenant_id: int) -> dict:
|
|
|
"""
|
|
|
获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用)
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
|
|
|
返回:
|
|
|
- dict: 配置字典
|
|
|
"""
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
config_fields = [
|
|
|
"name", "description", "version", "logo_url", "favicon",
|
|
|
"login_bg", "copyright", "keep_record", "help_doc", "privacy",
|
|
|
"clause", "git_code",
|
|
|
]
|
|
|
config = {field: getattr(tenant, field, None) for field in config_fields}
|
|
|
return config
|
|
|
|
|
|
async def get_config_items(self, tenant_id: int) -> list[TenantConfigOutSchema]:
|
|
|
"""获取租户所有配置(对外接口,返回结构化列表)"""
|
|
|
config = await self.get_config(tenant_id)
|
|
|
return [TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) for k, v in config.items()]
|
|
|
|
|
|
@staticmethod
|
|
|
async def get_config_cache(redis: Redis, tenant_id: int) -> dict:
|
|
|
"""
|
|
|
从 Redis 缓存获取租户配置,缓存未命中则从 DB 加载并回写缓存
|
|
|
|
|
|
参数:
|
|
|
- redis (Redis): Redis 客户端实例
|
|
|
- tenant_id (int): 租户ID
|
|
|
|
|
|
返回:
|
|
|
- dict: 租户配置字典
|
|
|
"""
|
|
|
redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}"
|
|
|
redis_config = await RedisCURD(redis).get(key=redis_key)
|
|
|
|
|
|
if redis_config:
|
|
|
try:
|
|
|
return json.loads(redis_config)
|
|
|
except Exception as e:
|
|
|
logger.error(f"解析租户配置数据失败: {e}")
|
|
|
|
|
|
logger.info(f"Redis 中没有租户[{tenant_id}]配置数据,从数据库中加载")
|
|
|
from app.core.database import async_db_session
|
|
|
|
|
|
async with async_db_session() as session:
|
|
|
async with session.begin():
|
|
|
from app.core.base_schema import AuthSchema as _AuthSchema
|
|
|
|
|
|
_auth = _AuthSchema(db=session, check_data_scope=False)
|
|
|
svc = TenantService(_auth)
|
|
|
config = await svc.get_config(tenant_id)
|
|
|
await TenantService._sync_configs_to_redis(redis, tenant_id, config)
|
|
|
logger.info("✅ 已从数据库加载租户配置到缓存")
|
|
|
|
|
|
return config
|
|
|
|
|
|
@staticmethod
|
|
|
async def get_config_cache_items(redis: Redis, tenant_id: int) -> list[TenantConfigOutSchema]:
|
|
|
"""获取租户缓存配置(对外接口,返回结构化列表)"""
|
|
|
config = await TenantService.get_config_cache(redis, tenant_id)
|
|
|
return [TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) for k, v in config.items()]
|
|
|
|
|
|
@staticmethod
|
|
|
async def _sync_configs_to_redis(redis: Redis, tenant_id: int, config: dict) -> None:
|
|
|
"""将租户配置写入 Redis 缓存"""
|
|
|
redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}"
|
|
|
value = json.dumps(config, ensure_ascii=False)
|
|
|
await RedisCURD(redis).set(key=redis_key, value=value, expire=None)
|
|
|
|
|
|
@staticmethod
|
|
|
async def _del_configs_from_redis(redis: Redis, tenant_id: int) -> None:
|
|
|
"""删除租户配置的 Redis 缓存"""
|
|
|
redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}"
|
|
|
await RedisCURD(redis).delete(redis_key)
|
|
|
|
|
|
async def update_config(self, redis: Redis, tenant_id: int, config: dict) -> list[TenantConfigOutSchema]:
|
|
|
"""
|
|
|
更新租户配置(同步 Redis 缓存)
|
|
|
|
|
|
参数:
|
|
|
- redis (Redis): Redis 客户端
|
|
|
- tenant_id (int): 租户ID
|
|
|
- config (dict): 配置字典
|
|
|
|
|
|
返回:
|
|
|
- list[TenantConfigOutSchema]: 更新后的配置项列表
|
|
|
"""
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
config_fields = [
|
|
|
"name", "description", "version", "logo_url", "favicon",
|
|
|
"login_bg", "copyright", "keep_record", "help_doc", "privacy",
|
|
|
"clause", "git_code",
|
|
|
]
|
|
|
|
|
|
for field in config_fields:
|
|
|
if field in config:
|
|
|
setattr(tenant, field, config[field])
|
|
|
|
|
|
await self.auth.db.flush()
|
|
|
|
|
|
# 刷新 DB 数据并同步到 Redis
|
|
|
new_config = await self.get_config(tenant_id)
|
|
|
await TenantService._sync_configs_to_redis(redis, tenant_id, new_config)
|
|
|
logger.info(f"租户[{tenant_id}]配置已更新")
|
|
|
return [TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) for k, v in new_config.items()]
|
|
|
|
|
|
@staticmethod
|
|
|
async def init_cache(redis: Redis) -> None:
|
|
|
"""
|
|
|
初始化所有租户配置到 Redis 缓存(应用启动时调用)。
|
|
|
|
|
|
参数:
|
|
|
- redis (Redis): Redis 客户端实例
|
|
|
|
|
|
返回:
|
|
|
- None
|
|
|
"""
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from app.core.database import async_db_session
|
|
|
|
|
|
async with async_db_session() as session:
|
|
|
async with session.begin():
|
|
|
stmt = select(TenantModel)
|
|
|
result = await session.execute(stmt)
|
|
|
tenants = result.scalars().all()
|
|
|
|
|
|
for tenant in tenants:
|
|
|
config_fields = [
|
|
|
"name", "description", "version", "logo_url", "favicon",
|
|
|
"login_bg", "copyright", "keep_record", "help_doc", "privacy",
|
|
|
"clause", "git_code",
|
|
|
]
|
|
|
config = {field: getattr(tenant, field, None) for field in config_fields}
|
|
|
|
|
|
await TenantService._sync_configs_to_redis(redis, tenant.id, config)
|
|
|
logger.info(f"✅ 租户[{tenant.name}](id={tenant.id}) 配置已缓存到 Redis")
|
|
|
|
|
|
async def renew(self, tenant_id: int, end_time: str) -> TenantOutSchema:
|
|
|
"""租户续期:延长 end_time 并恢复为 active 状态
|
|
|
|
|
|
仅 active(0)/grace(1)/suspended(2) 状态可续期。
|
|
|
expired(4)/frozen(3)/archived(5) 不可续期。
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
- end_time (str): 新的结束时间
|
|
|
|
|
|
返回:
|
|
|
- dict: 更新后的租户信息
|
|
|
"""
|
|
|
from datetime import datetime
|
|
|
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
if tenant.status not in (0, 1, 2):
|
|
|
status_labels = {0: "正常", 1: "宽限期", 2: "暂停", 3: "冻结", 4: "过期", 5: "归档"}
|
|
|
current_label = status_labels.get(str(tenant.status), str(tenant.status))
|
|
|
raise CustomException(msg=f"当前租户状态为「{current_label}」,仅正常/宽限期/暂停状态可续期")
|
|
|
|
|
|
new_end = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
|
|
|
if new_end <= datetime.now():
|
|
|
raise CustomException(msg="续期结束时间必须晚于当前时间")
|
|
|
|
|
|
tenant.end_time = new_end
|
|
|
tenant.status = 0
|
|
|
tenant.grace_start_time = None
|
|
|
|
|
|
await self.auth.db.flush()
|
|
|
logger.info(f"租户[{tenant.name}]续期成功, 新的结束时间: {end_time}")
|
|
|
|
|
|
return TenantOutSchema.model_validate(tenant)
|
|
|
|
|
|
async def package_change_preview(self, tenant_id: int, new_package_id: int) -> PackageChangePreviewOut:
|
|
|
"""
|
|
|
套餐变更影响预览
|
|
|
|
|
|
返回受影响角色、菜单清单、配额对比等,供超管确认后再执行变更。
|
|
|
|
|
|
参数:
|
|
|
- tenant_id (int): 租户ID
|
|
|
- new_package_id (int): 目标套餐ID
|
|
|
|
|
|
返回:
|
|
|
- PackageChangePreviewOut: 预览结果
|
|
|
"""
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
from app.api.v1.module_platform.menu.model import MenuModel
|
|
|
from app.api.v1.module_platform.package.crud import PackageCRUD
|
|
|
from app.api.v1.module_platform.package.service import PackageService
|
|
|
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
|
|
from app.api.v1.module_system.user.model import UserModel
|
|
|
|
|
|
tenant = await TenantCRUD(self.auth).get(id=tenant_id)
|
|
|
if not tenant:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
new_package = await PackageCRUD(self.auth).get(id=new_package_id)
|
|
|
if not new_package:
|
|
|
raise CustomException(msg="该数据不存在")
|
|
|
|
|
|
# 当前可用菜单
|
|
|
current_menu_ids = await PackageService(self.auth).get_tenant_available_menu_ids(tenant_id)
|
|
|
|
|
|
# 新套餐可用菜单(直接取套餐菜单,不再包含自定义授权)
|
|
|
new_menu_ids = set(await PackageService(self.auth).get_package_menu_ids(new_package_id))
|
|
|
final_menu_ids = new_menu_ids # 不再合并租户自定义菜单
|
|
|
|
|
|
# 差异计算
|
|
|
removed_ids = current_menu_ids - final_menu_ids
|
|
|
added_ids = final_menu_ids - current_menu_ids
|
|
|
|
|
|
removed_menus = []
|
|
|
added_menus = []
|
|
|
if removed_ids:
|
|
|
menu_stmt = select(MenuModel).where(MenuModel.id.in_(removed_ids))
|
|
|
menu_result = await self.auth.db.execute(menu_stmt)
|
|
|
removed_menus = [{"id": m.id, "name": m.name, "route_path": m.route_path} for m in menu_result.scalars().all()]
|
|
|
if added_ids:
|
|
|
menu_stmt = select(MenuModel).where(MenuModel.id.in_(added_ids))
|
|
|
menu_result = await self.auth.db.execute(menu_stmt)
|
|
|
added_menus = [{"id": m.id, "name": m.name, "route_path": m.route_path} for m in menu_result.scalars().all()]
|
|
|
|
|
|
# 受影响角色
|
|
|
role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id)
|
|
|
role_result = await self.auth.db.execute(role_stmt)
|
|
|
roles = role_result.scalars().all()
|
|
|
|
|
|
affected_roles = []
|
|
|
total_affected_users = 0
|
|
|
for role in roles:
|
|
|
# 查该角色下有多少菜单会被移除
|
|
|
role_menu_stmt = select(RoleMenusModel.menu_id).where(RoleMenusModel.role_id == role.id)
|
|
|
rm_result = await self.auth.db.execute(role_menu_stmt)
|
|
|
role_menu_ids = {row[0] for row in rm_result.all()}
|
|
|
affected_menu_count = len(role_menu_ids & removed_ids)
|
|
|
|
|
|
# 查该角色下用户数
|
|
|
user_count_stmt = select(func.count()).select_from(UserModel).join(UserModel.roles).where(RoleModel.id == role.id)
|
|
|
uc_result = await self.auth.db.execute(user_count_stmt)
|
|
|
user_count = uc_result.scalar() or 0
|
|
|
|
|
|
affected_roles.append(
|
|
|
{
|
|
|
"id": role.id,
|
|
|
"name": role.name,
|
|
|
"code": role.code,
|
|
|
"affected_menu_count": affected_menu_count,
|
|
|
"user_count": user_count,
|
|
|
}
|
|
|
)
|
|
|
total_affected_users += user_count
|
|
|
|
|
|
# 配额对比(从套餐读取)
|
|
|
old_pkg = None
|
|
|
if tenant.package_id:
|
|
|
old_pkg = await PackageCRUD(self.auth).get(id=tenant.package_id)
|
|
|
quota_changes = {
|
|
|
"max_users": {
|
|
|
"current": old_pkg.max_users if old_pkg else 0,
|
|
|
"new": new_package.max_users,
|
|
|
},
|
|
|
"max_roles": {
|
|
|
"current": old_pkg.max_roles if old_pkg else 0,
|
|
|
"new": new_package.max_roles,
|
|
|
},
|
|
|
"max_depts": {
|
|
|
"current": old_pkg.max_depts if old_pkg else 0,
|
|
|
"new": new_package.max_depts,
|
|
|
},
|
|
|
}
|
|
|
|
|
|
return PackageChangePreviewOut(
|
|
|
new_package_id=new_package.id,
|
|
|
new_package_name=new_package.name,
|
|
|
affected_roles=affected_roles,
|
|
|
removed_menus=removed_menus,
|
|
|
added_menus=added_menus,
|
|
|
quota_changes=quota_changes,
|
|
|
total_affected_users=total_affected_users,
|
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
|
async def check_tenant_expiry() -> None:
|
|
|
"""定时任务:多阶段租户到期自动处理
|
|
|
|
|
|
PRD §9 到期阶段:
|
|
|
grace(1) → 到期后第 1-7 天,仅提醒
|
|
|
suspended(2) → 到期后第 8-14 天,禁用登录
|
|
|
frozen(3) → 到期后第 15-30 天,只读模式
|
|
|
expired(4) → 第 31 天起,归档候选
|
|
|
"""
|
|
|
from datetime import datetime
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from app.core.database import async_db_session
|
|
|
|
|
|
now = datetime.now()
|
|
|
|
|
|
async with async_db_session() as session:
|
|
|
# 获取所有已过期的活跃租户(status=0)
|
|
|
rows = await session.execute(
|
|
|
text(
|
|
|
"SELECT id, name, contact_email, contact_name, end_time, status "
|
|
|
"FROM platform_tenant WHERE status = '0' AND end_time IS NOT NULL AND end_time < :now"
|
|
|
),
|
|
|
{"now": now},
|
|
|
)
|
|
|
expired_tenants = rows.fetchall()
|
|
|
|
|
|
for t in expired_tenants:
|
|
|
tenant_id, tenant_name, email, contact_name, end_time, cur_status = t
|
|
|
days_past = (now - end_time).days if end_time else 0
|
|
|
|
|
|
if days_past <= 7:
|
|
|
new_status, label = 1, "宽限期"
|
|
|
elif days_past <= 14:
|
|
|
new_status, label = 2, "已停用"
|
|
|
elif days_past <= 30:
|
|
|
new_status, label = 3, "已冻结"
|
|
|
else:
|
|
|
new_status, label = 4, "已过期"
|
|
|
|
|
|
if new_status == cur_status:
|
|
|
continue
|
|
|
|
|
|
await session.execute(
|
|
|
text("UPDATE platform_tenant SET status = :s WHERE id = :tid"),
|
|
|
{"s": new_status, "tid": tenant_id},
|
|
|
)
|
|
|
logger.info(f"租户状态切换: id={tenant_id} name={tenant_name} status={cur_status}→{new_status} ({label})")
|
|
|
|
|
|
# 发送通知邮件
|
|
|
if email:
|
|
|
await TenantService._send_expiry_email(
|
|
|
tenant_name, contact_name, email, end_time, days_past, label
|
|
|
)
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
logger.info(f"到期检查完成,处理了 {len(expired_tenants)} 个过期租户")
|
|
|
|
|
|
@staticmethod
|
|
|
async def send_grace_reminders() -> None:
|
|
|
"""定时任务:向宽限期租户发送续费提醒邮件(每天 09:00)"""
|
|
|
from datetime import datetime
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from app.core.database import async_db_session
|
|
|
|
|
|
async with async_db_session() as session:
|
|
|
rows = await session.execute(
|
|
|
text(
|
|
|
"SELECT id, name, contact_email, contact_name, end_time FROM platform_tenant "
|
|
|
"WHERE status = '1' AND contact_email IS NOT NULL AND contact_email != ''"
|
|
|
)
|
|
|
)
|
|
|
grace_tenants = rows.fetchall()
|
|
|
|
|
|
sent = 0
|
|
|
for t in grace_tenants:
|
|
|
tenant_id, name, email, contact_name, end_time = t
|
|
|
days_past = (datetime.now() - end_time).days if end_time else 0
|
|
|
|
|
|
try:
|
|
|
ok = await TenantService._send_renew_email(name, contact_name, email, days_past)
|
|
|
if ok:
|
|
|
sent += 1
|
|
|
except Exception as e:
|
|
|
logger.warning(f"续费提醒邮件发送失败: tenant_id={tenant_id}, err={e}")
|
|
|
|
|
|
logger.info(f"续费提醒完成,共发送 {sent}/{len(grace_tenants)} 封邮件")
|
|
|
|
|
|
@staticmethod
|
|
|
async def clean_expired_tenants() -> None:
|
|
|
"""定时任务:将过期超 90 天租户归档,清理旧审计日志(每月 1 号 02:00)"""
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
from app.core.database import async_db_session
|
|
|
|
|
|
cutoff = datetime.now() - timedelta(days=90)
|
|
|
|
|
|
async with async_db_session() as session:
|
|
|
# 归档过期租户
|
|
|
result = await session.execute(
|
|
|
text("SELECT COUNT(*) FROM platform_tenant WHERE status = '4' AND end_time < :cutoff"),
|
|
|
{"cutoff": cutoff},
|
|
|
)
|
|
|
count = result.scalar() or 0
|
|
|
if count > 0:
|
|
|
await session.execute(
|
|
|
text("UPDATE platform_tenant SET status = '5' WHERE status = '4' AND end_time < :cutoff"),
|
|
|
{"cutoff": cutoff},
|
|
|
)
|
|
|
await session.commit()
|
|
|
logger.info(f"已将 {count} 个过期超过 90 天的租户标记为归档")
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
@staticmethod
|
|
|
async def _send_expiry_email(
|
|
|
tenant_name: str,
|
|
|
contact_name: str | None,
|
|
|
email: str,
|
|
|
end_time: object,
|
|
|
days_past: int,
|
|
|
label: str,
|
|
|
) -> None:
|
|
|
"""发送租户状态变更通知邮件"""
|
|
|
from app.config.setting import settings
|
|
|
from app.utils.email_util import render_template, send_email
|
|
|
|
|
|
if not settings.SMTP_HOST:
|
|
|
return
|
|
|
|
|
|
smtp_config = {
|
|
|
"host": settings.SMTP_HOST,
|
|
|
"port": settings.SMTP_PORT,
|
|
|
"username": settings.SMTP_USER,
|
|
|
"password": settings.SMTP_PASSWORD,
|
|
|
"from_addr": settings.SMTP_FROM,
|
|
|
"starttls": settings.SMTP_TLS,
|
|
|
"ssl_tls": settings.SMTP_SSL,
|
|
|
}
|
|
|
end_str = end_time.strftime("%Y-%m-%d") if hasattr(end_time, "strftime") else str(end_time)
|
|
|
|
|
|
try:
|
|
|
html_body = await render_template(
|
|
|
"email/tenant_expiry_notice.html",
|
|
|
tenant_name=tenant_name,
|
|
|
contact_name=contact_name or "管理员",
|
|
|
days_expired=days_past,
|
|
|
status_name=label,
|
|
|
end_time=end_str,
|
|
|
site_url=getattr(settings, "SITE_URL", ""),
|
|
|
)
|
|
|
await send_email(
|
|
|
smtp_config=smtp_config,
|
|
|
recipients=[email],
|
|
|
subject=f"【重要】租户 {tenant_name} 服务状态变更通知",
|
|
|
body=html_body,
|
|
|
html=True,
|
|
|
)
|
|
|
except Exception as e:
|
|
|
logger.warning(f"到期通知邮件发送失败: tenant={tenant_name}, err={e}")
|
|
|
|
|
|
@staticmethod
|
|
|
async def _send_renew_email(name: str, contact_name: str | None, email: str, days_past: int) -> bool:
|
|
|
"""发送续费提醒邮件"""
|
|
|
from app.config.setting import settings
|
|
|
from app.utils.email_util import render_template, send_email
|
|
|
|
|
|
if not settings.SMTP_HOST:
|
|
|
return False
|
|
|
|
|
|
smtp_config = {
|
|
|
"host": settings.SMTP_HOST,
|
|
|
"port": settings.SMTP_PORT,
|
|
|
"username": settings.SMTP_USER,
|
|
|
"password": settings.SMTP_PASSWORD,
|
|
|
"from_addr": settings.SMTP_FROM,
|
|
|
"starttls": settings.SMTP_TLS,
|
|
|
"ssl_tls": settings.SMTP_SSL,
|
|
|
}
|
|
|
|
|
|
html_body = await render_template(
|
|
|
"email/tenant_renew_reminder.html",
|
|
|
tenant_name=name,
|
|
|
contact_name=contact_name or "管理员",
|
|
|
days_expired=days_past,
|
|
|
renew_url=f"{getattr(settings, 'SITE_URL', '')}/tenant/order/create",
|
|
|
)
|
|
|
return await send_email(
|
|
|
smtp_config=smtp_config,
|
|
|
recipients=[email],
|
|
|
subject=f"【续费提醒】租户 {name} 服务即将到期",
|
|
|
body=html_body,
|
|
|
html=True,
|
|
|
)
|