refactor: 移除多租户相关代码,重构为单租户架构

此次提交进行了大规模的架构重构:
1.  移除所有平台租户相关模块和代码,包括租户管理、套餐、订单、发票等功能
2.  将菜单模块从platform迁移到system模块,统一系统功能入口
3.  移除租户隔离相关的模型混入、中间件和配置
4.  简化文件上传、SSE事件总线、定时任务等模块的租户逻辑
5.  重构所有业务schema和模型,移除租户相关字段和关联
6.  清理初始化脚本、模板和常量中的租户相关代码
7.  简化认证和权限控制逻辑,移除数据范围检查相关代码
This commit is contained in:
zhangtao
2026-07-16 23:22:45 +08:00
parent f74b5f277e
commit a222cd9e43
119 changed files with 421 additions and 7145 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ class ChatSessionCRUD:
"""初始化CRUD数据层"""
self.auth = auth
self.user_id = auth.user.username or "user"
self.team_id = str(auth.user.tenant_id) if auth.user.tenant_id else None
self.team_id = "default"
self.db = self._get_db()
def _get_db(self) -> Any:
+2 -2
View File
@@ -2,7 +2,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, TenantByQueryParam, UserByQueryParam
from app.core.base_schema import BaseQueryParam, UserByQueryParam
class ChatQuerySchema(BaseModel):
@@ -53,7 +53,7 @@ class ChatSessionMessageSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
class ChatSessionQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class ChatSessionQueryParam(BaseQueryParam, UserByQueryParam):
"""会话查询参数"""
title: str | None = Field(None, description="会话标题")
+3 -6
View File
@@ -10,7 +10,6 @@ from agno.team.team import Team
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.service import TenantService
from app.common.enums import RedisInitKeyConfig
from app.common.request import PaginationService
from app.core.base_schema import AuthSchema, PageResultSchema
@@ -73,9 +72,7 @@ async def _format_session_data(session: TeamSession, auth: AuthSchema | None = N
try:
team_id_str = session_dict.get("team_id")
if team_id_str:
team_id = int(team_id_str)
tenant = await TenantService(auth, db).detail(id=team_id)
result["team_name"] = tenant.name
result["team_name"] = None
except Exception:
result["team_name"] = None
else:
@@ -155,7 +152,7 @@ class ChatService:
session_id = session.session_id
agno_factory = AgnoFactory()
team_id = str(self.auth.user.tenant_id or "default")
team_id = "default"
agent = agno_factory.create_agent(
user_id=self.auth.user.username or "user",
team_id=team_id,
@@ -216,7 +213,7 @@ class ChatService:
session_id = session.session_id
agno_factory = AgnoFactory()
team_id = str(self.auth.user.tenant_id or "default")
team_id = "default"
agent: Team = agno_factory.create_agent(
user_id=self.auth.user.username or "user",
team_id=team_id,
@@ -31,7 +31,6 @@ async def upload_controller(
file=file,
upload_type=upload_type or "file",
target_path=target_path,
tenant_id=auth.user.tenant_id if auth.user else None,
)
return SuccessResponse(data=result, msg="上传文件成功")
@@ -43,7 +42,7 @@ async def download_controller(
file_path: Annotated[str, Body(description="文件路径")],
delete: Annotated[bool, Body(description="是否删除文件")] = False,
) -> FileResponse:
result = await FileService.download_service(file_path=file_path, tenant_id=auth.user.tenant_id if auth.user else None)
result = await FileService.download_service(file_path=file_path)
if delete:
background_tasks.add_task(UploadUtil.delete_file, Path(result.file_path))
return UploadFileResponse(file_path=result.file_path, filename=result.file_name)
@@ -20,16 +20,14 @@ class FileService:
file: UploadFile,
upload_type: str = "file",
target_path: str | None = None,
tenant_id: int | None = None,
) -> UploadResponseSchema:
"""上传文件(带租户隔离)"""
tenant_prefix = f"tenant_{tenant_id}/" if tenant_id and tenant_id != 1 else ""
"""上传文件"""
filename, filepath, file_url = await UploadUtil.upload_file(
file=file,
base_url=base_url,
upload_type=upload_type,
target_path=f"{tenant_prefix}{target_path}" if target_path else None,
target_path=target_path,
)
return UploadResponseSchema(
@@ -40,8 +38,8 @@ class FileService:
)
@classmethod
async def download_service(cls, file_path: str, tenant_id: int | None = None) -> DownloadFileSchema:
"""下载文件(带租户隔离)"""
async def download_service(cls, file_path: str) -> DownloadFileSchema:
"""下载文件"""
if not file_path:
raise CustomException(msg="请选择要下载的文件")
@@ -58,12 +56,6 @@ class FileService:
logger.error(f"路径不在上传目录内: {file_path}")
raise CustomException(msg="非法的文件路径")
if tenant_id and tenant_id != 1:
tenant_prefix = f"{upload_root}/tenant_{tenant_id}"
if not abs_path.startswith(str(tenant_prefix)):
logger.error(f"文件不属于当前租户: {file_path}")
raise CustomException(msg="无权访问该文件")
if not UploadUtil.check_file_exists(abs_path):
raise CustomException(msg="文件不存在")
@@ -37,10 +37,9 @@ async def sse_event_stream(
连接保活由 FastAPI 自动处理(每 15 秒发送 ping 注释)。
"""
user_id = auth.user.id
tenant_id = auth.user.tenant_id
queue = EventBus.subscribe(user_id, tenant_id)
logger.info(f"SSE 连接建立: user_id={user_id} tenant_id={tenant_id}")
queue = EventBus.subscribe(user_id)
logger.info(f"SSE 连接建立: user_id={user_id}")
# 解析断连前最后收到的事件 ID,用于恢复
event_id = int(last_event_id) if last_event_id and last_event_id.isdigit() else 0
@@ -441,11 +441,10 @@ class Jinja2TemplateUtil:
has_date_import = False
has_time_import = False
# 基类 ModelMixin/TenantMixin/UserMixin 已定义的列,无需导入 SQLAlchemy 类型
# 基类 ModelMixin/UserMixin 已定义的列,无需导入 SQLAlchemy 类型
_BASE_MODEL_COLUMNS = {
"id",
"uuid",
"tenant_id",
"created_time",
"updated_time",
"created_id",
@@ -3,17 +3,17 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from sqlalchemy.sql import expression
from app.config.setting import settings
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
from app.utils.common_util import SqlalchemyUtil
class GenTableModel(ModelMixin, TenantMixin, UserMixin):
class GenTableModel(ModelMixin, UserMixin):
"""代码生成表
"""
__tablename__: str = "gen_table"
__table_args__: dict[str, str] = {"comment": "代码生成表"}
__loader_options__: list[str] = ["columns", "created_by", "updated_by", "deleted_by", "tenant_by"]
__loader_options__: list[str] = ["columns", "created_by", "updated_by", "deleted_by"]
table_name: Mapped[str] = mapped_column(String(200), nullable=False, default="", comment="表名称")
table_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="表描述")
@@ -44,12 +44,12 @@ class GenTableModel(ModelMixin, TenantMixin, UserMixin):
return class_name.strip()
class GenTableColumnModel(ModelMixin, TenantMixin, UserMixin):
class GenTableColumnModel(ModelMixin, UserMixin):
"""代码生成表字段"""
__tablename__: str = "gen_table_column"
__table_args__: dict[str, str] = {"comment": "代码生成表字段"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
column_name: Mapped[str] = mapped_column(String(200), nullable=False, comment="列名称")
column_comment: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="列描述")
@@ -2,7 +2,7 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class GenDBTableSchema(BaseModel):
@@ -238,7 +238,7 @@ class GenTableSchema(BaseModel):
return s if s else None
class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema, TenantBySchema):
class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema):
"""业务表输出模型(面向控制器/前端)。"""
model_config = ConfigDict(from_attributes=True)
@@ -279,7 +279,7 @@ class GenSyncPreviewSchema(BaseModel):
sub: "GenSyncPreviewSchema | None" = Field(default=None, description="子表差异(若配置了主子表)")
class GenTableQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class GenTableQueryParam(BaseQueryParam, UserByQueryParam):
"""代码生成业务表查询参数
- 支持按`table_name`、`table_comment`进行模糊检索(由CRUD层实现like)。
- 空值将被忽略,不参与过滤。
@@ -20,8 +20,8 @@ from sqlglot.expressions import (
Update,
)
from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.menu.schema import MenuCreateSchema
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.api.v1.module_system.menu.schema import MenuCreateSchema
from app.common.constant import GenConstant
from app.common.enums import QueueEnum
from app.config.path_conf import BASE_DIR
@@ -30,11 +30,7 @@ class DashboardStatsSchema(BaseModel):
"""仪表盘统计数据"""
online_users: int = 0
total_users: int = 0
total_tenants: int = 0
total_orders: int = 0
today_login_count: int = 0
today_unique_users: int = 0
week_user_created: int = 0
week_tenant_created: int = 0
paid_orders: int = 0
recent_logins: list[RecentLoginItem] = []
@@ -5,8 +5,6 @@ from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.order.model import OrderModel
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_system.log.model import LoginLogModel
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig
@@ -93,24 +91,6 @@ class OnlineService:
)
user_week_count = (await db.execute(users_week_sql)).scalar() or 0
tenants_sql = select(func.count()).select_from(TenantModel).where(TenantModel.is_deleted.is_(False))
tenant_count = (await db.execute(tenants_sql)).scalar() or 0
tenants_week_sql = (
select(func.count()).select_from(TenantModel)
.where(TenantModel.is_deleted.is_(False), TenantModel.created_time >= week_start)
)
tenant_week_count = (await db.execute(tenants_week_sql)).scalar() or 0
orders_sql = select(func.count()).select_from(OrderModel).where(OrderModel.is_deleted.is_(False))
order_count = (await db.execute(orders_sql)).scalar() or 0
paid_sql = (
select(func.count()).select_from(OrderModel)
.where(OrderModel.is_deleted.is_(False), OrderModel.status == 1)
)
paid_count = (await db.execute(paid_sql)).scalar() or 0
today_login_sql = (
select(func.count()).select_from(LoginLogModel)
.where(LoginLogModel.created_time >= today_start)
@@ -141,13 +121,9 @@ class OnlineService:
result = DashboardStatsSchema(
online_users=online_count,
total_users=user_count,
total_tenants=tenant_count,
total_orders=order_count,
today_login_count=today_login_count,
today_unique_users=today_unique_count,
week_user_created=user_week_count,
week_tenant_created=tenant_week_count,
paid_orders=paid_count,
recent_logins=recent_logins,
)
return result
@@ -1,15 +0,0 @@
from fastapi import APIRouter
from app.api.v1.module_platform.invoice.controller import InvoiceRouter
from app.api.v1.module_platform.menu.controller import MenuRouter
from app.api.v1.module_platform.order.controller import OrderRouter
from app.api.v1.module_platform.package.controller import PackageRouter
from app.api.v1.module_platform.tenant.controller import TenantRouter
platform_router = APIRouter(prefix="/platform")
platform_router.include_router(TenantRouter)
platform_router.include_router(PackageRouter)
platform_router.include_router(OrderRouter)
platform_router.include_router(InvoiceRouter)
platform_router.include_router(MenuRouter)
@@ -1,3 +0,0 @@
from .controller import InvoiceRouter
__all__ = ["InvoiceRouter"]
@@ -1,54 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
from app.core.router_class import OperationLogRoute
from .schema import InvoiceApplySchema, InvoiceOutSchema, InvoiceQueryParam
from .service import InvoiceTenantService
InvoiceRouter = APIRouter(prefix="/invoice", route_class=OperationLogRoute, tags=["发票管理"])
@InvoiceRouter.post("/apply", status_code=status.HTTP_201_CREATED, summary="申请开票", response_model=ResponseSchema[InvoiceOutSchema])
async def invoice_apply_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:invoice:create"]))],
data: Annotated[InvoiceApplySchema, Body(description="发票申请参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await InvoiceTenantService.apply(auth=auth, db=db, data=data, tenant_id=auth.user.tenant_id if auth.user else 0)
return SuccessResponse(data=result, msg="发票申请成功")
@InvoiceRouter.get("/mine/list", summary="我的发票列表", response_model=ResponseSchema[PageResultSchema[InvoiceOutSchema]])
async def invoice_list_my_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:invoice:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[InvoiceQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await InvoiceTenantService.list_my(
auth=auth,
db=db,
tenant_id=auth.user.tenant_id if auth.user else 0,
page_no=page.page_no,
page_size=page.page_size,
order_by=page.order_by,
search=search,
)
return SuccessResponse(data=result, msg="查询成功")
@InvoiceRouter.get("/{id}/download", summary="下载发票PDF", response_model=ResponseSchema[dict])
async def invoice_download_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:invoice:download"]))],
id: Annotated[int, Path(description="发票ID", ge=1)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
pdf_url = await InvoiceTenantService.download(auth=auth, db=db, invoice_id=id, tenant_id=auth.user.tenant_id if auth.user else 0)
return SuccessResponse(msg="下载地址", data={"pdf_url": pdf_url})
@@ -1,31 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import InvoiceModel
from .schema import InvoiceCreateSchema, InvoiceUpdateSchema
class InvoiceCRUD(CRUDBase[InvoiceModel, InvoiceCreateSchema, InvoiceUpdateSchema]):
"""发票 CRUD —— 继承 CRUDBase 获得增删改查、软删除过滤、租户隔离、权限过滤"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
"""初始化发票 CRUD
参数:
- auth (AuthSchema): 认证信息模型
- db (AsyncSession): 数据库会话
"""
super().__init__(model=InvoiceModel, auth=auth, db=db)
async def get_by_order_id(self, order_id: int) -> InvoiceModel | None:
"""根据订单 ID 查询发票
参数:
- order_id (int): 订单 ID
返回:
- InvoiceModel | None: 发票对象不存在返回 None
"""
return await self.get(order_id=order_id)
@@ -1,38 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.order.model import OrderModel
class InvoiceModel(ModelMixin, TenantMixin, UserMixin):
"""平台发票表
status: 0=待开票 1=已开票 2=开票失败 3=已作废
"""
__tablename__: str = "platform_invoice"
__table_args__: dict[str, str] = {"comment": "发票表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
invoice_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="发票号码")
order_id: Mapped[int] = mapped_column(Integer, ForeignKey("platform_order.id"), nullable=False, unique=True, comment="关联订单")
invoice_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="vat_normal/vat_special")
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="发票抬头")
tax_no: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="纳税人识别号")
bank_info: Mapped[str | None] = mapped_column(Text, nullable=True, comment="开户行及账号")
address_info: Mapped[str | None] = mapped_column(Text, nullable=True, comment="注册地址及电话")
amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="发票金额(分)")
tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="税额(分)")
pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="发票PDF下载地址")
oss_license_pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="开源授权函PDF下载地址")
api_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="第三方API响应")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待开票 1:已开票 2:开票失败 3:已作废)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
# 关联关系
order: Mapped["OrderModel"] = relationship("OrderModel", lazy="selectin")
@@ -1,100 +0,0 @@
import hashlib
from datetime import datetime
from app.config.path_conf import INVOICE_DIR, TEMPLATE_DIR
from app.utils.pdf_generator import amount_to_cn_uppercase, amount_to_yuan, generate_pdf_from_template
from .schema import InvoiceOutSchema
_INVOICE_TYPE_LABEL = {
"vat_normal": "电子普通发票",
"vat_special": "增值税专用发票",
}
def _render_invoice_pdf(invoice: InvoiceOutSchema) -> str:
"""渲染并保存电子发票 PDF
参数:
- invoice (InvoiceOutSchema): 发票对象
返回:
- str: PDF 的相对 URL 路径形如 /static/invoice/{tenant_id}/{invoice_no}.pdf
"""
amount_yuan = float(amount_to_yuan(invoice.amount))
tax_yuan = float(amount_to_yuan(invoice.tax_amount))
total_yuan = amount_yuan + tax_yuan
check_code_seed = f"{invoice.invoice_no}|{invoice.amount}|{invoice.invoice_type}"
check_code = hashlib.md5(check_code_seed.encode()).hexdigest()[:20].upper()
variables = {
"invoice_no": invoice.invoice_no,
"invoice_date": datetime.now().strftime("%Y-%m-%d"),
"invoice_type": invoice.invoice_type,
"invoice_type_label": _INVOICE_TYPE_LABEL.get(invoice.invoice_type, "电子发票"),
"buyer_name": invoice.title,
"buyer_tax_no": invoice.tax_no or "-",
"buyer_address_info": invoice.address_info or "-",
"buyer_bank_info": invoice.bank_info or "-",
"seller_name": "FastapiAdmin 平台",
"seller_tax_no": "91310115MA1K00000X",
"seller_address_info": "上海市浦东新区张江高科技园区",
"seller_bank_info": "招商银行上海分行 1234 5678 9012 3456",
"items": [
{
"name": "FastapiAdmin 企业服务",
"spec": "SaaS 订阅",
"unit": "",
"quantity": "1",
"unit_price": amount_to_yuan(invoice.amount),
"amount": amount_to_yuan(invoice.amount),
"tax_rate": 0,
"tax_amount": amount_to_yuan(invoice.tax_amount),
},
],
"amount_total_yuan": f"{total_yuan:.2f}",
"amount_cn_uppercase": amount_to_cn_uppercase(total_yuan),
"remarks": invoice.description or f"订单号: {invoice.order_id}",
"check_code": f"{check_code[:4]} {check_code[4:8]} {check_code[8:12]} {check_code[12:16]} {check_code[16:20]}",
"issuer_name": "系统开票",
"receiver_name": "-",
"reviewer_name": "-",
}
output_dir = INVOICE_DIR / str(invoice.tenant_id)
output_path = output_dir / f"{invoice.invoice_no}.pdf"
generate_pdf_from_template(
template_name="invoice/invoice.jinja2",
template_dir=TEMPLATE_DIR,
variables=variables,
output_path=output_path,
)
return f"/static/invoice/{invoice.tenant_id}/{invoice.invoice_no}.pdf"
def _render_oss_license_pdf(invoice: InvoiceOutSchema) -> str:
"""渲染并保存开源项目授权声明函 PDF(与发票 PDF 独立存储)
参数:
- invoice (InvoiceOutSchema): 发票对象用于在授权函中展示关联发票号
返回:
- str: PDF 的相对 URL 路径形如 /static/invoice/{tenant_id}/{invoice_no}_license.pdf
"""
variables = {
"invoice_no": invoice.invoice_no,
"invoice_date": datetime.now().strftime("%Y-%m-%d"),
"product_version": "v1.0.0",
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
output_dir = INVOICE_DIR / str(invoice.tenant_id)
output_path = output_dir / f"{invoice.invoice_no}_license.pdf"
generate_pdf_from_template(
template_name="invoice/oss_license.jinja2",
template_dir=TEMPLATE_DIR,
variables=variables,
output_path=output_path,
)
return f"/static/invoice/{invoice.tenant_id}/{invoice.invoice_no}_license.pdf"
@@ -1,62 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
from app.common.enums import InvoiceTypeEnum
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantBySchema, UserBySchema
class InvoiceCreateSchema(BaseModel):
"""创建发票(内部使用)"""
invoice_no: str = Field(..., description="发票号码")
order_id: int = Field(..., description="关联订单 ID")
invoice_type: InvoiceTypeEnum = Field(..., description="发票类型")
title: str = Field(..., max_length=200, description="发票抬头")
tax_no: str | None = Field(default=None, max_length=50, description="纳税人识别号")
bank_info: str | None = Field(default=None, description="开户行及账号")
address_info: str | None = Field(default=None, description="注册地址及电话")
amount: int = Field(..., ge=0, description="发票金额(分)")
tax_amount: int = Field(default=0, ge=0, description="税额(分)")
status: int = Field(default=0, ge=0, le=3, description="状态(0:待开票 1:已开票 2:开票失败 3:已作废)")
description: str | None = Field(default=None, description="备注")
class InvoiceUpdateSchema(BaseModel):
"""更新发票(内部使用)"""
status: int | None = Field(default=None, ge=0, le=3, description="状态(0:待开票 1:已开票 2:开票失败 3:已作废)")
pdf_url: str | None = Field(default=None, max_length=500, description="发票 PDF 下载地址")
oss_license_pdf_url: str | None = Field(default=None, max_length=500, description="开源授权函 PDF 下载地址")
api_response: str | None = Field(default=None, description="第三方 API 响应")
description: str | None = Field(default=None, description="备注")
class InvoiceApplySchema(BaseModel):
"""申请开票"""
order_id: int = Field(..., description="订单 ID")
invoice_type: InvoiceTypeEnum = Field(..., description="发票类型")
title: str = Field(..., max_length=200, description="发票抬头")
tax_no: str | None = Field(default=None, max_length=50, description="纳税人识别号")
bank_info: str | None = Field(default=None, description="开户行及账号")
address_info: str | None = Field(default=None, description="注册地址及电话")
description: str | None = Field(default=None, description="备注")
class InvoiceOutSchema(InvoiceCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
"""发票响应"""
model_config = ConfigDict(from_attributes=True)
pdf_url: str | None = Field(default=None, description="发票 PDF 下载地址")
oss_license_pdf_url: str | None = Field(default=None, description="开源授权函 PDF 下载地址")
api_response: str | None = Field(default=None, description="第三方 API 响应")
class InvoiceQueryParam(BaseQueryParam):
"""发票查询参数"""
invoice_type: InvoiceTypeEnum | None = Field(None, description="发票类型")
status: int | None = Field(None, description="状态")
tenant_id: int | None = Field(None, description="租户ID")
@@ -1,141 +0,0 @@
import random
from datetime import date, datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.order.crud import OrderCRUD
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from .crud import InvoiceCRUD
from .schema import (
InvoiceApplySchema,
InvoiceCreateSchema,
InvoiceOutSchema,
InvoiceQueryParam,
)
_INVOICE_TYPE_LABEL = {
"vat_normal": "电子普通发票",
"vat_special": "增值税专用发票",
}
def _generate_invoice_no() -> str:
"""生成发票编号
返回:
- str: 形如 INV20250620123456 的发票编号
"""
today = date.today().strftime("%Y%m%d")
suffix = str(random.randint(100000, 999999))
return f"INV{today}{suffix}"
class InvoiceTenantService:
"""租户端发票服务"""
@classmethod
async def apply(cls, auth: AuthSchema, db: AsyncSession, data: InvoiceApplySchema, tenant_id: int) -> InvoiceOutSchema:
"""租户申请开票
参数:
- auth (AuthSchema): 认证信息模型
- data (InvoiceApplySchema): 发票申请参数
- tenant_id (int): 租户 ID
返回:
- InvoiceOutSchema: 发票信息
"""
# 校验:专票必填字段
if data.invoice_type == "vat_special":
if not data.tax_no:
raise CustomException(msg="增值税专用发票必须填写纳税人识别号")
if not data.bank_info:
raise CustomException(msg="增值税专用发票必须填写开户行及账号")
if not data.address_info:
raise CustomException(msg="增值税专用发票必须填写注册地址及电话")
# 校验:订单存在且已支付
order = await OrderCRUD(auth, db).get(id=data.order_id)
if not order:
raise CustomException(msg="订单不存在")
if order.status != 1:
raise CustomException(msg="仅已支付订单可申请开票")
# 校验:30 天内
if order.created_time and datetime.now() - order.created_time > timedelta(days=30):
raise CustomException(msg="订单支付超过 30 天,不可申请开票")
crud = InvoiceCRUD(auth, db)
existing = await crud.get_by_order_id(data.order_id)
if existing:
raise CustomException(msg="该订单已申请过发票")
tax_rate = 0
tax_amount = int(order.amount * tax_rate / 100)
invoice = await crud.create(
InvoiceCreateSchema(
invoice_no=_generate_invoice_no(),
order_id=data.order_id,
invoice_type=data.invoice_type,
title=data.title,
tax_no=data.tax_no,
bank_info=data.bank_info,
address_info=data.address_info,
amount=order.amount,
tax_amount=tax_amount,
description=data.description,
),
)
logger.info(f"发票申请成功: invoice_no={invoice.invoice_no}, order_id={data.order_id}")
return InvoiceOutSchema.model_validate(invoice)
@classmethod
async def list_my(
cls,
auth: AuthSchema,
db: AsyncSession,
tenant_id: int,
page_no: int,
page_size: int,
search: InvoiceQueryParam,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[InvoiceOutSchema]:
"""租户查询自己的发票列表
参数:
- auth (AuthSchema): 认证信息模型
- tenant_id (int): 租户 ID
- page_no (int): 当前页码
- page_size (int): 每页数量
- search (InvoiceQueryParam): 查询参数
- order_by (list[dict] | None): 排序字段
返回:
- PageResultSchema[InvoiceOutSchema]: 分页数据
"""
_search: dict = {"tenant_id": tenant_id}
if search.invoice_type:
_search["invoice_type"] = search.invoice_type
if search.status is not None:
_search["status"] = search.status
return await InvoiceCRUD(auth, db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"created_time": "desc"}],
search=_search,
out_schema=InvoiceOutSchema,
)
@classmethod
async def download(cls, auth: AuthSchema, db: AsyncSession, invoice_id: int, tenant_id: int) -> str:
"""获取发票 PDF 下载地址"""
crud = InvoiceCRUD(auth, db)
invoice = await crud.get_or_404(id=invoice_id, msg="发票不存在")
if hasattr(invoice, "tenant_id") and invoice.tenant_id != tenant_id:
raise CustomException(msg="发票不存在")
if invoice.status != 1 or not invoice.pdf_url:
raise CustomException(msg="发票未开具或无PDF")
return invoice.pdf_url
@@ -1,3 +0,0 @@
from .controller import OrderRouter
__all__ = ["OrderRouter"]
@@ -1,229 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Request, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.enums import RET, EnvironmentEnum
from app.common.response import ErrorResponse, ResponseSchema, SuccessResponse
from app.config.setting import settings
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.router_class import OperationLogRoute
from app.utils.payment import get_mock_gateway
from .schema import (
OrderCreateSchema,
OrderOutSchema,
OrderQueryParam,
OrderStatusMessage,
PaymentCreateOut,
PaymentStatusOut,
RefundApplySchema,
RefundReviewSchema,
)
from .service import OrderService, PaymentService, RefundService
OrderRouter = APIRouter(route_class=OperationLogRoute, prefix="/order", tags=["订单管理"])
@OrderRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建订单", response_model=ResponseSchema[OrderOutSchema])
async def order_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[OrderCreateSchema, Body(description="订单创建参数")],
) -> JSONResponse:
result = await OrderService.create_order(auth=auth, db=db, data=data)
return SuccessResponse(data=result, msg="订单创建成功")
@OrderRouter.get("/detail/{order_id}", summary="订单详情", response_model=ResponseSchema[OrderOutSchema])
async def order_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(description="订单ID", ge=1)],
) -> JSONResponse:
order = await OrderService.get_detail(auth=auth, db=db, order_id=order_id)
if not order:
raise CustomException(msg="订单不存在", code=RET.NOT_FOUND.code, status_code=404)
return SuccessResponse(data=order)
@OrderRouter.get("/list", summary="订单列表", response_model=ResponseSchema[PageResultSchema[OrderOutSchema]])
async def order_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[OrderQueryParam, Query()],
) -> JSONResponse:
items, total = await OrderService.get_list(
auth=auth,
db=db,
page_no=page.page_no,
page_size=page.page_size,
order_by=page.order_by,
search=search,
)
offset = (page.page_no - 1) * page.page_size
result = PageResultSchema(
page_no=page.page_no,
page_size=page.page_size,
total=total,
has_next=offset + page.page_size < total,
items=items,
)
return SuccessResponse(data=result)
@OrderRouter.post("/cancel/{order_id}", summary="取消订单", response_model=ResponseSchema[OrderStatusMessage])
async def order_cancel_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(description="订单ID", ge=1)],
) -> JSONResponse:
result = await OrderService.cancel_order(auth=auth, db=db, order_id=order_id)
return SuccessResponse(data=result, msg=result.message)
@OrderRouter.post("/pay/{order_id}", summary="创建支付(获取支付 URL/二维码)", response_model=ResponseSchema[PaymentCreateOut])
async def order_pay_create_controller(
request: Request,
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(description="订单ID", ge=1)],
method: Annotated[str, Query(description="支付渠道: alipay / wxpay(留空=自动)")] = "",
) -> JSONResponse:
base_url = str(request.base_url).rstrip("/")
result = await PaymentService.create_payment(auth=auth, db=db, order_id=order_id, method=method, notify_base_url=base_url)
return SuccessResponse(data=result, msg="支付信息已生成")
@OrderRouter.get("/status/{order_id}", summary="查询支付状态(供前端轮询)", response_model=ResponseSchema[PaymentStatusOut])
async def order_pay_status_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(description="订单ID", ge=1)],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:query"], check_data_scope=False))],
) -> JSONResponse:
result = await OrderService.check_payment_status(auth=auth, db=db, order_id=order_id)
return SuccessResponse(data=result)
@OrderRouter.post("/callback/{method}", summary="支付回调(统一入口)", response_model=ResponseSchema[dict])
async def order_pay_callback_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
method: Annotated[str, Path(description="支付渠道: alipay / wxpay / mock")],
data: Annotated[dict, Body(description="支付回调数据")],
) -> JSONResponse:
try:
auth = AuthSchema(check_data_scope=False)
result = await PaymentService.handle_callback(auth=auth, db=db, method=method, callback_data=data)
logger.info(f"支付回调处理成功: {result}")
return SuccessResponse(data=result)
except CustomException as e:
logger.warning(f"支付回调处理失败: {e}")
return ErrorResponse(msg=str(e))
@OrderRouter.post("/mock/callback", summary="Mock 支付回调(仅开发/测试环境可用)", response_model=ResponseSchema[dict])
async def order_pay_mock_callback_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Body(description="订单ID", ge=1)],
) -> JSONResponse:
# Mock 回调仅在 DEV 环境暴露;生产环境必须通过真实支付网关的 webhook 触发
if settings.ENVIRONMENT != EnvironmentEnum.DEV:
raise CustomException(
msg="Mock 支付回调仅在开发环境可用",
code=RET.FORBIDDEN.code,
status_code=403,
)
from .service import OrderService
auth = AuthSchema(check_data_scope=False)
order = await OrderService.get_by_id(auth, db, order_id)
if not order:
raise CustomException(msg="订单不存在", code=RET.NOT_FOUND.code, status_code=404)
mock_gw = get_mock_gateway()
callback_data = mock_gw.get_mock_callback_data(order.id, order.order_no)
result = await PaymentService.handle_callback(auth=auth, db=db, method="mock", callback_data=callback_data)
logger.info(f"Mock 支付回调触发: order_id={order_id}")
return SuccessResponse(data=result, msg="模拟支付成功")
@OrderRouter.get("/refund/list", summary="退款审核列表", response_model=ResponseSchema[PageResultSchema[OrderOutSchema]])
async def order_refund_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
status: Annotated[int | None, Query(description="退款状态筛选")] = None,
) -> JSONResponse:
offset = (page.page_no - 1) * page.page_size
items, total = await RefundService.get_list(auth=auth, db=db, refund_status=status, offset=offset, limit=page.page_size)
result = PageResultSchema(
page_no=page.page_no,
page_size=page.page_size,
total=total,
has_next=offset + page.page_size < total,
items=items,
)
return SuccessResponse(data=result)
@OrderRouter.put("/approve/{refund_id}", summary="批准退款", response_model=ResponseSchema[OrderStatusMessage])
async def order_refund_approve_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
refund_id: Annotated[int, Path(description="订单ID", ge=1)],
) -> JSONResponse:
result = await RefundService.approve(
auth=auth,
db=db,
refund_id=refund_id,
reviewer_id=auth.user.id,
operator_name=auth.user.name or "",
)
return SuccessResponse(data=result, msg=result.message)
@OrderRouter.put("/reject/{refund_id}", summary="驳回退款", response_model=ResponseSchema[OrderStatusMessage])
async def order_refund_reject_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
refund_id: Annotated[int, Path(description="订单ID", ge=1)],
data: Annotated[RefundReviewSchema, Body(description="退款驳回数据")],
) -> JSONResponse:
result = await RefundService.reject(
auth=auth,
db=db,
refund_id=refund_id,
reviewer_id=auth.user.id,
data=data,
operator_name=auth.user.name or "",
)
return SuccessResponse(data=result, msg=result.message)
@OrderRouter.post("/tenant/create", status_code=status.HTTP_201_CREATED, summary="创建订单", response_model=ResponseSchema[OrderOutSchema])
async def tenant_order_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[OrderCreateSchema, Body(description="订单创建数据")],
) -> JSONResponse:
if auth.user is None or data.tenant_id != auth.user.tenant_id:
raise CustomException(msg="无权操作", code=RET.FORBIDDEN.code, status_code=403)
result = await OrderService.create_order(auth=auth, db=db, data=data)
return SuccessResponse(data=result, msg="订单创建成功")
@OrderRouter.post("/tenant/refund/apply/{order_id}", summary="申请退款", response_model=ResponseSchema[OrderOutSchema])
async def order_refund_apply_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:order:refund"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(description="订单ID", ge=1)],
data: Annotated[RefundApplySchema, Body(description="退款申请数据")],
) -> JSONResponse:
result = await RefundService.apply(auth=auth, db=db, data=data, order_id=order_id)
return SuccessResponse(data=result, msg="退款申请已提交")
@@ -1,35 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import OrderModel
from .schema import OrderCreateInternalSchema, OrderUpdateInternalSchema
class OrderCRUD(CRUDBase[OrderModel, OrderCreateInternalSchema, OrderUpdateInternalSchema]):
"""订单 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=OrderModel, auth=auth, db=db)
async def get_by_order_no(self, order_no: str) -> OrderModel | None:
return await self.get(order_no=order_no)
async def query(
self,
*,
tenant_id: int | None = None,
status: int | None = None,
refund_status: int | None = None,
order_type: str | None = None,
offset: int = 0,
limit: int = 20,
) -> tuple[list[OrderModel], int]:
result = await self.page(
search={"tenant_id": tenant_id, "status": status, "refund_status": refund_status, "order_type": order_type},
order_by=[{"created_time": "desc"}],
offset=offset,
limit=limit,
)
return result.items, result.total
@@ -1,54 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.package.model import PackageModel
from app.api.v1.module_system.user.model import UserModel
class OrderModel(ModelMixin, TenantMixin):
"""platform_order — 订单表
支持套餐订单new/renew/upgrade/downgrade
status: 0=待支付 1=已支付 2=已取消
refund_status: 1=申请中 2=已退款 3=已驳回 (仅退款时非空)
"""
__tablename__: str = "platform_order"
__table_args__: dict[str, str] = {"comment": "订单表"}
__loader_options__: list[str] = ["tenant_by"]
order_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="订单号")
package_id: Mapped[int | None] = mapped_column(ForeignKey("platform_package.id"), nullable=True, comment="购买套餐")
order_type: Mapped[str] = mapped_column(String(20), nullable=False, comment="new/renew/upgrade/downgrade")
amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="金额(分)")
period_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment="购买周期数")
pay_method: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="alipay/wxpay")
pay_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="支付时间")
expire_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="订单过期时间(15分钟)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="0:待支付 1:已支付 2:已取消", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
# 支付信息
transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, comment="第三方交易号")
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始回调JSON")
# 退款信息
refund_no: Mapped[str | None] = mapped_column(String(32), nullable=True, unique=True, comment="退款单号")
refund_amount: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="退款金额(分)")
refund_reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="退款原因")
refund_transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="退款交易号")
reviewer_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True, comment="审核人")
review_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="审核时间")
reject_reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="驳回原因")
refund_status: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="1:申请中 2:已退款 3:已驳回", index=True)
# 关联关系
package: Mapped["PackageModel | None"] = relationship("PackageModel", lazy="selectin")
reviewer: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys=[reviewer_id], lazy="selectin")
@@ -1,162 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantBySchema
class OrderCreateInternalSchema(BaseModel):
"""订单创建(内部 CRUD 用,包含所有业务字段)"""
order_no: str = Field(..., description="订单号")
tenant_id: int = Field(..., description="租户ID")
package_id: int | None = Field(default=None, description="套餐ID")
order_type: str = Field(..., description="订单类型")
amount: int = Field(..., description="订单金额(分)")
period_count: int = Field(default=1, description="时长(月)")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: datetime | None = Field(default=None, description="支付时间")
expire_time: datetime = Field(..., description="过期时间")
status: int = Field(default=0, description="订单状态")
description: str | None = Field(default=None, description="备注")
class OrderUpdateInternalSchema(BaseModel):
"""订单更新(内部 CRUD 用)"""
status: int | None = Field(default=None, description="订单状态")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: datetime | None = Field(default=None, description="支付时间")
transaction_id: str | None = Field(default=None, description="第三方交易号")
raw_response: str | None = Field(default=None, description="原始回调JSON")
description: str | None = Field(default=None, description="备注")
# 退款字段
refund_no: str | None = Field(default=None, description="退款单号")
refund_amount: int | None = Field(default=None, description="退款金额(分)")
refund_reason: str | None = Field(default=None, description="退款原因")
refund_transaction_id: str | None = Field(default=None, description="退款交易号")
reviewer_id: int | None = Field(default=None, description="审核人ID")
review_time: datetime | None = Field(default=None, description="审核时间")
reject_reason: str | None = Field(default=None, description="驳回原因")
refund_status: int | None = Field(default=None, description="1:申请中 2:已退款 3:已驳回")
class OrderCreateSchema(BaseModel):
"""创建订单"""
tenant_id: int = Field(..., ge=1, description="租户ID")
package_id: int | None = Field(default=None, ge=1, description="套餐ID")
order_type: Literal["new", "renew", "upgrade", "downgrade"] = Field(
...,
description="订单类型(new:新购 renew:续费 upgrade:升级 downgrade:降级)",
)
pay_method: Literal["alipay", "wxpay", "free"] | None = Field(default=None, description="支付方式(留空=自动)")
@field_validator("tenant_id")
@classmethod
def positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("必须为正整数")
return v
@model_validator(mode="after")
def check_target(self) -> "OrderCreateSchema":
if not self.package_id or self.package_id <= 0:
raise ValueError("必须指定套餐")
return self
class OrderOutSchema(BaseSchema, TenantBySchema):
"""订单输出"""
model_config = ConfigDict(from_attributes=True)
order_no: str = Field(..., description="订单号")
package_id: int | None = Field(default=None, description="套餐ID")
order_type: str = Field(..., description="订单类型")
amount: int = Field(..., description="订单金额(分)")
period_count: int = Field(..., description="时长(月)")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: datetime | None = Field(default=None, description="支付时间")
expire_time: datetime = Field(..., description="过期时间")
status: int = Field(..., description="订单状态(0:待支付 1:已支付 2:已取消)")
description: str | None = Field(default=None, description="备注")
# 支付信息
transaction_id: str | None = Field(default=None, description="第三方交易号")
raw_response: str | None = Field(default=None, description="原始回调JSON")
# 退款信息
refund_no: str | None = Field(default=None, description="退款单号")
refund_amount: int | None = Field(default=None, description="退款金额(分)")
refund_reason: str | None = Field(default=None, description="退款原因")
refund_transaction_id: str | None = Field(default=None, description="退款交易号")
reviewer_id: int | None = Field(default=None, description="审核人ID")
review_time: datetime | None = Field(default=None, description="审核时间")
reject_reason: str | None = Field(default=None, description="驳回原因")
refund_status: int | None = Field(default=None, description="1:申请中 2:已退款 3:已驳回")
class OrderQueryParam(BaseQueryParam):
"""订单查询参数"""
tenant_id: int | None = Field(None, description="租户ID")
status: int | None = Field(None, description="订单状态(0:待支付 1:已支付 2:已取消)")
refund_status: int | None = Field(None, description="退款状态(1:申请中 2:已退款 3:已驳回)")
order_type: str | None = Field(None, description="订单类型", json_schema_extra={"q": "eq"})
order_no: str | None = Field(None, description="订单号")
class PaymentCallbackSchema(BaseModel):
"""支付回调数据"""
transaction_id: str | None = Field(default=None, description="交易流水号")
amount: int = Field(..., description="支付金额(分)")
order_id: int | None = Field(default=None, description="订单ID")
raw_data: dict | None = Field(default=None, description="原始数据")
class PaymentCreateOut(BaseModel):
"""创建支付结果"""
pay_url: str | None = Field(default=None, description="支付链接")
qr_code_url: str | None = Field(default=None, description="二维码链接")
trade_no: str = Field(..., description="交易流水号")
order_id: int = Field(..., description="订单ID")
order_no: str = Field(..., description="订单号")
amount: int = Field(..., description="支付金额(分)")
class PaymentStatusOut(BaseModel):
"""支付状态查询结果"""
exists: bool = Field(..., description="是否存在")
order_id: int | None = Field(default=None, description="订单ID")
status: int | None = Field(default=None, description="支付状态")
paid: bool = Field(default=False, description="是否已支付")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: str | None = Field(default=None, description="支付时间")
class OrderStatusMessage(BaseModel):
"""订单/退款操作结果消息"""
id: int = Field(..., description="订单/退款ID")
status: int = Field(..., description="状态")
message: str = Field(..., description="消息")
class RefundApplySchema(BaseModel):
"""退款申请"""
reason: str = Field(..., min_length=1, max_length=500, description="退款原因")
class RefundReviewSchema(BaseModel):
"""退款审核"""
reject_reason: str | None = Field(default=None, max_length=500, description="驳回原因(审核通过时可不填)")
@@ -1,538 +0,0 @@
import secrets
import string
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.role.model import RoleModel
from app.api.v1.module_system.user.model import UserModel
from app.core.base_schema import AuthSchema
from app.core.database import async_db_session
from app.core.event_bus import EventBus
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.payment import create_payment_gateway
from ..package.model import PackageModel
from ..tenant.model import TenantModel
from .crud import OrderCRUD
from .model import OrderModel
from .schema import (
OrderCreateInternalSchema,
OrderCreateSchema,
OrderOutSchema,
OrderQueryParam,
OrderStatusMessage,
OrderUpdateInternalSchema,
PaymentCreateOut,
PaymentStatusOut,
RefundApplySchema,
RefundReviewSchema,
)
def _generate_order_no() -> str:
"""生成订单号:YYYYMMDD + 10位加密安全随机数(碰撞概率极低)。
使用 :func:`secrets.choice` 而非 ``random``避免伪随机带来的可预测性
"""
today = datetime.now().strftime("%Y%m%d")
rand = "".join(secrets.choice(string.digits) for _ in range(10))
return f"{today}{rand}"
def _generate_refund_no() -> str:
"""生成退款单号"""
today = datetime.now().strftime("%Y%m%d")
suffix = "".join(secrets.choice(string.digits) for _ in range(6))
return f"RF{today}{suffix}"
class OrderService:
"""订单管理服务
"""
@classmethod
async def get_by_id(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> OrderModel | None:
"""获取订单模型(仅供内部 mock 等场景使用)"""
return await OrderCRUD(auth, db).get_by_id(order_id)
@classmethod
async def create_order(cls, auth: AuthSchema, db: AsyncSession, data: OrderCreateSchema, amount: int | None = None) -> OrderOutSchema:
"""创建订单
套餐订单amount 从套餐价格自动计算
免费订单amount=0自动激活
参数:
- auth (AuthSchema): 认证信息模型
- data (OrderCreateSchema): 订单创建模型
- amount (int | None): 订单金额()None 时自动计算
返回:
- OrderOutSchema: 新创建的订单详情
"""
if amount is None:
pkg = await db.get(PackageModel, data.package_id)
if not pkg:
raise CustomException(msg=f"套餐[{data.package_id}]不存在或已删除")
amount = pkg.price
order = await OrderCRUD(auth, db).create(
OrderCreateInternalSchema(
order_no=_generate_order_no(),
tenant_id=data.tenant_id,
package_id=data.package_id,
order_type=data.order_type,
amount=amount,
expire_time=datetime.now() + timedelta(minutes=15),
),
)
# 免费订单自动激活
if amount == 0:
await OrderCRUD(auth, db).update(
order.id,
OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()),
)
await PaymentService._activate_tenant_package(auth, db, order)
await db.refresh(order)
return OrderOutSchema.model_validate(order)
@classmethod
async def get_detail(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> OrderOutSchema | None:
"""订单详情
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- OrderOutSchema | None: 订单详情不存在时返回 None
"""
order = await OrderCRUD(auth, db).get_by_id(order_id)
return OrderOutSchema.model_validate(order) if order else None
@classmethod
async def get_list(
cls,
auth: AuthSchema,
db: AsyncSession,
page_no: int,
page_size: int,
search: OrderQueryParam,
order_by: list[dict[str, str]] | None = None,
) -> tuple[list, int]:
"""订单列表
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 当前页码
- page_size (int): 每页数量
- search (OrderQueryParam): 查询参数
- order_by (list[dict] | None): 排序字段
返回:
- tuple[list, int]: (订单列表, 总数)
"""
offset = (page_no - 1) * page_size
tenant_id = search.tenant_id[1] if isinstance(search.tenant_id, tuple) else search.tenant_id
status = search.status[1] if isinstance(search.status, tuple) else search.status
refund_status = search.refund_status[1] if isinstance(search.refund_status, tuple) else search.refund_status
order_type = search.order_type[1] if isinstance(search.order_type, tuple) else search.order_type
rows, total = await OrderCRUD(auth, db).query(
tenant_id=tenant_id,
status=status,
refund_status=refund_status,
order_type=order_type,
offset=offset,
limit=page_size,
)
items = [OrderOutSchema.model_validate(r) for r in rows]
return items, total
@classmethod
async def cancel_order(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> OrderStatusMessage:
"""取消订单
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- OrderStatusMessage: 取消结果
"""
crud = OrderCRUD(auth, db)
order = await crud.get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="仅待支付订单可取消")
await crud.update(order_id, OrderUpdateInternalSchema(status=2))
return OrderStatusMessage(id=order.id, status=2, message="已取消")
@classmethod
async def check_payment_status(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> PaymentStatusOut:
"""查询订单支付状态(供前端轮询用)
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
返回:
- PaymentStatusOut: 支付状态信息
"""
order = await OrderCRUD(auth, db).get_by_id(order_id)
if not order:
return PaymentStatusOut(exists=False)
return PaymentStatusOut(
exists=True,
order_id=order.id,
status=order.status,
paid=order.status == 1,
pay_method=order.pay_method,
pay_time=order.pay_time.isoformat() if order.pay_time else None,
)
@staticmethod
async def cancel_expired_orders() -> None:
now = datetime.now()
async with async_db_session() as session:
async with session.begin():
result = await session.execute(
sa_update(OrderModel)
.where(OrderModel.status == 0)
.where(OrderModel.expire_time < now)
.where(OrderModel.is_deleted == False) # noqa: E712
.values(status=2),
)
rowcount = getattr(result, "rowcount", 0)
logger.info(f"超时订单取消: 已取消 {rowcount} 条订单")
class PaymentService:
"""支付管理服务
"""
@classmethod
async def create_payment(cls, auth: AuthSchema, db: AsyncSession, order_id: int, method: str, notify_base_url: str) -> PaymentCreateOut:
"""创建支付(调用支付网关)
参数:
- auth (AuthSchema): 认证信息模型
- order_id (int): 订单ID
- method (str): 支付方式(alipay/wxpay)
- notify_base_url (str): 回调基础URL
返回:
- PaymentCreateOut: 支付创建结果支付URL/二维码
"""
order = await OrderCRUD(auth, db).get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="订单状态异常,无法支付")
if order.amount <= 0:
raise CustomException(msg="免费订单无需支付")
pkg = await db.get(PackageModel, order.package_id)
subject = f"FastapiAdmin - {pkg.name}" if pkg else "FastapiAdmin 套餐"
notify_url = f"{notify_base_url}/api/v1/platform/payment/callback/{method}" if method else ""
gateway = create_payment_gateway(method)
info = await gateway.create_payment(
order_no=order.order_no,
amount=order.amount,
subject=subject,
notify_url=notify_url,
)
return PaymentCreateOut(
pay_url=info.pay_url,
qr_code_url=info.qr_code_url,
trade_no=info.trade_no,
order_id=order.id,
order_no=order.order_no,
amount=order.amount,
)
@classmethod
async def handle_callback(cls, auth: AuthSchema, db: AsyncSession, method: str, callback_data: dict) -> dict:
"""处理支付回调
参数:
- auth (AuthSchema): 认证信息模型
- method (str): 支付方式
- callback_data (dict): 支付网关回调数据
返回:
- dict: 处理结果
"""
gateway = create_payment_gateway(method)
callback_result = await gateway.verify_callback(callback_data)
if not callback_result.verified:
logger.warning(f"支付回调验签失败: method={method} data={callback_data}")
raise CustomException(msg="支付回调验签失败")
order_no = callback_data.get("order_no") or callback_data.get("out_trade_no", "")
o_crud = OrderCRUD(auth, db)
order = None
if order_no:
order = await o_crud.get_by_order_no(order_no)
elif callback_result.order_id:
order = await o_crud.get_by_id(callback_result.order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 0:
raise CustomException(msg="订单状态异常")
if order.amount != callback_result.amount and callback_result.amount > 0:
raise CustomException(msg="金额不一致")
pid = order.package_id
tid = order.tenant_id
otype = order.order_type
oid = order.id
await o_crud.update(
oid,
OrderUpdateInternalSchema(
status=1,
pay_method=method,
pay_time=datetime.now(),
transaction_id=callback_result.transaction_id,
raw_response=str(callback_result.raw) if callback_result.raw else None,
),
)
# order 已被 update refresh,直接传给激活方法
await PaymentService._activate_tenant_package(auth, db, order)
logger.info(f"支付回调处理完成: order_id={oid} method={method} tenant_id={tid} type={otype}")
# SSE 推送支付成功通知
_pkg = await db.get(PackageModel, pid)
await EventBus.publish_tenant(
tid,
{
"type": "payment_success",
"order_no": order.order_no,
"amount": order.amount,
"package_name": _pkg.name if _pkg else "",
},
)
return {"order_id": oid, "status": 1, "message": "支付成功"}
@classmethod
async def _activate_tenant_package(cls, auth: AuthSchema, db: AsyncSession, order: OrderModel) -> None:
"""支付成功后激活套餐
参数:
- auth (AuthSchema): 认证信息模型
- order (OrderModel): 订单模型
返回:
- None
"""
pkg = await db.get(PackageModel, order.package_id)
if not pkg:
logger.warning(f"支付回调:套餐 {order.package_id} 不存在,跳过激活")
return
tenant = await db.get(TenantModel, order.tenant_id)
if not tenant:
logger.warning(f"支付回调:租户 {order.tenant_id} 不存在,跳过激活")
return
now = datetime.now()
period_months = order.period_count or 1
duration = timedelta(days=30 * period_months)
if order.order_type == "new":
tenant.package_id = order.package_id
tenant.start_time = now
tenant.end_time = now + duration
tenant.status = 0
logger.info(f"租户[{tenant.name}]新开通 {pkg.name},有效期至 {tenant.end_time}")
elif order.order_type == "renew":
base = tenant.end_time if tenant.end_time and tenant.end_time > now else now
tenant.end_time = base + duration
tenant.status = 0
logger.info(f"租户[{tenant.name}]续费 {pkg.name},续至 {tenant.end_time}")
elif order.order_type in ("upgrade", "downgrade"):
if order.order_type == "downgrade":
await PaymentService._check_downgrade_quota(auth, db, order.tenant_id, pkg)
tenant.package_id = order.package_id
tenant.status = 0
logger.info(f"租户[{tenant.name}]套餐变更 {'升级' if order.order_type == 'upgrade' else '降级'}{pkg.name}")
await db.flush()
@classmethod
async def _check_downgrade_quota(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int, new_pkg: "PackageModel") -> None:
"""降级前检查:租户当前资源数是否超过新套餐限额
参数:
- auth (AuthSchema): 认证信息模型
- tenant_id (int): 租户ID
- new_pkg (object): 目标套餐
返回:
- None
"""
checks = {
"用户": (UserModel, new_pkg.max_users),
"角色": (RoleModel, new_pkg.max_roles),
"部门": (DeptModel, new_pkg.max_depts),
}
for label, (model, limit) in checks.items():
if limit <= 0:
continue
count_stmt = (
select(func.count())
.select_from(model)
.where(
model.tenant_id == tenant_id,
model.is_deleted.is_(False),
)
)
result = await db.execute(count_stmt)
current = result.scalar() or 0
if current > limit:
raise CustomException(msg=f"降级失败:当前租户已有 {current}{label},超过目标套餐限额 {limit}")
class RefundService:
"""退款管理服务
"""
@classmethod
async def apply(cls, auth: AuthSchema, db: AsyncSession, data: RefundApplySchema, order_id: int) -> OrderOutSchema:
"""申请退款
参数:
- auth (AuthSchema): 认证信息模型
- data (RefundApplySchema): 退款申请模型
- order_id (int): 订单ID
返回:
- OrderOutSchema: 更新后的订单详情
"""
crud = OrderCRUD(auth, db)
order = await crud.get_by_id(order_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.status != 1:
raise CustomException(msg="仅已支付订单可退款")
if order.amount == 0:
raise CustomException(msg="免费套餐不支持退款")
if order.pay_time and (datetime.now() - order.pay_time).days > 7:
raise CustomException(msg="已超过 7 天退款时限")
if order.refund_status and order.refund_status != 3:
raise CustomException(msg="存在进行中的退款申请")
orders = await crud.update(
order_id,
OrderUpdateInternalSchema(
refund_no=_generate_refund_no(),
refund_amount=order.amount,
refund_reason=data.reason,
refund_status=1,
),
)
return OrderOutSchema.model_validate(orders)
@classmethod
async def get_list(cls, auth: AuthSchema, db: AsyncSession, refund_status: int | None, offset: int, limit: int) -> tuple[list, int]:
"""退款列表
参数:
- auth (AuthSchema): 认证信息模型
- refund_status (int | None): 退款状态筛选
- offset (int): 偏移量
- limit (int): 每页数量
返回:
- tuple[list, int]: (订单列表, 总数)
"""
rows, total = await OrderCRUD(auth, db).query(refund_status=refund_status, offset=offset, limit=limit)
items = [OrderOutSchema.model_validate(r) for r in rows]
return items, total
@classmethod
async def approve(cls, auth: AuthSchema, db: AsyncSession, refund_id: int, reviewer_id: int, operator_name: str = "") -> OrderStatusMessage:
"""批准退款
参数:
- auth (AuthSchema): 认证信息模型
- refund_id (int): 订单ID refund_id 就是 order_id
- reviewer_id (int): 审核人ID
- operator_name (str): 操作人名称
返回:
- OrderStatusMessage: 审核结果
"""
crud = OrderCRUD(auth, db)
order = await crud.get_by_id(refund_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.refund_status != 1:
raise CustomException(msg="仅申请中可审核")
await crud.update(
refund_id,
OrderUpdateInternalSchema(
refund_status=2,
status=1,
reviewer_id=reviewer_id,
review_time=datetime.now(),
),
)
return OrderStatusMessage(id=order.id, status=2, message="已批准退款")
@classmethod
async def reject(
cls,
auth: AuthSchema,
db: AsyncSession,
refund_id: int,
reviewer_id: int,
data: RefundReviewSchema,
operator_name: str = "",
) -> OrderStatusMessage:
"""驳回退款
参数:
- auth (AuthSchema): 认证信息模型
- refund_id (int): 订单ID refund_id 就是 order_id
- reviewer_id (int): 审核人ID
- data (RefundReviewSchema): 驳回原因
- operator_name (str): 操作人名称
返回:
- OrderStatusMessage: 审核结果
"""
crud = OrderCRUD(auth, db)
order = await crud.get_by_id(refund_id)
if not order:
raise CustomException(msg="该数据不存在")
if order.refund_status != 1:
raise CustomException(msg="仅申请中可审核")
await crud.update(
refund_id,
OrderUpdateInternalSchema(
refund_status=3,
reviewer_id=reviewer_id,
review_time=datetime.now(),
reject_reason=data.reject_reason,
),
)
return OrderStatusMessage(id=order.id, status=3, message="已驳回")
@@ -1,3 +0,0 @@
from .controller import PackageRouter
__all__ = ["PackageRouter"]
@@ -1,122 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
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=["套餐管理"])
_PKG_NS = "package"
@PackageRouter.get("/options", summary="获取套餐下拉选项", response_model=ResponseSchema[list[dict[str, int | str]]])
async def get_package_options_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
options = await PackageService(auth, db).get_options()
return SuccessResponse(data=options, msg="获取套餐选项成功")
@PackageRouter.get("/detail/{id}", summary="获取套餐详情", response_model=ResponseSchema[PackageOutSchema])
@cache(expire=300, namespace=_PKG_NS)
async def get_obj_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="套餐ID", ge=1)],
) -> JSONResponse:
result_dict = await PackageService(auth, db).detail(id=id)
return SuccessResponse(data=result_dict, msg="获取套餐详情成功")
@PackageRouter.get("/list", summary="获取套餐列表", response_model=ResponseSchema[PageResultSchema[PackageOutSchema]])
async def get_obj_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[PackageQueryParam, Query()],
) -> JSONResponse:
result_dict = await PackageService(auth, db).page(
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", status_code=status.HTTP_201_CREATED, summary="创建套餐", response_model=ResponseSchema[PackageOutSchema])
async def create_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[PackageCreateSchema, Body(description="套餐信息")],
) -> JSONResponse:
result_dict = await PackageService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_PKG_NS)
return SuccessResponse(data=result_dict, msg="创建成功")
@PackageRouter.put("/update/{id}", summary="更新套餐", response_model=ResponseSchema[PackageOutSchema])
async def update_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="套餐ID", ge=1)],
data: Annotated[PackageUpdateSchema, Body(description="套餐信息")],
) -> JSONResponse:
result_dict = await PackageService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_PKG_NS)
return SuccessResponse(data=result_dict, msg="更新成功")
@PackageRouter.delete("/delete", summary="删除套餐", response_model=ResponseSchema)
async def delete_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await PackageService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_PKG_NS)
return SuccessResponse(msg="删除成功")
@PackageRouter.patch("/status/batch", summary="批量修改状态", response_model=ResponseSchema)
async def set_available_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
for id in data.ids:
await PackageService(auth, db).update(id=id, data=PackageUpdateSchema(status=data.status))
await FastAPICache.clear(namespace=_PKG_NS)
return SuccessResponse(msg="状态设置成功")
@PackageRouter.get("/menus/{package_id}", summary="获取套餐菜单", response_model=ResponseSchema[list[int]])
async def get_menus_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
package_id: Annotated[int, Path(description="套餐ID", ge=1)],
) -> JSONResponse:
result = await PackageService(auth, db).get_menus(package_id=package_id)
return SuccessResponse(data=result, msg="获取成功")
@PackageRouter.post("/menus/{package_id}/set", summary="设置套餐菜单", response_model=ResponseSchema)
async def set_menus_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_package:package:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
package_id: Annotated[int, Path(description="套餐ID", ge=1)],
data: Annotated[PackageMenuSetSchema, Body(description="菜单列表")],
) -> JSONResponse:
await PackageService(auth, db).set_menus(package_id=package_id, data=data)
return SuccessResponse(msg="设置成功")
@@ -1,21 +0,0 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import PackageModel
from .schema import PackageCreateSchema, PackageUpdateSchema
class PackageCRUD(CRUDBase[PackageModel, PackageCreateSchema, PackageUpdateSchema]):
"""套餐模块 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=PackageModel, auth=auth, db=db)
async def get_options(self) -> list[dict[str, Any]]:
"""获取套餐下拉选项,返回 [{value, label}]"""
items = await self.get_list(search={"status": 0})
return [{"value": item.id, "label": item.name} for item in items]
@@ -1,57 +0,0 @@
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, validates
from app.core.base_model import MappedBase, ModelMixin
class PackageModel(ModelMixin):
"""套餐模型 - 定义租户可用的功能套餐
status: 0=正常 1=禁用
"""
__tablename__: str = "platform_package"
__table_args__: dict[str, str] = {"comment": "租户套餐表"}
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐名称")
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐编码")
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
price: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="价格(分)")
period: Mapped[str] = mapped_column(String(10), nullable=False, default="month", comment="计费周期(month/year)")
trial_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="免费试用天数")
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=10, comment="最大用户数")
max_roles: Mapped[int] = mapped_column(Integer, nullable=False, default=5, comment="最大角色数")
max_depts: Mapped[int] = mapped_column(Integer, nullable=False, default=10, comment="最大部门数")
max_storage_mb: Mapped[int] = mapped_column(Integer, nullable=False, default=1024, comment="最大存储(MB)")
rate_limit: Mapped[int] = mapped_column(Integer, nullable=False, default=60, comment="API速率限制(请求/10秒)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, 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("platform_menu.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False, index=True, comment="菜单ID")
@@ -1,103 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, 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: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="描述")
sort: int = Field(default=0, ge=0, description="排序")
price: int = Field(default=0, ge=0, description="价格(分)")
period: str = Field(default="month", pattern=r"^(month|year)$", description="计费周期")
trial_days: int = Field(default=0, ge=0, description="免费试用天数")
max_users: int = Field(default=10, ge=0, description="最大用户数")
max_roles: int = Field(default=5, ge=0, description="最大角色数")
max_depts: int = Field(default=10, ge=0, description="最大部门数")
max_storage_mb: int = Field(default=1024, ge=0, description="最大存储(MB)")
rate_limit: int = Field(default=60, ge=10, description="API速率限制(请求/10秒)")
@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: int) -> int:
if v not in {0, 1}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
class PackageUpdateSchema(PackageCreateSchema):
"""更新套餐"""
name: str | None = Field(default=None, max_length=100, description="套餐名称") # type: ignore[assignment]
code: str | None = Field(default=None, max_length=100, description="套餐编码") # type: ignore[assignment]
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
sort: int | None = Field(default=None, ge=0, description="排序")
description: str | None = Field(default=None, max_length=255, description="描述")
price: int | None = Field(default=None, ge=0, description="价格(分)")
period: str | None = Field(default=None, pattern=r"^(month|year)$", description="计费周期")
trial_days: int | None = Field(default=None, ge=0, description="免费试用天数")
max_users: int | None = Field(default=None, ge=0, description="最大用户数")
max_roles: int | None = Field(default=None, ge=0, description="最大角色数")
max_depts: int | None = Field(default=None, ge=0, description="最大部门数")
max_storage_mb: int | None = Field(default=None, ge=0, description="最大存储(MB)")
rate_limit: int | None = Field(default=None, ge=10, description="API速率限制(请求/10秒)")
@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: int | None) -> int | 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(BaseQueryParam):
"""套餐查询参数"""
name: str | None = Field(None, description="套餐名称")
code: str | None = Field(None, description="套餐编码", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
class PackageMenuSetSchema(BaseModel):
"""批量设置套餐菜单权限"""
menu_ids: list[int] = Field(..., description="菜单ID列表")
@@ -1,172 +0,0 @@
from typing import Any
import sqlalchemy as sa
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.menu.model import MenuModel
from app.api.v1.module_platform.tenant.model import TenantModel
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict
from .crud import PackageCRUD
from .model import PackageMenuModel, PackageModel
from .schema import (
PackageCreateSchema,
PackageMenuSetSchema,
PackageOutSchema,
PackageQueryParam,
PackageUpdateSchema,
)
class PackageService:
"""套餐管理服务(仅超级管理员可操作)"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def get_options(self) -> list[dict[str, Any]]:
"""获取套餐下拉选项,委托给 PackageCRUD"""
return await PackageCRUD(self.auth, self.db).get_options()
async def detail(self, id: int) -> PackageOutSchema:
obj = await PackageCRUD(self.auth, self.db).get_or_404(id=id)
return PackageOutSchema.model_validate(obj)
async def page(
self,
page_no: int,
page_size: int,
search: PackageQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[PackageOutSchema]:
search_dict = search_to_dict(search)
return await PackageCRUD(self.auth, self.db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"sort": "asc"}, {"id": "asc"}],
search=search_dict,
out_schema=PackageOutSchema,
)
async def create(self, data: PackageCreateSchema) -> PackageOutSchema:
if await PackageCRUD(self.auth, self.db).get(name=data.name):
raise CustomException(msg="创建失败,套餐名称已存在")
if await PackageCRUD(self.auth, self.db).get(code=data.code):
raise CustomException(msg="创建失败,套餐编码已存在")
obj = await PackageCRUD(self.auth, self.db).create(data=data)
result = PackageOutSchema.model_validate(obj)
logger.info(f"创建套餐成功: {result.name}")
return result
async def update(self, id: int, data: PackageUpdateSchema) -> PackageOutSchema:
obj = await PackageCRUD(self.auth, self.db).get_or_404(id=id)
if data.name is not None:
exist = await PackageCRUD(self.auth, self.db).get(name=data.name)
if exist and exist.id != id:
raise CustomException(msg="更新失败,名称重复")
if data.code is not None:
exist = await PackageCRUD(self.auth, self.db).get(code=data.code)
if exist and exist.id != id:
raise CustomException(msg="更新失败,编码重复")
if data.status is not None and data.status == 1 and obj.status == 0:
await self.disable_cascade(package_id=id)
updated = await PackageCRUD(self.auth, self.db).update(id=id, data=data)
return PackageOutSchema.model_validate(updated)
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
# 批量查询套餐被租户引用情况(一次查询代替 N 次)
stmt = select(TenantModel.package_id, func.count()).where(
TenantModel.package_id.in_(ids)
).group_by(TenantModel.package_id)
result = await self.db.execute(stmt)
rows = result.all()
used_map = {row[0]: row[1] for row in rows}
for pid in ids:
count = used_map.get(pid, 0)
if count and count > 0:
raise CustomException(msg=f"套餐 ID={pid} 已被 {count} 个租户使用,无法删除")
await PackageCRUD(self.auth, self.db).delete(ids=ids)
async def disable_cascade(self, package_id: int) -> None:
"""停用套餐的级联动作:
- 把所有引用此套餐的状态为 normal 的租户切换为 ``suspended``不再可登录
- 不会物理删除租户或业务数据避免误伤管理员后续可恢复
注意原实现只 log 不生效已修复
"""
from sqlalchemy import update as sa_update # noqa
# 先 SELECT 统计受影响行数(SQLAlchemy 2.x async 下 ``Result.rowcount`` 不可用)
stmt_count = (
select(func.count(TenantModel.id))
.where(TenantModel.package_id == package_id, TenantModel.status == 0)
)
count = (await self.db.execute(stmt_count)).scalar_one()
if count == 0:
return
stmt = (
sa_update(TenantModel)
.where(TenantModel.package_id == package_id, TenantModel.status == 0)
.values(status=2) # TenantStatusEnum.SUSPENDED
)
await self.db.execute(stmt)
await self.db.flush()
logger.warning(f"套餐[{package_id}]已禁用,已级联冻结 {count} 个租户(status=2 suspended")
async def get_menus(self, package_id: int) -> list[int]:
stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == package_id)
result = await self.db.execute(stmt)
return [row[0] for row in result.all()]
async def set_menus(self, package_id: int, data: PackageMenuSetSchema) -> None:
await self.db.execute(sa.delete(PackageMenuModel).where(PackageMenuModel.package_id == package_id))
for menu_id in data.menu_ids:
self.db.add(PackageMenuModel(package_id=package_id, menu_id=menu_id))
await self.db.flush()
logger.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}")
async def get_package_menu_ids(self, package_id: int) -> list[int]:
stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == package_id)
result = await self.db.execute(stmt)
return [row[0] for row in result.all()]
async def get_tenant_available_menu_ids(self, tenant_id: int) -> list[int]:
if tenant_id == 1:
menu_stmt = select(MenuModel.id).where(MenuModel.status == 0)
result = await self.db.execute(menu_stmt)
return [row[0] for row in result.all()]
stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1)
result = await self.db.execute(stmt)
tenant = result.scalar_one_or_none()
if not tenant:
return []
if not tenant.package_id:
return []
pkg_stmt = select(PackageModel.status).where(PackageModel.id == tenant.package_id).limit(1)
pkg_result = await self.db.execute(pkg_stmt)
pkg_status = pkg_result.scalar_one_or_none()
if pkg_status != 0:
return []
menu_stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == tenant.package_id)
result = await self.db.execute(menu_stmt)
return [row[0] for row in result.all()]
@@ -1,8 +0,0 @@
# 见 docs/PLUGIN_ARCHITECTURE.md
name = "platform"
title = "平台"
version = "1.0.0"
description = "平台功能;路由由 module_platform/**/controller 动态注册。"
optional = true
tags = ["platform"]
@@ -1,278 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter, get_current_user, redis_getter
from app.core.router_class import OperationLogRoute
from .schema import (
PackageAvailableOut,
PackageChangePreviewOut,
PackagePreviewOut,
SelfOrderCreate,
SelfOrderDetailOut,
SelfOrderListOut,
SelfOrderOut,
TenantConfigItem,
TenantConfigOutSchema,
TenantCreateSchema,
TenantOutSchema,
TenantQueryParam,
TenantRenewSchema,
TenantUpdateSchema,
TenantUserAddSchema,
TenantUserOutSchema,
WorkspaceOut,
)
from .service import TenantService
TenantRouter = APIRouter(route_class=OperationLogRoute, prefix="/tenant", tags=["租户管理"])
_TENANT_NS = "tenant"
@TenantRouter.get("/detail/{id}", summary="获取租户详情", response_model=ResponseSchema[TenantOutSchema])
@cache(expire=120, namespace=_TENANT_NS)
async def get_obj_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
) -> JSONResponse:
result_dict = await TenantService(auth, db).detail(id=id)
return SuccessResponse(data=result_dict, msg="获取租户详情成功")
@TenantRouter.get("/list", summary="查询租户列表", response_model=ResponseSchema[PageResultSchema[TenantOutSchema]])
async def get_obj_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TenantQueryParam, Query()],
) -> JSONResponse:
result_dict = await TenantService(auth, db).page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result_dict, msg="查询租户列表成功")
@TenantRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建租户", response_model=ResponseSchema[TenantOutSchema])
async def create_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TenantCreateSchema, Body(description="租户创建参数")],
) -> JSONResponse:
result_dict = await TenantService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(data=result_dict, msg="创建租户成功")
@TenantRouter.put("/update/{id}", summary="修改租户", response_model=ResponseSchema[TenantOutSchema])
async def update_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
data: Annotated[TenantUpdateSchema, Body(description="租户更新参数")],
) -> JSONResponse:
result_dict = await TenantService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(data=result_dict, msg="修改租户成功")
@TenantRouter.delete("/delete", summary="删除租户", response_model=ResponseSchema[None])
async def delete_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="租户ID列表")],
) -> JSONResponse:
await TenantService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(msg="删除租户成功")
@TenantRouter.patch("/status/batch", summary="批量修改租户状态", response_model=ResponseSchema[None])
async def batch_set_available_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await TenantService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(msg="批量修改租户状态成功")
@TenantRouter.put("/status/{id}", summary="启/禁用租户", response_model=ResponseSchema[None])
async def toggle_tenant_status_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
) -> JSONResponse:
await TenantService(auth, db).toggle_status(id=id)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(msg="修改租户状态成功")
@TenantRouter.get("/{id}/users", summary="获取租户用户列表", response_model=ResponseSchema[list[TenantUserOutSchema]])
@cache(expire=120, namespace=_TENANT_NS)
async def get_tenant_users_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
) -> JSONResponse:
result = await TenantService(auth, db).get_tenant_users(tenant_id=id)
return SuccessResponse(data=result, msg="获取租户用户列表成功")
@TenantRouter.post("/{id}/users", status_code=status.HTTP_201_CREATED, summary="向租户添加用户", response_model=ResponseSchema[None])
async def add_tenant_user_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID")],
data: Annotated[TenantUserAddSchema, Body(description="添加用户参数")],
) -> JSONResponse:
await TenantService(auth, db).add_tenant_user(tenant_id=id, data=data)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(msg="添加用户成功")
@TenantRouter.delete("/{id}/users/{uid}", summary="从租户移除用户", response_model=ResponseSchema[None])
async def remove_tenant_user_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
uid: Annotated[int, Path(description="用户ID", ge=1)],
) -> JSONResponse:
await TenantService(auth, db).remove_tenant_user(tenant_id=id, user_id=uid)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(msg="移除用户成功")
@TenantRouter.get("/{id}/config", summary="获取租户配置", response_model=ResponseSchema[list[TenantConfigOutSchema]])
async def get_tenant_config_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID")],
) -> JSONResponse:
result = await TenantService(auth, db).get_config_items(tenant_id=id)
return SuccessResponse(data=result, msg="获取租户配置成功")
@TenantRouter.get("/{id}/config/info", summary="获取租户配置(公开-缓存)", response_model=ResponseSchema[list[TenantConfigOutSchema]])
async def get_tenant_config_info_controller(
redis: Annotated[Redis, Depends(redis_getter)],
id: Annotated[int, Path(description="租户ID")],
) -> JSONResponse:
result = await TenantService.get_config_cache_items(redis=redis, tenant_id=id)
return SuccessResponse(data=result, msg="获取租户配置成功")
@TenantRouter.put("/{id}/config", summary="更新租户配置", response_model=ResponseSchema[list[TenantConfigOutSchema]])
async def update_tenant_config_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
redis: Annotated[Redis, Depends(redis_getter)],
id: Annotated[int, Path(description="租户ID")],
data: Annotated[list[TenantConfigItem], Body(description="配置项列表")],
) -> JSONResponse:
config_dict = {item.key: item.value for item in data}
result = await TenantService(auth, db).update_config(redis=redis, tenant_id=id, config=config_dict)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(data=result, msg="更新租户配置成功")
@TenantRouter.put("/renew/{id}", summary="租户续期", response_model=ResponseSchema[TenantOutSchema])
async def renew_tenant_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID", ge=1)],
data: Annotated[TenantRenewSchema, Body(description="续费参数")],
) -> JSONResponse:
end_time_str = data.end_time.isoformat() if hasattr(data.end_time, "isoformat") else str(data.end_time)
result = await TenantService(auth, db).renew(tenant_id=id, end_time=end_time_str)
await FastAPICache.clear(namespace=_TENANT_NS)
return SuccessResponse(data=result, msg="租户续期成功")
@TenantRouter.get("/{id}/package-change-preview", summary="套餐变更影响预览", response_model=ResponseSchema[PackageChangePreviewOut])
async def package_change_preview_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:tenant:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="租户ID")],
new_package_id: Annotated[int, Query(description="目标套餐ID")],
) -> JSONResponse:
result = await TenantService(auth, db).package_change_preview(tenant_id=id, new_package_id=new_package_id)
return SuccessResponse(data=result, msg="套餐变更预览成功")
@TenantRouter.get("/package/available", summary="可选套餐列表", response_model=ResponseSchema[PackageAvailableOut])
async def package_available_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["tenant:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await TenantService.get_available_packages(auth=auth, db=db, tenant_id=auth.user.tenant_id if auth.user else 0)
return SuccessResponse(data=result, msg="查询成功")
@TenantRouter.get("/package/preview", summary="套餐变更影响预览", response_model=ResponseSchema[PackagePreviewOut])
async def package_preview_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["tenant:package:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
target_package_id: Annotated[int, Query(ge=1, description="目标套餐ID")],
) -> JSONResponse:
result = await TenantService.preview_package_change(auth=auth, db=db, tenant_id=auth.user.tenant_id if auth.user else 0, target_package_id=target_package_id)
return SuccessResponse(data=result, msg="查询成功")
@TenantRouter.post("/order/create", status_code=status.HTTP_201_CREATED, summary="创建自助订单", response_model=ResponseSchema[SelfOrderOut])
async def order_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["tenant:order:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[SelfOrderCreate, Body(description="自助订单创建参数")],
) -> JSONResponse:
result = await TenantService.create_self_order(auth=auth, db=db, tenant_id=auth.user.tenant_id if auth.user else 0, data=data)
return SuccessResponse(data=result, msg="订单创建成功")
@TenantRouter.get("/order/list", summary="我的订单列表", response_model=ResponseSchema[SelfOrderListOut])
async def order_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["tenant:order:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
) -> JSONResponse:
result = await TenantService.get_self_order_list(
auth=auth,
db=db,
tenant_id=auth.user.tenant_id if auth.user else 0,
page_no=page.page_no,
page_size=page.page_size,
order_by=page.order_by,
)
return SuccessResponse(data=result, msg="查询成功")
@TenantRouter.get("/order/detail/{order_id}", summary="订单详情", response_model=ResponseSchema[SelfOrderDetailOut])
async def order_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["tenant:order:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
order_id: Annotated[int, Path(ge=1, description="订单ID")],
) -> JSONResponse:
result = await TenantService.get_self_order_detail(auth=auth, db=db, order_id=order_id)
return SuccessResponse(data=result, msg="查询成功")
@TenantRouter.get("/workspace", summary="租户工作台概览", response_model=ResponseSchema[WorkspaceOut])
async def tenant_workspace_controller(
auth: Annotated[AuthSchema, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await TenantService.get_workspace_data(auth=auth, db=db, tenant_id=auth.user.tenant_id if auth.user else 0)
return SuccessResponse(data=result, msg="查询成功")
@@ -1,14 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import TenantModel
from .schema import TenantCreateSchema, TenantUpdateSchema
class TenantCRUD(CRUDBase[TenantModel, TenantCreateSchema, TenantUpdateSchema]):
"""租户数据层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=TenantModel, auth=auth, db=db)
@@ -1,87 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import MappedBase, ModelMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.package.model import PackageModel
class TenantModel(ModelMixin):
"""租户模型 - 单一大表设计
- 系统租户(id=1)平台管理由超级管理员维护不受套餐限制
- 普通租户(id>1)配额和菜单通过关联的 Package 控制
- 配置字段直接集成到主表
"""
__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="结束时间")
grace_start_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, comment="宽限期开始时间")
version: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None, comment="版本号")
favicon: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="favicon地址")
login_bg: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="登录背景地址")
copyright: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None, comment="版权信息")
keep_record: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None, comment="备案号")
help_doc: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="帮助文档地址")
privacy: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="隐私政策地址")
clause: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="服务条款地址")
git_code: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="源码地址")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="0:正常 1:宽限期 2:暂停 3:冻结 4:过期 5:归档", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
# 关联关系
package: Mapped["PackageModel | None"] = relationship("PackageModel", lazy="selectin")
@validates("name")
def validate_name(self, key: str, name: str) -> str:
if not name or not name.strip():
raise ValueError("名称不能为空")
return name
@validates("code")
def validate_code(self, key: str, code: str) -> str:
if not code or not code.strip():
raise ValueError("编码不能为空")
if not code.isalnum():
raise ValueError("编码只能包含字母和数字")
return code
class TenantUserModel(MappedBase):
"""用户-租户关联表
支持一个用户关联多个租户如顾问在多个租户间切换
每个用户有一个默认租户is_default=1用于登录后的默认上下文
"""
__tablename__: str = "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="创建时间")
@@ -1,453 +0,0 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.common.enums import OrderTypeEnum
from app.core.base_schema import BaseQueryParam, BaseSchema
from app.core.validator import DateTimeStr, email_validator, mobile_validator
PackageAction = Literal["buy", "renew", "upgrade", "downgrade"]
PayMethod = Literal["alipay", "wxpay", "free"]
class TenantCreateSchema(BaseModel):
"""新增租户"""
name: str = Field(..., min_length=1, max_length=100, description="租户名称")
code: str = Field(..., min_length=2, max_length=100, description="租户编码")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, 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")
version: str | None = Field(default=None, max_length=20, description="版本号")
favicon: str | None = Field(default=None, max_length=500, description="favicon地址")
login_bg: str | None = Field(default=None, max_length=500, description="登录背景地址")
copyright: str | None = Field(default=None, max_length=255, description="版权信息")
keep_record: str | None = Field(default=None, max_length=100, description="备案号")
help_doc: str | None = Field(default=None, max_length=500, description="帮助文档地址")
privacy: str | None = Field(default=None, max_length=500, description="隐私政策地址")
clause: str | None = Field(default=None, max_length=500, description="服务条款地址")
git_code: str | None = Field(default=None, max_length=500, 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: int) -> int:
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(TenantCreateSchema):
"""更新租户"""
name: str | None = Field(default=None, max_length=100, description="租户名称") # type: ignore[assignment]
code: str | None = Field(default=None, max_length=100, description="租户编码") # type: ignore[assignment]
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, 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")
version: str | None = Field(default=None, max_length=20, description="版本号")
favicon: str | None = Field(default=None, max_length=500, description="favicon地址")
login_bg: str | None = Field(default=None, max_length=500, description="登录背景地址")
copyright: str | None = Field(default=None, max_length=255, description="版权信息")
keep_record: str | None = Field(default=None, max_length=100, description="备案号")
help_doc: str | None = Field(default=None, max_length=500, description="帮助文档地址")
privacy: str | None = Field(default=None, max_length=500, description="隐私政策地址")
clause: str | None = Field(default=None, max_length=500, description="服务条款地址")
git_code: str | None = Field(default=None, max_length=500, 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: int | None) -> int | 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 TenantAdminInfo(BaseModel):
"""租户初始化管理员账号信息(密码仅在创建租户时一次性返回)"""
model_config = ConfigDict(from_attributes=True)
username: str = Field(..., description="初始管理员用户名")
initial_password: str = Field(..., description="初始明文密码(仅此一次返回,调用方需妥善保管)")
must_change_password: bool = Field(default=True, description="是否必须修改初始密码(首次登录强制改密)")
class TenantCreateResult(BaseModel):
"""创建租户响应:租户基础信息 + 初始化管理员账号信息"""
tenant: TenantOutSchema
admin: TenantAdminInfo
class TenantQueryParam(BaseQueryParam):
"""租户查询参数"""
name: str | None = Field(None, description="租户名称")
code: str | None = Field(None, description="租户编码", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 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="用户姓名")
class TenantConfigItem(BaseModel):
"""租户配置项"""
key: str = Field(..., description="配置键")
value: str | None = Field(default=None, description="配置值")
class TenantConfigOutSchema(BaseModel):
"""租户配置响应"""
model_config = ConfigDict(from_attributes=True)
config_key: str = Field(..., description="配置键")
config_value: str | None = Field(default=None, description="配置值")
class TenantRenewSchema(BaseModel):
"""租户续期"""
end_time: DateTimeStr = Field(..., description="新的结束时间")
@model_validator(mode="after")
def _validate_end_time(self):
from datetime import datetime
if self.end_time <= datetime.now():
raise ValueError("续期时间必须晚于当前时间")
return self
class PackageChangePreviewOut(BaseModel):
"""套餐变更影响预览响应"""
new_package_id: int = Field(..., description="新套餐ID")
new_package_name: str = Field(default="", description="新套餐名称")
affected_roles: list[dict] = Field(default_factory=list, description="受影响的角色列表(名称、用户数)")
removed_menus: list[dict] = Field(default_factory=list, description="将被移除的菜单清单(名称、路径)")
added_menus: list[dict] = Field(default_factory=list, description="新增的菜单清单(名称、路径)")
quota_changes: dict = Field(default_factory=dict, description="配额变化对比")
total_affected_users: int = Field(default=0, description="受影响用户数总计")
class PackageAvailableItem(BaseModel):
"""可选套餐项
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="套餐ID")
name: str = Field(..., description="套餐名称")
price: int = Field(..., ge=0, description="价格(分)")
period: str = Field(..., description="计费周期(month/year)")
trial_days: int = Field(default=0, ge=0, description="试用天数")
max_users: int = Field(default=0, ge=0, description="最大用户数")
max_roles: int = Field(default=0, ge=0, description="最大角色数")
max_depts: int = Field(default=0, ge=0, description="最大部门数")
max_storage_mb: int = Field(default=0, ge=0, description="最大存储(MB)")
description: str | None = Field(default=None, description="套餐描述")
is_current: bool = Field(default=False, description="是否为当前套餐")
available_actions: list[PackageAction] = Field(default_factory=list, description="可执行操作列表")
class PackageAvailableOut(BaseModel):
"""可选套餐列表
"""
model_config = ConfigDict(from_attributes=True)
current_package_id: int | None = Field(default=None, description="当前套餐ID")
packages: list[PackageAvailableItem] = Field(default_factory=list, description="可选套餐列表")
class PackagePreviewOut(BaseModel):
"""套餐变更预览结果
"""
model_config = ConfigDict(from_attributes=True)
current_package: str = Field(default="", description="当前套餐名称")
target_package: str = Field(default="", description="目标套餐名称")
action: PackageAction = Field(default="buy", description="操作类型")
amount: int = Field(default=0, ge=0, description="金额(分)")
period: str = Field(default="", description="计费周期")
gained_menus: list[dict] = Field(default_factory=list, description="新增菜单清单")
lost_menus: list[dict] = Field(default_factory=list, description="移除菜单清单")
affected_roles: list[str] = Field(default_factory=list, description="受影响的角色名")
affected_users: int = Field(default=0, ge=0, description="受影响用户数")
class SelfOrderCreate(BaseModel):
"""自助订单创建
"""
package_id: int = Field(..., ge=1, description="套餐ID")
order_type: PackageAction = Field(..., description="订单类型(buy/renew/upgrade/downgrade)")
class SelfOrderOut(BaseModel):
"""自助订单创建结果
"""
model_config = ConfigDict(from_attributes=True)
order_id: int = Field(..., description="订单ID")
order_no: str = Field(..., description="订单号")
amount: int = Field(..., ge=0, description="订单金额(分)")
need_pay: bool = Field(..., description="是否需要支付")
class SelfOrderListItem(BaseModel):
"""我的订单列表项
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="订单ID")
order_no: str = Field(..., description="订单号")
package_name: str = Field(default="", description="套餐名称")
order_type: OrderTypeEnum = Field(..., description="订单类型")
amount: int = Field(..., ge=0, description="订单金额(分)")
status: int = Field(..., description="订单状态(0:待支付 1:已支付 2:已取消 3:已退款)")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: str | None = Field(default=None, description="支付时间")
created_at: str | None = Field(default=None, description="创建时间")
class SelfOrderListOut(BaseModel):
"""我的订单列表
"""
model_config = ConfigDict(from_attributes=True)
items: list[SelfOrderListItem] = Field(default_factory=list, description="订单列表")
total: int = Field(..., ge=0, description="总记录数")
page_no: int = Field(..., ge=1, description="页码")
page_size: int = Field(..., ge=1, description="每页数量")
class SelfOrderDetailOut(BaseModel):
"""订单详情
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="订单ID")
order_no: str = Field(..., description="订单号")
package_id: int | None = Field(default=None, description="套餐ID")
package_name: str = Field(default="", description="套餐名称")
amount: int = Field(..., ge=0, description="订单金额(分)")
order_type: OrderTypeEnum = Field(..., description="订单类型")
status: int = Field(..., description="订单状态(0:待支付 1:已支付 2:已取消 3:已退款)")
pay_method: str | None = Field(default=None, description="支付方式")
pay_time: str | None = Field(default=None, description="支付时间")
created_at: str | None = Field(default=None, description="创建时间")
class WorkspaceTenantInfo(BaseModel):
"""工作台-租户信息
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="租户ID")
name: str = Field(..., description="租户名称")
code: str = Field(..., description="租户编码")
status: int = Field(..., description="租户状态(0:正常 1:宽限期 2:已暂停 3:已冻结 4:已过期 5:已归档)")
status_label: str = Field(..., description="租户状态描述")
start_time: str | None = Field(default=None, description="开始时间")
end_time: str | None = Field(default=None, description="结束时间")
days_remaining: int = Field(default=0, description="剩余天数")
class WorkspacePackageInfo(BaseModel):
"""工作台-套餐信息
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="套餐ID")
name: str = Field(..., description="套餐名称")
price: int = Field(..., ge=0, description="价格(分)")
period: str = Field(..., description="计费周期")
max_users: int = Field(..., ge=0, description="最大用户数")
max_roles: int = Field(..., ge=0, description="最大角色数")
max_depts: int = Field(..., ge=0, description="最大部门数")
class WorkspaceUsagePercent(BaseModel):
"""工作台-用量百分比
"""
model_config = ConfigDict(from_attributes=True)
users: float = Field(default=0.0, ge=0, description="用户用量占比(%)")
roles: float = Field(default=0.0, ge=0, description="角色用量占比(%)")
depts: float = Field(default=0.0, ge=0, description="部门用量占比(%)")
class WorkspaceQuotaInfo(BaseModel):
"""工作台-配额用量
"""
model_config = ConfigDict(from_attributes=True)
max_users: int = Field(default=0, ge=0, description="最大用户数")
max_roles: int = Field(default=0, ge=0, description="最大角色数")
max_depts: int = Field(default=0, ge=0, description="最大部门数")
current_users: int = Field(default=0, ge=0, description="当前用户数")
current_roles: int = Field(default=0, ge=0, description="当前角色数")
current_depts: int = Field(default=0, ge=0, description="当前部门数")
usage_percent: WorkspaceUsagePercent = Field(default_factory=WorkspaceUsagePercent, description="用量占比")
class WorkspaceOrderItem(BaseModel):
"""工作台-近期订单项
"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="订单ID")
order_no: str = Field(..., description="订单号")
amount: int = Field(..., ge=0, description="订单金额(分)")
order_type: OrderTypeEnum = Field(..., description="订单类型")
status: int = Field(..., description="订单状态(0:待支付 1:已支付 2:已取消 3:已退款)")
created_at: str | None = Field(default=None, description="创建时间")
class WorkspaceOut(BaseModel):
"""工作台概览
"""
model_config = ConfigDict(from_attributes=True)
tenant: WorkspaceTenantInfo = Field(..., description="租户信息")
package: WorkspacePackageInfo | None = Field(default=None, description="当前套餐信息")
quota: WorkspaceQuotaInfo = Field(..., description="配额用量")
recent_orders: list[WorkspaceOrderItem] = Field(default_factory=list, description="近期订单(最多5条)")
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -12,7 +12,7 @@ from app.api.v1.module_system.role.controller import RoleRouter
from app.api.v1.module_system.ticket.controller import TicketRouter
from app.api.v1.module_system.user.controller import UserRouter
from app.api.v1.module_system.versions.controller import VersionRouter
from app.api.v1.module_system.menu.controller import MenuRouter
system_router = APIRouter(prefix="/system")
system_router.include_router(AuthRouter)
@@ -27,3 +27,4 @@ system_router.include_router(TicketRouter)
system_router.include_router(UserRouter)
system_router.include_router(VersionRouter)
system_router.include_router(ApiTokenRouter)
system_router.include_router(MenuRouter)
@@ -1,25 +1,16 @@
"""API Token 数据模型
设计要点
- token 全名``fastpat_<tenant_code>_<tenant_id_hex>_<48-random>``明文存 ``token_plain`` 字段
按业务需求选择明文存储便于长期使用的 webhook / 集成 token
- ``token_prefix`` 字段存前 12 字符用于列表展示剩余部分用 ``fp_xxxx****yz`` 形式脱敏
- 支持 ``scopes````expires_at````rate_limit````status`` 等运营字段
- ``last_used_at`` ``used_count`` 提供审计
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class ApiTokenModel(ModelMixin, TenantMixin, UserMixin):
"""租户 API 访问令牌(用于外部系统/集成调用)
class ApiTokenModel(ModelMixin, UserMixin):
"""API 访问令牌(用于外部系统/集成调用)
token 全名格式
fastpat_<tenant_code>_<tenant_id_hex>_<48-char-base64url-secret>
fastpat_<user_id_hex>_<48-char-base64url-secret>
字段
- ``token_plain``明文令牌创建时一次性返回后续可读但不推荐直接读
@@ -32,11 +23,8 @@ class ApiTokenModel(ModelMixin, TenantMixin, UserMixin):
"""
__tablename__: str = "sys_api_token"
__table_args__ = (
Index("idx_tenant_token_prefix", "tenant_id", "token_prefix"),
{"comment": "租户 API 访问令牌"},
)
__loader_options__: list[str] = ["tenant_by", "created_by", "updated_by"]
__table_args__: dict[str, str] = {"comment": "API 访问令牌"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="令牌名称(业务语义,如:CRM-对账集成)")
token_prefix: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="明文 token 前 12 字符(用于展示)")
@@ -55,7 +55,6 @@ class ApiTokenOutSchema(BaseModel):
last_used_at: datetime | None
last_used_ip: str | None
description: str | None
tenant_id: int
created_id: int | None
updated_id: int | None
created_time: datetime | None
@@ -75,7 +74,6 @@ class ApiTokenCreatedSchema(BaseModel):
expires_at: datetime | None
rate_limit: int
status: int
tenant_id: int
created_time: datetime | None
warning: str = Field(
default="请立即保存此 token。关闭此页面后将无法再次完整查看明文,如遗失请重置。",
@@ -9,7 +9,6 @@ from redis.asyncio.client import Redis
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.crud import TenantCRUD
from app.api.v1.module_system.user.crud import UserCRUD
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
@@ -34,10 +33,10 @@ _TOKEN_PREFIX_DISPLAY_LEN = 12
_REDIS_RATE_KEY_PREFIX = "api_token:rate:"
def _generate_full_token(tenant_code: str, tenant_id: int) -> str:
"""生成完整 token``fastpat_<tenant_code>_<tenant_id_hex>_<48-base64url>``"""
def _generate_full_token(user_id: int) -> str:
"""生成完整 token``fastpat_<user_id_hex>_<48-base64url>``"""
secret_part = secrets.token_urlsafe(36)
return f"{_TOKEN_PREFIX_HEADER}{tenant_code}_{tenant_id:x}_{secret_part}"
return f"{_TOKEN_PREFIX_HEADER}{user_id:x}_{secret_part}"
def _mask_token(full_token: str) -> str:
@@ -62,7 +61,6 @@ def _to_out_schema(token: ApiTokenModel) -> ApiTokenOutSchema:
last_used_at=token.last_used_at,
last_used_ip=token.last_used_ip,
description=token.description,
tenant_id=token.tenant_id,
created_id=token.created_id,
updated_id=token.updated_id,
created_time=token.created_time,
@@ -92,34 +90,23 @@ def _parse_scopes(scopes_str: str) -> list[str]:
class ApiTokenService:
"""API Token 业务逻辑层"""
MAX_TOKENS_PER_TENANT: int = 50
MAX_TOKENS_PER_USER: int = 50
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
# ── 租户隔离检查 ──────────────────────────────────────
def _check_tenant_access(self, token: ApiTokenModel) -> None:
"""非超管只能访问本租户 token"""
if not self.auth.user.is_superuser and token.tenant_id != self.auth.user.tenant_id:
raise CustomException(msg="无权操作其他租户的 token")
# ── 创建 ──────────────────────────────────────────────
async def create(self, data: ApiTokenCreateSchema) -> ApiTokenCreatedSchema:
tenant = await TenantCRUD(self.auth, self.db).get(id=self.auth.user.tenant_id)
if not tenant:
raise CustomException(msg="租户上下文失效,无法创建 token")
existing = await ApiTokenCRUD(self.auth, self.db).get_list(
search={"tenant_id": tenant.id},
search={},
)
active_count = sum(1 for t in existing if t.status == 0 and not t.is_deleted)
if active_count >= self.MAX_TOKENS_PER_TENANT:
raise CustomException(msg=f"该租户 API Token 数量已达上限 ({self.MAX_TOKENS_PER_TENANT}),请先删除或禁用旧 token")
if active_count >= self.MAX_TOKENS_PER_USER:
raise CustomException(msg=f"API Token 数量已达上限 ({self.MAX_TOKENS_PER_USER}),请先删除或禁用旧 token")
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
scopes_str = ",".join(data.scopes) if data.scopes else "*"
@@ -140,7 +127,7 @@ class ApiTokenService:
if not token_obj:
raise CustomException(msg="创建 token 失败")
logger.info(f"租户[{tenant.id}]新 token 创建成功: id={token_obj.id} name={data.name}")
logger.info(f"新 token 创建成功: id={token_obj.id} name={data.name}")
return ApiTokenCreatedSchema(
id=token_obj.id,
name=token_obj.name,
@@ -150,7 +137,6 @@ class ApiTokenService:
expires_at=token_obj.expires_at,
rate_limit=token_obj.rate_limit,
status=token_obj.status,
tenant_id=token_obj.tenant_id,
created_time=token_obj.created_time,
)
@@ -187,7 +173,6 @@ class ApiTokenService:
async def detail(self, id: int) -> ApiTokenOutSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
return _to_out_schema(token)
# ── 状态/重置 ──────────────────────────────────────────
@@ -195,13 +180,8 @@ class ApiTokenService:
async def reset(self, id: int, data: ApiTokenResetSchema) -> ApiTokenCreatedSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
tenant = await TenantCRUD(self.auth, self.db).get(id=token.tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix_new = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
values: dict[str, Any] = {
@@ -222,7 +202,7 @@ class ApiTokenService:
await self.db.flush()
await self.db.refresh(token)
logger.info(f"租户[{tenant.id}] token[{id}] 已重置,新前缀={token_prefix_new}")
logger.info(f"token[{id}] 已重置,新前缀={token_prefix_new}")
return ApiTokenCreatedSchema(
id=token.id,
name=token.name,
@@ -232,7 +212,6 @@ class ApiTokenService:
expires_at=token.expires_at,
rate_limit=token.rate_limit,
status=token.status,
tenant_id=token.tenant_id,
created_time=token.created_time,
)
@@ -241,13 +220,11 @@ class ApiTokenService:
raise CustomException(msg="状态值不合法(0:启用 1:禁用 2:吊销)")
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
await crud.update(id=id, data={"status": status}) # pyright: ignore[reportArgumentType]
async def delete(self, id: int) -> None:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
await crud.delete(ids=[id])
# ── reveal:二次验证后展示明文 ─────────────────────────
@@ -262,7 +239,6 @@ class ApiTokenService:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
return ApiTokenRevealOutSchema(token=token.token_plain, name=token.name)
@@ -278,7 +254,7 @@ async def authenticate_api_token(token: str, request_ip: str | None = None, redi
raise CustomException(msg="API Token 格式不合法", code=10401, status_code=401)
async with async_db_session() as db:
crud = ApiTokenCRUD(AuthSchema(check_data_scope=False), db)
crud = ApiTokenCRUD(AuthSchema(), db)
candidate = await crud.get_list(search={"token_plain": ("=", token)})
if not candidate:
raise CustomException(msg="API Token 无效", code=10401, status_code=401)
@@ -4,8 +4,6 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,78 +29,26 @@ from .oauth_service import (
)
from .schema import (
CaptchaOutSchema,
EnterPlatformOutSchema,
ImpersonateOutSchema,
ImpersonateSchema,
LoginWithTenantsSchema,
SelectTenantOutSchema,
SelectTenantSchema,
LoginOutSchema,
SliderCompleteOutSchema,
SliderCompleteSchema,
TenantLookupOutSchema,
TenantOptionSchema,
TenantRegisterOutSchema,
TenantRegisterSchema,
)
from .service import (
CaptchaService,
LoginService,
TenantLookupService,
TenantRegisterService,
)
AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"])
_AUTH_TENANTS_NS = "auth_tenants"
@AuthRouter.get("/tenant/{code}", summary="通过编码查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
async def lookup_tenant_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
code: Annotated[str, Path(description="租户编码")],
) -> JSONResponse:
"""根据租户编码查询租户信息(用于登录页自动加载租户品牌配置)"""
data = await TenantLookupService.lookup_by_code(db=db, code=code)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-by-domain", summary="通过域名查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
async def lookup_tenant_by_domain_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
domain: Annotated[str, Query(description="域名(如 tenant.example.com")],
) -> JSONResponse:
"""根据域名查询租户信息(用于登录页通过访问域名自动识别租户品牌)"""
data = await TenantLookupService.lookup_by_domain(db=db, domain=domain)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-options", summary="获取所有租户选项(登录页下拉选择)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def get_tenant_options_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
"""获取所有活跃租户下拉选项"""
data = await TenantLookupService.list_options(db=db)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-search", summary="搜索租户(按编码/名称模糊匹配)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def search_tenant_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
q: Annotated[str, Query(description="搜索关键字")],
) -> JSONResponse:
"""模糊搜索租户"""
data = await TenantLookupService.search(db=db, q=q)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.post("/login", summary="登录", response_model=LoginWithTenantsSchema)
@AuthRouter.post("/login", summary="登录", response_model=LoginOutSchema)
async def login_for_access_token_controller(
request: Request,
background_tasks: BackgroundTasks,
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
login_form: Annotated[CustomOAuth2PasswordRequestForm, Depends()],
) -> JSONResponse | LoginWithTenantsSchema:
) -> JSONResponse | LoginOutSchema:
login_result = await LoginService.authenticate_user(request=request, redis=redis, login_form=login_form, db=db, background_tasks=background_tasks)
logger.info(f"用户{login_form.username}登录成功")
@@ -150,55 +96,6 @@ async def logout_controller(
return ErrorResponse(msg="退出失败")
@AuthRouter.post("/select-tenant", summary="选择租户", response_model=ResponseSchema[SelectTenantOutSchema], dependencies=[Depends(get_current_user)])
async def select_tenant_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[SelectTenantSchema, Body(description="租户选择参数")],
) -> JSONResponse:
result = await LoginService(auth, db).select_tenant(request=request, redis=redis, tenant_id=data.tenant_id)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="租户切换成功")
@AuthRouter.post("/enter-platform", summary="进入平台管理模式", response_model=ResponseSchema[EnterPlatformOutSchema], dependencies=[Depends(get_current_user)])
async def enter_platform_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await LoginService(auth, db).enter_platform(request=request, redis=redis)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="已返回平台管理模式")
@AuthRouter.get("/tenants", summary="获取可选租户列表", response_model=ResponseSchema[list[TenantOptionSchema]], dependencies=[Depends(get_current_user)])
@cache(expire=120, namespace=_AUTH_TENANTS_NS)
async def get_user_tenants_controller(
auth: Annotated[AuthSchema, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = LoginService(auth, db)
tenants = await service.get_user_tenants()
return SuccessResponse(data=tenants, msg="获取租户列表成功")
@AuthRouter.post("/impersonate", summary="平台管理员代签入", response_model=ResponseSchema[ImpersonateOutSchema], dependencies=[Depends(get_current_user)])
async def impersonate_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[ImpersonateSchema, Body(description="代签入参数")],
) -> JSONResponse:
result = await LoginService(auth, db).impersonate(request=request, redis=redis, tenant_id=data.tenant_id)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="代签入成功")
@AuthRouter.get("/oauth/{provider}/login", summary="第三方OAuth跳转")
async def oauth_login_redirect_controller(
request: Request,
@@ -281,19 +178,3 @@ async def oauth_callback_controller(
except CustomException as e:
fe = await resolve_frontend()
return RedirectContentResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302)
@AuthRouter.post("/tenant/register", status_code=status.HTTP_201_CREATED, summary="租户自助注册", response_model=ResponseSchema[TenantRegisterOutSchema])
async def tenant_register_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TenantRegisterSchema, Body(description="租户注册参数")],
) -> JSONResponse:
result = await TenantRegisterService.register(
db=db,
username=data.username,
password=data.password,
email=data.email,
tenant_name=data.tenant_name,
)
logger.info(f"新租户注册: username={data.username} tenant={result.tenant_name}")
return SuccessResponse(data=result, msg=result.message)
@@ -306,7 +306,7 @@ async def ensure_oauth_user(
unique_id: str,
display_name: str,
) -> UserModel:
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
username = _username_for_oauth(provider, unique_id)
existing = await UserCRUD(auth, db).get(username=username)
if existing:
@@ -316,7 +316,6 @@ async def ensure_oauth_user(
username=username,
password=secrets.token_urlsafe(24),
name=(display_name or username)[:32],
tenant_id=1, # 系统租户:OAuth 用户未指定业务租户,统一落到 system tenant
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
)
try:
@@ -384,7 +383,7 @@ async def complete_oauth_login(
if user.status == 1:
raise CustomException(msg="用户已被停用")
user = await UserCRUD(AuthSchema(check_data_scope=False), db).update_last_login(id=user.id)
user = await UserCRUD(AuthSchema(), db).update_last_login(id=user.id)
if not user:
raise CustomException(msg="用户不存在")
@@ -1,6 +1,6 @@
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import JWTOutSchema
@@ -15,101 +15,12 @@ class CaptchaOutSchema(BaseModel):
img_base: str = Field(default="", description="Base64编码的验证码图片(滑块模式为空字符串)")
class TenantOptionSchema(BaseModel):
"""租户选项(用于登录后选择租户)"""
class LoginOutSchema(JWTOutSchema):
"""登录响应"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="租户ID")
name: str = Field(..., description="租户名称")
code: str = Field(..., description="租户编码")
class SelectTenantSchema(BaseModel):
"""选择租户请求"""
tenant_id: int = Field(..., gt=0, description="租户ID")
class SelectTenantOutSchema(BaseModel):
"""选择租户响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(含租户上下文)")
token_type: str = Field(default="Bearer", description="token类型(RFC 6750")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class LoginWithTenantsSchema(JWTOutSchema):
"""登录响应(含租户列表)"""
tenants: list[TenantOptionSchema] = Field(default_factory=list, description="可选租户列表")
user_info: dict[str, Any] = Field(default_factory=dict, description="用户信息")
class TenantRegisterSchema(BaseModel):
"""租户自助注册请求"""
username: str = Field(..., min_length=3, max_length=32, description="登录账号")
password: str = Field(..., min_length=6, max_length=128, description="登录密码")
email: EmailStr = Field(..., max_length=128, description="邮箱(用于接收通知)")
tenant_name: str | None = Field(default=None, max_length=100, description="企业/团队名称(可选,默认:{用户名}的租户)")
class TenantRegisterOutSchema(BaseModel):
"""租户自助注册响应"""
user_id: int = Field(..., description="用户ID")
username: str = Field(..., description="账号")
tenant_id: int = Field(..., description="租户ID")
tenant_name: str = Field(..., description="租户名称")
tenant_code: str = Field(..., description="租户编码")
package: str | None = Field(default=None, description="开通套餐")
trial_end: str = Field(..., description="试用到期日")
message: str = Field(default="注册成功", description="提示信息")
class EnterPlatformOutSchema(BaseModel):
"""进入平台管理模式响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(平台上下文)")
token_type: str = Field(default="Bearer", description="token类型(RFC 6750")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class TenantLookupOutSchema(BaseModel):
"""通过编码查询租户响应"""
id: int = Field(..., description="租户ID")
name: str = Field(..., description="租户名称")
code: str = Field(..., description="租户编码")
logo_url: str | None = Field(default=None, description="Logo URL")
login_bg: str | None = Field(default=None, description="登录背景地址")
version: str | None = Field(default=None, description="版本号")
class ImpersonateSchema(BaseModel):
"""平台管理员代签入请求"""
tenant_id: int = Field(..., gt=0, description="目标租户ID")
class ImpersonateOutSchema(BaseModel):
"""平台管理员代签入响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(租户上下文)")
refresh_token: str = Field(..., description="刷新token")
token_type: str = Field(default="Bearer", description="token类型")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
tenant_id: int = Field(..., description="目标租户ID")
tenant_name: str = Field(..., description="目标租户名称")
class SliderCompleteSchema(BaseModel):
"""滑块验证完成请求"""
+13 -471
View File
@@ -7,19 +7,14 @@ from typing import Any, NewType
import ua_parser
from fastapi import BackgroundTasks, Request
from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_system.log.crud import LoginLogCRUD
from app.api.v1.module_system.log.model import LoginLogModel
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
from app.api.v1.module_system.user.schema import UserOutSchema
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
@@ -27,11 +22,7 @@ from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.core.request_context import (
RequestContext,
clear_current_tenant,
set_current_tenant,
)
from app.core.request_context import RequestContext
from app.core.security import (
CustomOAuth2PasswordRequestForm,
create_access_token,
@@ -43,12 +34,7 @@ from app.utils.password_util import PwdUtil
from .schema import (
CaptchaOutSchema,
EnterPlatformOutSchema,
ImpersonateOutSchema,
LoginWithTenantsSchema,
SelectTenantOutSchema,
TenantOptionSchema,
TenantRegisterOutSchema,
LoginOutSchema,
)
CaptchaKey = NewType("CaptchaKey", str)
@@ -67,7 +53,7 @@ async def _write_login_log(
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
try:
async with async_db_session() as session, session.begin():
_auth = AuthSchema(check_data_scope=False)
_auth = AuthSchema()
obj = await LoginLogCRUD(_auth, session).create(
data=LoginLogCreateSchema(
username=username,
@@ -110,43 +96,27 @@ class LoginService:
@staticmethod
def _collect_permissions(
user: UserModel,
) -> tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
"""收集用户角色下的权限菜单、数据范围及角色 ID
遍历用户角色聚合所有关联菜单的 permissionmenu_id
data_scope自定义部门 ID 和角色 ID 列表
) -> tuple[list[str], list[int]]:
"""收集用户角色下的权限菜单 ID
参数:
- user (UserModel): 用户对象
返回:
- tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
(permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids)
- tuple[list[str], list[int]]: (permissions, menu_ids)
"""
permissions: list[str] = []
permissions_with_menu: dict[str, int] = {}
menu_ids: list[int] = []
data_scopes: list[int] = []
custom_dept_ids: list[int] = []
role_ids: list[int] = []
if not user.is_superuser and hasattr(user, "roles"):
for role in user.roles:
if role and role.status == 0:
role_ids.append(role.id)
if hasattr(role, "menus"):
for menu in role.menus:
if menu and menu.status == 0:
menu_ids.append(menu.id)
if menu.permission:
permissions.append(menu.permission)
permissions_with_menu[menu.permission] = menu.id
if hasattr(role, "data_scope"):
data_scopes.append(role.data_scope)
if hasattr(role, "depts") and role.depts:
for dept in role.depts:
if dept:
custom_dept_ids.append(dept.id)
return permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids
return permissions, menu_ids
@classmethod
async def authenticate_user(
@@ -156,7 +126,7 @@ class LoginService:
redis: Redis,
login_form: CustomOAuth2PasswordRequestForm,
db: AsyncSession,
) -> LoginWithTenantsSchema:
) -> LoginOutSchema:
"""用户认证"""
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request)
@@ -177,7 +147,7 @@ class LoginService:
key=login_form.captcha_key,
)
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user = await UserCRUD(auth, db).get(username=login_form.username)
if not user:
@@ -215,20 +185,6 @@ class LoginService:
)
raise CustomException(msg="用户已被停用")
tenant_stmt = select(TenantModel).where(TenantModel.id == user.tenant_id, TenantModel.status == 0, TenantModel.is_deleted.is_(False)).limit(1)
tenant_result = await db.execute(tenant_stmt)
if not tenant_result.scalar_one_or_none():
await _write_login_log(
username=_login_username,
status=2,
login_ip=request_ip,
login_location=login_location,
request_os=_login_os,
request_browser=_login_browser,
msg="所属租户已被禁用",
)
raise CustomException(msg="所属租户已被禁用,请联系平台管理员")
await UserCRUD(auth, db).update_last_login(id=user.id)
if not user:
@@ -243,9 +199,6 @@ class LoginService:
login_type=login_form.login_type,
)
tenants_auth = AuthSchema(user=UserOutSchema.model_validate(user), check_data_scope=False)
tenants = await LoginService(tenants_auth, db).get_user_tenants(user_id=user.id)
user_info = {
"id": user.id,
"username": user.username,
@@ -267,12 +220,11 @@ class LoginService:
if log_id and login_location == "归属地查询中":
background_tasks.add_task(_async_fill_login_location, redis, log_id, request_ip)
return LoginWithTenantsSchema(
return LoginOutSchema(
access_token=token.access_token,
refresh_token=token.refresh_token,
expires_in=token.expires_in,
token_type=token.token_type,
tenants=tenants,
user_info=user_info,
)
@@ -281,11 +233,7 @@ class LoginService:
user: UserModel,
session_id: str,
permissions: list[str],
permissions_with_menu: dict[str, int],
menu_ids: list[int],
data_scopes: list[int],
custom_dept_ids: list[int],
role_ids: list[int],
request_ip: str,
login_location: str | None,
ua_result: Any,
@@ -297,11 +245,7 @@ class LoginService:
- user (UserModel): 用户对象
- session_id (str): 会话ID
- permissions (list[str]): 权限标识列表
- permissions_with_menu (dict[str, int]): 权限与菜单ID映射
- menu_ids (list[int]): 菜单ID列表
- data_scopes (list[int]): 数据范围列表
- custom_dept_ids (list[int]): 自定义部门ID列表
- role_ids (list[int]): 角色ID列表
- request_ip (str): 请求IP
- login_location (str): 登录地点
- ua_result: User-Agent 解析结果
@@ -310,12 +254,9 @@ class LoginService:
返回:
- dict: 会话信息字典
"""
tenant_status = getattr(user.tenant, "status", 0) if hasattr(user, "tenant") and user.tenant else 0
return {
"session_id": session_id,
"user_id": user.id,
"tenant_id": user.tenant_id if not user.is_superuser else 0,
"tenant_status": tenant_status,
"is_superuser": user.is_superuser,
"user_status": user.status,
"name": user.name,
@@ -326,11 +267,7 @@ class LoginService:
"gender": user.gender,
"avatar": user.avatar,
"permissions": permissions,
"permissions_with_menu": permissions_with_menu,
"menu_ids": menu_ids,
"data_scopes": data_scopes,
"custom_dept_ids": custom_dept_ids,
"role_ids": role_ids,
"ipaddr": request_ip,
"login_location": login_location,
"os": ua_result.os.family if ua_result.os else "Unknown",
@@ -361,17 +298,13 @@ class LoginService:
now = datetime.now()
permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids = LoginService._collect_permissions(user)
permissions, menu_ids = LoginService._collect_permissions(user)
session_dict = LoginService._build_session_dict(
user=user,
session_id=session_id,
permissions=permissions,
permissions_with_menu=permissions_with_menu,
menu_ids=menu_ids,
data_scopes=data_scopes,
custom_dept_ids=custom_dept_ids,
role_ids=role_ids,
request_ip=request_ip,
login_location=login_location,
ua_result=ua_result,
@@ -442,7 +375,7 @@ class LoginService:
if not session_id or not user_id:
raise CustomException(msg="非法凭证,无法获取会话编号或用户ID")
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user = await UserCRUD(auth, db).get(id=user_id)
if not user:
raise CustomException(msg="刷新token失败,用户不存在")
@@ -511,210 +444,6 @@ class LoginService:
return True
async def get_user_tenants(
self,
user_id: int | None = None,
) -> list[TenantOptionSchema]:
"""获取用户关联的租户列表"""
from sqlalchemy import select
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
uid = user_id or user.id
if not uid:
return []
if user.is_superuser:
stmt = select(TenantModel).where(TenantModel.status == 0, TenantModel.is_deleted.is_(False)).order_by(TenantModel.sort, TenantModel.id)
result = await self.db.execute(stmt)
tenant_objs = result.scalars().all()
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
stmt = (
select(TenantModel)
.join(TenantUserModel, TenantUserModel.tenant_id == TenantModel.id)
.where(
TenantUserModel.user_id == uid,
TenantModel.status == 0,
TenantModel.is_deleted.is_(False),
)
.order_by(TenantUserModel.is_default.desc(), TenantModel.sort, TenantModel.id)
)
result = await self.db.execute(stmt)
tenant_objs = result.scalars().all()
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
async def select_tenant(
self,
request: Request,
redis: Redis,
tenant_id: int,
) -> SelectTenantOutSchema:
"""选择租户:验证用户归属并签发含租户上下文的新 JWT Token"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
if not user.is_superuser:
exist_stmt = (
select(TenantUserModel)
.where(
TenantUserModel.user_id == user.id,
TenantUserModel.tenant_id == tenant_id,
)
.limit(1)
)
result = await self.db.execute(exist_stmt)
if not result.scalar_one_or_none():
raise CustomException(msg="您不属于该租户,无法切换")
tenant_stmt = select(TenantModel).where(TenantModel.id == tenant_id, TenantModel.status == 0).limit(1)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在或已被禁用")
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id}
)
set_current_tenant(tenant_id)
logger.info(f"用户 {user.username}(id={user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
return SelectTenantOutSchema(
access_token=new_access_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
)
async def _rebuild_tokens(
self,
request: Request,
redis: Redis,
session_updates: dict,
) -> tuple[str, str, timedelta]:
"""从请求上下文重建全套令牌(access + refresh + session
提取会话信息应用更新后写入 Redis签发新 JWT
参数:
- request (Request): FastAPI 请求对象
- redis (Redis): Redis 客户端
- session_updates (dict): 需更新到 session_info 的键值对
返回:
- tuple[str, str, timedelta]: (access_token, refresh_token, access_expires)
"""
ctx = getattr(request.state, "ctx", None)
session_id = ctx.session_id if ctx else None
session_info = ctx.session_info if ctx else None
if not session_id or not session_info:
raise CustomException(msg="会话已失效")
session_info.update(session_updates)
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
expire=int(refresh_expires.total_seconds()),
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
new_access_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=False,
exp=now + access_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
value=new_access_token,
expire=int(access_expires.total_seconds()),
)
new_refresh_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=True,
exp=now + refresh_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
value=new_refresh_token,
expire=int(refresh_expires.total_seconds()),
)
return new_access_token, new_refresh_token, access_expires
async def enter_platform(
self,
request: Request,
redis: Redis,
) -> EnterPlatformOutSchema:
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": 0}
)
clear_current_tenant()
logger.info(f"用户 {user.username}(id={user.id}) 返回平台管理模式")
return EnterPlatformOutSchema(
access_token=new_access_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
)
async def impersonate(
self,
request: Request,
redis: Redis,
tenant_id: int,
) -> ImpersonateOutSchema:
"""平台管理员代签入:以指定租户身份登录(仅超级管理员可用)"""
user = self.auth.user
if not user or not user.is_superuser:
raise CustomException(msg="仅平台管理员可执行代签入")
tenant_stmt = select(TenantModel).where(TenantModel.id == tenant_id, TenantModel.is_deleted.is_(False)).limit(1)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在")
new_access_token, new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id, "is_impersonate": True}
)
set_current_tenant(tenant_id)
logger.warning(f"平台管理员 {user.username}(id={user.id}) 代签入租户 {tenant.name}(id={tenant_id})")
return ImpersonateOutSchema(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
tenant_id=tenant_id,
tenant_name=tenant.name,
)
class CaptchaService:
"""验证码服务 — 滑块拖动模式"""
@@ -781,190 +510,3 @@ class CaptchaService:
await RedisCURD(redis).delete(redis_key)
return True
class TenantRegisterService:
"""PRD §4.5 租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
DEFAULT_TRIAL_DAYS: int = settings.TENANT_TRIAL_DAYS
@classmethod
async def register(
cls,
db: AsyncSession,
username: str,
password: str,
email: str,
tenant_name: str | None = None,
) -> TenantRegisterOutSchema:
"""租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
from sqlalchemy.exc import IntegrityError
exists_stmt = (
select(func.count())
.select_from(UserModel)
.where(
UserModel.is_deleted.is_(False),
(UserModel.username == username) | (UserModel.email == email),
)
)
cnt = (await db.execute(exists_stmt)).scalar() or 0
if cnt > 0:
raise CustomException(msg="用户名或邮箱已被占用")
pkg_stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.id).limit(1)
default_pkg = (await db.execute(pkg_stmt)).scalar_one_or_none()
now = datetime.now()
trial_end = now + timedelta(days=cls.DEFAULT_TRIAL_DAYS)
base = tenant_name or username
code_suffix = base.encode("utf-8").hex()[:6].upper()
tenant_code = f"T{code_suffix}"
tenant = TenantModel(
name=tenant_name or f"{username}的租户",
code=tenant_code,
contact_name=username,
package_id=default_pkg.id if default_pkg else None,
start_time=now,
end_time=trial_end,
status=0,
)
db.add(tenant)
await db.flush()
user = UserModel(
name=username,
username=username,
password=PwdUtil.hash_password(password),
email=email,
tenant_id=tenant.id,
status=0,
)
db.add(user)
await db.flush()
tenant_user = TenantUserModel(
user_id=user.id,
tenant_id=tenant.id,
role="owner",
is_default=1,
)
db.add(tenant_user)
await db.flush()
owner_role = RoleModel(
name="租户管理员",
code="owner",
tenant_id=tenant.id,
order=1,
data_scope=4,
description="自助注册创建的管理员角色",
)
db.add(owner_role)
await db.flush()
user_role = UserRolesModel(user_id=user.id, role_id=owner_role.id)
db.add(user_role)
if default_pkg:
pkg_menu_stmt = select(PackageMenuModel).where(
PackageMenuModel.package_id == default_pkg.id,
)
pkg_menus = (await db.execute(pkg_menu_stmt)).scalars().all()
for pm in pkg_menus:
db.add(RoleMenusModel(role_id=owner_role.id, menu_id=pm.menu_id))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise CustomException(msg="租户编码或用户名已被占用,请重试")
return TenantRegisterOutSchema(
user_id=user.id,
username=username,
tenant_id=tenant.id,
tenant_name=tenant.name,
tenant_code=tenant_code,
package=default_pkg.name if default_pkg else None,
trial_end=trial_end.strftime("%Y-%m-%d"),
message="注册成功",
)
class TenantLookupService:
"""租户查询服务(登录页根据编码查找租户)"""
@staticmethod
async def lookup_by_code(db: AsyncSession, code: str) -> dict:
stmt = select(TenantModel).where(
TenantModel.code == code,
TenantModel.is_deleted.is_(False),
)
result = (await db.execute(stmt)).scalar_one_or_none()
if not result:
raise CustomException(msg="未找到该租户")
return {
"id": result.id,
"name": result.name,
"code": result.code,
"logo_url": result.logo_url,
"login_bg": result.login_bg,
"version": result.version,
}
@staticmethod
async def lookup_by_domain(db: AsyncSession, domain: str) -> dict:
stmt = select(TenantModel).where(
TenantModel.domain == domain,
TenantModel.is_deleted.is_(False),
)
result = (await db.execute(stmt)).scalar_one_or_none()
if not result:
raise CustomException(msg="未找到该域名对应的租户")
return {
"id": result.id,
"name": result.name,
"code": result.code,
"logo_url": result.logo_url,
"login_bg": result.login_bg,
"version": result.version,
}
@staticmethod
async def list_options(db: AsyncSession) -> list[dict]:
"""获取所有活跃租户选项(登录页下拉选择)"""
stmt = (
select(TenantModel)
.where(TenantModel.is_deleted.is_(False), TenantModel.status == 0)
.order_by(TenantModel.id)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]
@staticmethod
async def search(db: AsyncSession, q: str) -> list[dict]:
"""模糊搜索租户(按编码或名称)"""
pattern = f"%{q}%"
stmt = (
select(TenantModel)
.where(
TenantModel.is_deleted.is_(False),
TenantModel.status == 0,
(TenantModel.code.ilike(pattern) | TenantModel.name.ilike(pattern)),
)
.order_by(TenantModel.id)
.limit(20)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]
+5 -13
View File
@@ -1,31 +1,23 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.role.model import RoleModel
from app.api.v1.module_system.user.model import UserModel
class DeptModel(ModelMixin, TenantMixin, UserMixin):
class DeptModel(ModelMixin, UserMixin):
"""部门模型
"""
__tablename__: str = "sys_dept"
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "部门表"})
__table_args__: dict[str, str] = {"comment": "部门表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = [
"children",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DEPT_RELATION
__loader_options__: list[str] = ["children", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
@@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import validate_required_code
@@ -44,7 +44,7 @@ class DeptUpdateSchema(DeptCreateSchema):
"""部门更新模型"""
class DeptOutSchema(DeptCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DeptOutSchema(DeptCreateSchema, BaseSchema, UserBySchema):
"""部门详情响应模型(不含 children,用于详情和更新)"""
model_config = ConfigDict(from_attributes=True)
@@ -58,7 +58,7 @@ class DeptTreeOutSchema(DeptOutSchema):
children: list["DeptTreeOutSchema"] | None = Field(default=None, description="子部门列表")
class DeptQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DeptQueryParam(BaseQueryParam, UserByQueryParam):
"""部门管理查询参数"""
name: str | None = Field(None, description="部门名称")
@@ -1,6 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.service import TenantService
from app.core.base_schema import AuthSchema, BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.common_util import (
@@ -24,7 +23,7 @@ from .schema import (
class DeptService:
"""部门管理服务
提供部门 CRUD树形结构查询级联启/禁用租户配额检查等业务能力
提供部门 CRUD树形结构查询级联启/禁用等业务能力
"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
@@ -57,12 +56,6 @@ class DeptService:
if obj:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
await TenantService(self.auth, self.db).check_quota(user.tenant_id, "dept")
dept = await DeptCRUD(self.auth, self.db).create(data=data)
return DeptOutSchema.model_validate(dept)
@@ -228,6 +228,6 @@ async def get_init_dict_data_controller(
redis: Annotated[Redis, Depends(redis_getter)],
dict_type: Annotated[str, Path(description="字典类型")],
) -> JSONResponse:
dict_data_query_result = await DictDataService.get_init_cache(redis=redis, dict_type=dict_type, tenant_id=1)
dict_data_query_result = await DictDataService.get_init_cache(redis=redis, dict_type=dict_type)
return SuccessResponse(data=dict_data_query_result, msg="获取初始化字典数据成功")
+9 -22
View File
@@ -1,20 +1,15 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class DictTypeModel(ModelMixin, TenantMixin, UserMixin):
"""字典类型表
__platform_data_shared__ = True 表示 tenant_id=1 的平台字典对
所有租户可读但只有平台管理员可写
"""
class DictTypeModel(ModelMixin, UserMixin):
"""字典类型表"""
__tablename__: str = "sys_dict_type"
__table_args__ = (UniqueConstraint("tenant_id", "dict_type"), {"comment": "字典类型表"})
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__table_args__: dict[str, str] = {"comment": "字典类型表"}
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by"]
dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称")
dict_type: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="字典类型")
@@ -27,20 +22,12 @@ class DictTypeModel(ModelMixin, TenantMixin, UserMixin):
)
class DictDataModel(ModelMixin, TenantMixin, UserMixin):
"""字典数据表
DictTypeModel 相同tenant_id=1 的平台字典数据对
所有租户可读但只有平台管理员可写
"""
class DictDataModel(ModelMixin, UserMixin):
"""字典数据表"""
__tablename__: str = "sys_dict_data"
__table_args__ = (
UniqueConstraint("tenant_id", "dict_type_id", "dict_value", name="uq_dict_data_value"),
{"comment": "字典数据表"},
)
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__table_args__: dict[str, str] = {"comment": "字典数据表"}
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by"]
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -8,7 +8,7 @@ from pydantic import (
model_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class DictTypeCreateSchema(BaseModel):
@@ -71,13 +71,13 @@ class DictTypeUpdateSchema(DictTypeCreateSchema):
"""字典类型更新模型"""
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema):
"""字典类型响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam):
"""字典类型查询参数"""
dict_name: str | None = Field(default=None, description="字典名称", max_length=100)
@@ -138,13 +138,13 @@ class DictDataUpdateSchema(DictDataCreateSchema):
"""字典数据更新模型"""
class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema):
"""字典数据响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictDataQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DictDataQueryParam(BaseQueryParam, UserByQueryParam):
"""字典数据查询参数"""
dict_label: str | None = Field(default=None, description="字典标签", max_length=100)
@@ -113,7 +113,7 @@ class DictTypeService:
new_obj_dict = DictTypeOutSchema.model_validate(obj)
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{data.dict_type}"
try:
await RedisCURD(redis).set(
@@ -166,7 +166,7 @@ class DictTypeService:
new_obj_dict = DictTypeOutSchema.model_validate(obj)
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{data.dict_type}"
try:
# 获取当前字典类型的所有字典数据,确保包含最新状态
dict_data_list = await DictDataCRUD(self.auth, self.db).get_list(search={"dict_type": data.dict_type})
@@ -214,7 +214,7 @@ class DictTypeService:
# 验证通过后统一删除 Redis 缓存
existing_dict_types = {obj.dict_type for obj in existing if obj.id in ids}
for dt in existing_dict_types:
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{dt}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dt}"
try:
await RedisCURD(redis).delete(redis_key)
logger.info(f"删除字典类型缓存: {dt}")
@@ -343,7 +343,7 @@ class DictDataService:
"""
try:
async with async_db_session() as session, session.begin():
init_auth = AuthSchema(check_data_scope=False)
init_auth = AuthSchema()
obj_list = await DictTypeCRUD(init_auth, session).get_list()
if not obj_list:
logger.warning("未找到任何字典类型数据")
@@ -351,11 +351,10 @@ class DictDataService:
for obj in obj_list:
dict_type = obj.dict_type
tenant_id = obj.tenant_id
try:
dict_data_list = await DictDataCRUD(init_auth, session).get_list(search={"dict_type": dict_type, "tenant_id": tenant_id})
dict_data_list = await DictDataCRUD(init_auth, session).get_list(search={"dict_type": dict_type})
dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row]
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dict_type}"
value = json.dumps(dict_data, ensure_ascii=False)
await RedisCURD(redis).set(
key=redis_key,
@@ -370,13 +369,12 @@ class DictDataService:
raise CustomException(msg="字典数据初始化失败") from e
@staticmethod
async def get_init_cache(redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]:
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
"""从缓存获取字典数据列表信息(无 auth)。
参数:
- redis (Redis): Redis客户端
- dict_type (str): 字典类型
- tenant_id (int): 租户ID
返回:
- list[dict]: 字典数据列表
@@ -394,7 +392,7 @@ class DictDataService:
return None
try:
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dict_type}"
obj_list_dict = await RedisCURD(redis).get(redis_key)
result = _parse(obj_list_dict)
@@ -425,7 +423,7 @@ class DictDataService:
- redis (Redis): Redis 客户端
- dict_type (str): 字典类型
"""
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dict_type}"
dict_data_list = await DictDataCRUD(self.auth, self.db).get_list(search={"dict_type": dict_type})
dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row]
value = json.dumps(dict_data, ensure_ascii=False)
@@ -2,7 +2,7 @@ from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.config.setting import settings
from app.core.base_model import ModelMixin, TenantMixin
from app.core.base_model import ModelMixin
def get_log_text_column_type():
@@ -20,13 +20,12 @@ def get_log_text_column_type():
return Text
class LoginLogModel(ModelMixin, TenantMixin):
class LoginLogModel(ModelMixin):
"""登录日志模型
"""
__tablename__: str = "sys_login_log"
__table_args__: dict[str, str] = {"comment": "登录日志表"}
__loader_options__: list[str] = ["tenant_by"]
status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -38,14 +37,14 @@ class LoginLogModel(ModelMixin, TenantMixin):
msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息")
class OperationLogModel(ModelMixin, TenantMixin):
class OperationLogModel(ModelMixin):
"""操作日志模型
"""
__tablename__: str = "sys_operation_log"
__table_args__: dict[str, str] = {"comment": "操作日志表"}
__loader_options__: list[str] = ["tenant_by"]
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="操作人用户名")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="操作状态(0:成功 1:失败)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
request_path: Mapped[str] = mapped_column(String(255), comment="请求路径")
@@ -1,7 +1,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
@@ -35,7 +35,7 @@ class LoginLogCreateSchema(BaseModel):
return v
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema, TenantBySchema):
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema):
"""登录日志响应"""
model_config = ConfigDict(from_attributes=True)
@@ -45,7 +45,7 @@ class LoginLogDetailOutSchema(LoginLogOutSchema):
"""登录日志详情响应"""
class LoginLogQueryParam(BaseQueryParam, TenantByQueryParam):
class LoginLogQueryParam(BaseQueryParam):
"""登录日志查询参数"""
username: str | tuple[str, str] | None = Field(None, max_length=64, description="用户名")
@@ -60,7 +60,7 @@ class LoginLogQueryParam(BaseQueryParam, TenantByQueryParam):
return self
class OperationLogQueryParam(BaseQueryParam, TenantByQueryParam):
class OperationLogQueryParam(BaseQueryParam):
"""操作日志查询参数"""
request_path: str | None = Field(None, description="请求路径")
@@ -70,11 +70,12 @@ class OperationLogQueryParam(BaseQueryParam, TenantByQueryParam):
request_ip: str | None = Field(None, description="请求IP")
class OperationLogOutSchema(BaseSchema, TenantBySchema):
class OperationLogOutSchema(BaseSchema):
"""操作日志响应模型"""
model_config = ConfigDict(from_attributes=True)
username: str = Field(..., description="操作人用户名")
status: int | None = Field(default=None, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, description="描述")
request_path: str = Field(..., description="请求路径")
@@ -92,6 +93,7 @@ class OperationLogDetailOutSchema(OperationLogOutSchema):
class OperationLogCreateSchema(BaseModel):
username: str = Field(..., min_length=1, max_length=64, description="操作人用户名")
request_path: str = Field(..., min_length=1, max_length=255, description="请求路径")
request_method: str = Field(..., description="请求方式")
request_payload: str | None = Field(None, description="请求体")
@@ -22,7 +22,7 @@ _MENU_NS = "menu"
@MenuRouter.get("/tree", summary="查询菜单树", response_model=ResponseSchema[list[MenuOutSchema]])
@cache(expire=300, namespace=_MENU_NS)
async def get_menu_tree_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:query"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[MenuQueryParam, Query()],
) -> JSONResponse:
@@ -33,7 +33,7 @@ async def get_menu_tree_controller(
@MenuRouter.get("/detail/{id}", summary="查询菜单详情", response_model=ResponseSchema[MenuOutSchema])
async def get_obj_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:detail"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="菜单ID", ge=1)],
) -> JSONResponse:
@@ -43,7 +43,7 @@ async def get_obj_detail_controller(
@MenuRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建菜单", response_model=ResponseSchema[MenuOutSchema])
async def create_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:create"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[MenuCreateSchema, Body(description="菜单创建参数")],
) -> JSONResponse:
@@ -54,7 +54,7 @@ async def create_obj_controller(
@MenuRouter.put("/update/{id}", summary="修改菜单", response_model=ResponseSchema[MenuOutSchema])
async def update_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:update"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="菜单ID", ge=1)],
data: Annotated[MenuUpdateSchema, Body(description="菜单修改参数")],
@@ -66,7 +66,7 @@ async def update_obj_controller(
@MenuRouter.delete("/delete", summary="删除菜单", response_model=ResponseSchema[None])
async def delete_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:delete"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="菜单ID列表")],
) -> JSONResponse:
@@ -77,7 +77,7 @@ async def delete_obj_controller(
@MenuRouter.patch("/status/batch", summary="批量修改菜单状态", response_model=ResponseSchema[None])
async def batch_set_available_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_platform:menu:patch"]))],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import ModelMixin
if TYPE_CHECKING:
@@ -24,7 +23,6 @@ class MenuModel(ModelMixin):
__table_args__: dict[str, str] = {"comment": "平台菜单表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = ["roles", "children"]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.MENU_AUTH
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="菜单名称")
type: Mapped[int] = mapped_column(Integer, nullable=False, default=2, comment="菜单类型(1:目录 2:菜单 3:按钮 4:链接)")
@@ -41,14 +39,13 @@ class MenuModel(ModelMixin):
title: Mapped[str | None] = mapped_column(String(50), comment="菜单标题")
params: Mapped[list[dict[str, str]] | None] = mapped_column(JSON, comment="路由参数(JSON对象)")
affix: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否固定标签页(True:是 False:否)")
client: Mapped[str] = mapped_column(String(20), nullable=False, default="pc", server_default="pc", comment="终端(pc:管理端桌面 app:移动端)")
link: Mapped[str | None] = mapped_column(String(500), comment="外链地址(仅type=4)")
is_iframe: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否嵌入iframe(True:是 False:否)")
is_hide_tab: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否隐藏标签页(True:是 False:否)")
active_path: Mapped[str | None] = mapped_column(String(200), comment="激活菜单路径(用于高亮父级)")
show_badge: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否显示红点角标(True:是 False:否)")
show_text_badge: Mapped[str | None] = mapped_column(String(20), comment="文字角标内容")
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", server_default="tenant", comment="菜单可见范围(platform:仅平台 tenant:租户可用)")
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="web", server_default="web", comment="菜单可见范围(web:管理端 desktop app:移动端)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("platform_menu.id", ondelete="SET NULL"), default=None, index=True, comment="父菜单ID")
@@ -30,19 +30,15 @@ class MenuCreateSchema(BaseModel):
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="描述")
client: Literal["pc", "app"] = Field(
default="pc",
description="终端(pc:管理端桌面 app:移动端)",
)
link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)")
is_iframe: bool = Field(default=False, description="是否嵌入iframe")
is_hide_tab: bool = Field(default=False, description="是否隐藏标签页")
active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径")
show_badge: bool = Field(default=False, description="是否显示红点角标")
show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容")
scope: Literal["platform", "tenant"] | None = Field(
default=None,
description="菜单可见范围(platform:仅平台 tenant:租户可用)",
scope: Literal["web", "app"] = Field(
default="web",
description="菜单可见范围(web:管理端 desktop app:移动端)",
)
@field_validator("status")
@@ -73,9 +69,6 @@ class MenuCreateSchema(BaseModel):
if k in values and isinstance(values[k], str):
stripped = values[k].strip()
values[k] = stripped or None
if "client" in values and isinstance(values["client"], str):
cv = values["client"].strip()
values["client"] = cv if cv in ("pc", "app") else "pc"
if "parent_id" in values and isinstance(values["parent_id"], str):
try:
values["parent_id"] = int(values["parent_id"].strip())
@@ -121,16 +114,15 @@ class MenuUpdateSchema(BaseModel):
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="描述")
client: Literal["pc", "app"] | None = Field(default=None, description="终端(pc:管理端桌面 app:移动端)")
link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)")
is_iframe: bool | None = Field(default=None, description="是否嵌入iframe")
is_hide_tab: bool | None = Field(default=None, description="是否隐藏标签页")
active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径")
show_badge: bool | None = Field(default=None, description="是否显示红点角标")
show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容")
scope: Literal["platform", "tenant"] | None = Field(
scope: Literal["platform"] | None = Field(
default=None,
description="菜单可见范围(platform:仅平台 tenant:租户可用)",
description="菜单可见范围",
)
parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称")
@@ -164,9 +156,6 @@ class MenuUpdateSchema(BaseModel):
if k in values and isinstance(values[k], str):
stripped = values[k].strip()
values[k] = stripped or None
if "client" in values and isinstance(values["client"], str):
cv = values["client"].strip()
values["client"] = cv if cv in ("pc", "app") else None
if "parent_id" in values and isinstance(values["parent_id"], str):
try:
values["parent_id"] = int(values["parent_id"].strip())
@@ -208,13 +197,9 @@ class MenuQueryParam(BaseQueryParam):
permission: str | None = Field(None, description="权限标识")
description: str | None = Field(None, description="描述")
status: int | None = Field(None, description="是否启用")
client: str | None = Field(
None,
description="终端(pc:桌面端菜单 app:移动端菜单);不传则不过滤终端",
)
scope: str | None = Field(
None,
description="菜单范围过滤tenant=仅租户可用菜单",
description="菜单范围过滤(web:管理端 desktop app:移动端)",
json_schema_extra={"q": "eq"},
)
@@ -51,15 +51,15 @@ class MenuService:
else:
raise CustomException(msg="菜单或链接类型下不允许新增子菜单")
async def _validate_parent_child_client(self, parent_id: int | None, client: str | None) -> None:
if parent_id is None or client is None:
async def _validate_parent_child_scope(self, parent_id: int | None, scope: str | None) -> None:
if parent_id is None or scope is None:
return
parent = await MenuCRUD(self.auth, self.db).get(id=parent_id)
if not parent:
return
p_client = getattr(parent, "client", None) or "pc"
if p_client != client:
raise CustomException(msg="子菜单终端须与父菜单一致(均为 pc 或均为 app")
p_scope = getattr(parent, "scope", None) or "web"
if p_scope != scope:
raise CustomException(msg="子菜单可见范围须与父菜单一致")
async def detail(self, id: int) -> MenuOutSchema:
menu = await MenuCRUD(self.auth, self.db).get(id=id, preload=["roles"])
@@ -97,7 +97,7 @@ class MenuService:
raise CustomException(msg="创建失败,该菜单已存在")
await self._validate_parent_child_type(data.parent_id, data.type)
await self._validate_parent_child_client(data.parent_id, data.client)
await self._validate_parent_child_scope(data.parent_id, data.scope)
new_menu = await MenuCRUD(self.auth, self.db).create(data=data)
return MenuOutSchema.model_validate(new_menu)
@@ -105,7 +105,7 @@ class MenuService:
async def update(self, id: int, data: MenuUpdateSchema) -> MenuOutSchema:
_ = await MenuCRUD(self.auth, self.db).get_or_404(id=id, msg="更新失败,该菜单不存在")
await self._validate_parent_child_type(data.parent_id, data.type)
await self._validate_parent_child_client(data.parent_id, data.client)
await self._validate_parent_child_scope(data.parent_id, data.scope)
if data.title is not None:
search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None:
@@ -1,51 +1,18 @@
from datetime import datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class NoticeModel(ModelMixin, TenantMixin, UserMixin):
"""通知公告表
__platform_data_shared__ = True 表示 tenant_id=1 的平台公告对
所有租户可读但只有平台管理员可写
"""
class NoticeModel(ModelMixin, UserMixin):
"""通知公告表"""
__tablename__: str = "sys_notice"
__table_args__: dict[str, str] = {"comment": "通知公告表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
notice_title: Mapped[str] = mapped_column(String(64), nullable=False, comment="公告标题")
notice_type: Mapped[str] = mapped_column(String(1), nullable=False, comment="公告类型(1通知 2公告)")
notice_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="公告内容")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:草稿 1:已发布 2:已归档)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
class NoticeReadModel(MappedBase):
"""通知已读记录表 — 记录用户对公告的已读状态。
设计说明:
- 不继承 TenantMixin该表按 user_id 隔离租户上下文由所属 notice 间接确定
- (user_id, notice_id) 唯一约束 未建立记录即代表未读
- 仅用于标记已读时间不做其他业务用途
- 不继承 ModelMixin使用 (user_id, notice_id) 复合主键无需自增 id
避免 SQLite 不支持复合主键列 autoincrement 的问题
"""
__tablename__: str = "sys_notice_read"
__table_args__: tuple = (
UniqueConstraint("user_id", "notice_id", name="uq_user_notice_read"),
{"comment": "通知已读记录表"},
)
__loader_options__: list[str] = ["notice"]
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_user.id", ondelete="CASCADE"), primary_key=True, comment="用户ID")
notice_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_notice.id", ondelete="CASCADE"), primary_key=True, comment="通知ID")
read_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.now, comment="已读时间")
# 关联
notice: Mapped[NoticeModel] = relationship("NoticeModel", lazy="selectin")
@@ -6,7 +6,7 @@ from pydantic import (
model_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.utils.xss_util import sanitize_html
@@ -16,7 +16,7 @@ class NoticeCreateSchema(BaseModel):
notice_title: str = Field(..., min_length=1, max_length=64, description="公告标题")
notice_type: str = Field(..., max_length=1, description="公告类型(1:通知 2:公告)")
notice_content: str | None = Field(default=None, max_length=65535, description="公告内容")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
status: int = Field(default=0, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator("notice_type")
@@ -29,8 +29,8 @@ class NoticeCreateSchema(BaseModel):
@field_validator("status")
@classmethod
def _validate_status(cls, value: int):
if value not in {0, 1}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
if value not in {0, 1, 2}:
raise ValueError("状态仅支持 0(草稿) 1(已发布) 2(已归档)")
return value
@field_validator("notice_content")
@@ -53,15 +53,15 @@ class NoticeUpdateSchema(NoticeCreateSchema):
"""公告通知更新模型"""
class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema):
"""公告通知响应模型"""
model_config = ConfigDict(from_attributes=True)
class NoticeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class NoticeQueryParam(BaseQueryParam, UserByQueryParam):
"""公告通知查询参数"""
notice_title: str | None = Field(None, description="公告标题")
notice_type: str | None = Field(None, description="公告类型", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
status: int | None = Field(None, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)")
@@ -109,5 +109,5 @@ async def export_param_list_controller(
async def get_init_config_controller(
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result_dict = await ParamsService.get_init_cache(redis=redis, tenant_id=1)
result_dict = await ParamsService.get_init_cache(redis=redis)
return SuccessResponse(data=result_dict, msg="获取初始化缓存参数成功")
@@ -1,25 +1,15 @@
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class ParamsModel(ModelMixin, TenantMixin, UserMixin):
"""系统参数表
用于存储全局系统配置 retention_dayssmtp 主机等
平台参数tenant_id=1对所有租户共享租户级参数仅本租户可见
"""
class ParamsModel(ModelMixin, UserMixin):
"""系统参数表"""
__tablename__: str = "sys_param"
__table_args__: dict[str, str] = {"comment": "系统参数表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__platform_data_shared__: bool = True
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
config_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
config_key: Mapped[str] = mapped_column(String(500), nullable=False, comment="参数键名")
@@ -2,7 +2,7 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class ParamsCreateSchema(BaseModel):
@@ -39,14 +39,14 @@ class ParamsUpdateSchema(ParamsCreateSchema):
"""
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema):
"""参数响应模型
"""
model_config = ConfigDict(from_attributes=True)
class ParamsQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class ParamsQueryParam(BaseQueryParam, UserByQueryParam):
"""参数管理查询参数
"""
@@ -9,7 +9,7 @@ from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.middlewares import invalidate_middleware_config_cache
from app.core.redis_crud import RedisCURD
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
@@ -126,7 +126,7 @@ class ParamsService:
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{data.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{data.config_key}"
try:
redis_payload = out.model_dump(mode="json")
value = json.dumps(redis_payload, ensure_ascii=False)
@@ -169,7 +169,7 @@ class ParamsService:
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{new_obj.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{new_obj.config_key}"
try:
value = json.dumps(redis_payload, ensure_ascii=False)
result = await RedisCURD(redis).set(
@@ -184,9 +184,6 @@ class ParamsService:
logger.error(f"更新系统配置失败: {e}")
raise CustomException(msg="同步配置到缓存失败") from e
# 失效中间件内存缓存,让下次请求重新加载
invalidate_middleware_config_cache(user.tenant_id)
return out
async def delete(self, redis: Redis, ids: list[int]) -> None:
@@ -218,16 +215,13 @@ class ParamsService:
if not user:
raise CustomException(msg="未登录")
for obj in objs:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{obj.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{obj.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"删除系统配置失败: {e}")
raise CustomException(msg="同步删除缓存失败") from e
# 失效中间件内存缓存
invalidate_middleware_config_cache(user.tenant_id)
async def batch_set_status(self, redis: Redis, ids: list[int], status: int) -> None:
"""批量设置系统参数状态
@@ -242,17 +236,16 @@ class ParamsService:
if not ids:
raise CustomException(msg="请选择要操作的数据")
# 先查参数列表获取 config_key 和 tenant_id
# 先查参数列表获取 config_key
params = await ParamsCRUD(self.auth, self.db).get_list(search={"id": ("in", list(ids))})
await ParamsCRUD(self.auth, self.db).set(ids=ids, status=status)
# 同步删除对应 Redis 缓存
for param in params:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{param.tenant_id}:{param.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{param.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"同步删除系统配置缓存失败: {e}")
invalidate_middleware_config_cache(None)
@staticmethod
def export(data_list: list[dict]) -> bytes:
@@ -288,7 +281,7 @@ class ParamsService:
@staticmethod
async def _load_all_configs_from_db() -> Sequence[object]:
async with async_db_session() as session, session.begin():
init_auth = AuthSchema(check_data_scope=False)
init_auth = AuthSchema()
return await ParamsCRUD(init_auth, session).get_list()
@staticmethod
@@ -296,7 +289,7 @@ class ParamsService:
"""将 DB 配置写入 Redis,返回对应的 dict 列表。"""
configs: list[dict] = []
for config in config_obj:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{config.tenant_id}:{config.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{config.config_key}"
out = ParamsOutSchema.model_validate(config)
payload = out.model_dump(mode="json")
try:
@@ -319,9 +312,9 @@ class ParamsService:
raise CustomException(msg="初始化系统参数到 Redis 失败") from e
@staticmethod
async def get_init_cache(redis: Redis, tenant_id: int = 1) -> list[dict]:
async def get_init_cache(redis: Redis) -> list[dict]:
"""从 Redis 读取系统配置;为空时自动回源 DB。"""
redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:*")
redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:*")
redis_configs = await RedisCURD(redis).mget(redis_keys)
configs = []
for raw in redis_configs:
@@ -3,24 +3,18 @@ from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.user.model import UserModel
class PositionModel(ModelMixin, TenantMixin, UserMixin):
class PositionModel(ModelMixin, UserMixin):
"""岗位模型"""
__tablename__: str = "sys_position"
__table_args__: dict[str, str] = {"comment": "岗位表"}
__loader_options__: list[str] = [
"users",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__loader_options__: list[str] = ["users", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位编码")
@@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class PositionCreateSchema(BaseModel):
@@ -40,13 +40,13 @@ class PositionUpdateSchema(PositionCreateSchema):
"""岗位更新模型"""
class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema):
"""岗位信息响应模型"""
model_config = ConfigDict(from_attributes=True)
class PositionQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class PositionQueryParam(BaseQueryParam, UserByQueryParam):
"""岗位管理查询参数"""
name: str | None = Field(None, description="岗位名称")
+4 -62
View File
@@ -2,13 +2,11 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.package.service import PackageService
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import logger as _lg
from .model import RoleModel
from .schema import RoleCreateSchema, RoleUpdateSchema
@@ -46,55 +44,13 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
missing = sorted(set(menu_ids) - {m.id for m in menus})
raise CustomException(msg=f"菜单不存在: {missing}")
# 非超管:按"调用方的租户套餐"校验;同时校验所有目标角色是否都在调用方租户内,
# 防止超管以外的人通过传入其他租户角色 ID 跨租户授权菜单。
user = self.auth.user
if user and user.tenant_id:
user_allowed = set[int](
await PackageService(self.auth, self.db).get_tenant_available_menu_ids(user.tenant_id)
)
# 调用方可见的角色必须在自己的租户内(防跨租户角色 IDOR)
for obj in roles:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
if user.is_superuser:
continue # 超管放行(含 system tenant=1
raise CustomException(msg=f"无权操作跨租户角色: {obj.name}")
for menu in menus:
if int(menu.id) not in user_allowed:
if user.is_superuser:
continue
raise CustomException(msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配")
# 超管给跨租户角色授权菜单时,也需校验菜单至少在目标租户套餐内
# —— 这一行为按业务灵活控制:默认通过,但记日志
if user.is_superuser:
cross_tenant_roles = [
r for r in roles if getattr(r, "tenant_id", None) and r.tenant_id != user.tenant_id
]
if cross_tenant_roles and menus:
_lg.info(
"超管跨租户授权菜单:roles={} menus={}",
[r.id for r in cross_tenant_roles],
[m.id for m in menus],
)
for obj in roles:
obj.menus.clear()
obj.menus.extend(menus)
await self.db.flush()
async def set_role_depts_crud(self, role_ids: list[int], dept_ids: list[int]) -> None:
"""设置角色的部门权限(含存在性校验 + 跨租户隔离
参数:
- role_ids (list[int]): 角色ID列表
- dept_ids (list[int]): 部门ID列表
返回:
- None
"""
"""设置角色的部门权限(含存在性校验)"""
if not role_ids:
raise CustomException(msg="角色ID列表不能为空")
@@ -108,16 +64,6 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
missing = sorted(set(dept_ids) - {d.id for d in depts})
raise CustomException(msg=f"部门不存在: {missing}")
# 跨租户隔离:非超管不能操作其他租户的角色
user = self.auth.user
if user and not user.is_superuser:
for obj in roles:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
raise CustomException(msg=f"无权操作跨租户角色: {obj.name}")
for obj in depts:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
raise CustomException(msg=f"无权操作跨租户部门: {obj.name}")
for obj in roles:
relationship = obj.depts
relationship.clear()
@@ -125,10 +71,6 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
await self.db.flush()
async def get_options(self) -> list[dict[str, Any]]:
"""获取角色下拉选项,返回 [{value, label}](自动按当前用户租户过滤)"""
search: dict[str, Any] = {"status": 0}
user = self.auth.user
if user and user.tenant_id and not user.is_superuser:
search["tenant_id"] = user.tenant_id
items = await self.get_list(search=search)
"""获取角色下拉选项,返回 [{value, label}](自动按状态过滤)"""
items = await self.get_list(search={"status": 0})
return [{"value": item.id, "label": item.name} for item in items]
+8 -20
View File
@@ -1,13 +1,12 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import MappedBase, ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.menu.model import MenuModel
from app.api.v1.module_system.menu.model import MenuModel
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.user.model import UserModel
@@ -59,26 +58,15 @@ class RoleDeptsModel(MappedBase):
)
class RoleModel(ModelMixin, TenantMixin, UserMixin):
"""角色模型
角色列表只显示当前用户绑定的角色
"""
class RoleModel(ModelMixin, UserMixin):
"""角色模型"""
__tablename__: str = "sys_role"
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "角色表"})
__loader_options__: list[str] = [
"menus",
"depts",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_BINDING
__table_args__: dict[str, str] = {"comment": "角色表"}
__loader_options__: list[str] = ["menus", "depts", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色编码")
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="角色编码")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -6,9 +6,9 @@ from pydantic import (
model_validator,
)
from app.api.v1.module_platform.menu.schema import MenuOutSchema
from app.api.v1.module_system.menu.schema import MenuOutSchema
from app.api.v1.module_system.dept.schema import DeptOutSchema
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import (
role_permission_request_validator,
validate_required_code,
@@ -84,7 +84,7 @@ class RoleUpdateSchema(RoleCreateSchema):
"""
class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema):
"""角色信息响应模型
"""
@@ -94,7 +94,7 @@ class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
depts: list[DeptOutSchema] = Field(default_factory=list, description="角色部门列表")
class RoleQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class RoleQueryParam(BaseQueryParam, UserByQueryParam):
"""角色管理查询参数
"""
@@ -2,7 +2,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.service import TenantService
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
from app.core.exceptions import CustomException
from app.utils.common_util import search_to_dict
@@ -105,9 +104,6 @@ class RoleService:
if obj:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
await TenantService(self.auth, self.db).check_quota(self.auth.user.tenant_id, "role")
new_role = await RoleCRUD(self.auth, self.db).create(data=data)
return RoleOutSchema.model_validate(new_role)
@@ -1,3 +0,0 @@
from .controller import TicketRouter
__all__ = ["TicketRouter"]
@@ -1,104 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
from app.core.router_class import OperationLogRoute
from .schema import TicketBatchSchema, TicketCommentCreateSchema, TicketCommentOutSchema, TicketCreateSchema, TicketOutSchema, TicketQueryParam, TicketUpdateSchema
from .service import TicketCommentService, TicketService
TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"])
@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[PageResultSchema[TicketOutSchema]])
async def ticket_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TicketQueryParam, Query()],
) -> JSONResponse:
result = await TicketService(auth, db).page(
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_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="工单ID")],
) -> JSONResponse:
result = await TicketService(auth, db).detail(id=id)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
async def ticket_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TicketCreateSchema, Body(description="工单创建参数")],
) -> JSONResponse:
result = await TicketService(auth, db).create(data=data)
return SuccessResponse(data=result, msg="创建成功")
@TicketRouter.put("/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema])
async def ticket_update_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="工单ID", ge=1)],
data: Annotated[TicketUpdateSchema, Body(description="工单更新参数")],
) -> JSONResponse:
result = await TicketService(auth, db).update(id=id, data=data)
return SuccessResponse(data=result, msg="更新成功")
@TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema)
async def ticket_batch_update_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TicketBatchSchema, Body(description="工单批量更新参数")],
) -> JSONResponse:
await TicketService(auth, db).batch(data=data)
return SuccessResponse(msg="批量操作成功")
@TicketRouter.delete("/delete", summary="删除工单", response_model=ResponseSchema[None])
async def ticket_delete_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="工单ID列表")],
) -> JSONResponse:
await TicketService(auth, db).delete(ids=ids)
return SuccessResponse(msg="删除成功")
@TicketRouter.get("/{ticket_id}/comments", summary="工单评论列表", response_model=ResponseSchema[PageResultSchema[TicketCommentOutSchema]])
async def ticket_comment_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ticket_id: Annotated[int, Path(description="工单ID")],
page: Annotated[PaginationQueryParam, Depends()],
) -> JSONResponse:
result = await TicketCommentService(auth, db).page(ticket_id=ticket_id, page_no=page.page_no, page_size=page.page_size)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED, summary="创建评论", response_model=ResponseSchema[TicketCommentOutSchema])
async def ticket_comment_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ticket_id: Annotated[int, Path(description="工单ID")],
data: Annotated[TicketCommentCreateSchema, Body(description="评论内容")],
) -> JSONResponse:
result = await TicketCommentService(auth, db).create(ticket_id=ticket_id, data=data)
return SuccessResponse(data=result, msg="评论成功")
@@ -1,23 +0,0 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import TicketCommentModel, TicketModel
from .schema import TicketCommentCreateSchema, TicketCreateSchema, TicketUpdateSchema
class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
"""工单 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=TicketModel, auth=auth, db=db)
class TicketCommentCRUD(CRUDBase[TicketCommentModel, TicketCommentCreateSchema, Any]):
"""工单评论 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=TicketCommentModel, auth=auth, db=db)
@@ -1,63 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, 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):
"""工单模型 — 用户提交的建议和反馈
status: 0=待处理 1=处理中 2=已完成 3=已关闭
"""
__tablename__: str = "sys_ticket"
__table_args__: dict[str, str] = {"comment": "工单表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
"assigned_by",
"tenant_by",
]
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="工单标题")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, 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:其他)")
images: Mapped[str | None] = mapped_column(Text, nullable=True, comment="图片URL列表(JSON数组)")
reply: 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
class TicketCommentModel(ModelMixin, UserMixin):
"""工单评论模型"""
__tablename__: str = "sys_ticket_comment"
__table_args__: dict[str, str] = {"comment": "工单评论表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
]
ticket_id: Mapped[int] = mapped_column(ForeignKey("sys_ticket.id", ondelete="CASCADE"), nullable=False, index=True, comment="工单ID")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="评论内容(富文本)")
@@ -1,105 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.common.enums import TicketTypeEnum
from app.core.base_schema import (
BaseQueryParam,
BaseSchema,
CommonSchema,
TenantByQueryParam,
TenantBySchema,
UserByQueryParam,
UserBySchema,
)
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: TicketTypeEnum = Field(default=TicketTypeEnum.SUGGESTION, 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("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: TicketTypeEnum | None = Field(default=None, description="工单类型")
status: int | None = Field(default=None, ge=0, le=3, 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("status")
@classmethod
def _validate_status(cls, v: int | None) -> int | 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(BaseSchema, UserBySchema, TenantBySchema):
"""工单响应"""
model_config = ConfigDict(from_attributes=True)
title: str = Field(..., description="工单标题")
ticket_content: str | None = Field(default=None, description="工单内容")
summary: str | None = Field(default=None, description="摘要")
ticket_type: TicketTypeEnum = Field(..., description="工单类型")
status: int = Field(..., description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
images: str | None = Field(default=None, description="图片")
reply: str | None = Field(default=None, description="回复内容")
assigned_id: int | None = Field(default=None, description="指派人ID")
assigned_by: CommonSchema | None = Field(default=None, description="指派人")
class TicketBatchSchema(BaseModel):
"""批量更新工单"""
ids: list[int] = Field(..., min_length=1, description="工单ID列表")
status: int = Field(..., ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
@field_validator("status")
@classmethod
def _validate_status(cls, v: int) -> int:
if v not in {0, 1, 2, 3}:
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
return v
class TicketQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
"""工单查询参数"""
title: str | None = Field(None, description="工单标题")
ticket_type: str | None = Field(None, description="工单类型", json_schema_extra={"q": "eq"})
assigned_id: int | None = Field(None, description="处理人ID")
status: int | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
class TicketCommentCreateSchema(BaseModel):
"""创建评论"""
content: str = Field(..., min_length=1, description="评论内容")
class TicketCommentOutSchema(BaseSchema, UserBySchema):
"""评论响应"""
model_config = ConfigDict(from_attributes=True)
ticket_id: int
content: str
created_by_name: str | None = None
@@ -1,182 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.model import UserModel
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.event_bus import EventBus
from app.core.exceptions import CustomException
from app.utils.common_util import search_to_dict
from .crud import TicketCommentCRUD, TicketCRUD
from .schema import (
TicketBatchSchema,
TicketCommentCreateSchema,
TicketCommentOutSchema,
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:
"""工单管理服务"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
def _validate_status_transition(self, ticket, new_status: int) -> None:
old_status = ticket.status if ticket.status is not None else 0
old_label = _TICKET_STATUS_LABELS.get(old_status, str(old_status))
new_label = _TICKET_STATUS_LABELS.get(new_status, str(new_status))
if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()):
raise CustomException(msg=f"不允许从{old_label}转换为{new_label}")
user = self.auth.user
is_super = user.is_superuser if user else False
is_creator = user and user.id and ticket.created_id == user.id
is_assignee = user and user.id and ticket.assigned_id == 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="仅创建人或超管可以确认关闭工单")
async def page(
self,
page_no: int,
page_size: int,
search: TicketQueryParam | None = None,
order_by: list | None = None,
) -> PageResultSchema[TicketOutSchema]:
return await TicketCRUD(self.auth, self.db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"created_time": "desc"}],
search=search_to_dict(search),
out_schema=TicketOutSchema,
)
async def detail(self, id: int) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id)
return TicketOutSchema.model_validate(obj)
async def create(self, data: TicketCreateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).create(data=data)
if not obj:
raise CustomException(msg="创建工单失败")
return TicketOutSchema.model_validate(obj)
async def update(self, id: int, data: TicketUpdateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id, msg="工单不存在")
if data.status is not None:
self._validate_status_transition(obj, data.status)
if data.assigned_id is not None:
user_stmt = select(UserModel).where(
UserModel.id == data.assigned_id,
UserModel.is_deleted.is_(False),
)
user_result = await self.db.execute(user_stmt)
assigned_user = user_result.scalar_one_or_none()
if not assigned_user:
raise CustomException(msg="指定的处理人不存在")
if assigned_user.tenant_id != obj.tenant_id:
raise CustomException(msg="处理人必须与工单属于同一租户")
updated = await TicketCRUD(self.auth, self.db).update(id=id, data=data)
if not updated:
raise CustomException(msg="工单不存在")
# 有回复内容时 SSE 推送通知给工单创建者
if data.reply and obj.created_id:
await EventBus.publish(
obj.created_id,
{
"type": "ticket_reply",
"ticket_id": obj.id,
"title": obj.title,
"ticket_type": obj.ticket_type,
},
)
return TicketOutSchema.model_validate(updated)
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除对象不能为空")
await TicketCRUD(self.auth, self.db).delete(ids=ids)
async def batch(self, data: TicketBatchSchema) -> None:
if not data.ids:
raise CustomException(msg="请选择要操作的工单")
tickets = await TicketCRUD(self.auth, self.db).get_list(search={"id": ("in", data.ids)})
ticket_map = {t.id: t for t in tickets}
for tid in data.ids:
obj = ticket_map.get(tid)
if not obj:
raise CustomException(msg=f"工单[{tid}]不存在")
self._validate_status_transition(obj, data.status)
await TicketCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
class TicketCommentService:
"""工单评论服务"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def page(self, ticket_id: int, page_no: int, page_size: int) -> PageResultSchema[TicketCommentOutSchema]:
return await TicketCommentCRUD(self.auth, self.db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=[{"created_time": "desc"}],
search={"ticket_id": ("eq", ticket_id)},
out_schema=TicketCommentOutSchema,
)
async def create(self, ticket_id: int, data: TicketCommentCreateSchema) -> TicketCommentOutSchema:
# 验证工单存在
await TicketCRUD(self.auth, self.db).get_or_404(id=ticket_id, msg="工单不存在")
create_data = data.model_dump() | {"ticket_id": ticket_id}
obj = await TicketCommentCRUD(self.auth, self.db).create(data=create_data) # type: ignore[arg-type]
if not obj:
raise CustomException(msg="评论失败")
return TicketCommentOutSchema.model_validate(obj)
async def delete(self, comment_id: int) -> None:
await TicketCommentCRUD(self.auth, self.db).get_or_404(id=comment_id, msg="评论不存在")
await TicketCommentCRUD(self.auth, self.db).delete(ids=[comment_id])
@@ -87,7 +87,7 @@ async def forget_password_controller(
key=data.captcha_key,
)
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user_forget_password_result = await UserService(auth, db).forget_password(data=data)
logger.info(f"{data.username} 重置密码成功")
return SuccessResponse(data=user_forget_password_result, msg="重置密码成功")
+5 -45
View File
@@ -6,13 +6,9 @@ from app.api.v1.module_system.position.crud import PositionCRUD
from app.api.v1.module_system.role.crud import RoleCRUD
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from .model import UserModel
from .schema import (
UserCreateSchema,
UserUpdateSchema,
)
from .schema import UserCreateSchema, UserUpdateSchema
class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
@@ -30,7 +26,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
await self.set([id], last_login=datetime.now())
async def set_user_roles(self, user_ids: list[int], role_ids: list[int]) -> None:
"""批量设置用户角色(带租户隔离验证)
"""批量设置用户角色
参数:
- user_ids (list[int]): 用户ID列表
@@ -40,15 +36,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
if role_ids:
role_objs = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
auth_user = self.auth.user
if auth_user and not auth_user.is_superuser:
for role in role_objs:
if role.tenant_id != auth_user.tenant_id:
raise CustomException(msg=f"角色 {role.name} 不属于当前租户")
else:
role_objs = []
role_objs = [] if not role_ids else await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
for obj in user_objs:
relationship = obj.roles
@@ -57,7 +45,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
await self.db.flush()
async def set_user_positions(self, user_ids: list[int], position_ids: list[int]) -> None:
"""批量设置用户岗位(带租户隔离验证)
"""批量设置用户岗位
参数:
- user_ids (list[int]): 用户ID列表
@@ -67,15 +55,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
if position_ids:
position_objs = await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
auth_user = self.auth.user
if auth_user and not auth_user.is_superuser:
for position in position_objs:
if position.tenant_id != auth_user.tenant_id:
raise CustomException(msg=f"岗位 {position.name} 不属于当前租户")
else:
position_objs = []
position_objs = [] if not position_ids else await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
for obj in user_objs:
relationship = obj.positions
@@ -98,23 +78,3 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
async def forget_password(self, id: int, password_hash: str) -> UserModel:
"""重置密码(与 change_password 逻辑相同)"""
return await self.change_password(id=id, password_hash=password_hash)
async def bump_token_version(self, user_id: int) -> None:
"""递增指定用户的 token_version 字段,使所有现有 JWT 立即失效。
配合 invalidate_user_sessionsservice 层调用可在用户改密/重置/禁用时
同时清掉 Redis 中的活跃会话
参数:
- user_id (int): 用户ID
"""
from sqlalchemy import update as sa_update
from .model import UserModel
await self.db.execute(
sa_update(UserModel)
.where(UserModel.id == user_id)
.values(token_version=UserModel.token_version + 1)
)
await self.db.flush()
+6 -14
View File
@@ -1,13 +1,12 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import MappedBase, ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.position.model import PositionModel
from app.api.v1.module_system.role.model import RoleModel
@@ -59,15 +58,15 @@ class UserPositionsModel(MappedBase):
)
class UserModel(ModelMixin, TenantMixin, UserMixin):
class UserModel(ModelMixin, UserMixin):
"""用户模型
"""
__tablename__: str = "sys_user"
__table_args__ = (UniqueConstraint("tenant_id", "username"), {"comment": "用户表"})
__loader_options__: list[str] = ["dept", "roles", "positions", "created_by", "updated_by", "deleted_by", "tenant_by"]
__table_args__: dict[str, str] = {"comment": "用户表"}
__loader_options__: list[str] = ["dept", "roles", "positions", "created_by", "updated_by", "deleted_by"]
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="用户名/登录账号")
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="用户名/登录账号")
password: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希")
name: Mapped[str] = mapped_column(String(32), nullable=False, comment="昵称")
mobile: Mapped[str | None] = mapped_column(String(11), nullable=True, comment="手机号")
@@ -82,15 +81,8 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
qq_login: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="QQ登录")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
token_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="令牌版本号:每次改密/重置/禁用递增,使旧 JWT 立即失效")
dept_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="部门ID")
tenant: Mapped["TenantModel | None"] = relationship(
"TenantModel",
foreign_keys="UserModel.tenant_id",
lazy="selectin",
viewonly=True,
)
dept: Mapped["DeptModel | None"] = relationship(back_populates="users", foreign_keys=[dept_id], lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_user_roles", back_populates="users", lazy="selectin")
positions: Mapped[list["PositionModel"]] = relationship(secondary="sys_user_positions", back_populates="users", lazy="selectin")
@@ -9,9 +9,9 @@ from pydantic import (
model_validator,
)
from app.api.v1.module_platform.menu.schema import MenuTreeOutSchema
from app.api.v1.module_system.menu.schema import MenuTreeOutSchema
from app.api.v1.module_system.role.schema import RoleOutSchema
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, CoreUserSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, CoreUserSchema, UserByQueryParam, UserBySchema
from app.core.validator import email_validator, mobile_validator
@@ -68,7 +68,6 @@ class CurrentUserUpdateSchema(BaseModel):
class UserForgetPasswordSchema(BaseModel):
"""忘记密码"""
tenant_name: str = Field(..., min_length=1, max_length=32, description="租户名称")
username: str = Field(..., min_length=3, max_length=32, description="用户名")
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
mobile: str | None = Field(default=None, max_length=11, description="手机号")
@@ -149,7 +148,6 @@ class UserCreateSchema(CurrentUserUpdateSchema):
description: str | None = Field(default=None, max_length=255, description="备注")
is_superuser: bool | None = Field(default=False, description="是否超管")
dept_id: int | None = Field(default=None, description="部门ID")
tenant_id: int | None = Field(default=None, description="租户ID,仅平台管理员创建时可指定")
role_ids: list[int] | None = Field(default=[], description="角色ID列表")
position_ids: list[int] | None = Field(default=[], description="岗位ID列表")
@@ -220,13 +218,12 @@ class UserUpdateSchema(CurrentUserUpdateSchema):
return v
class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema, TenantBySchema):
class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema):
"""响应"""
model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True)
id: int = Field(default=0, description="主键ID")
tenant_id: int = Field(default=0, description="租户ID")
username: str | None = Field(default=None, max_length=32, description="用户名")
name: str | None = Field(default=None, max_length=32, description="名称")
mobile: str | None = Field(default=None, max_length=11, description="手机号")
@@ -247,17 +244,15 @@ class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema, TenantBySchema):
positions: list[CommonSchema] | None = Field(default=[], description="岗位")
roles: list[RoleOutSchema] | None = Field(default=[], description="角色")
menus: list[MenuTreeOutSchema] | None = Field(default=[], description="菜单")
is_impersonate: bool = Field(default=False, description="是否为平台管理员代签入")
is_superuser: bool = Field(default=False, description="是否超管")
class UserQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class UserQueryParam(BaseQueryParam, UserByQueryParam):
"""用户管理查询参数(继承标准 Mixin)
支持
- 时间范围BaseQueryParam
- 创建人/更新人筛选UserByQueryParam
- 租户筛选TenantByQueryParam
- 业务字段用户名名称手机号邮箱部门状态
"""
@@ -3,15 +3,12 @@ from typing import Any
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.menu.schema import MenuOutSchema, MenuTreeOutSchema
from app.api.v1.module_platform.package.service import PackageService
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_platform.tenant.service import TenantService
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.api.v1.module_system.menu.schema import MenuOutSchema, MenuTreeOutSchema
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.core.base_schema import AuthSchema, BatchSetAvailable, CommonSchema, PageResultSchema
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict, traversal_to_tree
@@ -84,8 +81,6 @@ class UserService:
if not dept:
raise CustomException(msg="该数据不存在")
await TenantService(self.auth, self.db).check_quota(self.auth.user.tenant_id, "user")
if data.password:
data.password = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).create(data=data)
@@ -196,12 +191,12 @@ class UserService:
if not self.auth.user.id:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
if user is None:
raise CustomException(msg="该数据不存在")
user_dict = UserOutSchema.model_validate(user)
if user and user.dept:
if user.dept:
user_dict.dept_name = user.dept.name
if user and user.tenant_by:
user_dict.tenant_by = CommonSchema(id=user.tenant_by.id, name=user.tenant_by.name, status=user.tenant_by.status)
user_dict.is_impersonate = self.auth.is_impersonate
user_dict.is_superuser = user.is_superuser
_pc_only = {"client": "pc"}
if self.auth.user.is_superuser:
@@ -212,12 +207,6 @@ class UserService:
menus_raw = [MenuOutSchema.model_validate(menu) for menu in menu_all]
else:
menu_ids = set(self.auth.menu_ids)
if menu_ids and self.auth.user.tenant_id:
allowed_ids = await PackageService(self.auth, self.db).get_tenant_available_menu_ids(self.auth.user.tenant_id)
allowed_set = set(allowed_ids)
menu_ids = menu_ids & allowed_set
menus_raw = (
[
MenuOutSchema.model_validate(menu)
@@ -230,8 +219,6 @@ class UserService:
else []
)
for menu in menus_raw:
menu.scope = None
menu_tree = [MenuTreeOutSchema(**item) for item in traversal_to_tree([menu.model_dump(mode="json") for menu in menus_raw])]
user_dict.menus = menu_tree
return user_dict
@@ -262,10 +249,6 @@ class UserService:
if user.is_superuser:
raise CustomException(msg="超级管理员状态不能修改")
await UserCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
# 停用的用户立即让旧 token 失效
if data.status == 1:
for user in users:
await self._invalidate_user_sessions(user_id=user.id)
async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema:
if not self.auth.user.id:
@@ -281,8 +264,6 @@ class UserService:
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash)
# 改密后立即让旧 token 失效:递增 token_version + 清掉该用户的所有 Redis session
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def reset_password(self, data: ResetPasswordSchema) -> UserOutSchema:
@@ -298,56 +279,22 @@ class UserService:
new_password_hash = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
# 重置密码后立即让旧 token 失效
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def _invalidate_user_sessions(self, user_id: int) -> None:
"""使指定用户的所有活跃 session 立即失效。
递增 ``UserModel.token_version``JWT 中携带的旧 token_version DB 不匹配 401
Redis 中存储的 key 格式为 ``user_session:<session_id>``session_id = UUID
``user_id`` 无直接映射关系因此无法按 user_id 精确清理孤立 session 数据
``token_version`` 已确保旧 JWT 无法通过校验安全无虞
"""
await UserCRUD(self.auth, self.db).bump_token_version(user_id=user_id)
async def forget_password(self, data: UserForgetPasswordSchema) -> UserOutSchema:
from sqlalchemy import select
# 根据租户名称查租户
tenant_stmt = (
select(TenantModel)
.where(
TenantModel.name == data.tenant_name,
TenantModel.status == 0,
TenantModel.is_deleted.is_(False),
)
.limit(1)
)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在")
# 在租户范围内查找用户
user = await UserCRUD(self.auth, self.db).get(username=data.username, tenant_id=tenant.id)
# 直接按用户名查找用户
user = await UserCRUD(self.auth, self.db).get(username=data.username)
if not user:
raise CustomException(msg="该数据不存在")
if user.status == 1:
raise CustomException(msg="用户已停用")
if user.is_superuser:
raise CustomException(msg="超级管理员密码不能重置")
if data.mobile and user.mobile != data.mobile:
raise CustomException(msg="手机号不匹配")
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).forget_password(id=user.id, password_hash=new_password_hash)
# 忘记密码后使旧 token 失效
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> str:
@@ -437,8 +384,6 @@ class UserService:
dept = await DeptCRUD(self.auth, self.db).get(id=dept_id)
if not dept:
return 0, f"{row_num}行: 部门ID {dept_id} 不存在"
if not self.auth.user.is_superuser and dept.tenant_id != self.auth.user.tenant_id:
return 0, f"{row_num}行: 部门ID {dept_id} 不属于当前租户"
user_data = {
"username": username,
@@ -37,7 +37,7 @@ async def get_version_list_controller(
async def get_published_versions_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
service = VersionService(auth, db)
result = await service.get_published()
return SuccessResponse(data=result, msg="查询成功")
@@ -9,17 +9,13 @@ class VersionModel(ModelMixin, UserMixin):
__tablename__: str = "sys_version"
__table_args__: dict[str, str] = {"comment": "版本管理表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
]
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
version: Mapped[str] = mapped_column(String(50), nullable=False, comment="版本号")
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="版本标题")
date: Mapped[str] = mapped_column(String(50), nullable=False, comment="发布日期")
content: Mapped[str | None] = mapped_column(Text, nullable=True, default=None, comment="版本富文本内容")
description: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="备注")
sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态: 0=草稿,1=已发布,2=已回滚")
description: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="备注")
require_re_login: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否需要重新登录")
@@ -1,16 +1,15 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin
from app.core.base_model import ModelMixin
class JobModel(ModelMixin, TenantMixin):
class JobModel(ModelMixin):
"""任务执行日志表
"""
__tablename__: str = "task_job"
__table_args__: dict[str, str] = {"comment": "任务执行日志表"}
__loader_options__: list[str] = ["tenant_by"]
job_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="任务ID")
job_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="任务名称")
@@ -5,7 +5,7 @@ from pydantic import (
field_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class JobCreateSchema(BaseModel):
@@ -50,18 +50,16 @@ class JobUpdateSchema(BaseModel):
error: str | None = Field(default=None, description="错误信息")
class JobOutSchema(JobCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class JobOutSchema(JobCreateSchema, BaseSchema, UserBySchema):
"""执行日志响应模型"""
model_config = ConfigDict(from_attributes=True)
class JobQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class JobQueryParam(BaseQueryParam, UserByQueryParam):
"""执行日志查询参数"""
job_id: str | None = Field(None, description="任务ID")
job_name: str | None = Field(None, description="任务名称")
trigger_type: str | None = Field(None, description="触发方式", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=5, description="执行状态(0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消)")
@@ -1,16 +1,16 @@
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class NodeModel(ModelMixin, TenantMixin, UserMixin):
class NodeModel(ModelMixin, UserMixin):
"""节点类型模型 - 动态定义节点类型
"""
__tablename__: str = "task_node"
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "节点类型表"})
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__table_args__ = (UniqueConstraint("code"), {"comment": "节点类型表"})
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="节点名称")
code: Mapped[str] = mapped_column(String(32), nullable=False, comment="节点编码")
@@ -1,14 +1,8 @@
import re
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import datetime_validator
@@ -59,7 +53,7 @@ class NodeUpdateSchema(NodeCreateSchema):
"""节点更新模型"""
class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema):
"""节点响应模型"""
trigger: str | None = Field(default=None, description="触发器")
@@ -68,7 +62,7 @@ class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
model_config = ConfigDict(from_attributes=True)
class NodeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class NodeQueryParam(BaseQueryParam, UserByQueryParam):
"""节点查询参数"""
name: str | None = Field(None, description="节点名称")
@@ -1,19 +1,16 @@
from sqlalchemy import JSON, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class WorkflowModel(ModelMixin, TenantMixin, UserMixin):
class WorkflowModel(ModelMixin, UserMixin):
"""工作流定义:Vue Flow 画布序列化 + 拓扑分层并行执行
"""
__tablename__: str = "task_workflow"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_task_workflow_code"),
{"comment": "工作流定义表"},
)
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__table_args__: dict[str, str] = {"comment": "工作流定义表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="流程名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="流程编码")
@@ -3,7 +3,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import DateTimeStr
@@ -51,7 +51,7 @@ class WorkflowUpdateSchema(WorkflowCreateSchema):
return v
class WorkflowOutSchema(BaseSchema, UserBySchema, TenantBySchema):
class WorkflowOutSchema(BaseSchema, UserBySchema):
"""工作流输出(status 表示流程状态 draft/published/archived,与 ModelMixin.status 区分)"""
model_config = ConfigDict(from_attributes=True)
@@ -90,7 +90,7 @@ class WorkflowOutSchema(BaseSchema, UserBySchema, TenantBySchema):
return data
class WorkflowQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class WorkflowQueryParam(BaseQueryParam, UserByQueryParam):
"""工作流查询"""
name: str | None = Field(None, description="流程名称")
@@ -1,18 +1,18 @@
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class WorkflowNodeTypeModel(ModelMixin, TenantMixin, UserMixin):
class WorkflowNodeTypeModel(ModelMixin, UserMixin):
"""节点类型:用于 Vue Flow 左侧 palette 与执行引擎解析。"""
__tablename__: str = "task_workflow_node_type"
__table_args__ = (
UniqueConstraint("tenant_id", "code"),
UniqueConstraint("code"),
{"comment": "工作流节点类型(非定时任务节点)"},
)
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="显示名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="节点编码,对应画布 node.type")
@@ -2,7 +2,7 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class WorkflowNodeTypeCreateSchema(BaseModel):
@@ -55,13 +55,13 @@ class WorkflowNodeTypeUpdateSchema(WorkflowNodeTypeCreateSchema):
"""更新节点类型"""
class WorkflowNodeTypeOutSchema(WorkflowNodeTypeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class WorkflowNodeTypeOutSchema(WorkflowNodeTypeCreateSchema, BaseSchema, UserBySchema):
"""输出(含审计与用户信息)"""
model_config = ConfigDict(from_attributes=True)
class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam):
"""查询"""
name: str | None = Field(None, description="名称")
@@ -69,5 +69,3 @@ class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQuery
category: str | None = Field(None, description="分类", json_schema_extra={"q": "eq"})
is_active: bool | None = Field(None, description="是否启用")
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
+5 -6
View File
@@ -166,19 +166,19 @@ class GenConstant:
"bool",
]
# 页面不需要显示的添加字段
COLUMNNAME_NOT_ADD_SHOW = ["created_time", "updated_time", "tenant_id"]
COLUMNNAME_NOT_ADD_SHOW = ["created_time", "updated_time"]
# 页面不需要显示的编辑字段
COLUMNNAME_NOT_EDIT_SHOW = ["uuid", "tenant_id"]
COLUMNNAME_NOT_EDIT_SHOW = ["uuid"]
# 页面不需要编辑字段
COLUMNNAME_NOT_EDIT = ["id", "uuid", "tenant_id", "created_time", "updated_time"]
COLUMNNAME_NOT_EDIT = ["id", "uuid", "created_time", "updated_time"]
# 页面不需要显示的列表字段
COLUMNNAME_NOT_LIST = ["id", "uuid", "tenant_id"]
COLUMNNAME_NOT_LIST = ["id", "uuid"]
# 页面不需要查询字段
COLUMNNAME_NOT_QUERY = ["id", "uuid", "tenant_id", "description"]
COLUMNNAME_NOT_QUERY = ["id", "uuid", "description"]
# Crud基类字段
CRUD_COLUMN_NOT_EDIT = [
@@ -192,7 +192,6 @@ class GenConstant:
BASE_ENTITY = [
"id",
"uuid",
"tenant_id",
"status",
"description",
"created_time",
+1 -46
View File
@@ -46,12 +46,8 @@ class RedisInitKeyConfig(Enum):
USER_SESSION = {"key": "user_session", "remark": "用户会话信息"}
CAPTCHA_CODES = {"key": "captcha_codes", "remark": "图片验证码"}
SYSTEM_CONFIG = {"key": "system_config", "remark": "系统配置"}
TENANT_CONFIG = {"key": "tenant_config", "remark": "租户配置"}
SYSTEM_DICT = {"key": "system_dict", "remark": "数据字典"}
APSCHEDULER_LOCK_KEY = {
"key": "scheduler_job_lock",
"remark": "定时任务初始化锁",
}
APSCHEDULER_LOCK_KEY = {"key": "scheduler_job_lock", "remark": "定时任务初始化锁"}
AI_MODEL_CONFIG = {"key": "ai_model_config", "remark": "用户AI模型配置"}
@property
@@ -92,27 +88,11 @@ class QueueEnum(str, Enum):
le = "<="
class PermissionFilterStrategy(str, Enum):
"""权限过滤策略枚举
每个策略对应一种过滤实现模型通过 ``__permission_strategy__`` 选择
注意``DATA_SCOPE`` dispatcher基于 ``data_scope`` 字段再分发到
5 个具体的 data_scope 子策略其余是具体策略
"""
DATA_SCOPE = "data_scope" # 数据范围权限分发器(默认)
MENU_AUTH = "menu_auth" # 菜单授权(用于 MenuModel,按角色-菜单绑定过滤)
DEPT_RELATION = "dept_relation" # 部门关联(用于 DeptModel、RoleModel,按所属部门过滤)
OWN = "own" # 仅本人数据
USER_BINDING = "user_binding" # 用户绑定角色(用于 RoleModel,仅显示当前用户绑定的角色)
@unique
class OrderTypeEnum(str, Enum):
"""订单类型"""
NEW = "new"
BUY = "buy"
RENEW = "renew"
UPGRADE = "upgrade"
DOWNGRADE = "downgrade"
@@ -126,31 +106,6 @@ class InvoiceTypeEnum(str, Enum):
VAT_SPECIAL = "vat_special"
@unique
class TicketTypeEnum(str, Enum):
"""工单类型"""
SUGGESTION = "suggestion"
BUG = "bug"
OPTIMIZE = "optimize"
OTHER = "other"
@unique
class TenantStatusEnum(int, Enum):
"""租户状态枚举
状态流转
NORMAL(正常) TRIAL(试用) ARREARS(欠费) FROZEN(冻结) CANCELLED(注销)
"""
NORMAL = 0
TRIAL = 1
ARREARS = 2
FROZEN = 3
CANCELLED = 4
# ==================== 系统返回码 ====================
-3
View File
@@ -18,9 +18,6 @@ UPLOAD_DIR = STATIC_DIR / "upload"
# 下载文件目录
DOWNLOAD_DIR = STATIC_DIR / "download"
# 发票 PDF 输出目录
INVOICE_DIR = STATIC_DIR / "invoice"
# 环境配置目录
ENV_DIR = BASE_DIR / "env"
-15
View File
@@ -68,13 +68,6 @@ class Settings(BaseSettings):
TOKEN_TYPE: str = "Bearer" # token类型(RFC 6750 标准大小写)
TOKEN_SLIDING_EXPIRE: bool = True # 是否启用滑动过期(用户操作时自动续期)
# 多租户中间件白名单路径(不需要租户上下文的公开接口)
TENANT_WHITELIST_PATHS: list[str] = [
"/api/v1/system/auth/",
"/api/v1/health",
"/api/v1/common/health",
]
# ================================================= #
# ******************* 支付配置 ******************* #
# ================================================= #
@@ -147,11 +140,6 @@ class Settings(BaseSettings):
OAUTH_QQ_APP_SECRET: str = ""
OAUTH_STATE_TTL: int = 600 # OAuth state 参数过期时间(秒)
# ================================================= #
# ******************* 租户配置 ******************* #
# ================================================= #
TENANT_TRIAL_DAYS: int = 7 # 租户注册默认试用天数
# ================================================= #
# ******************* 外部 HTTPhttpx******************* #
# ================================================= #
@@ -199,8 +187,6 @@ class Settings(BaseSettings):
"/api/v1/system/auth/captcha/get",
"/api/v1/system/auth/captcha/slider/complete",
"/api/v1/system/auth/logout",
"/api/v1/system/auth/tenant-options",
"/api/v1/system/auth/tenant-search",
"/api/v1/system/config/info",
"/api/v1/system/user/current/info",
"/api/v1/system/notice/available",
@@ -283,7 +269,6 @@ class Settings(BaseSettings):
"app.core.middlewares.RequestLogMiddleware",
"app.core.middlewares.CustomGZipMiddleware",
"app.core.middlewares.CorrelationIdMiddleware", # 请求上下文
"app.core.middlewares.TenantMiddleware", # 租户上下文(需 JWT
"slowapi.middleware.SlowAPIMiddleware", # 接口限流(读取 app.state.limiter
]
return MIDDLEWARES
+1 -15
View File
@@ -111,27 +111,13 @@ class SchedulerUtil:
scheduler.resume()
# 注册系统级定时任务
from app.api.v1.module_platform.order.service import OrderService
from app.api.v1.module_platform.tenant.service import TenantService
from app.api.v1.module_system.log.service import OperationLogService
cls.register_system_job(
"system_tenant_expiry_check", TenantService.check_tenant_expiry,
trigger=IntervalTrigger(hours=1), name="租户到期检查",
)
cls.register_system_job(
"system_clean_expired", TenantService.clean_expired_tenants,
trigger=CronTrigger(day=1, hour=2, minute=0), name="过期租户归档清理",
)
cls.register_system_job(
"system_cancel_expired_orders", OrderService.cancel_expired_orders,
trigger=IntervalTrigger(minutes=30), name="超时订单取消",
)
cls.register_system_job(
"system_cleanup_operation_log", OperationLogService.cleanup_operation_log,
trigger=CronTrigger(day_of_week="sun", hour=3, minute=0), name="操作日志清理",
)
logger.info("4 个系统周期任务已注册(租户到期检查/归档清理/订单取消/日志清理)")
logger.info("1 个系统周期任务已注册(操作日志清理)")
except Exception as e:
logger.error(f"❌ 定时任务调度器初始化失败: {e}")
raise

Some files were not shown because too many files have changed in this diff Show More