mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 04:46:26 +00:00
refactor: 完成系统架构升级与模块拆分
本次提交进行了大规模的系统重构: 1. 拆分租户相关模块到platform平台层,重构租户表名与关联关系 2. 迁移日志、工单、插件等模块到对应层级,统一代码结构 3. 重构批量操作接口路径,从/available/setting改为/status/batch 4. 新增批量删除基础模型,统一处理批量操作逻辑 5. 优化导入导出接口,修正路由方法与描述信息 6. 修复循环引用问题,重构依赖注入与类型导入 7. 更新初始化脚本与路由注册,新增平台管理路由 8. 重构岗位模型,新增岗位编码字段与校验 9. 完善部门删除逻辑,新增子部门删除限制 10. 更新初始化数据与配置文件,适配新架构
This commit is contained in:
@@ -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)
|
||||
+3
-15
@@ -8,20 +8,13 @@ from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
def get_log_text_column_type():
|
||||
"""
|
||||
根据数据库类型选择适合存储大文本的列类型。
|
||||
|
||||
MySQL 使用 LONGTEXT,PostgreSQL 使用 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)
|
||||
|
||||
Reference in New Issue
Block a user