mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 20:55:14 +00:00
refactor: 移除多租户相关代码,重构为单租户架构
此次提交进行了大规模的架构重构: 1. 移除所有平台租户相关模块和代码,包括租户管理、套餐、订单、发票等功能 2. 将菜单模块从platform迁移到system模块,统一系统功能入口 3. 移除租户隔离相关的模型混入、中间件和配置 4. 简化文件上传、SSE事件总线、定时任务等模块的租户逻辑 5. 重构所有业务schema和模型,移除租户相关字段和关联 6. 清理初始化脚本、模板和常量中的租户相关代码 7. 简化认证和权限控制逻辑,移除数据范围检查相关代码
This commit is contained in:
@@ -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,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="会话标题")
|
||||
|
||||
@@ -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
@@ -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):
|
||||
"""滑块验证完成请求"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
遍历用户角色,聚合所有关联菜单的 permission、menu_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
|
||||
]
|
||||
|
||||
@@ -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="获取初始化字典数据成功")
|
||||
|
||||
@@ -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="请求体")
|
||||
|
||||
+6
-6
@@ -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:
|
||||
+1
-4
@@ -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")
|
||||
+6
-21
@@ -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"},
|
||||
)
|
||||
|
||||
+7
-7
@@ -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_days、smtp 主机等)。
|
||||
平台参数(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="岗位名称")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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="重置密码成功")
|
||||
|
||||
@@ -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_sessions(service 层调用)可在用户改密/重置/禁用时
|
||||
同时清掉 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()
|
||||
|
||||
@@ -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:停用)")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ==================== 系统返回码 ====================
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 # 租户注册默认试用天数
|
||||
|
||||
# ================================================= #
|
||||
# ******************* 外部 HTTP(httpx)******************* #
|
||||
# ================================================= #
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user