You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1021 lines
38 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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,
)