refactor: 完成系统架构升级与模块拆分

本次提交进行了大规模的系统重构:
1. 拆分租户相关模块到platform平台层,重构租户表名与关联关系
2. 迁移日志、工单、插件等模块到对应层级,统一代码结构
3. 重构批量操作接口路径,从/available/setting改为/status/batch
4. 新增批量删除基础模型,统一处理批量操作逻辑
5. 优化导入导出接口,修正路由方法与描述信息
6. 修复循环引用问题,重构依赖注入与类型导入
7. 更新初始化脚本与路由注册,新增平台管理路由
8. 重构岗位模型,新增岗位编码字段与校验
9. 完善部门删除逻辑,新增子部门删除限制
10. 更新初始化数据与配置文件,适配新架构
This commit is contained in:
zhangtao
2026-06-03 00:53:19 +08:00
parent 290728d9d2
commit 17b3cd0a4c
104 changed files with 2666 additions and 1438 deletions
+58 -30
View File
@@ -1,35 +1,63 @@
"""
系统级模块 - module_system
租户内部管理功能,受租户隔离限制:
- 认证授权 (auth)
- 用户管理 (user)
- 角色管理 (role)
- 部门管理 (dept)
- 菜单管理 (menu)
- 岗位管理 (position)
- 字典管理 (dict)
- 公告管理 (notice)
- 参数管理 (params)
- 操作日志 (operationlog)
"""
from fastapi import APIRouter
from app.common.response import ResponseSchema as ResponseSchema
system_router = APIRouter(prefix="/system", tags=["系统管理"])
_routers_included = False
from .auth.controller import AuthRouter
from .dept.controller import DeptRouter
from .dict.controller import DictRouter
from .log.controller import LogRouter
from .menu.controller import MenuRouter
from .notice.controller import NoticeRouter
from .params.controller import ParamsRouter
from .plugin.controller import PluginRouter
from .position.controller import PositionRouter
from .role.controller import RoleRouter
from .tenant.controller import TenantRouter
from .tenant.package_controller import PackageRouter
from .ticket.controller import TicketRouter
from .user.controller import UserRouter
system_router = APIRouter(prefix="/system")
def _include_routers():
"""延迟导入路由以避免循环导入"""
global _routers_included
if _routers_included:
return
from app.api.v1.module_system.auth.controller import AuthRouter
from app.api.v1.module_system.dept.controller import DeptRouter
from app.api.v1.module_system.dict.controller import DictRouter
from app.api.v1.module_system.menu.controller import MenuRouter
from app.api.v1.module_system.notice.controller import NoticeRouter
from app.api.v1.module_system.operationlog.controller import OperationLogRouter
from app.api.v1.module_system.params.controller import ParamsRouter
from app.api.v1.module_system.position.controller import PositionRouter
from app.api.v1.module_system.role.controller import RoleRouter
from app.api.v1.module_system.user.controller import UserRouter
system_router.include_router(AuthRouter)
system_router.include_router(DeptRouter)
system_router.include_router(DictRouter)
system_router.include_router(LogRouter)
system_router.include_router(MenuRouter)
system_router.include_router(NoticeRouter)
system_router.include_router(ParamsRouter)
system_router.include_router(PositionRouter)
system_router.include_router(RoleRouter)
system_router.include_router(TenantRouter)
system_router.include_router(PackageRouter)
system_router.include_router(UserRouter)
system_router.include_router(TicketRouter)
system_router.include_router(PluginRouter)
system_router.include_router(AuthRouter)
system_router.include_router(DeptRouter)
system_router.include_router(DictRouter)
system_router.include_router(OperationLogRouter)
system_router.include_router(MenuRouter)
system_router.include_router(NoticeRouter)
system_router.include_router(ParamsRouter)
system_router.include_router(PositionRouter)
system_router.include_router(RoleRouter)
system_router.include_router(UserRouter)
_routers_included = True
# 提供一个属性访问器,在首次访问时才加载路由
class _SystemRouter(APIRouter):
def __getattr__(self, name):
_include_routers()
return getattr(system_router, name)
# 使用包装类延迟路由加载
system_router = _SystemRouter(prefix="/system", tags=["系统管理"])
# 保持向后兼容性,直接暴露 include_router 方法
system_router.include_router = lambda router: _include_routers() or system_router.include_router(router)
@@ -1,66 +1,15 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.model import UserModel
class AuthSchema(BaseModel):
"""权限认证模型"""
model_config = ConfigDict(arbitrary_types_allowed=True)
user: UserModel | None = Field(default=None, description="用户信息")
check_data_scope: bool = Field(default=True, description="是否检查数据权限")
db: AsyncSession = Field(description="数据库会话")
tenant_id: int | None = Field(default=None, description="租户ID,用于用户认证前查询")
class JWTPayloadSchema(BaseModel):
"""JWT载荷模型"""
sub: str = Field(..., description="用户登录信息")
is_refresh: bool = Field(default=False, description="是否刷新token")
exp: datetime | int = Field(..., description="过期时间")
@model_validator(mode="after")
def validate_fields(self):
"""
校验 JWT 载荷字段的基本合法性。
返回:
- JWTPayloadSchema: 校验后的载荷实例。
异常:
- ValueError: 必填字段为空或格式不正确时抛出。
"""
if not self.sub or len(self.sub.strip()) == 0:
raise ValueError("会话编号不能为空")
return self
class JWTOutSchema(BaseModel):
"""JWT响应模型"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., min_length=1, description="访问token")
refresh_token: str = Field(..., min_length=1, description="刷新token")
token_type: str = Field(default="Bearer", description="token类型")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class RefreshTokenPayloadSchema(BaseModel):
"""刷新Token载荷模型"""
refresh_token: str = Field(..., min_length=1, description="刷新token")
class LogoutPayloadSchema(BaseModel):
"""退出登录载荷模型"""
token: str = Field(..., min_length=1, description="token")
# 从 core 模块导入基础认证 schema,避免循环导入
from app.core.auth_schema import (
AuthSchema,
JWTPayloadSchema,
JWTOutSchema,
RefreshTokenPayloadSchema,
LogoutPayloadSchema,
)
class CaptchaOutSchema(BaseModel):
@@ -109,7 +109,8 @@ class LoginService:
# 检查用户的默认租户是否正常
from sqlalchemy import select
from app.api.v1.module_system.tenant.model import TenantModel
from app.api.v1.module_platform.tenant.model import TenantModel
tenant_stmt = (
select(TenantModel)
.where(TenantModel.id == user.tenant_id, TenantModel.status == "0", TenantModel.is_deleted.is_(False))
@@ -405,7 +406,7 @@ class LoginService:
"""
from sqlalchemy import select
from app.api.v1.module_system.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
uid = user_id or (auth.user.id if auth.user else None)
if not uid:
@@ -462,7 +463,7 @@ class LoginService:
"""
from sqlalchemy import select
from app.api.v1.module_system.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
if not auth.user:
raise CustomException(msg="未认证用户")
@@ -162,7 +162,7 @@ async def delete_obj_controller(
@DeptRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改部门状态",
description="批量修改部门状态",
response_model=ResponseSchema[None],
@@ -93,7 +93,7 @@ class DeptService:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
from app.api.v1.module_system.tenant.service import TenantService
from app.api.v1.module_platform.tenant.service import TenantService
await TenantService.check_quota_service(auth, auth.tenant_id, "dept")
dept = await DeptCRUD(auth).create(data=data)
@@ -141,6 +141,7 @@ class DeptService:
异常:
- CustomException: 当删除对象为空时抛出。
- CustomException: 当存在子部门时抛出。
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
@@ -151,19 +152,13 @@ class DeptService:
# 构建子部门ID映射
child_id_map = get_child_id_map(model_list=all_depts)
# 收集所有需要删除的部门ID,包括直接指定的ID和它们的所有子部门ID
delete_ids_set = set()
for id in ids:
# 递归获取该ID的所有子部门ID
all_descendants = get_child_recursion(id=id, id_map=child_id_map)
delete_ids_set.update(all_descendants)
# 将集合转换为列表
delete_ids = list(delete_ids_set)
# 检查是否有子部门
if id in child_id_map and child_id_map[id]:
raise CustomException(msg="存在子部门,不允许删除父部门")
# 执行批量删除操作
await DeptCRUD(auth).delete(ids=delete_ids)
await DeptCRUD(auth).delete(ids=ids)
@classmethod
async def batch_set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
@@ -211,7 +211,7 @@ async def delete_type_controller(
@DictRouter.patch(
"/type/available/setting",
"/type/status/batch",
summary="批量修改字典类型状态",
description="批量修改字典类型状态",
response_model=ResponseSchema[None],
@@ -433,7 +433,7 @@ async def delete_data_controller(
@DictRouter.patch(
"/data/available/setting",
"/data/status/batch",
summary="批量修改字典数据状态",
description="批量修改字典数据状态",
response_model=ResponseSchema[None],
@@ -1 +0,0 @@
@@ -1,136 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse, StreamingResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import OperationLogOutSchema, OperationLogQueryParam
from .service import OperationLogService
LogRouter = APIRouter(route_class=OperationLogRoute, prefix="/log", tags=["日志管理"])
@LogRouter.get(
"/list",
summary="查询日志",
description="查询日志",
response_model=ResponseSchema[list[OperationLogOutSchema]],
)
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[OperationLogQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:log:query"]))],
) -> JSONResponse:
"""
查询日志
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (OperationLogQueryParam): 日志查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含分页日志详情的 JSON 响应模型
"""
order_by = [{"created_time": "desc"}]
if page.order_by:
order_by = page.order_by
result_dict = await OperationLogService.get_log_page_service(
auth=auth,
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=order_by,
)
log.info("查询日志成功")
return SuccessResponse(data=result_dict, msg="查询日志成功")
@LogRouter.get(
"/detail/{id}",
summary="日志详情",
description="日志详情",
response_model=ResponseSchema[OperationLogOutSchema],
)
async def get_obj_detail_controller(
id: Annotated[int, Path(description="操作日志ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:log:detail"]))],
) -> JSONResponse:
"""
获取日志详情
参数:
- id (int): 操作日志ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含日志详情的 JSON 响应模型
"""
result_dict = await OperationLogService.get_log_detail_service(id=id, auth=auth)
log.info(f"查询日志成功 {id}")
return SuccessResponse(data=result_dict, msg="获取日志详情成功")
@LogRouter.delete(
"/delete",
summary="删除日志",
description="删除日志",
response_model=ResponseSchema[None],
)
async def delete_obj_log_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:log:delete"]))],
) -> JSONResponse:
"""
删除日志
参数:
- ids (list[int]): 日志 ID 列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除结果的 JSON 响应模型
"""
await OperationLogService.delete_log_service(ids=ids, auth=auth)
log.info(f"删除日志成功 {ids}")
return SuccessResponse(msg="删除日志成功")
@LogRouter.post(
"/export",
summary="导出日志",
description="导出日志",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
search: Annotated[OperationLogQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:log:export"]))],
) -> StreamingResponse:
"""
导出日志
参数:
- search (OperationLogQueryParam): 日志查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含导出日志的流式响应模型
"""
operation_log_list = await OperationLogService.get_log_list_service(search=search, auth=auth)
operation_log_export_result = await OperationLogService.export_log_list_service(
operation_log_list=operation_log_list
)
log.info("导出日志成功")
return StreamResponse(
data=bytes2file_response(operation_log_export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=log.xlsx"},
)
@@ -1,74 +0,0 @@
from collections.abc import Sequence
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import OperationLogModel
from .schema import OperationLogCreateSchema
class OperationLogCRUD(
CRUDBase[OperationLogModel, OperationLogCreateSchema, OperationLogCreateSchema]
):
"""
操作日志数据层。
"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化操作日志数据层。
参数:
- auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
返回:
- None
"""
self.auth = auth
super().__init__(model=OperationLogModel, auth=auth)
async def create_crud(self, data: OperationLogCreateSchema) -> OperationLogModel | None:
"""
创建操作日志记录。
参数:
- data (OperationLogCreateSchema): 操作日志创建模型。
返回:
- OperationLogModel | None: 创建后的日志记录。
"""
return await self.create(data=data)
async def get_by_id_crud(
self, id: int, preload: list | None = None
) -> OperationLogModel | None:
"""
根据ID获取操作日志详情。
参数:
- id (int): 操作日志ID。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- OperationLogModel | None: 操作日志记录。
"""
return await self.get(id=id, preload=preload)
async def get_list_crud(
self,
search: dict | None = None,
order_by: list | None = None,
preload: list | None = None,
) -> Sequence[OperationLogModel]:
"""
获取操作日志列表。
参数:
- search (dict | None): 搜索条件字典。
- order_by (list | None): 排序字段列表。
- preload (list | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[OperationLogModel]: 操作日志列表。
"""
return await self.list(search=search, order_by=order_by, preload=preload)
@@ -1,117 +0,0 @@
import re
from fastapi import Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema, UserBySchema
from app.core.validator import DateTimeStr
class OperationLogCreateSchema(BaseModel):
"""日志创建模型"""
type: int | None = Field(default=None, description="日志类型(1登录日志 2操作日志)")
request_path: str | None = Field(default=None, description="请求路径")
request_method: str | None = Field(default=None, description="请求方法")
request_payload: str | None = Field(default=None, description="请求负载")
request_ip: str | None = Field(default=None, description="请求 IP 地址")
login_location: str | None = Field(default=None, description="登录位置")
request_os: str | None = Field(default=None, description="请求操作系统")
request_browser: str | None = Field(default=None, description="请求浏览器")
response_code: int | None = Field(default=None, description="响应状态码")
response_json: str | None = Field(default=None, description="响应 JSON 数据")
process_time: str | None = Field(default=None, description="处理时间")
status: str = Field(default="0", description="是否成功")
description: str | None = Field(default=None, max_length=255, description="描述")
created_id: int | None = Field(default=None, description="创建人ID")
updated_id: int | None = Field(default=None, description="更新人ID")
@field_validator("type")
@classmethod
def _validate_type(cls, value: int):
if value is None:
return value
if value not in {1, 2}:
raise ValueError("日志类型仅支持 1(登录) 或 2(操作)")
return value
@field_validator("request_method")
@classmethod
def _validate_method(cls, value: str):
if value is None:
return value
allowed = {"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
if value.upper() not in allowed:
raise ValueError(f"请求方法必须为 {', '.join(sorted(allowed))}")
return value.upper()
@field_validator("request_ip")
@classmethod
def _validate_ip(cls, value: str | None):
if value is None or value == "":
return value
ipv4 = r"^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$"
ipv6 = r"^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$"
if not re.match(ipv4, value) and not re.match(ipv6, value):
raise ValueError("请求IP必须为有效的IPv4或IPv6地址")
return value
class OperationLogOutSchema(OperationLogCreateSchema, BaseSchema, UserBySchema):
"""日志响应模型"""
model_config = ConfigDict(from_attributes=True)
class OperationLogQueryParam:
"""操作日志查询参数"""
def __init__(
self,
type: int | None = Query(None, description="日志类型(1:登录日志, 2:操作日志)"),
request_path: str | None = Query(None, description="请求路径"),
request_method: str | None = Query(None, description="请求方法"),
request_ip: str | None = Query(None, description="请求IP"),
response_code: int | None = Query(None, description="响应状态码"),
description: str | None = Query(None, description="描述"),
status: str | None = Query(None, description="是否启用"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.request_path = (QueueEnum.like.value, request_path)
# 精确查询字段
self.request_method = (QueueEnum.eq.value, request_method)
self.request_ip = (QueueEnum.eq.value, request_ip)
self.response_code = (QueueEnum.eq.value, response_code)
self.type = (QueueEnum.eq.value, type)
# 模糊查询字段
if description:
self.description = (QueueEnum.like.value, description)
# 精确查询字段
if status:
self.status = (QueueEnum.eq.value, status)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
# 关联查询字段
if created_id:
self.created_id = (QueueEnum.eq.value, created_id)
if updated_id:
self.updated_id = (QueueEnum.eq.value, updated_id)
@@ -1,167 +0,0 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.utils.excel_util import ExcelUtil
from .crud import OperationLogCRUD
from .schema import (
OperationLogCreateSchema,
OperationLogOutSchema,
OperationLogQueryParam,
)
class OperationLogService:
"""
日志模块服务层
"""
@classmethod
async def get_log_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取日志详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 日志 ID
返回:
- dict: 日志详情字典
"""
log = await OperationLogCRUD(auth).get_by_id_crud(id=id)
log_dict = OperationLogOutSchema.model_validate(log).model_dump()
return log_dict
@classmethod
async def get_log_list_service(
cls,
auth: AuthSchema,
search: OperationLogQueryParam | None = None,
order_by: list | None = None,
) -> list[dict]:
"""
获取日志列表
参数:
- auth (AuthSchema): 认证信息模型
- search (OperationLogQueryParam | None): 日志查询参数模型
- order_by (list | None): 排序字段列表
返回:
- list[dict]: 日志详情字典列表
"""
log_list = await OperationLogCRUD(auth).get_list_crud(
search=search.__dict__, order_by=order_by
)
log_dict_list = [OperationLogOutSchema.model_validate(log).model_dump() for log in log_list]
return log_dict_list
@classmethod
async def get_log_page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: OperationLogQueryParam | None = None,
order_by: list | None = None,
) -> dict:
"""
分页查询操作日志(数据库 OFFSET/LIMIT)。
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码(从 1 开始)
- page_size (int): 每页条数
- search (OperationLogQueryParam | None): 查询条件
- order_by (list | None): 排序字段列表
返回:
- dict: 分页结果(结构由 `CRUD.page` 返回约定)
"""
offset = (page_no - 1) * page_size
return await OperationLogCRUD(auth).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search.__dict__ if search else {},
out_schema=OperationLogOutSchema,
)
@classmethod
async def create_log_service(cls, auth: AuthSchema, data: OperationLogCreateSchema) -> dict:
"""
创建日志
参数:
- auth (AuthSchema): 认证信息模型
- data (OperationLogCreateSchema): 日志创建模型
返回:
- dict: 日志详情字典
"""
new_log = await OperationLogCRUD(auth).create(data=data)
new_log_dict = OperationLogOutSchema.model_validate(new_log).model_dump()
return new_log_dict
@classmethod
async def delete_log_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除日志
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 日志 ID 列表
返回:
- None
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
await OperationLogCRUD(auth).delete(ids=ids)
@classmethod
async def export_log_list_service(cls, operation_log_list: list[dict]) -> bytes:
"""
导出日志信息
参数:
- operation_log_list (list[dict]): 操作日志信息列表
返回:
- bytes: 操作日志信息excel的二进制数据
"""
# 操作日志字段映射
mapping_dict = {
"id": "编号",
"type": "日志类型",
"request_path": "请求URL",
"request_method": "请求方式",
"request_payload": "请求参数",
"request_ip": "操作地址",
"login_location": "登录位置",
"request_os": "操作系统",
"request_browser": "浏览器",
"response_json": "返回参数",
"response_code": "相应状态",
"process_time": "处理时间",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 处理数据
data = operation_log_list.copy()
for item in data:
# 处理状态
item["response_code"] = "成功" if item.get("response_code") == 200 else "失败"
# 处理日志类型 - 修正与schema.py保持一致
item["type"] = "登录日志" if item.get("type") == 1 else "操作日志"
item["creator"] = (
item.get("creator", {}).get("name", "未知")
if isinstance(item.get("creator"), dict)
else "未知"
)
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@@ -142,7 +142,7 @@ async def delete_obj_controller(
@MenuRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改菜单状态",
description="批量修改菜单状态",
response_model=ResponseSchema[None],
@@ -1,7 +1,7 @@
from typing import Literal
from fastapi import Query
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema
@@ -154,7 +154,7 @@ async def delete_obj_controller(
@NoticeRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改公告状态",
description="批量修改公告状态",
response_model=ResponseSchema[None],
@@ -178,10 +178,10 @@ async def batch_set_available_obj_controller(
return SuccessResponse(msg="批量修改公告状态成功")
@NoticeRouter.post(
@NoticeRouter.get(
"/export",
summary="导出公告",
description="导出公告",
description="导出公告列表",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
@@ -282,7 +282,7 @@ class NoticeService:
# 2. 消息:最近的操作日志(作为系统消息)
messages = []
try:
from app.api.v1.module_system.log.model import OperationLogModel
from app.api.v1.module_system.operationlog.model import OperationLogModel
stmt = select(OperationLogModel).order_by(desc(OperationLogModel.created_time)).limit(5)
result = await auth.db.execute(stmt)
@@ -0,0 +1,3 @@
from .controller import OperationLogRouter
__all__ = ["OperationLogRouter"]
@@ -0,0 +1,85 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import BatchDelete
from app.core.dependencies import AuthPermission, get_current_user
from .schema import (
OperationLogCreateSchema,
OperationLogDetailOutSchema,
OperationLogQueryParam,
)
from .service import OperationLogService
OperationLogRouter = APIRouter(prefix="/operationlog", tags=["操作日志"])
@OperationLogRouter.get(
"/detail/{id}",
summary="获取操作日志详情",
description="根据ID获取操作日志详情",
response_model=ResponseSchema[OperationLogDetailOutSchema],
dependencies=[Depends(AuthPermission("module_system:operation_log:query"))],
)
async def detail(
*,
id: Annotated[int, Path(gt=0)],
auth: AuthSchema = Depends(get_current_user),
):
return await OperationLogService.detail_service(auth, id)
@OperationLogRouter.get(
"/list",
summary="获取操作日志列表",
description="分页获取操作日志列表",
response_model=ResponseSchema[dict],
dependencies=[Depends(AuthPermission("module_system:operation_log:query"))],
)
async def list(
*,
page: Annotated[PaginationQueryParam, Depends()],
search: OperationLogQueryParam,
auth: AuthSchema = Depends(get_current_user),
):
return await OperationLogService.page_service(
auth,
page.page,
page.page_size,
search.to_dict(),
[{"id": page.order_by}],
)
@OperationLogRouter.post(
"/create",
summary="创建操作日志",
description="创建操作日志(由系统自动调用)",
response_model=ResponseSchema[OperationLogDetailOutSchema],
)
async def create(
*,
data: OperationLogCreateSchema,
auth: AuthSchema = Depends(get_current_user),
):
return await OperationLogService.create_service(auth, data)
@OperationLogRouter.delete(
"/delete",
summary="删除操作日志",
description="批量删除操作日志",
response_model=ResponseSchema,
dependencies=[Depends(AuthPermission("module_system:operation_log:delete"))],
)
async def delete(
*,
data: BatchDelete,
auth: AuthSchema = Depends(get_current_user),
):
await OperationLogService.delete_service(auth, data.ids)
return ResponseSchema()
@@ -0,0 +1,11 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import OperationLogModel
class OperationLogCRUD(CRUDBase[OperationLogModel, None, None]):
"""操作日志 CRUD"""
def __init__(self, auth: AuthSchema):
super().__init__(OperationLogModel, auth)
@@ -8,20 +8,13 @@ from app.core.base_model import ModelMixin, TenantMixin, UserMixin
def get_log_text_column_type():
"""
根据数据库类型选择适合存储大文本的列类型
MySQL 使用 LONGTEXTPostgreSQL 使用 TEXT其它数据库回退到 SQLAlchemy Text
返回:
- type: SQLAlchemy 列类型可用于 mapped_column
"""
db_type = settings.DATABASE_TYPE
if db_type == "mysql":
from sqlalchemy.dialects.mysql import LONGTEXT
return LONGTEXT
elif db_type == "postgres":
from sqlalchemy.dialects.postgresql import TEXT
return TEXT
else:
return Text
@@ -29,24 +22,19 @@ def get_log_text_column_type():
class OperationLogModel(ModelMixin, TenantMixin, UserMixin):
"""
系统日志模型
日志类型:
- 1: 登录日志
- 2: 操作日志
操作日志模型
"""
__tablename__: str = "sys_log"
__table_args__: dict[str, str] = {"comment": "系统日志表"}
__tablename__: str = "sys_operation_log"
__table_args__: dict[str, str] = {"comment": "操作日志表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
type: Mapped[int] = mapped_column(Integer, comment="日志类型(1登录日志 2操作日志)")
request_path: Mapped[str] = mapped_column(String(255), comment="请求路径")
request_method: Mapped[str] = mapped_column(String(10), comment="请求方式")
request_payload: Mapped[str | None] = mapped_column(
get_log_text_column_type(), comment="请求体"
)
request_ip: Mapped[str | None] = mapped_column(String(50), comment="请求IP地址")
login_location: Mapped[str | None] = mapped_column(String(255), comment="登录位置")
request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统")
request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器")
response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码")
@@ -0,0 +1,63 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
class OperationLogQueryParam(BaseModel):
request_path: str | None = Field(None, max_length=255, description="请求路径")
request_method: str | None = Field(None, description="请求方式")
username: str | None = Field(None, max_length=64, description="用户名")
@field_validator("request_method")
@classmethod
def validate_request_method(cls, value: str | None) -> str | None:
if value and value.upper() not in ALLOWED_REQUEST_METHODS:
raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}")
return value.upper() if value else None
def to_dict(self) -> dict | None:
"""转换为字典,仅包含非空字段"""
return self.model_dump(exclude_none=True)
class OperationLogOutSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
tenant_id: int
request_path: str
request_method: str
request_ip: str | None = None
request_os: str | None = None
request_browser: str | None = None
response_code: int
process_time: str | None = None
created_at: datetime
class OperationLogDetailOutSchema(OperationLogOutSchema):
request_payload: str | None = None
response_json: str | None = None
class OperationLogCreateSchema(BaseModel):
request_path: str = Field(..., min_length=1, max_length=255, description="请求路径")
request_method: str = Field(..., description="请求方式")
request_payload: str | None = Field(None, description="请求体")
request_ip: str | None = Field(None, max_length=50, description="请求IP地址")
request_os: str | None = Field(None, max_length=64, description="操作系统")
request_browser: str | None = Field(None, max_length=64, description="浏览器")
response_code: int = Field(200, ge=100, le=599, description="响应状态码")
response_json: str | None = Field(None, description="响应体")
process_time: str | None = Field(None, max_length=20, description="处理时间")
@field_validator("request_method")
@classmethod
def validate_request_method(cls, value: str) -> str:
upper_value = value.upper()
if upper_value not in ALLOWED_REQUEST_METHODS:
raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}")
return upper_value
@@ -0,0 +1,59 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_system.operationlog.crud import OperationLogCRUD
from app.api.v1.module_system.operationlog.schema import (
OperationLogCreateSchema,
OperationLogDetailOutSchema,
OperationLogOutSchema,
)
from app.core.logger import log
class OperationLogService:
@staticmethod
async def create_service(auth: AuthSchema, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema:
crud = OperationLogCRUD(auth)
obj = await crud.create(data.model_dump())
return OperationLogDetailOutSchema.model_validate(obj)
@staticmethod
async def page_service(
auth: AuthSchema,
page: int,
page_size: int,
search: dict | None = None,
order_by: list[dict[str, str]] | None = None,
) -> dict:
crud = OperationLogCRUD(auth)
# 构建过滤条件
filters = {}
if search:
if search.get("request_path"):
filters["request_path"] = search["request_path"]
if search.get("request_method"):
filters["request_method"] = search["request_method"]
if search.get("username"):
filters["username"] = search["username"]
result = await crud.page(
offset=(page - 1) * page_size,
limit=page_size,
order_by=order_by or [{"id": "desc"}],
search=filters,
out_schema=OperationLogOutSchema,
)
return result
@staticmethod
async def detail_service(auth: AuthSchema, id: int) -> OperationLogDetailOutSchema:
crud = OperationLogCRUD(auth)
obj = await crud.get(id=id)
return OperationLogDetailOutSchema.model_validate(obj)
@staticmethod
async def delete_service(auth: AuthSchema, ids: list[int]) -> None:
crud = OperationLogCRUD(auth)
await crud.delete(ids)
log.info(f"删除操作日志成功, ids={ids}")
@@ -211,10 +211,37 @@ async def delete_obj_controller(
return SuccessResponse(msg="删除参数成功")
@ParamsRouter.post(
@ParamsRouter.patch(
"/status/batch",
summary="批量设置参数状态",
description="批量设置参数状态",
response_model=ResponseSchema,
)
async def batch_set_status_controller(
ids: Annotated[list[int], Body(description="参数ID列表")],
status: Annotated[str, Body(description="状态值")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:patch"]))],
) -> JSONResponse:
"""
批量设置参数状态
参数:
- ids (list[int]): 参数ID列表
- status (str): 状态值
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含批量设置参数状态结果的 JSON 响应
"""
await ParamsService.batch_set_status_service(auth=auth, ids=ids, status=status)
log.info(f"批量设置参数状态成功: ids={ids}, status={status}")
return SuccessResponse(msg="批量设置参数状态成功")
@ParamsRouter.get(
"/export",
summary="导出参数",
description="导出参数",
description="导出参数列表",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
@@ -262,6 +262,28 @@ class ParamsService:
log.error(f"删除系统配置失败: {e}")
raise CustomException(msg="删除字典类型失败")
@classmethod
async def batch_set_status_service(cls, auth: AuthSchema, ids: list[int], status: str) -> None:
"""
批量设置系统参数状态
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 系统参数ID列表
- status (str): 状态值
返回:
- None
"""
if not ids:
raise CustomException(msg="请选择要操作的数据")
await ParamsCRUD(auth).update_obj_crud(
ids=ids,
data={"status": status},
)
log.info(f"批量设置系统参数状态成功: ids={ids}, status={status}")
@classmethod
async def export_obj_service(cls, data_list: list[dict]) -> bytes:
"""
@@ -1,118 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from .schema import (
PluginCreateSchema,
PluginInstallSchema,
PluginOutSchema,
PluginQueryParam,
PluginUpdateSchema,
)
from .service import PluginService
PluginRouter = APIRouter(route_class=OperationLogRoute, prefix="/plugin", tags=["插件管理"])
# ───── 超管:插件 CRUD ─────
@PluginRouter.get("/list", summary="插件列表", response_model=ResponseSchema[dict])
async def plugin_list(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[PluginQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))],
):
r = await PluginService.page_service(auth, page.page_no, page.page_size, search, page.order_by)
return SuccessResponse(data=r, msg="查询成功")
@PluginRouter.get(
"/detail/{id}", summary="插件详情", response_model=ResponseSchema[PluginOutSchema]
)
async def plugin_detail(
id: Annotated[int, Path()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))],
):
return SuccessResponse(data=await PluginService.detail_service(auth, id), msg="查询成功")
@PluginRouter.post("/create", summary="创建插件", response_model=ResponseSchema[PluginOutSchema])
async def plugin_create(
data: PluginCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:create"]))],
):
return SuccessResponse(data=await PluginService.create_service(auth, data), msg="创建成功")
@PluginRouter.put(
"/update/{id}", summary="更新插件", response_model=ResponseSchema[PluginOutSchema]
)
async def plugin_update(
id: Annotated[int, Path()],
data: PluginUpdateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:update"]))],
):
return SuccessResponse(data=await PluginService.update_service(auth, id, data), msg="更新成功")
@PluginRouter.delete("/delete", summary="删除插件")
async def plugin_delete(
ids: Annotated[list[int], Body()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:delete"]))],
):
await PluginService.delete_service(auth, ids)
return SuccessResponse(msg="删除成功")
# ───── 租户:插件市场 ─────
@PluginRouter.get("/marketplace", summary="插件市场", response_model=ResponseSchema[dict])
async def marketplace(
page: Annotated[PaginationQueryParam, Depends()],
category: str | None = None,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))] = None,
):
r = await PluginService.marketplace_service(auth, page.page_no, page.page_size, category)
return SuccessResponse(data=r, msg="查询成功")
@PluginRouter.post("/install", summary="安装插件")
async def plugin_install(
data: PluginInstallSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:install"]))],
):
await PluginService.install_service(auth, data.plugin_id)
return SuccessResponse(msg="安装成功")
@PluginRouter.post("/uninstall", summary="卸载插件")
async def plugin_uninstall(
data: PluginInstallSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:uninstall"]))],
):
await PluginService.uninstall_service(auth, data.plugin_id)
return SuccessResponse(msg="卸载成功")
@PluginRouter.post("/toggle", summary="启用/禁用插件")
async def plugin_toggle(
data: PluginInstallSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:toggle"]))],
):
await PluginService.toggle_service(auth, data.plugin_id)
return SuccessResponse(msg="操作成功")
@PluginRouter.get("/my", summary="我的插件", response_model=ResponseSchema[list[PluginOutSchema]])
async def my_plugins(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))],
):
return SuccessResponse(data=await PluginService.my_plugins_service(auth), msg="查询成功")
@@ -1,11 +0,0 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import PluginModel
from .schema import PluginCreateSchema, PluginUpdateSchema
class PluginCRUD(CRUDBase[PluginModel, PluginCreateSchema, PluginUpdateSchema]):
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=PluginModel, auth=auth)
@@ -1,80 +0,0 @@
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, validates
from app.core.base_model import MappedBase, ModelMixin
class PluginModel(ModelMixin):
"""插件注册表 — 超管维护的插件市场列表"""
__tablename__: str = "sys_plugin"
__table_args__: dict[str, str] = {"comment": "插件注册表"}
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="插件名称")
code: Mapped[str] = mapped_column(
String(50), nullable=False, unique=True, comment="插件编码(module_xxx)"
)
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="插件描述")
version: Mapped[str] = mapped_column(
String(20), nullable=False, default="1.0.0", comment="版本号"
)
author: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="作者")
icon: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="图标URL")
category: Mapped[str] = mapped_column(
String(20), nullable=False, default="tool", comment="分类(tool/ai/monitor/business)"
)
price: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, comment="价格(分,0=免费)"
)
menu_path: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="菜单路径(安装后显示)"
)
permission_prefix: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="权限前缀"
)
dependencies: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="依赖插件编码(JSON数组)"
)
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
@validates("name")
def validate_name(self, key: str, name: str) -> str:
if not name or not name.strip():
raise ValueError("插件名称不能为空")
return name.strip()
@validates("code")
def validate_code(self, key: str, code: str) -> str:
if not code or not code.strip():
raise ValueError("插件编码不能为空")
return code.strip()
class TenantPluginModel(MappedBase):
"""租户插件关联表 — 租户已安装的插件"""
__tablename__: str = "sys_tenant_plugin"
__table_args__ = (
UniqueConstraint("tenant_id", "plugin_id", name="uq_tenant_plugin"),
{"comment": "租户插件关联表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
tenant_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="租户ID",
)
plugin_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_plugin.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="插件ID",
)
enabled: Mapped[str] = mapped_column(
String(1), nullable=False, default="1", comment="启用(1:启用 0:禁用)"
)
installed_time: Mapped[DateTime] = mapped_column(DateTime, nullable=False, comment="安装时间")
@@ -1,111 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PluginCreateSchema(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="插件名称")
code: str = Field(..., min_length=1, max_length=50, description="插件编码(如 module_xxx")
description: str | None = Field(default=None, max_length=255, description="插件描述")
version: str = Field(default="1.0.0", max_length=20, description="版本号")
author: str | None = Field(default=None, max_length=100, description="作者")
icon: str | None = Field(default=None, max_length=500, description="图标URL")
category: str = Field(
default="tool", max_length=20, description="分类(tool/ai/monitor/business)"
)
price: int = Field(default=0, ge=0, description="价格(分,0=免费)")
menu_path: str | None = Field(default=None, max_length=200, description="菜单路径")
permission_prefix: str | None = Field(default=None, max_length=100, description="权限前缀")
dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)")
sort: int = Field(default=0, ge=0, description="排序")
@field_validator("category")
@classmethod
def _validate_category(cls, v: str) -> str:
allowed = {"tool", "ai", "monitor", "business"}
if v not in allowed:
raise ValueError(f"插件分类仅支持 tool、ai、monitor、business,当前值: {v}")
return v
@field_validator("version")
@classmethod
def _validate_version(cls, v: str) -> str:
import re
if not re.match(r"^\d+\.\d+\.\d+$", v):
raise ValueError("版本号格式需为 x.y.z(如 1.0.0")
return v
@field_validator("code")
@classmethod
def _validate_code(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("插件编码不能为空")
return v
class PluginUpdateSchema(BaseModel):
name: str | None = Field(default=None, max_length=100, description="插件名称")
description: str | None = Field(default=None, max_length=255, description="插件描述")
version: str | None = Field(default=None, max_length=20, description="版本号")
author: str | None = Field(default=None, max_length=100, description="作者")
icon: str | None = Field(default=None, max_length=500, description="图标URL")
category: str | None = Field(default=None, max_length=20, description="分类")
price: int | None = Field(default=None, ge=0, description="价格(分,0=免费)")
menu_path: str | None = Field(default=None, max_length=200, description="菜单路径")
permission_prefix: str | None = Field(default=None, max_length=100, description="权限前缀")
dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)")
sort: int | None = Field(default=None, ge=0, description="排序")
status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)")
@field_validator("category")
@classmethod
def _validate_category(cls, v: str | None) -> str | None:
if v is None:
return v
allowed = {"tool", "ai", "monitor", "business"}
if v not in allowed:
raise ValueError(f"插件分类仅支持 tool、ai、monitor、business,当前值: {v}")
return v
@field_validator("status")
@classmethod
def _validate_status(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in {"0", "1"}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
class PluginOutSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
code: str
description: str | None = None
version: str
author: str | None = None
icon: str | None = None
category: str
price: int
menu_path: str | None = None
permission_prefix: str | None = None
dependencies: str | None = None
sort: int
status: str
installed: bool = False # 当前租户是否已安装
class PluginQueryParam:
def __init__(
self, name: str | None = None, category: str | None = None, status: str | None = None
):
if name:
self.name = ("like", name)
if category:
self.category = ("eq", category)
if status:
self.status = ("eq", status)
class PluginInstallSchema(BaseModel):
plugin_id: int = Field(..., description="插件ID")
@@ -1,180 +0,0 @@
from datetime import datetime
import sqlalchemy as sa
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import log
from .crud import PluginCRUD
from .model import PluginModel, TenantPluginModel
from .schema import PluginCreateSchema, PluginOutSchema, PluginQueryParam, PluginUpdateSchema
class PluginService:
def __init__(self):
raise RuntimeError("Service is stateless, use classmethods")
@classmethod
async def page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: PluginQueryParam | None = None,
order_by: list | None = None,
) -> dict:
return await PluginCRUD(auth).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"sort": "asc"}],
search=search.__dict__ if search else {},
out_schema=PluginOutSchema,
)
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await PluginCRUD(auth).get(id=id)
if not obj:
raise CustomException(msg="插件不存在")
return PluginOutSchema.model_validate(obj).model_dump()
@classmethod
async def create_service(cls, auth: AuthSchema, data: PluginCreateSchema) -> dict:
if await PluginCRUD(auth).get(code=data.code):
raise CustomException(msg="插件编码已存在")
obj = await PluginCRUD(auth).create(data=data)
return PluginOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: PluginUpdateSchema) -> dict:
obj = await PluginCRUD(auth).get(id=id)
if not obj:
raise CustomException(msg="插件不存在")
updated = await PluginCRUD(auth).update(id=id, data=data)
return PluginOutSchema.model_validate(updated).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
await PluginCRUD(auth).delete(ids=ids)
# ───── 插件市场 API ─────
@classmethod
async def marketplace_service(
cls, auth: AuthSchema, page_no: int, page_size: int, category: str | None = None
) -> dict:
search = {}
if category:
search["category"] = ("eq", category)
search["status"] = ("eq", "0")
result = await PluginCRUD(auth).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=[{"sort": "asc"}],
search=search,
out_schema=PluginOutSchema,
)
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
if tenant_id and result.get("items"):
installed = await auth.db.execute(
sa.select(TenantPluginModel.plugin_id).where(
TenantPluginModel.tenant_id == tenant_id,
TenantPluginModel.enabled == "0",
)
)
installed_ids = {r[0] for r in installed.all()}
for item in result["items"]:
item["installed"] = item["id"] in installed_ids
return result
@classmethod
async def install_service(cls, auth: AuthSchema, plugin_id: int) -> None:
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
if not tenant_id:
raise CustomException(msg="无法获取租户信息")
plugin = await PluginCRUD(auth).get(id=plugin_id)
if not plugin or plugin.status == "1":
raise CustomException(msg="插件不可用")
exist = await auth.db.execute(
sa
.select(TenantPluginModel)
.where(
TenantPluginModel.tenant_id == tenant_id,
TenantPluginModel.plugin_id == plugin_id,
)
.limit(1)
)
if exist.scalar_one_or_none():
await auth.db.execute(
sa
.update(TenantPluginModel)
.where(
TenantPluginModel.tenant_id == tenant_id,
TenantPluginModel.plugin_id == plugin_id,
)
.values(enabled="0")
)
else:
tp = TenantPluginModel(
tenant_id=tenant_id, plugin_id=plugin_id, enabled="0", installed_time=datetime.now()
)
auth.db.add(tp)
await auth.db.flush()
log.info(f"租户[{tenant_id}]安装插件[{plugin.name}]")
@classmethod
async def uninstall_service(cls, auth: AuthSchema, plugin_id: int) -> None:
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
if not tenant_id:
raise CustomException(msg="无法获取租户信息")
await auth.db.execute(
sa.delete(TenantPluginModel).where(
TenantPluginModel.tenant_id == tenant_id,
TenantPluginModel.plugin_id == plugin_id,
)
)
await auth.db.flush()
log.info(f"租户[{tenant_id}]卸载插件[{plugin_id}]")
@classmethod
async def toggle_service(cls, auth: AuthSchema, plugin_id: int) -> None:
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
tp = await auth.db.execute(
sa
.select(TenantPluginModel)
.where(
TenantPluginModel.tenant_id == tenant_id,
TenantPluginModel.plugin_id == plugin_id,
)
.limit(1)
)
tp = tp.scalar_one_or_none()
if not tp:
raise CustomException(msg="未安装该插件")
tp.enabled = "1" if tp.enabled == "0" else "0"
await auth.db.flush()
log.info(f"租户[{tenant_id}]插件[{plugin_id}]状态→{tp.enabled}")
@classmethod
async def my_plugins_service(cls, auth: AuthSchema) -> list[dict]:
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
if not tenant_id:
return []
result = await auth.db.execute(
sa
.select(PluginModel, TenantPluginModel)
.join(TenantPluginModel, TenantPluginModel.plugin_id == PluginModel.id)
.where(TenantPluginModel.tenant_id == tenant_id)
.order_by(PluginModel.sort)
)
plugins = []
for p, tp in result.all():
d = PluginOutSchema.model_validate(p).model_dump()
d["enabled"] = tp.enabled
d["installed"] = True
d["installed_time"] = (
tp.installed_time.strftime("%Y-%m-%d %H:%M") if tp.installed_time else ""
)
plugins.append(d)
return plugins
@@ -162,7 +162,7 @@ async def delete_obj_controller(
@PositionRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改岗位状态",
description="批量修改岗位状态",
response_model=ResponseSchema[None],
@@ -186,10 +186,10 @@ async def batch_set_available_obj_controller(
return SuccessResponse(msg="批量修改岗位状态成功")
@PositionRouter.post(
@PositionRouter.get(
"/export",
summary="导出岗位",
description="导出岗位",
description="导出岗位列表",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
@@ -19,6 +19,7 @@ class PositionModel(ModelMixin, TenantMixin, UserMixin):
__loader_options__: list[str] = ["users", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位编码")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment="显示排序")
# 关联关系
@@ -10,6 +10,7 @@ class PositionCreateSchema(BaseModel):
"""岗位创建模型"""
name: str = Field(..., min_length=1, max_length=64, description="岗位名称")
code: str = Field(..., min_length=1, max_length=64, description="岗位编码")
order: int = Field(default=1, ge=0, description="显示排序")
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述")
@@ -22,6 +23,14 @@ class PositionCreateSchema(BaseModel):
raise ValueError("岗位名称不能为空")
return v
@field_validator("code")
@classmethod
def _validate_code(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("岗位编码不能为空")
return v
@field_validator("status")
@classmethod
def _validate_status(cls, v: str) -> str:
@@ -163,7 +163,7 @@ async def delete_obj_controller(
@RoleRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改角色状态",
description="批量修改角色状态",
response_model=ResponseSchema[None],
@@ -187,10 +187,10 @@ async def batch_set_available_obj_controller(
return SuccessResponse(msg="批量修改角色状态成功")
@RoleRouter.patch(
"/permission/setting",
@RoleRouter.put(
"/permission",
summary="角色授权",
description="角色授权",
description="设置角色权限",
response_model=ResponseSchema[None],
)
async def set_role_permission_controller(
@@ -212,10 +212,10 @@ async def set_role_permission_controller(
return SuccessResponse(msg="授权角色成功")
@RoleRouter.post(
@RoleRouter.get(
"/export",
summary="导出角色",
description="导出角色",
description="导出角色列表",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
@@ -78,7 +78,7 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
)
# 租户菜单约束:只允许分配租户菜单权限内的菜单
from app.api.v1.module_system.tenant.service import TenantService
from app.api.v1.module_platform.tenant.service import TenantService
if self.auth.user and not self.auth.user.is_superuser and self.auth.tenant_id:
allowed_menu_ids = await TenantService.get_tenant_menu_ids(
@@ -105,7 +105,7 @@ class RoleService:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
from app.api.v1.module_system.tenant.service import TenantService
from app.api.v1.module_platform.tenant.service import TenantService
await TenantService.check_quota_service(auth, auth.tenant_id, "role")
new_role = await RoleCRUD(auth).create(data=data)
@@ -1,303 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import BatchSetAvailable
from app.core.dependencies import AuthPermission, redis_getter
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .schema import (
TenantConfigItem,
TenantConfigOutSchema,
TenantCreateSchema,
TenantMenuSetSchema,
TenantOutSchema,
TenantQueryParam,
TenantQuotaOutSchema,
TenantQuotaUpdateSchema,
TenantUpdateSchema,
TenantUserAddSchema,
TenantUserOutSchema,
)
from .service import TenantService
TenantRouter = APIRouter(route_class=OperationLogRoute, prefix="/tenant", tags=["租户管理"])
@TenantRouter.get(
"/detail/{id}",
summary="获取租户详情",
description="获取租户详情",
response_model=ResponseSchema[TenantOutSchema],
)
async def get_obj_detail_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result_dict = await TenantService.detail_service(id=id, auth=auth)
log.info(f"获取租户详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取租户详情成功")
@TenantRouter.get(
"/list",
summary="查询租户列表",
description="查询租户列表(分页)",
response_model=ResponseSchema[dict],
)
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TenantQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
order_by = [{"id": "asc"}]
if page.order_by:
order_by = page.order_by
result_dict = await TenantService.page_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=order_by,
)
log.info("查询租户列表成功")
return SuccessResponse(data=result_dict, msg="查询租户列表成功")
@TenantRouter.post(
"/create",
summary="创建租户",
description="创建租户",
response_model=ResponseSchema[TenantOutSchema],
)
async def create_obj_controller(
data: TenantCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))],
) -> JSONResponse:
result_dict = await TenantService.create_service(auth=auth, data=data)
log.info(f"创建租户成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="创建租户成功")
@TenantRouter.put(
"/update/{id}",
summary="修改租户",
description="修改租户",
response_model=ResponseSchema[TenantOutSchema],
)
async def update_obj_controller(
data: TenantUpdateSchema,
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
result_dict = await TenantService.update_service(auth=auth, id=id, data=data)
log.info(f"修改租户成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="修改租户成功")
@TenantRouter.delete(
"/delete",
summary="删除租户",
description="删除租户",
)
async def delete_obj_controller(
ids: Annotated[list[int], Body(..., description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))],
) -> JSONResponse:
await TenantService.delete_service(auth=auth, ids=ids)
log.info(f"删除租户成功: {ids}")
return SuccessResponse(msg="删除租户成功")
@TenantRouter.patch(
"/available/setting",
summary="批量修改租户状态",
description="批量修改租户状态",
)
async def batch_set_available_obj_controller(
data: BatchSetAvailable,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))],
) -> JSONResponse:
await TenantService.set_available_service(auth=auth, data=data)
log.info(f"批量修改租户状态成功: {data.ids}")
return SuccessResponse(msg="批量修改租户状态成功")
@TenantRouter.put(
"/status/{id}",
summary="启/禁用租户",
description="修改单个租户的启用/禁用状态",
)
async def toggle_tenant_status_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))],
) -> JSONResponse:
await TenantService.toggle_status_service(auth=auth, id=id)
log.info(f"修改租户状态成功: {id}")
return SuccessResponse(msg="修改租户状态成功")
@TenantRouter.get(
"/{id}/users",
summary="获取租户用户列表",
description="获取指定租户下的所有用户",
response_model=ResponseSchema[list[TenantUserOutSchema]],
)
async def get_tenant_users_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result = await TenantService.get_tenant_users_service(auth=auth, tenant_id=id)
log.info(f"获取租户用户列表成功: tenant_id={id}")
return SuccessResponse(data=result, msg="获取租户用户列表成功")
@TenantRouter.post(
"/{id}/users",
summary="向租户添加用户",
description="将指定用户添加到租户中",
response_model=ResponseSchema[None],
)
async def add_tenant_user_controller(
id: Annotated[int, Path(description="租户ID")],
data: TenantUserAddSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))],
) -> JSONResponse:
await TenantService.add_tenant_user_service(auth=auth, tenant_id=id, data=data)
log.info(f"向租户添加用户成功: tenant_id={id}, user_id={data.user_id}")
return SuccessResponse(msg="添加用户成功")
@TenantRouter.delete(
"/{id}/users/{uid}",
summary="从租户移除用户",
description="将指定用户从租户中移除",
response_model=ResponseSchema[None],
)
async def remove_tenant_user_controller(
id: Annotated[int, Path(description="租户ID")],
uid: Annotated[int, Path(description="用户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))],
) -> JSONResponse:
await TenantService.remove_tenant_user_service(auth=auth, tenant_id=id, user_id=uid)
log.info(f"从租户移除用户成功: tenant_id={id}, user_id={uid}")
return SuccessResponse(msg="移除用户成功")
# ============ P1: 配额管理 ============
@TenantRouter.get(
"/{id}/quota",
summary="获取租户配额",
description="获取指定租户的资源配额信息",
response_model=ResponseSchema[TenantQuotaOutSchema],
)
async def get_tenant_quota_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result = await TenantService.get_quota_service(auth=auth, tenant_id=id)
return SuccessResponse(data=result, msg="获取租户配额成功")
@TenantRouter.put(
"/{id}/quota",
summary="修改租户配额",
description="修改指定租户的资源配额(需超级管理员权限)",
response_model=ResponseSchema[TenantQuotaOutSchema],
)
async def update_tenant_quota_controller(
id: Annotated[int, Path(description="租户ID")],
data: TenantQuotaUpdateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
result = await TenantService.update_quota_service(auth=auth, tenant_id=id, data=data)
return SuccessResponse(data=result, msg="修改租户配额成功")
# ============ P1: 租户配置 ============
@TenantRouter.get(
"/{id}/config",
summary="获取租户配置",
description="获取指定租户的个性化配置",
response_model=ResponseSchema[list[TenantConfigOutSchema]],
)
async def get_tenant_config_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result = await TenantService.get_config_service(auth=auth, tenant_id=id)
return SuccessResponse(data=result, msg="获取租户配置成功")
@TenantRouter.get(
"/{id}/config/info",
summary="获取租户配置(公开-缓存)",
description="从 Redis 缓存获取租户个性化配置,无需登录(供登录页等场景使用)",
response_model=ResponseSchema[list[TenantConfigOutSchema]],
)
async def get_tenant_config_info_controller(
id: Annotated[int, Path(description="租户ID")],
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result = await TenantService.get_config_cache_service(redis=redis, tenant_id=id)
return SuccessResponse(data=result, msg="获取租户配置成功")
@TenantRouter.put(
"/{id}/config",
summary="更新租户配置",
description="批量更新租户的个性化配置",
response_model=ResponseSchema[list[TenantConfigOutSchema]],
)
async def update_tenant_config_controller(
id: Annotated[int, Path(description="租户ID")],
data: Annotated[list[TenantConfigItem], Body(..., description="配置项列表")],
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
result = await TenantService.update_config_service(
auth=auth, redis=redis, tenant_id=id, items=data
)
return SuccessResponse(data=result, msg="更新租户配置成功")
# ============ P1: 租户菜单权限 ============
@TenantRouter.get(
"/{id}/menus",
summary="获取租户菜单权限",
description="获取指定租户有权限访问的菜单ID列表",
response_model=ResponseSchema[list[int]],
)
async def get_tenant_menus_controller(
id: Annotated[int, Path(description="租户ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result = await TenantService.get_menus_service(auth=auth, tenant_id=id)
return SuccessResponse(data=result, msg="获取租户菜单成功")
@TenantRouter.put(
"/{id}/menus",
summary="设置租户菜单权限",
description="批量设置租户的菜单权限(先清空再写入,需超级管理员权限)",
)
async def set_tenant_menus_controller(
id: Annotated[int, Path(description="租户ID")],
data: TenantMenuSetSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
await TenantService.set_menus_service(auth=auth, tenant_id=id, data=data)
log.info(f"设置租户菜单权限成功: tenant_id={id}, count={len(data.menu_ids)}")
return SuccessResponse(msg="设置租户菜单权限成功")
@@ -1,59 +0,0 @@
from collections.abc import Sequence
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import TenantModel
from .schema import TenantCreateSchema, TenantOutSchema, TenantUpdateSchema
class TenantCRUD(CRUDBase[TenantModel, TenantCreateSchema, TenantUpdateSchema]):
"""租户数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=TenantModel, auth=auth)
async def get_by_id_crud(
self, id: int, preload: list[str | Any] | None = None
) -> TenantModel | None:
return await self.get(id=id, preload=preload)
async def get_list_crud(
self,
search: dict | None = None,
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
) -> Sequence[TenantModel]:
return await self.list(search=search, order_by=order_by, preload=preload)
async def page_crud(
self,
offset: int,
limit: int,
order_by: list[dict[str, str]] | None,
search: dict | None = None,
out_schema: type[TenantOutSchema] | None = None,
preload: list[str | Any] | None = None,
) -> dict:
return await self.page(
offset=offset,
limit=limit,
order_by=order_by or [{"id": "asc"}],
search=search or {},
out_schema=out_schema or TenantOutSchema,
preload=preload or [],
)
async def create_crud(self, data: TenantCreateSchema) -> TenantModel | None:
return await self.create(data=data)
async def update_crud(self, id: int, data: TenantUpdateSchema) -> TenantModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
await self.delete(ids=ids)
async def set_available_crud(self, ids: list[int], status: str) -> None:
await self.set(ids=ids, status=status)
@@ -1,250 +0,0 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import MappedBase, ModelMixin
class TenantModel(ModelMixin):
"""
租户模型
- 系统租户(id=1)平台管理由超级管理员维护
- 普通租户(id>1)独立组织数据通过业务表的 tenant_id 隔离
"""
__tablename__: str = "sys_tenant"
__table_args__: dict[str, str] = {"comment": "租户表"}
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DATA_SCOPE
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="租户名称")
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="租户编码")
contact_name: Mapped[str | None] = mapped_column(
String(64), nullable=True, default=None, comment="联系人姓名"
)
contact_phone: Mapped[str | None] = mapped_column(
String(20), nullable=True, default=None, comment="联系人电话"
)
contact_email: Mapped[str | None] = mapped_column(
String(128), nullable=True, default=None, comment="联系人邮箱"
)
address: Mapped[str | None] = mapped_column(
String(255), nullable=True, default=None, comment="地址"
)
domain: Mapped[str | None] = mapped_column(
String(255), nullable=True, default=None, comment="域名"
)
logo_url: Mapped[str | None] = mapped_column(
String(500), nullable=True, default=None, comment="Logo URL"
)
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
package_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("sys_tenant_package.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
default=None,
index=True,
comment="关联套餐ID",
)
start_time: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, default=None, comment="开始时间"
)
end_time: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, default=None, comment="结束时间"
)
@validates("name")
def validate_name(self, key: str, name: str) -> str:
if not name or not name.strip():
raise ValueError("名称不能为空")
return name
@validates("code")
def validate_code(self, key: str, code: str) -> str:
if not code or not code.strip():
raise ValueError("编码不能为空")
if not code.isalnum():
raise ValueError("编码只能包含字母和数字")
return code
class TenantUserModel(MappedBase):
"""
用户-租户关联表
支持一个用户关联多个租户如顾问在多个租户间切换
每个用户有一个默认租户is_default=1用于登录后的默认上下文
"""
__tablename__: str = "sys_user_tenant"
__table_args__ = (
UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),
{"comment": "用户租户关联表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_user.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="用户ID",
)
tenant_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="租户ID",
)
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="member",
comment="租户内角色(owner:拥有者 admin:管理员 member:成员)",
)
is_default: Mapped[int] = mapped_column(
SmallInteger,
nullable=False,
default=0,
comment="是否默认租户(0:否 1:是)",
)
create_time: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.now,
nullable=False,
comment="创建时间",
)
class TenantQuotaModel(MappedBase):
"""租户配额模型 — 限制租户资源使用上限"""
__tablename__: str = "sys_tenant_quota"
__table_args__: dict[str, str] = {"comment": "租户配额表"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
tenant_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="租户ID",
)
max_users: Mapped[int] = mapped_column(
Integer, nullable=False, default=50, comment="最大用户数"
)
max_roles: Mapped[int] = mapped_column(
Integer, nullable=False, default=20, comment="最大角色数"
)
max_storage_mb: Mapped[int] = mapped_column(
Integer, nullable=False, default=500, comment="最大存储(MB)"
)
max_depts: Mapped[int] = mapped_column(
Integer, nullable=False, default=50, comment="最大部门数"
)
tenant: Mapped["TenantModel"] = relationship("TenantModel", lazy="selectin")
class TenantConfigModel(MappedBase):
"""租户个性化配置模型 — 键值对存储"""
__tablename__: str = "sys_tenant_config"
__table_args__ = (
UniqueConstraint("tenant_id", "config_key", name="uq_tenant_config_key"),
{"comment": "租户配置表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
tenant_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="租户ID",
)
config_key: Mapped[str] = mapped_column(String(100), nullable=False, comment="配置键")
config_value: Mapped[str | None] = mapped_column(Text, nullable=True, comment="配置值")
config_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="string", comment="配置类型(string/json/int/bool)"
)
class TenantMenuModel(MappedBase):
"""租户菜单权限模型 — 控制租户可见的菜单项"""
__tablename__: str = "sys_tenant_menu"
__table_args__ = (
UniqueConstraint("tenant_id", "menu_id", name="uq_tenant_menu"),
{"comment": "租户菜单权限表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
tenant_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="租户ID",
)
menu_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="菜单ID",
)
class TenantPackageModel(MappedBase):
"""租户套餐表 — 预定义的功能套餐,简化租户授权"""
__tablename__: str = "sys_tenant_package"
__table_args__: dict[str, str] = {"comment": "租户套餐表"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐名称")
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐编码")
status: Mapped[str] = mapped_column(
String(10), nullable=False, default="0", comment="状态(0:正常 1:禁用)"
)
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
description: Mapped[str | None] = mapped_column(
String(255), nullable=True, default=None, comment="描述"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, nullable=False, comment="创建时间"
)
update_time: Mapped[datetime] = mapped_column(
DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间"
)
class TenantPackageMenuModel(MappedBase):
"""套餐-菜单关联表 — 定义套餐包含的菜单资源"""
__tablename__: str = "sys_tenant_package_menu"
__table_args__ = (
UniqueConstraint("package_id", "menu_id", name="uq_package_menu"),
{"comment": "套餐菜单关联表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
package_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_tenant_package.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="套餐ID",
)
menu_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"),
nullable=False,
index=True,
comment="菜单ID",
)
@@ -1,138 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .package_schema import (
TenantPackageCreateSchema,
TenantPackageMenuSetSchema,
TenantPackageOutSchema,
TenantPackageQueryParam,
TenantPackageUpdateSchema,
)
from .package_service import TenantPackageService
PackageRouter = APIRouter(
route_class=OperationLogRoute, prefix="/tenant/package", tags=["租户套餐管理"]
)
@PackageRouter.get(
"/detail/{id}",
summary="获取套餐详情",
description="获取套餐详情",
response_model=ResponseSchema[TenantPackageOutSchema],
)
async def get_package_detail_controller(
id: Annotated[int, Path(description="套餐ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result_dict = await TenantPackageService.detail_service(id=id, auth=auth)
log.info(f"获取套餐详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取套餐详情成功")
@PackageRouter.get(
"/list",
summary="查询套餐列表",
description="查询套餐列表(分页)",
response_model=ResponseSchema[dict],
)
async def get_package_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TenantPackageQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
order_by = [{"sort": "asc"}, {"id": "asc"}]
if page.order_by:
order_by = page.order_by
result_dict = await TenantPackageService.page_service(
auth=auth,
page_no=page.page_no if page.page_no is not None else 1,
page_size=page.page_size if page.page_size is not None else 10,
search=search,
order_by=order_by,
)
log.info("查询套餐列表成功")
return SuccessResponse(data=result_dict, msg="查询套餐列表成功")
@PackageRouter.post(
"/create",
summary="创建套餐",
description="创建套餐",
response_model=ResponseSchema[TenantPackageOutSchema],
)
async def create_package_controller(
data: TenantPackageCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))],
) -> JSONResponse:
result_dict = await TenantPackageService.create_service(auth=auth, data=data)
log.info(f"创建套餐成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="创建套餐成功")
@PackageRouter.put(
"/update/{id}",
summary="修改套餐",
description="修改套餐",
response_model=ResponseSchema[TenantPackageOutSchema],
)
async def update_package_controller(
data: TenantPackageUpdateSchema,
id: Annotated[int, Path(description="套餐ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
result_dict = await TenantPackageService.update_service(auth=auth, id=id, data=data)
log.info(f"修改套餐成功: {result_dict.get('name')}")
return SuccessResponse(data=result_dict, msg="修改套餐成功")
@PackageRouter.delete(
"/delete",
summary="删除套餐",
description="删除套餐",
)
async def delete_package_controller(
ids: Annotated[list[int], Body(..., description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))],
) -> JSONResponse:
await TenantPackageService.delete_service(auth=auth, ids=ids)
log.info(f"删除套餐成功: {ids}")
return SuccessResponse(msg="删除套餐成功")
@PackageRouter.get(
"/{id}/menus",
summary="获取套餐菜单权限",
description="获取指定套餐包含的菜单ID列表",
response_model=ResponseSchema[list[int]],
)
async def get_package_menus_controller(
id: Annotated[int, Path(description="套餐ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
) -> JSONResponse:
result = await TenantPackageService.get_menus_service(auth=auth, package_id=id)
return SuccessResponse(data=result, msg="获取套餐菜单成功")
@PackageRouter.put(
"/{id}/menus",
summary="设置套餐菜单权限",
description="批量设置套餐的菜单权限(先清空再写入)",
)
async def set_package_menus_controller(
id: Annotated[int, Path(description="套餐ID")],
data: TenantPackageMenuSetSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
) -> JSONResponse:
await TenantPackageService.set_menus_service(auth=auth, package_id=id, data=data)
log.info(f"设置套餐菜单权限成功: package_id={id}, count={len(data.menu_ids)}")
return SuccessResponse(msg="设置套餐菜单权限成功")
@@ -1,64 +0,0 @@
from collections.abc import Sequence
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import TenantPackageModel
from .package_schema import (
TenantPackageCreateSchema,
TenantPackageOutSchema,
TenantPackageUpdateSchema,
)
class TenantPackageCRUD(
CRUDBase[TenantPackageModel, TenantPackageCreateSchema, TenantPackageUpdateSchema]
):
"""租户套餐数据层"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=TenantPackageModel, auth=auth)
async def get_by_id_crud(
self, id: int, preload: list[str | Any] | None = None
) -> TenantPackageModel | None:
return await self.get(id=id, preload=preload)
async def get_list_crud(
self,
search: dict | None = None,
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
) -> Sequence[TenantPackageModel]:
return await self.list(search=search, order_by=order_by, preload=preload)
async def page_crud(
self,
offset: int,
limit: int,
order_by: list[dict[str, str]] | None,
search: dict | None = None,
out_schema: type[TenantPackageOutSchema] | None = None,
preload: list[str | Any] | None = None,
) -> dict:
return await self.page(
offset=offset,
limit=limit,
order_by=order_by or [{"sort": "asc"}, {"id": "asc"}],
search=search or {},
out_schema=out_schema or TenantPackageOutSchema,
preload=preload or [],
)
async def create_crud(self, data: TenantPackageCreateSchema) -> TenantPackageModel | None:
return await self.create(data=data)
async def update_crud(
self, id: int, data: TenantPackageUpdateSchema
) -> TenantPackageModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
await self.delete(ids=ids)
@@ -1,90 +0,0 @@
from fastapi import Query
from pydantic import BaseModel, Field, field_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema
from app.core.validator import DateTimeStr
class TenantPackageCreateSchema(BaseModel):
"""新增租户套餐"""
name: str = Field(..., max_length=100, description="套餐名称")
code: str = Field(..., max_length=100, description="套餐编码")
status: str = Field(default="0", description="状态(0:正常 1:禁用)")
sort: int = Field(default=0, description="排序")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator("name")
@classmethod
def _validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("名称不能为空")
return v
@field_validator("code")
@classmethod
def _validate_code(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("编码不能为空")
if not v.isalnum():
raise ValueError("编码只能包含字母和数字")
return v
class TenantPackageUpdateSchema(BaseModel):
"""更新租户套餐"""
name: str | None = Field(default=None, max_length=100, description="套餐名称")
code: str | None = Field(default=None, max_length=100, description="套餐编码")
status: str | None = Field(default=None, description="状态(0:正常 1:禁用)")
sort: int | None = Field(default=None, description="排序")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator("code")
@classmethod
def _validate_code(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v.isalnum():
raise ValueError("编码只能包含字母和数字")
return v
class TenantPackageOutSchema(BaseSchema):
"""套餐响应"""
id: int
name: str
code: str
status: str
sort: int
description: str | None
create_time: DateTimeStr | None
update_time: DateTimeStr | None
class TenantPackageQueryParam:
"""套餐查询参数"""
def __init__(
self,
name: str | None = Query(None, description="套餐名称"),
code: str | None = Query(None, description="套餐编码"),
status: str | None = Query(None, description="状态"),
) -> None:
if name:
self.name = (QueueEnum.like.value, name)
if code:
self.code = (QueueEnum.like.value, code)
if status:
self.status = (QueueEnum.eq.value, status)
class TenantPackageMenuSetSchema(BaseModel):
"""批量设置套餐菜单权限"""
menu_ids: list[int] = Field(..., description="菜单ID列表")
@@ -1,183 +0,0 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import log
from .model import TenantPackageMenuModel
from .package_crud import TenantPackageCRUD
from .package_schema import (
TenantPackageCreateSchema,
TenantPackageMenuSetSchema,
TenantPackageOutSchema,
TenantPackageQueryParam,
TenantPackageUpdateSchema,
)
class TenantPackageService:
"""租户套餐模块服务层"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await TenantPackageCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="套餐不存在")
return TenantPackageOutSchema.model_validate(obj).model_dump()
@classmethod
async def page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: TenantPackageQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> dict:
return await TenantPackageCRUD(auth).page_crud(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"sort": "asc"}, {"id": "asc"}],
search=search.__dict__ if search else {},
out_schema=TenantPackageOutSchema,
)
@classmethod
async def create_service(cls, auth: AuthSchema, data: TenantPackageCreateSchema) -> dict:
if await TenantPackageCRUD(auth).get(name=data.name):
raise CustomException(msg="创建失败,套餐名称已存在")
if await TenantPackageCRUD(auth).get(code=data.code):
raise CustomException(msg="创建失败,套餐编码已存在")
obj = await TenantPackageCRUD(auth).create_crud(data=data)
if not obj:
raise CustomException(msg="创建套餐失败")
result = TenantPackageOutSchema.model_validate(obj).model_dump()
log.info(f"创建套餐成功: {result.get('name')}")
return result
@classmethod
async def update_service(
cls, auth: AuthSchema, id: int, data: TenantPackageUpdateSchema
) -> dict:
obj = await TenantPackageCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="套餐不存在")
if data.name is not None:
exist = await TenantPackageCRUD(auth).get(name=data.name)
if exist and exist.id != id:
raise CustomException(msg="更新失败,名称重复")
if data.code is not None:
exist = await TenantPackageCRUD(auth).get(code=data.code)
if exist and exist.id != id:
raise CustomException(msg="更新失败,编码重复")
updated = await TenantPackageCRUD(auth).update_crud(id=id, data=data)
if not updated:
raise CustomException(msg="更新失败")
return TenantPackageOutSchema.model_validate(updated).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
from sqlalchemy import func, select
from .model import TenantModel
for pid in ids:
stmt = (
select(func.count()).select_from(TenantModel).where(TenantModel.package_id == pid)
)
result = await auth.db.execute(stmt)
count = result.scalar()
if count and count > 0:
raise CustomException(msg=f"套餐 ID={pid} 已被 {count} 个租户使用,无法删除")
await TenantPackageCRUD(auth).delete_crud(ids=ids)
@classmethod
async def get_menus_service(cls, auth: AuthSchema, package_id: int) -> list[int]:
"""获取套餐菜单权限(返回 menu_id 列表)"""
from sqlalchemy import select
stmt = select(TenantPackageMenuModel.menu_id).where(
TenantPackageMenuModel.package_id == package_id
)
result = await auth.db.execute(stmt)
return [row[0] for row in result.all()]
@classmethod
async def set_menus_service(
cls, auth: AuthSchema, package_id: int, data: TenantPackageMenuSetSchema
) -> None:
"""批量设置套餐菜单权限(先清空再写入)"""
from sqlalchemy import delete
await auth.db.execute(
delete(TenantPackageMenuModel).where(TenantPackageMenuModel.package_id == package_id)
)
for menu_id in data.menu_ids:
auth.db.add(TenantPackageMenuModel(package_id=package_id, menu_id=menu_id))
await auth.db.flush()
log.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}")
@staticmethod
async def get_package_menu_ids(auth: AuthSchema, package_id: int) -> list[int]:
"""获取套餐包含的菜单ID列表(供租户权限约束使用)"""
from sqlalchemy import select
stmt = select(TenantPackageMenuModel.menu_id).where(
TenantPackageMenuModel.package_id == package_id,
)
result = await auth.db.execute(stmt)
ids = [row[0] for row in result.all()]
return ids
@staticmethod
async def get_tenant_available_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户的完整可用菜单ID列表(套餐菜单 + 自定义授权菜单)
合并逻辑
1. 如果租户关联了套餐且套餐状态正常(status=0)取套餐包含的所有菜单
2. 如果套餐被禁用(status=1)跳过套餐菜单
3. 再取 sys_tenant_menu 中显式授权的菜单
4. 返回两者的并集
"""
from sqlalchemy import select
from .model import TenantMenuModel, TenantPackageModel, TenantModel
# 查询租户信息
stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1)
result = await auth.db.execute(stmt)
tenant = result.scalar_one_or_none()
if not tenant:
return []
all_menu_ids: set[int] = set()
# 1. 如果有关联套餐且套餐状态正常,获取套餐包含的菜单
if tenant.package_id:
pkg_stmt = select(TenantPackageModel.status).where(
TenantPackageModel.id == tenant.package_id
).limit(1)
pkg_result = await auth.db.execute(pkg_stmt)
pkg_status = pkg_result.scalar_one_or_none()
if pkg_status == "0": # 仅正常套餐计入
stmt = select(TenantPackageMenuModel.menu_id).where(
TenantPackageMenuModel.package_id == tenant.package_id
)
result = await auth.db.execute(stmt)
for row in result.all():
all_menu_ids.add(row[0])
# 2. 获取自定义授权的菜单(sys_tenant_menu
stmt = select(TenantMenuModel.menu_id).where(
TenantMenuModel.tenant_id == tenant_id,
)
result = await auth.db.execute(stmt)
for row in result.all():
all_menu_ids.add(row[0])
return list(all_menu_ids)
@@ -1,252 +0,0 @@
from fastapi import Query
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema
from app.core.validator import DateTimeStr, email_validator, mobile_validator
class TenantCreateSchema(BaseModel):
"""新增租户"""
name: str = Field(..., min_length=1, max_length=100, description="租户名称")
code: str = Field(..., min_length=2, max_length=100, description="租户编码")
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述")
start_time: DateTimeStr | None = Field(default=None, description="开始时间")
end_time: DateTimeStr | None = Field(default=None, description="结束时间")
contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名")
contact_phone: str | None = Field(default=None, max_length=20, description="联系人电话")
contact_email: str | None = Field(default=None, max_length=128, description="联系人邮箱")
address: str | None = Field(default=None, max_length=255, description="地址")
domain: str | None = Field(default=None, max_length=255, description="域名")
logo_url: str | None = Field(default=None, max_length=500, description="Logo URL")
sort: int = Field(default=0, ge=0, description="排序")
package_id: int | None = Field(default=None, gt=0, description="关联套餐ID")
@field_validator("name")
@classmethod
def _validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("租户名称不能为空")
return v
@field_validator("code")
@classmethod
def _validate_code(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("租户编码不能为空")
if not v.isalnum():
raise ValueError("租户编码仅允许字母和数字")
return v
@field_validator("status")
@classmethod
def _validate_status(cls, v: str) -> str:
if v not in {"0", "1"}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
@field_validator("contact_phone")
@classmethod
def _validate_contact_phone(cls, v: str | None) -> str | None:
return mobile_validator(v)
@field_validator("contact_email")
@classmethod
def _validate_contact_email(cls, v: str | None) -> str | None:
if not v:
return v
return email_validator(v)
@model_validator(mode="after")
def _validate_time_range(self):
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("结束时间不能早于开始时间")
return self
class TenantUpdateSchema(BaseModel):
"""更新租户"""
name: str | None = Field(default=None, max_length=100, description="租户名称")
code: str | None = Field(default=None, max_length=100, description="租户编码")
status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述")
start_time: DateTimeStr | None = Field(default=None, description="开始时间")
end_time: DateTimeStr | None = Field(default=None, description="结束时间")
contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名")
contact_phone: str | None = Field(default=None, max_length=20, description="联系人电话")
contact_email: str | None = Field(default=None, max_length=128, description="联系人邮箱")
address: str | None = Field(default=None, max_length=255, description="地址")
domain: str | None = Field(default=None, max_length=255, description="域名")
logo_url: str | None = Field(default=None, max_length=500, description="Logo URL")
sort: int | None = Field(default=None, ge=0, description="排序")
package_id: int | None = Field(default=None, gt=0, description="关联套餐ID")
@field_validator("code")
@classmethod
def _validate_code(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v.isalnum():
raise ValueError("租户编码仅允许字母和数字")
return v
@field_validator("status")
@classmethod
def _validate_status(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in {"0", "1"}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
@field_validator("contact_phone")
@classmethod
def _validate_contact_phone(cls, v: str | None) -> str | None:
return mobile_validator(v)
@field_validator("contact_email")
@classmethod
def _validate_contact_email(cls, v: str | None) -> str | None:
if not v:
return v
return email_validator(v)
@model_validator(mode="after")
def _validate_time_range(self):
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("结束时间不能早于开始时间")
return self
class TenantOutSchema(TenantCreateSchema, BaseSchema):
"""租户响应"""
model_config = ConfigDict(from_attributes=True)
class TenantQueryParam:
"""租户查询参数"""
def __init__(
self,
name: str | None = Query(None, description="租户名称"),
code: str | None = Query(None, description="租户编码"),
status: str | None = Query(None, description="状态"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
) -> None:
if name:
self.name = (QueueEnum.like.value, name)
if code:
self.code = (QueueEnum.like.value, code)
if status:
self.status = (QueueEnum.eq.value, status)
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
class TenantUserAddSchema(BaseModel):
"""向租户添加用户"""
user_id: int = Field(..., gt=0, description="用户ID")
role: str = Field(default="member", max_length=20, description="租户内角色(owner/admin/member)")
is_default: int = Field(default=0, ge=0, le=1, description="是否默认租户(0:否 1:是)")
@field_validator("role")
@classmethod
def _validate_role(cls, v: str) -> str:
if v not in {"owner", "admin", "member"}:
raise ValueError("租户角色仅支持 owner(拥有者)、admin(管理员)、member(成员)")
return v
@field_validator("is_default")
@classmethod
def _validate_is_default(cls, v: int) -> int:
if v not in {0, 1}:
raise ValueError("是否默认仅支持 0(否) 或 1(是)")
return v
class TenantUserOutSchema(BaseModel):
"""租户用户响应"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="关联ID")
user_id: int = Field(..., description="用户ID")
tenant_id: int = Field(..., description="租户ID")
role: str = Field(..., description="租户内角色")
is_default: int = Field(..., description="是否默认租户")
create_time: DateTimeStr | None = Field(default=None, description="创建时间")
username: str = Field(default="", description="用户名")
name: str = Field(default="", description="用户姓名")
# ============ P1: 配额管理 ============
class TenantQuotaOutSchema(BaseModel):
"""租户配额响应"""
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
max_users: int
max_roles: int
max_storage_mb: int
max_depts: int
class TenantQuotaUpdateSchema(BaseModel):
"""租户配额更新"""
max_users: int | None = Field(default=None, ge=0, description="最大用户数")
max_roles: int | None = Field(default=None, ge=0, description="最大角色数")
max_storage_mb: int | None = Field(default=None, ge=0, description="最大存储(MB)")
max_depts: int | None = Field(default=None, ge=0, description="最大部门数")
# ============ P1: 租户配置 ============
class TenantConfigItem(BaseModel):
"""单个配置项"""
config_key: str = Field(..., min_length=1, max_length=100, description="配置键")
config_value: str = Field(..., max_length=65535, description="配置值")
config_type: str = Field(default="string", max_length=20, description="配置类型(string/json/int/bool)")
@field_validator("config_type")
@classmethod
def _validate_config_type(cls, v: str) -> str:
if v not in {"string", "json", "int", "bool"}:
raise ValueError("配置类型仅支持 string、json、int、bool")
return v
class TenantConfigOutSchema(TenantConfigItem):
"""租户配置响应"""
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
# ============ P1: 租户菜单 ============
class TenantMenuSetSchema(BaseModel):
"""批量设置租户菜单权限"""
menu_ids: list[int] = Field(..., description="菜单ID列表")
@@ -1,695 +0,0 @@
import json
import random
import string
import sqlalchemy as sa
from redis.asyncio.client import Redis
from app.api.v1.module_system.auth.schema import AuthSchema
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
from app.common.enums import RedisInitKeyConfig
from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException
from app.core.logger import log
from app.core.redis_crud import RedisCURD
from app.utils.hash_bcrpy_util import PwdUtil
from .crud import TenantCRUD
from .model import (
TenantConfigModel,
TenantMenuModel,
TenantModel,
TenantQuotaModel,
TenantUserModel,
)
from .schema import (
TenantConfigItem,
TenantConfigOutSchema,
TenantCreateSchema,
TenantMenuSetSchema,
TenantOutSchema,
TenantQueryParam,
TenantQuotaOutSchema,
TenantQuotaUpdateSchema,
TenantUpdateSchema,
TenantUserAddSchema,
TenantUserOutSchema,
)
class TenantService:
"""租户管理模块服务层"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="租户不存在")
result = TenantOutSchema.model_validate(obj).model_dump()
return result
@classmethod
async def page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: TenantQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> dict:
return await TenantCRUD(auth).page_crud(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search.__dict__ if search else {},
out_schema=TenantOutSchema,
)
@classmethod
async def create_service(cls, auth: AuthSchema, data: TenantCreateSchema) -> dict:
if await TenantCRUD(auth).get(name=data.name):
raise CustomException(msg="创建失败,名称已存在")
if await TenantCRUD(auth).get(code=data.code):
raise CustomException(msg="创建失败,编码已存在")
tenant_obj = await TenantCRUD(auth).create_crud(data=data)
if not tenant_obj:
raise CustomException(msg="创建租户失败")
# 创建租户初始管理员
# 1. 生成初始管理员用户名
# 2. 检查用户名是否已存在
# 3. 创建初始管理员用户
username = f"{tenant_obj.code}_admin"
if await UserCRUD(auth).get_by_username_crud(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.set_password_hash(password=password),
"name": f"{tenant_obj.name}管理员",
"tenant_id": tenant_obj.id,
"status": "0",
"is_superuser": False,
}
try:
user_obj = await UserCRUD(auth).create(data=admin_data)
if not user_obj:
raise CustomException(msg="创建租户初始管理员失败")
except CustomException:
raise
except Exception as e:
log.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}")
raise CustomException(msg="创建租户初始管理员失败")
log.info(
f"为租户[{tenant_obj.name}]创建初始管理员成功,用户名: {username},临时密码: {password}"
)
await auth.db.refresh(tenant_obj)
result = TenantOutSchema.model_validate(tenant_obj).model_dump()
# P1: 自动初始化租户配额
quota = TenantQuotaModel(tenant_id=tenant_obj.id)
auth.db.add(quota)
await auth.db.flush()
return result
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> dict:
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="租户不存在")
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 auth.user or not auth.user.is_superuser:
raise CustomException(msg="仅平台管理员可变更租户套餐")
if data.name is not None:
exist = await TenantCRUD(auth).get(name=data.name)
if exist and exist.id != id:
raise CustomException(msg="更新失败,名称重复")
if data.code is not None:
exist = await TenantCRUD(auth).get(code=data.code)
if exist and exist.id != id:
raise CustomException(msg="更新失败,编码重复")
updated = await TenantCRUD(auth).update_crud(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_system.role.model import RoleMenusModel, RoleModel
from .package_service import TenantPackageService
available_ids = await TenantPackageService.get_tenant_available_menu_ids(auth, id)
if available_ids:
role_ids_stmt = select(RoleModel.id).where(RoleModel.tenant_id == id)
result = await auth.db.execute(role_ids_stmt)
tenant_role_ids = [row[0] for row in result.all()]
if tenant_role_ids:
await auth.db.execute(
sa_delete(RoleMenusModel).where(
RoleMenusModel.role_id.in_(tenant_role_ids),
RoleMenusModel.menu_id.notin_(available_ids),
)
)
await auth.db.flush()
log.info(
f"租户[{id}]套餐变更:已清理角色中不再可用的菜单关联, "
f"available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}"
)
result = TenantOutSchema.model_validate(updated).model_dump()
return result
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
if 1 in ids:
raise CustomException(msg="系统租户不允许删除")
for id in ids:
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
if not obj:
continue
for tid in ids:
reasons: list[str] = []
if await UserCRUD(auth).list(search={"tenant_id": tid}):
reasons.append("用户")
if await DeptCRUD(auth).list(search={"tenant_id": tid}):
reasons.append("部门")
if await RoleCRUD(auth).list(search={"tenant_id": tid}):
reasons.append("角色")
if await PositionCRUD(auth).list(search={"tenant_id": tid}):
reasons.append("岗位")
if reasons:
raise CustomException(
msg=f"租户 ID={tid} 下仍有关联数据({','.join(reasons)}),请先清理后再删除"
)
await TenantCRUD(auth).delete_crud(ids=ids)
@classmethod
async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None:
if data.status == "1" and 1 in data.ids:
raise CustomException(msg="系统租户不允许禁用")
await TenantCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
@classmethod
async def toggle_status_service(cls, auth: AuthSchema, id: int) -> None:
"""切换单个租户的启用/禁用状态"""
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="租户不存在")
if id == 1:
raise CustomException(msg="系统租户不允许禁用")
new_status = "0" if obj.status == "1" else "1"
await TenantCRUD(auth).set_available_crud(ids=[id], status=new_status)
@classmethod
async def get_tenant_users_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]:
"""获取租户下的用户列表"""
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 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,
).model_dump()
)
return users
@classmethod
async def add_tenant_user_service(
cls, auth: AuthSchema, tenant_id: int, data: TenantUserAddSchema
) -> None:
"""向租户添加用户"""
# 验证租户存在
tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
# 验证用户存在
from app.api.v1.module_system.user.crud import UserCRUD
user = await UserCRUD(auth).get_by_id_crud(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 auth.db.execute(exist_stmt)
if result.scalar_one_or_none():
raise CustomException(msg="该用户已关联此租户")
# 如果设为默认租户,先取消其他默认
if data.is_default == 1:
await 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 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(),
)
auth.db.add(tu)
await auth.db.flush()
log.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}")
@classmethod
async def remove_tenant_user_service(
cls, auth: AuthSchema, tenant_id: int, user_id: int
) -> None:
"""从租户移除用户"""
from sqlalchemy import select
# 查找关联记录
exist_stmt = (
select(TenantUserModel)
.where(
TenantUserModel.user_id == user_id,
TenantUserModel.tenant_id == tenant_id,
)
.limit(1)
)
result = await 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 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 auth.db.delete(tu)
await auth.db.flush()
log.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功")
# ============ P1: 配额管理 ============
@classmethod
async def get_quota_service(cls, auth: AuthSchema, tenant_id: int) -> dict:
"""获取租户配额"""
from sqlalchemy import select
stmt = select(TenantQuotaModel).where(TenantQuotaModel.tenant_id == tenant_id).limit(1)
result = await auth.db.execute(stmt)
quota = result.scalar_one_or_none()
if not quota:
quota = TenantQuotaModel(tenant_id=tenant_id)
auth.db.add(quota)
await auth.db.flush()
return TenantQuotaOutSchema.model_validate(quota).model_dump()
@classmethod
async def update_quota_service(
cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema
) -> dict:
"""更新租户配额"""
from sqlalchemy import select
stmt = select(TenantQuotaModel).where(TenantQuotaModel.tenant_id == tenant_id).limit(1)
result = await auth.db.execute(stmt)
quota = result.scalar_one_or_none()
if not quota:
quota = TenantQuotaModel(tenant_id=tenant_id)
auth.db.add(quota)
await auth.db.flush()
update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(quota, k, v)
await auth.db.flush()
log.info(f"租户[{tenant_id}]配额已更新: {update_data}")
return TenantQuotaOutSchema.model_validate(quota).model_dump()
@classmethod
async def check_quota_service(
cls,
auth: AuthSchema,
tenant_id: int,
resource_type: str, # "user"/"role"/"dept"
) -> None:
"""检查租户配额是否充足,不足时抛出异常"""
from sqlalchemy import func, select
quota = await cls.get_quota_obj(auth, tenant_id)
field_map = {
"user": "max_users",
"role": "max_roles",
"dept": "max_depts",
}
if resource_type not in field_map:
return
max_field = field_map[resource_type]
max_limit = getattr(quota, max_field, 0)
# 根据资源类型动态获取当前数量
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),
)
result = await auth.db.execute(count_stmt)
current_count = result.scalar() or 0
if max_limit > 0 and current_count >= max_limit:
resource_labels = {"user": "用户", "role": "角色", "dept": "部门"}
raise CustomException(
msg=f"租户{resource_labels.get(resource_type, resource_type)}数量已达上限({max_limit}),无法继续创建"
)
@classmethod
async def get_quota_obj(cls, auth: AuthSchema, tenant_id: int) -> TenantQuotaModel:
"""获取租户配额对象,不存在则自动初始化"""
from sqlalchemy import select
stmt = select(TenantQuotaModel).where(TenantQuotaModel.tenant_id == tenant_id).limit(1)
result = await auth.db.execute(stmt)
quota = result.scalar_one_or_none()
if not quota:
quota = TenantQuotaModel(tenant_id=tenant_id)
auth.db.add(quota)
await auth.db.flush()
return quota
# ============ P1: 租户配置 ============
@classmethod
async def get_config_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]:
"""获取租户所有配置(带 Redis 缓存)"""
from sqlalchemy import select
stmt = select(TenantConfigModel).where(TenantConfigModel.tenant_id == tenant_id)
result = await auth.db.execute(stmt)
configs = result.scalars().all()
return [TenantConfigOutSchema.model_validate(c).model_dump() for c in configs]
@classmethod
async def get_config_cache_service(cls, redis: Redis, tenant_id: int) -> list[dict]:
"""
Redis 缓存获取租户配置缓存未命中则从 DB 加载并回写缓存
参数:
- redis (Redis): Redis 客户端实例
- tenant_id (int): 租户ID
返回:
- list[dict]: 租户配置列表
"""
redis_keys = await RedisCURD(redis).get_keys(
f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:*"
)
redis_configs = await RedisCURD(redis).mget(redis_keys)
configs = []
for config in redis_configs:
if not config:
continue
try:
configs.append(json.loads(config))
except Exception as e:
log.error(f"解析租户配置数据失败: {e}")
continue
if not configs:
log.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.api.v1.module_system.auth.schema import AuthSchema
auth = AuthSchema(db=session, check_data_scope=False)
configs = await cls.get_config_service(auth, tenant_id)
await cls._sync_configs_to_redis(redis, tenant_id, configs)
log.info(f"✅ 已从数据库加载 {len(configs)} 条租户配置到缓存")
return configs
@classmethod
async def _sync_configs_to_redis(
cls, redis: Redis, tenant_id: int, configs: list[dict]
) -> None:
"""将租户配置列表批量写入 Redis 缓存"""
for cfg in configs:
redis_key = (
f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{cfg.get('config_key')}"
)
value = json.dumps(cfg, ensure_ascii=False)
await RedisCURD(redis).set(key=redis_key, value=value, expire=None)
@classmethod
async def _del_configs_from_redis(cls, redis: Redis, tenant_id: int, keys: list[str]) -> None:
"""删除租户配置的 Redis 缓存"""
redis_keys = [f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{k}" for k in keys]
if redis_keys:
await RedisCURD(redis).delete(*redis_keys)
@classmethod
async def update_config_service(
cls, auth: AuthSchema, redis: Redis, tenant_id: int, items: list[TenantConfigItem]
) -> list[dict]:
"""批量更新租户配置(同步 Redis 缓存)"""
from sqlalchemy import select
for item in items:
stmt = (
select(TenantConfigModel)
.where(
TenantConfigModel.tenant_id == tenant_id,
TenantConfigModel.config_key == item.config_key,
)
.limit(1)
)
result = await auth.db.execute(stmt)
cfg = result.scalar_one_or_none()
if cfg:
cfg.config_value = item.config_value
if item.config_type:
cfg.config_type = item.config_type
else:
cfg = TenantConfigModel(
tenant_id=tenant_id,
config_key=item.config_key,
config_value=item.config_value,
config_type=item.config_type or "string",
)
auth.db.add(cfg)
await auth.db.flush()
# 刷新 DB 数据并同步到 Redis
configs = await cls.get_config_service(auth, tenant_id)
await cls._sync_configs_to_redis(redis, tenant_id, configs)
log.info(f"租户[{tenant_id}]配置已更新, keys={[i.config_key for i in items]}")
return configs
# ============ P1: 租户菜单 ============
@classmethod
async def get_menus_service(cls, auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户菜单权限(返回 menu_id 列表)"""
from sqlalchemy import select
stmt = select(TenantMenuModel.menu_id).where(TenantMenuModel.tenant_id == tenant_id)
result = await auth.db.execute(stmt)
return [row[0] for row in result.all()]
@classmethod
async def set_menus_service(
cls, auth: AuthSchema, tenant_id: int, data: TenantMenuSetSchema
) -> None:
"""批量设置租户菜单权限(先清空再写入)"""
from sqlalchemy import delete
await auth.db.execute(delete(TenantMenuModel).where(TenantMenuModel.tenant_id == tenant_id))
for menu_id in data.menu_ids:
auth.db.add(TenantMenuModel(tenant_id=tenant_id, menu_id=menu_id))
await auth.db.flush()
log.info(f"租户[{tenant_id}]菜单权限已设置, count={len(data.menu_ids)}")
@staticmethod
async def get_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户可用菜单ID列表(套餐菜单 + 自定义授权菜单的并集)
供角色/用户权限约束使用
"""
from .package_service import TenantPackageService
return await TenantPackageService.get_tenant_available_menu_ids(auth, tenant_id)
# ============ P1: 初始化缓存 ============
@classmethod
async def init_tenant_config_cache(cls, 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_stmt = select(TenantConfigModel).where(
TenantConfigModel.tenant_id == tenant.id
)
config_result = await session.execute(config_stmt)
configs = config_result.scalars().all()
config_list = [
TenantConfigOutSchema.model_validate(c).model_dump() for c in configs
]
if config_list:
await cls._sync_configs_to_redis(redis, tenant.id, config_list)
log.info(
f"✅ 租户[{tenant.name}](id={tenant.id}) {len(config_list)} 条配置已缓存到 Redis"
)
else:
log.warning(f"⚠️ 租户[{tenant.name}](id={tenant.id}) 无配置数据,跳过缓存")
# ============ P1: 到期提醒 ============
@staticmethod
async def check_tenant_expiry() -> None:
"""定时任务:检查租户到期并发送通知 / 自动禁用"""
from datetime import datetime, timedelta
from app.core.db_session import async_session_factory
async with async_session_factory() as db:
now = datetime.now()
# 扫描所有启用的租户
stmt = sa.select(TenantModel).where(
TenantModel.status == "0",
TenantModel.end_time.isnot(None),
)
result = await db.execute(stmt)
tenants = result.scalars().all()
for t in tenants:
if t.end_time <= now:
# 已到期:自动禁用
await db.execute(
sa.update(TenantModel).where(TenantModel.id == t.id).values(status="1")
)
log.info(f"租户[{t.name}]已到期,自动禁用")
elif t.end_time <= now + timedelta(days=1):
TenantService._notify_expiry(t, 1)
elif t.end_time <= now + timedelta(days=7):
TenantService._notify_expiry(t, 7)
elif t.end_time <= now + timedelta(days=30):
TenantService._notify_expiry(t, 30)
await db.commit()
@staticmethod
async def _notify_expiry(tenant: TenantModel, days: int) -> None:
"""发送到期提醒通知"""
log.info(f"租户[{tenant.name}]将在 {days} 天后到期,联系人: {tenant.contact_email}")
@@ -1,91 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .schema import (
TicketBatchSchema,
TicketCreateSchema,
TicketOutSchema,
TicketQueryParam,
TicketUpdateSchema,
)
from .service import TicketService
TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"])
@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[dict])
async def ticket_list(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TicketQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))],
):
result = await TicketService.page_service(
auth=auth,
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.get(
"/detail/{id}", summary="工单详情", response_model=ResponseSchema[TicketOutSchema]
)
async def ticket_detail(
id: Annotated[int, Path()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))],
):
result = await TicketService.detail_service(auth=auth, id=id)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.post("/create", summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
async def ticket_create(
data: TicketCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:create"]))],
):
result = await TicketService.create_service(auth=auth, data=data)
log.info(f"创建工单: {data.title}")
return SuccessResponse(data=result, msg="创建成功")
@TicketRouter.put(
"/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema]
)
async def ticket_update(
id: Annotated[int, Path()],
data: TicketUpdateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))],
):
result = await TicketService.update_service(auth=auth, id=id, data=data)
log.info(f"更新工单: {id}")
return SuccessResponse(data=result, msg="更新成功")
@TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema)
async def ticket_batch_update(
data: TicketBatchSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))],
):
await TicketService.batch_service(auth=auth, data=data)
log.info(f"批量更新工单状态: {data.ids} -> {data.status}")
return SuccessResponse(msg="批量更新成功")
@TicketRouter.delete("/delete", summary="删除工单")
async def ticket_delete(
ids: Annotated[list[int], Body()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:delete"]))],
):
await TicketService.delete_service(auth=auth, ids=ids)
log.info(f"删除工单: {ids}")
return SuccessResponse(msg="删除成功")
@@ -1,51 +0,0 @@
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import TicketModel
from .schema import TicketCreateSchema, TicketUpdateSchema
class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
"""工单 CRUD"""
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth
super().__init__(model=TicketModel, auth=auth)
async def page_crud(
self,
offset: int,
limit: int,
order_by: list[dict[str, str]] | None,
search: dict | None = None,
out_schema: type | None = None,
preload: list[str | Any] | None = None,
) -> dict:
from .schema import TicketOutSchema
return await self.page(
offset=offset,
limit=limit,
order_by=order_by or [{"id": "asc"}],
search=search or {},
out_schema=out_schema or TicketOutSchema,
preload=preload,
)
async def get_by_id_crud(self, id: int) -> TicketModel | None:
return await self.get(id=id)
async def create_crud(self, data: TicketCreateSchema) -> TicketModel | None:
return await self.create(data=data)
async def update_crud(self, id: int, data: TicketUpdateSchema) -> TicketModel | None:
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
await self.delete(ids=ids)
async def set_crud(self, ids: list[int], **kwargs) -> None:
"""批量设置工单状态"""
await self.set(ids=ids, **kwargs)
@@ -1,63 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.user.model import UserModel
class TicketModel(ModelMixin, TenantMixin, UserMixin):
"""工单模型 — 用户提交的建议和反馈"""
__tablename__: str = "sys_ticket"
__table_args__: dict[str, str] = {"comment": "工单表"}
__loader_options__: list[str] = ["created_by", "updated_by", "assigned_by"]
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="工单标题")
ticket_content: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="工单内容(富文本)"
)
summary: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="工单内容(纯文本摘要)"
)
ticket_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="suggestion",
comment="工单类型(suggestion:建议 bug:缺陷 optimize:优化 other:其他)",
)
status: Mapped[str] = mapped_column(
String(10), nullable=False, default="0", comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)"
)
images: Mapped[str | None] = mapped_column(Text, nullable=True, comment="图片URL列表(JSON数组)")
reply: Mapped[str | None] = mapped_column(Text, nullable=True, comment="回复内容")
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单描述")
assigned_id: Mapped[int | None] = mapped_column(
ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
comment="处理人ID",
)
# 处理人关联关系
assigned_by: Mapped["UserModel | None"] = relationship(
"UserModel",
foreign_keys=[assigned_id],
lazy="selectin",
uselist=False,
)
@validates("title")
def validate_title(self, key: str, title: str) -> str:
if not title or not title.strip():
raise ValueError("工单标题不能为空")
return title.strip()
@validates("summary", "ticket_content")
def validate_content(self, key: str, content: str | None) -> str | None:
if content and content.strip():
return content.strip()
return content
@@ -1,126 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import CommonSchema
from app.core.validator import DateTimeStr
class TicketCreateSchema(BaseModel):
"""创建工单"""
title: str = Field(..., min_length=1, max_length=200, description="工单标题")
ticket_content: str = Field(default="", description="工单内容(富文本)")
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
ticket_type: str = Field(
default="suggestion", max_length=20, description="工单类型(suggestion/bug/optimize/other)"
)
images: str | None = Field(default=None, description="图片URL列表(JSON数组)")
description: str | None = Field(default=None, max_length=255, description="工单描述")
@field_validator("ticket_type")
@classmethod
def _validate_ticket_type(cls, v: str) -> str:
allowed = {"suggestion", "bug", "optimize", "other"}
if v not in allowed:
raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}")
return v
@field_validator("title")
@classmethod
def _validate_title(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("工单标题不能为空")
return v
class TicketUpdateSchema(BaseModel):
"""更新工单"""
title: str | None = Field(default=None, max_length=200, description="工单标题")
ticket_content: str | None = Field(default=None, description="工单内容(富文本)")
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
ticket_type: str | None = Field(default=None, max_length=20, description="工单类型")
status: str | None = Field(
default=None, max_length=10, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)"
)
reply: str | None = Field(default=None, description="回复内容")
assigned_id: int | None = Field(default=None, gt=0, description="处理人ID")
description: str | None = Field(default=None, max_length=255, description="工单描述")
@field_validator("ticket_type")
@classmethod
def _validate_ticket_type(cls, v: str | None) -> str | None:
if v is None:
return v
allowed = {"suggestion", "bug", "optimize", "other"}
if v not in allowed:
raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}")
return v
@field_validator("status")
@classmethod
def _validate_status(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in {"0", "1", "2", "3"}:
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
return v
class TicketOutSchema(BaseModel):
"""工单响应"""
model_config = ConfigDict(from_attributes=True)
id: int
title: str
ticket_content: str | None = None
summary: str | None = None
ticket_type: str
status: str
images: str | None = None
reply: str | None = None
description: str | None = None
assigned_id: int | None = None
created_time: DateTimeStr | None = None
updated_time: DateTimeStr | None = None
created_by: CommonSchema | None = None
updated_by: CommonSchema | None = None
assigned_by: CommonSchema | None = None
class TicketBatchSchema(BaseModel):
"""批量更新工单"""
ids: list[int] = Field(..., min_length=1, description="工单ID列表")
status: str = Field(..., max_length=10, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
@field_validator("status")
@classmethod
def _validate_status(cls, v: str) -> str:
if v not in {"0", "1", "2", "3"}:
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
return v
class TicketQueryParam:
"""工单查询参数"""
def __init__(
self,
title: str | None = None,
ticket_type: str | None = None,
status: str | None = None,
created_id: int | None = None,
assigned_id: int | None = None,
) -> None:
if title:
self.title = ("like", title)
if ticket_type:
self.ticket_type = ("eq", ticket_type)
if status:
self.status = ("eq", status)
if created_id:
self.created_id = ("eq", created_id)
if assigned_id:
self.assigned_id = ("eq", assigned_id)
@@ -1,133 +0,0 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from .crud import TicketCRUD
from .schema import (
TicketBatchSchema,
TicketCreateSchema,
TicketOutSchema,
TicketQueryParam,
TicketUpdateSchema,
)
_TICKET_STATUS_TRANSITIONS = {
"0": {"1", "3"},
"1": {"2", "3"},
"2": {"3"},
"3": {"0"},
}
_TICKET_STATUS_LABELS = {
"0": "待处理",
"1": "处理中",
"2": "已完成",
"3": "已关闭",
}
class TicketService:
"""工单管理服务层"""
@classmethod
def _validate_status_transition(
cls,
auth: AuthSchema,
ticket,
new_status: str,
) -> None:
"""校验工单状态流转是否合法"""
old_status = str(ticket.status) if ticket.status is not None else "0"
old_label = _TICKET_STATUS_LABELS.get(old_status, old_status)
new_label = _TICKET_STATUS_LABELS.get(new_status, new_status)
if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()):
raise CustomException(
msg=f"不允许从“{old_label}”转换为“{new_label}"
)
is_super = auth.user and auth.user.is_superuser
is_creator = auth.user and ticket.created_id == auth.user.id
is_assignee = auth.user and ticket.assigned_id == auth.user.id
if new_status == "0":
if not is_super:
raise CustomException(msg="仅超管可以重新打开已关闭的工单")
elif old_status == "0" and new_status == "1":
if not (is_super or is_creator or is_assignee):
raise CustomException(msg="仅创建人、处理人或超管可以受理工单")
elif old_status == "0" and new_status == "3":
if not (is_super or is_creator):
raise CustomException(msg="仅创建人或超管可以取消工单")
elif old_status == "1" and new_status == "2":
if not (is_super or is_assignee):
raise CustomException(msg="仅处理人或超管可以将工单标记为已完成")
elif old_status == "1" and new_status == "3":
if not (is_super or is_creator or is_assignee):
raise CustomException(msg="仅创建人、处理人或超管可以关闭工单")
elif old_status == "2" and new_status == "3":
if not (is_super or is_creator):
raise CustomException(msg="仅创建人或超管可以确认关闭工单")
@classmethod
async def page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: TicketQueryParam | None = None,
order_by: list | None = None,
) -> dict:
return await TicketCRUD(auth).page_crud(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"created_time": "desc"}],
search=search.__dict__ if search else {},
out_schema=TicketOutSchema,
)
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await TicketCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="工单不存在")
return TicketOutSchema.model_validate(obj).model_dump()
@classmethod
async def create_service(cls, auth: AuthSchema, data: TicketCreateSchema) -> dict:
obj = await TicketCRUD(auth).create_crud(data=data)
if not obj:
raise CustomException(msg="创建工单失败")
return TicketOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: TicketUpdateSchema) -> dict:
obj = await TicketCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="工单不存在")
if data.status is not None:
cls._validate_status_transition(auth, obj, data.status)
updated = await TicketCRUD(auth).update_crud(id=id, data=data)
if not updated:
raise CustomException(msg="更新失败")
return TicketOutSchema.model_validate(updated).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除对象不能为空")
await TicketCRUD(auth).delete_crud(ids=ids)
@classmethod
async def batch_service(cls, auth: AuthSchema, data: TicketBatchSchema) -> None:
"""批量更新工单状态"""
if not data.ids:
raise CustomException(msg="请选择要操作的工单")
for tid in data.ids:
obj = await TicketCRUD(auth).get_by_id_crud(id=tid)
if not obj:
raise CustomException(msg=f"工单[{tid}]不存在")
cls._validate_status_transition(auth, obj, data.status)
await TicketCRUD(auth).set_crud(ids=data.ids, status=data.status)
@@ -79,7 +79,7 @@ async def update_current_user_info_controller(
@UserRouter.put(
"/current/password/change",
"/password/change",
summary="修改当前用户密码",
description="修改当前用户密码",
response_model=ResponseSchema[UserOutSchema],
@@ -103,10 +103,10 @@ async def change_current_user_password_controller(
return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录")
@UserRouter.post(
"/{id}/reset-password",
summary="重置密码",
description="重置密码",
@UserRouter.put(
"/password/reset/{id}",
summary="重置用户密码",
description="重置指定用户密码",
response_model=ResponseSchema[UserOutSchema],
)
async def reset_password_controller(
@@ -158,9 +158,9 @@ async def register_user_controller(
@UserRouter.post(
"/forget/password",
"/password/forget",
summary="忘记密码",
description="忘记密码",
description="忘记密码找回",
response_model=ResponseSchema[UserOutSchema],
)
async def forget_password_controller(
@@ -323,7 +323,7 @@ async def delete_obj_controller(
@UserRouter.patch(
"/available/setting",
"/status/batch",
summary="批量修改用户状态",
description="批量修改用户状态",
response_model=ResponseSchema[None],
@@ -347,7 +347,7 @@ async def batch_set_available_obj_controller(
return SuccessResponse(msg="批量修改用户状态成功")
@UserRouter.post(
@UserRouter.get(
"/import/template",
summary="获取用户导入模板",
description="获取用户导入模板",
@@ -374,10 +374,10 @@ async def export_obj_template_controller() -> StreamingResponse:
)
@UserRouter.post(
@UserRouter.get(
"/export",
summary="导出用户",
description="导出用户",
description="导出用户列表",
response_model=ResponseSchema[None],
)
async def export_obj_list_controller(
@@ -7,10 +7,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.position.model import PositionModel
from app.api.v1.module_system.role.model import RoleModel
from app.api.v1.module_system.tenant.model import TenantModel
class UserRolesModel(MappedBase):
@@ -142,3 +142,9 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
uselist=False,
viewonly=True, # 防止级联操作
)
# 修复 Pydantic 循环引用问题
from app.core.auth_schema import AuthSchema
AuthSchema.model_rebuild()
@@ -145,7 +145,7 @@ class UserService:
raise CustomException(msg="部门不存在")
# 检查租户配额
from app.api.v1.module_system.tenant.service import TenantService
from app.api.v1.module_platform.tenant.service import TenantService
await TenantService.check_quota_service(auth, auth.tenant_id, "user")
# 创建用户
@@ -322,7 +322,7 @@ class UserService:
# 租户菜单约束:非超管用户只能看到租户菜单权限内的菜单
if menu_ids and auth.tenant_id:
from app.api.v1.module_system.tenant.service import TenantService
from app.api.v1.module_platform.tenant.service import TenantService
allowed_ids = await TenantService.get_tenant_menu_ids(auth, auth.tenant_id)
allowed_set = set(allowed_ids)