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

本次提交进行了大规模的系统重构:
1. 拆分租户相关模块到platform平台层,重构租户表名与关联关系
2. 迁移日志、工单、插件等模块到对应层级,统一代码结构
3. 重构批量操作接口路径,从/available/setting改为/status/batch
4. 新增批量删除基础模型,统一处理批量操作逻辑
5. 优化导入导出接口,修正路由方法与描述信息
6. 修复循环引用问题,重构依赖注入与类型导入
7. 更新初始化脚本与路由注册,新增平台管理路由
8. 重构岗位模型,新增岗位编码字段与校验
9. 完善部门删除逻辑,新增子部门删除限制
10. 更新初始化数据与配置文件,适配新架构
This commit is contained in:
zhangtao
2026-06-03 00:53:19 +08:00
parent 290728d9d2
commit 17b3cd0a4c
104 changed files with 2666 additions and 1438 deletions
@@ -0,0 +1,25 @@
"""
平台级模块 - module_platform
包含平台级管理功能,不受租户隔离限制:
- 租户管理 (tenant)
- 套餐管理 (package)
- 插件管理 (plugin)
- 登录日志 (loginlog)
- 工单管理 (ticket)
"""
from fastapi import APIRouter
from app.api.v1.module_platform.loginlog.controller import LoginLogRouter
from app.api.v1.module_platform.package.controller import PackageRouter
from app.api.v1.module_platform.plugin.controller import PluginRouter
from app.api.v1.module_platform.tenant.controller import TenantRouter
from app.api.v1.module_platform.ticket.controller import TicketRouter
platform_router = APIRouter(prefix="/platform", tags=["平台管理"])
platform_router.include_router(TenantRouter, prefix="/tenant")
platform_router.include_router(PackageRouter, prefix="/package")
platform_router.include_router(PluginRouter, prefix="/plugin")
platform_router.include_router(LoginLogRouter)
platform_router.include_router(TicketRouter, prefix="/ticket")
@@ -0,0 +1,3 @@
from .controller import LoginLogRouter
__all__ = ["LoginLogRouter"]
@@ -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 (
LoginLogCreateSchema,
LoginLogDetailOutSchema,
LoginLogQueryParam,
)
from .service import LoginLogService
LoginLogRouter = APIRouter(prefix="/loginlog", tags=["登录日志"])
@LoginLogRouter.get(
"/detail/{id}",
summary="获取登录日志详情",
description="根据ID获取登录日志详情",
response_model=ResponseSchema[LoginLogDetailOutSchema],
dependencies=[Depends(AuthPermission("module_platform:login_log:query"))],
)
async def detail(
*,
id: Annotated[int, Path(gt=0)],
auth: AuthSchema = Depends(get_current_user),
):
return await LoginLogService.detail_service(auth, id)
@LoginLogRouter.get(
"/list",
summary="获取登录日志列表",
description="分页获取登录日志列表",
response_model=ResponseSchema[dict],
dependencies=[Depends(AuthPermission("module_platform:login_log:query"))],
)
async def list(
*,
page: Annotated[PaginationQueryParam, Depends()],
search: LoginLogQueryParam,
auth: AuthSchema = Depends(get_current_user),
):
return await LoginLogService.page_service(
auth,
page.page,
page.page_size,
search.to_dict(),
[{"id": page.order_by}],
)
@LoginLogRouter.post(
"/create",
summary="创建登录日志",
description="创建登录日志(由系统自动调用)",
response_model=ResponseSchema[LoginLogDetailOutSchema],
)
async def create(
*,
data: LoginLogCreateSchema,
auth: AuthSchema = Depends(get_current_user),
):
return await LoginLogService.create_service(auth, data)
@LoginLogRouter.delete(
"/delete",
summary="删除登录日志",
description="批量删除登录日志",
response_model=ResponseSchema,
dependencies=[Depends(AuthPermission("module_platform:login_log:delete"))],
)
async def delete(
*,
data: BatchDelete,
auth: AuthSchema = Depends(get_current_user),
):
await LoginLogService.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 LoginLogModel
class LoginLogCRUD(CRUDBase[LoginLogModel, None, None]):
"""登录日志 CRUD"""
def __init__(self, auth: AuthSchema):
super().__init__(LoginLogModel, auth)
@@ -0,0 +1,20 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class LoginLogModel(ModelMixin, UserMixin):
"""
登录日志模型
"""
__tablename__: str = "platform_login_log"
__table_args__: dict[str, str] = {"comment": "登录日志表"}
status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)")
login_location: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="登录位置")
login_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="登录IP地址")
request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统")
request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器")
msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息")
@@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class LoginLogQueryParam(BaseModel):
status: int | None = Field(None, ge=1, le=2, description="登录状态(1成功 2失败)")
username: str | None = Field(None, max_length=64, description="用户名")
def to_dict(self) -> dict | None:
"""转换为字典,仅包含非空字段"""
return self.model_dump(exclude_none=True)
class LoginLogOutSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
status: int
login_ip: str | None = None
login_location: str | None = None
request_os: str | None = None
request_browser: str | None = None
msg: str | None = None
created_at: datetime
class LoginLogDetailOutSchema(LoginLogOutSchema):
pass
class LoginLogCreateSchema(BaseModel):
username: str = Field(..., min_length=1, max_length=64, description="用户名")
status: int = Field(1, ge=1, le=2, description="登录状态(1成功 2失败)")
login_ip: str | None = Field(None, max_length=50, description="登录IP地址")
login_location: str | None = Field(None, max_length=255, description="登录位置")
request_os: str | None = Field(None, max_length=64, description="操作系统")
request_browser: str | None = Field(None, max_length=64, description="浏览器")
msg: str | None = Field(None, max_length=255, description="提示消息")
@field_validator("status")
@classmethod
def validate_status(cls, value: int) -> int:
if value not in [1, 2]:
raise ValueError("登录状态只能为1(成功)或2(失败)")
return value
@@ -0,0 +1,55 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.api.v1.module_platform.loginlog.crud import LoginLogCRUD
from app.api.v1.module_platform.loginlog.schema import (
LoginLogCreateSchema,
LoginLogDetailOutSchema,
LoginLogOutSchema,
)
from app.core.logger import log
class LoginLogService:
@staticmethod
async def create_service(auth: AuthSchema, data: LoginLogCreateSchema) -> LoginLogDetailOutSchema:
crud = LoginLogCRUD(auth)
obj = await crud.create(data.model_dump())
return LoginLogDetailOutSchema.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 = LoginLogCRUD(auth)
# 构建过滤条件
filters = {}
if search:
if search.get("status"):
filters["status"] = search["status"]
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=LoginLogOutSchema,
)
return result
@staticmethod
async def detail_service(auth: AuthSchema, id: int) -> LoginLogDetailOutSchema:
crud = LoginLogCRUD(auth)
obj = await crud.get(id=id)
return LoginLogDetailOutSchema.model_validate(obj)
@staticmethod
async def delete_service(auth: AuthSchema, ids: list[int]) -> None:
crud = LoginLogCRUD(auth)
await crud.delete(ids)
log.info(f"删除登录日志成功, ids={ids}")
@@ -0,0 +1,151 @@
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.base_schema import BatchDelete, BatchSetAvailable
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .schema import (
PackageCreateSchema,
PackageMenuSetSchema,
PackageOutSchema,
PackageQueryParam,
PackageUpdateSchema,
)
from .service import PackageService
PackageRouter = APIRouter(route_class=OperationLogRoute, prefix="/package", tags=["套餐管理"])
@PackageRouter.get(
"/detail/{id}",
summary="获取套餐详情",
description="根据套餐ID获取套餐详情",
response_model=ResponseSchema[PackageOutSchema],
)
async def get_obj_detail_controller(
id: Annotated[int, Path(description="套餐ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))],
) -> JSONResponse:
result_dict = await PackageService.detail_service(auth=auth, id=id)
log.info(f"获取套餐详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取套餐详情成功")
@PackageRouter.get(
"/list",
summary="获取套餐列表",
description="分页获取套餐列表",
response_model=ResponseSchema[dict],
)
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[PackageQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))],
) -> JSONResponse:
result_dict = await PackageService.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_dict, msg="查询成功")
@PackageRouter.post(
"/create",
summary="创建套餐",
description="创建新的租户套餐",
response_model=ResponseSchema[PackageOutSchema],
)
async def create_obj_controller(
data: Annotated[PackageCreateSchema, Body(description="套餐信息")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:create"]))],
) -> JSONResponse:
result_dict = await PackageService.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[PackageOutSchema],
)
async def update_obj_controller(
id: Annotated[int, Path(description="套餐ID")],
data: Annotated[PackageUpdateSchema, Body(description="套餐信息")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))],
) -> JSONResponse:
result_dict = await PackageService.update_service(auth=auth, id=id, data=data)
log.info(f"更新套餐成功 {id}")
return SuccessResponse(data=result_dict, msg="更新成功")
@PackageRouter.delete(
"/delete",
summary="删除套餐",
description="批量删除套餐(已被租户使用的套餐无法删除)",
response_model=ResponseSchema,
)
async def delete_obj_controller(
data: Annotated[BatchDelete, Body(description="删除信息")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:delete"]))],
) -> JSONResponse:
await PackageService.delete_service(auth=auth, ids=data.ids)
log.info(f"删除套餐成功 {data.ids}")
return SuccessResponse(msg="删除成功")
@PackageRouter.patch(
"/status/batch",
summary="批量修改状态",
description="批量启用/禁用套餐",
response_model=ResponseSchema,
)
async def set_available_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))],
) -> JSONResponse:
for id in data.ids:
await PackageService.update_service(auth=auth, id=id, data=PackageUpdateSchema(status=data.status))
log.info(f"套餐状态设置成功 {data.ids}")
return SuccessResponse(msg="状态设置成功")
@PackageRouter.get(
"/menus/{package_id}",
summary="获取套餐菜单",
description="获取套餐包含的菜单ID列表",
response_model=ResponseSchema[list[int]],
)
async def get_menus_controller(
package_id: Annotated[int, Path(description="套餐ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:query"]))],
) -> JSONResponse:
result = await PackageService.get_menus_service(auth=auth, package_id=package_id)
return SuccessResponse(data=result, msg="获取成功")
@PackageRouter.post(
"/menus/{package_id}/set",
summary="设置套餐菜单",
description="批量设置套餐包含的菜单(先清空再写入)",
response_model=ResponseSchema,
)
async def set_menus_controller(
package_id: Annotated[int, Path(description="套餐ID")],
data: Annotated[PackageMenuSetSchema, Body(description="菜单列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))],
) -> JSONResponse:
await PackageService.set_menus_service(auth=auth, package_id=package_id, data=data)
log.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}")
return SuccessResponse(msg="设置成功")
@@ -0,0 +1,11 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import PackageModel
class PackageCRUD(CRUDBase):
"""套餐模块 CRUD"""
def __init__(self, auth: AuthSchema):
super().__init__(PackageModel, auth)
@@ -0,0 +1,74 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, validates
from app.core.base_model import MappedBase
class PackageModel(MappedBase):
"""
套餐模型 - 定义租户可用的功能套餐
"""
__tablename__: str = "platform_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="更新时间"
)
@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 PackageMenuModel(MappedBase):
"""
套餐-菜单关联表 — 定义套餐包含的菜单资源
"""
__tablename__: str = "platform_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("platform_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",
)
@@ -0,0 +1,98 @@
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
class PackageCreateSchema(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:禁用)")
sort: int = Field(default=0, ge=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
@field_validator("status")
@classmethod
def _validate_status(cls, v: str) -> str:
if v not in {"0", "1"}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
class PackageUpdateSchema(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:禁用)")
sort: int | None = Field(default=None, ge=0, 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
@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 PackageOutSchema(PackageCreateSchema, BaseSchema):
"""套餐响应"""
model_config = ConfigDict(from_attributes=True)
class PackageQueryParam:
"""套餐查询参数"""
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 PackageMenuSetSchema(BaseModel):
"""批量设置套餐菜单权限"""
menu_ids: list[int] = Field(..., description="菜单ID列表")
@@ -0,0 +1,150 @@
import sqlalchemy as sa
from sqlalchemy import func, select
from app.api.v1.module_platform.tenant.model import TenantModel
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 PackageCRUD
from .model import PackageMenuModel, PackageModel
from .schema import (
PackageCreateSchema,
PackageMenuSetSchema,
PackageOutSchema,
PackageQueryParam,
PackageUpdateSchema,
)
class PackageService:
"""套餐模块服务层"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
obj = await PackageCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="套餐不存在")
return PackageOutSchema.model_validate(obj).model_dump()
@classmethod
async def page_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: PackageQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> dict:
return await PackageCRUD(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=PackageOutSchema,
)
@classmethod
async def create_service(cls, auth: AuthSchema, data: PackageCreateSchema) -> dict:
if await PackageCRUD(auth).get(name=data.name):
raise CustomException(msg="创建失败,套餐名称已存在")
if await PackageCRUD(auth).get(code=data.code):
raise CustomException(msg="创建失败,套餐编码已存在")
obj = await PackageCRUD(auth).create_crud(data=data)
if not obj:
raise CustomException(msg="创建套餐失败")
result = PackageOutSchema.model_validate(obj).model_dump()
log.info(f"创建套餐成功: {result.get('name')}")
return result
@classmethod
async def update_service(
cls, auth: AuthSchema, id: int, data: PackageUpdateSchema
) -> dict:
obj = await PackageCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="套餐不存在")
if data.name is not None:
exist = await PackageCRUD(auth).get(name=data.name)
if exist and exist.id != id:
raise CustomException(msg="更新失败,名称重复")
if data.code is not None:
exist = await PackageCRUD(auth).get(code=data.code)
if exist and exist.id != id:
raise CustomException(msg="更新失败,编码重复")
updated = await PackageCRUD(auth).update_crud(id=id, data=data)
if not updated:
raise CustomException(msg="更新失败")
return PackageOutSchema.model_validate(updated).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
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 PackageCRUD(auth).delete_crud(ids=ids)
@classmethod
async def get_menus_service(cls, auth: AuthSchema, package_id: int) -> list[int]:
"""获取套餐菜单权限(返回 menu_id 列表)"""
stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.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: PackageMenuSetSchema
) -> None:
"""批量设置套餐菜单权限(先清空再写入)"""
await auth.db.execute(sa.delete(PackageMenuModel).where(PackageMenuModel.package_id == package_id))
for menu_id in data.menu_ids:
auth.db.add(PackageMenuModel(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列表"""
stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == package_id)
result = await auth.db.execute(stmt)
return [row[0] for row in result.all()]
@staticmethod
async def get_tenant_available_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户的完整可用菜单ID列表(套餐菜单 + 自定义授权菜单)"""
from app.api.v1.module_platform.tenant.model import TenantMenuModel, 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()
if tenant.package_id:
pkg_stmt = select(PackageModel.status).where(PackageModel.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(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == tenant.package_id)
result = await auth.db.execute(stmt)
for row in result.all():
all_menu_ids.add(row[0])
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)
@@ -0,0 +1,118 @@
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="查询成功")
@@ -0,0 +1,11 @@
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)
@@ -0,0 +1,80 @@
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 = "platform_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 = "platform_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("platform_tenant.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="租户ID",
)
plugin_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("platform_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="安装时间")
@@ -0,0 +1,111 @@
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")
@@ -0,0 +1,180 @@
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
@@ -0,0 +1,303 @@
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(
"/status/batch",
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="设置租户菜单权限成功")
@@ -0,0 +1,59 @@
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)
@@ -0,0 +1,189 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, 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 = "platform_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("platform_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="结束时间"
)
# 配额字段 - 直接集成到主表
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="最大部门数"
)
@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
@validates("max_users", "max_roles", "max_storage_mb", "max_depts")
def validate_quota(self, key: str, value: int) -> int:
if value < 1:
raise ValueError(f"{key} 不能小于 1")
return value
class TenantUserModel(MappedBase):
"""
用户-租户关联表
支持一个用户关联多个租户(如顾问在多个租户间切换)。
每个用户有一个默认租户(is_default=1),用于登录后的默认上下文。
"""
__tablename__: str = "platform_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("platform_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 TenantConfigModel(MappedBase):
"""租户个性化配置模型 — 键值对存储"""
__tablename__: str = "platform_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("platform_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 = "platform_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("platform_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",
)
@@ -0,0 +1,251 @@
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)
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列表")
@@ -0,0 +1,676 @@
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,
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()
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_platform.package.service import PackageService
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
available_ids = await PackageService.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:
"""获取租户配额(从租户主表读取)"""
tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
return TenantQuotaOutSchema(
tenant_id=tenant.id,
max_users=tenant.max_users,
max_roles=tenant.max_roles,
max_storage_mb=tenant.max_storage_mb,
max_depts=tenant.max_depts,
).model_dump()
@classmethod
async def update_quota_service(
cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema
) -> dict:
"""更新租户配额(直接更新租户主表)"""
tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(tenant, k, v)
await auth.db.flush()
log.info(f"租户[{tenant_id}]配额已更新: {update_data}")
return TenantQuotaOutSchema(
tenant_id=tenant.id,
max_users=tenant.max_users,
max_roles=tenant.max_roles,
max_storage_mb=tenant.max_storage_mb,
max_depts=tenant.max_depts,
).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
tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
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(tenant, 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}),无法继续创建"
)
# ============ 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 app.api.v1.module_platform.package.service import PackageService
return await PackageService.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}")
@@ -0,0 +1,91 @@
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="删除成功")
@@ -0,0 +1,51 @@
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)
@@ -0,0 +1,63 @@
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 = "platform_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
@@ -0,0 +1,126 @@
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)
@@ -0,0 +1,133 @@
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)