mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 12:52:26 +00:00
本次提交包含多项优化: 1. 移除大量冗余的文件头注释与过时的from __future__导入 2. 将CRUD的list方法统一重命名为get_list保持接口一致 3. 修复前后端状态字段类型不匹配问题,将string类型status改为number 4. 修正前端文案错别字,将"代办事项"修正为标准写法 5. 更新sqlalchemy版本并调整依赖配置 6. 新增缓存工具类替代fastapi-cache2,重构缓存调用逻辑 7. 新增开源授权函生成相关工具与数据库字段支持 8. 为多个业务模块添加防重复提交loading状态 9. 修复邮件模型的外键关联缺失问题 10. 优化pdf生成工具的导入时机与文档注释
1021 lines
38 KiB
Python
1021 lines
38 KiB
Python
|
||
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,
|
||
)
|