diff --git a/backend/app/api/v1/module_ai/chat/crud.py b/backend/app/api/v1/module_ai/chat/crud.py index 19fa13f7..72dd6ca3 100644 --- a/backend/app/api/v1/module_ai/chat/crud.py +++ b/backend/app/api/v1/module_ai/chat/crud.py @@ -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: diff --git a/backend/app/api/v1/module_ai/chat/schema.py b/backend/app/api/v1/module_ai/chat/schema.py index b22aa348..058130ff 100644 --- a/backend/app/api/v1/module_ai/chat/schema.py +++ b/backend/app/api/v1/module_ai/chat/schema.py @@ -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="会话标题") diff --git a/backend/app/api/v1/module_ai/chat/service.py b/backend/app/api/v1/module_ai/chat/service.py index 7739952f..93ccb548 100644 --- a/backend/app/api/v1/module_ai/chat/service.py +++ b/backend/app/api/v1/module_ai/chat/service.py @@ -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, diff --git a/backend/app/api/v1/module_common/file/controller.py b/backend/app/api/v1/module_common/file/controller.py index 2e46e465..65fc148c 100644 --- a/backend/app/api/v1/module_common/file/controller.py +++ b/backend/app/api/v1/module_common/file/controller.py @@ -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) diff --git a/backend/app/api/v1/module_common/file/service.py b/backend/app/api/v1/module_common/file/service.py index d6dd39c1..68e22750 100644 --- a/backend/app/api/v1/module_common/file/service.py +++ b/backend/app/api/v1/module_common/file/service.py @@ -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="文件不存在") diff --git a/backend/app/api/v1/module_common/sse/controller.py b/backend/app/api/v1/module_common/sse/controller.py index e3acc750..4e2c6539 100644 --- a/backend/app/api/v1/module_common/sse/controller.py +++ b/backend/app/api/v1/module_common/sse/controller.py @@ -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 diff --git a/backend/app/api/v1/module_generator/gencode/jinja2_template_util.py b/backend/app/api/v1/module_generator/gencode/jinja2_template_util.py index 4b07a845..df63c83c 100644 --- a/backend/app/api/v1/module_generator/gencode/jinja2_template_util.py +++ b/backend/app/api/v1/module_generator/gencode/jinja2_template_util.py @@ -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", diff --git a/backend/app/api/v1/module_generator/gencode/model.py b/backend/app/api/v1/module_generator/gencode/model.py index 58b5c46a..7009579a 100644 --- a/backend/app/api/v1/module_generator/gencode/model.py +++ b/backend/app/api/v1/module_generator/gencode/model.py @@ -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="列描述") diff --git a/backend/app/api/v1/module_generator/gencode/schema.py b/backend/app/api/v1/module_generator/gencode/schema.py index 706c2a21..5ba10456 100644 --- a/backend/app/api/v1/module_generator/gencode/schema.py +++ b/backend/app/api/v1/module_generator/gencode/schema.py @@ -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)。 - 空值将被忽略,不参与过滤。 diff --git a/backend/app/api/v1/module_generator/gencode/service.py b/backend/app/api/v1/module_generator/gencode/service.py index 9d9f300f..6188e6a7 100644 --- a/backend/app/api/v1/module_generator/gencode/service.py +++ b/backend/app/api/v1/module_generator/gencode/service.py @@ -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 diff --git a/backend/app/api/v1/module_monitor/online/schema.py b/backend/app/api/v1/module_monitor/online/schema.py index de9184f8..4b3c9593 100644 --- a/backend/app/api/v1/module_monitor/online/schema.py +++ b/backend/app/api/v1/module_monitor/online/schema.py @@ -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] = [] diff --git a/backend/app/api/v1/module_monitor/online/service.py b/backend/app/api/v1/module_monitor/online/service.py index 3142ffac..c5d53dbc 100644 --- a/backend/app/api/v1/module_monitor/online/service.py +++ b/backend/app/api/v1/module_monitor/online/service.py @@ -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 diff --git a/backend/app/api/v1/module_platform/__init__.py b/backend/app/api/v1/module_platform/__init__.py deleted file mode 100644 index 1910f942..00000000 --- a/backend/app/api/v1/module_platform/__init__.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/module_platform/invoice/__init__.py b/backend/app/api/v1/module_platform/invoice/__init__.py deleted file mode 100644 index aa7eb079..00000000 --- a/backend/app/api/v1/module_platform/invoice/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import InvoiceRouter - -__all__ = ["InvoiceRouter"] diff --git a/backend/app/api/v1/module_platform/invoice/controller.py b/backend/app/api/v1/module_platform/invoice/controller.py deleted file mode 100644 index 29a219ba..00000000 --- a/backend/app/api/v1/module_platform/invoice/controller.py +++ /dev/null @@ -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}) diff --git a/backend/app/api/v1/module_platform/invoice/crud.py b/backend/app/api/v1/module_platform/invoice/crud.py deleted file mode 100644 index b1867bfd..00000000 --- a/backend/app/api/v1/module_platform/invoice/crud.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/module_platform/invoice/model.py b/backend/app/api/v1/module_platform/invoice/model.py deleted file mode 100644 index e7d9bdec..00000000 --- a/backend/app/api/v1/module_platform/invoice/model.py +++ /dev/null @@ -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") diff --git a/backend/app/api/v1/module_platform/invoice/pdf_helper.py b/backend/app/api/v1/module_platform/invoice/pdf_helper.py deleted file mode 100644 index 6f80482a..00000000 --- a/backend/app/api/v1/module_platform/invoice/pdf_helper.py +++ /dev/null @@ -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" diff --git a/backend/app/api/v1/module_platform/invoice/schema.py b/backend/app/api/v1/module_platform/invoice/schema.py deleted file mode 100644 index 83223837..00000000 --- a/backend/app/api/v1/module_platform/invoice/schema.py +++ /dev/null @@ -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") - - diff --git a/backend/app/api/v1/module_platform/invoice/service.py b/backend/app/api/v1/module_platform/invoice/service.py deleted file mode 100644 index 01a374f7..00000000 --- a/backend/app/api/v1/module_platform/invoice/service.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/module_platform/order/__init__.py b/backend/app/api/v1/module_platform/order/__init__.py deleted file mode 100644 index a7c17981..00000000 --- a/backend/app/api/v1/module_platform/order/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import OrderRouter - -__all__ = ["OrderRouter"] diff --git a/backend/app/api/v1/module_platform/order/controller.py b/backend/app/api/v1/module_platform/order/controller.py deleted file mode 100644 index 0237fe40..00000000 --- a/backend/app/api/v1/module_platform/order/controller.py +++ /dev/null @@ -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="退款申请已提交") diff --git a/backend/app/api/v1/module_platform/order/crud.py b/backend/app/api/v1/module_platform/order/crud.py deleted file mode 100644 index 6dc69bc1..00000000 --- a/backend/app/api/v1/module_platform/order/crud.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/module_platform/order/model.py b/backend/app/api/v1/module_platform/order/model.py deleted file mode 100644 index 97ed4cf1..00000000 --- a/backend/app/api/v1/module_platform/order/model.py +++ /dev/null @@ -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") diff --git a/backend/app/api/v1/module_platform/order/schema.py b/backend/app/api/v1/module_platform/order/schema.py deleted file mode 100644 index b00c1bbd..00000000 --- a/backend/app/api/v1/module_platform/order/schema.py +++ /dev/null @@ -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="驳回原因(审核通过时可不填)") diff --git a/backend/app/api/v1/module_platform/order/service.py b/backend/app/api/v1/module_platform/order/service.py deleted file mode 100644 index 757bf566..00000000 --- a/backend/app/api/v1/module_platform/order/service.py +++ /dev/null @@ -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="已驳回") diff --git a/backend/app/api/v1/module_platform/package/__init__.py b/backend/app/api/v1/module_platform/package/__init__.py deleted file mode 100644 index b15115ed..00000000 --- a/backend/app/api/v1/module_platform/package/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import PackageRouter - -__all__ = ["PackageRouter"] diff --git a/backend/app/api/v1/module_platform/package/controller.py b/backend/app/api/v1/module_platform/package/controller.py deleted file mode 100644 index f54fda06..00000000 --- a/backend/app/api/v1/module_platform/package/controller.py +++ /dev/null @@ -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="设置成功") diff --git a/backend/app/api/v1/module_platform/package/crud.py b/backend/app/api/v1/module_platform/package/crud.py deleted file mode 100644 index c13f21e2..00000000 --- a/backend/app/api/v1/module_platform/package/crud.py +++ /dev/null @@ -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] diff --git a/backend/app/api/v1/module_platform/package/model.py b/backend/app/api/v1/module_platform/package/model.py deleted file mode 100644 index bfb6cc16..00000000 --- a/backend/app/api/v1/module_platform/package/model.py +++ /dev/null @@ -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") diff --git a/backend/app/api/v1/module_platform/package/schema.py b/backend/app/api/v1/module_platform/package/schema.py deleted file mode 100644 index c1dd73b5..00000000 --- a/backend/app/api/v1/module_platform/package/schema.py +++ /dev/null @@ -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列表") diff --git a/backend/app/api/v1/module_platform/package/service.py b/backend/app/api/v1/module_platform/package/service.py deleted file mode 100644 index e36e9bf1..00000000 --- a/backend/app/api/v1/module_platform/package/service.py +++ /dev/null @@ -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()] diff --git a/backend/app/api/v1/module_platform/plugin.toml b/backend/app/api/v1/module_platform/plugin.toml deleted file mode 100644 index 9ed439ba..00000000 --- a/backend/app/api/v1/module_platform/plugin.toml +++ /dev/null @@ -1,8 +0,0 @@ -# 见 docs/PLUGIN_ARCHITECTURE.md - -name = "platform" -title = "平台" -version = "1.0.0" -description = "平台功能;路由由 module_platform/**/controller 动态注册。" -optional = true -tags = ["platform"] diff --git a/backend/app/api/v1/module_platform/tenant/__init__.py b/backend/app/api/v1/module_platform/tenant/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/app/api/v1/module_platform/tenant/controller.py b/backend/app/api/v1/module_platform/tenant/controller.py deleted file mode 100644 index ae422813..00000000 --- a/backend/app/api/v1/module_platform/tenant/controller.py +++ /dev/null @@ -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="查询成功") diff --git a/backend/app/api/v1/module_platform/tenant/crud.py b/backend/app/api/v1/module_platform/tenant/crud.py deleted file mode 100644 index f49e2fee..00000000 --- a/backend/app/api/v1/module_platform/tenant/crud.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/module_platform/tenant/model.py b/backend/app/api/v1/module_platform/tenant/model.py deleted file mode 100644 index ef496ef5..00000000 --- a/backend/app/api/v1/module_platform/tenant/model.py +++ /dev/null @@ -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="创建时间") diff --git a/backend/app/api/v1/module_platform/tenant/schema.py b/backend/app/api/v1/module_platform/tenant/schema.py deleted file mode 100644 index 6bde7411..00000000 --- a/backend/app/api/v1/module_platform/tenant/schema.py +++ /dev/null @@ -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条)") diff --git a/backend/app/api/v1/module_platform/tenant/service.py b/backend/app/api/v1/module_platform/tenant/service.py deleted file mode 100644 index ec79196a..00000000 --- a/backend/app/api/v1/module_platform/tenant/service.py +++ /dev/null @@ -1,1282 +0,0 @@ -import json -from datetime import datetime, timedelta -from typing import Any - -from redis.asyncio.client import Redis -from sqlalchemy import delete, func, select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.api.v1.module_platform.menu.model import MenuModel -from app.api.v1.module_platform.order.crud import OrderCRUD -from app.api.v1.module_platform.order.model import OrderModel -from app.api.v1.module_platform.order.schema import ( - OrderCreateInternalSchema, - OrderUpdateInternalSchema, -) -from app.api.v1.module_platform.package.crud import PackageCRUD -from app.api.v1.module_platform.package.model import PackageModel -from app.api.v1.module_platform.package.service import PackageService -from app.api.v1.module_system.dept.crud import DeptCRUD -from app.api.v1.module_system.dept.model import DeptModel -from app.api.v1.module_system.position.crud import PositionCRUD -from app.api.v1.module_system.role.crud import RoleCRUD -from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel -from app.api.v1.module_system.role.schema import RoleCreateSchema -from app.api.v1.module_system.user.crud import UserCRUD -from app.api.v1.module_system.user.model import UserModel -from app.api.v1.module_system.user.schema import UserCreateSchema -from app.common.enums import OrderTypeEnum, RedisInitKeyConfig -from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema -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.utils.common_util import search_to_dict -from app.utils.password_util import PwdUtil - -from .crud import TenantCRUD -from .model import TenantModel, TenantUserModel -from .schema import ( - PackageAction, - PackageAvailableItem, - PackageAvailableOut, - PackageChangePreviewOut, - PackagePreviewOut, - SelfOrderCreate, - SelfOrderDetailOut, - SelfOrderListItem, - SelfOrderListOut, - SelfOrderOut, - TenantAdminInfo, - TenantConfigOutSchema, - TenantCreateResult, - TenantCreateSchema, - TenantOutSchema, - TenantQueryParam, - TenantUpdateSchema, - TenantUserAddSchema, - TenantUserOutSchema, - WorkspaceOrderItem, - WorkspaceOut, - WorkspacePackageInfo, - WorkspaceQuotaInfo, - WorkspaceTenantInfo, - WorkspaceUsagePercent, -) - - -class TenantService: - """租户管理服务(查询操作租户可见,写操作仅超级管理员可操作) - - 设计:实例方法承载「当前用户上下文 (auth)」,``redis`` 仍是方法参数。 - 内部跨方法调用从 ``cls.xxx(auth, ...)`` 改为 ``self.xxx(...)``。 - 定时任务方法与静态工具方法保持 ``@staticmethod``(无 auth)。 - """ - - CONFIG_FIELDS = [ - "name", - "description", - "version", - "logo_url", - "favicon", - "login_bg", - "copyright", - "keep_record", - "help_doc", - "privacy", - "clause", - "git_code", - ] - - def __init__(self, auth: AuthSchema, db: AsyncSession) -> None: - self.auth = auth - self.db = db - - async def detail(self, id: int) -> TenantOutSchema: - """租户详情 - - 参数: - - id (int): 租户ID - - 返回: - - TenantOutSchema: 租户详情 - """ - obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id) - return TenantOutSchema.model_validate(obj) - - async def page( - self, - page_no: int, - page_size: int, - search: TenantQueryParam | None = None, - order_by: list[dict[str, str]] | None = None, - ) -> PageResultSchema[TenantOutSchema]: - return await TenantCRUD(self.auth, self.db).page( - offset=(page_no - 1) * page_size, - limit=page_size, - order_by=order_by or [{"id": "asc"}], - search=search_to_dict(search), - out_schema=TenantOutSchema, - ) - - async def create(self, data: TenantCreateSchema) -> TenantCreateResult: - # ① 预校验:name / code 唯一 - if await TenantCRUD(self.auth, self.db).get(name=data.name): - raise CustomException(msg="创建失败,名称已存在") - if await TenantCRUD(self.auth, self.db).get(code=data.code): - raise CustomException(msg="创建失败,编码已存在") - - # ② 校验套餐:必须存在且启用 - - package = await PackageCRUD(self.auth, self.db).get(id=data.package_id) - if not package: - raise CustomException(msg=f"套餐[{data.package_id}]不存在") - if package.status != 0: - raise CustomException(msg=f"套餐[{package.name}]已停用,无法注册租户") - - # ③ 生成初始管理员账号(用户名 = code_admin),随机密码仅此一次返回 - username = f"{data.code}_admin" - - if await UserCRUD(self.auth, self.db).get(username=username): - raise CustomException(msg=f"初始管理员用户名已存在: {username},请更换租户编码后重试") - - password = PwdUtil.generate_strong_password(length=12) - - # ④ 创建租户(事务起点) - tenant_obj = await TenantCRUD(self.auth, self.db).create(data=data) - if not tenant_obj: - raise CustomException(msg="创建租户失败") - - # ⑤ 创建「管理员」角色(tenant_id 已建立,code 用 f"{tenant_code}_admin" 避免与编码冲突) - - admin_role = RoleCreateSchema( - name=f"{tenant_obj.name}管理员", - code=f"{tenant_obj.code}_admin", - order=1, - data_scope=4, # 全部数据权限 - status=0, - description="租户初始管理员角色(由系统开通时自动创建)", - ) - role_obj = await RoleCRUD(self.auth, self.db).create(data=admin_role) - if not role_obj: - raise CustomException(msg="创建租户管理员角色失败") - # 强制同步 tenant_id(CRUD.create 不会从 auth 写入新模型,避免被默认 0 覆盖) - role_obj.tenant_id = tenant_obj.id - await self.db.flush() - - # ⑥ 创建初始管理员用户,并关联管理员角色 - admin_user = UserCreateSchema( - username=username, - password=PwdUtil.hash_password(password=password), - name=f"{tenant_obj.name}管理员", - tenant_id=tenant_obj.id, - status=0, - is_superuser=False, - role_ids=[role_obj.id], - ) - try: - user_obj = await UserCRUD(self.auth, self.db).create(data=admin_user) - if not user_obj: - raise CustomException(msg="创建租户初始管理员失败") - except CustomException: - raise - except Exception as e: - logger.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}") - raise CustomException(msg="创建租户初始管理员失败") from e - - # ⑦ 把套餐所有菜单授权给管理员角色(快照模式:复制 ID,不维护引用) - - menu_ids = await PackageService(self.auth, self.db).get_package_menu_ids(data.package_id) - if menu_ids: - await RoleCRUD(self.auth, self.db).set_role_menus_crud( - role_ids=[role_obj.id], - menu_ids=menu_ids, - ) - - # ⑧ 缓存刷新(失败不阻塞:DB 已是真相,后续 _sync_all_configs_to_redis 会补偿) - try: - await self.db.commit() - logger.info( - f"✅ 租户[{tenant_obj.name}]开通完成 " - f"(套餐={package.name}, 菜单授权={len(menu_ids)}, 管理员={username})" - ) - except Exception as e: - logger.warning(f"租户[{tenant_obj.name}]缓存刷新失败(事务已提交,可后续补偿): {e!s}") - - await self.db.refresh(tenant_obj) - - return TenantCreateResult( - tenant=TenantOutSchema.model_validate(tenant_obj), - admin=TenantAdminInfo( - username=username, - initial_password=password, - must_change_password=True, - ), - ) - - async def update(self, id: int, data: TenantUpdateSchema) -> TenantOutSchema: - """更新租户 - - 参数: - - id (int): 租户ID - - data (TenantUpdateSchema): 租户更新模型 - - 返回: - - TenantOutSchema: 租户详情 - """ - obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id) - old_package_id = obj.package_id - - await self._validate_tenant_update(id, obj, data) - - updated = await TenantCRUD(self.auth, self.db).update(id=id, data=data) - if not updated: - raise CustomException(msg="更新失败") - - # 套餐变更后:清理角色中不再可用的菜单关联;如果是降级,先校验不超额再清理 - if data.package_id is not None and data.package_id != old_package_id: - await self._handle_package_change(id, old_package_id, data.package_id) - - return TenantOutSchema.model_validate(updated) - - async def _validate_tenant_update(self, id: int, obj: TenantModel, data: TenantUpdateSchema) -> None: - """校验租户更新约束""" - if id == 1: - if data.code is not None and data.code != obj.code: - raise CustomException(msg="系统租户编码不可修改") - if data.status is not None and data.status == 1: - raise CustomException(msg="系统租户不允许禁用") - - # 套餐变更:仅超管可操作,防止租户管理员自行升级/降级套餐 - if data.package_id is not None and data.package_id != obj.package_id: - if not self.auth.user or not self.auth.user.is_superuser: - raise CustomException(msg="仅平台管理员可变更租户套餐") - - if data.name is not None: - exist = await TenantCRUD(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 TenantCRUD(self.auth, self.db).get(code=data.code) - if exist and exist.id != id: - raise CustomException(msg="更新失败,编码重复") - - async def _handle_package_change(self, tenant_id: int, old_package_id: int | None, new_package_id: int) -> None: - """处理套餐变更:降级预检 + 菜单清理""" - - new_pkg = await PackageCRUD(self.auth, self.db).get(id=new_package_id) - if not new_pkg: - raise CustomException(msg=f"套餐[{new_package_id}]不存在") - - # 降级前的超额预检:升级和首次绑定跳过;降级时若现有资源超过新套餐上限则拒 - if old_package_id is not None: - counts_stmt_user = select(func.count(UserModel.id)).where( - UserModel.tenant_id == tenant_id, UserModel.is_deleted.is_(False) - ) - counts_stmt_role = select(func.count(RoleModel.id)).where( - RoleModel.tenant_id == tenant_id, RoleModel.is_deleted.is_(False) - ) - counts_stmt_dept = select(func.count(DeptModel.id)).where( - DeptModel.tenant_id == tenant_id, DeptModel.is_deleted.is_(False) - ) - user_count = (await self.db.execute(counts_stmt_user)).scalar_one() - role_count = (await self.db.execute(counts_stmt_role)).scalar_one() - dept_count = (await self.db.execute(counts_stmt_dept)).scalar_one() - - exceed_msgs = [] - if new_pkg.max_users and user_count > new_pkg.max_users: - exceed_msgs.append(f"用户({user_count}>{new_pkg.max_users})") - if new_pkg.max_roles and role_count > new_pkg.max_roles: - exceed_msgs.append(f"角色({role_count}>{new_pkg.max_roles})") - if new_pkg.max_depts and dept_count > new_pkg.max_depts: - exceed_msgs.append(f"部门({dept_count}>{new_pkg.max_depts})") - if exceed_msgs: - raise CustomException( - msg="降级失败:当前资源超过新套餐上限,请先清理或升级套餐: " + " ".join(exceed_msgs), - ) - - available_ids = await PackageService(self.auth, self.db).get_tenant_available_menu_ids(tenant_id) - if not available_ids: - return - role_ids_stmt = select(RoleModel.id).where(RoleModel.tenant_id == tenant_id) - result = await self.db.execute(role_ids_stmt) - tenant_role_ids = [row[0] for row in result.all()] - if tenant_role_ids: - await self.db.execute( - delete(RoleMenusModel).where( - RoleMenusModel.role_id.in_(tenant_role_ids), - RoleMenusModel.menu_id.notin_(available_ids), - ), - ) - await self.db.flush() - logger.info(f"租户[{tenant_id}]套餐变更:已清理角色中不再可用的菜单关联, available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}") - - async def delete(self, ids: list[int]) -> None: - """批量删除租户(含级联资源检查:用户/部门/角色/岗位) - - 参数: - - ids (list[int]): 租户ID列表 - - 返回: - - None - """ - if not ids: - raise CustomException(msg="删除失败,删除对象不能为空") - if 1 in ids: - raise CustomException(msg="系统租户不允许删除") - - # 批量检查所有租户是否有关联资源(一次查询代替 N*4 次) - resource_checks = [ - ("用户", UserCRUD, "tenant_id"), - ("部门", DeptCRUD, "tenant_id"), - ("角色", RoleCRUD, "tenant_id"), - ("岗位", PositionCRUD, "tenant_id"), - ] - tid_set = set(ids) - for name, crud_cls, field in resource_checks: - existing = await crud_cls(self.auth, self.db).get_list(search={field: ("in", list(tid_set))}) - used_tids = {getattr(obj, field) for obj in existing if getattr(obj, field, None) is not None} - conflict = tid_set & used_tids - if conflict: - raise CustomException(msg=f"租户下已存在{name},操作失败") - - await TenantCRUD(self.auth, self.db).delete(ids=ids) - - async def set_available(self, data: BatchSetAvailable) -> None: - """批量设置租户状态 - - 参数: - - data (BatchSetAvailable): 批量状态设置 - - 返回: - - None - """ - if data.status == 1 and 1 in data.ids: - raise CustomException(msg="系统租户不允许禁用") - await TenantCRUD(self.auth, self.db).set(ids=data.ids, status=data.status) - - async def toggle_status(self, id: int) -> None: - """切换单个租户的启用/禁用状态 - - 参数: - - id (int): 租户ID - - 返回: - - None - """ - obj = await TenantCRUD(self.auth, self.db).get_or_404(id=id) - if id == 1: - raise CustomException(msg="系统租户不允许禁用") - new_status = 0 if obj.status == 1 else 1 - await TenantCRUD(self.auth, self.db).set(ids=[id], status=new_status) - - async def get_tenant_users(self, tenant_id: int) -> list[TenantUserOutSchema]: - """获取租户下的用户列表""" - - stmt = ( - select(TenantUserModel, UserModel) - .join(UserModel, UserModel.id == TenantUserModel.user_id) - .where(TenantUserModel.tenant_id == tenant_id) - .order_by(TenantUserModel.is_default.desc(), TenantUserModel.id) - ) - result = await self.db.execute(stmt) - rows = result.all() - - users = [] - for tenant_user, user_row in rows: - users.append( - TenantUserOutSchema( - id=tenant_user.id, - user_id=tenant_user.user_id, - tenant_id=tenant_user.tenant_id, - role=tenant_user.role, - is_default=tenant_user.is_default, - create_time=tenant_user.create_time, - username=user_row.username, - name=user_row.name, - ), - ) - return users - - async def add_tenant_user(self, tenant_id: int, data: TenantUserAddSchema) -> None: - """向租户添加用户 - - 参数: - - tenant_id (int): 租户ID - - data (TenantUserAddSchema): 用户添加参数 - - 返回: - - None - """ - # 验证租户存在 - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - - # 验证用户存在 - - user = await UserCRUD(self.auth, self.db).get(id=data.user_id) - if not user: - raise CustomException(msg="该数据不存在") - - # 检查是否已关联 - exist_stmt = ( - select(TenantUserModel) - .where( - TenantUserModel.user_id == data.user_id, - TenantUserModel.tenant_id == tenant_id, - ) - .limit(1) - ) - result = await self.db.execute(exist_stmt) - if result.scalar_one_or_none(): - raise CustomException(msg="该用户已关联此租户") - - # 如果设为默认租户,先取消其他默认 - if data.is_default == 1: - await self.db.execute(update(TenantUserModel).where(TenantUserModel.user_id == data.user_id).values(is_default=0)) - elif data.is_default == 0: - # 检查是否是该用户的第一个租户关联 - count_result = await self.db.execute(select(func.count()).select_from(TenantUserModel).where(TenantUserModel.user_id == data.user_id)) - count = count_result.scalar() - if count == 0: - # 第一个租户自动设为默认 - data.is_default = 1 - - tu = TenantUserModel( - user_id=data.user_id, - tenant_id=tenant_id, - role=data.role, - is_default=data.is_default, - create_time=datetime.now(), - ) - self.db.add(tu) - await self.db.flush() - - logger.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}") - - async def remove_tenant_user(self, tenant_id: int, user_id: int) -> None: - """从租户移除用户 - - 参数: - - tenant_id (int): 租户ID - - user_id (int): 用户ID - - 返回: - - None - """ - # 查找关联记录 - exist_stmt = ( - select(TenantUserModel) - .where( - TenantUserModel.user_id == user_id, - TenantUserModel.tenant_id == tenant_id, - ) - .limit(1) - ) - result = await self.db.execute(exist_stmt) - tu = result.scalar_one_or_none() - if not tu: - raise CustomException(msg="该用户未关联此租户") - - # 不允许移除租户最后一个 owner - if tu.role == "owner": - count_result = await self.db.execute( - select(func.count()) - .select_from(TenantUserModel) - .where( - TenantUserModel.tenant_id == tenant_id, - TenantUserModel.role == "owner", - ), - ) - owner_count = count_result.scalar() or 0 - if owner_count <= 1: - raise CustomException(msg="租户至少需要保留一个拥有者(owner)") - - await self.db.delete(tu) - await self.db.flush() - - logger.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功") - - async def get_quota(self, tenant_id: int) -> dict: - """获取租户配额(从关联套餐读取,系统租户返回无限配额) - - 参数: - - tenant_id (int): 租户ID - - 返回: - - dict: 配额信息 - """ - if tenant_id == 1: - return { - "tenant_id": 1, - "max_users": 999999, - "max_roles": 999999, - "max_storage_mb": 999999, - "max_depts": 999999, - "package_name": "系统租户(无限)", - } - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - if not tenant.package_id: - return { - "tenant_id": tenant.id, - "max_users": 0, - "max_roles": 0, - "max_storage_mb": 0, - "max_depts": 0, - "package_name": "未绑定套餐", - } - - pkg = await PackageCRUD(self.auth, self.db).get(id=tenant.package_id) - if not pkg: - return { - "tenant_id": tenant.id, - "max_users": 0, - "max_roles": 0, - "max_storage_mb": 0, - "max_depts": 0, - "package_name": "套餐已删除", - } - return { - "tenant_id": tenant.id, - "max_users": pkg.max_users, - "max_roles": pkg.max_roles, - "max_depts": pkg.max_depts, - "max_storage_mb": getattr(pkg, "max_storage_mb", 0), - "package_name": pkg.name, - } - - async def check_quota(self, tenant_id: int, resource_type: str) -> None: - """检查租户配额是否充足,不足时抛出异常(系统租户跳过检查)""" - if tenant_id == 1: - return - from sqlalchemy import func, select - - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant or not tenant.package_id: - return - - pkg = await PackageCRUD(self.auth, self.db).get(id=tenant.package_id) - if not pkg: - return - - # resource_type → (model_class, label, max_field) - resource_map: dict[str, tuple[Any, str, str]] = {} - - resource_map["user"] = (UserModel, "用户", "max_users") - resource_map["role"] = (RoleModel, "角色", "max_roles") - resource_map["dept"] = (DeptModel, "部门", "max_depts") - - entry = resource_map.get(resource_type) - if not entry: - return # storage 和未知类型跳过 - model_cls, label, max_field = entry - - max_limit = getattr(pkg, max_field, None) - if max_limit is None or max_limit == 0: - return - - count_stmt = ( - select(func.count()) - .select_from(model_cls) - .where( - model_cls.tenant_id == tenant_id, - model_cls.is_deleted.is_(False), - ) - ) - result = await self.db.execute(count_stmt) - current_count = result.scalar() or 0 - - if current_count >= max_limit: - raise CustomException(msg=f"租户{label}数量已达套餐上限({max_limit}),无法继续创建") - - async def get_config(self, tenant_id: int) -> dict: - """获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用) - - 参数: - - tenant_id (int): 租户ID - - 返回: - - dict: 配置字典 - """ - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - - config = {field: getattr(tenant, field, None) for field in self.CONFIG_FIELDS} - return config - - @staticmethod - def _config_to_items(config: dict) -> list[TenantConfigOutSchema]: - """将配置字典转换为结构化列表""" - return [TenantConfigOutSchema(config_key=k, config_value=str(v) if v is not None else None) for k, v in config.items()] - - async def get_config_items(self, tenant_id: int) -> list[TenantConfigOutSchema]: - return TenantService._config_to_items(await self.get_config(tenant_id)) - - @staticmethod - async def get_config_cache(redis: Redis, tenant_id: int) -> dict: - """从 Redis 缓存获取租户配置,缓存未命中则从 DB 加载并回写缓存 - - 参数: - - redis (Redis): Redis 客户端实例 - - tenant_id (int): 租户ID - - 返回: - - dict: 租户配置字典 - """ - redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" - redis_config = await RedisCURD(redis).get(key=redis_key) - - if redis_config: - try: - return json.loads(redis_config) - except Exception as e: - logger.error(f"解析租户配置数据失败: {e}") - - logger.info(f"Redis 中没有租户[{tenant_id}]配置数据,从数据库中加载") - async with async_db_session() as session, session.begin(): - _auth = AuthSchema(check_data_scope=False) - svc = TenantService(_auth, session) - config = await svc.get_config(tenant_id) - await TenantService._sync_configs_to_redis(redis, tenant_id, config) - logger.info("✅ 已从数据库加载租户配置到缓存") - - return config - - @staticmethod - async def get_config_cache_items(redis: Redis, tenant_id: int) -> list[TenantConfigOutSchema]: - return TenantService._config_to_items(await TenantService.get_config_cache(redis, tenant_id)) - - @staticmethod - async def _sync_configs_to_redis(redis: Redis, tenant_id: int, config: dict) -> None: - """将租户配置写入 Redis 缓存""" - redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" - value = json.dumps(config, ensure_ascii=False) - await RedisCURD(redis).set(key=redis_key, value=value, expire=None) - - @staticmethod - async def _del_configs_from_redis(redis: Redis, tenant_id: int) -> None: - """删除租户配置的 Redis 缓存""" - redis_key = f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}" - await RedisCURD(redis).delete(redis_key) - - async def update_config(self, redis: Redis, tenant_id: int, config: dict) -> list[TenantConfigOutSchema]: - """更新租户配置(同步 Redis 缓存) - - 参数: - - redis (Redis): Redis 客户端 - - tenant_id (int): 租户ID - - config (dict): 配置字典 - - 返回: - - list[TenantConfigOutSchema]: 更新后的配置项列表 - """ - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - - for field in self.CONFIG_FIELDS: - if field in config: - setattr(tenant, field, config[field]) - - await self.db.flush() - - # 刷新 DB 数据并同步到 Redis - new_config = await self.get_config(tenant_id) - await TenantService._sync_configs_to_redis(redis, tenant_id, new_config) - logger.info(f"租户[{tenant_id}]配置已更新") - return TenantService._config_to_items(new_config) - - @staticmethod - async def init_cache(redis: Redis) -> None: - """初始化所有租户配置到 Redis 缓存(应用启动时调用)。 - - 参数: - - redis (Redis): Redis 客户端实例 - - 返回: - - None - """ - try: - async with async_db_session() as session, session.begin(): - stmt = select(TenantModel) - result = await session.execute(stmt) - tenants = result.scalars().all() - - for tenant in tenants: - config = {field: getattr(tenant, field, None) for field in TenantService.CONFIG_FIELDS} - - await TenantService._sync_configs_to_redis(redis, tenant.id, config) - logger.info(f"✅ 租户[{tenant.name}](id={tenant.id}) 配置已缓存到 Redis") - except Exception as e: - logger.error(f"❌️ 初始化租户配置到 Redis 失败: {e}") - raise CustomException(msg="初始化租户配置到 Redis 失败") from e - - async def renew(self, tenant_id: int, end_time: str) -> TenantOutSchema: - """租户续期:延长 end_time 并恢复为 active 状态 - - 仅 active(0)/grace(1)/suspended(2) 状态可续期。 - expired(4)/frozen(3)/archived(5) 不可续期。 - - 参数: - - tenant_id (int): 租户ID - - end_time (str): 新的结束时间 - - 返回: - - dict: 更新后的租户信息 - """ - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - - if tenant.status not in (0, 1, 2): - status_labels = {0: "正常", 1: "宽限期", 2: "暂停", 3: "冻结", 4: "过期", 5: "归档"} - current_label = status_labels.get(tenant.status, str(tenant.status)) - raise CustomException(msg=f"当前租户状态为「{current_label}」,仅正常/宽限期/暂停状态可续期") - - new_end = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time - if new_end <= datetime.now(): - raise CustomException(msg="续期结束时间必须晚于当前时间") - - tenant.end_time = new_end - tenant.status = 0 - tenant.grace_start_time = None - - await self.db.flush() - logger.info(f"租户[{tenant.name}]续期成功, 新的结束时间: {end_time}") - - return TenantOutSchema.model_validate(tenant) - - async def package_change_preview(self, tenant_id: int, new_package_id: int) -> PackageChangePreviewOut: - """套餐变更影响预览 - - 返回受影响角色、菜单清单、配额对比等,供超管确认后再执行变更。 - - 参数: - - tenant_id (int): 租户ID - - new_package_id (int): 目标套餐ID - - 返回: - - PackageChangePreviewOut: 预览结果 - """ - from sqlalchemy import func, select - - tenant = await TenantCRUD(self.auth, self.db).get(id=tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - - new_package = await PackageCRUD(self.auth, self.db).get(id=new_package_id) - if not new_package: - raise CustomException(msg="该数据不存在") - - # 当前可用菜单 - current_menu_ids = set(await PackageService(self.auth, self.db).get_tenant_available_menu_ids(tenant_id)) - - # 新套餐可用菜单(直接取套餐菜单,不再包含自定义授权) - new_menu_ids = set(await PackageService(self.auth, self.db).get_package_menu_ids(new_package_id)) - final_menu_ids = new_menu_ids # 不再合并租户自定义菜单 - - # 差异计算 - removed_ids = current_menu_ids - final_menu_ids - added_ids = final_menu_ids - current_menu_ids - - removed_menus = [] - added_menus = [] - if removed_ids: - menu_stmt = select(MenuModel).where(MenuModel.id.in_(removed_ids)) - menu_result = await self.db.execute(menu_stmt) - removed_menus = [{"id": m.id, "name": m.name, "route_path": m.route_path} for m in menu_result.scalars().all()] - if added_ids: - menu_stmt = select(MenuModel).where(MenuModel.id.in_(added_ids)) - menu_result = await self.db.execute(menu_stmt) - added_menus = [{"id": m.id, "name": m.name, "route_path": m.route_path} for m in menu_result.scalars().all()] - - # 受影响角色 - role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id) - role_result = await self.db.execute(role_stmt) - roles = role_result.scalars().all() - - affected_roles = [] - total_affected_users = 0 - for role in roles: - # 查该角色下有多少菜单会被移除 - role_menu_stmt = select(RoleMenusModel.menu_id).where(RoleMenusModel.role_id == role.id) - rm_result = await self.db.execute(role_menu_stmt) - role_menu_ids = {row[0] for row in rm_result.all()} - affected_menu_count = len(role_menu_ids & removed_ids) - - # 查该角色下用户数 - user_count_stmt = select(func.count()).select_from(UserModel).join(UserModel.roles).where(RoleModel.id == role.id) - uc_result = await self.db.execute(user_count_stmt) - user_count = uc_result.scalar() or 0 - - affected_roles.append( - { - "id": role.id, - "name": role.name, - "code": role.code, - "affected_menu_count": affected_menu_count, - "user_count": user_count, - }, - ) - total_affected_users += user_count - - # 配额对比(从套餐读取) - old_pkg = None - if tenant.package_id: - old_pkg = await PackageCRUD(self.auth, self.db).get(id=tenant.package_id) - quota_changes = { - "max_users": { - "current": old_pkg.max_users if old_pkg else 0, - "new": new_package.max_users, - }, - "max_roles": { - "current": old_pkg.max_roles if old_pkg else 0, - "new": new_package.max_roles, - }, - "max_depts": { - "current": old_pkg.max_depts if old_pkg else 0, - "new": new_package.max_depts, - }, - } - - return PackageChangePreviewOut( - new_package_id=new_package.id, - new_package_name=new_package.name, - affected_roles=affected_roles, - removed_menus=removed_menus, - added_menus=added_menus, - quota_changes=quota_changes, - total_affected_users=total_affected_users, - ) - - @staticmethod - async def check_tenant_expiry() -> None: - """定时任务:多阶段租户到期自动处理 - - PRD §9 到期阶段: - grace(1) → 到期后第 1-7 天,仅提醒 - suspended(2) → 到期后第 8-14 天,禁用登录 - frozen(3) → 到期后第 15-30 天,只读模式 - expired(4) → 第 31 天起,归档候选 - """ - from sqlalchemy import text - - now = datetime.now() - - async with async_db_session() as session: - # 获取所有已过期的活跃租户(status=0) - rows = await session.execute( - text("SELECT id, name, end_time, status FROM platform_tenant WHERE status = '0' AND end_time IS NOT NULL AND end_time < :now"), - {"now": now}, - ) - expired_tenants = rows.fetchall() - - for t in expired_tenants: - tenant_id, tenant_name, end_time, cur_status = t - days_past = (now - end_time).days if end_time else 0 - - if days_past <= 7: - new_status, label = 1, "宽限期" - elif days_past <= 14: - new_status, label = 2, "已停用" - elif days_past <= 30: - new_status, label = 3, "已冻结" - else: - new_status, label = 4, "已过期" - - if new_status == cur_status: - continue - - await session.execute( - text("UPDATE platform_tenant SET status = :s WHERE id = :tid"), - {"s": new_status, "tid": tenant_id}, - ) - logger.info(f"租户状态切换: id={tenant_id} name={tenant_name} status={cur_status}→{new_status} ({label})") - - await session.commit() - - logger.info(f"到期检查完成,处理了 {len(expired_tenants)} 个过期租户") - - @staticmethod - async def clean_expired_tenants() -> None: - """定时任务:将过期超 90 天租户归档,清理旧审计日志(每月 1 号 02:00)""" - from datetime import datetime, timedelta - - from sqlalchemy import text - - cutoff = datetime.now() - timedelta(days=90) - - async with async_db_session() as session: - # 归档过期租户 - result = await session.execute( - text("SELECT COUNT(*) FROM platform_tenant WHERE status = '4' AND end_time < :cutoff"), - {"cutoff": cutoff}, - ) - count = result.scalar() or 0 - if count > 0: - await session.execute( - text("UPDATE platform_tenant SET status = '5' WHERE status = '4' AND end_time < :cutoff"), - {"cutoff": cutoff}, - ) - await session.commit() - logger.info(f"已将 {count} 个过期超过 90 天的租户标记为归档") - - @classmethod - async def get_available_packages(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int) -> PackageAvailableOut: - """获取可选套餐列表 - - 参数: - - auth (AuthSchema): 认证信息模型 - - db (AsyncSession): 数据库会话 - - tenant_id (int): 租户ID - - 返回: - - PackageAvailableOut: 可选套餐列表 - """ - tenant = await db.get(TenantModel, tenant_id) - current_pkg_id = tenant.package_id if tenant else None - - # 一次性获取当前套餐价格(可能未启用,不在后续结果中) - current_price: int | None = None - if current_pkg_id: - cp = await db.get(PackageModel, current_pkg_id) - current_price = cp.price if cp else 0 - - stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.price) - result = await db.execute(stmt) - packages = result.scalars().all() - - items: list[PackageAvailableItem] = [] - for pkg in packages: - is_current = pkg.id == current_pkg_id - actions: list[PackageAction] = [] - if is_current: - actions = ["renew"] - elif current_pkg_id is None: - actions = ["buy"] - elif current_price is not None: - actions = ["upgrade"] if pkg.price > current_price else ["downgrade"] - - items.append( - PackageAvailableItem( - id=pkg.id, - name=pkg.name, - price=pkg.price, - period=pkg.period, - trial_days=pkg.trial_days, - max_users=pkg.max_users, - max_roles=pkg.max_roles, - max_depts=pkg.max_depts, - max_storage_mb=pkg.max_storage_mb, - description=pkg.description, - is_current=is_current, - available_actions=actions, - ), - ) - - return PackageAvailableOut( - current_package_id=current_pkg_id, - packages=items, - ) - - @classmethod - async def preview_package_change(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int, target_package_id: int) -> PackagePreviewOut: - """套餐变更预览(委托给 package_change_preview 并映射输出)""" - svc = cls(auth, db) - preview = await svc.package_change_preview(tenant_id, target_package_id) - - tenant = await db.get(TenantModel, tenant_id) - target_pkg = await db.get(PackageModel, target_package_id) - - current_pkg = None - if tenant and tenant.package_id: - current_pkg = await db.get(PackageModel, tenant.package_id) - - # 确定操作类型 - if not tenant or not tenant.package_id: - action = "buy" - elif current_pkg and target_pkg and target_pkg.price > current_pkg.price: - action = "upgrade" - elif current_pkg and target_pkg and target_pkg.price < current_pkg.price: - action = "downgrade" - else: - action = "renew" - - return PackagePreviewOut( - current_package=current_pkg.name if current_pkg else "", - target_package=target_pkg.name if target_pkg else "", - action=action, - amount=target_pkg.price if target_pkg else 0, - period=target_pkg.period if target_pkg else "", - gained_menus=preview.added_menus, - lost_menus=preview.removed_menus, - affected_roles=[r.get("name", "") for r in preview.affected_roles], - affected_users=preview.total_affected_users, - ) - - @classmethod - async def create_self_order(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int, data: SelfOrderCreate) -> SelfOrderOut: - """创建自助订单(套餐购买/续费/升级/降级;免费订单自动激活) - - 参数: - - auth (AuthSchema): 认证信息模型 - - db (AsyncSession): 数据库会话 - - tenant_id (int): 租户ID - - data (SelfOrderCreate): 自助订单创建参数 - - 返回: - - SelfOrderOut: 自助订单创建结果 - """ - tenant = await db.get(TenantModel, tenant_id) - if not tenant: - raise CustomException(msg="该数据不存在") - if tenant.status not in (0, 1, 2): - raise CustomException(msg="租户状态不允许操作") - - pkg = await db.get(PackageModel, data.package_id) - if not pkg or pkg.status == 1: - raise CustomException(msg="该数据不存在") - - # 校验 order_type 与当前套餐状态的一致性: - # - 未购套餐时只能 buy / renew(不能用 upgrade/downgrade) - # - 已有套餐时禁止 buy(应走 upgrade 等) - if not tenant.package_id: - if data.order_type not in ("buy", "renew"): - raise CustomException(msg="当前租户未购买套餐,只能 buy 或 renew") - else: - if data.order_type == "buy": - raise CustomException(msg="该租户已存在套餐,请使用 upgrade/renew/downgrade") - - amount = pkg.price - - from app.api.v1.module_platform.order.service import PaymentService, _generate_order_no - - order = await OrderCRUD(auth, db).create( - OrderCreateInternalSchema( - order_no=_generate_order_no(), - tenant_id=tenant_id, - package_id=data.package_id, - order_type=data.order_type, - amount=amount, - expire_time=datetime.now() + timedelta(minutes=15), - ), - ) - await db.flush() - - # 免费订单自动激活 - 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) - - logger.info(f"自助订单创建: order_no={order.order_no} tenant={tenant_id} amount={amount}") - return SelfOrderOut( - order_id=order.id, - order_no=order.order_no, - amount=amount, - need_pay=amount > 0, - ) - - @classmethod - async def get_self_order_list( - cls, - auth: AuthSchema, - db: AsyncSession, - tenant_id: int, - page_no: int = 1, - page_size: int = 20, - order_by: list[dict] | None = None, - ) -> SelfOrderListOut: - """我的订单列表 - - 参数: - - auth (AuthSchema): 认证信息模型 - - db (AsyncSession): 数据库会话 - - tenant_id (int): 租户ID - - page_no (int): 页码 - - page_size (int): 每页数量 - - order_by (list[dict] | None): 排序参数 - - 返回: - - SelfOrderListOut: 订单分页列表 - """ - offset = (page_no - 1) * page_size - page_result = await OrderCRUD(auth, db).page( - offset=offset, - limit=page_size, - order_by=order_by or [{"created_time": "desc"}], - search={"tenant_id": tenant_id}, - ) - - # 批量查询关联套餐,避免 N+1 - package_ids = [o.package_id for o in page_result.items if hasattr(o, "package_id") and o.package_id] - pkg_map: dict[int, str] = {} - if package_ids: - pkg_result = await db.execute(select(PackageModel.id, PackageModel.name).where(PackageModel.id.in_(package_ids))) - pkg_map = {row[0]: row[1] for row in pkg_result.all()} - - items = [] - for o in page_result.items: - pkg_name = pkg_map.get(o.package_id, "") if hasattr(o, "package_id") and o.package_id else "" - items.append( - SelfOrderListItem( - id=o.id, - order_no=o.order_no, - package_name=pkg_name, - order_type=o.order_type, - amount=o.amount, - status=o.status, - pay_method=o.pay_method, - pay_time=o.pay_time.isoformat() if o.pay_time else None, - created_at=o.created_time.isoformat() if o.created_time else None, - ), - ) - - return SelfOrderListOut( - items=items, - total=page_result.total, - page_no=page_no, - page_size=page_size, - ) - - @classmethod - async def get_self_order_detail(cls, auth: AuthSchema, db: AsyncSession, order_id: int) -> SelfOrderDetailOut: - """订单详情 - - 参数: - - auth (AuthSchema): 认证信息模型 - - db (AsyncSession): 数据库会话 - - order_id (int): 订单ID - - 返回: - - SelfOrderDetailOut: 订单详情 - """ - order = await OrderCRUD(auth, db).get_or_404(id=order_id, msg="该数据不存在") - - pkg_name = "" - if order.package_id: - p = await db.get(PackageModel, order.package_id) - if p: - pkg_name = p.name - - return SelfOrderDetailOut( - id=order.id, - order_no=order.order_no, - package_id=order.package_id, - package_name=pkg_name, - amount=order.amount, - order_type=OrderTypeEnum(order.order_type), - status=order.status, - pay_method=order.pay_method, - pay_time=order.pay_time.isoformat() if order.pay_time else None, - created_at=order.created_time.isoformat() if order.created_time else None, - ) - - @classmethod - async def get_workspace_data(cls, auth: AuthSchema, db: AsyncSession, tenant_id: int) -> WorkspaceOut: - """获取租户工作台概览(租户信息、套餐、配额用量、近期订单) - - 参数: - - auth (AuthSchema): 认证信息模型 - - db (AsyncSession): 数据库会话 - - tenant_id (int): 租户ID - - 返回: - - WorkspaceOut: 工作台概览数据 - """ - tenant = await db.get(TenantModel, tenant_id) - if not tenant: - return WorkspaceOut( - tenant=WorkspaceTenantInfo(id=0, name="", code="", status=0, status_label="未知"), - quota=WorkspaceQuotaInfo(), - ) - - package = None - if tenant.package_id: - package = await db.get(PackageModel, tenant.package_id) - - async def _count(model_cls) -> int: - stmt = ( - select(func.count()) - .select_from(model_cls) - .where( - model_cls.tenant_id == tenant_id, - model_cls.is_deleted.is_(False), - ) - ) - return (await db.execute(stmt)).scalar() or 0 - - user_count = await _count(UserModel) - role_count = await _count(RoleModel) - dept_count = await _count(DeptModel) - - now = datetime.now() - days_remaining = (tenant.end_time - now).days if tenant.end_time else 0 - - status_labels = { - "0": "正常", - "1": "宽限期", - "2": "已暂停", - "3": "已冻结", - "4": "已过期", - "5": "已归档", - } - - orders_stmt = select(OrderModel).where(OrderModel.tenant_id == tenant_id).order_by(OrderModel.created_time.desc()).limit(5) - orders_result = await db.execute(orders_stmt) - recent_orders = [] - for o in orders_result.scalars().all(): - recent_orders.append( - WorkspaceOrderItem( - id=o.id, - order_no=o.order_no, - amount=o.amount, - order_type=OrderTypeEnum(o.order_type), - status=o.status, - created_at=o.created_time.isoformat() if o.created_time else None, - ), - ) - - return WorkspaceOut( - tenant=WorkspaceTenantInfo( - id=tenant.id, - name=tenant.name, - code=tenant.code, - status=tenant.status, - status_label=status_labels.get(str(tenant.status), "未知"), - start_time=tenant.start_time.isoformat() if tenant.start_time else None, - end_time=tenant.end_time.isoformat() if tenant.end_time else None, - days_remaining=max(days_remaining, 0), - ), - package=WorkspacePackageInfo( - id=package.id, - name=package.name, - price=package.price, - period=package.period, - max_users=package.max_users, - max_roles=package.max_roles, - max_depts=package.max_depts, - ) - if package - else None, - quota=WorkspaceQuotaInfo( - max_users=package.max_users if package else 0, - max_roles=package.max_roles if package else 0, - max_depts=package.max_depts if package else 0, - current_users=user_count, - current_roles=role_count, - current_depts=dept_count, - usage_percent=WorkspaceUsagePercent( - users=round(user_count / package.max_users * 100, 1) if package and package.max_users > 0 else 0, - roles=round(role_count / package.max_roles * 100, 1) if package and package.max_roles > 0 else 0, - depts=round(dept_count / package.max_depts * 100, 1) if package and package.max_depts > 0 else 0, - ), - ), - recent_orders=recent_orders, - ) diff --git a/backend/app/api/v1/module_system/__init__.py b/backend/app/api/v1/module_system/__init__.py index 1d82378f..75160144 100644 --- a/backend/app/api/v1/module_system/__init__.py +++ b/backend/app/api/v1/module_system/__init__.py @@ -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) diff --git a/backend/app/api/v1/module_system/api_token/model.py b/backend/app/api/v1/module_system/api_token/model.py index 83bb0fa8..25320616 100644 --- a/backend/app/api/v1/module_system/api_token/model.py +++ b/backend/app/api/v1/module_system/api_token/model.py @@ -1,25 +1,16 @@ -"""API Token 数据模型 - -设计要点: -- token 全名:``fastpat___<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___<48-char-base64url-secret> + fastpat__<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 字符(用于展示)") diff --git a/backend/app/api/v1/module_system/api_token/schema.py b/backend/app/api/v1/module_system/api_token/schema.py index f023ffac..f07b01b7 100644 --- a/backend/app/api/v1/module_system/api_token/schema.py +++ b/backend/app/api/v1/module_system/api_token/schema.py @@ -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。关闭此页面后将无法再次完整查看明文,如遗失请重置。", diff --git a/backend/app/api/v1/module_system/api_token/service.py b/backend/app/api/v1/module_system/api_token/service.py index 98068d3b..2639a045 100644 --- a/backend/app/api/v1/module_system/api_token/service.py +++ b/backend/app/api/v1/module_system/api_token/service.py @@ -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___<48-base64url>``""" +def _generate_full_token(user_id: int) -> str: + """生成完整 token:``fastpat__<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) diff --git a/backend/app/api/v1/module_system/auth/controller.py b/backend/app/api/v1/module_system/auth/controller.py index ea7c0c13..0dd94fb7 100644 --- a/backend/app/api/v1/module_system/auth/controller.py +++ b/backend/app/api/v1/module_system/auth/controller.py @@ -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) diff --git a/backend/app/api/v1/module_system/auth/oauth_service.py b/backend/app/api/v1/module_system/auth/oauth_service.py index 23cf445e..b1cc464d 100644 --- a/backend/app/api/v1/module_system/auth/oauth_service.py +++ b/backend/app/api/v1/module_system/auth/oauth_service.py @@ -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="用户不存在") diff --git a/backend/app/api/v1/module_system/auth/schema.py b/backend/app/api/v1/module_system/auth/schema.py index 914fae2d..28c9b8df 100644 --- a/backend/app/api/v1/module_system/auth/schema.py +++ b/backend/app/api/v1/module_system/auth/schema.py @@ -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): """滑块验证完成请求""" diff --git a/backend/app/api/v1/module_system/auth/service.py b/backend/app/api/v1/module_system/auth/service.py index d4e5f6bf..6444475a 100644 --- a/backend/app/api/v1/module_system/auth/service.py +++ b/backend/app/api/v1/module_system/auth/service.py @@ -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 - ] diff --git a/backend/app/api/v1/module_system/dept/model.py b/backend/app/api/v1/module_system/dept/model.py index 7bd84c91..5de4fa22 100644 --- a/backend/app/api/v1/module_system/dept/model.py +++ b/backend/app/api/v1/module_system/dept/model.py @@ -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) diff --git a/backend/app/api/v1/module_system/dept/schema.py b/backend/app/api/v1/module_system/dept/schema.py index 7d77290e..cc65c161 100644 --- a/backend/app/api/v1/module_system/dept/schema.py +++ b/backend/app/api/v1/module_system/dept/schema.py @@ -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="部门名称") diff --git a/backend/app/api/v1/module_system/dept/service.py b/backend/app/api/v1/module_system/dept/service.py index d201a1b6..783c9e2f 100644 --- a/backend/app/api/v1/module_system/dept/service.py +++ b/backend/app/api/v1/module_system/dept/service.py @@ -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) diff --git a/backend/app/api/v1/module_system/dict/controller.py b/backend/app/api/v1/module_system/dict/controller.py index e4a00ecd..f886868d 100644 --- a/backend/app/api/v1/module_system/dict/controller.py +++ b/backend/app/api/v1/module_system/dict/controller.py @@ -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="获取初始化字典数据成功") diff --git a/backend/app/api/v1/module_system/dict/model.py b/backend/app/api/v1/module_system/dict/model.py index 0c201d1b..f63ed2d4 100644 --- a/backend/app/api/v1/module_system/dict/model.py +++ b/backend/app/api/v1/module_system/dict/model.py @@ -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="备注") diff --git a/backend/app/api/v1/module_system/dict/schema.py b/backend/app/api/v1/module_system/dict/schema.py index 44bb75c3..37e056c4 100644 --- a/backend/app/api/v1/module_system/dict/schema.py +++ b/backend/app/api/v1/module_system/dict/schema.py @@ -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) diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index 08fa6f5f..eb185402 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -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) diff --git a/backend/app/api/v1/module_system/log/model.py b/backend/app/api/v1/module_system/log/model.py index effdbba6..cf19114c 100644 --- a/backend/app/api/v1/module_system/log/model.py +++ b/backend/app/api/v1/module_system/log/model.py @@ -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,14 +20,13 @@ 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="备注") username: Mapped[str] = mapped_column(String(64), nullable=False, 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="请求路径") diff --git a/backend/app/api/v1/module_system/log/schema.py b/backend/app/api/v1/module_system/log/schema.py index 88f2d1fd..d4eb3df8 100644 --- a/backend/app/api/v1/module_system/log/schema.py +++ b/backend/app/api/v1/module_system/log/schema.py @@ -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="请求体") diff --git a/backend/app/api/v1/module_platform/menu/__init__.py b/backend/app/api/v1/module_system/menu/__init__.py similarity index 100% rename from backend/app/api/v1/module_platform/menu/__init__.py rename to backend/app/api/v1/module_system/menu/__init__.py diff --git a/backend/app/api/v1/module_platform/menu/controller.py b/backend/app/api/v1/module_system/menu/controller.py similarity index 96% rename from backend/app/api/v1/module_platform/menu/controller.py rename to backend/app/api/v1/module_system/menu/controller.py index 59cd805b..2d9fa5f2 100644 --- a/backend/app/api/v1/module_platform/menu/controller.py +++ b/backend/app/api/v1/module_system/menu/controller.py @@ -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: diff --git a/backend/app/api/v1/module_platform/menu/crud.py b/backend/app/api/v1/module_system/menu/crud.py similarity index 100% rename from backend/app/api/v1/module_platform/menu/crud.py rename to backend/app/api/v1/module_system/menu/crud.py diff --git a/backend/app/api/v1/module_platform/menu/model.py b/backend/app/api/v1/module_system/menu/model.py similarity index 90% rename from backend/app/api/v1/module_platform/menu/model.py rename to backend/app/api/v1/module_system/menu/model.py index 4c03b790..2938d22e 100644 --- a/backend/app/api/v1/module_platform/menu/model.py +++ b/backend/app/api/v1/module_system/menu/model.py @@ -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") diff --git a/backend/app/api/v1/module_platform/menu/schema.py b/backend/app/api/v1/module_system/menu/schema.py similarity index 89% rename from backend/app/api/v1/module_platform/menu/schema.py rename to backend/app/api/v1/module_system/menu/schema.py index 188e0b63..021bad97 100644 --- a/backend/app/api/v1/module_platform/menu/schema.py +++ b/backend/app/api/v1/module_system/menu/schema.py @@ -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"}, ) diff --git a/backend/app/api/v1/module_platform/menu/service.py b/backend/app/api/v1/module_system/menu/service.py similarity index 92% rename from backend/app/api/v1/module_platform/menu/service.py rename to backend/app/api/v1/module_system/menu/service.py index 5386ca85..488e7e9d 100644 --- a/backend/app/api/v1/module_platform/menu/service.py +++ b/backend/app/api/v1/module_system/menu/service.py @@ -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: diff --git a/backend/app/api/v1/module_system/notice/model.py b/backend/app/api/v1/module_system/notice/model.py index 7f225339..d52a2196 100644 --- a/backend/app/api/v1/module_system/notice/model.py +++ b/backend/app/api/v1/module_system/notice/model.py @@ -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") diff --git a/backend/app/api/v1/module_system/notice/schema.py b/backend/app/api/v1/module_system/notice/schema.py index 14ddddae..99239f37 100644 --- a/backend/app/api/v1/module_system/notice/schema.py +++ b/backend/app/api/v1/module_system/notice/schema.py @@ -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:已归档)") diff --git a/backend/app/api/v1/module_system/params/controller.py b/backend/app/api/v1/module_system/params/controller.py index f8e7c4a3..bbff2b77 100644 --- a/backend/app/api/v1/module_system/params/controller.py +++ b/backend/app/api/v1/module_system/params/controller.py @@ -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="获取初始化缓存参数成功") diff --git a/backend/app/api/v1/module_system/params/model.py b/backend/app/api/v1/module_system/params/model.py index 10bd2dc2..b715b2d3 100644 --- a/backend/app/api/v1/module_system/params/model.py +++ b/backend/app/api/v1/module_system/params/model.py @@ -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="参数键名") diff --git a/backend/app/api/v1/module_system/params/schema.py b/backend/app/api/v1/module_system/params/schema.py index 82b0db9d..2bf561aa 100644 --- a/backend/app/api/v1/module_system/params/schema.py +++ b/backend/app/api/v1/module_system/params/schema.py @@ -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): """参数管理查询参数 """ diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 323a12c8..3c3d40e8 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -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: diff --git a/backend/app/api/v1/module_system/position/model.py b/backend/app/api/v1/module_system/position/model.py index 5a5b0d9e..574f1782 100644 --- a/backend/app/api/v1/module_system/position/model.py +++ b/backend/app/api/v1/module_system/position/model.py @@ -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="岗位编码") diff --git a/backend/app/api/v1/module_system/position/schema.py b/backend/app/api/v1/module_system/position/schema.py index 336fc72f..72673f1c 100644 --- a/backend/app/api/v1/module_system/position/schema.py +++ b/backend/app/api/v1/module_system/position/schema.py @@ -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="岗位名称") diff --git a/backend/app/api/v1/module_system/role/crud.py b/backend/app/api/v1/module_system/role/crud.py index efd81ee2..a84b7559 100644 --- a/backend/app/api/v1/module_system/role/crud.py +++ b/backend/app/api/v1/module_system/role/crud.py @@ -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] diff --git a/backend/app/api/v1/module_system/role/model.py b/backend/app/api/v1/module_system/role/model.py index 775bafa3..a00b9856 100644 --- a/backend/app/api/v1/module_system/role/model.py +++ b/backend/app/api/v1/module_system/role/model.py @@ -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="备注") diff --git a/backend/app/api/v1/module_system/role/schema.py b/backend/app/api/v1/module_system/role/schema.py index f96bb7ec..cebdb916 100644 --- a/backend/app/api/v1/module_system/role/schema.py +++ b/backend/app/api/v1/module_system/role/schema.py @@ -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): """角色管理查询参数 """ diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index 8c434efa..d54f06eb 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -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) diff --git a/backend/app/api/v1/module_system/ticket/__init__.py b/backend/app/api/v1/module_system/ticket/__init__.py deleted file mode 100644 index a4d8bf66..00000000 --- a/backend/app/api/v1/module_system/ticket/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .controller import TicketRouter - -__all__ = ["TicketRouter"] diff --git a/backend/app/api/v1/module_system/ticket/controller.py b/backend/app/api/v1/module_system/ticket/controller.py deleted file mode 100644 index 5e4dcb07..00000000 --- a/backend/app/api/v1/module_system/ticket/controller.py +++ /dev/null @@ -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="评论成功") diff --git a/backend/app/api/v1/module_system/ticket/crud.py b/backend/app/api/v1/module_system/ticket/crud.py deleted file mode 100644 index 63e88d1f..00000000 --- a/backend/app/api/v1/module_system/ticket/crud.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/module_system/ticket/model.py b/backend/app/api/v1/module_system/ticket/model.py deleted file mode 100644 index a33a8d6e..00000000 --- a/backend/app/api/v1/module_system/ticket/model.py +++ /dev/null @@ -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="评论内容(富文本)") diff --git a/backend/app/api/v1/module_system/ticket/schema.py b/backend/app/api/v1/module_system/ticket/schema.py deleted file mode 100644 index 0ca1cb83..00000000 --- a/backend/app/api/v1/module_system/ticket/schema.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/module_system/ticket/service.py b/backend/app/api/v1/module_system/ticket/service.py deleted file mode 100644 index 36fb7338..00000000 --- a/backend/app/api/v1/module_system/ticket/service.py +++ /dev/null @@ -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]) diff --git a/backend/app/api/v1/module_system/user/controller.py b/backend/app/api/v1/module_system/user/controller.py index 14edcba0..8896ab41 100644 --- a/backend/app/api/v1/module_system/user/controller.py +++ b/backend/app/api/v1/module_system/user/controller.py @@ -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="重置密码成功") diff --git a/backend/app/api/v1/module_system/user/crud.py b/backend/app/api/v1/module_system/user/crud.py index b8c65392..5dc6b17b 100644 --- a/backend/app/api/v1/module_system/user/crud.py +++ b/backend/app/api/v1/module_system/user/crud.py @@ -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() diff --git a/backend/app/api/v1/module_system/user/model.py b/backend/app/api/v1/module_system/user/model.py index 65e9c269..a949a481 100644 --- a/backend/app/api/v1/module_system/user/model.py +++ b/backend/app/api/v1/module_system/user/model.py @@ -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") diff --git a/backend/app/api/v1/module_system/user/schema.py b/backend/app/api/v1/module_system/user/schema.py index 337ef460..98f7d7a9 100644 --- a/backend/app/api/v1/module_system/user/schema.py +++ b/backend/app/api/v1/module_system/user/schema.py @@ -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) - 业务字段:用户名、名称、手机号、邮箱、部门、状态 """ diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index 743dbeb4..b4a8f60c 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -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 = 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, diff --git a/backend/app/api/v1/module_system/versions/controller.py b/backend/app/api/v1/module_system/versions/controller.py index 9f9b2e7e..a6527575 100644 --- a/backend/app/api/v1/module_system/versions/controller.py +++ b/backend/app/api/v1/module_system/versions/controller.py @@ -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="查询成功") diff --git a/backend/app/api/v1/module_system/versions/model.py b/backend/app/api/v1/module_system/versions/model.py index e05e2c3a..4b124a74 100644 --- a/backend/app/api/v1/module_system/versions/model.py +++ b/backend/app/api/v1/module_system/versions/model.py @@ -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="是否需要重新登录") diff --git a/backend/app/api/v1/module_task/cronjob/job/model.py b/backend/app/api/v1/module_task/cronjob/job/model.py index 9aebf7ec..c286cedc 100644 --- a/backend/app/api/v1/module_task/cronjob/job/model.py +++ b/backend/app/api/v1/module_task/cronjob/job/model.py @@ -1,17 +1,16 @@ 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="任务名称") trigger_type: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="触发方式: cron/interval/date/manual") diff --git a/backend/app/api/v1/module_task/cronjob/job/schema.py b/backend/app/api/v1/module_task/cronjob/job/schema.py index a5ee30d8..a499f1e2 100644 --- a/backend/app/api/v1/module_task/cronjob/job/schema.py +++ b/backend/app/api/v1/module_task/cronjob/job/schema.py @@ -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:已取消)") - - diff --git a/backend/app/api/v1/module_task/cronjob/node/model.py b/backend/app/api/v1/module_task/cronjob/node/model.py index 98686d7e..dc94eb21 100644 --- a/backend/app/api/v1/module_task/cronjob/node/model.py +++ b/backend/app/api/v1/module_task/cronjob/node/model.py @@ -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="节点编码") diff --git a/backend/app/api/v1/module_task/cronjob/node/schema.py b/backend/app/api/v1/module_task/cronjob/node/schema.py index 5d59c203..4be155d0 100644 --- a/backend/app/api/v1/module_task/cronjob/node/schema.py +++ b/backend/app/api/v1/module_task/cronjob/node/schema.py @@ -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="节点名称") diff --git a/backend/app/api/v1/module_task/workflow/flows/model.py b/backend/app/api/v1/module_task/workflow/flows/model.py index 3d6a8f4a..c9f84abc 100644 --- a/backend/app/api/v1/module_task/workflow/flows/model.py +++ b/backend/app/api/v1/module_task/workflow/flows/model.py @@ -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="流程编码") diff --git a/backend/app/api/v1/module_task/workflow/flows/schema.py b/backend/app/api/v1/module_task/workflow/flows/schema.py index 094d9646..39b63674 100644 --- a/backend/app/api/v1/module_task/workflow/flows/schema.py +++ b/backend/app/api/v1/module_task/workflow/flows/schema.py @@ -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="流程名称") diff --git a/backend/app/api/v1/module_task/workflow/node_type/model.py b/backend/app/api/v1/module_task/workflow/node_type/model.py index bf7fdcae..2a540557 100644 --- a/backend/app/api/v1/module_task/workflow/node_type/model.py +++ b/backend/app/api/v1/module_task/workflow/node_type/model.py @@ -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") diff --git a/backend/app/api/v1/module_task/workflow/node_type/schema.py b/backend/app/api/v1/module_task/workflow/node_type/schema.py index c905ca9e..0f784142 100644 --- a/backend/app/api/v1/module_task/workflow/node_type/schema.py +++ b/backend/app/api/v1/module_task/workflow/node_type/schema.py @@ -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:停用)") - - diff --git a/backend/app/common/constant.py b/backend/app/common/constant.py index de52f4bb..9562c80e 100644 --- a/backend/app/common/constant.py +++ b/backend/app/common/constant.py @@ -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", diff --git a/backend/app/common/enums.py b/backend/app/common/enums.py index 377027d8..9b1ce749 100644 --- a/backend/app/common/enums.py +++ b/backend/app/common/enums.py @@ -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 - - # ==================== 系统返回码 ==================== diff --git a/backend/app/config/path_conf.py b/backend/app/config/path_conf.py index 7c74ae74..9b66f091 100644 --- a/backend/app/config/path_conf.py +++ b/backend/app/config/path_conf.py @@ -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" diff --git a/backend/app/config/setting.py b/backend/app/config/setting.py index 9aa15086..aa562bf1 100755 --- a/backend/app/config/setting.py +++ b/backend/app/config/setting.py @@ -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 diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index 14210263..c169cdcb 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -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 diff --git a/backend/app/core/base_crud.py b/backend/app/core/base_crud.py index 10b47433..a7bef404 100644 --- a/backend/app/core/base_crud.py +++ b/backend/app/core/base_crud.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from typing import Any, TypeVar, cast from pydantic import BaseModel -from sqlalchemy import Select, asc, delete, desc, false, func, literal_column, select, update +from sqlalchemy import asc, delete, desc, false, func, literal_column, select, update from sqlalchemy import inspect as sa_inspect from sqlalchemy.engine import Result from sqlalchemy.ext.asyncio import AsyncSession @@ -13,7 +13,6 @@ from sqlalchemy.sql.elements import ColumnElement from app.core.base_model import ModelMixin from app.core.base_schema import AuthSchema, PageResultSchema from app.core.exceptions import CustomException -from app.core.permission import Permission OutSchemaType = TypeVar("OutSchemaType", bound=BaseModel) CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) @@ -32,19 +31,11 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: """ def __init__(self, model: type[ModelType], auth: AuthSchema, db: AsyncSession) -> None: - """初始化 CRUDBase。 - - 参数: - - model: 数据模型类 - - auth: 认证信息 - - db: 数据库会话 - """ self.model = model self.auth = auth self.db = db def _get_pk_col(self) -> ColumnElement: - """获取模型主键列""" mapper = sa_inspect(self.model) pk_cols = list[Any](getattr(mapper, "primary_key", [])) if not pk_cols: @@ -55,53 +46,26 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: @property def _supports_soft_delete(self) -> bool: - """模型是否支持软删除""" return all(hasattr(self.model, attr) for attr in ("is_deleted", "deleted_time", "deleted_id")) def _soft_delete_values(self) -> dict[str, Any]: - """软删除时需要更新的字段值""" data: dict[str, Any] = {"is_deleted": True, "deleted_time": datetime.now()} if self.auth.user.id: data["deleted_id"] = self.auth.user.id return data - async def _get_one(self, preload: list[str | Any] | None = None, **kwargs) -> ModelType | None: - """内部方法:在当前实例会话上执行单条查询(get / update 共用) - - 参数: - - preload: 预加载关系 - - **kwargs: 查询条件 - - 返回: - - 对象实例或 None - """ - conditions = await self.__build_conditions(**kwargs) - sql = select(self.model).where(*conditions) - for opt in self.__loader_options(preload): - sql = sql.options(opt) - sql = await self.__filter_permissions(sql) - result: Result = await self.db.execute(sql) - return result.scalars().first() - async def get(self, preload: list[str | Any] | None = None, **kwargs) -> ModelType | None: - """根据条件获取单个对象(复用请求级事务会话,保证读已写一致性) - - 参数: - - preload: 预加载关系 - - **kwargs: 查询条件 - - 返回: - - 对象实例或 None - """ try: - return await self._get_one(preload=preload, **kwargs) - except CustomException: - raise + conditions = await self.__build_conditions(**kwargs) + sql = select(self.model).where(*conditions) + for opt in self.__loader_options(preload): + sql = sql.options(opt) + result: Result = await self.db.execute(sql) + return result.scalars().first() except Exception as e: raise CustomException(msg=f"获取查询失败: {e!s}") async def get_by_id(self, model_id: int) -> ModelType | None: - """按主键查询""" return await self.get(id=model_id) async def get_or_404( @@ -112,21 +76,6 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: out_schema: type[OutSchemaType] | None = None, **kwargs, ) -> ModelType | OutSchemaType: - """按条件查询单条记录,不存在时抛出 404。 - - 参数: - - id: 主键 ID(快捷方式,等价于 kwargs={"id": id})。 - - msg: 不存在时的错误消息。 - - preload: 预加载关系列表。 - - out_schema: 输出 Schema,为 None 时返回 ORM 对象。 - - **kwargs: 其他查询条件(与 id 互斥)。 - - 返回: - - ORM 对象或 Pydantic Schema 实例。 - - 异常: - - CustomException: 记录不存在。 - """ if id is not None: kwargs["id"] = id obj = await self.get(preload=preload, **kwargs) @@ -135,33 +84,14 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: return out_schema.model_validate(obj) if out_schema else obj async def exists(self, **kwargs) -> bool: - """检查是否存在符合条件的记录 - - 参数: - - **kwargs: 查询条件 - - 返回: - - 是否存在 - """ return await self.get(**kwargs) is not None async def count(self, **kwargs) -> int: - """统计符合条件的记录数(复用请求级事务会话) - - 参数: - - **kwargs: 查询条件,支持元组语法 - - 返回: - - 记录数 - """ try: conditions = await self.__build_conditions(**kwargs) count_sql = select(func.count()).select_from(self.model).where(*conditions) - count_sql = await self.__filter_permissions(count_sql) result: Result = await self.db.execute(count_sql) return result.scalar() or 0 - except CustomException: - raise except Exception as e: raise CustomException(msg=f"统计失败: {e!s}") @@ -172,17 +102,6 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: preload: list[str | Any] | None = None, load_columns: list | None = None, ) -> Sequence[ModelType]: - """根据条件获取对象列表(复用请求级事务会话) - - 参数: - - search: 查询条件 - - order_by: 排序字段, 格式为 [{'id': 'asc'}, {'name': 'desc'}] - - preload: 预加载关系 - - load_columns: 仅加载指定的列(减少 SELECT 传输量) - - 返回: - - 对象列表 - """ try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] @@ -191,11 +110,8 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: sql = sql.options(load_only(*load_columns)) for opt in self.__loader_options(preload): sql = sql.options(opt) - sql = await self.__filter_permissions(sql) result: Result = await self.db.execute(sql) return result.scalars().all() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"列表查询失败: {e!s}") @@ -206,18 +122,6 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: children_attr: str | None = None, preload: list[str | Any] | None = None, ) -> Sequence[ModelType]: - """获取树形结构数据列表(复用请求级事务会话) - - 参数: - - search: 查询条件 - - order_by: 排序字段 - - children_attr: 子节点属性名(None 时自动从模型 __tree_children_attr__ 推断) - - preload: 额外预加载关系 - - 返回: - - 树形结构数据列表 - """ - # 自动从模型推断 children_attr if children_attr is None: children_attr = getattr(self.model, "__tree_children_attr__", "children") try: @@ -233,11 +137,8 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: for opt in self.__loader_options(final_preload): sql = sql.options(opt) - sql = await self.__filter_permissions(sql) result: Result = await self.db.execute(sql) return result.scalars().all() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"树形列表查询失败: {e!s}") @@ -251,20 +152,6 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: preload: list[str | Any] | None = None, load_columns: list | None = None, ) -> PageResultSchema[OutSchemaType] | PageResultSchema: - """获取分页数据(复用请求级事务会话;count 与 data 共享同一会话) - - 参数: - - offset: 偏移量 - - limit: 每页数量 - - order_by: 排序字段 - - search: 查询条件 - - out_schema: 输出数据模型(None 时返回原始 ORM 对象) - - preload: 预加载关系 - - load_columns: 仅加载指定的列(减少 SELECT 传输量) - - 返回: - - PageResultSchema: 分页结果 - """ try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] @@ -278,8 +165,6 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: data_sql = data_sql.options(load_only(*load_columns)) for opt in self.__loader_options(preload): data_sql = data_sql.options(opt) - data_sql = await self.__filter_permissions(data_sql) - count_sql = select(func.count(pk)).select_from(self.model) where_clause = data_sql.whereclause if where_clause is not None: @@ -300,35 +185,16 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: has_next=offset + limit < total, items=items, ) - except CustomException: - raise except Exception as e: raise CustomException(msg=f"分页查询失败: {e!s}") async def create(self, data: CreateSchemaType) -> ModelType: - """创建新对象(有认证时自动填充租户与审计字段) - - 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 - - 参数: - - data: 对象属性 - - 返回: - - 新创建的对象实例 - """ try: obj_dict = data if isinstance(data, dict) else cast("BaseModel", data).model_dump() obj = self.model(**obj_dict) user = self.auth.user if user.id: - if hasattr(obj, "tenant_id"): - # 仅当调用方未显式指定 tenant_id 时,才默认使用当前用户的租户 - # 超管可以显式传任意 tenant_id(管理跨租户数据),非超管必须强制为本租户 - if not hasattr(obj, "tenant_id") or getattr(obj, "tenant_id", None) is None: - setattr(obj, "tenant_id", user.tenant_id) - elif not user.is_superuser and getattr(obj, "tenant_id") != user.tenant_id: - raise CustomException(msg="无权创建其他租户的数据") if hasattr(obj, "created_id"): setattr(obj, "created_id", user.id) if hasattr(obj, "updated_id"): @@ -338,42 +204,18 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: await self.db.flush() await self.db.refresh(obj) return obj - except CustomException: - raise except Exception as e: raise CustomException(msg=f"创建失败: {e!s}") async def update(self, id: int, data: UpdateSchemaType) -> ModelType: - """更新对象(有认证时检查租户归属 + 填充审计字段) - - 事务由 request 级 db_getter 统一管理,本方法不开启独立事务。 - - 参数: - - id: 对象 ID - - data: 更新属性 - - 返回: - - 更新后的对象实例 - """ try: obj_dict = data if isinstance(data, dict) else cast("BaseModel", data).model_dump(exclude_unset=True, exclude={"id"}) model_defaults = getattr(self.model, "__loader_options__", []) - obj = await self._get_one(id=id, preload=model_defaults) + obj = await self.get(id=id, preload=model_defaults) if not obj: raise CustomException(msg="更新对象不存在") - # 租户权限检查(仅在有认证且非超管时) user = self.auth.user - if user.id and not user.is_superuser: - if hasattr(obj, "tenant_id"): - obj_tid = getattr(obj, "tenant_id", None) - if obj_tid is not None and obj_tid != user.tenant_id: - is_platform = getattr(self.model, "__platform_data_shared__", False) - if is_platform and obj_tid == 1: - raise CustomException(msg="平台数据仅管理员可修改") - raise CustomException(msg="无权修改其他租户的数据") - - # 审计字段 if user.id and hasattr(obj, "updated_id"): setattr(obj, "updated_id", user.id) @@ -390,109 +232,57 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: raise CustomException(msg=f"更新失败: {e!s}") async def delete(self, ids: list[int]) -> None: - """软删除对象(有认证时填充删除人 + 租户隔离)""" try: pk = self._get_pk_col() - if self._supports_soft_delete: - sql = self._tenant_dml_where(update(self.model).where(pk.in_(ids))).values(**self._soft_delete_values()) - await self.db.execute(sql) + sql = update(self.model).where(pk.in_(ids)).values(**self._soft_delete_values()) else: - sql = self._tenant_dml_where(delete(self.model).where(pk.in_(ids))) - await self.db.execute(sql) + sql = delete(self.model).where(pk.in_(ids)) + await self.db.execute(sql) await self.db.flush() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"删除失败: {e!s}") async def clear(self) -> None: - """软清空对象表(有认证时填充删除人 + 租户隔离)""" try: if self._supports_soft_delete: - sql = self._tenant_dml_where(update(self.model)).values(**self._soft_delete_values()) - await self.db.execute(sql) + sql = update(self.model).values(**self._soft_delete_values()) else: - sql = self._tenant_dml_where(delete(self.model)) - await self.db.execute(sql) + sql = delete(self.model) + await self.db.execute(sql) await self.db.flush() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"清空失败: {e!s}") async def set(self, ids: list[int], **kwargs) -> None: - """批量更新字段(带租户隔离)""" try: pk = self._get_pk_col() - sql = self._tenant_dml_where(update(self.model)).where(pk.in_(ids)).values(**kwargs) + sql = update(self.model).where(pk.in_(ids)).values(**kwargs) await self.db.execute(sql) await self.db.flush() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"批量更新失败: {e!s}") async def restore(self, ids: list[int]) -> None: - """恢复软删除对象(带租户隔离)""" try: if not self._supports_soft_delete: raise CustomException(msg="该模型不支持软删除,无法恢复") pk = self._get_pk_col() - sql = self._tenant_dml_where(update(self.model).where(pk.in_(ids))).values(is_deleted=False, deleted_time=None, deleted_id=None) + sql = update(self.model).where(pk.in_(ids)).values(is_deleted=False, deleted_time=None, deleted_id=None) await self.db.execute(sql) await self.db.flush() - except CustomException: - raise except Exception as e: raise CustomException(msg=f"恢复失败: {e!s}") - async def __filter_permissions(self, sql: Select) -> Select: - """过滤数据权限(仅用于 Select)""" - if not self.auth: - return sql - if getattr(self.model, "__platform_data_shared__", False): - for condition in self._platform_shared_conditions(): - sql = sql.where(condition) - filter_obj = Permission(model=self.model, auth=self.auth, db=self.db) - return await filter_obj.filter_query(sql) - - def _platform_shared_conditions(self) -> list[ColumnElement]: - user = self.auth.user - if not user.id: - return [] - tid = user.tenant_id - if tid is not None and tid != 1: - return [(getattr(self.model, "tenant_id") == tid) | (getattr(self.model, "tenant_id") == 1)] - return [] - - def _tenant_dml_where(self, sql): - """为 DML 语句注入 tenant_id 条件(不读平台数据)""" - if hasattr(self.model, "tenant_id"): - user = self.auth.user - if user.id and not user.is_superuser: - tid = user.tenant_id - if tid is not None: - return sql.where(getattr(self.model, "tenant_id") == tid) - return sql - async def __build_conditions(self, **kwargs) -> list[ColumnElement]: conditions: list[ColumnElement] = [] if hasattr(self.model, "is_deleted"): conditions.append(getattr(self.model, "is_deleted") == false()) - if hasattr(self.model, "tenant_id") and not getattr(self.model, "__platform_data_shared__", False): - user = self.auth.user - if user.id and not user.is_superuser: - tid = user.tenant_id - if tid is not None: - conditions.append(getattr(self.model, "tenant_id") == tid) - for key, value in kwargs.items(): if value is None or value == "": continue - attr = getattr(self.model, key) if isinstance(value, tuple): conditions.extend(self._resolve_condition(attr, value)) @@ -508,17 +298,14 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: @staticmethod def _resolve_condition(attr: ColumnElement, value: tuple) -> list[ColumnElement]: - """解析 (operator, value) 元组为 SQLAlchemy 条件列表""" seq, val = value - handlers: dict[str, tuple] = { - "None": (lambda: [attr.is_(None)], True), - "not None": (lambda: [attr.isnot(None)], True), + handlers: dict[str, Any] = { + "None": lambda: [attr.is_(None)], + "not None": lambda: [attr.isnot(None)], } - # 需要额外校验的运算符 if seq in handlers: - fn, _always = handlers[seq] - return fn() + return handlers[seq]() if val is None: return [] @@ -539,7 +326,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: if seq == "between" and isinstance(val, (list, tuple)) and len(val) == 2: return [attr.between(val[0], val[1])] - _COMPARATORS: dict[str, Any] = { + _COMPARATORS = { "!=": attr.__ne__, "ne": attr.__ne__, ">": attr.__gt__, "gt": attr.__gt__, ">=": attr.__ge__, "ge": attr.__ge__, @@ -552,50 +339,32 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]: return [cmp(val)] return [] - def _parse_order(self, order: list[dict[str, str]]) -> list[ColumnElement]: - """解析排序参数 - - 参数: - - order: 排序字段列表, 格式为 [{'id': 'asc'}, {'name': 'desc'}] - - 返回: - - 排序表达式列表 - """ + @staticmethod + def _parse_order(order: list[dict[str, str]]) -> list[ColumnElement]: columns: list[ColumnElement] = [] for item in order: for field, direction in item.items(): - column = getattr(self.model, field) + column = getattr(self.model, field) # type: ignore[arg-type] columns.append(desc(column) if direction.lower() == "desc" else asc(column)) return columns def __loader_options(self, preload: list[str | Any] | None = None) -> list[Any]: - """构建预加载选项 - - 参数: - - preload: 预加载关系,支持关系名字符串或 SQLAlchemy loader option - - 返回: - - 预加载选项列表 - """ model_loader_options = getattr(self.model, "__loader_options__", []) if preload == []: return [] - # 收集所有需要预加载的关系名 names: set[str] = set(model_loader_options) if preload: for opt in preload: if isinstance(opt, str): names.add(opt) - # 字符串名 → selectinload options: list[Any] = [] for name in names: if hasattr(self.model, name): options.append(selectinload(getattr(self.model, name))) - # 非字符串预加载项直接追加(如递归 selectinload) if preload: options.extend(opt for opt in preload if not isinstance(opt, str)) diff --git a/backend/app/core/base_model.py b/backend/app/core/base_model.py index 03005465..ed8ded8e 100644 --- a/backend/app/core/base_model.py +++ b/backend/app/core/base_model.py @@ -4,7 +4,6 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, relationship -from app.common.enums import PermissionFilterStrategy from app.utils.common_util import uuid4_str @@ -22,9 +21,6 @@ class MappedBase(AsyncAttrs, DeclarativeBase): __abstract__: bool = True - # 权限过滤策略,子类可以覆盖 - __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DATA_SCOPE - class ModelMixin(MappedBase): """模型混入类 - 提供通用字段和功能 @@ -102,31 +98,6 @@ class ModelMixin(MappedBase): ) -class TenantMixin(MappedBase): - """租户隔离字段 Mixin""" - - __abstract__ = True - - tenant_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("platform_tenant.id", ondelete="RESTRICT", onupdate="CASCADE"), - nullable=False, - default=1, - index=True, - comment="租户ID", - ) - - @declared_attr - def tenant_by(self): - """租户关联关系""" - return relationship( - "TenantModel", - lazy="selectin", - foreign_keys=lambda: self.tenant_id, # pyright: ignore[reportArgumentType] - uselist=False, - ) - - class UserMixin(MappedBase): """用户审计字段 Mixin""" diff --git a/backend/app/core/base_schema.py b/backend/app/core/base_schema.py index 482cc2f8..24934ee4 100644 --- a/backend/app/core/base_schema.py +++ b/backend/app/core/base_schema.py @@ -43,15 +43,6 @@ class UserBySchema(BaseModel): deleted_by: CommonSchema | None = Field(default=None, description="删除人信息") -class TenantBySchema(BaseModel): - """租户嵌套出参(不再使用扁平 tenant_id / tenant_name / tenant_code)""" - - model_config = ConfigDict(from_attributes=True) - - tenant_id: int | None = Field(default=None, description="租户ID") - tenant_by: CommonSchema | None = Field(default=None, description="租户信息") - - class BatchSetAvailable(BaseModel): """批量设置可用状态的请求模型""" @@ -86,8 +77,6 @@ class SessionInfoSchema(BaseModel): session_id: str = Field(default="", description="会话ID(Redis key 后缀)") user_id: int | None = Field(default=None, description="用户ID") - tenant_id: int = Field(default=0, description="租户ID") - tenant_status: int = Field(default=0, description="租户状态") is_superuser: bool = Field(default=False, description="是否为超级管理员") user_status: int = Field(default=0, description="用户状态") name: str | None = Field(default=None, description="用户名称") @@ -98,10 +87,7 @@ class SessionInfoSchema(BaseModel): gender: str | None = Field(default=None, description="性别(0:男 1:女 2:未知)") avatar: str | None = Field(default=None, description="头像") permissions: list[str] = Field(default_factory=list, description="用户权限列表") - permissions_with_menu: dict[str, int] = Field(default_factory=dict, description="权限→菜单ID映射") menu_ids: list[int] = Field(default_factory=list, description="菜单ID列表") - data_scopes: list[int] = Field(default_factory=list, description="数据权限范围") - custom_dept_ids: list[int] = Field(default_factory=list, description="自定义部门ID") ipaddr: str | None = Field(default=None, description="登陆IP地址") login_location: str | None = Field(default=None, description="登录所属地") os: str | None = Field(default=None, description="操作系统") @@ -197,12 +183,6 @@ class UserByQueryParam(BaseModel): updated_id: int | None = Field(None, description="更新人") -class TenantByQueryParam(BaseModel): - """tenant_id —— 子类自动继承""" - - tenant_id: int | None = Field(None, description="租户ID") - - class OptionSchema(BaseModel): """通用下拉选项 Schema,返回 [{value, label}]""" @@ -219,12 +199,10 @@ class CoreUserSchema(BaseModel): model_config = ConfigDict(from_attributes=True) id: int = Field(default=0, description="用户ID") - tenant_id: int = Field(default=0, description="租户ID") username: str | None = Field(default=None, description="用户名") name: str | None = Field(default=None, description="名称") dept_id: int | None = Field(default=None, description="部门ID") is_superuser: bool = Field(default=False, description="是否超管") - token_version: int = Field(default=0, description="令牌版本(用于校验旧 token 失效)") class AuthSchema(BaseModel): @@ -233,13 +211,5 @@ class AuthSchema(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) user: CoreUserSchema = Field(default_factory=CoreUserSchema, description="用户信息", exclude=True) - check_data_scope: bool = Field(default=True, description="是否检查数据权限") - - # 以下字段从缓存会话中提取,避免裸 dict permissions: list[str] = Field(default_factory=list, description="用户权限标识列表") - permissions_with_menu: dict[str, int] = Field(default_factory=dict, description="权限标识 → 菜单ID 映射") - menu_ids: list[int] = Field(default_factory=list, description="角色授权的菜单ID列表") - data_scopes: list[int] = Field(default_factory=list, description="数据权限范围列表") - custom_dept_ids: list[int] = Field(default_factory=list, description="自定义可见部门ID列表") - role_ids: list[int] = Field(default_factory=list, description="用户关联的角色ID列表") - is_impersonate: bool = Field(default=False, description="是否模拟登录") + menu_ids: list[int] = Field(default_factory=list, description="角色授权的菜单ID列表") \ No newline at end of file diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 49f4076e..e977aa99 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -8,7 +8,7 @@ from redis.asyncio.client import Redis from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.common.enums import RET, RedisInitKeyConfig, TenantStatusEnum +from app.common.enums import RET, RedisInitKeyConfig from app.config.setting import settings from app.core.base_schema import AuthSchema, CoreUserSchema from app.core.database import async_db_session @@ -106,60 +106,48 @@ async def _authenticate( if token.startswith("Bearer"): token = token.split(" ")[1] - # 优先使用 TenantMiddleware 缓存在 request.state.ctx 中的会话信息(避免重复 Redis 读取) - user_info = None - if request: - ctx = getattr(request.state, "ctx", None) - user_info = ctx.jwt_user_info if ctx else None + # 滑动模式下跳过 JWT exp 校验,由 Redis session TTL 决定实际有效期 + payload = decode_access_token(token, verify_exp=not settings.TOKEN_SLIDING_EXPIRE) + if not payload or payload.is_refresh: + raise CustomException(msg="非法凭证", code=RET.INVALID_CREDENTIALS.code, status_code=401) - if not user_info: - # 降级路径:自行解码 token + 从 Redis 读取会话信息 - payload = decode_access_token(token) - if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh: - raise CustomException(msg="非法凭证", code=RET.INVALID_CREDENTIALS.code, status_code=401) - session_id = payload.sub - if not session_id: - raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) - raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}") - if not raw: - raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) - user_info = json.loads(raw) - - session_id = user_info.get("session_id") + session_id = payload.sub if not session_id: raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) - # 滑动过期续期(仅在 token 剩余不足一半时触发) + raw = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}") + if not raw: + raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) + user_info = json.loads(raw) + + # 校验 session 数据完整性 + if not user_info.get("session_id"): + raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) + + # 滑动过期续期 if settings.TOKEN_SLIDING_EXPIRE: ttl = await RedisCURD(redis).ttl(key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}") expire_seconds = settings.ACCESS_TOKEN_EXPIRE_SECONDS if ttl > 0 and ttl < expire_seconds // 2: - await RedisCURD(redis).expire(key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}", expire=expire_seconds) - await RedisCURD(redis).expire(key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}", expire=settings.REFRESH_TOKEN_EXPIRE_SECONDS) + await RedisCURD(redis).expire( + key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}", + expire=expire_seconds, + ) + await RedisCURD(redis).expire( + key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}", + expire=settings.REFRESH_TOKEN_EXPIRE_SECONDS, + ) username = user_info.get("user_name") if not username: raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) user_status = user_info.get("user_status", 0) - tenant_status = user_info.get("tenant_status", 0) - is_superuser = user_info.get("is_superuser", False) - tenant_id = user_info.get("tenant_id", 0) user_id = user_info.get("user_id") if user_status == 1: raise CustomException(msg="用户已被停用", code=RET.UNAUTHORIZED.code, status_code=401) - if not is_superuser and tenant_id > 0: - if tenant_status == TenantStatusEnum.FROZEN: - raise CustomException(msg="租户已被冻结,请联系平台管理员", code=RET.FORBIDDEN.code, status_code=423) - if tenant_status == TenantStatusEnum.CANCELLED: - raise CustomException(msg="租户已注销", code=RET.FORBIDDEN.code, status_code=423) - if tenant_status == TenantStatusEnum.ARREARS: - raise CustomException(msg="租户已欠费,仅允许查看操作,请联系平台管理员续费", code=RET.FORBIDDEN.code, status_code=423) - if tenant_status == TenantStatusEnum.TRIAL: - raise CustomException(msg="租户处于试用期,部分功能受限,请升级正式套餐", code=RET.FORBIDDEN.code, status_code=423) - if request: request.state.ctx = replace( (getattr(request.state, "ctx", None) or RequestContext()), @@ -171,6 +159,7 @@ async def _authenticate( if not user_id: raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) + from app.api.v1.module_system.user.model import UserModel stmt = select(UserModel).where(UserModel.id == user_id, UserModel.is_deleted == False) @@ -178,25 +167,13 @@ async def _authenticate( user_obj = result.scalars().first() if not user_obj: raise CustomException(msg="用户不存在", code=RET.NOT_FOUND.code, status_code=401) + user = CoreUserSchema.model_validate(user_obj) - - # token_version 比对:用户登出、改密码、被踢下线等场景会使 DB 版本递增,从而使旧 token 失效 - session_token_version = user_info.get("token_version", 0) or 0 - if user.token_version != session_token_version: - raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401) - - auth = AuthSchema( - check_data_scope=False, + return AuthSchema( user=user, permissions=user_info.get("permissions", []), - permissions_with_menu=user_info.get("permissions_with_menu", {}), menu_ids=user_info.get("menu_ids", []), - data_scopes=user_info.get("data_scopes", []), - custom_dept_ids=user_info.get("custom_dept_ids", []), - role_ids=user_info.get("role_ids", []), - is_impersonate=user_info.get("is_impersonate", False), ) - return auth class AuthPermission: @@ -205,16 +182,13 @@ class AuthPermission: def __init__( self, permissions: list[str] | None = None, - check_data_scope: bool = True, ) -> None: """初始化权限验证 参数: - permissions (list[str] | None): 权限标识列表。 - - check_data_scope (bool): 是否启用严格模式校验。 """ self.permissions = permissions or [] - self.check_data_scope = check_data_scope async def __call__(self, auth: AuthSchema = Depends(get_current_user), db: AsyncSession = Depends(db_getter)) -> AuthSchema: """调用权限验证 @@ -225,8 +199,6 @@ class AuthPermission: 返回: - AuthSchema: 已认证的权限信息对象。 """ - auth = auth.model_copy(update={"check_data_scope": self.check_data_scope}) - user = auth.user if user.id is None or user.is_superuser: return auth @@ -242,12 +214,6 @@ class AuthPermission: if not user_permissions: raise CustomException(msg="无权限操作", code=RET.FORBIDDEN.code, status_code=403) - if user.tenant_id: - from app.api.v1.module_platform.package.service import PackageService - result = await PackageService(auth, db).get_tenant_available_menu_ids(user.tenant_id) - allowed_ids = set[int](result) - user_permissions = {p for p, mid in auth.permissions_with_menu.items() if mid in allowed_ids} - if not any(perm in user_permissions for perm in self.permissions): logger.error(f"用户缺少任何所需的权限: {self.permissions}") raise CustomException(msg="无权限操作", code=10403, status_code=403) diff --git a/backend/app/core/event_bus.py b/backend/app/core/event_bus.py index de9b2963..8191f090 100644 --- a/backend/app/core/event_bus.py +++ b/backend/app/core/event_bus.py @@ -2,11 +2,11 @@ 职责: - 维护每个用户的 asyncio.Queue(用户退出后自动清理) -- 提供 publish / publish_tenant / subscribe / unsubscribe 接口 +- 提供 publish / subscribe / unsubscribe 接口 使用方: - SSE 端点 → subscribe / unsubscribe -- 各业务服务 → publish / publish_tenant +- 各业务服务 → publish """ from __future__ import annotations @@ -25,7 +25,6 @@ class _Subscriber: """订阅者信息""" user_id: int - tenant_id: int queue: asyncio.Queue[str] = field(default_factory=lambda: asyncio.Queue(maxsize=256)) @@ -35,14 +34,14 @@ class EventBus: _subscribers: dict[int, _Subscriber] = {} @classmethod - def subscribe(cls, user_id: int, tenant_id: int) -> asyncio.Queue[str]: + def subscribe(cls, user_id: int) -> asyncio.Queue[str]: """为用户创建一个事件队列(已存在则返回现有队列)""" sub = cls._subscribers.get(user_id) if sub: return sub.queue - sub = _Subscriber(user_id=user_id, tenant_id=tenant_id) + sub = _Subscriber(user_id=user_id) cls._subscribers[user_id] = sub - logger.debug(f"SSE 订阅: user_id={user_id} tenant_id={tenant_id}") + logger.debug(f"SSE 订阅: user_id={user_id}") return sub.queue @classmethod @@ -63,17 +62,6 @@ class EventBus: except (TimeoutError, asyncio.QueueFull): logger.warning(f"SSE 推送超时或队列满: user_id={user_id}, event={event.get('type')}") - @classmethod - async def publish_tenant(cls, tenant_id: int, event: dict[str, Any]) -> None: - """向租户下所有在线用户推送事件""" - payload = _build_sse_payload(event) - tasks = [] - for sub in cls._subscribers.values(): - if sub.tenant_id == tenant_id: - tasks.append(_put(sub.queue, payload)) - if tasks: - await asyncio.gather(*tasks, return_exceptions=True) - @classmethod async def publish_all(cls, event: dict[str, Any]) -> None: """向所有在线用户广播事件""" diff --git a/backend/app/core/middlewares.py b/backend/app/core/middlewares.py index 741ed628..7eea053d 100644 --- a/backend/app/core/middlewares.py +++ b/backend/app/core/middlewares.py @@ -21,7 +21,7 @@ from app.config.setting import settings 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, reset_correlation_id, set_correlation_id, set_current_tenant +from app.core.request_context import RequestContext, reset_correlation_id, set_correlation_id from app.core.security import decode_access_token from app.utils.ip_local_util import get_client_ip @@ -34,11 +34,6 @@ MIDDLEWARE_CONFIG_KEYS: tuple[str, ...] = ( "operation_log_retention_days", ) -# 内存缓存(按租户隔离) -_MID_CONFIG_TTL: float = 60.0 -_mid_config_cache: dict[int, tuple[float, dict]] = {} - - def _parse_bool(value: object) -> bool: """兼容字符串 / 布尔值 / JSON 布尔值的开关字段解析。""" if isinstance(value, bool): @@ -91,17 +86,9 @@ def _parse_value(key: str, value: object) -> object: return value -def invalidate_middleware_config_cache(tenant_id: int | None = None) -> None: - """失效中间件内存缓存。tenant_id 为 None 时清空所有租户。""" - if tenant_id is None: - _mid_config_cache.clear() - else: - _mid_config_cache.pop(tenant_id, None) - - -async def _load_middleware_config_from_redis(redis: Redis, tenant_id: int = 1) -> dict: +async def _load_middleware_config_from_redis(redis: Redis) -> dict: """从 Redis 批量拉取并解析 MIDDLEWARE_CONFIG_KEYS 中的配置。""" - config_keys = [f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{key}" for key in MIDDLEWARE_CONFIG_KEYS] + config_keys = [f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{key}" for key in MIDDLEWARE_CONFIG_KEYS] config_values = await RedisCURD(redis).mget(config_keys) result: dict[str, Any] = {} @@ -130,17 +117,6 @@ async def _load_middleware_config_from_redis(redis: Redis, tenant_id: int = 1) - return result -async def get_middleware_config(redis: Redis, tenant_id: int = 1) -> dict: - """获取中间件 / 调度器所需的系统配置(带 60 秒内存缓存,按租户隔离)。""" - cached = _mid_config_cache.get(tenant_id) - if cached and time.monotonic() - cached[0] < _MID_CONFIG_TTL: - return cached[1] - - config = await _load_middleware_config_from_redis(redis, tenant_id) - _mid_config_cache[tenant_id] = (time.monotonic(), config) - return config - - def _strip_bearer(authorization: str) -> str | None: """从 Authorization header 提取 token,非 Bearer 返回 None。""" v = authorization.strip() @@ -236,13 +212,12 @@ class RequestLogMiddleware(BaseHTTPMiddleware): @staticmethod async def _load_config(request: Request) -> dict: - """加载中间件配置(带 60 秒内存缓存),失败时返回全部默认值。""" + """加载中间件配置,失败时返回全部默认值。""" redis = getattr(request.app.state, "redis", None) if not redis: return dict(_DEFAULT_CONFIG) try: - tenant_id = await _extract_tenant_from_token(request) or 1 - return await get_middleware_config(redis, tenant_id) + return await _load_middleware_config_from_redis(redis) except Exception: return dict(_DEFAULT_CONFIG) @@ -282,65 +257,6 @@ class CorrelationIdMiddleware(BaseHTTPMiddleware): reset_correlation_id(token) -_TENANT_WHITELIST_PREFIXES = ("/docs", "/redoc", "/openapi.json", "/metrics", "/static/") -_WHITELIST_ALL = ( - "/api/v1/system/auth/login", - "/api/v1/system/auth/captcha", - "/api/v1/system/auth/refresh", - "/api/v1/health", - "/api/v1/common/health", -) + tuple(settings.TENANT_WHITELIST_PATHS) - - -def _tenant_is_whitelisted(path: str) -> bool: - """白名单路径:精确匹配公共接口,前缀匹配文档 / 静态资源。""" - for prefix in (*_WHITELIST_ALL, *_TENANT_WHITELIST_PREFIXES): - if path == prefix or path.startswith(prefix): - return True - return False - - -async def _extract_tenant_from_token(request: Request) -> int | None: - """从 JWT + Redis 会话解析租户 ID;结果挂到 request.state 上以便本请求内复用。 - - 返回 None 表示未登录 / 会话过期,调用方应避免回退到平台租户(1)。 - """ - if hasattr(request.state, "tenant_id_resolved"): - return request.state.tenant_id - - request.state.tenant_id_resolved = True - request.state.tenant_id = None - - token = _strip_bearer(request.headers.get("Authorization", "")) - if not token: - return None - try: - payload = decode_access_token(token) - if not payload or not hasattr(payload, "sub"): - return None - - session_id = payload.sub - redis = getattr(request.app.state, "redis", None) - raw = await await_redis_get(redis, session_id) if redis else None - user_info = json.loads(raw) if raw else None - - base = getattr(request.state, "ctx", None) or RequestContext() - request.state.ctx = replace(base, jwt_payload=payload, jwt_user_info=user_info) - if user_info and user_info.get("tenant_id"): - request.state.tenant_id = int(user_info["tenant_id"]) - except Exception: - pass - return request.state.tenant_id - - -async def await_redis_get(redis, key: str) -> str | None: - """获取用户会话;Redis 不可用时返回 None。""" - try: - return await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{key}") - except Exception: - return None - - def _is_path_whitelisted(path: str, whitelist: list) -> bool: """精确匹配;``*`` 结尾表示前缀通配。""" for item in whitelist: @@ -353,19 +269,3 @@ def _is_path_whitelisted(path: str, whitelist: list) -> bool: return True return False - -class TenantMiddleware(BaseHTTPMiddleware): - def __init__(self, app: ASGIApp) -> None: - super().__init__(app) - - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: - if request.method == "OPTIONS" or _tenant_is_whitelisted(request.url.path): - return await call_next(request) - try: - set_current_tenant(await _extract_tenant_from_token(request)) - except Exception: - logger.exception("租户中间件异常: path={}", request.url.path) - try: - return await call_next(request) - finally: - clear_current_tenant() diff --git a/backend/app/core/permission.py b/backend/app/core/permission.py deleted file mode 100644 index 6efaa173..00000000 --- a/backend/app/core/permission.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.sql.elements import ColumnElement - -from app.common.enums import PermissionFilterStrategy -from app.core.base_schema import AuthSchema -from app.utils.common_util import get_child_id_map, get_child_recursion - - -class Permission: - """为业务模型提供数据权限过滤功能 - - 使用策略模式,根据模型的 __permission_strategy__ 属性选择合适的过滤策略 - """ - - # 数据权限常量定义,提高代码可读性 - DATA_SCOPE_SELF = 1 # 仅本人数据 - DATA_SCOPE_DEPT = 2 # 本部门数据 - DATA_SCOPE_DEPT_AND_CHILD = 3 # 本部门及以下数据 - DATA_SCOPE_ALL = 4 # 全部数据 - DATA_SCOPE_CUSTOM = 5 # 自定义数据 - - def __init__(self, model: Any, auth: AuthSchema, db: AsyncSession) -> None: - """初始化权限过滤器实例""" - self.model = model - self.auth = auth - self.db = db - self.conditions: list[ColumnElement] = [] # 权限条件列表 - - async def filter_query(self, query: Any) -> Any: - """按数据权限为 SQLAlchemy 查询追加 WHERE 条件。 - - 参数: - - query (Any): SQLAlchemy 查询对象。 - - 返回: - - Any: 附加条件后的查询对象(无权限条件时原样返回)。 - """ - condition = await self.__permission_condition() - return query.where(condition) if condition is not None else query - - async def __permission_condition(self) -> ColumnElement | None: - """根据模型的权限过滤策略,选择合适的过滤方法 - - 注意:当 ``auth.user.is_superuser=True`` 时直接返回 ``None``(不应用任何过滤), - 以便平台超管能查看/管理所有角色的菜单授权(``USER_BINDING`` 等策略对超管不生效)。 - """ - if not self.auth.user or not self.auth.user.id or not self.auth.check_data_scope or self.auth.user.is_superuser: - return None - - strategy = getattr(self.model, "__permission_strategy__", PermissionFilterStrategy.DATA_SCOPE) - method = { - PermissionFilterStrategy.MENU_AUTH: self.__filter_by_menu_auth, - PermissionFilterStrategy.DEPT_RELATION: self.__filter_by_dept_relation, - PermissionFilterStrategy.OWN: self.__filter_by_own, - PermissionFilterStrategy.USER_BINDING: self.__filter_by_user_binding, - }.get(strategy, self.__filter_by_data_scope) - return await method() - - async def __filter_by_menu_auth(self) -> ColumnElement | None: - """基于角色-菜单授权的过滤(适用于菜单模型) - - 只显示用户角色授权的菜单,同时受租户套餐约束。 - """ - menu_ids = set(self.auth.menu_ids) - if not menu_ids: - return self.__id_eq(-1) - - if self.auth.user and self.auth.user.tenant_id: - cache_attr = "_cached_package_menu_ids" - cached = getattr(self.auth, cache_attr, None) - if cached is None: - from app.api.v1.module_platform.package.service import PackageService - - cached = set[int](await PackageService(self.auth, self.db).get_tenant_available_menu_ids(self.auth.user.tenant_id)) - object.__setattr__(self.auth, cache_attr, cached) - menu_ids = menu_ids & cached - - return self.__id_in(menu_ids) if menu_ids else self.__id_eq(-1) - - async def __filter_by_user_binding(self) -> ColumnElement | None: - """基于当前用户绑定角色的过滤(适用于角色模型) - - 只显示当前用户绑定的角色。超管场景下不应用此过滤(参见 `__permission_condition`)。 - """ - role_ids = self.auth.role_ids - return self.__id_in(role_ids) if role_ids else self.__id_eq(-1) - - async def __filter_by_dept_relation(self) -> ColumnElement | None: - """基于部门关联的过滤(适用于部门模型、用户模型) - - 根据用户的部门权限范围过滤数据 - """ - assert self.auth.user is not None - - data_scopes = set(self.auth.data_scopes) - custom_dept_ids = set(self.auth.custom_dept_ids) - - if not data_scopes: - # 无数据权限范围:仅能看本部门 - user_dept_id = self.auth.user.dept_id - return self.__id_eq(user_dept_id) if user_dept_id else None - - if self.DATA_SCOPE_ALL in data_scopes: - return None - - accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids) - - if self.model.__name__ == "DeptModel": - return self.__filter_dept_model(accessible_dept_ids) - if self.model.__name__ == "UserModel": - return self.__filter_user_model(accessible_dept_ids) - return None - - async def __filter_by_own(self) -> ColumnElement | None: - """仅本人数据过滤""" - assert self.auth.user is not None - return self.__created_id_eq(self.auth.user.id) - - async def __filter_by_data_scope(self) -> ColumnElement | None: - """基于数据范围权限的通用过滤(默认策略) - - 适用于大多数业务模型 - """ - assert self.auth.user is not None - - created_id_attr = getattr(self.model, "created_id", None) - if created_id_attr is None: - return None - - data_scopes = set(self.auth.data_scopes) - custom_dept_ids = set(self.auth.custom_dept_ids) - - if not data_scopes or self.DATA_SCOPE_SELF in data_scopes: - return created_id_attr == self.auth.user.id - - if self.DATA_SCOPE_ALL in data_scopes: - return None - - accessible_dept_ids = await self.__get_accessible_dept_ids(data_scopes, custom_dept_ids) - if not accessible_dept_ids: - return created_id_attr == self.auth.user.id - - if self.model.__name__ == "UserModel" and hasattr(self.model, "dept_id"): - dept_id_attr = getattr(self.model, "dept_id", None) - if dept_id_attr is not None: - return dept_id_attr.in_(list[int](accessible_dept_ids)) - - creator_rel = getattr(self.model, "created_by", None) - if creator_rel is not None: - from app.api.v1.module_system.user.model import UserModel - - return creator_rel.has(UserModel.dept_id.in_(list(accessible_dept_ids))) - - return created_id_attr == self.auth.user.id - - async def __get_accessible_dept_ids(self, data_scopes: set[int], custom_dept_ids: set[int]) -> set[int]: - """获取用户可访问的所有部门ID""" - assert self.auth.user is not None - accessible_dept_ids: set[int] = set(custom_dept_ids) - user_dept_id = self.auth.user.dept_id - - if self.DATA_SCOPE_DEPT in data_scopes and user_dept_id is not None: - accessible_dept_ids.add(user_dept_id) - - if self.DATA_SCOPE_DEPT_AND_CHILD in data_scopes and user_dept_id is not None: - try: - from app.api.v1.module_system.dept.model import DeptModel - - dept_objs = (await self.db.execute(select(DeptModel))).scalars().all() - id_map = get_child_id_map(dept_objs) - accessible_dept_ids.update(get_child_recursion(id=user_dept_id, id_map=id_map)) - except Exception: - accessible_dept_ids.add(user_dept_id) - - return accessible_dept_ids - - def __filter_dept_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None: - """过滤部门模型""" - assert self.auth.user is not None - if accessible_dept_ids: - return self.__id_in(accessible_dept_ids) - user_dept_id = self.auth.user.dept_id - return self.__id_eq(user_dept_id) if user_dept_id else None - - def __filter_user_model(self, accessible_dept_ids: set[int]) -> ColumnElement | None: - """过滤用户模型""" - if not accessible_dept_ids: - return None - dept_id_attr = getattr(self.model, "dept_id", None) - return dept_id_attr.in_(list(accessible_dept_ids)) if dept_id_attr is not None else None - - def __id_eq(self, value: int | None) -> ColumnElement | None: - """主键等于指定值""" - if value is None: - return None - id_attr = getattr(self.model, "id", None) - return id_attr == value if id_attr is not None else None - - def __id_in(self, values: set[int] | list[int]) -> ColumnElement | None: - """主键在指定集合中""" - if not values: - return None - id_attr = getattr(self.model, "id", None) - return id_attr.in_(list(values)) if id_attr is not None else None - - def __created_id_eq(self, value: int) -> ColumnElement | None: - """created_id 等于指定值""" - created_id_attr = getattr(self.model, "created_id", None) - return created_id_attr == value if created_id_attr is not None else None diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py index 1505d5f9..d6b1fd81 100644 --- a/backend/app/core/request_context.py +++ b/backend/app/core/request_context.py @@ -18,22 +18,6 @@ def reset_correlation_id(token: Token) -> None: _correlation_id.reset(token) -# ── 租户上下文 ── -current_tenant_id: ContextVar[int | None] = ContextVar("current_tenant_id", default=None) - - -def set_current_tenant(tenant_id: int | None) -> None: - current_tenant_id.set(tenant_id) - - -def get_current_tenant_id() -> int | None: - return current_tenant_id.get() - - -def clear_current_tenant() -> None: - current_tenant_id.set(None) - - # ── request.state.ctx ── diff --git a/backend/app/core/router_class.py b/backend/app/core/router_class.py index c4c3c866..00c2129a 100644 --- a/backend/app/core/router_class.py +++ b/backend/app/core/router_class.py @@ -18,7 +18,6 @@ _PUBLIC_WRITE_PATHS: set[str] = { "/auth/login", "/auth/token/refresh", "/auth/captcha/slider/complete", - "/auth/tenant/register", "/auth/user/register", } @@ -32,7 +31,7 @@ async def _write_operation_log_async(log_data: dict) -> None: from app.core.database import async_db_session async with async_db_session() as _session, _session.begin(): - auth = AuthSchema(check_data_scope=False) + auth = AuthSchema() await OperationLogCRUD(auth, _session).create(data=OperationLogCreateSchema(**log_data)) except Exception: logger.exception("操作日志写入失败: path={}", log_data.get("request_path")) @@ -89,6 +88,7 @@ class OperationLogRoute(APIRoute): response_data = response.body if is_json else b"{}" log_data: dict[str, Any] = { + "username": getattr(getattr(request.state, "ctx", None), "user_username", "unknown"), "request_path": request.url.path, "request_method": request.method, "request_payload": log_payload, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 30a90407..71a6e01b 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -115,11 +115,12 @@ def create_access_token(payload: JWTPayloadSchema) -> str: ) -def decode_access_token(token: str) -> JWTPayloadSchema: +def decode_access_token(token: str, verify_exp: bool = True) -> JWTPayloadSchema: """解析JWT访问令牌 参数: - token (str): JWT访问令牌字符串。 + - verify_exp (bool): 是否校验 exp 声明。滑动续期场景设为 False,由 Redis session 决定有效期。 返回: - JWTPayloadSchema: 解析后的JWT有效载荷,包含用户信息等。 @@ -131,7 +132,10 @@ def decode_access_token(token: str) -> JWTPayloadSchema: raise CustomException(msg="认证不存在,请重新登录", code=10401, status_code=401) try: - payload = jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + options: dict = {} + if not verify_exp: + options["verify_exp"] = False + payload = jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=[settings.ALGORITHM], options=options) # type: ignore[arg-type] online_user_info = payload.get("sub") if not online_user_info: diff --git a/backend/app/init_app.py b/backend/app/init_app.py index 1d76f15a..6b3d41e9 100644 --- a/backend/app/init_app.py +++ b/backend/app/init_app.py @@ -20,7 +20,6 @@ from .utils.console import console_end, console_start @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: - from app.api.v1.module_platform.tenant.service import TenantService from app.api.v1.module_system.dict.service import DictDataService from app.api.v1.module_system.params.service import ParamsService from app.core.ap_scheduler import SchedulerUtil @@ -35,8 +34,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: logger.info("✅ Redis系统参数初始化完成") await DictDataService.init_cache(redis=app.state.redis) logger.info("✅ Redis数据字典初始化完成") - await TenantService.init_cache(redis=app.state.redis) - logger.info("✅ Redis租户配置初始化完成") await SchedulerUtil.init_scheduler(redis=app.state.redis) logger.info("✅ 定时任务调度器初始化完成") FastAPICache.init(RedisBackend(app.state.redis), prefix="fastapi-admin-cache") @@ -88,13 +85,11 @@ def register_routers(app: FastAPI) -> None: from app.api.v1.module_common import common_router from app.api.v1.module_generator import generator_router from app.api.v1.module_monitor import monitor_router - from app.api.v1.module_platform import platform_router from app.api.v1.module_system import system_router from app.api.v1.module_task import task_router app.include_router(common_router) app.include_router(monitor_router) - app.include_router(platform_router) app.include_router(system_router) app.include_router(ai_router) app.include_router(generator_router) diff --git a/backend/app/plugin/module_example/demo/model.py b/backend/app/plugin/module_example/demo/model.py index 664f3afa..7e18daf6 100644 --- a/backend/app/plugin/module_example/demo/model.py +++ b/backend/app/plugin/module_example/demo/model.py @@ -15,7 +15,7 @@ from sqlalchemy import ( ) 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 StatusEnum(enum.Enum): @@ -25,13 +25,13 @@ class StatusEnum(enum.Enum): INACTIVE = "inactive" -class DemoModel(ModelMixin, TenantMixin, UserMixin): +class DemoModel(ModelMixin, UserMixin): """示例表 - 涵盖大多数常用数据类型 """ __tablename__: str = "example_demo" __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"] # 字符串类型 name: Mapped[str] = mapped_column(String(64), nullable=False, comment="名称") diff --git a/backend/app/plugin/module_example/demo/schema.py b/backend/app/plugin/module_example/demo/schema.py index 31a8bc78..151de706 100644 --- a/backend/app/plugin/module_example/demo/schema.py +++ b/backend/app/plugin/module_example/demo/schema.py @@ -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.core.validator import DateStr, DateTimeStr, TimeStr @@ -68,17 +68,15 @@ class DemoUpdateSchema(DemoCreateSchema): """更新模型""" -class DemoOutSchema(DemoCreateSchema, BaseSchema, UserBySchema, TenantBySchema): +class DemoOutSchema(DemoCreateSchema, BaseSchema, UserBySchema): """响应模型""" model_config = ConfigDict(from_attributes=True) -class DemoQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): +class DemoQueryParam(BaseQueryParam, UserByQueryParam): """示例查询参数(演示 Mixin 继承用法)""" name: str | None = Field(None, description="名称") description: str | None = Field(None, description="描述") status: int | None = Field(None, description="是否启用") - - diff --git a/backend/app/scripts/initialize.py b/backend/app/scripts/initialize.py index 07c22ac6..179b3f30 100644 --- a/backend/app/scripts/initialize.py +++ b/backend/app/scripts/initialize.py @@ -6,9 +6,7 @@ from typing import Any 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.package.model import PackageMenuModel, PackageModel -from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel +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.dict.model import DictDataModel, DictTypeModel from app.api.v1.module_system.params.model import ParamsModel @@ -29,11 +27,8 @@ class InitializeData: # 按依赖关系排序:先基础表,再关联表 prepare_init_models: list[type] = [ - # ── 平台管理:基础表 ── - PackageModel, - TenantModel, - MenuModel, # ── 系统管理:基础表 ── + MenuModel, ParamsModel, DeptModel, RoleModel, @@ -42,14 +37,12 @@ class InitializeData: UserModel, # ── 关联表 ── UserRolesModel, - TenantUserModel, - PackageMenuModel, # ── 版本管理 ── VersionModel, ] # 树形模型:JSON 含嵌套 children,需递归创建对象 - _RECURSIVE_TABLES: set[str] = {"platform_menu", "sys_dept"} + _RECURSIVE_TABLES: set[str] = {"sys_menu", "sys_dept"} async def init_db(self) -> None: """建表并导入种子数据""" @@ -73,7 +66,7 @@ class InitializeData: continue try: - # 树形表(platform_menu / sys_dept):递归创建含 children 的对象 + # 树形表(sys_menu / sys_dept):递归创建含 children 的对象 if table_name in self._RECURSIVE_TABLES: count = await db.execute(select(func.count()).select_from(model)) if count.scalar(): diff --git a/backend/sql/data/sys_param.json b/backend/sql/data/sys_param.json index bccf1c97..b4e2b3cc 100644 --- a/backend/sql/data/sys_param.json +++ b/backend/sql/data/sys_param.json @@ -25,5 +25,95 @@ "status": 0, "description": "禁止访问的IP列表(任意请求均拒绝)", "tenant_id": 1 + }, + { + "config_name": "Logo URL", + "config_key": "logo_url", + "config_value": "https://service.fastapiadmin.com/api/v1/static/image/logo.svg", + "config_type": true, + "status": 0, + "description": "平台Logo地址", + "tenant_id": 1 + }, + { + "config_name": "Favicon 地址", + "config_key": "favicon", + "config_value": "https://service.fastapiadmin.com/api/v1/static/image/favicon.ico", + "config_type": true, + "status": 0, + "description": "浏览器标签栏图标地址", + "tenant_id": 1 + }, + { + "config_name": "登录背景图", + "config_key": "login_bg", + "config_value": "https://service.fastapiadmin.com/api/v1/static/image/background.svg", + "config_type": true, + "status": 0, + "description": "登录页面背景图地址", + "tenant_id": 1 + }, + { + "config_name": "版权信息", + "config_key": "copyright", + "config_value": "Copyright © 2025-2027 service.fastapiadmin.com 版权所有", + "config_type": true, + "status": 0, + "description": "页面底部版权信息", + "tenant_id": 1 + }, + { + "config_name": "备案号", + "config_key": "keep_record", + "config_value": "陕ICP备2025069493号-1", + "config_type": true, + "status": 0, + "description": "ICP备案号", + "tenant_id": 1 + }, + { + "config_name": "帮助文档地址", + "config_key": "help_doc", + "config_value": "https://docs.fastapiadmin.com", + "config_type": true, + "status": 0, + "description": "帮助文档链接地址", + "tenant_id": 1 + }, + { + "config_name": "隐私政策地址", + "config_key": "privacy", + "config_value": "https://fastapiadmin.com/privacy", + "config_type": true, + "status": 0, + "description": "隐私政策链接地址", + "tenant_id": 1 + }, + { + "config_name": "用户协议地址", + "config_key": "clause", + "config_value": "https://fastapiadmin.com/clause", + "config_type": true, + "status": 0, + "description": "用户协议链接地址", + "tenant_id": 1 + }, + { + "config_name": "源码地址", + "config_key": "git_code", + "config_value": "https://github.com/fastapi-admin/fastapi-admin", + "config_type": true, + "status": 0, + "description": "项目源码仓库地址", + "tenant_id": 1 + }, + { + "config_name": "系统版本", + "config_key": "version", + "config_value": "3.0.0", + "config_type": true, + "status": 0, + "description": "系统版本号", + "tenant_id": 1 } ] diff --git a/backend/templates/emails/welcome.jinja2 b/backend/templates/emails/welcome.jinja2 deleted file mode 100644 index dfebaa9c..00000000 --- a/backend/templates/emails/welcome.jinja2 +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - -
-
欢迎加入 {{ tenant_name }}!
-
-

{{ username }},您好!

-

您的租户已成功创建,试用期至 {{ trial_end }}

-

请登录后台开始使用。

-
- -
- - diff --git a/backend/templates/invoice/invoice.jinja2 b/backend/templates/invoice/invoice.jinja2 deleted file mode 100644 index c74504ac..00000000 --- a/backend/templates/invoice/invoice.jinja2 +++ /dev/null @@ -1,262 +0,0 @@ - - - - -电子发票 - {{ invoice_no }} - - - -
-
-
-
-

电 子 发 票

-
{{ invoice_type_label }}
-
-
- 发票
监制 -
-
- -
-
发票号码:{{ invoice_no }}
-
开票日期:{{ invoice_date }}
-
- -
-
-

购买方信息

- - - - {% if invoice_type == 'vat_special' %} - - - {% endif %} -
名称:{{ buyer_name }}
纳税人识别号:{{ buyer_tax_no }}
地址、电话:{{ buyer_address_info }}
开户行及账号:{{ buyer_bank_info }}
-
-
-

销售方信息

- - - - - -
名称:{{ seller_name }}
纳税人识别号:{{ seller_tax_no }}
地址、电话:{{ seller_address_info }}
开户行及账号:{{ seller_bank_info }}
-
-
- - - - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - - {% endfor %} - -
序号项目名称规格型号单位数量单价金额税率税额
{{ loop.index }}{{ item.name }}{{ item.spec or '-' }}{{ item.unit }}{{ item.quantity }}{{ item.unit_price }}{{ item.amount }}{{ item.tax_rate }}%{{ item.tax_amount }}
- - - - - - -
价税合计(大写)
{{ amount_cn_uppercase }}
(小写)¥{{ amount_total_yuan }}
-
备注:{{ remarks or '-' }}
- - -
- - \ No newline at end of file diff --git a/backend/templates/invoice/oss_license.jinja2 b/backend/templates/invoice/oss_license.jinja2 deleted file mode 100644 index d4d45b30..00000000 --- a/backend/templates/invoice/oss_license.jinja2 +++ /dev/null @@ -1,177 +0,0 @@ - - - - -开源项目授权声明 - {{ invoice_no }} - - - -
- -

开源项目授权声明函

-
Open Source Components Authorization Statement
- -
- - - - - - - - - - - - - - - -
关联发票号{{ invoice_no }}开票日期{{ invoice_date }}
产品名称FastapiAdmin 企业管理后台
产品版本{{ product_version }}
许可证总数{{ groups | length }} 类依赖包总数{{ total_packages }} 个
-
- -
一、声明
-

- 兹声明,本产品 FastapiAdmin 企业管理后台 在开发与部署过程中使用了 {{ total_packages }} 个第三方开源软件包, - 涵盖 {{ groups | length }} 类许可证。本产品严格遵循各开源许可证的条款要求, - 在使用、修改、分发相关开源组件时保留原始版权声明及许可证文本。 - 本声明函随同电子发票一同提供给客户,便于客户进行开源合规审计与软件资产管理。 -

- -
二、许可证分类清单
- - {% for group in groups %} -
-
- {{ group.license }} - 共 {{ group.packages | length }} 个包 -
- - - - - - - - - {% for pkg in group.packages %} - - - - - {% endfor %} - -
包名 (Package)版本 (Version)
{{ pkg.name }}{{ pkg.version }}
-
- {% endfor %} - -
三、通用合规承诺
-

- 1. 上述所有开源组件均通过官方包管理器(PyPI)合法获取,并保留其原始许可证文本;
- 2. 本产品未对 GPL/AGPL 等强 copyleft 协议组件进行源码闭源分发;
- 3. 各许可证全文可访问各组件官方仓库或开源许可证标准文本;
- 4. 如客户在二次开发或再分发过程中对许可证合规有进一步要求,本平台可提供完整的 LicenseText 文本。 -

- - - -
- - diff --git a/backend/templates/python/schema.py.jinja2 b/backend/templates/python/schema.py.jinja2 index 85af01f7..1b3acae4 100644 --- a/backend/templates/python/schema.py.jinja2 +++ b/backend/templates/python/schema.py.jinja2 @@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field {% for import_stmt in schema_import_list %} {{ import_stmt }} {% endfor %} -from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema, BaseQueryParam, TenantByQueryParam, UserByQueryParam +from app.core.base_schema import BaseSchema, UserBySchema, BaseQueryParam, UserByQueryParam class {{ class_name }}CreateSchema(BaseModel): """ @@ -38,14 +38,14 @@ class {{ class_name }}UpdateSchema({{ class_name }}CreateSchema): ... -class {{ class_name }}OutSchema({{ class_name }}CreateSchema, BaseSchema, UserBySchema, TenantBySchema): +class {{ class_name }}OutSchema({{ class_name }}CreateSchema, BaseSchema, UserBySchema): """ {{ function_name }}响应模型 """ model_config = ConfigDict(from_attributes=True) -class {{ class_name }}QueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): +class {{ class_name }}QueryParam(BaseQueryParam, UserByQueryParam): """{{ function_name }}查询参数""" {% for column in columns %}