mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 12:52:26 +00:00
本次提交包含多项改进: 1. 国际化补充:新增通知空状态文案中英文支持 2. 启动优化:重构banner输出、移除环境参数依赖,简化启动日志 3. 配置清理:移除冗余的REDIS_ENABLE/SQL_DB_ENABLE配置项,同步更新env示例与测试配置 4. 接口分页改造:将全局分页参数从Query改为Depends自动解析,统一排序逻辑 5. 菜单权限优化:调整菜单可见范围默认值、修复平台菜单路由路径错误 6. 异常处理增强:完善数据库异常捕获逻辑,新增连接失败专项处理 7. API令牌重构:重命名API令牌路由与权限标识,拆分前端API文件 8. 定时任务整合:将系统任务注册移入调度器初始化逻辑 9. 数据库连接优化:新增连接检查,简化建表/删表逻辑 10. 控制台美化:重构启动控制台面板,优化信息展示格式 11. 租户/角色服务优化:移除不必要的超级管理员装饰器,统一分页排序逻辑 12. 用户菜单适配:修复超级用户菜单过滤逻辑,移除scope强制校验
1283 lines
49 KiB
Python
1283 lines
49 KiB
Python
import json
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
from redis.asyncio.client import Redis
|
||
from sqlalchemy import delete, func, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.v1.module_platform.menu.model import MenuModel
|
||
from app.api.v1.module_platform.order.crud import OrderCRUD
|
||
from app.api.v1.module_platform.order.model import OrderModel
|
||
from app.api.v1.module_platform.order.schema import (
|
||
OrderCreateInternalSchema,
|
||
OrderUpdateInternalSchema,
|
||
)
|
||
from app.api.v1.module_platform.package.crud import PackageCRUD
|
||
from app.api.v1.module_platform.package.model import PackageModel
|
||
from app.api.v1.module_platform.package.service import PackageService
|
||
from app.api.v1.module_system.dept.crud import DeptCRUD
|
||
from app.api.v1.module_system.dept.model import DeptModel
|
||
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.role.model import RoleMenusModel, RoleModel
|
||
from app.api.v1.module_system.role.schema import RoleCreateSchema
|
||
from app.api.v1.module_system.user.crud import UserCRUD
|
||
from app.api.v1.module_system.user.model import UserModel
|
||
from app.api.v1.module_system.user.schema import UserCreateSchema
|
||
from app.common.enums import OrderTypeEnum, RedisInitKeyConfig
|
||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||
from app.core.database import async_db_session
|
||
from app.core.exceptions import CustomException
|
||
from app.core.logger import logger
|
||
from app.core.redis_crud import RedisCURD
|
||
from app.utils.common_util import search_to_dict
|
||
from app.utils.password_util import PwdUtil
|
||
|
||
from .crud import TenantCRUD
|
||
from .model import TenantModel, TenantUserModel
|
||
from .schema import (
|
||
PackageAction,
|
||
PackageAvailableItem,
|
||
PackageAvailableOut,
|
||
PackageChangePreviewOut,
|
||
PackagePreviewOut,
|
||
SelfOrderCreate,
|
||
SelfOrderDetailOut,
|
||
SelfOrderListItem,
|
||
SelfOrderListOut,
|
||
SelfOrderOut,
|
||
TenantAdminInfo,
|
||
TenantConfigOutSchema,
|
||
TenantCreateResult,
|
||
TenantCreateSchema,
|
||
TenantOutSchema,
|
||
TenantQueryParam,
|
||
TenantUpdateSchema,
|
||
TenantUserAddSchema,
|
||
TenantUserOutSchema,
|
||
WorkspaceOrderItem,
|
||
WorkspaceOut,
|
||
WorkspacePackageInfo,
|
||
WorkspaceQuotaInfo,
|
||
WorkspaceTenantInfo,
|
||
WorkspaceUsagePercent,
|
||
)
|
||
|
||
|
||
class TenantService:
|
||
"""租户管理服务(查询操作租户可见,写操作仅超级管理员可操作)
|
||
|
||
设计:实例方法承载「当前用户上下文 (auth)」,``redis`` 仍是方法参数。
|
||
内部跨方法调用从 ``cls.xxx(auth, ...)`` 改为 ``self.xxx(...)``。
|
||
定时任务方法与静态工具方法保持 ``@staticmethod``(无 auth)。
|
||
"""
|
||
|
||
CONFIG_FIELDS = [
|
||
"name",
|
||
"description",
|
||
"version",
|
||
"logo_url",
|
||
"favicon",
|
||
"login_bg",
|
||
"copyright",
|
||
"keep_record",
|
||
"help_doc",
|
||
"privacy",
|
||
"clause",
|
||
"git_code",
|
||
]
|
||
|
||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||
self.auth = auth
|
||
self.db = db
|
||
|
||
async def detail(self, id: int) -> TenantOutSchema:
|
||
"""租户详情
|
||
|
||
参数:
|
||
- id (int): 租户ID
|
||
|
||
返回:
|
||
- TenantOutSchema: 租户详情
|
||
"""
|
||
obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id)
|
||
return TenantOutSchema.model_validate(obj)
|
||
|
||
async def page(
|
||
self,
|
||
page_no: int,
|
||
page_size: int,
|
||
search: TenantQueryParam | None = None,
|
||
order_by: list[dict[str, str]] | None = None,
|
||
) -> PageResultSchema[TenantOutSchema]:
|
||
return await TenantCRUD(self.auth, self.db).page(
|
||
offset=(page_no - 1) * page_size,
|
||
limit=page_size,
|
||
order_by=order_by or [{"id": "asc"}],
|
||
search=search_to_dict(search),
|
||
out_schema=TenantOutSchema,
|
||
)
|
||
|
||
async def create(self, data: TenantCreateSchema) -> TenantCreateResult:
|
||
# ① 预校验:name / code 唯一
|
||
if await TenantCRUD(self.auth, self.db).get(name=data.name):
|
||
raise CustomException(msg="创建失败,名称已存在")
|
||
if await TenantCRUD(self.auth, self.db).get(code=data.code):
|
||
raise CustomException(msg="创建失败,编码已存在")
|
||
|
||
# ② 校验套餐:必须存在且启用
|
||
|
||
package = await PackageCRUD(self.auth, self.db).get(id=data.package_id)
|
||
if not package:
|
||
raise CustomException(msg=f"套餐[{data.package_id}]不存在")
|
||
if package.status != 0:
|
||
raise CustomException(msg=f"套餐[{package.name}]已停用,无法注册租户")
|
||
|
||
# ③ 生成初始管理员账号(用户名 = code_admin),随机密码仅此一次返回
|
||
username = f"{data.code}_admin"
|
||
|
||
if await UserCRUD(self.auth, self.db).get(username=username):
|
||
raise CustomException(msg=f"初始管理员用户名已存在: {username},请更换租户编码后重试")
|
||
|
||
password = PwdUtil.generate_strong_password(length=12)
|
||
|
||
# ④ 创建租户(事务起点)
|
||
tenant_obj = await TenantCRUD(self.auth, self.db).create(data=data)
|
||
if not tenant_obj:
|
||
raise CustomException(msg="创建租户失败")
|
||
|
||
# ⑤ 创建「管理员」角色(tenant_id 已建立,code 用 f"{tenant_code}_admin" 避免与编码冲突)
|
||
|
||
admin_role = RoleCreateSchema(
|
||
name=f"{tenant_obj.name}管理员",
|
||
code=f"{tenant_obj.code}_admin",
|
||
order=1,
|
||
data_scope=4, # 全部数据权限
|
||
status=0,
|
||
description="租户初始管理员角色(由系统开通时自动创建)",
|
||
)
|
||
role_obj = await RoleCRUD(self.auth, self.db).create(data=admin_role)
|
||
if not role_obj:
|
||
raise CustomException(msg="创建租户管理员角色失败")
|
||
# 强制同步 tenant_id(CRUD.create 不会从 auth 写入新模型,避免被默认 0 覆盖)
|
||
role_obj.tenant_id = tenant_obj.id
|
||
await self.db.flush()
|
||
|
||
# ⑥ 创建初始管理员用户,并关联管理员角色
|
||
admin_user = UserCreateSchema(
|
||
username=username,
|
||
password=PwdUtil.hash_password(password=password),
|
||
name=f"{tenant_obj.name}管理员",
|
||
tenant_id=tenant_obj.id,
|
||
status=0,
|
||
is_superuser=False,
|
||
role_ids=[role_obj.id],
|
||
)
|
||
try:
|
||
user_obj = await UserCRUD(self.auth, self.db).create(data=admin_user)
|
||
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
|
||
|
||
# ⑦ 把套餐所有菜单授权给管理员角色(快照模式:复制 ID,不维护引用)
|
||
|
||
menu_ids = await PackageService(self.auth, self.db).get_package_menu_ids(data.package_id)
|
||
if menu_ids:
|
||
await RoleCRUD(self.auth, self.db).set_role_menus_crud(
|
||
role_ids=[role_obj.id],
|
||
menu_ids=menu_ids,
|
||
)
|
||
|
||
# ⑧ 缓存刷新(失败不阻塞:DB 已是真相,后续 _sync_all_configs_to_redis 会补偿)
|
||
try:
|
||
await self.db.commit()
|
||
logger.info(
|
||
f"✅ 租户[{tenant_obj.name}]开通完成 "
|
||
f"(套餐={package.name}, 菜单授权={len(menu_ids)}, 管理员={username})"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"租户[{tenant_obj.name}]缓存刷新失败(事务已提交,可后续补偿): {e!s}")
|
||
|
||
await self.db.refresh(tenant_obj)
|
||
|
||
return TenantCreateResult(
|
||
tenant=TenantOutSchema.model_validate(tenant_obj),
|
||
admin=TenantAdminInfo(
|
||
username=username,
|
||
initial_password=password,
|
||
must_change_password=True,
|
||
),
|
||
)
|
||
|
||
async def update(self, id: int, data: TenantUpdateSchema) -> TenantOutSchema:
|
||
"""更新租户
|
||
|
||
参数:
|
||
- id (int): 租户ID
|
||
- data (TenantUpdateSchema): 租户更新模型
|
||
|
||
返回:
|
||
- TenantOutSchema: 租户详情
|
||
"""
|
||
obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id)
|
||
old_package_id = obj.package_id
|
||
|
||
await self._validate_tenant_update(id, obj, data)
|
||
|
||
updated = await TenantCRUD(self.auth, self.db).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:
|
||
await self._handle_package_change(id, old_package_id, data.package_id)
|
||
|
||
return TenantOutSchema.model_validate(updated)
|
||
|
||
async def _validate_tenant_update(self, id: int, obj: TenantModel, data: TenantUpdateSchema) -> None:
|
||
"""校验租户更新约束"""
|
||
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 != obj.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, self.db).get(name=data.name)
|
||
if exist and exist.id != id:
|
||
raise CustomException(msg="更新失败,名称重复")
|
||
if data.code is not None:
|
||
exist = await TenantCRUD(self.auth, self.db).get(code=data.code)
|
||
if exist and exist.id != id:
|
||
raise CustomException(msg="更新失败,编码重复")
|
||
|
||
async def _handle_package_change(self, tenant_id: int, old_package_id: int | None, new_package_id: int) -> None:
|
||
"""处理套餐变更:降级预检 + 菜单清理"""
|
||
|
||
new_pkg = await PackageCRUD(self.auth, self.db).get(id=new_package_id)
|
||
if not new_pkg:
|
||
raise CustomException(msg=f"套餐[{new_package_id}]不存在")
|
||
|
||
# 降级前的超额预检:升级和首次绑定跳过;降级时若现有资源超过新套餐上限则拒
|
||
if old_package_id is not None:
|
||
counts_stmt_user = select(func.count(UserModel.id)).where(
|
||
UserModel.tenant_id == tenant_id, UserModel.is_deleted.is_(False)
|
||
)
|
||
counts_stmt_role = select(func.count(RoleModel.id)).where(
|
||
RoleModel.tenant_id == tenant_id, RoleModel.is_deleted.is_(False)
|
||
)
|
||
counts_stmt_dept = select(func.count(DeptModel.id)).where(
|
||
DeptModel.tenant_id == tenant_id, DeptModel.is_deleted.is_(False)
|
||
)
|
||
user_count = (await self.db.execute(counts_stmt_user)).scalar_one()
|
||
role_count = (await self.db.execute(counts_stmt_role)).scalar_one()
|
||
dept_count = (await self.db.execute(counts_stmt_dept)).scalar_one()
|
||
|
||
exceed_msgs = []
|
||
if new_pkg.max_users and user_count > new_pkg.max_users:
|
||
exceed_msgs.append(f"用户({user_count}>{new_pkg.max_users})")
|
||
if new_pkg.max_roles and role_count > new_pkg.max_roles:
|
||
exceed_msgs.append(f"角色({role_count}>{new_pkg.max_roles})")
|
||
if new_pkg.max_depts and dept_count > new_pkg.max_depts:
|
||
exceed_msgs.append(f"部门({dept_count}>{new_pkg.max_depts})")
|
||
if exceed_msgs:
|
||
raise CustomException(
|
||
msg="降级失败:当前资源超过新套餐上限,请先清理或升级套餐: " + " ".join(exceed_msgs),
|
||
)
|
||
|
||
available_ids = await PackageService(self.auth, self.db).get_tenant_available_menu_ids(tenant_id)
|
||
if not available_ids:
|
||
return
|
||
role_ids_stmt = select(RoleModel.id).where(RoleModel.tenant_id == tenant_id)
|
||
result = await self.db.execute(role_ids_stmt)
|
||
tenant_role_ids = [row[0] for row in result.all()]
|
||
if tenant_role_ids:
|
||
await self.db.execute(
|
||
delete(RoleMenusModel).where(
|
||
RoleMenusModel.role_id.in_(tenant_role_ids),
|
||
RoleMenusModel.menu_id.notin_(available_ids),
|
||
),
|
||
)
|
||
await self.db.flush()
|
||
logger.info(f"租户[{tenant_id}]套餐变更:已清理角色中不再可用的菜单关联, available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}")
|
||
|
||
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="系统租户不允许删除")
|
||
|
||
# 批量检查所有租户是否有关联资源(一次查询代替 N*4 次)
|
||
resource_checks = [
|
||
("用户", UserCRUD, "tenant_id"),
|
||
("部门", DeptCRUD, "tenant_id"),
|
||
("角色", RoleCRUD, "tenant_id"),
|
||
("岗位", PositionCRUD, "tenant_id"),
|
||
]
|
||
tid_set = set(ids)
|
||
for name, crud_cls, field in resource_checks:
|
||
existing = await crud_cls(self.auth, self.db).get_list(search={field: ("in", list(tid_set))})
|
||
used_tids = {getattr(obj, field) for obj in existing if getattr(obj, field, None) is not None}
|
||
conflict = tid_set & used_tids
|
||
if conflict:
|
||
raise CustomException(msg=f"租户下已存在{name},操作失败")
|
||
|
||
await TenantCRUD(self.auth, self.db).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, self.db).set(ids=data.ids, status=data.status)
|
||
|
||
async def toggle_status(self, id: int) -> None:
|
||
"""切换单个租户的启用/禁用状态
|
||
|
||
参数:
|
||
- id (int): 租户ID
|
||
|
||
返回:
|
||
- None
|
||
"""
|
||
obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id)
|
||
if id == 1:
|
||
raise CustomException(msg="系统租户不允许禁用")
|
||
new_status = 0 if obj.status == 1 else 1
|
||
await TenantCRUD(self.auth, self.db).set(ids=[id], status=new_status)
|
||
|
||
async def get_tenant_users(self, tenant_id: int) -> list[TenantUserOutSchema]:
|
||
"""获取租户下的用户列表"""
|
||
|
||
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.db.execute(stmt)
|
||
rows = result.all()
|
||
|
||
users = []
|
||
for tenant_user, user_row in rows:
|
||
users.append(
|
||
TenantUserOutSchema(
|
||
id=tenant_user.id,
|
||
user_id=tenant_user.user_id,
|
||
tenant_id=tenant_user.tenant_id,
|
||
role=tenant_user.role,
|
||
is_default=tenant_user.is_default,
|
||
create_time=tenant_user.create_time,
|
||
username=user_row.username,
|
||
name=user_row.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, self.db).get(id=tenant_id)
|
||
if not tenant:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
# 验证用户存在
|
||
|
||
user = await UserCRUD(self.auth, self.db).get(id=data.user_id)
|
||
if not user:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
# 检查是否已关联
|
||
exist_stmt = (
|
||
select(TenantUserModel)
|
||
.where(
|
||
TenantUserModel.user_id == data.user_id,
|
||
TenantUserModel.tenant_id == tenant_id,
|
||
)
|
||
.limit(1)
|
||
)
|
||
result = await self.db.execute(exist_stmt)
|
||
if result.scalar_one_or_none():
|
||
raise CustomException(msg="该用户已关联此租户")
|
||
|
||
# 如果设为默认租户,先取消其他默认
|
||
if data.is_default == 1:
|
||
await self.db.execute(update(TenantUserModel).where(TenantUserModel.user_id == data.user_id).values(is_default=0))
|
||
elif data.is_default == 0:
|
||
# 检查是否是该用户的第一个租户关联
|
||
count_result = await self.db.execute(select(func.count()).select_from(TenantUserModel).where(TenantUserModel.user_id == data.user_id))
|
||
count = count_result.scalar()
|
||
if count == 0:
|
||
# 第一个租户自动设为默认
|
||
data.is_default = 1
|
||
|
||
tu = TenantUserModel(
|
||
user_id=data.user_id,
|
||
tenant_id=tenant_id,
|
||
role=data.role,
|
||
is_default=data.is_default,
|
||
create_time=datetime.now(),
|
||
)
|
||
self.db.add(tu)
|
||
await self.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
|
||
"""
|
||
# 查找关联记录
|
||
exist_stmt = (
|
||
select(TenantUserModel)
|
||
.where(
|
||
TenantUserModel.user_id == user_id,
|
||
TenantUserModel.tenant_id == tenant_id,
|
||
)
|
||
.limit(1)
|
||
)
|
||
result = await self.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.db.execute(
|
||
select(func.count())
|
||
.select_from(TenantUserModel)
|
||
.where(
|
||
TenantUserModel.tenant_id == tenant_id,
|
||
TenantUserModel.role == "owner",
|
||
),
|
||
)
|
||
owner_count = count_result.scalar() or 0
|
||
if owner_count <= 1:
|
||
raise CustomException(msg="租户至少需要保留一个拥有者(owner)")
|
||
|
||
await self.db.delete(tu)
|
||
await self.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, self.db).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": "未绑定套餐",
|
||
}
|
||
|
||
pkg = await PackageCRUD(self.auth, self.db).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_depts": pkg.max_depts,
|
||
"max_storage_mb": getattr(pkg, "max_storage_mb", 0),
|
||
"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, self.db).get(id=tenant_id)
|
||
if not tenant or not tenant.package_id:
|
||
return
|
||
|
||
pkg = await PackageCRUD(self.auth, self.db).get(id=tenant.package_id)
|
||
if not pkg:
|
||
return
|
||
|
||
# resource_type → (model_class, label, max_field)
|
||
resource_map: dict[str, tuple[Any, str, str]] = {}
|
||
|
||
resource_map["user"] = (UserModel, "用户", "max_users")
|
||
resource_map["role"] = (RoleModel, "角色", "max_roles")
|
||
resource_map["dept"] = (DeptModel, "部门", "max_depts")
|
||
|
||
entry = resource_map.get(resource_type)
|
||
if not entry:
|
||
return # storage 和未知类型跳过
|
||
model_cls, label, max_field = entry
|
||
|
||
max_limit = getattr(pkg, max_field, None)
|
||
if max_limit is None or max_limit == 0:
|
||
return
|
||
|
||
count_stmt = (
|
||
select(func.count())
|
||
.select_from(model_cls)
|
||
.where(
|
||
model_cls.tenant_id == tenant_id,
|
||
model_cls.is_deleted.is_(False),
|
||
)
|
||
)
|
||
result = await self.db.execute(count_stmt)
|
||
current_count = result.scalar() or 0
|
||
|
||
if current_count >= max_limit:
|
||
raise CustomException(msg=f"租户{label}数量已达套餐上限({max_limit}),无法继续创建")
|
||
|
||
async def get_config(self, tenant_id: int) -> dict:
|
||
"""获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用)
|
||
|
||
参数:
|
||
- tenant_id (int): 租户ID
|
||
|
||
返回:
|
||
- dict: 配置字典
|
||
"""
|
||
tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id)
|
||
if not tenant:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
config = {field: getattr(tenant, field, None) for field in self.CONFIG_FIELDS}
|
||
return config
|
||
|
||
@staticmethod
|
||
def _config_to_items(config: dict) -> list[TenantConfigOutSchema]:
|
||
"""将配置字典转换为结构化列表"""
|
||
return [TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) for k, v in config.items()]
|
||
|
||
async def get_config_items(self, tenant_id: int) -> list[TenantConfigOutSchema]:
|
||
return TenantService._config_to_items(await self.get_config(tenant_id))
|
||
|
||
@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}]配置数据,从数据库中加载")
|
||
async with async_db_session() as session, session.begin():
|
||
_auth = AuthSchema(check_data_scope=False)
|
||
svc = TenantService(_auth, session)
|
||
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]:
|
||
return TenantService._config_to_items(await TenantService.get_config_cache(redis, tenant_id))
|
||
|
||
@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, self.db).get(id=tenant_id)
|
||
if not tenant:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
for field in self.CONFIG_FIELDS:
|
||
if field in config:
|
||
setattr(tenant, field, config[field])
|
||
|
||
await self.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 TenantService._config_to_items(new_config)
|
||
|
||
@staticmethod
|
||
async def init_cache(redis: Redis) -> None:
|
||
"""初始化所有租户配置到 Redis 缓存(应用启动时调用)。
|
||
|
||
参数:
|
||
- redis (Redis): Redis 客户端实例
|
||
|
||
返回:
|
||
- None
|
||
"""
|
||
try:
|
||
async with async_db_session() as session, session.begin():
|
||
stmt = select(TenantModel)
|
||
result = await session.execute(stmt)
|
||
tenants = result.scalars().all()
|
||
|
||
for tenant in tenants:
|
||
config = {field: getattr(tenant, field, None) for field in TenantService.CONFIG_FIELDS}
|
||
|
||
await TenantService._sync_configs_to_redis(redis, tenant.id, config)
|
||
logger.info(f"✅ 租户[{tenant.name}](id={tenant.id}) 配置已缓存到 Redis")
|
||
except Exception as e:
|
||
logger.error(f"❌️ 初始化租户配置到 Redis 失败: {e}")
|
||
raise CustomException(msg="初始化租户配置到 Redis 失败") from e
|
||
|
||
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: 更新后的租户信息
|
||
"""
|
||
tenant = await TenantCRUD(self.auth, self.db).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(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.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
|
||
|
||
tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id)
|
||
if not tenant:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
new_package = await PackageCRUD(self.auth, self.db).get(id=new_package_id)
|
||
if not new_package:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
# 当前可用菜单
|
||
current_menu_ids = set(await PackageService(self.auth, self.db).get_tenant_available_menu_ids(tenant_id))
|
||
|
||
# 新套餐可用菜单(直接取套餐菜单,不再包含自定义授权)
|
||
new_menu_ids = set(await PackageService(self.auth, self.db).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.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.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.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.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.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, self.db).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 sqlalchemy import text
|
||
|
||
now = datetime.now()
|
||
|
||
async with async_db_session() as session:
|
||
# 获取所有已过期的活跃租户(status=0)
|
||
rows = await session.execute(
|
||
text("SELECT id, 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, 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})")
|
||
|
||
await session.commit()
|
||
|
||
logger.info(f"到期检查完成,处理了 {len(expired_tenants)} 个过期租户")
|
||
|
||
@staticmethod
|
||
async def clean_expired_tenants() -> None:
|
||
"""定时任务:将过期超 90 天租户归档,清理旧审计日志(每月 1 号 02:00)"""
|
||
from datetime import datetime, timedelta
|
||
|
||
from sqlalchemy import text
|
||
|
||
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 天的租户标记为归档")
|
||
|
||
@classmethod
|
||
async def get_available_packages(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int) -> PackageAvailableOut:
|
||
"""获取可选套餐列表
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息模型
|
||
- db (AsyncSession): 数据库会话
|
||
- tenant_id (int): 租户ID
|
||
|
||
返回:
|
||
- PackageAvailableOut: 可选套餐列表
|
||
"""
|
||
tenant = await db.get(TenantModel, tenant_id)
|
||
current_pkg_id = tenant.package_id if tenant else None
|
||
|
||
# 一次性获取当前套餐价格(可能未启用,不在后续结果中)
|
||
current_price: int | None = None
|
||
if current_pkg_id:
|
||
cp = await db.get(PackageModel, current_pkg_id)
|
||
current_price = cp.price if cp else 0
|
||
|
||
stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.price)
|
||
result = await db.execute(stmt)
|
||
packages = result.scalars().all()
|
||
|
||
items: list[PackageAvailableItem] = []
|
||
for pkg in packages:
|
||
is_current = pkg.id == current_pkg_id
|
||
actions: list[PackageAction] = []
|
||
if is_current:
|
||
actions = ["renew"]
|
||
elif current_pkg_id is None:
|
||
actions = ["buy"]
|
||
elif current_price is not None:
|
||
actions = ["upgrade"] if pkg.price > current_price else ["downgrade"]
|
||
|
||
items.append(
|
||
PackageAvailableItem(
|
||
id=pkg.id,
|
||
name=pkg.name,
|
||
price=pkg.price,
|
||
period=pkg.period,
|
||
trial_days=pkg.trial_days,
|
||
max_users=pkg.max_users,
|
||
max_roles=pkg.max_roles,
|
||
max_depts=pkg.max_depts,
|
||
max_storage_mb=pkg.max_storage_mb,
|
||
description=pkg.description,
|
||
is_current=is_current,
|
||
available_actions=actions,
|
||
),
|
||
)
|
||
|
||
return PackageAvailableOut(
|
||
current_package_id=current_pkg_id,
|
||
packages=items,
|
||
)
|
||
|
||
@classmethod
|
||
async def preview_package_change(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int, target_package_id: int) -> PackagePreviewOut:
|
||
"""套餐变更预览(委托给 package_change_preview 并映射输出)"""
|
||
svc = cls(auth, db)
|
||
preview = await svc.package_change_preview(tenant_id, target_package_id)
|
||
|
||
tenant = await db.get(TenantModel, tenant_id)
|
||
target_pkg = await db.get(PackageModel, target_package_id)
|
||
|
||
current_pkg = None
|
||
if tenant and tenant.package_id:
|
||
current_pkg = await db.get(PackageModel, tenant.package_id)
|
||
|
||
# 确定操作类型
|
||
if not tenant or not tenant.package_id:
|
||
action = "buy"
|
||
elif current_pkg and target_pkg and target_pkg.price > current_pkg.price:
|
||
action = "upgrade"
|
||
elif current_pkg and target_pkg and target_pkg.price < current_pkg.price:
|
||
action = "downgrade"
|
||
else:
|
||
action = "renew"
|
||
|
||
return PackagePreviewOut(
|
||
current_package=current_pkg.name if current_pkg else "",
|
||
target_package=target_pkg.name if target_pkg else "",
|
||
action=action,
|
||
amount=target_pkg.price if target_pkg else 0,
|
||
period=target_pkg.period if target_pkg else "",
|
||
gained_menus=preview.added_menus,
|
||
lost_menus=preview.removed_menus,
|
||
affected_roles=[r.get("name", "") for r in preview.affected_roles],
|
||
affected_users=preview.total_affected_users,
|
||
)
|
||
|
||
@classmethod
|
||
async def create_self_order(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int, data: SelfOrderCreate) -> SelfOrderOut:
|
||
"""创建自助订单(套餐购买/续费/升级/降级;免费订单自动激活)
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息模型
|
||
- db (AsyncSession): 数据库会话
|
||
- tenant_id (int): 租户ID
|
||
- data (SelfOrderCreate): 自助订单创建参数
|
||
|
||
返回:
|
||
- SelfOrderOut: 自助订单创建结果
|
||
"""
|
||
tenant = await db.get(TenantModel, tenant_id)
|
||
if not tenant:
|
||
raise CustomException(msg="该数据不存在")
|
||
if tenant.status not in (0, 1, 2):
|
||
raise CustomException(msg="租户状态不允许操作")
|
||
|
||
pkg = await db.get(PackageModel, data.package_id)
|
||
if not pkg or pkg.status == 1:
|
||
raise CustomException(msg="该数据不存在")
|
||
|
||
# 校验 order_type 与当前套餐状态的一致性:
|
||
# - 未购套餐时只能 buy / renew(不能用 upgrade/downgrade)
|
||
# - 已有套餐时禁止 buy(应走 upgrade 等)
|
||
if not tenant.package_id:
|
||
if data.order_type not in ("buy", "renew"):
|
||
raise CustomException(msg="当前租户未购买套餐,只能 buy 或 renew")
|
||
else:
|
||
if data.order_type == "buy":
|
||
raise CustomException(msg="该租户已存在套餐,请使用 upgrade/renew/downgrade")
|
||
|
||
amount = pkg.price
|
||
|
||
from app.api.v1.module_platform.order.service import PaymentService, _generate_order_no
|
||
|
||
order = await OrderCRUD(auth, db).create(
|
||
OrderCreateInternalSchema(
|
||
order_no=_generate_order_no(),
|
||
tenant_id=tenant_id,
|
||
package_id=data.package_id,
|
||
order_type=data.order_type,
|
||
amount=amount,
|
||
expire_time=datetime.now() + timedelta(minutes=15),
|
||
),
|
||
)
|
||
await db.flush()
|
||
|
||
# 免费订单自动激活
|
||
if amount == 0:
|
||
await OrderCRUD(auth, db).update(
|
||
order.id,
|
||
OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()),
|
||
)
|
||
await PaymentService._activate_tenant_package(auth, db, order)
|
||
|
||
logger.info(f"自助订单创建: order_no={order.order_no} tenant={tenant_id} amount={amount}")
|
||
return SelfOrderOut(
|
||
order_id=order.id,
|
||
order_no=order.order_no,
|
||
amount=amount,
|
||
need_pay=amount > 0,
|
||
)
|
||
|
||
@classmethod
|
||
async def get_self_order_list(
|
||
cls,
|
||
auth: AuthSchema,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
page_no: int = 1,
|
||
page_size: int = 20,
|
||
order_by: list[dict] | None = None,
|
||
) -> SelfOrderListOut:
|
||
"""我的订单列表
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息模型
|
||
- db (AsyncSession): 数据库会话
|
||
- tenant_id (int): 租户ID
|
||
- page_no (int): 页码
|
||
- page_size (int): 每页数量
|
||
- order_by (list[dict] | None): 排序参数
|
||
|
||
返回:
|
||
- SelfOrderListOut: 订单分页列表
|
||
"""
|
||
offset = (page_no - 1) * page_size
|
||
page_result = await OrderCRUD(auth, db).page(
|
||
offset=offset,
|
||
limit=page_size,
|
||
order_by=order_by or [{"created_time": "desc"}],
|
||
search={"tenant_id": tenant_id},
|
||
)
|
||
|
||
# 批量查询关联套餐,避免 N+1
|
||
package_ids = [o.package_id for o in page_result.items if hasattr(o, "package_id") and o.package_id]
|
||
pkg_map: dict[int, str] = {}
|
||
if package_ids:
|
||
pkg_result = await db.execute(select(PackageModel.id, PackageModel.name).where(PackageModel.id.in_(package_ids)))
|
||
pkg_map = {row[0]: row[1] for row in pkg_result.all()}
|
||
|
||
items = []
|
||
for o in page_result.items:
|
||
pkg_name = pkg_map.get(o.package_id, "") if hasattr(o, "package_id") and o.package_id else ""
|
||
items.append(
|
||
SelfOrderListItem(
|
||
id=o.id,
|
||
order_no=o.order_no,
|
||
package_name=pkg_name,
|
||
order_type=o.order_type,
|
||
amount=o.amount,
|
||
status=o.status,
|
||
pay_method=o.pay_method,
|
||
pay_time=o.pay_time.isoformat() if o.pay_time else None,
|
||
created_at=o.created_time.isoformat() if o.created_time else None,
|
||
),
|
||
)
|
||
|
||
return SelfOrderListOut(
|
||
items=items,
|
||
total=page_result.total,
|
||
page_no=page_no,
|
||
page_size=page_size,
|
||
)
|
||
|
||
@classmethod
|
||
async def get_self_order_detail(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> SelfOrderDetailOut:
|
||
"""订单详情
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息模型
|
||
- db (AsyncSession): 数据库会话
|
||
- order_id (int): 订单ID
|
||
|
||
返回:
|
||
- SelfOrderDetailOut: 订单详情
|
||
"""
|
||
order = await OrderCRUD(auth, db).get_or_404(id=order_id, msg="该数据不存在")
|
||
|
||
pkg_name = ""
|
||
if order.package_id:
|
||
p = await db.get(PackageModel, order.package_id)
|
||
if p:
|
||
pkg_name = p.name
|
||
|
||
return SelfOrderDetailOut(
|
||
id=order.id,
|
||
order_no=order.order_no,
|
||
package_id=order.package_id,
|
||
package_name=pkg_name,
|
||
amount=order.amount,
|
||
order_type=OrderTypeEnum(order.order_type),
|
||
status=order.status,
|
||
pay_method=order.pay_method,
|
||
pay_time=order.pay_time.isoformat() if order.pay_time else None,
|
||
created_at=order.created_time.isoformat() if order.created_time else None,
|
||
)
|
||
|
||
@classmethod
|
||
async def get_workspace_data(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int) -> WorkspaceOut:
|
||
"""获取租户工作台概览(租户信息、套餐、配额用量、近期订单)
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息模型
|
||
- db (AsyncSession): 数据库会话
|
||
- tenant_id (int): 租户ID
|
||
|
||
返回:
|
||
- WorkspaceOut: 工作台概览数据
|
||
"""
|
||
tenant = await db.get(TenantModel, tenant_id)
|
||
if not tenant:
|
||
return WorkspaceOut(
|
||
tenant=WorkspaceTenantInfo(id=0, name="", code="", status=0, status_label="未知"),
|
||
quota=WorkspaceQuotaInfo(),
|
||
)
|
||
|
||
package = None
|
||
if tenant.package_id:
|
||
package = await db.get(PackageModel, tenant.package_id)
|
||
|
||
async def _count(model_cls) -> int:
|
||
stmt = (
|
||
select(func.count())
|
||
.select_from(model_cls)
|
||
.where(
|
||
model_cls.tenant_id == tenant_id,
|
||
model_cls.is_deleted.is_(False),
|
||
)
|
||
)
|
||
return (await db.execute(stmt)).scalar() or 0
|
||
|
||
user_count = await _count(UserModel)
|
||
role_count = await _count(RoleModel)
|
||
dept_count = await _count(DeptModel)
|
||
|
||
now = datetime.now()
|
||
days_remaining = (tenant.end_time - now).days if tenant.end_time else 0
|
||
|
||
status_labels = {
|
||
"0": "正常",
|
||
"1": "宽限期",
|
||
"2": "已暂停",
|
||
"3": "已冻结",
|
||
"4": "已过期",
|
||
"5": "已归档",
|
||
}
|
||
|
||
orders_stmt = select(OrderModel).where(OrderModel.tenant_id == tenant_id).order_by(OrderModel.created_time.desc()).limit(5)
|
||
orders_result = await db.execute(orders_stmt)
|
||
recent_orders = []
|
||
for o in orders_result.scalars().all():
|
||
recent_orders.append(
|
||
WorkspaceOrderItem(
|
||
id=o.id,
|
||
order_no=o.order_no,
|
||
amount=o.amount,
|
||
order_type=OrderTypeEnum(o.order_type),
|
||
status=o.status,
|
||
created_at=o.created_time.isoformat() if o.created_time else None,
|
||
),
|
||
)
|
||
|
||
return WorkspaceOut(
|
||
tenant=WorkspaceTenantInfo(
|
||
id=tenant.id,
|
||
name=tenant.name,
|
||
code=tenant.code,
|
||
status=tenant.status,
|
||
status_label=status_labels.get(str(tenant.status), "未知"),
|
||
start_time=tenant.start_time.isoformat() if tenant.start_time else None,
|
||
end_time=tenant.end_time.isoformat() if tenant.end_time else None,
|
||
days_remaining=max(days_remaining, 0),
|
||
),
|
||
package=WorkspacePackageInfo(
|
||
id=package.id,
|
||
name=package.name,
|
||
price=package.price,
|
||
period=package.period,
|
||
max_users=package.max_users,
|
||
max_roles=package.max_roles,
|
||
max_depts=package.max_depts,
|
||
)
|
||
if package
|
||
else None,
|
||
quota=WorkspaceQuotaInfo(
|
||
max_users=package.max_users if package else 0,
|
||
max_roles=package.max_roles if package else 0,
|
||
max_depts=package.max_depts if package else 0,
|
||
current_users=user_count,
|
||
current_roles=role_count,
|
||
current_depts=dept_count,
|
||
usage_percent=WorkspaceUsagePercent(
|
||
users=round(user_count / package.max_users * 100, 1) if package and package.max_users > 0 else 0,
|
||
roles=round(role_count / package.max_roles * 100, 1) if package and package.max_roles > 0 else 0,
|
||
depts=round(dept_count / package.max_depts * 100, 1) if package and package.max_depts > 0 else 0,
|
||
),
|
||
),
|
||
recent_orders=recent_orders,
|
||
)
|