diff --git a/backend/app/api/v1/module_common/file/schema.py b/backend/app/api/v1/module_common/file/schema.py deleted file mode 100644 index 08da2115..00000000 --- a/backend/app/api/v1/module_common/file/schema.py +++ /dev/null @@ -1,71 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field, model_validator -from pydantic.alias_generators import to_camel - - -class ImportFieldModel(BaseModel): - """ - Excel 导入时单字段映射配置(数据库列、Excel 列、默认值、是否必选等)。 - """ - - model_config = ConfigDict(alias_generator=to_camel) - - base_column: str | None = Field(description="数据库字段名", default=None) - excel_column: str | None = Field(description="excel字段名", default=None) - default_value: str | None = Field(description="默认值", default=None) - is_required: bool | None = Field(description="是否必传", default=None) - selected: bool | None = Field(description="是否勾选", default=None) - - @model_validator(mode="before") - @classmethod - def _normalize(cls, data): - if isinstance(data, dict): - for key in ("base_column", "excel_column", "default_value"): - val = data.get(key) - if isinstance(val, str): - val = val.strip() - if val == "": - val = None - data[key] = val - # is_required 兼容转换 - val = data.get("is_required") - if isinstance(val, str): - lowered = val.strip().lower() - if lowered in {"true", "1", "y", "yes"}: - data["is_required"] = True - elif lowered in {"false", "0", "n", "no"}: - data["is_required"] = False - return data - - @model_validator(mode="after") - def _validate(self): - if self.selected and not (self.base_column and self.base_column.strip()): - raise ValueError("选中字段必须提供数据库字段名") - if self.is_required and not (self.excel_column and self.excel_column.strip()): - raise ValueError("必传字段必须提供excel字段名") - return self - - -class ImportModel(BaseModel): - """ - Excel 导入请求体:目标表、Sheet、文件名及字段映射列表。 - """ - - model_config = ConfigDict(alias_generator=to_camel) - - table_name: str | None = Field(description="表名", default=None) - sheet_name: str | None = Field(description="Sheet名", default=None) - filed_info: list[ImportFieldModel] | None = Field(description="字段关联表", default=None) - file_name: str | None = Field(description="文件名", default=None) - - @model_validator(mode="after") - def _validate(self): - # excel_column 不重复(忽略 None) - if self.filed_info: - seen = set() - for f in self.filed_info: - if f.excel_column: - key = f.excel_column.strip() - if key in seen: - raise ValueError("excel字段名存在重复") - seen.add(key) - return self diff --git a/backend/app/api/v1/module_common/monitoring/schema.py b/backend/app/api/v1/module_common/monitoring/schema.py index 3d59b31c..3d999371 100644 --- a/backend/app/api/v1/module_common/monitoring/schema.py +++ b/backend/app/api/v1/module_common/monitoring/schema.py @@ -2,31 +2,31 @@ from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class DependencyStatus(BaseModel): """依赖状态""" - status: str - latency_ms: float | None = None + status: str = Field(..., description="状态") + latency_ms: float | None = Field(default=None, description="延迟(毫秒)") class HealthOut(BaseModel): """基础健康检查响应""" - status: str - timestamp: str - version: str - uptime_seconds: float + status: str = Field(..., description="状态") + timestamp: str = Field(..., description="时间戳") + version: str = Field(..., description="版本号") + uptime_seconds: float = Field(..., description="运行时间(秒)") class ReadinessOut(BaseModel): """就绪探针响应""" - status: str - timestamp: str - version: str - uptime_seconds: float - dependencies: dict[str, DependencyStatus] - disk_usage: float + status: str = Field(..., description="状态") + timestamp: str = Field(..., description="时间戳") + version: str = Field(..., description="版本号") + uptime_seconds: float = Field(..., description="运行时间(秒)") + dependencies: dict[str, DependencyStatus] = Field(..., description="依赖状态") + disk_usage: float = Field(..., description="磁盘使用率") diff --git a/backend/app/api/v1/module_monitor/online/schema.py b/backend/app/api/v1/module_monitor/online/schema.py index 85d72e00..819772ae 100644 --- a/backend/app/api/v1/module_monitor/online/schema.py +++ b/backend/app/api/v1/module_monitor/online/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import BaseModel, Field @@ -24,6 +26,7 @@ class OnlineOutSchema(BaseModel): login_type: str | None = Field(default=None, description="登录类型 PC端 | 移动端") +@dataclass class OnlineQueryParam: """在线用户查询参数""" diff --git a/backend/app/api/v1/module_monitor/resource/schema.py b/backend/app/api/v1/module_monitor/resource/schema.py index 3a9fa72d..f0aa0b81 100644 --- a/backend/app/api/v1/module_monitor/resource/schema.py +++ b/backend/app/api/v1/module_monitor/resource/schema.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import datetime from urllib.parse import urlparse @@ -187,6 +188,7 @@ class ResourceCreateDirSchema(BaseModel): return value.strip() +@dataclass class ResourceSearchQueryParam: """资源搜索查询参数""" diff --git a/backend/app/api/v1/module_platform/email/controller.py b/backend/app/api/v1/module_platform/email/controller.py index 9a32b6c5..950b15a3 100644 --- a/backend/app/api/v1/module_platform/email/controller.py +++ b/backend/app/api/v1/module_platform/email/controller.py @@ -28,7 +28,7 @@ EmailRouter = APIRouter(route_class=OperationLogRoute, prefix="/email", tags=[" @EmailRouter.get("/config/list", summary="SMTP 配置列表", response_model=ResponseSchema[PageResultSchema[EmailConfigOutSchema]]) -async def email_config_list( +async def email_config_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[EmailConfigQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:query"]))], @@ -54,7 +54,7 @@ async def email_config_list( @EmailRouter.get("/config/detail/{id}", summary="SMTP 配置详情", response_model=ResponseSchema[EmailConfigOutSchema]) -async def email_config_detail( +async def email_config_detail_controller( id: Annotated[int, Path()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:query"]))], ): @@ -72,7 +72,7 @@ async def email_config_detail( @EmailRouter.post("/config/create", summary="创建 SMTP 配置", response_model=ResponseSchema[EmailConfigOutSchema]) -async def email_config_create( +async def email_config_create_controller( data: EmailConfigCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -90,7 +90,7 @@ async def email_config_create( @EmailRouter.put("/config/update/{id}", summary="更新 SMTP 配置", response_model=ResponseSchema[EmailConfigOutSchema]) -async def email_config_update( +async def email_config_update_controller( id: Annotated[int, Path()], data: EmailConfigUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], @@ -110,7 +110,7 @@ async def email_config_update( @EmailRouter.delete("/config/delete", summary="删除 SMTP 配置", response_model=ResponseSchema[None]) -async def email_config_delete( +async def email_config_delete_controller( ids: Annotated[list[int], Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -128,7 +128,7 @@ async def email_config_delete( @EmailRouter.post("/config/test", summary="测试 SMTP 连接", response_model=ResponseSchema) -async def email_config_test( +async def email_config_test_controller( data: EmailTestSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -146,7 +146,7 @@ async def email_config_test( @EmailRouter.get("/template/list", summary="邮件模板列表", response_model=ResponseSchema[PageResultSchema[EmailTemplateOutSchema]]) -async def email_template_list( +async def email_template_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[EmailTemplateQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:query"]))], @@ -172,7 +172,7 @@ async def email_template_list( @EmailRouter.get("/template/detail/{id}", summary="邮件模板详情", response_model=ResponseSchema[EmailTemplateOutSchema]) -async def email_template_detail( +async def email_template_detail_controller( id: Annotated[int, Path()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:query"]))], ): @@ -190,7 +190,7 @@ async def email_template_detail( @EmailRouter.post("/template/create", summary="创建邮件模板", response_model=ResponseSchema[EmailTemplateOutSchema]) -async def email_template_create( +async def email_template_create_controller( data: EmailTemplateCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -208,7 +208,7 @@ async def email_template_create( @EmailRouter.put("/template/update/{id}", summary="更新邮件模板", response_model=ResponseSchema[EmailTemplateOutSchema]) -async def email_template_update( +async def email_template_update_controller( id: Annotated[int, Path()], data: EmailTemplateUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], @@ -228,7 +228,7 @@ async def email_template_update( @EmailRouter.delete("/template/delete", summary="删除邮件模板", response_model=ResponseSchema[None]) -async def email_template_delete( +async def email_template_delete_controller( ids: Annotated[list[int], Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -246,7 +246,7 @@ async def email_template_delete( @EmailRouter.post("/send", summary="手动发送邮件(超管测试/补发)", response_model=ResponseSchema) -async def email_send( +async def email_send_controller( data: EmailSendSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:update"]))], ): @@ -264,7 +264,7 @@ async def email_send( @EmailRouter.get("/log/list", summary="邮件发送日志", response_model=ResponseSchema[PageResultSchema[EmailLogOutSchema]]) -async def email_log_list( +async def email_log_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[EmailLogQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:email:query"]))], diff --git a/backend/app/api/v1/module_platform/email/model.py b/backend/app/api/v1/module_platform/email/model.py index 06a75f16..c072cb19 100644 --- a/backend/app/api/v1/module_platform/email/model.py +++ b/backend/app/api/v1/module_platform/email/model.py @@ -1,9 +1,13 @@ from datetime import datetime +from typing import TYPE_CHECKING from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column, validates +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates -from app.core.base_model import ModelMixin, UserMixin +from app.core.base_model import ModelMixin, TenantMixin, UserMixin + +if TYPE_CHECKING: + from app.api.v1.module_platform.email.model import EmailConfigModel class EmailConfigModel(ModelMixin): @@ -70,7 +74,7 @@ class EmailTemplateModel(ModelMixin): return value -class EmailLogModel(ModelMixin, UserMixin): +class EmailLogModel(ModelMixin, TenantMixin, UserMixin): """ 邮件发送日志表 @@ -81,7 +85,7 @@ class EmailLogModel(ModelMixin, UserMixin): __tablename__: str = "platform_email_log" __table_args__: dict = {"comment": "邮件发送日志表"} - __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"] config_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("platform_email_config.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="使用的 SMTP 配置 ID") template_code: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="模板编码(冗余存储,模板删除后仍可追溯)") @@ -95,3 +99,6 @@ class EmailLogModel(ModelMixin, UserMixin): sent_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, 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="备注") + + # 关联关系 + config: Mapped["EmailConfigModel | None"] = relationship("EmailConfigModel", lazy="selectin") diff --git a/backend/app/api/v1/module_platform/email/schema.py b/backend/app/api/v1/module_platform/email/schema.py index 227e2450..8b530806 100644 --- a/backend/app/api/v1/module_platform/email/schema.py +++ b/backend/app/api/v1/module_platform/email/schema.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict, Field from app.common.enums import QueueEnum from app.core.base_params import BaseQueryParam, UserByQueryParam -from app.core.base_schema import BaseSchema, TenantBySchema +from app.core.base_schema import BaseSchema, UserBySchema from app.core.validator import DateTimeStr @@ -108,21 +108,20 @@ class EmailSendSchema(BaseModel): biz_type: str = Field(default="other", max_length=50, description="业务类型") -class EmailLogOutSchema(BaseSchema, TenantBySchema): +class EmailLogOutSchema(BaseSchema, UserBySchema): """邮件日志响应""" model_config = ConfigDict(from_attributes=True) - config_id: int | None = None - template_code: str | None = None - to_email: str - to_name: str | None = None - subject: str - biz_type: str - error_msg: str | None = None - retry_count: int - tenant_id: int | None = None - sent_time: DateTimeStr | None = None + config_id: int | None = Field(default=None, description="SMTP 配置 ID") + template_code: str | None = Field(default=None, description="模板编码") + to_email: str = Field(..., description="收件人邮箱") + to_name: str | None = Field(default=None, description="收件人姓名") + subject: str = Field(..., description="邮件主题") + biz_type: str = Field(..., description="业务类型") + error_msg: str | None = Field(default=None, description="错误信息") + retry_count: int = Field(..., description="重试次数") + sent_time: DateTimeStr | None = Field(default=None, description="发送时间") @dataclass diff --git a/backend/app/api/v1/module_platform/email/service.py b/backend/app/api/v1/module_platform/email/service.py index 4fbe3645..be881ef9 100644 --- a/backend/app/api/v1/module_platform/email/service.py +++ b/backend/app/api/v1/module_platform/email/service.py @@ -2,6 +2,7 @@ from datetime import datetime from app.core.base_schema import AuthSchema from app.core.database import async_db_session +from app.core.dependencies import require_superadmin from app.core.exceptions import CustomException from app.utils.email_util import render_template, send_email @@ -23,9 +24,17 @@ from .schema import ( class EmailConfigService: - """SMTP 配置管理""" + """ + 邮件配置服务 + """ + + """ + 邮件配置服务 + """ + """SMTP 配置管理(仅超级管理员可操作)""" @classmethod + @require_superadmin async def page_service( cls, auth: AuthSchema, @@ -51,11 +60,12 @@ class EmailConfigService: offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=EmailConfigOutSchema, ) @classmethod + @require_superadmin async def detail_service(cls, auth: AuthSchema, id: int) -> EmailConfigOutSchema: """ SMTP 配置详情 @@ -67,10 +77,7 @@ class EmailConfigService: 返回: - EmailConfigOutSchema: SMTP 配置详情 """ - obj = await EmailConfigCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="SMTP 配置不存在") - return EmailConfigOutSchema.model_validate(obj) + return await EmailConfigCRUD(auth).get_or_404(id=id, out_schema=EmailConfigOutSchema, msg="SMTP 配置不存在") @classmethod async def create_service(cls, auth: AuthSchema, data: EmailConfigCreateSchema) -> EmailConfigOutSchema: @@ -104,9 +111,7 @@ class EmailConfigService: - EmailConfigOutSchema: 更新后的 SMTP 配置 """ crud = EmailConfigCRUD(auth) - obj = await crud.get(id=id) - if not obj: - raise CustomException(msg="SMTP 配置不存在") + _ = await crud.get_or_404(id=id, msg="SMTP 配置不存在") if data.is_default is True: await crud.clear_default() updated = await crud.update(id=id, data=data) @@ -134,6 +139,7 @@ class EmailConfigService: await crud.delete(ids=ids) @classmethod + @require_superadmin async def test_service(cls, auth: AuthSchema, data: EmailTestSchema) -> dict: """ 测试 SMTP 连接:发送一封测试邮件 @@ -173,9 +179,14 @@ class EmailConfigService: class EmailTemplateService: - """邮件模板管理""" + """ + 邮件模板服务 + """ + + """邮件模板管理(仅超级管理员可操作)""" @classmethod + @require_superadmin async def page_service( cls, auth: AuthSchema, @@ -201,11 +212,12 @@ class EmailTemplateService: offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=EmailTemplateOutSchema, ) @classmethod + @require_superadmin async def detail_service(cls, auth: AuthSchema, id: int) -> EmailTemplateOutSchema: """ 邮件模板详情 @@ -217,12 +229,10 @@ class EmailTemplateService: 返回: - EmailTemplateOutSchema: 邮件模板详情 """ - obj = await EmailTemplateCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="邮件模板不存在") - return EmailTemplateOutSchema.model_validate(obj) + return await EmailTemplateCRUD(auth).get_or_404(id=id, out_schema=EmailTemplateOutSchema, msg="邮件模板不存在") @classmethod + @require_superadmin async def create_service(cls, auth: AuthSchema, data: EmailTemplateCreateSchema) -> EmailTemplateOutSchema: """ 创建邮件模板 @@ -242,6 +252,7 @@ class EmailTemplateService: return EmailTemplateOutSchema.model_validate(obj) @classmethod + @require_superadmin async def update_service(cls, auth: AuthSchema, id: int, data: EmailTemplateUpdateSchema) -> EmailTemplateOutSchema: """ 更新邮件模板 @@ -254,14 +265,13 @@ class EmailTemplateService: 返回: - EmailTemplateOutSchema: 更新后的邮件模板 """ - obj = await EmailTemplateCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="邮件模板不存在") + _ = await EmailTemplateCRUD(auth).get_or_404(id=id, msg="邮件模板不存在") updated = await EmailTemplateCRUD(auth).update(id=id, data=data) return EmailTemplateOutSchema.model_validate(updated) @classmethod + @require_superadmin async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除邮件模板 @@ -279,6 +289,13 @@ class EmailTemplateService: class EmailSendService: + """ + 邮件发送服务 + """ + + """ + 邮件发送服务 + """ """ 邮件发送服务 — 供其他模块调用。 @@ -443,6 +460,13 @@ class EmailSendService: class EmailLogService: + """ + 邮件日志服务 + """ + + """ + 邮件日志服务 + """ """邮件日志查询""" @classmethod @@ -471,6 +495,6 @@ class EmailLogService: offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"created_time": "desc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=EmailLogOutSchema, ) diff --git a/backend/app/api/v1/module_platform/invoice/controller.py b/backend/app/api/v1/module_platform/invoice/controller.py index a933979c..e1517bb7 100644 --- a/backend/app/api/v1/module_platform/invoice/controller.py +++ b/backend/app/api/v1/module_platform/invoice/controller.py @@ -23,7 +23,7 @@ TenantInvoiceRouter = APIRouter(prefix="/tenant/invoice", route_class=OperationL @TenantInvoiceRouter.post("/apply", summary="申请开票", response_model=ResponseSchema[InvoiceOutSchema]) -async def invoice_apply( +async def invoice_apply_controller( data: Annotated[InvoiceApplySchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], ) -> JSONResponse: @@ -41,7 +41,7 @@ async def invoice_apply( @TenantInvoiceRouter.get("/list", summary="我的发票列表", response_model=ResponseSchema[PageResultSchema[InvoiceOutSchema]]) -async def invoice_list_my( +async def invoice_list_my_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], invoice_type: Annotated[str | None, Query()] = None, status: Annotated[int | None, Query()] = None, @@ -69,7 +69,7 @@ async def invoice_list_my( @TenantInvoiceRouter.get("/{id}/download", summary="下载发票PDF", response_model=ResponseSchema[dict]) -async def invoice_download( +async def invoice_download_controller( id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], ) -> JSONResponse: @@ -83,9 +83,7 @@ async def invoice_download( - JSONResponse: 包含 PDF 下载地址的 JSON 响应。 """ crud = InvoiceCRUD(auth) - invoice = await crud.get(id=id) - if not invoice: - raise CustomException(msg="发票不存在") + invoice = await crud.get_or_404(id=id, msg="发票不存在") if hasattr(invoice, "tenant_id") and invoice.tenant_id != auth.tenant_id: raise CustomException(msg="发票不存在") if invoice.status != 1 or not invoice.pdf_url: @@ -100,7 +98,7 @@ PlatformInvoiceRouter = APIRouter(prefix="/invoice", route_class=OperationLogRou @PlatformInvoiceRouter.get("/list", summary="全部发票列表", response_model=ResponseSchema[PageResultSchema[InvoiceOutSchema]]) -async def invoice_list_all( +async def invoice_list_all_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], invoice_type: Annotated[str | None, Query()] = None, status: Annotated[int | None, Query()] = None, @@ -129,7 +127,7 @@ async def invoice_list_all( @PlatformInvoiceRouter.put("/issue/{id}", summary="开具发票", response_model=ResponseSchema[InvoiceOutSchema]) -async def invoice_issue( +async def invoice_issue_controller( id: Annotated[int, Path(ge=1)], data: Annotated[InvoiceIssueSchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], @@ -154,7 +152,7 @@ async def invoice_issue( @PlatformInvoiceRouter.put("/void/{id}", summary="作废发票", response_model=ResponseSchema[InvoiceOutSchema]) -async def invoice_void( +async def invoice_void_controller( id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], data: Annotated[InvoiceVoidSchema, Body()] = InvoiceVoidSchema(), diff --git a/backend/app/api/v1/module_platform/invoice/model.py b/backend/app/api/v1/module_platform/invoice/model.py index 5877de8a..f88933e1 100644 --- a/backend/app/api/v1/module_platform/invoice/model.py +++ b/backend/app/api/v1/module_platform/invoice/model.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING + from sqlalchemy import ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column +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): """ @@ -28,3 +33,6 @@ class InvoiceModel(ModelMixin, TenantMixin, UserMixin): 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/schema.py b/backend/app/api/v1/module_platform/invoice/schema.py index 2edc341f..5733f705 100644 --- a/backend/app/api/v1/module_platform/invoice/schema.py +++ b/backend/app/api/v1/module_platform/invoice/schema.py @@ -1,12 +1,11 @@ from dataclasses import dataclass -from typing import Literal from fastapi import Query from pydantic import BaseModel, ConfigDict, Field -from app.common.enums import QueueEnum +from app.common.enums import InvoiceTypeEnum, QueueEnum from app.core.base_params import BaseQueryParam -from app.core.base_schema import BaseSchema, TenantBySchema +from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema class InvoiceCreateSchema(BaseModel): @@ -15,7 +14,7 @@ class InvoiceCreateSchema(BaseModel): invoice_no: str = Field(..., description="发票号码") order_id: int = Field(..., description="关联订单 ID") tenant_id: int = Field(..., description="租户 ID") - invoice_type: Literal["vat_normal", "vat_special"] = Field(..., description="发票类型") + 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="开户行及账号") @@ -39,7 +38,7 @@ class InvoiceApplySchema(BaseModel): """申请开票""" order_id: int = Field(..., description="订单 ID") - invoice_type: Literal["vat_normal", "vat_special"] = Field(..., description="发票类型") + 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="开户行及账号") @@ -60,13 +59,13 @@ class InvoiceVoidSchema(BaseModel): description: str | None = Field(default=None, description="作废原因") -class InvoiceOutSchema(InvoiceCreateSchema, BaseSchema, TenantBySchema): +class InvoiceOutSchema(InvoiceCreateSchema, BaseSchema, UserBySchema, TenantBySchema): """发票响应""" model_config = ConfigDict(from_attributes=True) - pdf_url: str | None = None - api_response: str | None = None + pdf_url: str | None = Field(default=None, description="PDF 下载地址") + api_response: str | None = Field(default=None, description="第三方 API 响应") @dataclass @@ -75,7 +74,7 @@ class InvoiceQueryParam(BaseQueryParam): def __init__( self, - invoice_type: Literal["vat_normal", "vat_special"] | None = Query(None, description="发票类型"), + invoice_type: InvoiceTypeEnum | None = Query(None, description="发票类型"), status: int | None = Query(None, description="状态"), *args, **kwargs, diff --git a/backend/app/api/v1/module_platform/invoice/service.py b/backend/app/api/v1/module_platform/invoice/service.py index adcdad98..f516be38 100644 --- a/backend/app/api/v1/module_platform/invoice/service.py +++ b/backend/app/api/v1/module_platform/invoice/service.py @@ -39,8 +39,8 @@ def _generate_invoice_no() -> str: class InvoiceTenantService: """租户端发票服务""" - @staticmethod - async def apply(auth: AuthSchema, data: InvoiceApplySchema, tenant_id: int) -> InvoiceOutSchema: + @classmethod + async def apply(cls, auth: AuthSchema, data: InvoiceApplySchema, tenant_id: int) -> InvoiceOutSchema: """ 租户申请开票 @@ -97,8 +97,8 @@ class InvoiceTenantService: logger.info(f"发票申请成功: invoice_no={invoice.invoice_no}, order_id={data.order_id}") return InvoiceOutSchema.model_validate(invoice) - @staticmethod - async def list_my(auth: AuthSchema, tenant_id: int, params: InvoiceQueryParam) -> dict: + @classmethod + async def list_my(cls, auth: AuthSchema, tenant_id: int, params: InvoiceQueryParam) -> dict: """ 租户查询自己的发票列表 @@ -127,8 +127,8 @@ class InvoiceTenantService: class InvoicePlatformService: """平台端发票服务""" - @staticmethod - async def list_all(auth: AuthSchema, params: InvoiceQueryParam) -> dict: + @classmethod + async def list_all(cls, auth: AuthSchema, params: InvoiceQueryParam) -> dict: """ 平台查询全部发票列表 @@ -154,8 +154,8 @@ class InvoicePlatformService: out_schema=InvoiceOutSchema, ) - @staticmethod - async def issue(auth: AuthSchema, invoice_id: int, pdf_url: str, api_response: str) -> InvoiceOutSchema: + @classmethod + async def issue(cls, auth: AuthSchema, invoice_id: int, pdf_url: str, api_response: str) -> InvoiceOutSchema: """ 平台开具发票 @@ -211,8 +211,8 @@ class InvoicePlatformService: logger.info(f"发票开具成功: invoice_no={invoice.invoice_no}") return InvoiceOutSchema.model_validate(invoice) - @staticmethod - async def void(auth: AuthSchema, invoice_id: int, data: InvoiceVoidSchema) -> InvoiceOutSchema: + @classmethod + async def void(cls, auth: AuthSchema, invoice_id: int, data: InvoiceVoidSchema) -> InvoiceOutSchema: """ 平台作废发票 diff --git a/backend/app/api/v1/module_platform/menu/controller.py b/backend/app/api/v1/module_platform/menu/controller.py index c363077f..b8b3856d 100644 --- a/backend/app/api/v1/module_platform/menu/controller.py +++ b/backend/app/api/v1/module_platform/menu/controller.py @@ -38,7 +38,7 @@ async def get_menu_tree_controller( - JSONResponse: 包含菜单树的 JSON 响应。 """ order_by = [{"order": "asc"}] - result_dict_list = await MenuService.get_menu_tree_service(search=search, auth=auth, order_by=order_by) + result_dict_list = await MenuService.tree_service(search=search, auth=auth, order_by=order_by) return SuccessResponse(data=result_dict_list, msg="查询菜单树成功") @@ -60,7 +60,7 @@ async def get_obj_detail_controller( 返回: - JSONResponse: 包含菜单详情的 JSON 响应。 """ - result_dict = await MenuService.get_menu_detail_service(id=id, auth=auth) + result_dict = await MenuService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="查询菜单详情成功") @@ -82,7 +82,7 @@ async def create_obj_controller( 返回: - JSONResponse: 包含创建菜单的 JSON 响应。 """ - result_dict = await MenuService.create_menu_service(data=data, auth=auth) + result_dict = await MenuService.create_service(data=data, auth=auth) await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="创建菜单成功") @@ -107,7 +107,7 @@ async def update_obj_controller( 返回: - JSONResponse: 包含修改菜单的 JSON 响应。 """ - result_dict = await MenuService.update_menu_service(id=id, data=data, auth=auth) + result_dict = await MenuService.update_service(id=id, data=data, auth=auth) await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="修改菜单成功") @@ -130,7 +130,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 包含删除菜单的 JSON 响应。 """ - await MenuService.delete_menu_service(ids=ids, auth=auth) + await MenuService.delete_service(ids=ids, auth=auth) await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(msg="删除菜单成功") @@ -153,6 +153,6 @@ async def batch_set_available_obj_controller( 返回: - JSONResponse: 批量修改菜单状态的 JSON 响应。 """ - await MenuService.set_menu_available_service(data=data, auth=auth) + await MenuService.set_available_service(data=data, auth=auth) await FastAPICache.clear(namespace=_MENU_NS) return SuccessResponse(msg="批量修改菜单状态成功") diff --git a/backend/app/api/v1/module_platform/menu/crud.py b/backend/app/api/v1/module_platform/menu/crud.py index ef61cbcd..d2235e2e 100644 --- a/backend/app/api/v1/module_platform/menu/crud.py +++ b/backend/app/api/v1/module_platform/menu/crud.py @@ -1,5 +1,3 @@ -from collections.abc import Sequence - from app.core.base_crud import CRUDBase from app.core.base_schema import AuthSchema @@ -12,27 +10,3 @@ class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]): def __init__(self, auth: AuthSchema) -> None: super().__init__(model=MenuModel, auth=auth) - - async def get_tree_list( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list[str] | None = None, - ) -> Sequence[MenuModel]: - """ - 获取菜单树形列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict] | None): 排序字段列表。 - - preload (list[str] | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[MenuModel]: 菜单树形列表。 - """ - return await self.tree_list( - search=search, - order_by=order_by, - children_attr="children", - preload=preload, - ) diff --git a/backend/app/api/v1/module_platform/menu/model.py b/backend/app/api/v1/module_platform/menu/model.py index ed09d080..40127903 100644 --- a/backend/app/api/v1/module_platform/menu/model.py +++ b/backend/app/api/v1/module_platform/menu/model.py @@ -23,6 +23,7 @@ class MenuModel(ModelMixin): __tablename__: str = "platform_menu" __table_args__: dict[str, str] = {"comment": "平台菜单表"} + __tree_children_attr__: str = "children" __loader_options__: list[str] = ["roles", "children"] __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.ROLE_BASED diff --git a/backend/app/api/v1/module_platform/menu/schema.py b/backend/app/api/v1/module_platform/menu/schema.py index 04d8b03b..80833a07 100644 --- a/backend/app/api/v1/module_platform/menu/schema.py +++ b/backend/app/api/v1/module_platform/menu/schema.py @@ -5,8 +5,9 @@ from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.common.enums import QueueEnum +from app.core.base_params import BaseQueryParam from app.core.base_schema import BaseSchema -from app.core.validator import DateTimeStr, menu_request_validator +from app.core.validator import menu_request_validator class MenuCreateSchema(BaseModel): @@ -188,7 +189,7 @@ class MenuUpdateSchema(BaseModel): return menu_request_validator(self) -class MenuDetailOutSchema(MenuCreateSchema, BaseSchema): +class MenuOutSchema(MenuCreateSchema, BaseSchema): """菜单详情响应模型(不含 children,用于详情和更新)""" model_config = ConfigDict(from_attributes=True) @@ -196,71 +197,50 @@ class MenuDetailOutSchema(MenuCreateSchema, BaseSchema): parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称") -class MenuTreeOutSchema(MenuDetailOutSchema): +class MenuTreeOutSchema(MenuOutSchema): """菜单树形响应模型(含 children,用于树形列表)""" children: list["MenuTreeOutSchema"] | None = Field(default=None, description="子菜单列表") -# 兼容旧代码的别名(后续可逐步移除) -MenuOutSchema = MenuDetailOutSchema - - @dataclass -class MenuQueryParam: - """菜单管理查询参数""" +class MenuQueryParam(BaseQueryParam): + """菜单管理查询参数(菜单为平台级资源,无用户归属)""" - name: str | None = Query(None, description="菜单名称") - route_path: str | None = Query(None, description="路由地址") - component_path: str | None = Query(None, description="组件路径") - type: Literal[1, 2, 3, 4] | None = Query(None, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)") - permission: str | None = Query(None, description="权限标识") - description: str | None = Query(None, description="描述") - status: str | None = Query(None, description="是否启用") - created_time: list[DateTimeStr] | None = Query( - None, - description="创建时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ) - updated_time: list[DateTimeStr] | None = Query( - None, - description="更新时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ) - created_id: int | None = Query(None, description="创建人") - updated_id: int | None = Query(None, description="更新人") - menu_client: Literal["pc", "app"] | None = Query( - None, - description="管理端 Tab:pc=桌面端菜单 app=移动端菜单;不传则不过滤终端", - ) - scope: Literal["tenant"] | None = Query( - None, - description="菜单范围过滤:tenant=仅租户可用菜单", - ) - - def __post_init__(self) -> None: - """处理查询条件,转换为 ORM 表达式""" - if self.name: - self.name = (QueueEnum.like.value, self.name) - if self.route_path: - self.route_path = (QueueEnum.like.value, self.route_path) - if self.component_path: - self.component_path = (QueueEnum.like.value, self.component_path) - if self.permission: - self.permission = (QueueEnum.like.value, self.permission) - if self.description: - self.description = (QueueEnum.like.value, self.description) - if self.status: - self.status = (QueueEnum.eq.value, self.status) - if self.created_time and len(self.created_time) == 2: - self.created_time = (QueueEnum.between.value, (self.created_time[0], self.created_time[1])) - if self.updated_time and len(self.updated_time) == 2: - self.updated_time = (QueueEnum.between.value, (self.updated_time[0], self.updated_time[1])) - if self.created_id: - self.created_id = (QueueEnum.eq.value, self.created_id) - if self.updated_id: - self.updated_id = (QueueEnum.eq.value, self.updated_id) - if self.menu_client in ("pc", "app"): - self.client = (QueueEnum.eq.value, self.menu_client) - if self.scope == "tenant": + def __init__( + self, + name: str | None = Query(None, description="菜单名称"), + route_path: str | None = Query(None, description="路由地址"), + component_path: str | None = Query(None, description="组件路径"), + type: Literal[1, 2, 3, 4] | None = Query(None, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)"), + permission: str | None = Query(None, description="权限标识"), + description: str | None = Query(None, description="描述"), + status: str | None = Query(None, description="是否启用"), + menu_client: Literal["pc", "app"] | None = Query( + None, + description="管理端 Tab:pc=桌面端菜单 app=移动端菜单;不传则不过滤终端", + ), + scope: Literal["tenant"] | None = Query( + None, + description="菜单范围过滤:tenant=仅租户可用菜单", + ), + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + if name: + self.name = (QueueEnum.like.value, name) + if route_path: + self.route_path = (QueueEnum.like.value, route_path) + if component_path: + self.component_path = (QueueEnum.like.value, component_path) + if permission: + self.permission = (QueueEnum.like.value, permission) + if description: + self.description = (QueueEnum.like.value, description) + if status: + self.status = (QueueEnum.eq.value, status) + if menu_client in ("pc", "app"): + self.client = (QueueEnum.eq.value, menu_client) + if scope == "tenant": self.scope = (QueueEnum.eq.value, "tenant") diff --git a/backend/app/api/v1/module_platform/menu/service.py b/backend/app/api/v1/module_platform/menu/service.py index 47977e59..39795b4b 100644 --- a/backend/app/api/v1/module_platform/menu/service.py +++ b/backend/app/api/v1/module_platform/menu/service.py @@ -1,6 +1,7 @@ from typing import Any from app.core.base_schema import AuthSchema, BatchSetAvailable +from app.core.dependencies import require_superadmin from app.core.exceptions import CustomException from app.utils.common_util import ( get_child_id_map, @@ -13,7 +14,7 @@ from app.utils.common_util import ( from .crud import MenuCRUD from .schema import ( MenuCreateSchema, - MenuDetailOutSchema, + MenuOutSchema, MenuQueryParam, MenuTreeOutSchema, MenuUpdateSchema, @@ -22,7 +23,7 @@ from .schema import ( class MenuService: """ - 菜单模块服务层 + 菜单管理服务(查询操作租户可见,写操作仅超级管理员可操作) """ @classmethod @@ -63,7 +64,7 @@ class MenuService: raise CustomException(msg="子菜单终端须与父菜单一致(均为 pc 或均为 app)") @classmethod - async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> MenuDetailOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> MenuOutSchema: """ 获取菜单详情。 @@ -72,12 +73,12 @@ class MenuService: - id (int): 菜单ID。 返回: - - MenuDetailOutSchema: 菜单详情对象。 + - MenuOutSchema: 菜单详情对象。 """ menu = await MenuCRUD(auth).get(id=id, preload=["roles"]) if not menu: raise CustomException(msg="菜单不存在") - menu_out = MenuDetailOutSchema.model_validate(menu) + menu_out = MenuOutSchema.model_validate(menu) if menu.parent_id: parent = await MenuCRUD(auth).get(id=menu.parent_id) if parent: @@ -85,7 +86,7 @@ class MenuService: return menu_out @classmethod - async def get_menu_tree_service( + async def tree_service( cls, auth: AuthSchema, search: MenuQueryParam | None = None, @@ -103,14 +104,15 @@ class MenuService: - list[dict]: 菜单树形列表对象。 """ # 使用树形结构查询,预加载children关系 - menu_list = await MenuCRUD(auth).get_tree_list(search=vars(search) if search else None, order_by=order_by) + menu_list = await MenuCRUD(auth).tree_list(search=vars(search) if search else None, order_by=order_by) # 转换为字典列表(使用树形 Schema) menu_dict_list = [MenuTreeOutSchema.model_validate(menu).model_dump() for menu in menu_list] # 使用traversal_to_tree构建树形结构 return traversal_to_tree(menu_dict_list) @classmethod - async def create_menu_service(cls, auth: AuthSchema, data: MenuCreateSchema) -> MenuDetailOutSchema: + @require_superadmin + async def create_service(cls, auth: AuthSchema, data: MenuCreateSchema) -> MenuOutSchema: """ 创建菜单。 @@ -119,7 +121,7 @@ class MenuService: - data (MenuCreateSchema): 创建参数对象。 返回: - - MenuDetailOutSchema: 创建的菜单对象。 + - MenuOutSchema: 创建的菜单对象。 """ search: dict[str, Any] = {} if data.title is not None: @@ -134,10 +136,11 @@ class MenuService: await cls._validate_parent_child_client(auth, data.parent_id, data.client) new_menu = await MenuCRUD(auth).create(data=data) - return MenuDetailOutSchema.model_validate(new_menu) + return MenuOutSchema.model_validate(new_menu) @classmethod - async def update_menu_service(cls, auth: AuthSchema, id: int, data: MenuUpdateSchema) -> MenuDetailOutSchema: + @require_superadmin + async def update_service(cls, auth: AuthSchema, id: int, data: MenuUpdateSchema) -> MenuOutSchema: """ 更新菜单。 @@ -147,11 +150,9 @@ class MenuService: - data (MenuUpdateSchema): 更新参数对象。 返回: - - MenuDetailOutSchema: 更新的菜单对象。 + - MenuOutSchema: 更新的菜单对象。 """ - menu = await MenuCRUD(auth).get(id=id) - if not menu: - raise CustomException(msg="更新失败,该菜单不存在") + _ = await MenuCRUD(auth).get_or_404(id=id, msg="更新失败,该菜单不存在") await cls._validate_parent_child_type(auth, data.parent_id, data.type) await cls._validate_parent_child_client(auth, data.parent_id, data.client) if data.title is not None: @@ -170,9 +171,9 @@ class MenuService: new_menu = await MenuCRUD(auth).update(id=id, data=data) if data.status is not None: - await cls.set_menu_available_service(auth=auth, data=BatchSetAvailable(ids=[id], status=data.status)) + await cls.set_available_service(auth=auth, data=BatchSetAvailable(ids=[id], status=data.status)) - menu_out = MenuDetailOutSchema.model_validate(new_menu) + menu_out = MenuOutSchema.model_validate(new_menu) if menu_out.parent_id: parent = await MenuCRUD(auth).get(id=menu_out.parent_id) if parent: @@ -180,7 +181,8 @@ class MenuService: return menu_out @classmethod - async def delete_menu_service(cls, auth: AuthSchema, ids: list[int]) -> None: + @require_superadmin + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除菜单。 @@ -215,7 +217,8 @@ class MenuService: await MenuCRUD(auth).delete(ids=delete_ids) @classmethod - async def set_menu_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + @require_superadmin + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 递归获取所有父、子级菜单,然后批量修改菜单可用状态。 diff --git a/backend/app/api/v1/module_platform/order/controller.py b/backend/app/api/v1/module_platform/order/controller.py index 8c7a6d4c..fc116b6a 100644 --- a/backend/app/api/v1/module_platform/order/controller.py +++ b/backend/app/api/v1/module_platform/order/controller.py @@ -39,11 +39,12 @@ def _make_bare_auth(db: AsyncSession) -> AuthSchema: return AuthSchema(db=db, check_data_scope=False) -# ─── 平台订单 ────────────────────────────────────────── - - -@OrderRouter.post("/create", summary="创建订单", response_model=ResponseSchema[OrderOutSchema]) -async def order_create( +@OrderRouter.post( + "/create", + summary="创建订单", + response_model=ResponseSchema[OrderOutSchema], +) +async def order_create_controller( data: Annotated[OrderCreateSchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:create"]))], ) -> JSONResponse: @@ -52,16 +53,21 @@ async def order_create( 参数: - data (OrderCreateSchema): 订单创建参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含订单详情的 JSON 响应。 """ - result = await OrderService.create_order(auth, data) + result = await OrderService.create_order(auth=auth, data=data) return SuccessResponse(data=result, msg="订单创建成功") -@OrderRouter.get("/detail/{order_id}", summary="订单详情", response_model=ResponseSchema[OrderOutSchema]) -async def order_detail( +@OrderRouter.get( + "/detail/{order_id}", + summary="订单详情", + response_model=ResponseSchema[OrderOutSchema], +) +async def order_detail_controller( order_id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:query"]))], ) -> JSONResponse: @@ -70,18 +76,23 @@ async def order_detail( 参数: - order_id (int): 订单 ID。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含订单详情的 JSON 响应。 """ - order = await OrderService.get_detail(auth, order_id) + order = await OrderService.get_detail(auth=auth, order_id=order_id) if not order: raise HTTPException(status_code=404, detail="订单不存在") return SuccessResponse(data=order) -@OrderRouter.get("/list", summary="订单列表", response_model=ResponseSchema[PageResultSchema[OrderOutSchema]]) -async def order_list( +@OrderRouter.get( + "/list", + summary="订单列表", + response_model=ResponseSchema[PageResultSchema[OrderOutSchema]], +) +async def order_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:query"]))], tenant_id: Annotated[int | None, Query()] = None, status: Annotated[int | None, Query()] = None, @@ -93,6 +104,7 @@ async def order_list( 订单列表 参数: + - auth (AuthSchema): 认证信息模型。 - tenant_id (int | None): 租户 ID 筛选。 - status (int | None): 状态筛选。 - order_type (str | None): 订单类型筛选。 @@ -104,7 +116,7 @@ async def order_list( """ params = OrderQueryParam(tenant_id=tenant_id, status=status, order_type=order_type) offset = (page - 1) * page_size - items, total = await OrderService.get_list(auth, params, offset, page_size) + items, total = await OrderService.get_list(auth=auth, params=params, offset=offset, limit=page_size) result = PageResultSchema( page_no=page, page_size=page_size, @@ -115,8 +127,12 @@ async def order_list( return SuccessResponse(data=result) -@OrderRouter.post("/cancel/{order_id}", summary="取消订单", response_model=ResponseSchema[OrderStatusMessage]) -async def order_cancel( +@OrderRouter.post( + "/cancel/{order_id}", + summary="取消订单", + response_model=ResponseSchema[OrderStatusMessage], +) +async def order_cancel_controller( order_id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:update"]))], ) -> JSONResponse: @@ -125,57 +141,93 @@ async def order_cancel( 参数: - order_id (int): 订单 ID。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含取消结果的 JSON 响应。 """ - result = await OrderService.cancel_order(auth, order_id) + result = await OrderService.cancel_order(auth=auth, order_id=order_id) return SuccessResponse(data=result, msg=result["message"]) -# ─── 支付 ────────────────────────────────────────────── - - -@PaymentRouter.post("/pay/{order_id}", summary="创建支付(获取支付 URL/二维码)", response_model=ResponseSchema[PaymentCreateOut]) -async def payment_create( +@PaymentRouter.post( + "/pay/{order_id}", + summary="创建支付(获取支付 URL/二维码)", + response_model=ResponseSchema[PaymentCreateOut], +) +async def payment_create_controller( order_id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:update"]))], request: Request, method: Annotated[str, Query(description="支付渠道: alipay / wxpay(留空=自动)")] = "", ) -> JSONResponse: - """创建支付 + """ + 创建支付 调用支付网关生成支付 URL(H5 跳转)或二维码(Native 扫码)。 + + 参数: + - order_id (int): 订单 ID。 + - auth (AuthSchema): 认证信息模型。 + - request (Request): FastAPI 请求对象。 + - method (str): 支付渠道。 + + 返回: + - JSONResponse: 包含支付信息的 JSON 响应。 """ base_url = str(request.base_url).rstrip("/") - result = await PaymentService.create_payment(auth, order_id, method, base_url) + result = await PaymentService.create_payment(auth=auth, order_id=order_id, method=method, notify_base_url=base_url) return SuccessResponse(data=result, msg="支付信息已生成") -@PaymentRouter.get("/status/{order_id}", summary="查询支付状态(供前端轮询)", response_model=ResponseSchema[PaymentStatusOut]) -async def payment_status( +@PaymentRouter.get( + "/status/{order_id}", + summary="查询支付状态(供前端轮询)", + response_model=ResponseSchema[PaymentStatusOut], +) +async def payment_status_controller( order_id: Annotated[int, Path(ge=1)], db: Annotated[AsyncSession, Depends(db_getter)], ) -> JSONResponse: - """查询支付状态(无需登录,前端轮询用)""" + """ + 查询支付状态(无需登录,前端轮询用) + + 参数: + - order_id (int): 订单 ID。 + - db (AsyncSession): 数据库会话。 + + 返回: + - JSONResponse: 包含支付状态的 JSON 响应。 + """ auth = _make_bare_auth(db) - result = await OrderService.check_payment_status(auth, order_id) + result = await OrderService.check_payment_status(auth=auth, order_id=order_id) return SuccessResponse(data=result) -# ─── 支付回调 ────────────────────────────────────────── - - -@PaymentRouter.post("/callback/{method}", summary="支付回调(统一入口)", response_model=ResponseSchema[dict]) -async def payment_callback( +@PaymentRouter.post( + "/callback/{method}", + summary="支付回调(统一入口)", + response_model=ResponseSchema[dict], +) +async def payment_callback_controller( method: Annotated[str, Path(description="支付渠道: alipay / wxpay / mock")], data: Annotated[dict, Body()], db: Annotated[AsyncSession, Depends(db_getter)], ) -> JSONResponse: - """接收支付网关的异步通知(无需认证)""" + """ + 接收支付网关的异步通知(无需认证) + + 参数: + - method (str): 支付渠道。 + - data (dict): 支付回调原始数据。 + - db (AsyncSession): 数据库会话。 + + 返回: + - JSONResponse: 包含处理结果的 JSON 响应。 + """ try: auth = _make_bare_auth(db) - result = await PaymentService.handle_callback(auth, method, data) + result = await PaymentService.handle_callback(auth=auth, method=method, callback_data=data) logger.info(f"支付回调处理成功: {result}") return SuccessResponse(data=result) except CustomException as e: @@ -183,12 +235,25 @@ async def payment_callback( return SuccessResponse(data={"message": str(e)}, code=400) -@PaymentRouter.post("/mock/callback", summary="Mock 支付回调(开发环境触发模拟支付)", response_model=ResponseSchema[dict]) -async def payment_mock_callback( +@PaymentRouter.post( + "/mock/callback", + summary="Mock 支付回调(开发环境触发模拟支付)", + response_model=ResponseSchema[dict], +) +async def payment_mock_callback_controller( order_id: Annotated[int, Body(ge=1, description="订单 ID")], db: Annotated[AsyncSession, Depends(db_getter)], ) -> JSONResponse: - """开发环境下手动触发模拟支付成功回调""" + """ + 开发环境下手动触发模拟支付成功回调 + + 参数: + - order_id (int): 订单 ID。 + - db (AsyncSession): 数据库会话。 + + 返回: + - JSONResponse: 包含模拟支付结果的 JSON 响应。 + """ from app.core.payment import get_mock_gateway from .crud import OrderCRUD @@ -200,16 +265,17 @@ async def payment_mock_callback( mock_gw = get_mock_gateway() callback_data = mock_gw.get_mock_callback_data(order.id, order.order_no) - result = await PaymentService.handle_callback(auth, "mock", callback_data) + result = await PaymentService.handle_callback(auth=auth, method="mock", callback_data=callback_data) logger.info(f"Mock 支付回调触发: order_id={order_id}") return SuccessResponse(data=result, msg="模拟支付成功") -# ─── 支付记录 ────────────────────────────────────────── - - -@PaymentRouter.get("/record/list", summary="支付记录列表", response_model=ResponseSchema[PageResultSchema[PaymentRecordOutSchema]]) -async def payment_record_list( +@PaymentRouter.get( + "/record/list", + summary="支付记录列表", + response_model=ResponseSchema[PageResultSchema[PaymentRecordOutSchema]], +) +async def payment_record_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:query"]))], page: Annotated[int, Query(ge=1)] = 1, page_size: Annotated[int, Query(ge=1, le=100)] = 20, @@ -218,6 +284,7 @@ async def payment_record_list( 支付记录列表 参数: + - auth (AuthSchema): 认证信息模型。 - page (int): 页码。 - page_size (int): 每页条数。 @@ -225,7 +292,7 @@ async def payment_record_list( - JSONResponse: 包含分页支付记录列表的 JSON 响应。 """ offset = (page - 1) * page_size - items, total = await PaymentService.get_records(auth, offset, page_size) + items, total = await PaymentService.get_records(auth=auth, offset=offset, limit=page_size) result = PageResultSchema( page_no=page, page_size=page_size, @@ -236,11 +303,12 @@ async def payment_record_list( return SuccessResponse(data=result) -# ─── 退款管理 ────────────────────────────────────────── - - -@RefundRouter.get("/list", summary="退款审核列表", response_model=ResponseSchema[PageResultSchema[RefundOutSchema]]) -async def refund_list( +@RefundRouter.get( + "/list", + summary="退款审核列表", + response_model=ResponseSchema[PageResultSchema[RefundOutSchema]], +) +async def refund_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:query"]))], status: Annotated[int | None, Query(description="状态筛选")] = None, page: Annotated[int, Query(ge=1)] = 1, @@ -250,6 +318,7 @@ async def refund_list( 退款审核列表 参数: + - auth (AuthSchema): 认证信息模型。 - status (int | None): 状态筛选。 - page (int): 页码。 - page_size (int): 每页条数。 @@ -258,7 +327,7 @@ async def refund_list( - JSONResponse: 包含分页退款列表的 JSON 响应。 """ offset = (page - 1) * page_size - items, total = await RefundService.get_list(auth, status, offset, page_size) + items, total = await RefundService.get_list(auth=auth, status=status, offset=offset, limit=page_size) result = PageResultSchema( page_no=page, page_size=page_size, @@ -269,8 +338,12 @@ async def refund_list( return SuccessResponse(data=result) -@RefundRouter.put("/approve/{refund_id}", summary="批准退款", response_model=ResponseSchema[OrderStatusMessage]) -async def refund_approve( +@RefundRouter.put( + "/approve/{refund_id}", + summary="批准退款", + response_model=ResponseSchema[OrderStatusMessage], +) +async def refund_approve_controller( refund_id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:update"]))], ) -> JSONResponse: @@ -279,21 +352,26 @@ async def refund_approve( 参数: - refund_id (int): 退款申请 ID。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含批准结果的 JSON 响应。 """ result = await RefundService.approve( - auth, - refund_id, - auth.user.id if auth.user else 0, - auth.user.name if auth.user else "", + auth=auth, + refund_id=refund_id, + reviewer_id=auth.user.id if auth.user else 0, + operator_name=auth.user.name if auth.user else "", ) return SuccessResponse(data=result, msg=result["message"]) -@RefundRouter.put("/reject/{refund_id}", summary="驳回退款", response_model=ResponseSchema[OrderStatusMessage]) -async def refund_reject( +@RefundRouter.put( + "/reject/{refund_id}", + summary="驳回退款", + response_model=ResponseSchema[OrderStatusMessage], +) +async def refund_reject_controller( refund_id: Annotated[int, Path(ge=1)], data: Annotated[RefundReviewSchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:order:update"]))], @@ -304,25 +382,27 @@ async def refund_reject( 参数: - refund_id (int): 退款申请 ID。 - data (RefundReviewSchema): 驳回原因。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含驳回结果的 JSON 响应。 """ result = await RefundService.reject( - auth, - refund_id, - auth.user.id if auth.user else 0, - data, - auth.user.name if auth.user else "", + auth=auth, + refund_id=refund_id, + reviewer_id=auth.user.id if auth.user else 0, + data=data, + operator_name=auth.user.name if auth.user else "", ) return SuccessResponse(data=result, msg=result["message"]) -# ─── 租户端订单 ──────────────────────────────────────── - - -@TenantOrderRouter.post("/create", summary="租户端创建订单", response_model=ResponseSchema[OrderOutSchema]) -async def tenant_order_create( +@TenantOrderRouter.post( + "/create", + summary="租户端创建订单", + response_model=ResponseSchema[OrderOutSchema], +) +async def tenant_order_create_controller( data: Annotated[OrderCreateSchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:create"]))], ) -> JSONResponse: @@ -331,18 +411,23 @@ async def tenant_order_create( 参数: - data (OrderCreateSchema): 订单创建参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含订单详情的 JSON 响应。 """ if data.tenant_id != auth.tenant_id: raise HTTPException(status_code=403, detail="无权操作") - result = await OrderService.create_order(auth, data) + result = await OrderService.create_order(auth=auth, data=data) return SuccessResponse(data=result, msg="订单创建成功") -@TenantOrderRouter.post("/refund/apply/{order_id}", summary="申请退款", response_model=ResponseSchema[RefundOutSchema]) -async def tenant_refund_apply( +@TenantOrderRouter.post( + "/refund/apply/{order_id}", + summary="申请退款", + response_model=ResponseSchema[RefundOutSchema], +) +async def tenant_refund_apply_controller( order_id: Annotated[int, Path(ge=1)], data: Annotated[RefundApplySchema, Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:refund"]))], @@ -353,9 +438,10 @@ async def tenant_refund_apply( 参数: - order_id (int): 订单 ID。 - data (RefundApplySchema): 退款申请参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含退款申请结果的 JSON 响应。 """ - result = await RefundService.apply(auth, data, order_id) + result = await RefundService.apply(auth=auth, 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 index 9f03ff87..29bed975 100644 --- a/backend/app/api/v1/module_platform/order/crud.py +++ b/backend/app/api/v1/module_platform/order/crud.py @@ -63,13 +63,7 @@ class OrderCRUD(CRUDBase[OrderModel, OrderCreateInternalSchema, OrderUpdateInter 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) - ) + result = await session.execute(sa_update(OrderModel).where(OrderModel.status == 0).where(OrderModel.expire_time < now).where(OrderModel.is_deleted.is_(False)).values(status=2)) logger.info(f"超时订单取消: 已取消 {result.rowcount} 条订单") diff --git a/backend/app/api/v1/module_platform/order/model.py b/backend/app/api/v1/module_platform/order/model.py index a7918335..2f1c490c 100644 --- a/backend/app/api/v1/module_platform/order/model.py +++ b/backend/app/api/v1/module_platform/order/model.py @@ -1,12 +1,18 @@ """订单与支付 Model""" from datetime import datetime +from typing import TYPE_CHECKING from sqlalchemy import DateTime, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column +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_platform.plugin.model import PluginModel + from app.api.v1.module_system.user.model import UserModel + class OrderModel(ModelMixin, TenantMixin): """platform_order — 订单表 @@ -18,8 +24,9 @@ class OrderModel(ModelMixin, TenantMixin): status: 0=待支付 1=已支付 2=已取消 3=已退款 """ - __tablename__ = "platform_order" + __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="购买套餐(插件订单为空)") @@ -30,9 +37,13 @@ class OrderModel(ModelMixin, TenantMixin): 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:停用)", index=True) + 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="备注") + # 关联关系 + package: Mapped["PackageModel | None"] = relationship("PackageModel", lazy="selectin") + plugin: Mapped["PluginModel | None"] = relationship("PluginModel", lazy="selectin") + class PaymentRecordModel(ModelMixin, TenantMixin): """platform_payment_record — 支付记录表 @@ -40,8 +51,9 @@ class PaymentRecordModel(ModelMixin, TenantMixin): status: 0=处理中 1=成功 2=失败 """ - __tablename__ = "platform_payment_record" + __tablename__: str = "platform_payment_record" __table_args__: dict[str, str] = {"comment": "支付记录表"} + __loader_options__: list[str] = ["tenant_by"] order_id: Mapped[int] = mapped_column(ForeignKey("platform_order.id"), nullable=False, comment="关联订单") transaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, comment="第三方交易号") @@ -49,9 +61,12 @@ class PaymentRecordModel(ModelMixin, TenantMixin): amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="支付金额(分)") raw_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="原始回调JSON") pay_time: Mapped[datetime | None] = mapped_column(DateTime, 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="备注") + # 关联关系 + order: Mapped["OrderModel"] = relationship("OrderModel", lazy="selectin") + class RefundModel(ModelMixin, TenantMixin): """platform_refund — 退款表 @@ -59,8 +74,9 @@ class RefundModel(ModelMixin, TenantMixin): status: 1=申请中 2=已退款 3=已驳回 4=已取消 """ - __tablename__ = "platform_refund" + __tablename__: str = "platform_refund" __table_args__: dict[str, str] = {"comment": "退款表"} + __loader_options__: list[str] = ["tenant_by"] order_id: Mapped[int] = mapped_column(ForeignKey("platform_order.id"), nullable=False, unique=True, comment="关联订单") refund_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="退款单号") @@ -70,5 +86,9 @@ class RefundModel(ModelMixin, TenantMixin): 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="驳回原因") - status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True) + status: Mapped[int] = mapped_column(Integer, default=1, nullable=False, comment="状态(1:申请中 2:已退款 3:已驳回 4:已取消)", index=True) description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注") + + # 关联关系 + order: Mapped["OrderModel"] = relationship("OrderModel", 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 index caac6694..9b5c84df 100644 --- a/backend/app/api/v1/module_platform/order/schema.py +++ b/backend/app/api/v1/module_platform/order/schema.py @@ -2,83 +2,94 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime +from typing import Literal +from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.common.enums import QueueEnum from app.core.base_params import BaseQueryParam -from app.core.base_schema import BaseSchema - -# ─── Internal Create/Update Schemas(CRUD 层用,字段映射 Model 1:1)─── +from app.core.base_schema import BaseSchema, TenantBySchema class OrderCreateInternalSchema(BaseModel): """订单创建(内部 CRUD 用,包含所有业务字段)""" - order_no: str - tenant_id: int - package_id: int | None = None - plugin_id: int | None = None - order_type: str - amount: int - period_count: int = 1 - pay_method: str | None = None - pay_time: datetime | None = None - expire_time: datetime - status: int = 0 + order_no: str = Field(..., description="订单号") + tenant_id: int = Field(..., description="租户ID") + package_id: int | None = Field(default=None, description="套餐ID") + plugin_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 = None - pay_method: str | None = None - pay_time: datetime | None = None + status: int | None = Field(default=None, description="订单状态") + pay_method: str | None = Field(default=None, description="支付方式") + pay_time: datetime | None = Field(default=None, description="支付时间") + description: str | None = Field(default=None, description="备注") class PaymentRecordCreateSchema(BaseModel): """支付记录创建""" - order_id: int - transaction_id: str | None = None - pay_method: str - amount: int - status: int = 1 - raw_response: str | None = None - pay_time: datetime | None = None + order_id: int = Field(..., description="订单ID") + transaction_id: str | None = Field(default=None, description="交易流水号") + pay_method: str = Field(..., description="支付方式") + amount: int = Field(..., description="支付金额(分)") + status: int = Field(default=1, description="支付状态") + raw_response: str | None = Field(default=None, description="原始响应") + pay_time: datetime | None = Field(default=None, description="支付时间") + description: str | None = Field(default=None, description="备注") class RefundCreateSchema(BaseModel): """退款记录创建""" - order_id: int - refund_no: str - amount: int - reason: str - status: int = 1 + order_id: int = Field(..., description="订单ID") + refund_no: str = Field(..., description="退款单号") + amount: int = Field(..., description="退款金额(分)") + reason: str = Field(..., 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="驳回原因") + status: int = Field(default=1, description="退款状态") + description: str | None = Field(default=None, description="备注") class RefundUpdateSchema(BaseModel): """退款记录更新""" - status: int | None = None - reviewer_id: int | None = None - review_time: datetime | None = None - reject_reason: str | None = None - - -# ─── Order ────────────────────────────────────────────── + status: int | 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_transaction_id: str | None = Field(default=None, description="退款交易流水号") + description: str | None = Field(default=None, description="备注") class OrderCreateSchema(BaseModel): """创建订单(套餐或插件)""" - tenant_id: int - package_id: int | None = Field(default=None, description="套餐ID(套餐订单必填)") - plugin_id: int | None = Field(default=None, description="插件ID(插件订单必填)") - order_type: str = Field(pattern=r"^(new|renew|upgrade|downgrade|plugin)$") - pay_method: str | None = Field(default=None, pattern=r"^(alipay|wxpay)?$") + tenant_id: int = Field(..., ge=1, description="租户ID") + package_id: int | None = Field(default=None, ge=1, description="套餐ID(套餐订单必填)") + plugin_id: int | None = Field(default=None, ge=1, description="插件ID(插件订单必填)") + order_type: Literal["new", "renew", "upgrade", "downgrade", "plugin"] = Field( + ..., + description="订单类型(new:新购 renew:续费 upgrade:升级 downgrade:降级 plugin:插件)", + ) + pay_method: Literal["alipay", "wxpay", "free"] | None = Field(default=None, description="支付方式(留空=自动)") @field_validator("tenant_id") @classmethod @@ -98,31 +109,34 @@ class OrderCreateSchema(BaseModel): return self -class OrderOutSchema(BaseSchema): +class OrderOutSchema(BaseSchema, TenantBySchema): """订单输出""" - order_no: str - tenant_id: int - package_id: int | None = None - plugin_id: int | None = None - order_type: str - amount: int - period_count: int - pay_method: str | None = None - pay_time: datetime | None = None - expire_time: datetime - model_config = ConfigDict(from_attributes=True) + order_no: str = Field(..., description="订单号") + package_id: int | None = Field(default=None, description="套餐ID") + plugin_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:已取消 3:已退款)") + description: str | None = Field(default=None, description="备注") + +@dataclass class OrderQueryParam(BaseQueryParam): """订单查询参数""" def __init__( self, - tenant_id: int | None = None, - status: int | None = None, - order_type: str | None = None, + tenant_id: int | None = Query(None, description="租户ID"), + status: int | None = Query(None, description="订单状态(0:待支付 1:已支付 2:已取消 3:已退款)"), + order_type: str | None = Query(None, description="订单类型"), + order_no: str | None = Query(None, description="订单号"), *args, **kwargs, ) -> None: @@ -133,87 +147,87 @@ class OrderQueryParam(BaseQueryParam): self.status = (QueueEnum.eq.value, status) if order_type: self.order_type = (QueueEnum.eq.value, order_type) - - -# ─── Payment ──────────────────────────────────────────── + if order_no: + self.order_no = (QueueEnum.like.value, order_no) class PaymentCallbackSchema(BaseModel): """支付回调数据""" - transaction_id: str | None = None - amount: int - order_id: int | None = None - raw_data: dict | None = None + 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 PaymentRecordOutSchema(BaseSchema): +class PaymentRecordOutSchema(BaseSchema, TenantBySchema): """支付记录输出""" - order_id: int - transaction_id: str | None = None - pay_method: str - amount: int - pay_time: datetime | None = None - model_config = ConfigDict(from_attributes=True) + order_id: int = Field(..., description="订单ID") + transaction_id: str | None = Field(default=None, description="交易流水号") + pay_method: str = Field(..., description="支付方式") + amount: int = Field(..., description="支付金额(分)") + pay_time: datetime | None = Field(default=None, description="支付时间") + status: int = Field(..., description="支付状态") + description: str | None = Field(default=None, description="备注") + class PaymentCreateOut(BaseModel): """创建支付结果""" - pay_url: str - qr_code_url: str - trade_no: str - order_id: int - order_no: str - amount: int + pay_url: str = Field(..., description="支付链接") + qr_code_url: str = Field(..., 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 - order_id: int | None = None - status: int | None = None - paid: bool = False - pay_method: str | None = None - pay_time: str | None = None + 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 - status: int - message: str - - -# ─── Refund ───────────────────────────────────────────── + 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) + reason: str = Field(..., min_length=1, max_length=500, description="退款原因") class RefundReviewSchema(BaseModel): """退款审核""" - reject_reason: str | None = Field(default=None, max_length=500) + reject_reason: str | None = Field(default=None, max_length=500, description="驳回原因(审核通过时可不填)") -class RefundOutSchema(BaseSchema): +class RefundOutSchema(BaseSchema, TenantBySchema): """退款记录输出""" - order_id: int - refund_no: str - amount: int - reason: str - refund_transaction_id: str | None = None - reviewer_id: int | None = None - review_time: datetime | None = None - reject_reason: str | None = None - model_config = ConfigDict(from_attributes=True) + + order_id: int = Field(..., description="订单ID") + refund_no: str = Field(..., description="退款单号") + amount: int = Field(..., description="退款金额(分)") + reason: str = Field(..., 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="驳回原因") + status: int = Field(..., description="退款状态") + description: str | None = Field(default=None, description="备注") diff --git a/backend/app/api/v1/module_platform/order/service.py b/backend/app/api/v1/module_platform/order/service.py index 66204111..8d370427 100644 --- a/backend/app/api/v1/module_platform/order/service.py +++ b/backend/app/api/v1/module_platform/order/service.py @@ -46,24 +46,38 @@ def _generate_refund_no() -> str: class OrderService: - @staticmethod - async def create_order(auth: AuthSchema, data: OrderCreateSchema, amount: int | None = None) -> OrderOutSchema: - """创建订单 + """ + 订单管理服务 + """ + + @classmethod + async def create_order(cls, auth: AuthSchema, data: OrderCreateSchema, amount: int | None = None) -> OrderOutSchema: + """ + 创建订单 套餐订单:amount 从套餐价格自动计算 插件订单:amount 从插件价格自动计算 + 免费订单(amount=0):自动激活 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (OrderCreateSchema): 订单创建模型 + - amount (int | None): 订单金额(分),None 时自动计算 + + 返回: + - OrderOutSchema: 新创建的订单详情 """ if amount is None: if data.order_type == "plugin": from app.api.v1.module_platform.plugin.model import PluginModel plugin = await auth.db.get(PluginModel, data.plugin_id) - amount = plugin.price if plugin and hasattr(plugin, "price") else 0 + amount = plugin.price if plugin else 0 else: from app.api.v1.module_platform.package.model import PackageModel pkg = await auth.db.get(PackageModel, data.package_id) - amount = pkg.price if pkg and hasattr(pkg, "price") else 0 + amount = pkg.price if pkg else 0 order = await OrderCRUD(auth).create( OrderCreateInternalSchema( @@ -88,13 +102,35 @@ class OrderService: return OrderOutSchema.model_validate(order) - @staticmethod - async def get_detail(auth: AuthSchema, order_id: int) -> OrderOutSchema | None: + @classmethod + async def get_detail(cls, auth: AuthSchema, order_id: int) -> OrderOutSchema | None: + """ + 订单详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - order_id (int): 订单ID + + 返回: + - OrderOutSchema | None: 订单详情,不存在时返回 None + """ order = await OrderCRUD(auth).get_by_id(order_id) return OrderOutSchema.model_validate(order) if order else None - @staticmethod - async def get_list(auth: AuthSchema, params: OrderQueryParam, offset: int, limit: int) -> tuple[list, int]: + @classmethod + async def get_list(cls, auth: AuthSchema, params: OrderQueryParam, offset: int, limit: int) -> tuple[list, int]: + """ + 订单列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - params (OrderQueryParam): 查询参数 + - offset (int): 偏移量 + - limit (int): 每页数量 + + 返回: + - tuple[list, int]: (订单列表, 总数) + """ rows, total = await OrderCRUD(auth).query( tenant_id=params.tenant_id, status=params.status, @@ -105,20 +141,39 @@ class OrderService: items = [OrderOutSchema.model_validate(r) for r in rows] return items, total - @staticmethod - async def cancel_order(auth: AuthSchema, order_id: int) -> OrderStatusMessage: + @classmethod + async def cancel_order(cls, auth: AuthSchema, order_id: int) -> OrderStatusMessage: + """ + 取消订单 + + 参数: + - auth (AuthSchema): 认证信息模型 + - order_id (int): 订单ID + + 返回: + - OrderStatusMessage: 取消结果 + """ crud = OrderCRUD(auth) order = await crud.get_by_id(order_id) if not order: - raise CustomException(msg="订单不存在") + 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="已取消") - @staticmethod - async def check_payment_status(auth: AuthSchema, order_id: int) -> PaymentStatusOut: - """查询订单支付状态(供前端轮询用)""" + @classmethod + async def check_payment_status(cls, auth: AuthSchema, order_id: int) -> PaymentStatusOut: + """ + 查询订单支付状态(供前端轮询用) + + 参数: + - auth (AuthSchema): 认证信息模型 + - order_id (int): 订单ID + + 返回: + - PaymentStatusOut: 支付状态信息 + """ order = await OrderCRUD(auth).get_by_id(order_id) if not order: return PaymentStatusOut(exists=False) @@ -131,21 +186,41 @@ class OrderService: pay_time=order.pay_time.isoformat() if order.pay_time else None, ) - @staticmethod - async def cancel_expired_orders() -> None: - """定时任务:取消超时未支付的订单""" + @classmethod + async def cancel_expired_orders(cls) -> None: + """ + 定时任务:取消超时未支付的订单 + + 返回: + - None + """ await OrderCRUD.cancel_expired_orders() class PaymentService: - @staticmethod - async def create_payment(auth: AuthSchema, order_id: int, method: str, notify_base_url: str) -> PaymentCreateOut: - """创建支付(调用支付网关)""" + """ + 支付管理服务 + """ + + @classmethod + async def create_payment(cls, auth: AuthSchema, 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/二维码) + """ from app.api.v1.module_platform.package.model import PackageModel order = await OrderCRUD(auth).get_by_id(order_id) if not order: - raise CustomException(msg="订单不存在") + raise CustomException(msg="该数据不存在") if order.status != 0: raise CustomException(msg="订单状态异常,无法支付") if order.amount <= 0: @@ -178,9 +253,19 @@ class PaymentService: amount=order.amount, ) - @staticmethod - async def handle_callback(auth: AuthSchema, method: str, callback_data: dict) -> dict: - """处理支付回调""" + @classmethod + async def handle_callback(cls, auth: AuthSchema, 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) @@ -197,7 +282,7 @@ class PaymentService: order = await o_crud.get_by_id(callback_result.order_id) if not order: - raise CustomException(msg="订单不存在") + raise CustomException(msg="该数据不存在") if order.status != 0: raise CustomException(msg="订单状态异常") if order.amount != callback_result.amount and callback_result.amount > 0: @@ -232,9 +317,18 @@ class PaymentService: logger.info(f"支付回调处理完成: order_id={oid} method={method} tenant_id={tid} type={otype}") return {"order_id": oid, "status": 1, "message": "支付成功"} - @staticmethod - async def _activate_tenant_package(auth: AuthSchema, order: OrderModel) -> None: - """支付成功后激活套餐/插件""" + @classmethod + async def _activate_tenant_package(cls, auth: AuthSchema, order: OrderModel) -> None: + """ + 支付成功后激活套餐 + + 参数: + - auth (AuthSchema): 认证信息模型 + - order (OrderModel): 订单模型 + + 返回: + - None + """ from app.api.v1.module_platform.package.model import PackageModel from app.api.v1.module_platform.tenant.model import TenantModel @@ -281,9 +375,18 @@ class PaymentService: if tenant.contact_email: await PaymentService._send_order_email(order, pkg, tenant) - @staticmethod - async def _activate_plugin(auth: AuthSchema, order: OrderModel) -> None: - """支付成功后标记插件为已购买""" + @classmethod + async def _activate_plugin(cls, auth: AuthSchema, order: OrderModel) -> None: + """ + 支付成功后标记插件为已购买 + + 参数: + - auth (AuthSchema): 认证信息模型 + - order (OrderModel): 订单模型 + + 返回: + - None + """ from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel plugin = await auth.db.get(PluginModel, order.plugin_id) @@ -321,9 +424,19 @@ class PaymentService: if tenant and tenant.contact_email: await PaymentService._send_order_email(order, plugin, tenant, order_type_label="购买") - @staticmethod - async def _check_downgrade_quota(auth: AuthSchema, tenant_id: int, new_pkg: object) -> None: - """降级前检查:租户当前资源数是否超过新套餐限额""" + @classmethod + async def _check_downgrade_quota(cls, auth: AuthSchema, tenant_id: int, new_pkg: object) -> None: + """ + 降级前检查:租户当前资源数是否超过新套餐限额 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - new_pkg (object): 目标套餐 + + 返回: + - None + """ from sqlalchemy import func, select from app.api.v1.module_system.dept.model import DeptModel @@ -352,9 +465,20 @@ class PaymentService: if current > limit: raise CustomException(msg=f"降级失败:当前租户已有 {current} 个{label},超过目标套餐限额 {limit}") - @staticmethod - async def _send_order_email(order: "OrderModel", product: object, tenant: object, order_type_label: str = "") -> None: - """发送购买确认邮件(失败静默降级)""" + @classmethod + async def _send_order_email(cls, order: "OrderModel", product: object, tenant: object, order_type_label: str = "") -> None: + """ + 发送购买确认邮件(失败静默降级) + + 参数: + - order (OrderModel): 订单模型 + - product (object): 商品(套餐/插件) + - tenant (object): 租户模型 + - order_type_label (str): 订单类型文案 + + 返回: + - None + """ try: from app.api.v1.module_platform.email.service import EmailSendService @@ -388,20 +512,46 @@ class PaymentService: except Exception: pass # 邮件发送失败不阻塞业务流程 - @staticmethod - async def get_records(auth: AuthSchema, offset: int, limit: int) -> tuple[list, int]: + @classmethod + async def get_records(cls, auth: AuthSchema, offset: int, limit: int) -> tuple[list, int]: + """ + 支付记录列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - offset (int): 偏移量 + - limit (int): 每页数量 + + 返回: + - tuple[list, int]: (支付记录列表, 总数) + """ rows, total = await PaymentRecordCRUD(auth).query(offset, limit) items = [PaymentRecordOutSchema.model_validate(r) for r in rows] return items, total class RefundService: - @staticmethod - async def apply(auth: AuthSchema, data: RefundApplySchema, order_id: int) -> RefundOutSchema: + """ + 退款管理服务 + """ + + @classmethod + async def apply(cls, auth: AuthSchema, data: RefundApplySchema, order_id: int) -> RefundOutSchema: + """ + 申请退款 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (RefundApplySchema): 退款申请模型 + - order_id (int): 订单ID + + 返回: + - RefundOutSchema: 退款记录详情 + """ o_crud = OrderCRUD(auth) order = await o_crud.get_by_id(order_id) if not order: - raise CustomException(msg="订单不存在") + raise CustomException(msg="该数据不存在") if order.status != 1: raise CustomException(msg="仅已支付订单可退款") if order.amount == 0: @@ -421,18 +571,42 @@ class RefundService: ) return RefundOutSchema.model_validate(refund) - @staticmethod - async def get_list(auth: AuthSchema, status: int | None, offset: int, limit: int) -> tuple[list, int]: + @classmethod + async def get_list(cls, auth: AuthSchema, status: int | None, offset: int, limit: int) -> tuple[list, int]: + """ + 退款列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - status (int | None): 退款状态筛选 + - offset (int): 偏移量 + - limit (int): 每页数量 + + 返回: + - tuple[list, int]: (退款列表, 总数) + """ rows, total = await RefundCRUD(auth).query(status, offset, limit) items = [RefundOutSchema.model_validate(r) for r in rows] return items, total - @staticmethod - async def approve(auth: AuthSchema, refund_id: int, reviewer_id: int, operator_name: str = "") -> OrderStatusMessage: + @classmethod + async def approve(cls, auth: AuthSchema, refund_id: int, reviewer_id: int, operator_name: str = "") -> OrderStatusMessage: + """ + 批准退款 + + 参数: + - auth (AuthSchema): 认证信息模型 + - refund_id (int): 退款ID + - reviewer_id (int): 审核人ID + - operator_name (str): 操作人名称 + + 返回: + - OrderStatusMessage: 审核结果 + """ crud = RefundCRUD(auth) refund = await crud.get_by_id(refund_id) if not refund: - raise CustomException(msg="退款记录不存在") + raise CustomException(msg="该数据不存在") if refund.status != 1: raise CustomException(msg="仅申请中可审核") await crud.update( @@ -442,18 +616,32 @@ class RefundService: await OrderCRUD(auth).mark_refunded(refund.order_id) return OrderStatusMessage(id=refund.id, status=2, message="已批准退款") - @staticmethod + @classmethod async def reject( + cls, auth: AuthSchema, refund_id: int, reviewer_id: int, data: RefundReviewSchema, operator_name: str = "", ) -> OrderStatusMessage: + """ + 驳回退款 + + 参数: + - auth (AuthSchema): 认证信息模型 + - refund_id (int): 退款ID + - reviewer_id (int): 审核人ID + - data (RefundReviewSchema): 驳回原因 + - operator_name (str): 操作人名称 + + 返回: + - OrderStatusMessage: 审核结果 + """ crud = RefundCRUD(auth) refund = await crud.get_by_id(refund_id) if not refund: - raise CustomException(msg="退款记录不存在") + raise CustomException(msg="该数据不存在") if refund.status != 1: raise CustomException(msg="仅申请中可审核") await crud.update( diff --git a/backend/app/api/v1/module_platform/package/model.py b/backend/app/api/v1/module_platform/package/model.py index a82cf320..734d7ea0 100644 --- a/backend/app/api/v1/module_platform/package/model.py +++ b/backend/app/api/v1/module_platform/package/model.py @@ -1,4 +1,4 @@ -from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, validates from app.core.base_model import MappedBase, ModelMixin diff --git a/backend/app/api/v1/module_platform/package/schema.py b/backend/app/api/v1/module_platform/package/schema.py index c21b82a8..47bb5882 100644 --- a/backend/app/api/v1/module_platform/package/schema.py +++ b/backend/app/api/v1/module_platform/package/schema.py @@ -12,8 +12,8 @@ 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:停用)") - sort: int = Field(default=0, ge=0, description="排序") 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="免费试用天数") diff --git a/backend/app/api/v1/module_platform/package/service.py b/backend/app/api/v1/module_platform/package/service.py index 32dad6b0..90df4c87 100644 --- a/backend/app/api/v1/module_platform/package/service.py +++ b/backend/app/api/v1/module_platform/package/service.py @@ -3,6 +3,7 @@ from sqlalchemy import func, select from app.api.v1.module_platform.tenant.model import TenantModel from app.core.base_schema import AuthSchema +from app.core.dependencies import require_superadmin from app.core.exceptions import CustomException from app.core.logger import logger @@ -19,16 +20,27 @@ from .schema import ( class PackageService: - """套餐模块服务层""" + """ + 套餐管理服务(仅超级管理员可操作) + """ @classmethod + @require_superadmin async def detail_service(cls, auth: AuthSchema, id: int) -> PackageOutSchema: - obj = await PackageCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="套餐不存在") - return PackageOutSchema.model_validate(obj) + """ + 套餐详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 套餐ID + + 返回: + - PackageOutSchema: 套餐详情 + """ + return await PackageCRUD(auth).get_or_404(id=id, out_schema=PackageOutSchema, msg="该数据不存在") @classmethod + @require_superadmin async def page_service( cls, auth: AuthSchema, @@ -37,16 +49,40 @@ class PackageService: search: PackageQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> dict: + """ + 分页查询套餐 + + 参数: + - auth (AuthSchema): 认证信息模型 + - page_no (int): 页码 + - page_size (int): 每页数量 + - search (PackageQueryParam | None): 查询参数 + - order_by (list[dict[str, str]] | None): 排序参数 + + 返回: + - dict: 分页数据 + """ return await PackageCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"sort": "asc"}, {"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=PackageOutSchema, ) @classmethod + @require_superadmin async def create_service(cls, auth: AuthSchema, data: PackageCreateSchema) -> PackageOutSchema: + """ + 创建套餐 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (PackageCreateSchema): 套餐创建模型 + + 返回: + - PackageOutSchema: 套餐详情 + """ if await PackageCRUD(auth).get(name=data.name): raise CustomException(msg="创建失败,套餐名称已存在") if await PackageCRUD(auth).get(code=data.code): @@ -58,10 +94,20 @@ class PackageService: return result @classmethod + @require_superadmin async def update_service(cls, auth: AuthSchema, id: int, data: PackageUpdateSchema) -> PackageOutSchema: - obj = await PackageCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="套餐不存在") + """ + 更新套餐 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 套餐ID + - data (PackageUpdateSchema): 套餐更新模型 + + 返回: + - PackageOutSchema: 套餐详情 + """ + obj = await PackageCRUD(auth).get_or_404(id=id) if data.name is not None: exist = await PackageCRUD(auth).get(name=data.name) @@ -81,7 +127,18 @@ class PackageService: return PackageOutSchema.model_validate(updated) @classmethod + @require_superadmin async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 删除套餐 + + 参数: + - auth (AuthSchema): 认证信息模型 + - ids (list[int]): 套餐ID列表 + + 返回: + - None + """ if not ids: raise CustomException(msg="删除失败,删除对象不能为空") @@ -96,7 +153,16 @@ class PackageService: @classmethod async def disable_cascade_service(cls, auth: AuthSchema, package_id: int) -> None: - """套餐禁用时日志记录受影响租户""" + """ + 套餐禁用时日志记录受影响租户 + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + + 返回: + - None + """ stmt = select(TenantModel.id, TenantModel.name).where( TenantModel.package_id == package_id, TenantModel.status == 0, @@ -109,32 +175,68 @@ class PackageService: @classmethod async def get_menus_service(cls, auth: AuthSchema, package_id: int) -> list[int]: - """获取套餐菜单权限(返回 menu_id 列表)""" + """ + 获取套餐菜单权限 + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + + 返回: + - list[int]: menu_id 列表 + """ stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == package_id) result = await auth.db.execute(stmt) return [row[0] for row in result.all()] @classmethod async def set_menus_service(cls, auth: AuthSchema, package_id: int, data: PackageMenuSetSchema) -> None: - """批量设置套餐菜单权限(先清空再写入)""" + """ + 批量设置套餐菜单权限(先清空再写入) + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + - data (PackageMenuSetSchema): 菜单ID列表 + + 返回: + - None + """ await auth.db.execute(sa.delete(PackageMenuModel).where(PackageMenuModel.package_id == package_id)) for menu_id in data.menu_ids: auth.db.add(PackageMenuModel(package_id=package_id, menu_id=menu_id)) await auth.db.flush() logger.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}") - @staticmethod - async def get_package_menu_ids(auth: AuthSchema, package_id: int) -> list[int]: - """获取套餐包含的菜单ID列表""" + @classmethod + async def get_package_menu_ids(cls, auth: AuthSchema, package_id: int) -> list[int]: + """ + 获取套餐包含的菜单ID列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + + 返回: + - list[int]: menu_id 列表 + """ stmt = select(PackageMenuModel.menu_id).where(PackageMenuModel.package_id == package_id) result = await auth.db.execute(stmt) return [row[0] for row in result.all()] - @staticmethod - async def get_tenant_available_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]: - """获取租户的完整可用菜单ID列表(仅从套餐菜单获取) + @classmethod + async def get_tenant_available_menu_ids(cls, auth: AuthSchema, tenant_id: int) -> list[int]: + """ + 获取租户的完整可用菜单ID列表(仅从套餐菜单获取) 平台租户 (id=1) 返回全部启用菜单,不受套餐限制。 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - list[int]: menu_id 列表 """ from app.api.v1.module_platform.menu.model import MenuModel from app.api.v1.module_platform.tenant.model import TenantModel @@ -163,9 +265,18 @@ class PackageService: result = await auth.db.execute(menu_stmt) return [row[0] for row in result.all()] - @staticmethod - async def get_tenant_available_plugin_ids(auth: AuthSchema, tenant_id: int) -> list[int]: - """获取租户套餐可用的插件ID列表""" + @classmethod + async def get_tenant_available_plugin_ids(cls, auth: AuthSchema, tenant_id: int) -> list[int]: + """ + 获取租户套餐可用的插件ID列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - list[int]: plugin_id 列表 + """ from app.api.v1.module_platform.tenant.model import TenantModel stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1) @@ -180,22 +291,40 @@ class PackageService: if pkg_status != 0: return [] - plugin_stmt = select(PackagePluginModel.plugin_id).where(PackagePluginModel.package_id == tenant.package_id) + plugin_stmt = select(PackagePluginModel.plugin_id).where(PackagePluginModel.plugin_id == tenant.package_id) result = await auth.db.execute(plugin_stmt) return [row[0] for row in result.all()] @classmethod async def get_plugins_service(cls, auth: AuthSchema, package_id: int) -> list[int]: - """获取套餐插件权限(返回 plugin_id 列表)""" - stmt = select(PackagePluginModel.plugin_id).where(PackagePluginModel.package_id == package_id) + """ + 获取套餐插件权限 + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + + 返回: + - list[int]: plugin_id 列表 + """ + stmt = select(PackagePluginModel.plugin_id).where(PackagePluginModel.plugin_id == package_id) result = await auth.db.execute(stmt) return [row[0] for row in result.all()] @classmethod async def set_plugins_service(cls, auth: AuthSchema, package_id: int, data: PackagePluginSetSchema) -> None: - """批量设置套餐插件(先清空再写入)""" + """ + 批量设置套餐插件(先清空再写入) + + 参数: + - auth (AuthSchema): 认证信息模型 + - package_id (int): 套餐ID + - data (PackagePluginSetSchema): 插件ID列表 + + 返回: + - None + """ await auth.db.execute(sa.delete(PackagePluginModel).where(PackagePluginModel.package_id == package_id)) for plugin_id in data.plugin_ids: auth.db.add(PackagePluginModel(package_id=package_id, plugin_id=plugin_id)) await auth.db.flush() - logger.info(f"套餐[{package_id}]插件已设置, count={len(data.plugin_ids)}") diff --git a/backend/app/api/v1/module_platform/plugin/controller.py b/backend/app/api/v1/module_platform/plugin/controller.py index 43eaf1f1..ff5ce39d 100644 --- a/backend/app/api/v1/module_platform/plugin/controller.py +++ b/backend/app/api/v1/module_platform/plugin/controller.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import APIRouter, Body, Depends, Path +from fastapi import APIRouter, Body, Depends, Path, Query from fastapi.responses import JSONResponse from fastapi_cache import FastAPICache from fastapi_cache.decorator import cache @@ -27,9 +27,13 @@ _PLUGIN_NS = "plugin" # ───── 超管:插件 CRUD ───── -@PluginRouter.get("/list", summary="插件列表", response_model=ResponseSchema[PageResultSchema[PluginOutSchema]]) +@PluginRouter.get( + "/list", + summary="插件列表", + response_model=ResponseSchema[PageResultSchema[PluginOutSchema]], +) @cache(expire=300, namespace=_PLUGIN_NS) -async def plugin_list( +async def plugin_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[PluginQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:query"]))], @@ -40,16 +44,27 @@ async def plugin_list( 参数: - page (PaginationQueryParam): 分页查询参数。 - search (PluginQueryParam): 查询筛选参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含分页插件列表的 JSON 响应。 """ - r = await PluginService.page_service(auth, page.page_no, page.page_size, search, page.order_by) + r = await PluginService.page_service( + auth=auth, + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by, + ) return SuccessResponse(data=r, msg="查询成功") -@PluginRouter.get("/detail/{id}", summary="插件详情", response_model=ResponseSchema[PluginOutSchema]) -async def plugin_detail( +@PluginRouter.get( + "/detail/{id}", + summary="插件详情", + response_model=ResponseSchema[PluginOutSchema], +) +async def plugin_detail_controller( id: Annotated[int, Path()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:query"]))], ) -> JSONResponse: @@ -58,15 +73,20 @@ async def plugin_detail( 参数: - id (int): 插件 ID。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含插件详情的 JSON 响应。 """ - return SuccessResponse(data=await PluginService.detail_service(auth, id), msg="查询成功") + return SuccessResponse(data=await PluginService.detail_service(auth=auth, id=id), msg="查询成功") -@PluginRouter.post("/create", summary="创建插件", response_model=ResponseSchema[PluginOutSchema]) -async def plugin_create( +@PluginRouter.post( + "/create", + summary="创建插件", + response_model=ResponseSchema[PluginOutSchema], +) +async def plugin_create_controller( data: PluginCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:create"]))], ) -> JSONResponse: @@ -75,17 +95,22 @@ async def plugin_create( 参数: - data (PluginCreateSchema): 插件创建参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含创建后的插件详情的 JSON 响应。 """ - r = await PluginService.create_service(auth, data) + r = await PluginService.create_service(auth=auth, data=data) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(data=r, msg="创建成功") -@PluginRouter.put("/update/{id}", summary="更新插件", response_model=ResponseSchema[PluginOutSchema]) -async def plugin_update( +@PluginRouter.put( + "/update/{id}", + summary="更新插件", + response_model=ResponseSchema[PluginOutSchema], +) +async def plugin_update_controller( id: Annotated[int, Path()], data: PluginUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:update"]))], @@ -96,17 +121,22 @@ async def plugin_update( 参数: - id (int): 插件 ID。 - data (PluginUpdateSchema): 插件更新参数。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含更新后的插件详情的 JSON 响应。 """ - r = await PluginService.update_service(auth, id, data) + r = await PluginService.update_service(auth=auth, id=id, data=data) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(data=r, msg="更新成功") -@PluginRouter.delete("/delete", summary="删除插件", response_model=ResponseSchema[None]) -async def plugin_delete( +@PluginRouter.delete( + "/delete", + summary="删除插件", + response_model=ResponseSchema[None], +) +async def plugin_delete_controller( ids: Annotated[list[int], Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:delete"]))], ) -> JSONResponse: @@ -115,11 +145,12 @@ async def plugin_delete( 参数: - ids (list[int]): 插件 ID 列表。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 删除结果。 """ - await PluginService.delete_service(auth, ids) + await PluginService.delete_service(auth=auth, ids=ids) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="删除成功") @@ -127,12 +158,16 @@ async def plugin_delete( # ───── 租户:插件市场 ───── -@PluginRouter.get("/marketplace", summary="插件市场", response_model=ResponseSchema[PageResultSchema[PluginOutSchema]]) +@PluginRouter.get( + "/marketplace", + summary="插件市场", + response_model=ResponseSchema[PageResultSchema[PluginOutSchema]], +) @cache(expire=600, namespace=_PLUGIN_NS) -async def marketplace( +async def plugin_marketplace_controller( + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:query"]))], page: Annotated[PaginationQueryParam, Depends()], - category: str | None = None, - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:query"]))] = None, + category: Annotated[str | None, Query(description="分类筛选")] = None, ) -> JSONResponse: """ 插件市场 @@ -140,16 +175,21 @@ async def marketplace( 参数: - page (PaginationQueryParam): 分页查询参数。 - category (str | None): 分类筛选。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 包含分页市场插件列表的 JSON 响应。 """ - r = await PluginService.marketplace_service(auth, page.page_no, page.page_size, category) + r = await PluginService.marketplace_service(auth=auth, page_no=page.page_no, page_size=page.page_size, category=category) return SuccessResponse(data=r, msg="查询成功") -@PluginRouter.post("/install", summary="安装插件", response_model=ResponseSchema[None]) -async def plugin_install( +@PluginRouter.post( + "/install", + summary="安装插件", + response_model=ResponseSchema[None], +) +async def plugin_install_controller( data: PluginInstallSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:install"]))], ) -> JSONResponse: @@ -158,17 +198,22 @@ async def plugin_install( 参数: - data (PluginInstallSchema): 安装参数(插件 ID)。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 安装结果。 """ - await PluginService.install_service(auth, data.plugin_id) + await PluginService.install_service(auth=auth, plugin_id=data.plugin_id) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="安装成功") -@PluginRouter.post("/uninstall", summary="卸载插件", response_model=ResponseSchema[None]) -async def plugin_uninstall( +@PluginRouter.post( + "/uninstall", + summary="卸载插件", + response_model=ResponseSchema[None], +) +async def plugin_uninstall_controller( data: PluginInstallSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:uninstall"]))], ) -> JSONResponse: @@ -177,17 +222,22 @@ async def plugin_uninstall( 参数: - data (PluginInstallSchema): 卸载参数(插件 ID)。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 卸载结果。 """ - await PluginService.uninstall_service(auth, data.plugin_id) + await PluginService.uninstall_service(auth=auth, plugin_id=data.plugin_id) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="卸载成功") -@PluginRouter.post("/toggle", summary="启用/禁用插件", response_model=ResponseSchema[None]) -async def plugin_toggle( +@PluginRouter.post( + "/toggle", + summary="启用/禁用插件", + response_model=ResponseSchema[None], +) +async def plugin_toggle_controller( data: PluginInstallSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:toggle"]))], ) -> JSONResponse: @@ -196,31 +246,43 @@ async def plugin_toggle( 参数: - data (PluginInstallSchema): 操作参数(插件 ID)。 + - auth (AuthSchema): 认证信息模型。 返回: - JSONResponse: 操作结果。 """ - await PluginService.toggle_service(auth, data.plugin_id) + await PluginService.toggle_service(auth=auth, plugin_id=data.plugin_id) await FastAPICache.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="操作成功") -@PluginRouter.get("/my", summary="我的插件", response_model=ResponseSchema[list[PluginOutSchema]]) +@PluginRouter.get( + "/my", + summary="我的插件", + response_model=ResponseSchema[list[PluginOutSchema]], +) @cache(expire=120, namespace=_PLUGIN_NS) -async def my_plugins( +async def plugin_my_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:query"]))], ) -> JSONResponse: """ 我的插件 + 参数: + - auth (AuthSchema): 认证信息模型。 + 返回: - JSONResponse: 包含已安装插件列表的 JSON 响应。 """ - return SuccessResponse(data=await PluginService.my_plugins_service(auth), msg="查询成功") + return SuccessResponse(data=await PluginService.my_plugins_service(auth=auth), msg="查询成功") -@PluginRouter.post("/reload", summary="热重载插件路由", response_model=ResponseSchema[str]) -async def plugin_reload( +@PluginRouter.post( + "/reload", + summary="热重载插件路由", + response_model=ResponseSchema[str], +) +async def plugin_reload_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:reload"]))], ) -> JSONResponse: """ @@ -228,6 +290,12 @@ async def plugin_reload( 重新扫描 app/plugin/module_* 目录,清除模块缓存并注册新路由, 无需重启服务器。 + + 参数: + - auth (AuthSchema): 认证信息模型。 + + 返回: + - JSONResponse: 包含重载结果的 JSON 响应。 """ msg = PluginService.reload_service() await FastAPICache.clear(namespace=_PLUGIN_NS) diff --git a/backend/app/api/v1/module_platform/plugin/model.py b/backend/app/api/v1/module_platform/plugin/model.py index a91c1933..58ad229e 100644 --- a/backend/app/api/v1/module_platform/plugin/model.py +++ b/backend/app/api/v1/module_platform/plugin/model.py @@ -1,4 +1,4 @@ -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, validates from app.core.base_model import MappedBase, ModelMixin @@ -49,6 +49,6 @@ class TenantPluginModel(MappedBase): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID") tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("platform_tenant.id", ondelete="CASCADE"), nullable=False, index=True, comment="租户ID") plugin_id: Mapped[int] = mapped_column(Integer, ForeignKey("platform_plugin.id", ondelete="CASCADE"), nullable=False, index=True, comment="插件ID") - enabled: Mapped[str] = mapped_column(String(1), nullable=False, default="1", comment="启用(1:启用 0:禁用)") - purchased: Mapped[str] = mapped_column(String(1), nullable=False, default="0", comment="是否已购买(1:已购买 0:未购买)") + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="启用(True:启用 False:禁用)") + purchased: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否已购买(True:已购买 False:未购买)") installed_time: Mapped[DateTime] = mapped_column(DateTime, nullable=False, comment="安装时间") diff --git a/backend/app/api/v1/module_platform/plugin/schema.py b/backend/app/api/v1/module_platform/plugin/schema.py index 8b558d64..3e6461df 100644 --- a/backend/app/api/v1/module_platform/plugin/schema.py +++ b/backend/app/api/v1/module_platform/plugin/schema.py @@ -1,3 +1,6 @@ +from dataclasses import dataclass + +from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator from app.common.enums import QueueEnum @@ -10,7 +13,6 @@ class PluginCreateSchema(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="插件名称") code: str = Field(..., min_length=1, max_length=50, description="插件编码(如 module_xxx)") - description: str | None = Field(default=None, max_length=255, description="插件描述") version: str = Field(default="1.0.0", max_length=20, description="版本号") author: str | None = Field(default=None, max_length=100, description="作者") icon: str | None = Field(default=None, max_length=500, description="图标URL") @@ -21,6 +23,7 @@ class PluginCreateSchema(BaseModel): dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)") sort: int = Field(default=0, ge=0, description="排序") status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)") + description: str | None = Field(default=None, max_length=255, description="插件描述") @field_validator("category") @classmethod @@ -79,13 +82,14 @@ class PluginInstallSchema(BaseModel): plugin_id: int = Field(..., description="插件ID") +@dataclass class PluginQueryParam(BaseQueryParam): """插件查询参数""" def __init__( self, - name: str | None = None, - category: str | None = None, + name: str | None = Query(None, description="插件名称"), + category: str | None = Query(None, description="插件分类(tool/ai/monitor/business)"), *args, **kwargs, ) -> None: diff --git a/backend/app/api/v1/module_platform/plugin/service.py b/backend/app/api/v1/module_platform/plugin/service.py index b2f86b69..f2e5486b 100644 --- a/backend/app/api/v1/module_platform/plugin/service.py +++ b/backend/app/api/v1/module_platform/plugin/service.py @@ -3,6 +3,7 @@ from datetime import datetime import sqlalchemy as sa from app.core.base_schema import AuthSchema +from app.core.dependencies import require_superadmin from app.core.exceptions import CustomException from app.core.logger import logger @@ -12,7 +13,12 @@ from .schema import PluginCreateSchema, PluginOutSchema, PluginQueryParam, Plugi class PluginService: + """ + 插件管理服务(仅超级管理员可操作 CRUD,租户通过 marketplace/install/uninstall/toggle/my 操作) + """ + @classmethod + @require_superadmin async def page_service( cls, auth: AuthSchema, @@ -21,44 +27,109 @@ class PluginService: search: PluginQueryParam | None = None, order_by: list | None = None, ) -> dict: + """ + 分页查询插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - page_no (int): 页码 + - page_size (int): 每页数量 + - search (PluginQueryParam | None): 查询参数 + - order_by (list | None): 排序参数 + + 返回: + - dict: 分页数据 + """ return await PluginCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"sort": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=PluginOutSchema, ) @classmethod + @require_superadmin async def detail_service(cls, auth: AuthSchema, id: int) -> PluginOutSchema: - obj = await PluginCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="插件不存在") - return PluginOutSchema.model_validate(obj) + """ + 插件详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 插件ID + + 返回: + - PluginOutSchema: 插件详情 + """ + return await PluginCRUD(auth).get_or_404(id=id, out_schema=PluginOutSchema) @classmethod + @require_superadmin async def create_service(cls, auth: AuthSchema, data: PluginCreateSchema) -> PluginOutSchema: + """ + 创建插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (PluginCreateSchema): 插件创建模型 + + 返回: + - PluginOutSchema: 插件详情 + """ if await PluginCRUD(auth).get(code=data.code): - raise CustomException(msg="插件编码已存在") + raise CustomException(msg="创建失败,插件编码已存在") obj = await PluginCRUD(auth).create(data=data) return PluginOutSchema.model_validate(obj) @classmethod + @require_superadmin async def update_service(cls, auth: AuthSchema, id: int, data: PluginUpdateSchema) -> PluginOutSchema: - obj = await PluginCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="插件不存在") + """ + 更新插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 插件ID + - data (PluginUpdateSchema): 插件更新模型 + + 返回: + - PluginOutSchema: 插件详情 + """ + _ = await PluginCRUD(auth).get_or_404(id=id) updated = await PluginCRUD(auth).update(id=id, data=data) return PluginOutSchema.model_validate(updated) @classmethod + @require_superadmin async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 删除插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - ids (list[int]): 插件ID列表 + + 返回: + - None + """ await PluginCRUD(auth).delete(ids=ids) # ───── 插件市场 API ───── @classmethod async def marketplace_service(cls, auth: AuthSchema, page_no: int, page_size: int, category: str | None = None) -> dict: + """ + 插件市场列表(租户端) + + 参数: + - auth (AuthSchema): 认证信息模型 + - page_no (int): 页码 + - page_size (int): 每页数量 + - category (str | None): 分类筛选 + + 返回: + - dict: 分页数据(含租户的 installed/purchased 标记) + """ search = {} if category: search["category"] = ("eq", category) @@ -81,11 +152,21 @@ class PluginService: for item in result.items: pid = item["id"] item["installed"] = pid in record_map - item["purchased"] = record_map.get(pid, "0") == "1" + item["purchased"] = record_map.get(pid, False) return result @classmethod async def install_service(cls, auth: AuthSchema, plugin_id: int) -> None: + """ + 安装插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - plugin_id (int): 插件ID + + 返回: + - None + """ tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id if not tenant_id: raise CustomException(msg="无法获取租户信息") @@ -101,7 +182,7 @@ class PluginService: plugin = await PluginCRUD(auth).get(id=plugin_id) if not plugin or plugin.status == 1: - raise CustomException(msg="插件不可用") + raise CustomException(msg="该数据不存在") # 付费插件需要先购买 if tenant_id != 1 and getattr(plugin, "price", 0) > 0: @@ -114,7 +195,7 @@ class PluginService: .limit(1) ) tp_record = exist.scalar_one_or_none() - if not tp_record or tp_record.purchased != "1": + if not tp_record or not tp_record.purchased: raise CustomException(msg="此插件为付费插件,请先购买后再安装") exist = await auth.db.execute( @@ -132,14 +213,14 @@ class PluginService: TenantPluginModel.tenant_id == tenant_id, TenantPluginModel.plugin_id == plugin_id, ) - .values(enabled="0") + .values(enabled=False) ) else: tp = TenantPluginModel( tenant_id=tenant_id, plugin_id=plugin_id, - enabled="0", - purchased="1" if getattr(plugin, "price", 0) == 0 else "0", + enabled=False, + purchased=True if getattr(plugin, "price", 0) == 0 else False, installed_time=datetime.now(), ) auth.db.add(tp) @@ -148,6 +229,16 @@ class PluginService: @classmethod async def uninstall_service(cls, auth: AuthSchema, plugin_id: int) -> None: + """ + 卸载插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - plugin_id (int): 插件ID + + 返回: + - None + """ tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id if not tenant_id: raise CustomException(msg="无法获取租户信息") @@ -162,6 +253,16 @@ class PluginService: @classmethod async def toggle_service(cls, auth: AuthSchema, plugin_id: int) -> None: + """ + 启用/禁用插件 + + 参数: + - auth (AuthSchema): 认证信息模型 + - plugin_id (int): 插件ID + + 返回: + - None + """ tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id tp = await auth.db.execute( sa.select(TenantPluginModel) @@ -174,12 +275,21 @@ class PluginService: tp = tp.scalar_one_or_none() if not tp: raise CustomException(msg="未安装该插件") - tp.enabled = "1" if tp.enabled == "0" else "0" + tp.enabled = not tp.enabled await auth.db.flush() logger.info(f"租户[{tenant_id}]插件[{plugin_id}]状态→{tp.enabled}") @classmethod async def my_plugins_service(cls, auth: AuthSchema) -> list[dict]: + """ + 查询我的插件列表(租户端) + + 参数: + - auth (AuthSchema): 认证信息模型 + + 返回: + - list[dict]: 已安装插件列表 + """ tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id if not tenant_id: return [] diff --git a/backend/app/api/v1/module_platform/self_service/controller.py b/backend/app/api/v1/module_platform/self_service/controller.py index 0172f3a4..9af98d9e 100644 --- a/backend/app/api/v1/module_platform/self_service/controller.py +++ b/backend/app/api/v1/module_platform/self_service/controller.py @@ -3,6 +3,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query +from fastapi.responses import JSONResponse from app.common.response import ResponseSchema, SuccessResponse from app.core.base_params import PaginationQueryParam @@ -20,14 +21,7 @@ from .schema import ( SelfOrderOut, WorkspaceOut, ) -from .service import ( - create_plugin_purchase_order, - create_self_order, - get_available_packages, - get_self_order_detail, - get_self_order_list, - preview_package_change, -) +from .service import SelfService TenantSelfServiceRouter = APIRouter( route_class=OperationLogRoute, @@ -41,16 +35,16 @@ TenantSelfServiceRouter = APIRouter( summary="可选套餐列表", response_model=ResponseSchema[PackageAvailableOut], ) -async def package_available( +async def package_available_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:package:query"]))], -): +) -> JSONResponse: """ 可选套餐列表 返回: - - SuccessResponse: 包含可用套餐列表的 JSON 响应。 + - JSONResponse: 包含可用套餐列表的 JSON 响应。 """ - result = await get_available_packages(auth=auth, tenant_id=auth.tenant_id) + result = await SelfService.get_available_packages(auth=auth, tenant_id=auth.tenant_id) return SuccessResponse(data=result, msg="查询成功") @@ -59,10 +53,10 @@ async def package_available( summary="套餐变更影响预览", response_model=ResponseSchema[PackagePreviewOut], ) -async def package_preview( +async def package_preview_controller( target_package_id: Annotated[int, Query(ge=1, description="目标套餐ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:package:query"]))], -): +) -> JSONResponse: """ 套餐变更影响预览 @@ -70,9 +64,9 @@ async def package_preview( - target_package_id (int): 目标套餐 ID。 返回: - - SuccessResponse: 包含套餐变更影响预览的 JSON 响应。 + - JSONResponse: 包含套餐变更影响预览的 JSON 响应。 """ - result = await preview_package_change(auth=auth, tenant_id=auth.tenant_id, target_package_id=target_package_id) + result = await SelfService.preview_package_change(auth=auth, tenant_id=auth.tenant_id, target_package_id=target_package_id) return SuccessResponse(data=result, msg="查询成功") @@ -81,10 +75,10 @@ async def package_preview( summary="创建自助订单", response_model=ResponseSchema[SelfOrderOut], ) -async def order_create( +async def order_create_controller( data: SelfOrderCreate, auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:create"]))], -): +) -> JSONResponse: """ 创建自助订单 @@ -92,9 +86,9 @@ async def order_create( - data (SelfOrderCreate): 订单创建参数。 返回: - - SuccessResponse: 包含订单详情的 JSON 响应。 + - JSONResponse: 包含订单详情的 JSON 响应。 """ - result = await create_self_order(auth=auth, tenant_id=auth.tenant_id, data=data) + result = await SelfService.create_self_order(auth=auth, tenant_id=auth.tenant_id, data=data) return SuccessResponse(data=result, msg="订单创建成功") @@ -103,10 +97,10 @@ async def order_create( summary="购买付费插件", response_model=ResponseSchema[SelfOrderOut], ) -async def plugin_purchase( +async def plugin_purchase_controller( data: PluginPurchaseCreate, auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:create"]))], -): +) -> JSONResponse: """ 购买付费插件 @@ -114,9 +108,9 @@ async def plugin_purchase( - data (PluginPurchaseCreate): 插件购买参数。 返回: - - SuccessResponse: 包含订单详情的 JSON 响应。 + - JSONResponse: 包含订单详情的 JSON 响应。 """ - result = await create_plugin_purchase_order(auth=auth, tenant_id=auth.tenant_id, data=data) + result = await SelfService.create_plugin_purchase_order(auth=auth, tenant_id=auth.tenant_id, data=data) return SuccessResponse(data=result, msg="插件订单创建成功") @@ -125,10 +119,10 @@ async def plugin_purchase( summary="我的订单列表", response_model=ResponseSchema[SelfOrderListOut], ) -async def order_list( +async def order_list_controller( page: Annotated[PaginationQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:query"]))], -): +) -> JSONResponse: """ 我的订单列表 @@ -136,9 +130,9 @@ async def order_list( - page (PaginationQueryParam): 分页查询参数。 返回: - - SuccessResponse: 包含分页订单列表的 JSON 响应。 + - JSONResponse: 包含分页订单列表的 JSON 响应。 """ - result = await get_self_order_list( + result = await SelfService.get_self_order_list( auth=auth, tenant_id=auth.tenant_id, page_no=page.page_no, @@ -153,10 +147,10 @@ async def order_list( summary="订单详情", response_model=ResponseSchema[SelfOrderDetailOut], ) -async def order_detail( +async def order_detail_controller( order_id: Annotated[int, Path(ge=1, description="订单ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["tenant:order:query"]))], -): +) -> JSONResponse: """ 订单详情 @@ -164,9 +158,9 @@ async def order_detail( - order_id (int): 订单 ID。 返回: - - SuccessResponse: 包含订单详情的 JSON 响应。 + - JSONResponse: 包含订单详情的 JSON 响应。 """ - result = await get_self_order_detail(auth=auth, tenant_id=auth.tenant_id, order_id=order_id) + result = await SelfService.get_self_order_detail(auth=auth, tenant_id=auth.tenant_id, order_id=order_id) return SuccessResponse(data=result, msg="查询成功") @@ -175,16 +169,14 @@ async def order_detail( summary="租户工作台概览", response_model=ResponseSchema[WorkspaceOut], ) -async def tenant_workspace( +async def tenant_workspace_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], -): +) -> JSONResponse: """ 租户工作台概览 返回: - - SuccessResponse: 包含工作台概览信息的 JSON 响应。 + - JSONResponse: 包含工作台概览信息的 JSON 响应。 """ - from .service import get_workspace_data - - result = await get_workspace_data(auth=auth, tenant_id=auth.tenant_id) + result = await SelfService.get_workspace_data(auth=auth, tenant_id=auth.tenant_id) return SuccessResponse(data=result, msg="查询成功") diff --git a/backend/app/api/v1/module_platform/self_service/schema.py b/backend/app/api/v1/module_platform/self_service/schema.py index 90c808d2..70de6b38 100644 --- a/backend/app/api/v1/module_platform/self_service/schema.py +++ b/backend/app/api/v1/module_platform/self_service/schema.py @@ -1,179 +1,231 @@ """租户自助服务 Schema""" -from pydantic import BaseModel, Field +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.common.enums import OrderTypeEnum + +OrderType = OrderTypeEnum # 兼容旧代码的类型别名 +PackageAction = Literal["buy", "renew", "upgrade", "downgrade"] +PayMethod = Literal["alipay", "wxpay", "free"] class PackageAvailableItem(BaseModel): - """可选套餐项""" + """ + 可选套餐项 + """ - id: int - name: str - price: int # 分 - period: str # month/year - trial_days: int = 0 - max_users: int = 0 - max_roles: int = 0 - max_depts: int = 0 - max_storage_mb: int = 0 - description: str | None = None - is_current: bool = False - available_actions: list[str] = [] # [buy, renew, upgrade, downgrade] + 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): - """可选套餐列表""" + """ + 可选套餐列表 + """ - current_package_id: int | None = None - packages: list[PackageAvailableItem] + model_config = ConfigDict(from_attributes=True) - -class PackagePreviewQuery(BaseModel): - """套餐变更预览请求""" - - target_package_id: int = Field(..., ge=1) + current_package_id: int | None = Field(default=None, description="当前套餐ID") + packages: list[PackageAvailableItem] = Field(default_factory=list, description="可选套餐列表") class PackagePreviewOut(BaseModel): - """套餐变更预览结果""" + """ + 套餐变更预览结果 + """ - current_package: str = "" - target_package: str = "" - action: str = "" # upgrade/downgrade/buy/renew - amount: int = 0 # 分 - period: str = "" - gained_menus: list[dict] = [] - lost_menus: list[dict] = [] - affected_roles: list[str] = [] - affected_users: int = 0 + 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) - order_type: str = Field(..., pattern="^(buy|renew|upgrade|downgrade)$") + package_id: int = Field(..., ge=1, description="套餐ID") + order_type: PackageAction = Field(..., description="订单类型(buy/renew/upgrade/downgrade)") class PluginPurchaseCreate(BaseModel): - """插件购买""" + """ + 插件购买 + """ plugin_id: int = Field(..., ge=1, description="插件ID") - pay_method: str | None = Field(default=None, pattern=r"^(alipay|wxpay)?$") - - -# ─── 订单 ─── + pay_method: PayMethod | None = Field(default=None, description="支付方式(alipay/wxpay/free)") class SelfOrderOut(BaseModel): - """自助订单创建结果""" + """ + 自助订单创建结果 + """ - order_id: int - order_no: str - amount: int - need_pay: bool + 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): - """我的订单列表项""" + """ + 我的订单列表项 + """ - id: int - order_no: str - package_name: str = "" - order_type: str - amount: int - status: int - pay_method: str | None = None - pay_time: str | None = None - created_at: str | None = None + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="订单ID") + order_no: str = Field(..., description="订单号") + package_name: str = Field(default="", description="套餐名称") + order_type: OrderType = 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): - """我的订单列表""" + """ + 我的订单列表 + """ - items: list[SelfOrderListItem] - total: int - page_no: int - page_size: int + 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): - """订单详情""" + """ + 订单详情 + """ - id: int - order_no: str - package_id: int | None = None - package_name: str = "" - amount: int - order_type: str - status: int - pay_method: str | None = None - pay_time: str | None = None - created_at: str | None = None + 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: OrderType = 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): - """工作台-租户信息""" + """ + 工作台-租户信息 + """ - id: int - name: str - code: str - status: int - status_label: str - start_time: str | None = None - end_time: str | None = None - days_remaining: int = 0 + 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): - """工作台-套餐信息""" + """ + 工作台-套餐信息 + """ - id: int - name: str - price: int - period: str - max_users: int - max_roles: int - max_depts: int + 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): - """工作台-用量百分比""" + """ + 工作台-用量百分比 + """ - users: float = 0 - roles: float = 0 - depts: float = 0 + 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): - """工作台-配额用量""" + """ + 工作台-配额用量 + """ - max_users: int = 0 - max_roles: int = 0 - max_depts: int = 0 - current_users: int = 0 - current_roles: int = 0 - current_depts: int = 0 - usage_percent: WorkspaceUsagePercent = Field(default_factory=WorkspaceUsagePercent) + 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): - """工作台-近期订单项""" + """ + 工作台-近期订单项 + """ - id: int - order_no: str - amount: int - order_type: str - status: int - created_at: str | None = None + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="订单ID") + order_no: str = Field(..., description="订单号") + amount: int = Field(..., ge=0, description="订单金额(分)") + order_type: OrderType = Field(..., description="订单类型") + status: int = Field(..., description="订单状态(0:待支付 1:已支付 2:已取消 3:已退款)") + created_at: str | None = Field(default=None, description="创建时间") class WorkspaceOut(BaseModel): - """工作台概览""" + """ + 工作台概览 + """ - tenant: WorkspaceTenantInfo - package: WorkspacePackageInfo | None = None - quota: WorkspaceQuotaInfo - recent_orders: list[WorkspaceOrderItem] = [] + 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/self_service/service.py b/backend/app/api/v1/module_platform/self_service/service.py index 6144ccb7..2f66253c 100644 --- a/backend/app/api/v1/module_platform/self_service/service.py +++ b/backend/app/api/v1/module_platform/self_service/service.py @@ -5,9 +5,24 @@ from datetime import datetime, timedelta from sqlalchemy import func, select 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, + OrderCreateSchema, + OrderUpdateInternalSchema, +) +from app.api.v1.module_platform.order.service import ( + OrderService, + PaymentService, + _generate_order_no, +) from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel +from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel 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.role.model import RoleModel +from app.api.v1.module_system.user.model import UserModel from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from app.core.logger import logger @@ -31,420 +46,484 @@ from .schema import ( ) -async def get_available_packages(auth: AuthSchema, tenant_id: int) -> PackageAvailableOut: - """获取可选套餐列表""" - tenant = await auth.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 auth.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 auth.db.execute(stmt) - packages = result.scalars().all() - - items: list[PackageAvailableItem] = [] - for pkg in packages: - is_current = pkg.id == current_pkg_id - actions: list[str] = [] - 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, - ) - - -async def preview_package_change(auth: AuthSchema, tenant_id: int, target_package_id: int) -> PackagePreviewOut: - """套餐变更影响预览""" - tenant = await auth.db.get(TenantModel, tenant_id) - current_pkg_id = tenant.package_id if tenant else None - - target_pkg = await auth.db.get(PackageModel, target_package_id) - if not target_pkg: - raise CustomException(msg="目标套餐不存在") - - current_pkg = None - if current_pkg_id: - current_pkg = await auth.db.get(PackageModel, current_pkg_id) - - # 获取当前套餐的菜单 - current_menus = set() - if current_pkg_id: - cm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == current_pkg_id) - result = await auth.db.execute(cm_stmt) - current_menus = {m.id for m in result.scalars().all()} - - # 获取目标套餐的菜单 - tm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == target_package_id) - result = await auth.db.execute(tm_stmt) - target_menus = {m.id for m in result.scalars().all()} - - # 计算差异 - gained_menus, lost_menus = [], [] - if current_menus: - lost_ids = current_menus - target_menus - if lost_ids: - lost_stmt = select(MenuModel).where(MenuModel.id.in_(lost_ids)) - lost = await auth.db.execute(lost_stmt) - lost_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in lost.scalars().all()] - - gained_ids = target_menus - (current_menus or set()) - if gained_ids: - gain_stmt = select(MenuModel).where(MenuModel.id.in_(gained_ids)) - gained = await auth.db.execute(gain_stmt) - gained_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in gained.scalars().all()] - - # 确定操作类型 - if current_pkg_id is None: - action = "buy" - elif current_pkg and target_pkg.price > current_pkg.price: - action = "upgrade" - elif current_pkg and target_pkg.price < current_pkg.price: - action = "downgrade" - else: - action = "renew" - - # 受影响角色 - affected_roles = [] - if current_pkg_id: - role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id) - r_result = await auth.db.execute(role_stmt) - affected_roles = [r.name for r in r_result.scalars().all()] - - return PackagePreviewOut( - current_package=current_pkg.name if current_pkg else "", - target_package=target_pkg.name, - action=action, - amount=target_pkg.price if hasattr(target_pkg, "price") else 0, - period=target_pkg.period if hasattr(target_pkg, "period") else "month", - gained_menus=gained_menus, - lost_menus=lost_menus, - affected_roles=affected_roles, - affected_users=0, - ) - - -async def create_self_order(auth: AuthSchema, tenant_id: int, data: SelfOrderCreate) -> SelfOrderOut: - """创建自助订单""" - tenant = await auth.db.get(TenantModel, tenant_id) - if not tenant: - raise CustomException(msg="租户不存在") - if tenant.status not in (0, 1, 2): - raise CustomException(msg="租户状态不允许操作") - - pkg = await auth.db.get(PackageModel, data.package_id) - if not pkg or pkg.status == 1: - raise CustomException(msg="套餐不可用") - - amount = pkg.price if hasattr(pkg, "price") else 0 - - from app.api.v1.module_platform.order.crud import OrderCRUD - from app.api.v1.module_platform.order.schema import ( - OrderCreateInternalSchema, - OrderUpdateInternalSchema, - ) - from app.api.v1.module_platform.order.service import _generate_order_no - - order = await OrderCRUD(auth).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 auth.db.flush() - - # 免费订单自动激活 - if amount == 0: - await OrderCRUD(auth).update( - order.id, - OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()), - ) - from app.api.v1.module_platform.order.service import PaymentService - - await PaymentService._activate_tenant_package(auth, 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, - ) - - -async def create_plugin_purchase_order(auth: AuthSchema, tenant_id: int, data: PluginPurchaseCreate) -> SelfOrderOut: - """创建插件购买订单(复用套餐订单流程)""" - from app.api.v1.module_platform.order.schema import OrderCreateSchema - from app.api.v1.module_platform.order.service import OrderService - from app.api.v1.module_platform.plugin.model import PluginModel, TenantPluginModel - - # 校验插件存在且可用 - plugin = await auth.db.get(PluginModel, data.plugin_id) - if not plugin or plugin.status == 1: - raise CustomException(msg="插件不可用") - - if getattr(plugin, "price", 0) == 0: - raise CustomException(msg="免费插件可直接安装,无需购买") - - # 校验未重复购买 - existing = await auth.db.execute( - select(TenantPluginModel) - .where( - TenantPluginModel.tenant_id == tenant_id, - TenantPluginModel.plugin_id == data.plugin_id, - ) - .limit(1) - ) - tp = existing.scalar_one_or_none() - if tp and tp.purchased == "1": - raise CustomException(msg="您已购买此插件,可直接安装") - - order_data = OrderCreateSchema( - tenant_id=tenant_id, - package_id=None, - plugin_id=data.plugin_id, - order_type="plugin", - pay_method=data.pay_method, - ) - result = await OrderService.create_order(auth, order_data) - - logger.info(f"插件购买订单创建: order_no={result.order_no} plugin={plugin.name}") - return SelfOrderOut( - order_id=result.id, - order_no=result.order_no, - amount=result.amount, - need_pay=result.amount > 0, - ) - - -async def get_self_order_list( - auth: AuthSchema, - tenant_id: int, - page_no: int = 1, - page_size: int = 20, - order_by: list[dict] | None = None, -) -> SelfOrderListOut: - """我的订单列表""" - from sqlalchemy import func as sa_func - - from app.api.v1.module_platform.order.model import OrderModel - - stmt = select(OrderModel).where(OrderModel.tenant_id == tenant_id).order_by(OrderModel.created_time.desc()).offset((page_no - 1) * page_size).limit(page_size) - result = await auth.db.execute(stmt) - orders = result.scalars().all() - - # 查总数 - total = (await auth.db.execute(select(sa_func.count(OrderModel.id)).where(OrderModel.tenant_id == tenant_id))).scalar() or 0 - - # 批量查询关联套餐,避免 N+1 - package_ids = [o.package_id for o in orders if o.package_id] - pkg_map: dict[int, str] = {} - if package_ids: - pkg_result = await auth.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 orders: - pkg_name = pkg_map.get(o.package_id, "") if 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=total, - page_no=page_no, - page_size=page_size, - ) - - -async def get_self_order_detail(auth: AuthSchema, tenant_id: int, order_id: int) -> SelfOrderDetailOut: - """订单详情""" - from app.api.v1.module_platform.order.model import OrderModel - - stmt = select(OrderModel).where( - OrderModel.id == order_id, - OrderModel.tenant_id == tenant_id, - ) - result = await auth.db.execute(stmt) - order = result.scalar_one_or_none() - if not order: - raise CustomException(msg="订单不存在") - - pkg_name = "" - if order.package_id: - p = await auth.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=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, - ) - - -async def get_workspace_data(auth: AuthSchema, tenant_id: int) -> WorkspaceOut: - """获取租户工作台概览数据 - - 聚合返回:租户信息、当前套餐、配额用量、近期订单。 +class SelfService: + """ + 租户自助服务 """ - from app.api.v1.module_platform.package.model import PackageModel - 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.role.model import RoleModel - from app.api.v1.module_system.user.model import UserModel - tenant = await auth.db.get(TenantModel, tenant_id) - if not tenant: - return WorkspaceOut( - tenant=WorkspaceTenantInfo(id=0, name="", code="", status=0, status_label="未知"), - quota=WorkspaceQuotaInfo(), + @classmethod + async def get_available_packages(cls, auth: AuthSchema, tenant_id: int) -> PackageAvailableOut: + """ + 获取可选套餐列表 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - PackageAvailableOut: 可选套餐列表 + """ + tenant = await auth.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 auth.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 auth.db.execute(stmt) + packages = result.scalars().all() + + items: list[PackageAvailableItem] = [] + for pkg in packages: + is_current = pkg.id == current_pkg_id + actions: list[str] = [] + 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, ) - # ── 套餐 ── - package = None - if tenant.package_id: - package = await auth.db.get(PackageModel, tenant.package_id) + @classmethod + async def preview_package_change(cls, auth: AuthSchema, tenant_id: int, target_package_id: int) -> PackagePreviewOut: + """ + 套餐变更影响预览 - # ── 用量计数 ── - async def _count(model_cls) -> int: - stmt = ( - select(func.count()) - .select_from(model_cls) + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - target_package_id (int): 目标套餐ID + + 返回: + - PackagePreviewOut: 套餐变更预览结果 + """ + tenant = await auth.db.get(TenantModel, tenant_id) + current_pkg_id = tenant.package_id if tenant else None + + target_pkg = await auth.db.get(PackageModel, target_package_id) + if not target_pkg: + raise CustomException(msg="该数据不存在") + + current_pkg = None + if current_pkg_id: + current_pkg = await auth.db.get(PackageModel, current_pkg_id) + + # 获取当前套餐的菜单 + current_menus = set() + if current_pkg_id: + cm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == current_pkg_id) + result = await auth.db.execute(cm_stmt) + current_menus = {m.id for m in result.scalars().all()} + + # 获取目标套餐的菜单 + tm_stmt = select(MenuModel).join(PackageMenuModel, MenuModel.id == PackageMenuModel.menu_id).where(PackageMenuModel.package_id == target_package_id) + result = await auth.db.execute(tm_stmt) + target_menus = {m.id for m in result.scalars().all()} + + # 计算差异 + gained_menus, lost_menus = [], [] + if current_menus: + lost_ids = current_menus - target_menus + if lost_ids: + lost_stmt = select(MenuModel).where(MenuModel.id.in_(lost_ids)) + lost = await auth.db.execute(lost_stmt) + lost_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in lost.scalars().all()] + + gained_ids = target_menus - (current_menus or set()) + if gained_ids: + gain_stmt = select(MenuModel).where(MenuModel.id.in_(gained_ids)) + gained = await auth.db.execute(gain_stmt) + gained_menus = [{"id": m.id, "name": m.name, "path": m.route_path} for m in gained.scalars().all()] + + # 确定操作类型 + if current_pkg_id is None: + action = "buy" + elif current_pkg and target_pkg.price > current_pkg.price: + action = "upgrade" + elif current_pkg and target_pkg.price < current_pkg.price: + action = "downgrade" + else: + action = "renew" + + # 受影响角色 + affected_roles = [] + if current_pkg_id: + role_stmt = select(RoleModel).where(RoleModel.tenant_id == tenant_id) + r_result = await auth.db.execute(role_stmt) + affected_roles = [r.name for r in r_result.scalars().all()] + + return PackagePreviewOut( + current_package=current_pkg.name if current_pkg else "", + target_package=target_pkg.name, + action=action, + amount=target_pkg.price, + period=target_pkg.period, + gained_menus=gained_menus, + lost_menus=lost_menus, + affected_roles=affected_roles, + affected_users=0, + ) + + @classmethod + async def create_self_order(cls, auth: AuthSchema, tenant_id: int, data: SelfOrderCreate) -> SelfOrderOut: + """ + 创建自助订单(套餐购买/续费/升级/降级;免费订单自动激活) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - data (SelfOrderCreate): 自助订单创建参数 + + 返回: + - SelfOrderOut: 自助订单创建结果 + """ + tenant = await auth.db.get(TenantModel, tenant_id) + if not tenant: + raise CustomException(msg="该数据不存在") + if tenant.status not in (0, 1, 2): + raise CustomException(msg="租户状态不允许操作") + + pkg = await auth.db.get(PackageModel, data.package_id) + if not pkg or pkg.status == 1: + raise CustomException(msg="该数据不存在") + + amount = pkg.price + + order = await OrderCRUD(auth).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 auth.db.flush() + + # 免费订单自动激活 + if amount == 0: + await OrderCRUD(auth).update( + order.id, + OrderUpdateInternalSchema(status=1, pay_method="free", pay_time=datetime.now()), + ) + await PaymentService._activate_tenant_package(auth, 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 create_plugin_purchase_order(cls, auth: AuthSchema, tenant_id: int, data: PluginPurchaseCreate) -> SelfOrderOut: + """ + 创建插件购买订单(复用套餐订单流程) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - data (PluginPurchaseCreate): 插件购买参数 + + 返回: + - SelfOrderOut: 订单创建结果 + """ + # 校验插件存在且可用 + plugin = await auth.db.get(PluginModel, data.plugin_id) + if not plugin or plugin.status == 1: + raise CustomException(msg="该数据不存在") + + if plugin.price == 0: + raise CustomException(msg="免费插件可直接安装,无需购买") + + # 校验未重复购买 + existing = await auth.db.execute( + select(TenantPluginModel) .where( - model_cls.tenant_id == tenant_id, - model_cls.is_deleted == False, + TenantPluginModel.tenant_id == tenant_id, + TenantPluginModel.plugin_id == data.plugin_id, ) + .limit(1) ) - return (await auth.db.execute(stmt)).scalar() or 0 + tp = existing.scalar_one_or_none() + if tp and tp.purchased == "1": + raise CustomException(msg="您已购买此插件,可直接安装") - user_count = await _count(UserModel) - role_count = await _count(RoleModel) - dept_count = await _count(DeptModel) + order_data = OrderCreateSchema( + tenant_id=tenant_id, + package_id=None, + plugin_id=data.plugin_id, + order_type="plugin", + pay_method=data.pay_method, + ) + result = await OrderService.create_order(auth=auth, data=order_data) - # ── 到期状态 ── - now = datetime.now() - days_remaining = (tenant.end_time - now).days if tenant.end_time else 0 + logger.info(f"插件购买订单创建: order_no={result.order_no} plugin={plugin.name}") + return SelfOrderOut( + order_id=result.id, + order_no=result.order_no, + amount=result.amount, + need_pay=result.amount > 0, + ) - status_labels = { - "0": "正常", - "1": "宽限期", - "2": "已暂停", - "3": "已冻结", - "4": "已过期", - "5": "已归档", - } + @classmethod + async def get_self_order_list( + cls, + auth: AuthSchema, + tenant_id: int, + page_no: int = 1, + page_size: int = 20, + order_by: list[dict] | None = None, + ) -> SelfOrderListOut: + """ + 我的订单列表 - # ── 近期订单 ── - from app.api.v1.module_platform.order.model import OrderModel + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - page_no (int): 页码 + - page_size (int): 每页数量 + - order_by (list[dict] | None): 排序参数 - orders_stmt = select(OrderModel).where(OrderModel.tenant_id == tenant_id).order_by(OrderModel.created_time.desc()).limit(5) - orders_result = await auth.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=o.order_type, - status=o.status, - created_at=o.created_time.isoformat() if o.created_time else None, + 返回: + - SelfOrderListOut: 订单分页列表 + """ + offset = (page_no - 1) * page_size + page_result = await OrderCRUD(auth).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 auth.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, ) - 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, + @classmethod + async def get_self_order_detail(cls, auth: AuthSchema, tenant_id: int, order_id: int) -> SelfOrderDetailOut: + """ + 订单详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - order_id (int): 订单ID + + 返回: + - SelfOrderDetailOut: 订单详情 + """ + order = await OrderCRUD(auth).get_or_404(id=order_id, msg="该数据不存在") + + pkg_name = "" + if order.package_id: + p = await auth.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=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, ) - 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, + + @classmethod + async def get_workspace_data(cls, auth: AuthSchema, tenant_id: int) -> WorkspaceOut: + """ + 获取租户工作台概览(租户信息、套餐、配额用量、近期订单) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - WorkspaceOut: 工作台概览数据 + """ + tenant = await auth.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 auth.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 auth.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 auth.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=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), ), - ), - recent_orders=recent_orders, - ) + 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, + ) + @classmethod + async def get_tenant_api_usage_today(cls, auth: AuthSchema, tenant_id: int) -> dict: + """ + 获取租户当日 API 用量汇总(已移除 api_usage 模块) -async def get_tenant_api_usage_today(auth: AuthSchema, tenant_id: int) -> dict: - """获取租户当日 API 用量汇总(已移除 api_usage 模块)""" - return {"today": {"request_count": 0, "error_count": 0, "total_duration_ms": 0}, "month": {"total_calls": 0}, "top_paths": []} + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + 返回: + - dict: API 用量占位数据 + """ + return { + "today": {"request_count": 0, "error_count": 0, "total_duration_ms": 0}, + "month": {"total_calls": 0}, + "top_paths": [], + } -async def get_tenant_api_usage_daily(auth: AuthSchema, tenant_id: int, days: int = 7) -> dict: - """获取租户每日 API 用量(已移除 api_usage 模块)""" - return {"daily": []} + @classmethod + async def get_tenant_api_usage_daily(cls, auth: AuthSchema, tenant_id: int, days: int = 7) -> dict: + """ + 获取租户每日 API 用量(已移除 api_usage 模块) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - days (int): 查询天数 + + 返回: + - dict: 每日用量占位数据 + """ + return {"daily": []} diff --git a/backend/app/api/v1/module_platform/tenant/model.py b/backend/app/api/v1/module_platform/tenant/model.py index 7f4192f9..3863c5ee 100644 --- a/backend/app/api/v1/module_platform/tenant/model.py +++ b/backend/app/api/v1/module_platform/tenant/model.py @@ -1,11 +1,15 @@ from datetime import datetime +from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, validates +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): """ @@ -44,6 +48,9 @@ class TenantModel(ModelMixin): 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="备注") + # 关联关系 + 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(): diff --git a/backend/app/api/v1/module_platform/tenant/service.py b/backend/app/api/v1/module_platform/tenant/service.py index 066490cc..1c84e56f 100644 --- a/backend/app/api/v1/module_platform/tenant/service.py +++ b/backend/app/api/v1/module_platform/tenant/service.py @@ -7,6 +7,7 @@ from redis.asyncio.client import Redis from app.common.enums import RedisInitKeyConfig from app.core.base_schema import AuthSchema, BatchSetAvailable +from app.core.dependencies import require_superadmin from app.core.exceptions import CustomException from app.core.logger import logger from app.core.redis_crud import RedisCURD @@ -27,15 +28,23 @@ from .schema import ( class TenantService: - """租户管理模块服务层""" + """ + 租户管理服务(查询操作租户可见,写操作仅超级管理员可操作) + """ @classmethod async def detail_service(cls, auth: AuthSchema, id: int) -> TenantOutSchema: - obj = await TenantCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="租户不存在") - result = TenantOutSchema.model_validate(obj) - return result + """ + 租户详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 租户ID + + 返回: + - TenantOutSchema: 租户详情 + """ + return await TenantCRUD(auth).get_or_404(id=id, out_schema=TenantOutSchema) @classmethod async def page_service( @@ -50,11 +59,12 @@ class TenantService: offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=TenantOutSchema, ) @classmethod + @require_superadmin async def create_service(cls, auth: AuthSchema, data: TenantCreateSchema) -> TenantOutSchema: if await TenantCRUD(auth).get(name=data.name): raise CustomException(msg="创建失败,名称已存在") @@ -94,7 +104,7 @@ class TenantService: raise except Exception as e: logger.error(f"为租户[{tenant_obj.name}]创建初始管理员失败: {e!s}") - raise CustomException(msg="创建租户初始管理员失败") + raise CustomException(msg="创建租户初始管理员失败") from e logger.info(f"为租户[{tenant_obj.name}]创建初始管理员成功,用户名: {username},临时密码: {password}") @@ -104,10 +114,20 @@ class TenantService: return result @classmethod + @require_superadmin async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> TenantOutSchema: - obj = await TenantCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="租户不存在") + """ + 更新租户 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 租户ID + - data (TenantUpdateSchema): 租户更新模型 + + 返回: + - TenantOutSchema: 租户详情 + """ + obj = await TenantCRUD(auth).get_or_404(id=id) old_package_id = obj.package_id @@ -162,15 +182,22 @@ class TenantService: return result @classmethod + @require_superadmin async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 批量删除租户(含级联资源检查:用户/部门/角色/岗位) + + 参数: + - auth (AuthSchema): 认证信息模型 + - ids (list[int]): 租户ID列表 + + 返回: + - None + """ if not ids: raise CustomException(msg="删除失败,删除对象不能为空") if 1 in ids: raise CustomException(msg="系统租户不允许删除") - for id in ids: - obj = await TenantCRUD(auth).get(id=id) - if not obj: - continue 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 @@ -193,16 +220,33 @@ class TenantService: @classmethod async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + """ + 批量设置租户状态 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (BatchSetAvailable): 批量状态设置 + + 返回: + - None + """ if data.status == 1 and 1 in data.ids: raise CustomException(msg="系统租户不允许禁用") await TenantCRUD(auth).set(ids=data.ids, status=data.status) @classmethod async def toggle_status_service(cls, auth: AuthSchema, id: int) -> None: - """切换单个租户的启用/禁用状态""" - obj = await TenantCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="租户不存在") + """ + 切换单个租户的启用/禁用状态 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 租户ID + + 返回: + - None + """ + obj = await TenantCRUD(auth).get_or_404(id=id) if id == 1: raise CustomException(msg="系统租户不允许禁用") new_status = 0 if obj.status == 1 else 1 @@ -242,18 +286,28 @@ class TenantService: @classmethod async def add_tenant_user_service(cls, auth: AuthSchema, tenant_id: int, data: TenantUserAddSchema) -> None: - """向租户添加用户""" + """ + 向租户添加用户 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - data (TenantUserAddSchema): 用户添加参数 + + 返回: + - None + """ # 验证租户存在 tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") # 验证用户存在 from app.api.v1.module_system.user.crud import UserCRUD user = await UserCRUD(auth).get(id=data.user_id) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") # 检查是否已关联 from sqlalchemy import select @@ -297,7 +351,17 @@ class TenantService: @classmethod async def remove_tenant_user_service(cls, auth: AuthSchema, tenant_id: int, user_id: int) -> None: - """从租户移除用户""" + """ + 从租户移除用户 + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + - user_id (int): 用户ID + + 返回: + - None + """ from sqlalchemy import select # 查找关联记录 @@ -339,7 +403,16 @@ class TenantService: @classmethod async def get_quota_service(cls, auth: AuthSchema, tenant_id: int) -> dict: - """获取租户配额(从关联套餐读取,系统租户返回无限配额)""" + """ + 获取租户配额(从关联套餐读取,系统租户返回无限配额) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - dict: 配额信息 + """ if tenant_id == 1: return { "tenant_id": 1, @@ -351,7 +424,7 @@ class TenantService: } tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") if not tenant.package_id: return { "tenant_id": tenant.id, @@ -466,10 +539,19 @@ class TenantService: @classmethod async def get_config_service(cls, auth: AuthSchema, tenant_id: int) -> dict: - """获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用)""" + """ + 获取租户所有配置(从租户主表读取,返回原始 dict 供内部使用) + + 参数: + - auth (AuthSchema): 认证信息模型 + - tenant_id (int): 租户ID + + 返回: + - dict: 配置字典 + """ tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") config_fields = ["name", "description", "version", "logo_url", "favicon", "login_bg", "copyright", "keep_record", "help_doc", "privacy", "clause", "git_code"] config = {field: getattr(tenant, field, None) for field in config_fields} @@ -537,10 +619,21 @@ class TenantService: @classmethod async def update_config_service(cls, auth: AuthSchema, redis: Redis, tenant_id: int, config: dict) -> list[TenantConfigOutSchema]: - """更新租户配置(同步 Redis 缓存)""" + """ + 更新租户配置(同步 Redis 缓存) + + 参数: + - auth (AuthSchema): 认证信息模型 + - redis (Redis): Redis 客户端 + - tenant_id (int): 租户ID + - config (dict): 配置字典 + + 返回: + - list[TenantConfigOutSchema]: 更新后的配置项列表 + """ tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") config_fields = ["name", "description", "version", "logo_url", "favicon", "login_bg", "copyright", "keep_record", "help_doc", "privacy", "clause", "git_code"] @@ -612,7 +705,7 @@ class TenantService: tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") if tenant.status not in (0, 1, 2): status_labels = {0: "正常", 1: "宽限期", 2: "暂停", 3: "冻结", 4: "过期", 5: "归档"} @@ -634,7 +727,8 @@ class TenantService: @classmethod async def package_change_preview_service(cls, auth: AuthSchema, tenant_id: int, new_package_id: int) -> PackageChangePreviewOut: - """套餐变更影响预览 + """ + 套餐变更影响预览 返回受影响角色、菜单清单、配额对比等,供超管确认后再执行变更。 @@ -644,7 +738,7 @@ class TenantService: - new_package_id (int): 目标套餐ID 返回: - - dict: 预览结果 + - PackageChangePreviewOut: 预览结果 """ from sqlalchemy import func, select @@ -656,11 +750,11 @@ class TenantService: tenant = await TenantCRUD(auth).get(id=tenant_id) if not tenant: - raise CustomException(msg="租户不存在") + raise CustomException(msg="该数据不存在") new_package = await PackageCRUD(auth).get(id=new_package_id) if not new_package: - raise CustomException(msg="目标套餐不存在") + raise CustomException(msg="该数据不存在") # 当前可用菜单 current_menu_ids = await PackageService.get_tenant_available_menu_ids(auth, tenant_id) diff --git a/backend/app/api/v1/module_system/auth/controller.py b/backend/app/api/v1/module_system/auth/controller.py index d41e4c05..83b6b293 100644 --- a/backend/app/api/v1/module_system/auth/controller.py +++ b/backend/app/api/v1/module_system/auth/controller.py @@ -425,7 +425,7 @@ async def tenant_register_controller( 返回: - TenantRegisterOutSchema: 注册结果,含 tenant_id/user_id/试用到期日 """ - result = await TenantRegisterService.register( + result = await TenantRegisterService.register_service( db=db, username=data.username, password=data.password, 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 75b567ac..8dea5b97 100644 --- a/backend/app/api/v1/module_system/auth/oauth_service.py +++ b/backend/app/api/v1/module_system/auth/oauth_service.py @@ -323,7 +323,7 @@ async def ensure_oauth_user( role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS), ) try: - await UserService.register_user_service(auth=auth, data=reg) + await UserService.register_service(auth=auth, data=reg) except Exception: # 并发创建可能触发唯一约束冲突,回退到再次查询 existing = await UserCRUD(auth).get(username=username) diff --git a/backend/app/api/v1/module_system/auth/service.py b/backend/app/api/v1/module_system/auth/service.py index 3e7e14f9..74130c23 100644 --- a/backend/app/api/v1/module_system/auth/service.py +++ b/backend/app/api/v1/module_system/auth/service.py @@ -865,7 +865,7 @@ class TenantRegisterService: DEFAULT_TRIAL_DAYS = 7 @classmethod - async def register( + async def register_service( cls, db: AsyncSession, username: str, @@ -873,6 +873,22 @@ class TenantRegisterService: email: str, tenant_name: str | None = None, ) -> TenantRegisterOutSchema: + """ + 租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配。 + + 参数: + - db (AsyncSession): 数据库会话对象。 + - username (str): 登录账号。 + - password (str): 登录密码。 + - email (str): 邮箱。 + - tenant_name (str | None): 企业/团队名称。 + + 返回: + - TenantRegisterOutSchema: 注册结果。 + + 异常: + - CustomException: 用户名或邮箱已被占用时抛出。 + """ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError diff --git a/backend/app/api/v1/module_system/dept/controller.py b/backend/app/api/v1/module_system/dept/controller.py index 21521548..7a07eba1 100644 --- a/backend/app/api/v1/module_system/dept/controller.py +++ b/backend/app/api/v1/module_system/dept/controller.py @@ -42,7 +42,7 @@ async def get_dept_tree_controller( - CustomException: 查询部门树失败时抛出异常。 """ order_by = [{"order": "asc"}] - result_dict_list = await DeptService.get_dept_tree_service(search=search, auth=auth, order_by=order_by) + result_dict_list = await DeptService.tree_service(search=search, auth=auth, order_by=order_by) return SuccessResponse(data=result_dict_list, msg="查询部门树成功") @@ -68,7 +68,7 @@ async def get_obj_detail_controller( 异常: - CustomException: 查询部门详情失败时抛出异常。 """ - result_dict = await DeptService.get_dept_detail_service(id=id, auth=auth) + result_dict = await DeptService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="查询部门详情成功") @@ -94,7 +94,7 @@ async def create_obj_controller( 异常: - CustomException: 创建部门失败时抛出异常。 """ - result_dict = await DeptService.create_dept_service(data=data, auth=auth) + result_dict = await DeptService.create_service(data=data, auth=auth) await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="创建部门成功") @@ -123,7 +123,7 @@ async def update_obj_controller( 异常: - CustomException: 修改部门失败时抛出异常。 """ - result_dict = await DeptService.update_dept_service(auth=auth, id=id, data=data) + result_dict = await DeptService.update_service(auth=auth, id=id, data=data) await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="修改部门成功") @@ -150,7 +150,7 @@ async def delete_obj_controller( 异常: - CustomException: 删除部门失败时抛出异常。 """ - await DeptService.delete_dept_service(ids=ids, auth=auth) + await DeptService.delete_service(ids=ids, auth=auth) await FastAPICache.clear(namespace=_DEPT_NS) return SuccessResponse(msg="删除部门成功") diff --git a/backend/app/api/v1/module_system/dept/crud.py b/backend/app/api/v1/module_system/dept/crud.py index b39d0148..eefd102e 100644 --- a/backend/app/api/v1/module_system/dept/crud.py +++ b/backend/app/api/v1/module_system/dept/crud.py @@ -1,5 +1,3 @@ -from collections.abc import Sequence - from app.core.base_crud import CRUDBase from app.core.base_schema import AuthSchema @@ -11,37 +9,4 @@ class DeptCRUD(CRUDBase[DeptModel, DeptCreateSchema, DeptUpdateSchema]): """部门模块数据层""" def __init__(self, auth: AuthSchema) -> None: - """ - 初始化部门数据层。 - - 参数: - - auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。 - - 返回: - - None - """ super().__init__(model=DeptModel, auth=auth) - - async def get_tree_list( - self, - search: dict | None = None, - order_by: list[dict] | None = None, - preload: list | None = None, - ) -> Sequence[DeptModel]: - """ - 获取部门树形列表。 - - 参数: - - search (dict | None): 搜索条件。 - - order_by (list[dict] | None): 排序字段列表。 - - preload (list | None): 预加载关系,未提供时使用模型默认项 - - 返回: - - Sequence[DeptModel]: 部门树形列表。 - """ - return await self.tree_list( - search=search, - order_by=order_by, - children_attr="children", - preload=preload, - ) diff --git a/backend/app/api/v1/module_system/dept/model.py b/backend/app/api/v1/module_system/dept/model.py index 7ad2e8aa..125f34dc 100644 --- a/backend/app/api/v1/module_system/dept/model.py +++ b/backend/app/api/v1/module_system/dept/model.py @@ -1,24 +1,31 @@ from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.common.enums import PermissionFilterStrategy -from app.core.base_model import ModelMixin, TenantMixin +from app.core.base_model import ModelMixin, TenantMixin, 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): +class DeptModel(ModelMixin, TenantMixin, UserMixin): """ 部门模型 """ __tablename__: str = "sys_dept" __table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "部门表"}) - __loader_options__: list[str] = ["children"] + __tree_children_attr__: str = "children" + __loader_options__: list[str] = [ + "children", + "created_by", + "updated_by", + "deleted_by", + "tenant_by", + ] __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DEPT_BASED name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称") diff --git a/backend/app/api/v1/module_system/dept/schema.py b/backend/app/api/v1/module_system/dept/schema.py index e7d0a46f..9bd1f7ee 100644 --- a/backend/app/api/v1/module_system/dept/schema.py +++ b/backend/app/api/v1/module_system/dept/schema.py @@ -47,7 +47,7 @@ class DeptUpdateSchema(DeptCreateSchema): """部门更新模型""" -class DeptDetailOutSchema(DeptCreateSchema, BaseSchema, UserBySchema, TenantBySchema): +class DeptOutSchema(DeptCreateSchema, BaseSchema, UserBySchema, TenantBySchema): """部门详情响应模型(不含 children,用于详情和更新)""" model_config = ConfigDict(from_attributes=True) @@ -55,16 +55,12 @@ class DeptDetailOutSchema(DeptCreateSchema, BaseSchema, UserBySchema, TenantBySc parent_name: str | None = Field(default=None, max_length=64, description="父部门名称") -class DeptTreeOutSchema(DeptDetailOutSchema): +class DeptTreeOutSchema(DeptOutSchema): """部门树形响应模型(含 children,用于树形列表)""" children: list["DeptTreeOutSchema"] | None = Field(default=None, description="子部门列表") -# 兼容旧代码的别名(后续可逐步移除) -DeptOutSchema = DeptDetailOutSchema - - class DeptQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """部门管理查询参数""" diff --git a/backend/app/api/v1/module_system/dept/service.py b/backend/app/api/v1/module_system/dept/service.py index 49808ac6..2a9a6954 100644 --- a/backend/app/api/v1/module_system/dept/service.py +++ b/backend/app/api/v1/module_system/dept/service.py @@ -10,7 +10,7 @@ from app.utils.common_util import ( from .crud import DeptCRUD from .schema import ( DeptCreateSchema, - DeptDetailOutSchema, + DeptOutSchema, DeptQueryParam, DeptTreeOutSchema, DeptUpdateSchema, @@ -19,25 +19,25 @@ from .schema import ( class DeptService: """ - 部门管理模块服务层 + 部门管理服务 + + 提供部门 CRUD、树形结构查询、级联启/禁用、租户配额检查等业务能力。 """ @classmethod - async def get_dept_detail_service(cls, auth: AuthSchema, id: int) -> DeptDetailOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> DeptOutSchema: """ - 获取部门详情。 + 获取部门详情 参数: - - auth (AuthSchema): 认证对象。 - - id (int): 部门 ID。 + - auth (AuthSchema): 认证信息模型 + - id (int): 部门 ID 返回: - - DeptDetailOutSchema: 部门详情对象。 + - DeptOutSchema: 部门详情响应模型 """ - dept = await DeptCRUD(auth).get(id=id) - if not dept: - raise CustomException(msg="部门不存在") - dept_out = DeptDetailOutSchema.model_validate(dept) + dept = await DeptCRUD(auth).get_or_404(id=id) + dept_out = DeptOutSchema.model_validate(dept) if dept.parent_id: parent = await DeptCRUD(auth).get(id=dept.parent_id) if parent: @@ -45,7 +45,7 @@ class DeptService: return dept_out @classmethod - async def get_dept_tree_service( + async def tree_service( cls, auth: AuthSchema, search: DeptQueryParam | None = None, @@ -63,14 +63,14 @@ class DeptService: - list[dict]: 部门树形列表对象。 """ # 使用树形结构查询,预加载children关系 - dept_list = await DeptCRUD(auth).get_tree_list(search=search.__dict__ if search else {}, order_by=order_by) + dept_list = await DeptCRUD(auth).tree_list(search=vars(search) if search else None, order_by=order_by) # 转换为字典列表(使用树形 Schema),tree_list 已通过 selectin 预加载 children dept_dict_list = [DeptTreeOutSchema.model_validate(dept).model_dump() for dept in dept_list] # 仅保留根节点,子树已在 model_dump 中递归序列化 return [d for d in dept_dict_list if d.get("parent_id") is None] @classmethod - async def create_dept_service(cls, auth: AuthSchema, data: DeptCreateSchema) -> DeptDetailOutSchema: + async def create_service(cls, auth: AuthSchema, data: DeptCreateSchema) -> DeptOutSchema: """ 创建部门。 @@ -79,14 +79,14 @@ class DeptService: - data (DeptCreateSchema): 部门创建对象。 返回: - - DeptDetailOutSchema: 新创建的部门对象。 + - DeptOutSchema: 新创建的部门对象。 异常: - CustomException: 当部门已存在时抛出。 """ dept = await DeptCRUD(auth).get(name=data.name) if dept: - raise CustomException(msg="创建失败,该部门已存在") + raise CustomException(msg="创建失败,该数据已存在") obj = await DeptCRUD(auth).get(code=data.code) if obj: raise CustomException(msg="创建失败,编码已存在") @@ -97,10 +97,10 @@ class DeptService: await TenantService.check_quota_service(auth, auth.tenant_id, "dept") dept = await DeptCRUD(auth).create(data=data) - return DeptDetailOutSchema.model_validate(dept) + return DeptOutSchema.model_validate(dept) @classmethod - async def update_dept_service(cls, auth: AuthSchema, id: int, data: DeptUpdateSchema) -> DeptDetailOutSchema: + async def update_service(cls, auth: AuthSchema, id: int, data: DeptUpdateSchema) -> DeptOutSchema: """ 更新部门。 @@ -110,23 +110,21 @@ class DeptService: - data (DeptUpdateSchema): 部门更新对象。 返回: - - DeptDetailOutSchema: 更新后的部门对象。 + - DeptOutSchema: 更新后的部门对象。 异常: - CustomException: 当部门不存在或名称重复时抛出。 """ - dept = await DeptCRUD(auth).get(id=id) - if not dept: - raise CustomException(msg="更新失败,该部门不存在") + dept = await DeptCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") exist_dept = await DeptCRUD(auth).get(name=data.name) if exist_dept and exist_dept.id != id: - raise CustomException(msg="更新失败,部门名称重复") + raise CustomException(msg="更新失败,名称已存在") exist_code = await DeptCRUD(auth).get(code=data.code) if exist_code and exist_code.id != id: - raise CustomException(msg="更新失败,部门编码已存在") + raise CustomException(msg="更新失败,编码已存在") dept = await DeptCRUD(auth).update(id=id, data=data) - dept_out = DeptDetailOutSchema.model_validate(dept) + dept_out = DeptOutSchema.model_validate(dept) if dept_out.parent_id: parent = await DeptCRUD(auth).get(id=dept_out.parent_id) if parent: @@ -134,7 +132,7 @@ class DeptService: return dept_out @classmethod - async def delete_dept_service(cls, auth: AuthSchema, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除部门。 diff --git a/backend/app/api/v1/module_system/dict/controller.py b/backend/app/api/v1/module_system/dict/controller.py index 4c99b523..8c7dfbc9 100644 --- a/backend/app/api/v1/module_system/dict/controller.py +++ b/backend/app/api/v1/module_system/dict/controller.py @@ -52,7 +52,7 @@ async def get_type_detail_controller( 异常: - CustomException: 获取字典类型详情失败时抛出异常。 """ - result_dict = await DictTypeService.get_obj_detail_service(id=id, auth=auth) + result_dict = await DictTypeService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取字典类型详情成功") @@ -80,7 +80,7 @@ async def get_type_list_controller( 异常: - CustomException: 查询字典类型失败时抛出异常。 """ - result_dict = await DictTypeService.get_obj_page_service( + result_dict = await DictTypeService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -111,7 +111,7 @@ async def get_type_optionselect_controller( 异常: - CustomException: 获取字典类型列表失败时抛出异常。 """ - result_dict_list = await DictTypeService.get_obj_list_service(auth=auth) + result_dict_list = await DictTypeService.list_service(auth=auth) return SuccessResponse(data=result_dict_list, msg="获取字典类型列表成功") @@ -139,7 +139,7 @@ async def create_type_controller( 异常: - CustomException: 创建字典类型失败时抛出异常。 """ - result_dict = await DictTypeService.create_obj_service(auth=auth, redis=redis, data=data) + result_dict = await DictTypeService.create_service(auth=auth, redis=redis, data=data) await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="创建字典类型成功") @@ -170,7 +170,7 @@ async def update_type_controller( 异常: - CustomException: 修改字典类型失败时抛出异常。 """ - result_dict = await DictTypeService.update_obj_service(auth=auth, redis=redis, id=id, data=data) + result_dict = await DictTypeService.update_service(auth=auth, redis=redis, id=id, data=data) await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="修改字典类型成功") @@ -199,7 +199,7 @@ async def delete_type_controller( 异常: - CustomException: 删除字典类型失败时抛出异常。 """ - await DictTypeService.delete_obj_service(auth=auth, redis=redis, ids=ids) + await DictTypeService.delete_service(auth=auth, redis=redis, ids=ids) await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="删除字典类型成功") @@ -226,7 +226,7 @@ async def batch_set_available_dict_type_controller( 异常: - CustomException: 批量修改字典类型状态失败时抛出异常。 """ - await DictTypeService.set_obj_available_service(auth=auth, data=data) + await DictTypeService.set_available_service(auth=auth, data=data) await FastAPICache.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="批量修改字典类型状态成功") @@ -254,9 +254,9 @@ async def export_type_list_controller( - CustomException: 导出字典类型失败时抛出异常。 """ # 获取全量数据并转为dict列表 - result_dict_list = await DictTypeService.get_obj_list_service(search=search, auth=auth) + result_dict_list = await DictTypeService.list_service(search=search, auth=auth) export_data = [item.model_dump() for item in result_dict_list] - export_result = await DictTypeService.export_obj_service(data_list=export_data) + export_result = await DictTypeService.export_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -287,7 +287,7 @@ async def get_data_detail_controller( 异常: - CustomException: 获取字典数据详情失败时抛出异常。 """ - result_dict = await DictDataService.get_obj_detail_service(id=id, auth=auth) + result_dict = await DictDataService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取字典数据详情成功") @@ -318,7 +318,7 @@ async def get_data_list_controller( order_by = [{"order": "asc"}] if page.order_by: order_by = page.order_by - result_dict = await DictDataService.get_obj_page_service( + result_dict = await DictDataService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -352,7 +352,7 @@ async def create_data_controller( 异常: - CustomException: 创建字典数据失败时抛出异常。 """ - result_dict = await DictDataService.create_obj_service(auth=auth, redis=redis, data=data) + result_dict = await DictDataService.create_service(auth=auth, redis=redis, data=data) return SuccessResponse(data=result_dict, msg="创建字典数据成功") @@ -382,7 +382,7 @@ async def update_data_controller( 异常: - CustomException: 修改字典数据失败时抛出异常。 """ - result_dict = await DictDataService.update_obj_service(auth=auth, redis=redis, id=id, data=data) + result_dict = await DictDataService.update_service(auth=auth, redis=redis, id=id, data=data) return SuccessResponse(data=result_dict, msg="修改字典数据成功") @@ -410,7 +410,7 @@ async def delete_data_controller( 异常: - CustomException: 删除字典数据失败时抛出异常。 """ - await DictDataService.delete_obj_service(auth=auth, redis=redis, ids=ids) + await DictDataService.delete_service(auth=auth, redis=redis, ids=ids) return SuccessResponse(msg="删除字典数据成功") @@ -436,7 +436,7 @@ async def batch_set_available_dict_data_controller( 异常: - CustomException: 批量修改字典数据状态失败时抛出异常。 """ - await DictDataService.set_obj_available_service(auth=auth, data=data) + await DictDataService.set_available_service(auth=auth, data=data) return SuccessResponse(msg="批量修改字典数据状态成功") @@ -464,9 +464,9 @@ async def export_data_list_controller( 异常: - CustomException: 导出字典数据失败时抛出异常。 """ - result_dict_list = await DictDataService.get_obj_list_service(auth=auth, search=search, order_by=page.order_by) + result_dict_list = await DictDataService.list_service(auth=auth, search=search, order_by=page.order_by) export_data = [item.model_dump() for item in result_dict_list] - export_result = await DictDataService.export_obj_service(data_list=export_data) + export_result = await DictDataService.export_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -494,6 +494,6 @@ async def get_init_dict_data_controller(dict_type: str, redis: Annotated[Redis, 异常: - CustomException: 根据字典类型获取数据失败时抛出异常。 """ - dict_data_query_result = await DictDataService.get_init_dict_service(redis=redis, dict_type=dict_type, tenant_id=1) + dict_data_query_result = await DictDataService.get_init_cache_service(redis=redis, dict_type=dict_type, tenant_id=1) 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 8ba90f07..4fe75783 100644 --- a/backend/app/api/v1/module_system/dict/model.py +++ b/backend/app/api/v1/module_system/dict/model.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint +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 @@ -14,14 +14,13 @@ class DictTypeModel(ModelMixin, TenantMixin): __tablename__: str = "sys_dict_type" __table_args__ = (UniqueConstraint("tenant_id", "dict_type"), {"comment": "字典类型表"}) - __loader_options__: list[str] = [] + __loader_options__: list[str] = ["dict_data_list"] __platform_data_shared__: bool = True dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称") dict_type: Mapped[str] = mapped_column(String(255), 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="备注") - # 关系定义 dict_data_list: Mapped[list["DictDataModel"]] = relationship( "DictDataModel", back_populates="dict_type_obj", @@ -42,7 +41,7 @@ class DictDataModel(ModelMixin, TenantMixin): UniqueConstraint("tenant_id", "dict_type_id", "dict_value", name="uq_dict_data_value"), {"comment": "字典数据表"}, ) - __loader_options__: list[str] = [] + __loader_options__: list[str] = ["dict_type_obj"] __platform_data_shared__: bool = True status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True) diff --git a/backend/app/api/v1/module_system/dict/schema.py b/backend/app/api/v1/module_system/dict/schema.py index f930e033..ed3c6521 100644 --- a/backend/app/api/v1/module_system/dict/schema.py +++ b/backend/app/api/v1/module_system/dict/schema.py @@ -1,4 +1,5 @@ import re +from dataclasses import dataclass from fastapi import Query from pydantic import ( @@ -83,6 +84,7 @@ class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema, TenantBy model_config = ConfigDict(from_attributes=True) +@dataclass class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """字典类型查询参数""" @@ -94,8 +96,10 @@ class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.dict_name = (QueueEnum.like.value, dict_name) - self.dict_type = (QueueEnum.eq.value, dict_type) + if dict_name: + self.dict_name = (QueueEnum.like.value, dict_name) + if dict_type: + self.dict_type = (QueueEnum.eq.value, dict_type) class DictDataCreateSchema(BaseModel): @@ -159,6 +163,7 @@ class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema, TenantBy model_config = ConfigDict(from_attributes=True) +@dataclass class DictDataQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """字典数据查询参数""" @@ -171,6 +176,9 @@ class DictDataQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.dict_label = (QueueEnum.like.value, dict_label) - self.dict_type = (QueueEnum.eq.value, dict_type) - self.dict_type_id = (QueueEnum.eq.value, dict_type_id) + if dict_label: + self.dict_label = (QueueEnum.like.value, dict_label) + if dict_type: + self.dict_type = (QueueEnum.eq.value, dict_type) + if dict_type_id is not None: + self.dict_type_id = (QueueEnum.eq.value, dict_type_id) diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index 07a5b5f2..4ad897dc 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -25,11 +25,13 @@ from .schema import ( class DictTypeService: """ - 字典类型管理模块服务层 + 字典类型管理服务 + + 提供字典类型 CRUD、Redis 缓存同步、字典数据联动更新、批量启/禁用、Excel 导出等业务能力。 """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> DictTypeOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> DictTypeOutSchema: """ 获取数据字典类型详情 @@ -38,15 +40,12 @@ class DictTypeService: - id (int): 数据字典类型ID 返回: - - dict: 数据字典类型详情字典 + - DictTypeOutSchema: 字典类型响应模型 """ - obj = await DictTypeCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="字典类型不存在") - return DictTypeOutSchema.model_validate(obj) + return await DictTypeCRUD(auth).get_or_404(id=id, out_schema=DictTypeOutSchema) @classmethod - async def get_obj_list_service( + async def list_service( cls, auth: AuthSchema, search: DictTypeQueryParam | None = None, @@ -61,13 +60,13 @@ class DictTypeService: - order_by (list[dict] | None): 排序字段列表 返回: - - list[DictTypeOutSchema]: 数据字典类型 + - list[DictTypeOutSchema]: 字典类型响应模型列表 """ - obj_list = await DictTypeCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + obj_list = await DictTypeCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [DictTypeOutSchema.model_validate(obj) for obj in obj_list] @classmethod - async def get_obj_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -93,12 +92,12 @@ class DictTypeService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=DictTypeOutSchema, ) @classmethod - async def create_obj_service(cls, auth: AuthSchema, redis: Redis, data: DictTypeCreateSchema) -> DictTypeOutSchema: + async def create_service(cls, auth: AuthSchema, redis: Redis, data: DictTypeCreateSchema) -> DictTypeOutSchema: """ 创建数据字典类型 @@ -108,11 +107,11 @@ class DictTypeService: - data (DictTypeCreateSchema): 数据字典类型创建模型 返回: - - dict: 数据字典类型详情字典 + - DictTypeOutSchema: 字典类型响应模型 """ exist_obj = await DictTypeCRUD(auth).get(dict_name=data.dict_name) if exist_obj: - raise CustomException(msg="创建失败,该数据字典类型已存在") + raise CustomException(msg="创建失败,该数据已存在") obj = await DictTypeCRUD(auth).create(data=data) new_obj_dict = DictTypeOutSchema.model_validate(obj) @@ -128,12 +127,12 @@ class DictTypeService: logger.info(f"创建字典类型成功: {new_obj_dict}") except Exception as e: logger.error(f"创建字典类型失败: {e}") - raise CustomException(msg=f"创建字典类型失败 {e}") + raise CustomException(msg="同步字典类型缓存失败") from e return new_obj_dict @classmethod - async def update_obj_service( + async def update_service( cls, auth: AuthSchema, redis: Redis, @@ -150,11 +149,9 @@ class DictTypeService: - data (DictTypeUpdateSchema): 数据字典类型更新模型 返回: - - dict: 数据字典类型详情字典 + - DictTypeOutSchema: 字典类型响应模型 """ - exist_obj = await DictTypeCRUD(auth).get(id=id) - if not exist_obj: - raise CustomException(msg="更新失败,该数据字典类型不存在") + exist_obj = await DictTypeCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") if exist_obj.dict_name != data.dict_name: raise CustomException(msg="更新失败,数据字典类型名称不可以修改") @@ -197,12 +194,12 @@ class DictTypeService: logger.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}") except Exception as e: logger.error(f"更新字典类型缓存失败: {e}") - raise CustomException(msg=f"更新字典类型缓存失败 {e}") + raise CustomException(msg="同步字典类型缓存失败") from e return new_obj_dict @classmethod - async def delete_obj_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: """ 删除数据字典类型 @@ -220,7 +217,7 @@ class DictTypeService: existing_map = {obj.id: obj for obj in existing} for nid in ids: if nid not in existing_map: - raise CustomException(msg="删除失败,该数据字典类型不存在") + raise CustomException(msg="删除失败,该数据不存在") exist_obj = existing_map[nid] # 检查是否有字典数据 exist_obj_type_list = await DictDataCRUD(auth).list(search={"dict_type": exist_obj.dict_type}) @@ -234,11 +231,11 @@ class DictTypeService: logger.info(f"删除字典类型成功: {nid}") except Exception as e: logger.error(f"删除字典类型失败: {e}") - raise CustomException(msg="删除字典类型失败") + raise CustomException(msg="同步删除字典缓存失败") from e await DictTypeCRUD(auth).delete(ids=ids) @classmethod - async def set_obj_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 设置数据字典类型状态 @@ -252,7 +249,7 @@ class DictTypeService: await DictTypeCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def export_obj_service(cls, data_list: list[dict]) -> bytes: + async def export_service(cls, data_list: list[dict]) -> bytes: """ 导出数据字典类型列表 @@ -285,11 +282,13 @@ class DictTypeService: class DictDataService: """ - 字典数据管理模块服务层 + 字典数据管理服务 + + 提供字典数据 CRUD、Redis 缓存同步、初始化字典、批量启/禁用、Excel 导出等业务能力。 """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> DictDataOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> DictDataOutSchema: """ 获取数据字典数据详情 @@ -298,15 +297,12 @@ class DictDataService: - id (int): 数据字典数据ID 返回: - - dict: 数据字典数据详情字典 + - DictDataOutSchema: 字典数据响应模型 """ - obj = await DictDataCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="字典数据不存在") - return DictDataOutSchema.model_validate(obj) + return await DictDataCRUD(auth).get_or_404(id=id, out_schema=DictDataOutSchema) @classmethod - async def get_obj_list_service( + async def list_service( cls, auth: AuthSchema, search: DictDataQueryParam | None = None, @@ -321,13 +317,13 @@ class DictDataService: - order_by (list[dict] | None): 排序字段列表 返回: - - list[DictDataOutSchema]: 数据字典数据 + - list[DictDataOutSchema]: 字典数据响应模型列表 """ - obj_list = await DictDataCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + obj_list = await DictDataCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [DictDataOutSchema.model_validate(obj) for obj in obj_list] @classmethod - async def get_obj_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -353,12 +349,12 @@ class DictDataService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=DictDataOutSchema, ) @classmethod - async def init_dict_service(cls, redis: Redis) -> None: + async def init_cache_service(cls, redis: Redis) -> None: """ 应用初始化: 获取所有字典类型对应的字典数据信息并按租户缓存。 @@ -395,10 +391,10 @@ class DictDataService: except Exception as e: logger.error(f"字典初始化过程发生错误: {e}") - raise CustomException(msg=f"字典数据初始化失败: {e!s}") + raise CustomException(msg="字典数据初始化失败") from e @classmethod - async def get_init_dict_service(cls, redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]: + async def get_init_cache_service(cls, redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]: """ 从缓存获取字典数据列表信息 @@ -423,26 +419,26 @@ class DictDataService: elif isinstance(obj_list_dict, list): return obj_list_dict - await cls.init_dict_service(redis) + await cls.init_cache_service(redis) redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}" obj_list_dict = await RedisCURD(redis).get(redis_key) if not obj_list_dict: - raise CustomException(msg="数据字典不存在") + raise CustomException(msg="该数据不存在") if isinstance(obj_list_dict, str): try: return json.loads(obj_list_dict) except json.JSONDecodeError: - raise CustomException(msg="字典数据格式错误") + raise CustomException(msg="字典数据格式错误") from None return obj_list_dict except CustomException: raise except Exception as e: logger.error(f"获取字典缓存失败: {e!s}") - raise CustomException(msg=f"获取字典数据失败: {e!s}") + raise CustomException(msg="获取字典数据失败") from e @classmethod - async def create_obj_service(cls, auth: AuthSchema, redis: Redis, data: DictDataCreateSchema) -> DictDataOutSchema: + async def create_service(cls, auth: AuthSchema, redis: Redis, data: DictDataCreateSchema) -> DictDataOutSchema: """ 创建数据字典数据 @@ -452,7 +448,7 @@ class DictDataService: - data (DictDataCreateSchema): 数据字典数据创建模型 返回: - - dict: 数据字典数据详情字典 + - DictDataOutSchema: 字典数据响应模型 """ # 检查相同字典类型下dict_label是否已存在 exist_label_obj = await DictDataCRUD(auth).get(dict_type=data.dict_type, dict_label=data.dict_label) @@ -481,12 +477,12 @@ class DictDataService: logger.info(f"创建字典数据写入缓存成功: {obj}") except Exception as e: logger.error(f"创建字典数据写入缓存失败: {e}") - raise CustomException(msg=f"创建字典数据失败 {e}") + raise CustomException(msg="同步字典数据缓存失败") from e return DictDataOutSchema.model_validate(obj) @classmethod - async def update_obj_service( + async def update_service( cls, auth: AuthSchema, redis: Redis, @@ -503,11 +499,9 @@ class DictDataService: - data (DictDataUpdateSchema): 数据字典数据更新模型 返回: - - Dict: 数据字典数据详情字典 + - DictDataOutSchema: 字典数据响应模型 """ - exist_obj = await DictDataCRUD(auth).get(id=id) - if not exist_obj: - raise CustomException(msg="更新失败,该字典数据不存在") + exist_obj = await DictDataCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") # 检查相同字典类型下dict_label是否已存在(排除当前记录) if exist_obj.dict_label != data.dict_label: @@ -554,12 +548,12 @@ class DictDataService: logger.info(f"更新字典数据写入缓存成功: {obj}") except Exception as e: logger.error(f"更新字典数据写入缓存失败: {e}") - raise CustomException(msg=f"更新字典数据失败 {e}") + raise CustomException(msg="同步字典数据缓存失败") from e return DictDataOutSchema.model_validate(obj) @classmethod - async def delete_obj_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: """ 删除数据字典数据 @@ -608,10 +602,10 @@ class DictDataService: raise except Exception as e: logger.error(f"删除字典数据失败: {e!s}") - raise CustomException(msg=f"删除字典数据失败: {e!s}") + raise CustomException(msg="删除字典数据失败") from e @classmethod - async def set_obj_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 批量修改数据字典数据状态 @@ -625,7 +619,7 @@ class DictDataService: await DictDataCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def export_obj_service(cls, data_list: list[dict]) -> bytes: + async def export_service(cls, data_list: list[dict]) -> bytes: """ 导出数据字典数据列表 diff --git a/backend/app/api/v1/module_system/log/controller.py b/backend/app/api/v1/module_system/log/controller.py index 737f5f28..9d561e86 100644 --- a/backend/app/api/v1/module_system/log/controller.py +++ b/backend/app/api/v1/module_system/log/controller.py @@ -29,7 +29,7 @@ LogRouter = APIRouter(route_class=OperationLogRoute, prefix="/log", tags=["日 summary="获取登录日志详情", response_model=ResponseSchema[LoginLogDetailOutSchema], ) -async def get_obj_detail_controller( +async def get_log_detail_controller( id: Annotated[int, Path(description="登录日志ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:query"]))], ) -> JSONResponse: @@ -52,7 +52,7 @@ async def get_obj_detail_controller( summary="查询登录日志列表", response_model=ResponseSchema[PageResultSchema[LoginLogOutSchema]], ) -async def get_obj_list_controller( +async def get_log_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[LoginLogQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:query"]))], @@ -83,7 +83,7 @@ async def get_obj_list_controller( summary="创建登录日志", response_model=ResponseSchema[LoginLogDetailOutSchema], ) -async def create_obj_controller( +async def create_log_controller( data: LoginLogCreateSchema, auth: Annotated[AuthSchema, Depends(get_current_user)], ) -> JSONResponse: @@ -106,7 +106,7 @@ async def create_obj_controller( summary="删除登录日志", response_model=ResponseSchema, ) -async def delete_obj_controller( +async def delete_log_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:delete"]))], ) -> JSONResponse: @@ -130,7 +130,7 @@ async def delete_obj_controller( response_model=ResponseSchema[OperationLogDetailOutSchema], dependencies=[Depends(AuthPermission(["module_system:log:query"]))], ) -async def detail( +async def get_operation_log_detail_controller( *, id: Annotated[int, Path(gt=0)], auth: Annotated[AuthSchema, Depends(get_current_user)], @@ -145,7 +145,7 @@ async def detail( 返回: - JSONResponse: 包含操作日志详情的 JSON 响应。 """ - result_dict = await OperationLogService.detail_service(auth, id) + result_dict = await OperationLogService.detail_service(auth=auth, id=id) return SuccessResponse(data=result_dict, msg="获取操作日志详情成功") @@ -187,7 +187,7 @@ async def list( summary="创建操作日志", response_model=ResponseSchema[OperationLogDetailOutSchema], ) -async def create( +async def create_operation_log_controller( *, data: OperationLogCreateSchema, auth: Annotated[AuthSchema, Depends(get_current_user)], @@ -202,7 +202,7 @@ async def create( 返回: - JSONResponse: 包含创建后的操作日志详情的 JSON 响应。 """ - result_dict = await OperationLogService.create_service(auth, data) + result_dict = await OperationLogService.create_service(auth=auth, data=data) return SuccessResponse(data=result_dict, msg="创建操作日志成功") @@ -227,5 +227,5 @@ async def delete( 返回: - JSONResponse: 删除结果。 """ - await OperationLogService.delete_service(auth, data.ids) + await OperationLogService.delete_service(auth=auth, ids=data.ids) return SuccessResponse(msg="删除操作日志成功") diff --git a/backend/app/api/v1/module_system/log/crud.py b/backend/app/api/v1/module_system/log/crud.py index cf457545..144b69bf 100644 --- a/backend/app/api/v1/module_system/log/crud.py +++ b/backend/app/api/v1/module_system/log/crud.py @@ -16,4 +16,4 @@ class OperationLogCRUD(CRUDBase[OperationLogModel, None, None]): """操作日志 CRUD""" def __init__(self, auth: AuthSchema): - super().__init__(OperationLogModel, auth) + super().__init__(model=OperationLogModel, auth=auth) diff --git a/backend/app/api/v1/module_system/log/model.py b/backend/app/api/v1/module_system/log/model.py index 86e8f74c..264db429 100644 --- a/backend/app/api/v1/module_system/log/model.py +++ b/backend/app/api/v1/module_system/log/model.py @@ -29,6 +29,7 @@ class LoginLogModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_login_log" __table_args__: dict[str, str] = {"comment": "登录日志表"} + __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "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="备注") @@ -47,7 +48,7 @@ class OperationLogModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_operation_log" __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", "tenant_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/log/schema.py b/backend/app/api/v1/module_system/log/schema.py index cb730363..d1861d4d 100644 --- a/backend/app/api/v1/module_system/log/schema.py +++ b/backend/app/api/v1/module_system/log/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -47,6 +49,7 @@ class LoginLogDetailOutSchema(LoginLogOutSchema): """登录日志详情响应""" +@dataclass class LoginLogQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """登录日志查询参数""" @@ -61,33 +64,45 @@ class LoginLogQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): self.username = (QueueEnum.like.value, username) -class OperationLogQueryParam(BaseModel): - request_path: str | None = Field(None, max_length=255, description="请求路径") - request_method: str | None = Field(None, description="请求方式") - username: str | None = Field(None, max_length=64, description="用户名") +@dataclass +class OperationLogQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): + """操作日志查询参数""" - @field_validator("request_method") - @classmethod - def validate_request_method(cls, value: str | None) -> str | None: - if value and value.upper() not in ALLOWED_REQUEST_METHODS: - raise ValueError(f"请求方式必须是: {', '.join(ALLOWED_REQUEST_METHODS)}") - return value.upper() if value else None + def __init__( + self, + request_path: str | None = Query(None, description="请求路径"), + request_method: str | None = Query(None, description="请求方式"), + username: str | None = Query(None, description="用户名"), + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + if request_path: + self.request_path = (QueueEnum.like.value, request_path) + if request_method: + self.request_method = (QueueEnum.eq.value, request_method) + if username: + self.username = (QueueEnum.like.value, username) -class OperationLogOutSchema(BaseSchema): +class OperationLogOutSchema(BaseSchema, UserBySchema, TenantBySchema): + """操作日志响应模型""" + model_config = ConfigDict(from_attributes=True) - id: int - tenant_id: int - request_path: str - request_method: str - response_code: int - process_time: str | None = None + status: int | None = Field(default=None, description="状态(0:启动 1:停用)") + description: str | None = Field(default=None, description="描述") + request_path: str = Field(..., description="请求路径") + request_method: str = Field(..., description="请求方式") + response_code: int = Field(..., description="响应状态码") + process_time: str | None = Field(default=None, description="处理时间") class OperationLogDetailOutSchema(OperationLogOutSchema): - request_payload: str | None = None - response_json: str | None = None + """操作日志详情响应模型""" + + request_payload: str | None = Field(default=None, description="请求体") + response_json: str | None = Field(default=None, description="响应体") class OperationLogCreateSchema(BaseModel): @@ -97,6 +112,9 @@ class OperationLogCreateSchema(BaseModel): response_code: int = Field(200, ge=100, le=599, description="响应状态码") response_json: str | None = Field(None, description="响应体") process_time: str | None = Field(None, max_length=20, description="处理时间") + created_id: int | None = Field(None, description="创建人ID") + updated_id: int | None = Field(None, description="更新人ID") + description: str | None = Field(None, description="备注") @field_validator("request_method") @classmethod diff --git a/backend/app/api/v1/module_system/log/service.py b/backend/app/api/v1/module_system/log/service.py index 3b235318..a10ad32f 100644 --- a/backend/app/api/v1/module_system/log/service.py +++ b/backend/app/api/v1/module_system/log/service.py @@ -11,18 +11,20 @@ from .schema import ( OperationLogCreateSchema, OperationLogDetailOutSchema, OperationLogOutSchema, + OperationLogQueryParam, ) class LoginLogService: - """登录日志管理模块服务层""" + """ + 登录日志管理服务 + + 提供登录日志 CRUD、清理过期日志等业务能力。 + """ @classmethod async def detail_service(cls, auth: AuthSchema, id: int) -> LoginLogDetailOutSchema: - obj = await LoginLogCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="该数据不存在") - return LoginLogDetailOutSchema.model_validate(obj) + return await LoginLogCRUD(auth).get_or_404(id=id, out_schema=LoginLogDetailOutSchema) @classmethod async def page_service( @@ -33,7 +35,7 @@ class LoginLogService: search: LoginLogQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> dict: - search_dict = search.__dict__ if search else {} + search_dict = vars(search) if search else None order_by_list = order_by or [{"updated_time": "desc"}] offset = (page_no - 1) * page_size @@ -50,7 +52,7 @@ class LoginLogService: async def create_service(cls, auth: AuthSchema, data: LoginLogCreateSchema) -> LoginLogDetailOutSchema: obj = await LoginLogCRUD(auth).create(data=data) if not obj: - raise CustomException(msg="创建登录日志失败") + raise CustomException(msg="创建失败") return LoginLogDetailOutSchema.model_validate(obj) @classmethod @@ -68,12 +70,22 @@ class LoginLogService: class OperationLogService: + """ + 操作日志管理服务 + + 提供操作日志记录、分页查询、清理过期日志等业务能力。 + """ + @staticmethod async def cleanup_operation_log() -> None: - """定时任务:清理超过保留期的操作日志和登录日志(PRD §14.5) + """ + 定时任务:清理超过保留期的操作日志和登录日志。 - 清理 create_time < now - retention_days 的记录。 + 清理 created_time < now - retention_days 的记录。 保留期从全局参数 `operation_log_retention_days` 读取,默认 90 天。 + + 返回: + - bool: 清理完成返回 True。 """ from datetime import datetime, timedelta @@ -110,56 +122,95 @@ class OperationLogService: logger.info(f"操作日志清理完成: 操作日志 {op_result.rowcount} 条, 登录日志 {login_result.rowcount} 条") return True - @staticmethod - async def create_service(auth: AuthSchema, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema: + @classmethod + async def create_service(cls, auth: AuthSchema, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema: + """ + 创建操作日志。 + + 参数: + - auth (AuthSchema): 认证信息模型。 + - data (OperationLogCreateSchema): 操作日志创建模型。 + + 返回: + - OperationLogDetailOutSchema: 新创建的操作日志。 + + 异常: + - CustomException: 创建失败时抛出。 + """ crud = OperationLogCRUD(auth) - obj = await crud.create(data) + obj = await crud.create(data=data) if not obj: - raise CustomException(msg="创建操作日志失败") + raise CustomException(msg="创建失败") return OperationLogDetailOutSchema.model_validate(obj) - @staticmethod + @classmethod async def page_service( + cls, auth: AuthSchema, - page: int, + page_no: int, page_size: int, - search: dict | None = None, + search: OperationLogQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> dict: - from app.common.enums import QueueEnum + """ + 分页查询操作日志。 + 参数: + - auth (AuthSchema): 认证信息模型。 + - page_no (int): 页码。 + - page_size (int): 每页数量。 + - search (OperationLogQueryParam | None): 查询参数。 + - order_by (list[dict[str, str]] | None): 排序参数。 + + 返回: + - dict: 分页数据。 + """ crud = OperationLogCRUD(auth) - - # 构建过滤条件 - filters = {} - if search: - if search.get("request_path"): - filters["request_path"] = (QueueEnum.like.value, search["request_path"]) - if search.get("request_method"): - filters["request_method"] = (QueueEnum.eq.value, search["request_method"]) - if search.get("username"): - filters["username"] = (QueueEnum.like.value, search["username"]) - - result = await crud.page( - offset=(page - 1) * page_size, + return await crud.page( + offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"id": "desc"}], - search=filters, + search=vars(search) if search else None, out_schema=OperationLogOutSchema, ) - return result - @staticmethod - async def detail_service(auth: AuthSchema, id: int) -> OperationLogDetailOutSchema: + @classmethod + async def detail_service(cls, auth: AuthSchema, id: int) -> OperationLogDetailOutSchema: + """ + 获取操作日志详情。 + + 参数: + - auth (AuthSchema): 认证信息模型。 + - id (int): 操作日志ID。 + + 返回: + - OperationLogDetailOutSchema: 操作日志详情。 + """ + crud = OperationLogCRUD(auth) - obj = await crud.get(id=id) - if not obj: - raise CustomException(msg="该操作日志不存在") - return OperationLogDetailOutSchema.model_validate(obj) + return await crud.get_or_404(id=id, out_schema=OperationLogDetailOutSchema) - @staticmethod - async def delete_service(auth: AuthSchema, ids: list[int]) -> None: + @classmethod + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 删除操作日志。 + + 参数: + - auth (AuthSchema): 认证信息模型。 + - ids (list[int]): 操作日志ID列表。 + + 异常: + - CustomException: 删除失败时抛出。 + + 返回: + - None + """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") + existing = await OperationLogCRUD(auth).list(search={"id": ("in", ids)}) + existing_map = {obj.id for obj in existing} + for nid in ids: + if nid not in existing_map: + raise CustomException(msg="删除失败,该数据不存在") crud = OperationLogCRUD(auth) await crud.delete(ids) diff --git a/backend/app/api/v1/module_system/notice/controller.py b/backend/app/api/v1/module_system/notice/controller.py index 108f8797..37d2118a 100644 --- a/backend/app/api/v1/module_system/notice/controller.py +++ b/backend/app/api/v1/module_system/notice/controller.py @@ -32,7 +32,7 @@ _NOTICE_NS = "notice" summary="获取公告详情", response_model=ResponseSchema[NoticeOutSchema], ) -async def get_obj_detail_controller( +async def get_notice_detail_controller( id: Annotated[int, Path(description="公告ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:detail"]))], ) -> JSONResponse: @@ -46,7 +46,7 @@ async def get_obj_detail_controller( 返回: - JSONResponse: 包含公告详情的响应模型。 """ - result_dict = await NoticeService.get_notice_detail_service(id=id, auth=auth) + result_dict = await NoticeService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取公告详情成功") @@ -55,7 +55,7 @@ async def get_obj_detail_controller( summary="查询公告", response_model=ResponseSchema[PageResultSchema[NoticeOutSchema]], ) -async def get_obj_list_controller( +async def get_notice_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[NoticeQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:query"]))], @@ -71,7 +71,7 @@ async def get_obj_list_controller( 返回: - JSONResponse: 包含分页公告详情的响应模型。 """ - result_dict = await NoticeService.get_notice_page_service( + result_dict = await NoticeService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -86,7 +86,7 @@ async def get_obj_list_controller( summary="创建公告", response_model=ResponseSchema[NoticeOutSchema], ) -async def create_obj_controller( +async def create_notice_controller( data: NoticeCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:create"]))], ) -> JSONResponse: @@ -100,7 +100,7 @@ async def create_obj_controller( 返回: - JSONResponse: 包含创建公告结果的响应模型。 """ - result_dict = await NoticeService.create_notice_service(auth=auth, data=data) + result_dict = await NoticeService.create_service(auth=auth, data=data) await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="创建公告成功") @@ -110,7 +110,7 @@ async def create_obj_controller( summary="修改公告", response_model=ResponseSchema[NoticeOutSchema], ) -async def update_obj_controller( +async def update_notice_controller( data: NoticeUpdateSchema, id: Annotated[int, Path(description="公告ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:update"]))], @@ -126,7 +126,7 @@ async def update_obj_controller( 返回: - JSONResponse: 包含修改公告结果的响应模型。 """ - result_dict = await NoticeService.update_notice_service(auth=auth, id=id, data=data) + result_dict = await NoticeService.update_service(auth=auth, id=id, data=data) await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="修改公告成功") @@ -136,7 +136,7 @@ async def update_obj_controller( summary="删除公告", response_model=ResponseSchema[None], ) -async def delete_obj_controller( +async def delete_notice_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:delete"]))], ) -> JSONResponse: @@ -150,7 +150,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 包含删除公告结果的响应模型。 """ - await NoticeService.delete_notice_service(auth=auth, ids=ids) + await NoticeService.delete_service(auth=auth, ids=ids) await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="删除公告成功") @@ -160,7 +160,7 @@ async def delete_obj_controller( summary="批量修改公告状态", response_model=ResponseSchema[None], ) -async def batch_set_available_obj_controller( +async def batch_set_available_notice_controller( data: BatchSetAvailable, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:patch"]))], ) -> JSONResponse: @@ -174,17 +174,16 @@ async def batch_set_available_obj_controller( 返回: - JSONResponse: 包含批量修改公告状态结果的响应模型。 """ - await NoticeService.set_notice_available_service(auth=auth, data=data) + await NoticeService.set_available_service(auth=auth, data=data) await FastAPICache.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="批量修改公告状态成功") -@NoticeRouter.get( +@NoticeRouter.post( "/export", summary="导出公告", - response_model=ResponseSchema[None], ) -async def export_obj_list_controller( +async def export_notice_list_controller( search: Annotated[NoticeQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:export"]))], ) -> StreamingResponse: @@ -198,7 +197,7 @@ async def export_obj_list_controller( 返回: - StreamingResponse: 包含导出公告的流式响应模型。 """ - result_dict_list = await NoticeService.get_notice_list_service(search=search, auth=auth) + result_dict_list = await NoticeService.list_service(search=search, auth=auth) export_data = [item.model_dump() for item in result_dict_list] export_result = await NoticeService.export_notice_service(notice_list=export_data) @@ -215,7 +214,7 @@ async def export_obj_list_controller( response_model=ResponseSchema[list[NoticeOutSchema]], ) @cache(expire=120, namespace=_NOTICE_NS) -async def get_obj_list_available_controller( +async def get_notice_list_available_controller( auth: Annotated[AuthSchema, Depends(get_current_user)], ) -> JSONResponse: """ @@ -227,7 +226,7 @@ async def get_obj_list_available_controller( 返回: - JSONResponse: 包含分页已启用公告详情的响应模型。 """ - result_dict = await NoticeService.get_notice_available_page_service(auth=auth) + result_dict = await NoticeService.available_page_service(auth=auth) return SuccessResponse(data=result_dict, msg="查询已启用公告列表成功") diff --git a/backend/app/api/v1/module_system/notice/model.py b/backend/app/api/v1/module_system/notice/model.py index 96ce2705..e9320414 100644 --- a/backend/app/api/v1/module_system/notice/model.py +++ b/backend/app/api/v1/module_system/notice/model.py @@ -13,7 +13,7 @@ class NoticeModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_notice" __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", "tenant_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公告)") @@ -21,6 +21,7 @@ class NoticeModel(ModelMixin, TenantMixin, UserMixin): 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="备注") + class NoticeReadModel(MappedBase): """ 通知已读记录表 — 记录用户对公告的已读状态。 @@ -38,9 +39,8 @@ class NoticeReadModel(MappedBase): UniqueConstraint("user_id", "notice_id", name="uq_user_notice_read"), {"comment": "通知已读记录表"}, ) + __loader_options__: list[str] = ["notice"] - 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="备注") 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="已读时间") diff --git a/backend/app/api/v1/module_system/notice/schema.py b/backend/app/api/v1/module_system/notice/schema.py index 6cb25328..46e897e1 100644 --- a/backend/app/api/v1/module_system/notice/schema.py +++ b/backend/app/api/v1/module_system/notice/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import ( BaseModel, @@ -62,6 +64,7 @@ class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema, TenantBySche model_config = ConfigDict(from_attributes=True) +@dataclass class NoticeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """公告通知查询参数""" @@ -73,26 +76,25 @@ class NoticeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.notice_title = (QueueEnum.like.value, notice_title) - self.notice_type = (QueueEnum.eq.value, notice_type) - - -# ─── 通知面板 ─── + if notice_title: + self.notice_title = (QueueEnum.like.value, notice_title) + if notice_type: + self.notice_type = (QueueEnum.eq.value, notice_type) class PanelMessageItem(BaseModel): """面板-消息项""" - id: int - title: str - content: str - time: str - type: str + id: int = Field(..., description="消息ID") + title: str = Field(..., description="标题") + content: str = Field(..., description="内容") + time: str = Field(..., description="时间") + type: str = Field(..., description="类型") class PanelDataOut(BaseModel): """通知面板聚合数据""" - notices: list[NoticeOutSchema] = [] - messages: list[PanelMessageItem] = [] - pendings: list[dict] = [] + notices: list[NoticeOutSchema] = Field(default_factory=list, description="通知列表") + messages: list[PanelMessageItem] = Field(default_factory=list, description="消息列表") + pendings: list[dict] = Field(default_factory=list, description="待办列表") diff --git a/backend/app/api/v1/module_system/notice/service.py b/backend/app/api/v1/module_system/notice/service.py index 5968b593..0886bd4e 100644 --- a/backend/app/api/v1/module_system/notice/service.py +++ b/backend/app/api/v1/module_system/notice/service.py @@ -17,11 +17,13 @@ from .schema import ( class NoticeService: """ - 公告管理模块服务层 + 公告管理服务 + + 提供公告 CRUD、状态切换、已启用公告分页查询、消息面板、Excel 导出等业务能力。 """ @classmethod - async def get_notice_detail_service(cls, auth: AuthSchema, id: int) -> NoticeOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> NoticeOutSchema: """ 获取公告详情。 @@ -30,15 +32,12 @@ class NoticeService: - id (int): 公告ID。 返回: - - Dict: 公告详情字典。 + - NoticeOutSchema: 公告响应模型。 """ - notice_obj = await NoticeCRUD(auth).get(id=id) - if not notice_obj: - raise CustomException(msg="公告不存在") - return NoticeOutSchema.model_validate(notice_obj) + return await NoticeCRUD(auth).get_or_404(id=id, out_schema=NoticeOutSchema) @classmethod - async def get_notice_list_service( + async def list_service( cls, auth: AuthSchema, search: NoticeQueryParam | None = None, @@ -53,13 +52,13 @@ class NoticeService: - order_by (list[dict] | None): 排序参数列表。 返回: - - list[dict]: 公告详情字典列表。 + - list[NoticeOutSchema]: 公告响应模型列表。 """ - notice_obj_list = await NoticeCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + notice_obj_list = await NoticeCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [NoticeOutSchema.model_validate(notice_obj) for notice_obj in notice_obj_list] @classmethod - async def get_notice_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -85,12 +84,12 @@ class NoticeService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=NoticeOutSchema, ) @classmethod - async def get_notice_available_page_service(cls, auth: AuthSchema) -> dict: + async def available_page_service(cls, auth: AuthSchema) -> dict: """ 已启用公告分页(与历史行为一致:固定第 1 页、每页 10 条)。 @@ -109,7 +108,7 @@ class NoticeService: ) @classmethod - async def create_notice_service(cls, auth: AuthSchema, data: NoticeCreateSchema) -> NoticeOutSchema: + async def create_service(cls, auth: AuthSchema, data: NoticeCreateSchema) -> NoticeOutSchema: """ 创建公告。 @@ -118,19 +117,19 @@ class NoticeService: - data (NoticeCreateSchema): 创建公告负载模型。 返回: - - dict: 创建的公告详情字典。 + - NoticeOutSchema: 创建的公告响应模型。 异常: - CustomException: 创建失败,该公告通知已存在。 """ notice = await NoticeCRUD(auth).get(notice_title=data.notice_title) if notice: - raise CustomException(msg="创建失败,该公告通知已存在") + raise CustomException(msg="创建失败,该数据已存在") notice_obj = await NoticeCRUD(auth).create(data=data) return NoticeOutSchema.model_validate(notice_obj) @classmethod - async def update_notice_service(cls, auth: AuthSchema, id: int, data: NoticeUpdateSchema) -> NoticeOutSchema: + async def update_service(cls, auth: AuthSchema, id: int, data: NoticeUpdateSchema) -> NoticeOutSchema: """ 更新公告。 @@ -140,22 +139,20 @@ class NoticeService: - data (NoticeUpdateSchema): 更新公告负载模型。 返回: - - dict: 更新的公告详情字典。 + - NoticeOutSchema: 更新的公告响应模型。 异常: - CustomException: 更新失败,该公告通知不存在或公告通知标题重复。 """ - notice = await NoticeCRUD(auth).get(id=id) - if not notice: - raise CustomException(msg="更新失败,该公告通知不存在") + _ = await NoticeCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") exist_notice = await NoticeCRUD(auth).get(notice_title=data.notice_title) if exist_notice and exist_notice.id != id: - raise CustomException(msg="更新失败,公告通知标题重复") + raise CustomException(msg="更新失败,标题已存在") notice_obj = await NoticeCRUD(auth).update(id=id, data=data) return NoticeOutSchema.model_validate(notice_obj) @classmethod - async def delete_notice_service(cls, auth: AuthSchema, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除公告。 @@ -175,11 +172,11 @@ class NoticeService: notice_map = {n.id: n for n in notices} for nid in ids: if nid not in notice_map: - raise CustomException(msg="删除失败,该公告通知不存在") + raise CustomException(msg="删除失败,该数据不存在") await NoticeCRUD(auth).delete(ids=ids) @classmethod - async def set_notice_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 批量设置公告状态。 @@ -196,7 +193,7 @@ class NoticeService: await NoticeCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def export_notice_service(cls, notice_list: list[dict]) -> bytes: + async def export_service(cls, notice_list: list[dict]) -> bytes: """ 导出公告列表。 @@ -230,7 +227,7 @@ class NoticeService: return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) @classmethod - async def get_latest_notices_service(cls, auth: AuthSchema, limit: int = 5) -> list[NoticeOutSchema]: + async def latest_service(cls, auth: AuthSchema, limit: int = 5) -> list[NoticeOutSchema]: """获取最新 N 条已启用公告""" from sqlalchemy import desc, select @@ -322,12 +319,12 @@ class NoticeService: return max(0, total_count - read_count) @classmethod - async def get_panel_data_service(cls, auth: AuthSchema) -> PanelDataOut: + async def panel_data_service(cls, auth: AuthSchema) -> PanelDataOut: """聚合通知面板数据:通知 + 消息 + 待办""" from sqlalchemy import desc, select # 1. 通知:最新 5 条已启用公告 - notices = await cls.get_latest_notices_service(auth, limit=5) + notices = await cls.latest_service(auth, limit=5) # 2. 消息:最近的操作日志(作为系统消息) messages = [] diff --git a/backend/app/api/v1/module_system/params/controller.py b/backend/app/api/v1/module_system/params/controller.py index 675012a4..cc974e1a 100644 --- a/backend/app/api/v1/module_system/params/controller.py +++ b/backend/app/api/v1/module_system/params/controller.py @@ -22,7 +22,7 @@ ParamsRouter = APIRouter(route_class=OperationLogRoute, prefix="/param", tags=[" summary="获取参数详情", response_model=ResponseSchema[ParamsOutSchema], ) -async def get_type_detail_controller( +async def get_param_detail_controller( id: Annotated[int, Path(description="参数ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:detail"]))], ) -> JSONResponse: @@ -36,7 +36,7 @@ async def get_type_detail_controller( 返回: - JSONResponse: 包含参数详情的 JSON 响应 """ - result_dict = await ParamsService.get_obj_detail_service(id=id, auth=auth) + result_dict = await ParamsService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取参数详情成功") @@ -45,7 +45,7 @@ async def get_type_detail_controller( summary="根据配置键获取参数详情", response_model=ResponseSchema[ParamsOutSchema], ) -async def get_obj_by_key_controller( +async def get_param_by_key_controller( config_key: Annotated[str, Path(description="配置键")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))], ) -> JSONResponse: @@ -59,7 +59,7 @@ async def get_obj_by_key_controller( 返回: - JSONResponse: 包含参数详情的 JSON 响应 """ - result_dict = await ParamsService.get_obj_by_key_service(config_key=config_key, auth=auth) + result_dict = await ParamsService.get_by_key_service(config_key=config_key, auth=auth) return SuccessResponse(data=result_dict, msg="根据配置键获取参数详情成功") @@ -91,7 +91,7 @@ async def get_config_value_by_key_controller( summary="获取参数列表", response_model=ResponseSchema[PageResultSchema[ParamsOutSchema]], ) -async def get_obj_list_controller( +async def get_param_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))], page: Annotated[PaginationQueryParam, Depends()], search: Annotated[ParamsQueryParam, Depends()], @@ -107,7 +107,7 @@ async def get_obj_list_controller( 返回: - JSONResponse: 包含参数列表的 JSON 响应 """ - result_dict = await ParamsService.get_obj_page_service( + result_dict = await ParamsService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -122,7 +122,7 @@ async def get_obj_list_controller( summary="创建参数", response_model=ResponseSchema[ParamsOutSchema], ) -async def create_obj_controller( +async def create_param_controller( data: ParamsCreateSchema, redis: Annotated[Redis, Depends(redis_getter)], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:create"]))], @@ -138,7 +138,7 @@ async def create_obj_controller( 返回: - JSONResponse: 包含创建参数结果的 JSON 响应 """ - result_dict = await ParamsService.create_obj_service(auth=auth, redis=redis, data=data) + result_dict = await ParamsService.create_service(auth=auth, redis=redis, data=data) return SuccessResponse(data=result_dict, msg="创建参数成功") @@ -147,7 +147,7 @@ async def create_obj_controller( summary="修改参数", response_model=ResponseSchema[ParamsOutSchema], ) -async def update_objs_controller( +async def update_param_controller( data: ParamsUpdateSchema, id: Annotated[int, Path(description="参数ID")], redis: Annotated[Redis, Depends(redis_getter)], @@ -165,7 +165,7 @@ async def update_objs_controller( 返回: - JSONResponse: 包含修改参数结果的 JSON 响应 """ - result_dict = await ParamsService.update_obj_service(auth=auth, redis=redis, id=id, data=data) + result_dict = await ParamsService.update_service(auth=auth, redis=redis, id=id, data=data) return SuccessResponse(data=result_dict, msg="更新参数成功") @@ -174,7 +174,7 @@ async def update_objs_controller( summary="删除参数", response_model=ResponseSchema[ParamsOutSchema], ) -async def delete_obj_controller( +async def delete_param_controller( redis: Annotated[Redis, Depends(redis_getter)], ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:delete"]))], @@ -190,7 +190,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 包含删除参数结果的 JSON 响应 """ - await ParamsService.delete_obj_service(auth=auth, redis=redis, ids=ids) + await ParamsService.delete_service(auth=auth, redis=redis, ids=ids) return SuccessResponse(msg="删除参数成功") @@ -224,7 +224,7 @@ async def batch_set_status_controller( summary="导出参数", response_model=ResponseSchema[None], ) -async def export_obj_list_controller( +async def export_param_list_controller( search: Annotated[ParamsQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:export"]))], ) -> StreamingResponse: @@ -238,9 +238,9 @@ async def export_obj_list_controller( 返回: - StreamingResponse: 包含导出参数的 Excel 文件流响应 """ - result_dict_list = await ParamsService.get_obj_list_service(search=search, auth=auth) + result_dict_list = await ParamsService.list_service(search=search, auth=auth) export_data = [item.model_dump() for item in result_dict_list] - export_result = await ParamsService.export_obj_service(data_list=export_data) + export_result = await ParamsService.export_service(data_list=export_data) return StreamResponse( data=bytes2file_response(export_result), @@ -254,7 +254,7 @@ async def export_obj_list_controller( summary="获取初始化缓存参数", response_model=ResponseSchema[list[ParamsOutSchema]], ) -async def get_init_obj_controller( +async def get_init_config_controller( redis: Annotated[Redis, Depends(redis_getter)], ) -> JSONResponse: """ @@ -266,5 +266,5 @@ async def get_init_obj_controller( 返回: - JSONResponse: 获取初始化缓存参数的 JSON 响应 """ - result_dict = await ParamsService.get_init_config_service(redis=redis, tenant_id=1) + result_dict = await ParamsService.get_init_cache_service(redis=redis, tenant_id=1) 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 3c7610ea..0a8aa253 100644 --- a/backend/app/api/v1/module_system/params/model.py +++ b/backend/app/api/v1/module_system/params/model.py @@ -1,17 +1,25 @@ from sqlalchemy import Boolean, 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, TenantMixin, UserMixin -class ParamsModel(ModelMixin, TenantMixin): +class ParamsModel(ModelMixin, TenantMixin, UserMixin): """ - 参数配置表 + 系统参数表 + + 用于存储全局系统配置(如 retention_days、smtp 主机等)。 + 平台参数(tenant_id=1)对所有租户共享;租户级参数仅本租户可见。 """ __tablename__: str = "sys_param" __table_args__: dict[str, str] = {"comment": "系统参数表"} - __loader_options__: list[str] = [] + __loader_options__: list[str] = [ + "created_by", + "updated_by", + "deleted_by", + "tenant_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 b31a9712..e112593d 100644 --- a/backend/app/api/v1/module_system/params/schema.py +++ b/backend/app/api/v1/module_system/params/schema.py @@ -1,3 +1,6 @@ +import re +from dataclasses import dataclass + from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -7,21 +10,22 @@ from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema class ParamsCreateSchema(BaseModel): - """配置创建模型""" + """ + 参数创建模型 + """ config_name: str = Field(..., min_length=1, max_length=64, description="参数名称") - config_key: str = Field(..., min_length=1, max_length=500, description="参数键名") + config_key: str = Field(..., min_length=1, max_length=500, description="参数键名(小写字母开头,仅允许字母数字_.-)") config_value: str | None = Field(default=None, max_length=500, description="参数键值") - config_type: bool = Field(default=False, description="是否系统内置") + config_type: bool = Field(default=False, description="是否系统内置(True:是 False:否)") status: int = Field(default=0, ge=0, le=1, description="状态(0:正常 1:停用)") - description: str | None = Field(default=None, max_length=500, description="描述") + description: str | None = Field(default=None, max_length=500, description="参数描述") @field_validator("config_key") @classmethod def _validate_config_key(cls, v: str) -> str: + """校验参数键名:小写字母开头,仅含字母/数字/_ . -""" v = v.strip().lower() - import re - if not re.match(r"^[a-z][a-z0-9_.-]*$", v): raise ValueError("参数键名必须以小写字母开头,仅允许小写字母、数字、_ . -") return v @@ -29,33 +33,50 @@ class ParamsCreateSchema(BaseModel): @field_validator("status") @classmethod def _validate_status(cls, v: int) -> int: + """校验状态:仅支持 0(正常) 或 1(停用)""" if v not in {0, 1}: raise ValueError("状态仅支持 0(正常) 或 1(停用)") return v class ParamsUpdateSchema(ParamsCreateSchema): - """配置更新模型""" + """ + 参数更新模型 + """ class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema, TenantBySchema): - """配置响应模型""" + """ + 参数响应模型 + """ model_config = ConfigDict(from_attributes=True) +@dataclass class ParamsQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): - """配置管理查询参数""" + """ + 参数管理查询参数 + + 支持: + - 时间范围(BaseQueryParam) + - 创建人/更新人筛选(UserByQueryParam) + - 租户筛选(TenantByQueryParam) + - 业务字段:参数名称、参数键名、是否系统内置 + """ def __init__( self, - config_name: str | None = Query(None, description="配置名称"), - config_key: str | None = Query(None, description="配置键名"), - config_type: bool | None = Query(None, description="系统内置((True:是 False:否))"), + config_name: str | None = Query(None, description="参数名称"), + config_key: str | None = Query(None, description="参数键名"), + config_type: bool | None = Query(None, description="是否系统内置(True:是 False:否)"), *args, **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.config_name = (QueueEnum.like.value, config_name) - self.config_key = (QueueEnum.like.value, config_key) - self.config_type = (QueueEnum.eq.value, config_type) + if config_name: + self.config_name = (QueueEnum.like.value, config_name) + if config_key: + self.config_key = (QueueEnum.like.value, config_key) + if config_type is not None: + self.config_type = (QueueEnum.eq.value, config_type) diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 8448c116..37eb6ef1 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -26,62 +26,61 @@ _mid_config_cache: dict = {"ts": 0.0, "data": None} class ParamsService: """ - 配置管理模块服务层 + 参数管理服务 + + 提供参数 CRUD、Redis 缓存同步、初始化配置、批量启/禁用、Excel 导出等业务能力。 """ @classmethod - async def get_obj_detail_service(cls, auth: AuthSchema, id: int) -> ParamsOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> ParamsOutSchema: """ - 获取配置详情 + 获取参数详情 参数: - auth (AuthSchema): 认证信息模型 - - id (int): 配置管理型ID + - id (int): 参数ID 返回: - - dict: 配置管理型模型实例字典表示 + - ParamsOutSchema: 参数响应模型 """ - obj = await ParamsCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="参数不存在") - return ParamsOutSchema.model_validate(obj) + return await ParamsCRUD(auth).get_or_404(id=id, out_schema=ParamsOutSchema) @classmethod - async def get_obj_by_key_service(cls, auth: AuthSchema, config_key: str) -> ParamsOutSchema: + async def get_by_key_service(cls, auth: AuthSchema, config_key: str) -> ParamsOutSchema: """ - 根据配置键获取配置详情 + 根据配置键获取参数详情 参数: - auth (AuthSchema): 认证信息模型 - - config_key (str): 配置管理型key + - config_key (str): 参数键名 返回: - - Dict: 配置管理型模型实例字典表示 + - ParamsOutSchema: 参数响应模型 """ obj = await ParamsCRUD(auth).get(config_key=config_key) if not obj: - raise CustomException(msg=f"配置键 {config_key} 不存在") + raise CustomException(msg="该数据不存在") return ParamsOutSchema.model_validate(obj) @classmethod async def get_config_value_by_key_service(cls, auth: AuthSchema, config_key: str) -> str | None: """ - 根据配置键获取配置值 + 根据配置键获取参数值 参数: - auth (AuthSchema): 认证信息模型 - - config_key (str): 配置管理型key + - config_key (str): 参数键名 返回: - - str | None: 配置值字符串或None + - str | None: 参数键值字符串或 None """ obj = await ParamsCRUD(auth).get(config_key=config_key) if not obj: - raise CustomException(msg=f"配置键 {config_key} 不存在") + raise CustomException(msg="该数据不存在") return obj.config_value @classmethod - async def get_obj_list_service( + async def list_service( cls, auth: AuthSchema, search: ParamsQueryParam | None = None, @@ -96,13 +95,13 @@ class ParamsService: - order_by (list[dict] | None): 排序参数列表 返回: - - list[ParamsOutSchema]: 配置管理型模型实例 + - list[ParamsOutSchema]: 参数响应模型列表 """ - obj_list = await ParamsCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + obj_list = await ParamsCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [ParamsOutSchema.model_validate(obj) for obj in obj_list] @classmethod - async def get_obj_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -128,12 +127,12 @@ class ParamsService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=ParamsOutSchema, ) @classmethod - async def create_obj_service(cls, auth: AuthSchema, redis: Redis, data: ParamsCreateSchema) -> ParamsOutSchema: + async def create_service(cls, auth: AuthSchema, redis: Redis, data: ParamsCreateSchema) -> ParamsOutSchema: """ 创建配置管理型 @@ -143,11 +142,11 @@ class ParamsService: - data (ParamsCreateSchema): 配置管理型创建模型 返回: - - dict: 新创建的配置管理型模型实例字典表示 + - ParamsOutSchema: 新创建的参数响应模型 """ exist_obj = await ParamsCRUD(auth).get(config_key=data.config_key) if exist_obj: - raise CustomException(msg="创建失败,该配置key已存在") + raise CustomException(msg="创建失败,该数据已存在") obj = await ParamsCRUD(auth).create(data=data) out = ParamsOutSchema.model_validate(obj) @@ -167,27 +166,25 @@ class ParamsService: raise CustomException(msg="同步配置到缓存失败") except Exception as e: logger.error(f"创建字典类型失败: {e}") - raise CustomException(msg=f"创建字典类型失败 {e}") + raise CustomException(msg="同步配置到缓存失败") from e return out @classmethod - async def update_obj_service(cls, auth: AuthSchema, redis: Redis, id: int, data: ParamsUpdateSchema) -> ParamsOutSchema: + async def update_service(cls, auth: AuthSchema, redis: Redis, id: int, data: ParamsUpdateSchema) -> ParamsOutSchema: """ - 更新配置管理型 + 更新参数 参数: - auth (AuthSchema): 认证信息模型 - redis (Redis): Redis 客户端实例 - - id (int): 配置管理型ID - - data (ParamsUpdateSchema): 配置管理型更新模型 + - id (int): 参数ID + - data (ParamsUpdateSchema): 参数更新模型 返回: - - Dict: 更新后的配置管理型模型实例字典表示 + - ParamsOutSchema: 更新后的参数响应模型 """ - exist_obj = await ParamsCRUD(auth).get(id=id) - if not exist_obj: - raise CustomException(msg="更新失败,该数系统配置不存在") + exist_obj = await ParamsCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") if exist_obj.config_key != data.config_key: raise CustomException(msg="更新失败,系统配置key不允许修改") @@ -211,12 +208,12 @@ class ParamsService: raise CustomException(msg="同步配置到缓存失败") except Exception as e: logger.error(f"更新系统配置失败: {e}") - raise CustomException(msg="更新系统配置失败") + raise CustomException(msg="同步配置到缓存失败") from e return out @classmethod - async def delete_obj_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, redis: Redis, ids: list[int]) -> None: """ 删除配置管理型 @@ -236,7 +233,7 @@ class ParamsService: for pid in ids: obj = obj_map.get(pid) if not obj: - raise CustomException(msg="删除失败,该数据字典类型不存在") + raise CustomException(msg="删除失败,该数据不存在") if obj.config_type: raise CustomException(msg=f"{obj.config_name} 删除失败,系统初始化配置不可以删除") @@ -249,7 +246,7 @@ class ParamsService: await RedisCURD(redis).delete(redis_key) except Exception as e: logger.error(f"删除系统配置失败: {e}") - raise CustomException(msg="删除字典类型失败") + raise CustomException(msg="同步删除缓存失败") from e @classmethod async def batch_set_status_service(cls, auth: AuthSchema, ids: list[int], status: str) -> None: @@ -270,15 +267,15 @@ class ParamsService: await ParamsCRUD(auth).set(ids=ids, status=status) @classmethod - async def export_obj_service(cls, data_list: list[dict]) -> bytes: + async def export_service(cls, data_list: list[dict]) -> bytes: """ - 导出系统配置列表 + 导出参数列表 参数: - - data_list (list[dict]): 系统配置模型实例字典列表表示 + - data_list (list[dict]): 参数字典列表 返回: - - bytes: Excel文件二进制数据 + - bytes: Excel 文件字节流 """ mapping_dict = { "id": "编号", @@ -302,7 +299,7 @@ class ParamsService: return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) @classmethod - async def init_config_service(cls, redis: Redis) -> None: + async def init_cache_service(cls, redis: Redis) -> None: """ 初始化系统配置并按租户缓存。 @@ -317,7 +314,7 @@ class ParamsService: auth = AuthSchema(db=session, check_data_scope=False) config_obj = await ParamsCRUD(auth).list() if not config_obj: - raise CustomException(msg="系统配置不存在") + raise CustomException(msg="该数据不存在") try: for config in config_obj: tenant_id = config.tenant_id @@ -335,10 +332,10 @@ class ParamsService: raise CustomException(msg="初始化系统配置失败") except Exception as e: logger.error(f"❌️ 初始化系统配置失败: {e}") - raise CustomException(msg="初始化系统配置失败") + raise CustomException(msg="初始化系统配置失败") from e @classmethod - async def get_init_config_service(cls, redis: Redis, tenant_id: int = 1) -> list[dict]: + async def get_init_cache_service(cls, redis: Redis, tenant_id: int = 1) -> list[dict]: """ 获取系统配置 @@ -347,7 +344,7 @@ class ParamsService: - tenant_id (int): 租户ID 返回: - - list[dict]: 系统配置模型实例字典列表表示 + - list[dict]: 系统配置字典列表 """ redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:*") redis_configs = await RedisCURD(redis).mget(redis_keys) diff --git a/backend/app/api/v1/module_system/position/controller.py b/backend/app/api/v1/module_system/position/controller.py index 16175205..9923dd0a 100644 --- a/backend/app/api/v1/module_system/position/controller.py +++ b/backend/app/api/v1/module_system/position/controller.py @@ -50,7 +50,7 @@ async def get_obj_list_controller( order_by = [{"order": "asc"}] if page.order_by: order_by = page.order_by - result_dict = await PositionService.get_position_page_service( + result_dict = await PositionService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -79,7 +79,7 @@ async def get_obj_detail_controller( 返回: - JSONResponse: 岗位详情对象 """ - result_dict = await PositionService.get_position_detail_service(id=id, auth=auth) + result_dict = await PositionService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取岗位详情成功") @@ -102,7 +102,7 @@ async def create_obj_controller( 返回: - JSONResponse: 岗位详情对象 """ - result_dict = await PositionService.create_position_service(data=data, auth=auth) + result_dict = await PositionService.create_service(data=data, auth=auth) await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="创建岗位成功") @@ -128,7 +128,7 @@ async def update_obj_controller( 返回: - JSONResponse: 岗位详情对象 """ - result_dict = await PositionService.update_position_service(id=id, data=data, auth=auth) + result_dict = await PositionService.update_service(id=id, data=data, auth=auth) await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="修改岗位成功") @@ -152,7 +152,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 成功消息 """ - await PositionService.delete_position_service(ids=ids, auth=auth) + await PositionService.delete_service(ids=ids, auth=auth) await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(msg="删除岗位成功") @@ -176,7 +176,7 @@ async def batch_set_available_obj_controller( 返回: - JSONResponse: 成功消息 """ - await PositionService.set_position_available_service(data=data, auth=auth) + await PositionService.set_available_service(data=data, auth=auth) await FastAPICache.clear(namespace=_POS_NS) return SuccessResponse(msg="批量修改岗位状态成功") @@ -200,8 +200,8 @@ async def export_obj_list_controller( 返回: - StreamingResponse: 岗位Excel文件流 """ - position_query_result = await PositionService.get_position_list_service(search=search, auth=auth) - position_export_result = await PositionService.export_position_list_service(position_list=position_query_result) + position_query_result = await PositionService.list_service(search=search, auth=auth) + position_export_result = await PositionService.export_list_service(position_list=position_query_result) return StreamResponse( data=bytes2file_response(position_export_result), diff --git a/backend/app/api/v1/module_system/position/model.py b/backend/app/api/v1/module_system/position/model.py index a2e9bbf4..d049c7ce 100644 --- a/backend/app/api/v1/module_system/position/model.py +++ b/backend/app/api/v1/module_system/position/model.py @@ -16,7 +16,13 @@ class PositionModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_position" __table_args__: dict[str, str] = {"comment": "岗位表"} - __loader_options__: list[str] = ["users", "created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = [ + "users", + "created_by", + "updated_by", + "deleted_by", + "tenant_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/service.py b/backend/app/api/v1/module_system/position/service.py index dd4cca34..72d3ec08 100644 --- a/backend/app/api/v1/module_system/position/service.py +++ b/backend/app/api/v1/module_system/position/service.py @@ -12,10 +12,14 @@ from .schema import ( class PositionService: - """岗位模块服务层""" + """ + 岗位管理服务 + + 提供岗位 CRUD、批量启/禁用、Excel 导出等业务能力。 + """ @classmethod - async def get_position_detail_service(cls, auth: AuthSchema, id: int) -> PositionOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> PositionOutSchema: """ 获取岗位详情 @@ -24,15 +28,12 @@ class PositionService: - id (int): 岗位ID 返回: - - Dict: 岗位详情对象 + - PositionOutSchema: 岗位详情响应模型 """ - position = await PositionCRUD(auth).get(id=id) - if not position: - raise CustomException(msg="岗位不存在") - return PositionOutSchema.model_validate(position) + return await PositionCRUD(auth).get_or_404(id=id, out_schema=PositionOutSchema) @classmethod - async def get_position_list_service( + async def list_service( cls, auth: AuthSchema, search: PositionQueryParam | None = None, @@ -49,11 +50,11 @@ class PositionService: 返回: - list[PositionOutSchema]: 岗位列表 """ - position_list = await PositionCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + position_list = await PositionCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [PositionOutSchema.model_validate(position) for position in position_list] @classmethod - async def get_position_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -79,12 +80,12 @@ class PositionService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=PositionOutSchema, ) @classmethod - async def create_position_service(cls, auth: AuthSchema, data: PositionCreateSchema) -> PositionOutSchema: + async def create_service(cls, auth: AuthSchema, data: PositionCreateSchema) -> PositionOutSchema: """ 创建岗位 @@ -93,16 +94,16 @@ class PositionService: - data (PositionCreateSchema): 岗位创建模型 返回: - - dict: 创建的岗位详情字典 + - PositionOutSchema: 新创建的岗位响应模型 """ position = await PositionCRUD(auth).get(name=data.name) if position: - raise CustomException(msg="创建失败,该岗位已存在") + raise CustomException(msg="创建失败,该数据已存在") new_position = await PositionCRUD(auth).create(data=data) return PositionOutSchema.model_validate(new_position) @classmethod - async def update_position_service(cls, auth: AuthSchema, id: int, data: PositionUpdateSchema) -> PositionOutSchema: + async def update_service(cls, auth: AuthSchema, id: int, data: PositionUpdateSchema) -> PositionOutSchema: """ 更新岗位 @@ -112,19 +113,17 @@ class PositionService: - data (PositionUpdateSchema): 岗位更新模型 返回: - - dict: 更新的岗位对象 + - PositionOutSchema: 更新后的岗位响应模型 """ - position = await PositionCRUD(auth).get(id=id) - if not position: - raise CustomException(msg="更新失败,该岗位不存在") + _ = await PositionCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") exist_position = await PositionCRUD(auth).get(name=data.name) if exist_position and exist_position.id != id: - raise CustomException(msg="更新失败,岗位名称重复") + raise CustomException(msg="更新失败,名称已存在") updated_position = await PositionCRUD(auth).update(id=id, data=data) return PositionOutSchema.model_validate(updated_position) @classmethod - async def delete_position_service(cls, auth: AuthSchema, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除岗位 @@ -142,11 +141,11 @@ class PositionService: position_map = {p.id: p for p in positions} for pid in ids: if pid not in position_map: - raise CustomException(msg="删除失败,该岗位不存在") + raise CustomException(msg="删除失败,该数据不存在") await PositionCRUD(auth).delete(ids=ids) @classmethod - async def set_position_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 设置岗位状态 @@ -161,11 +160,11 @@ class PositionService: position_map = {p.id: p for p in positions} for pid in data.ids: if pid not in position_map: - raise CustomException(msg=f"岗位ID {pid} 不存在") + raise CustomException(msg="该数据不存在") await PositionCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def export_position_list_service(cls, position_list: list[dict]) -> bytes: + async def export_list_service(cls, position_list: list[dict]) -> bytes: """ 导出岗位列表 diff --git a/backend/app/api/v1/module_system/role/controller.py b/backend/app/api/v1/module_system/role/controller.py index fb48b0c7..948ceb37 100644 --- a/backend/app/api/v1/module_system/role/controller.py +++ b/backend/app/api/v1/module_system/role/controller.py @@ -32,7 +32,7 @@ _ROLE_NS = "role" response_model=ResponseSchema[PageResultSchema[RoleOutSchema]], ) @cache(expire=300, namespace=_ROLE_NS) -async def get_obj_list_controller( +async def get_role_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[RoleQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:query"]))], @@ -51,7 +51,7 @@ async def get_obj_list_controller( order_by = [{"order": "asc"}] if page.order_by: order_by = page.order_by - result_dict = await RoleService.get_role_page_service( + result_dict = await RoleService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -66,7 +66,7 @@ async def get_obj_list_controller( summary="查询角色详情", response_model=ResponseSchema[RoleOutSchema], ) -async def get_obj_detail_controller( +async def get_role_detail_controller( id: Annotated[int, Path(description="角色ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:detail"]))], ) -> JSONResponse: @@ -80,7 +80,7 @@ async def get_obj_detail_controller( 返回: - JSONResponse: 角色详情JSON响应 """ - result_dict = await RoleService.get_role_detail_service(id=id, auth=auth) + result_dict = await RoleService.detail_service(id=id, auth=auth) return SuccessResponse(data=result_dict, msg="获取角色详情成功") @@ -89,7 +89,7 @@ async def get_obj_detail_controller( summary="创建角色", response_model=ResponseSchema[RoleOutSchema], ) -async def create_obj_controller( +async def create_role_controller( data: RoleCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:create"]))], ) -> JSONResponse: @@ -103,7 +103,7 @@ async def create_obj_controller( 返回: - JSONResponse: 创建角色JSON响应 """ - result_dict = await RoleService.create_role_service(data=data, auth=auth) + result_dict = await RoleService.create_service(data=data, auth=auth) await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="创建角色成功") @@ -113,7 +113,7 @@ async def create_obj_controller( summary="修改角色", response_model=ResponseSchema[RoleOutSchema], ) -async def update_obj_controller( +async def update_role_controller( data: RoleUpdateSchema, id: Annotated[int, Path(description="角色ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:update"]))], @@ -129,7 +129,7 @@ async def update_obj_controller( 返回: - JSONResponse: 修改角色JSON响应 """ - result_dict = await RoleService.update_role_service(id=id, data=data, auth=auth) + result_dict = await RoleService.update_service(id=id, data=data, auth=auth) await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="修改角色成功") @@ -139,7 +139,7 @@ async def update_obj_controller( summary="删除角色", response_model=ResponseSchema[None], ) -async def delete_obj_controller( +async def delete_role_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:delete"]))], ) -> JSONResponse: @@ -153,7 +153,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 删除角色JSON响应 """ - await RoleService.delete_role_service(ids=ids, auth=auth) + await RoleService.delete_service(ids=ids, auth=auth) await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="删除角色成功") @@ -163,7 +163,7 @@ async def delete_obj_controller( summary="批量修改角色状态", response_model=ResponseSchema[None], ) -async def batch_set_available_obj_controller( +async def batch_set_available_role_controller( data: BatchSetAvailable, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:patch"]))], ) -> JSONResponse: @@ -177,7 +177,7 @@ async def batch_set_available_obj_controller( 返回: - JSONResponse: 批量修改角色状态JSON响应 """ - await RoleService.set_role_available_service(data=data, auth=auth) + await RoleService.set_available_service(data=data, auth=auth) await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="批量修改角色状态成功") @@ -201,7 +201,7 @@ async def set_role_permission_controller( 返回: - JSONResponse: 角色授权JSON响应 """ - await RoleService.set_role_permission_service(data=data, auth=auth) + await RoleService.set_permission_service(data=data, auth=auth) await FastAPICache.clear(namespace=_ROLE_NS) return SuccessResponse(msg="授权角色成功") @@ -211,7 +211,7 @@ async def set_role_permission_controller( summary="导出角色", response_model=ResponseSchema[None], ) -async def export_obj_list_controller( +async def export_role_list_controller( search: Annotated[RoleQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:export"]))], ) -> StreamingResponse: @@ -225,8 +225,8 @@ async def export_obj_list_controller( 返回: - StreamingResponse: 导出角色流响应 """ - role_query_result = await RoleService.get_role_list_service(search=search, auth=auth) - role_export_result = await RoleService.export_role_list_service(role_list=role_query_result) + role_query_result = await RoleService.list_service(search=search, auth=auth) + role_export_result = await RoleService.export_list_service(role_list=role_query_result) return StreamResponse( data=bytes2file_response(role_export_result), diff --git a/backend/app/api/v1/module_system/role/model.py b/backend/app/api/v1/module_system/role/model.py index dd9af112..039081a3 100644 --- a/backend/app/api/v1/module_system/role/model.py +++ b/backend/app/api/v1/module_system/role/model.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.common.enums import PermissionFilterStrategy -from app.core.base_model import MappedBase, ModelMixin, TenantMixin +from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin if TYPE_CHECKING: from app.api.v1.module_platform.menu.model import MenuModel @@ -61,7 +61,7 @@ class RoleDeptsModel(MappedBase): ) -class RoleModel(ModelMixin, TenantMixin): +class RoleModel(ModelMixin, TenantMixin, UserMixin): """ 角色模型 @@ -70,7 +70,14 @@ class RoleModel(ModelMixin, TenantMixin): __tablename__: str = "sys_role" __table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "角色表"}) - __loader_options__: list[str] = ["menus", "depts"] + __loader_options__: list[str] = [ + "menus", + "depts", + "created_by", + "updated_by", + "deleted_by", + "tenant_by", + ] __permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_ROLE name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称") @@ -80,6 +87,11 @@ class RoleModel(ModelMixin, TenantMixin): description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注") data_scope: Mapped[int] = mapped_column(Integer, default=1, nullable=False, comment="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)") - menus: Mapped[list["MenuModel"]] = relationship(secondary="sys_role_menus", back_populates="roles", lazy="selectin", order_by="MenuModel.order",) + menus: Mapped[list["MenuModel"]] = relationship( + secondary="sys_role_menus", + back_populates="roles", + lazy="selectin", + order_by="MenuModel.order", + ) depts: Mapped[list["DeptModel"]] = relationship(secondary="sys_role_depts", back_populates="roles", lazy="selectin") users: Mapped[list["UserModel"]] = relationship(secondary="sys_user_roles", back_populates="roles", lazy="selectin") diff --git a/backend/app/api/v1/module_system/role/schema.py b/backend/app/api/v1/module_system/role/schema.py index bb0c54ec..0695fb4d 100644 --- a/backend/app/api/v1/module_system/role/schema.py +++ b/backend/app/api/v1/module_system/role/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import ( BaseModel, @@ -10,16 +12,18 @@ from pydantic import ( from app.api.v1.module_platform.menu.schema import MenuOutSchema from app.api.v1.module_system.dept.schema import DeptOutSchema from app.common.enums import QueueEnum -from app.core.base_schema import BaseSchema +from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam +from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema from app.core.validator import ( - DateTimeStr, role_permission_request_validator, validate_required_code, ) class RoleCreateSchema(BaseModel): - """角色创建模型""" + """ + 角色创建模型 + """ name: str = Field(..., min_length=1, max_length=64, description="角色名称") code: str = Field(..., min_length=2, max_length=64, description="角色编码") @@ -58,10 +62,14 @@ class RoleCreateSchema(BaseModel): class RolePermissionSettingSchema(BaseModel): - """角色权限配置模型""" + """ + 角色权限配置模型 + """ data_scope: int = Field( default=1, + ge=1, + le=5, description="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)", ) role_ids: list[int] = Field(default_factory=list, description="角色ID列表") @@ -73,24 +81,22 @@ class RolePermissionSettingSchema(BaseModel): """ 校验角色权限配置字段(数据范围与关联 ID 等)。 - 参数: - - self: 当前模型实例(校验后状态)。 - 返回: - RolePermissionSettingSchema: 通过 `role_permission_request_validator` 校验后的同一实例。 - - 异常: - - CustomException: 不满足权限配置约束时抛出。 """ return role_permission_request_validator(self) class RoleUpdateSchema(RoleCreateSchema): - """角色更新模型""" + """ + 角色更新模型 + """ -class RoleOutSchema(RoleCreateSchema, BaseSchema): - """角色信息响应模型""" +class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema): + """ + 角色信息响应模型 + """ model_config = ConfigDict(from_attributes=True) @@ -98,36 +104,23 @@ class RoleOutSchema(RoleCreateSchema, BaseSchema): depts: list[DeptOutSchema] = Field(default_factory=list, description="角色部门列表") -class RoleQueryParam: - """角色管理查询参数""" +@dataclass +class RoleQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): + """ + 角色管理查询参数 + """ def __init__( self, name: str | None = Query(None, description="角色名称"), - description: str | None = Query(None, description="描述"), - status: str | None = Query(None, description="是否启用"), - created_time: list[DateTimeStr] | None = Query( - None, - description="创建时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ), - updated_time: list[DateTimeStr] | None = Query( - None, - description="更新时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ), + code: str | None = Query(None, description="角色编码"), + status: int | None = Query(None, description="状态(0:启动 1:停用)"), + *args, + **kwargs, ) -> None: - # 模糊查询字段 + super().__init__(*args, **kwargs) self.name = (QueueEnum.like.value, name) - if description: - self.description = (QueueEnum.like.value, description) - - # 精确查询字段 - if status: + if code: + self.code = (QueueEnum.like.value, code) + if status is not None: self.status = (QueueEnum.eq.value, status) - - # 时间范围查询 - if created_time and len(created_time) == 2: - self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1])) - if updated_time and len(updated_time) == 2: - self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1])) diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index 8a9cd177..8c9829e6 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -1,5 +1,6 @@ from typing import Any +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.excel_util import ExcelUtil @@ -15,10 +16,14 @@ from .schema import ( class RoleService: - """角色模块服务层""" + """ + 角色管理服务 + + 提供角色 CRUD、权限配置、数据权限范围设置、批量启/禁用、Excel 导出等业务能力。 + """ @classmethod - async def get_role_detail_service(cls, auth: AuthSchema, id: int) -> RoleOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> RoleOutSchema: """ 获取角色详情 @@ -27,15 +32,12 @@ class RoleService: - id (int): 角色ID 返回: - - dict: 角色详情字典 + - RoleOutSchema: 角色详情响应模型 """ - role = await RoleCRUD(auth).get(id=id) - if not role: - raise CustomException(msg="角色不存在") - return RoleOutSchema.model_validate(role) + return await RoleCRUD(auth).get_or_404(id=id, out_schema=RoleOutSchema) @classmethod - async def get_role_list_service( + async def list_service( cls, auth: AuthSchema, search: RoleQueryParam | None = None, @@ -50,13 +52,13 @@ class RoleService: - order_by (list[dict[str, str]] | None): 排序参数列表 返回: - - list[RoleOutSchema]: 角色详情字典列表 + - list[RoleOutSchema]: 角色响应模型列表 """ - role_list = await RoleCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + role_list = await RoleCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [RoleOutSchema.model_validate(role) for role in role_list] @classmethod - async def get_role_page_service( + async def page_service( cls, auth: AuthSchema, page_no: int, @@ -82,12 +84,12 @@ class RoleService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=RoleOutSchema, ) @classmethod - async def create_role_service(cls, auth: AuthSchema, data: RoleCreateSchema) -> RoleOutSchema: + async def create_service(cls, auth: AuthSchema, data: RoleCreateSchema) -> RoleOutSchema: """ 创建角色 @@ -96,25 +98,23 @@ class RoleService: - data (RoleCreateSchema): 创建角色模型 返回: - - dict: 新创建的角色详情字典 + - RoleOutSchema: 新创建的角色响应模型 """ role = await RoleCRUD(auth).get(name=data.name) if role: - raise CustomException(msg="创建失败,该角色已存在") + raise CustomException(msg="创建失败,该数据已存在") obj = await RoleCRUD(auth).get(code=data.code) if obj: raise CustomException(msg="创建失败,编码已存在") # 检查租户配额 - from app.api.v1.module_platform.tenant.service import TenantService - await TenantService.check_quota_service(auth, auth.tenant_id, "role") new_role = await RoleCRUD(auth).create(data=data) return RoleOutSchema.model_validate(new_role) @classmethod - async def update_role_service(cls, auth: AuthSchema, id: int, data: RoleUpdateSchema) -> RoleOutSchema: + async def update_service(cls, auth: AuthSchema, id: int, data: RoleUpdateSchema) -> RoleOutSchema: """ 更新角色 @@ -124,14 +124,12 @@ class RoleService: - data (RoleUpdateSchema): 更新角色模型 返回: - - dict: 更新后的角色详情字典 + - RoleOutSchema: 更新后的角色响应模型 """ - role = await RoleCRUD(auth).get(id=id) - if not role: - raise CustomException(msg="更新失败,该角色不存在") + _ = await RoleCRUD(auth).get_or_404(id=id, msg="更新失败,该数据不存在") exist_role = await RoleCRUD(auth).get(name=data.name) if exist_role and exist_role.id != id: - raise CustomException(msg="更新失败,角色名称重复") + raise CustomException(msg="更新失败,名称已存在") exist_code = await RoleCRUD(auth).get(code=data.code) if exist_code and exist_code.id != id: raise CustomException(msg="更新失败,角色编码已存在") @@ -139,7 +137,7 @@ class RoleService: return RoleOutSchema.model_validate(updated_role) @classmethod - async def delete_role_service(cls, auth: AuthSchema, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除角色 @@ -156,14 +154,12 @@ class RoleService: # 批量校验角色存在性 roles = await RoleCRUD(auth).list(search={"id": ("in", ids)}) if len(roles) != len(ids): - found = {r.id for r in roles} - missing = [rid for rid in ids if rid not in found] - raise CustomException(msg=f"角色 ID {missing} 不存在") + raise CustomException(msg="删除失败,部分ID不存在") await RoleCRUD(auth).delete(ids=ids) @classmethod - async def set_role_permission_service(cls, auth: AuthSchema, data: RolePermissionSettingSchema) -> None: + async def set_permission_service(cls, auth: AuthSchema, data: RolePermissionSettingSchema) -> None: """ 设置角色权限 @@ -187,7 +183,7 @@ class RoleService: await RoleCRUD(auth).set_role_depts_crud(role_ids=data.role_ids, dept_ids=[]) @classmethod - async def set_role_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 设置角色可用状态 @@ -202,11 +198,11 @@ class RoleService: role_map = {r.id: r for r in roles} for rid in data.ids: if rid not in role_map: - raise CustomException(msg=f"角色ID {rid} 不存在") + raise CustomException(msg="该数据不存在") await RoleCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def export_role_list_service(cls, role_list: list[dict[str, Any]]) -> bytes: + async def export_list_service(cls, role_list: list[dict[str, Any]]) -> bytes: """ 导出角色列表 diff --git a/backend/app/api/v1/module_system/ticket/controller.py b/backend/app/api/v1/module_system/ticket/controller.py index e5098a14..a029300e 100644 --- a/backend/app/api/v1/module_system/ticket/controller.py +++ b/backend/app/api/v1/module_system/ticket/controller.py @@ -22,7 +22,7 @@ TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=[ @TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[PageResultSchema[TicketOutSchema]]) -async def ticket_list( +async def ticket_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[TicketQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))], @@ -49,8 +49,8 @@ async def ticket_list( @TicketRouter.get("/detail/{id}", summary="工单详情", response_model=ResponseSchema[TicketOutSchema]) -async def ticket_detail( - id: Annotated[int, Path()], +async def ticket_detail_controller( + id: Annotated[int, Path(description="ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))], ) -> JSONResponse: """ @@ -68,7 +68,7 @@ async def ticket_detail( @TicketRouter.post("/create", summary="创建工单", response_model=ResponseSchema[TicketOutSchema]) -async def ticket_create( +async def ticket_create_controller( data: TicketCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:create"]))], ) -> JSONResponse: @@ -87,8 +87,8 @@ async def ticket_create( @TicketRouter.put("/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema]) -async def ticket_update( - id: Annotated[int, Path()], +async def ticket_update_controller( + id: Annotated[int, Path(description="ID")], data: TicketUpdateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))], ) -> JSONResponse: @@ -108,7 +108,7 @@ async def ticket_update( @TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema) -async def ticket_batch_update( +async def ticket_batch_update_controller( data: TicketBatchSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))], ) -> JSONResponse: @@ -127,7 +127,7 @@ async def ticket_batch_update( @TicketRouter.delete("/delete", summary="删除工单", response_model=ResponseSchema[None]) -async def ticket_delete( +async def ticket_delete_controller( ids: Annotated[list[int], Body()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:delete"]))], ) -> JSONResponse: diff --git a/backend/app/api/v1/module_system/ticket/model.py b/backend/app/api/v1/module_system/ticket/model.py index 83ba8d28..a2249c60 100644 --- a/backend/app/api/v1/module_system/ticket/model.py +++ b/backend/app/api/v1/module_system/ticket/model.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, String, Text +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 @@ -16,10 +16,16 @@ class TicketModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "sys_ticket" __table_args__: dict[str, str] = {"comment": "工单表"} - __loader_options__: list[str] = ["created_by", "updated_by", "assigned_by"] + __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:停用)", index=True) + 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="工单内容(纯文本摘要)") diff --git a/backend/app/api/v1/module_system/ticket/schema.py b/backend/app/api/v1/module_system/ticket/schema.py index f238d38e..aa63657c 100644 --- a/backend/app/api/v1/module_system/ticket/schema.py +++ b/backend/app/api/v1/module_system/ticket/schema.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pydantic import BaseModel, ConfigDict, Field, field_validator -from app.common.enums import QueueEnum +from app.common.enums import QueueEnum, TicketTypeEnum from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam from app.core.base_schema import BaseSchema, CommonSchema, TenantBySchema, UserBySchema @@ -13,18 +13,10 @@ class TicketCreateSchema(BaseModel): title: str = Field(..., min_length=1, max_length=200, description="工单标题") ticket_content: str = Field(default="", description="工单内容(富文本)") summary: str | None = Field(default=None, description="工单内容(纯文本摘要)") - ticket_type: str = Field(default="suggestion", max_length=20, description="工单类型(suggestion/bug/optimize/other)") + 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("ticket_type") - @classmethod - def _validate_ticket_type(cls, v: str) -> str: - allowed = {"suggestion", "bug", "optimize", "other"} - if v not in allowed: - raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}") - return v - @field_validator("title") @classmethod def _validate_title(cls, v: str) -> str: @@ -40,22 +32,12 @@ class TicketUpdateSchema(BaseModel): title: str | None = Field(default=None, max_length=200, description="工单标题") ticket_content: str | None = Field(default=None, description="工单内容(富文本)") summary: str | None = Field(default=None, description="工单内容(纯文本摘要)") - ticket_type: str | None = Field(default=None, max_length=20, description="工单类型") + 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("ticket_type") - @classmethod - def _validate_ticket_type(cls, v: str | None) -> str | None: - if v is None: - return v - allowed = {"suggestion", "bug", "optimize", "other"} - if v not in allowed: - raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}") - return v - @field_validator("status") @classmethod def _validate_status(cls, v: int | None) -> int | None: @@ -71,18 +53,15 @@ class TicketOutSchema(BaseSchema, UserBySchema, TenantBySchema): model_config = ConfigDict(from_attributes=True) - id: int - title: str - ticket_content: str | None = None - summary: str | None = None - ticket_type: str - status: int - images: str | None = None - reply: str | None = None - assigned_id: int | None = None - created_by: CommonSchema | None = None - updated_by: CommonSchema | None = None - assigned_by: CommonSchema | None = None + 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): diff --git a/backend/app/api/v1/module_system/ticket/service.py b/backend/app/api/v1/module_system/ticket/service.py index 58431659..9cad23c2 100644 --- a/backend/app/api/v1/module_system/ticket/service.py +++ b/backend/app/api/v1/module_system/ticket/service.py @@ -1,3 +1,6 @@ +from sqlalchemy import select + +from app.api.v1.module_system.user.model import UserModel from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException @@ -26,7 +29,11 @@ _TICKET_STATUS_LABELS = { class TicketService: - """工单管理服务层""" + """ + 工单管理服务 + + 提供工单 CRUD、状态流转校验、批量更新、分配处理人等业务能力。 + """ @classmethod def _validate_status_transition( @@ -35,13 +42,23 @@ class TicketService: ticket, new_status: int, ) -> None: - """校验工单状态流转是否合法""" + """ + 校验工单状态流转是否合法 + + 参数: + - auth (AuthSchema): 认证信息模型 + - ticket: 工单对象 + - new_status (int): 新状态 + + 异常: + - CustomException: 状态流转不合法或权限不足 + """ 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}”") + raise CustomException(msg=f"不允许从{old_label}转换为{new_label}") is_super = auth.user and auth.user.is_superuser is_creator = auth.user and ticket.created_id == auth.user.id @@ -75,23 +92,53 @@ class TicketService: search: TicketQueryParam | None = None, order_by: list | None = None, ) -> dict: + """ + 分页查询工单 + + 参数: + - auth (AuthSchema): 认证信息模型 + - page_no (int): 页码 + - page_size (int): 每页条数 + - search (TicketQueryParam | None): 查询参数 + - order_by (list | None): 排序参数 + + 返回: + - dict: 分页结果 + """ return await TicketCRUD(auth).page( offset=(page_no - 1) * page_size, limit=page_size, order_by=order_by or [{"created_time": "desc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=TicketOutSchema, ) @classmethod async def detail_service(cls, auth: AuthSchema, id: int) -> TicketOutSchema: - obj = await TicketCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="工单不存在") - return TicketOutSchema.model_validate(obj) + """ + 获取工单详情 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 工单ID + + 返回: + - TicketOutSchema: 工单详情响应模型 + """ + return await TicketCRUD(auth).get_or_404(id=id, out_schema=TicketOutSchema) @classmethod async def create_service(cls, auth: AuthSchema, data: TicketCreateSchema) -> TicketOutSchema: + """ + 创建工单 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (TicketCreateSchema): 工单创建数据 + + 返回: + - TicketOutSchema: 创建后的工单响应模型 + """ obj = await TicketCRUD(auth).create(data=data) if not obj: raise CustomException(msg="创建工单失败") @@ -99,19 +146,24 @@ class TicketService: @classmethod async def update_service(cls, auth: AuthSchema, id: int, data: TicketUpdateSchema) -> TicketOutSchema: - obj = await TicketCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg="工单不存在") + """ + 更新工单 + + 参数: + - auth (AuthSchema): 认证信息模型 + - id (int): 工单ID + - data (TicketUpdateSchema): 工单更新数据 + + 返回: + - TicketOutSchema: 更新后的工单响应模型 + """ + obj = await TicketCRUD(auth).get_or_404(id=id, msg="工单不存在") if data.status is not None: cls._validate_status_transition(auth, obj, data.status) # 校验 assigned_id:分配处理人时验证用户是否存在且属于同一租户 if data.assigned_id is not None: - from sqlalchemy import select - - from app.api.v1.module_system.user.model import UserModel - user_stmt = select(UserModel).where( UserModel.id == data.assigned_id, UserModel.is_deleted.is_(False), @@ -125,18 +177,31 @@ class TicketService: updated = await TicketCRUD(auth).update(id=id, data=data) if not updated: - raise CustomException(msg="更新失败") + raise CustomException(msg="工单不存在") return TicketOutSchema.model_validate(updated) @classmethod async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: + """ + 删除工单 + + 参数: + - auth (AuthSchema): 认证信息模型 + - ids (list[int]): 工单ID列表 + """ if not ids: raise CustomException(msg="删除对象不能为空") await TicketCRUD(auth).delete(ids=ids) @classmethod async def batch_service(cls, auth: AuthSchema, data: TicketBatchSchema) -> None: - """批量更新工单状态""" + """ + 批量更新工单状态 + + 参数: + - auth (AuthSchema): 认证信息模型 + - data (TicketBatchSchema): 批量更新参数 + """ if not data.ids: raise CustomException(msg="请选择要操作的工单") diff --git a/backend/app/api/v1/module_system/user/controller.py b/backend/app/api/v1/module_system/user/controller.py index 851bc704..74ad3307 100644 --- a/backend/app/api/v1/module_system/user/controller.py +++ b/backend/app/api/v1/module_system/user/controller.py @@ -46,8 +46,8 @@ async def get_current_user_info_controller( 返回: - JSONResponse: 当前用户信息JSON响应 """ - result_dict = await UserService.get_current_user_info_service(auth=auth) - return SuccessResponse(data=result_dict, msg="获取当前用户信息成功") + user_dict = await UserService.current_info_service(auth=auth) + return SuccessResponse(data=user_dict, msg="获取当前用户信息成功") @UserRouter.put( @@ -69,7 +69,7 @@ async def update_current_user_info_controller( 返回: - JSONResponse: 更新当前用户基本信息JSON响应 """ - result_dict = await UserService.update_current_user_info_service(data=data, auth=auth) + result_dict = await UserService.update_current_info_service(auth=auth, data=data) return SuccessResponse(data=result_dict, msg="更新当前用户基本信息成功") @@ -92,7 +92,7 @@ async def change_current_user_password_controller( 返回: - JSONResponse: 修改密码JSON响应 """ - result_dict = await UserService.change_user_password_service(data=data, auth=auth) + result_dict = await UserService.change_password_service(auth=auth, data=data) return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录") @@ -118,7 +118,7 @@ async def reset_password_controller( - JSONResponse: 重置密码JSON响应 """ data.id = id - result_dict = await UserService.reset_user_password_service(data=data, auth=auth) + result_dict = await UserService.reset_password_service(auth=auth, data=data) return SuccessResponse(data=result_dict, msg="重置密码成功") @@ -142,7 +142,7 @@ async def register_user_controller( - JSONResponse: 注册用户JSON响应 """ auth = AuthSchema(db=db) - user_register_result = await UserService.register_user_service(data=data, auth=auth) + user_register_result = await UserService.register_service(data=data, auth=auth) logger.info(f"{data.username} 注册用户成功: {user_register_result}") return SuccessResponse(data=user_register_result, msg="注册用户成功") @@ -177,7 +177,7 @@ async def forget_password_controller( summary="查询用户", response_model=ResponseSchema[PageResultSchema[UserOutSchema]], ) -async def get_obj_list_controller( +async def get_user_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[UserQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:query"]))], @@ -193,7 +193,7 @@ async def get_obj_list_controller( 返回: - JSONResponse: 分页查询结果JSON响应 """ - result_dict = await UserService.get_user_page_service( + result_dict = await UserService.page_service( auth=auth, page_no=page.page_no, page_size=page.page_size, @@ -208,7 +208,7 @@ async def get_obj_list_controller( summary="查询用户详情", response_model=ResponseSchema[UserOutSchema], ) -async def get_obj_detail_controller( +async def get_user_detail_controller( id: Annotated[int, Path(description="用户ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:detail"]))], ) -> JSONResponse: @@ -222,7 +222,7 @@ async def get_obj_detail_controller( 返回: - JSONResponse: 用户详情JSON响应 """ - result_dict = await UserService.get_detail_by_id_service(id=id, auth=auth) + result_dict = await UserService.detail_service(auth=auth, id=id) return SuccessResponse(data=result_dict, msg="获取用户详情成功") @@ -231,7 +231,7 @@ async def get_obj_detail_controller( summary="创建用户", response_model=ResponseSchema[UserOutSchema], ) -async def create_obj_controller( +async def create_user_controller( data: UserCreateSchema, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:create"]))], ) -> JSONResponse: @@ -249,7 +249,7 @@ async def create_obj_controller( 返回: - JSONResponse: 创建用户JSON响应 """ - result_dict = await UserService.create_user_service(data=data, auth=auth) + result_dict = await UserService.create_service(data=data, auth=auth) return SuccessResponse(data=result_dict, msg="创建用户成功") @@ -258,7 +258,7 @@ async def create_obj_controller( summary="修改用户", response_model=ResponseSchema[UserOutSchema], ) -async def update_obj_controller( +async def update_user_controller( data: UserUpdateSchema, id: Annotated[int, Path(description="用户ID")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))], @@ -274,7 +274,7 @@ async def update_obj_controller( 返回: - JSONResponse: 修改用户JSON响应 """ - result_dict = await UserService.update_user_service(id=id, data=data, auth=auth) + result_dict = await UserService.update_service(auth=auth, id=id, data=data) return SuccessResponse(data=result_dict, msg="修改用户成功") @@ -283,7 +283,7 @@ async def update_obj_controller( summary="删除用户", response_model=ResponseSchema[None], ) -async def delete_obj_controller( +async def delete_user_controller( ids: Annotated[list[int], Body(description="ID列表")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:delete"]))], ) -> JSONResponse: @@ -297,7 +297,7 @@ async def delete_obj_controller( 返回: - JSONResponse: 删除用户JSON响应 """ - await UserService.delete_user_service(ids=ids, auth=auth) + await UserService.delete_service(auth=auth, ids=ids) return SuccessResponse(msg="删除用户成功") @@ -306,7 +306,7 @@ async def delete_obj_controller( summary="批量修改用户状态", response_model=ResponseSchema[None], ) -async def batch_set_available_obj_controller( +async def batch_set_available_user_controller( data: BatchSetAvailable, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:patch"]))], ) -> JSONResponse: @@ -320,7 +320,7 @@ async def batch_set_available_obj_controller( 返回: - JSONResponse: 批量修改用户状态JSON响应 """ - await UserService.set_user_available_service(data=data, auth=auth) + await UserService.set_available_service(auth=auth, data=data) return SuccessResponse(msg="批量修改用户状态成功") @@ -330,14 +330,14 @@ async def batch_set_available_obj_controller( response_model=ResponseSchema[None], dependencies=[Depends(AuthPermission(["module_system:user:download"]))], ) -async def export_obj_template_controller() -> StreamingResponse: +async def export_user_import_template_controller() -> StreamingResponse: """ 获取用户导入模板 返回: - StreamingResponse: 用户导入模板流响应 """ - user_import_template_result = await UserService.get_import_template_user_service() + user_import_template_result = await UserService.get_import_template_service() return StreamResponse( data=bytes2file_response(user_import_template_result), @@ -354,7 +354,7 @@ async def export_obj_template_controller() -> StreamingResponse: summary="导出用户", response_model=ResponseSchema[None], ) -async def export_obj_list_controller( +async def export_user_list_controller( page: Annotated[PaginationQueryParam, Depends()], search: Annotated[UserQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:export"]))], @@ -370,8 +370,8 @@ async def export_obj_list_controller( 返回: - StreamingResponse: 用户导出模板流响应 """ - user_list = await UserService.get_user_list_service(auth=auth, search=search, order_by=page.order_by) - user_export_result = await UserService.export_user_list_service(user_list) + user_list = await UserService.list_service(auth=auth, search=search, order_by=page.order_by) + user_export_result = await UserService.export_list_service(user_list=user_list) return StreamResponse( data=bytes2file_response(user_export_result), @@ -385,7 +385,7 @@ async def export_obj_list_controller( summary="导入用户", response_model=ResponseSchema[None], ) -async def import_obj_list_controller( +async def import_user_list_controller( file: UploadFile, auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:import"]))], ) -> JSONResponse: @@ -399,5 +399,5 @@ async def import_obj_list_controller( 返回: - JSONResponse: 导入用户JSON响应 """ - batch_import_result = await UserService.batch_import_user_service(file=file, auth=auth, update_support=True) + batch_import_result = await UserService.batch_import_service(auth=auth, file=file, update_support=True) return SuccessResponse(data=batch_import_result, msg="导入用户成功") diff --git a/backend/app/api/v1/module_system/user/model.py b/backend/app/api/v1/module_system/user/model.py index c4b29fb6..b1a08b3a 100644 --- a/backend/app/api/v1/module_system/user/model.py +++ b/backend/app/api/v1/module_system/user/model.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin @@ -87,10 +87,14 @@ class UserModel(ModelMixin, TenantMixin, UserMixin): description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注") 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,) + 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") created_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.created_id", remote_side="UserModel.id", lazy="selectin", uselist=False, viewonly=True) updated_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.updated_id", remote_side="UserModel.id", lazy="selectin", uselist=False, viewonly=True) - diff --git a/backend/app/api/v1/module_system/user/schema.py b/backend/app/api/v1/module_system/user/schema.py index 59f53061..9429092d 100644 --- a/backend/app/api/v1/module_system/user/schema.py +++ b/backend/app/api/v1/module_system/user/schema.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from urllib.parse import urlparse from fastapi import Query @@ -13,8 +14,9 @@ from pydantic import ( from app.api.v1.module_platform.menu.schema import MenuOutSchema from app.api.v1.module_system.role.schema import RoleOutSchema from app.common.enums import QueueEnum +from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam from app.core.base_schema import BaseSchema, CommonSchema, TenantBySchema, UserBySchema -from app.core.validator import DateTimeStr, email_validator, mobile_validator +from app.core.validator import email_validator, mobile_validator class CurrentUserUpdateSchema(BaseModel): @@ -92,6 +94,7 @@ class UserRegisterSchema(BaseModel): if not v: raise ValueError("账号不能为空") import re + if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v): raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -") return v @@ -133,6 +136,7 @@ class UserForgetPasswordSchema(BaseModel): if not v: raise ValueError("账号不能为空") import re + if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v): raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -") return v @@ -189,9 +193,9 @@ class ResetPasswordSchema(BaseModel): class UserCreateSchema(CurrentUserUpdateSchema): - """新增""" - - model_config = ConfigDict(from_attributes=True) + """ + 新增用户 + """ username: str | None = Field(default=None, max_length=32, description="用户名") password: str | None = Field(default=None, min_length=6, max_length=128, description="密码") @@ -219,6 +223,7 @@ class UserCreateSchema(CurrentUserUpdateSchema): return value v = value.strip() import re + if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{1,31}$", v): raise ValueError("账号需以字母开头,2-32 位,仅允许字母、数字、_ . -") return v @@ -262,6 +267,7 @@ class UserUpdateSchema(CurrentUserUpdateSchema): return value v = value.strip() import re + if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{1,31}$", v): raise ValueError("账号需以字母开头,2-32 位,仅允许字母、数字、_ . -") return v @@ -290,8 +296,17 @@ class UserOutSchema(UserUpdateSchema, BaseSchema, UserBySchema, TenantBySchema): menus: list[MenuOutSchema] | None = Field(default=[], description="菜单") -class UserQueryParam: - """用户管理查询参数""" +@dataclass +class UserQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): + """ + 用户管理查询参数(继承标准 Mixin) + + 支持: + - 时间范围(BaseQueryParam) + - 创建人/更新人筛选(UserByQueryParam) + - 租户筛选(TenantByQueryParam) + - 业务字段:用户名、名称、手机号、邮箱、部门、状态 + """ def __init__( self, @@ -304,37 +319,18 @@ class UserQueryParam: pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", ), dept_id: int | None = Query(None, description="部门ID"), - tenant_id: int | None = Query(None, description="租户ID(仅平台管理员可筛选)"), status: str | None = Query(None, description="是否可用"), - created_time: list[DateTimeStr] | None = Query( - None, - description="创建时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ), - updated_time: list[DateTimeStr] | None = Query( - None, - description="更新时间范围", - examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"], - ), - created_id: int | None = Query(None, description="创建人"), - updated_id: int | None = Query(None, description="更新人"), + *args, + **kwargs, ) -> None: - - # 模糊查询字段 + super().__init__(*args, **kwargs) self.username = (QueueEnum.like.value, username) self.name = (QueueEnum.like.value, name) - self.mobile = (QueueEnum.like.value, mobile) - self.email = (QueueEnum.like.value, email) - - # 精确查询字段 - self.dept_id = (QueueEnum.eq.value, dept_id) - self.tenant_id = (QueueEnum.eq.value, tenant_id) - self.created_id = (QueueEnum.eq.value, created_id) - self.updated_id = (QueueEnum.eq.value, updated_id) - self.status = (QueueEnum.eq.value, status) - - # 时间范围查询 - if created_time and len(created_time) == 2: - self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1])) - if updated_time and len(updated_time) == 2: - self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1])) + if mobile: + self.mobile = (QueueEnum.like.value, mobile) + if email: + self.email = (QueueEnum.like.value, email) + if dept_id: + self.dept_id = (QueueEnum.eq.value, dept_id) + if status: + self.status = (QueueEnum.eq.value, status) diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index 91ddccfb..72c8ec8c 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -6,6 +6,8 @@ from fastapi import UploadFile from app.api.v1.module_platform.menu.crud import MenuCRUD from app.api.v1.module_platform.menu.schema import MenuOutSchema +from app.api.v1.module_platform.package.service import PackageService +from app.api.v1.module_platform.tenant.service import TenantService 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 @@ -31,10 +33,14 @@ from .schema import ( class UserService: - """用户模块服务层""" + """ + 用户管理服务 + + 提供用户 CRUD、密码管理、状态切换、批量导入/导出、当前用户信息获取/更新、忘记密码/注册等业务能力。 + """ @classmethod - async def get_detail_by_id_service(cls, auth: AuthSchema, id: int) -> UserOutSchema: + async def detail_service(cls, auth: AuthSchema, id: int) -> UserOutSchema: """ 根据ID获取用户详情 @@ -45,9 +51,7 @@ class UserService: 返回: - dict: 用户详情字典 """ - user = await UserCRUD(auth).get(id=id) - if not user: - raise CustomException(msg="用户不存在") + user = await UserCRUD(auth).get_or_404(id=id) result = UserOutSchema.model_validate(user) # 如果用户绑定了部门,则获取部门名称 @@ -58,7 +62,7 @@ class UserService: return result @classmethod - async def get_user_list_service(cls, auth: AuthSchema, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[UserOutSchema]: + async def list_service(cls, auth: AuthSchema, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[UserOutSchema]: """ 获取用户列表 @@ -70,7 +74,7 @@ class UserService: 返回: - list[dict]: 用户详情字典列表 """ - user_list = await UserCRUD(auth).list(search=search.__dict__ if search else {}, order_by=order_by) + user_list = await UserCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) user_dict_list = [] for user in user_list: user_dict = UserOutSchema.model_validate(user) @@ -79,7 +83,7 @@ class UserService: return user_dict_list @classmethod - async def get_user_page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> dict: + async def page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> dict: """ 分页查询用户(数据库 OFFSET/LIMIT)。 @@ -98,12 +102,12 @@ class UserService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=UserOutSchema, ) @classmethod - async def create_user_service(cls, data: UserCreateSchema, auth: AuthSchema) -> UserOutSchema: + async def create_service(cls, data: UserCreateSchema, auth: AuthSchema) -> UserOutSchema: """ 创建用户 @@ -128,11 +132,9 @@ class UserService: if data.dept_id: dept = await DeptCRUD(auth).get(id=data.dept_id) if not dept: - raise CustomException(msg="部门不存在") + raise CustomException(msg="该数据不存在") # 检查租户配额 - from app.api.v1.module_platform.tenant.service import TenantService - await TenantService.check_quota_service(auth, auth.tenant_id, "user") # 创建用户 @@ -152,7 +154,7 @@ class UserService: return new_user_dict @classmethod - async def update_user_service(cls, id: int, data: UserUpdateSchema, auth: AuthSchema) -> UserOutSchema: + async def update_service(cls, id: int, data: UserUpdateSchema, auth: AuthSchema) -> UserOutSchema: """ 更新用户 @@ -168,9 +170,7 @@ class UserService: raise CustomException(msg="账号不能为空") # 检查用户是否存在 - user = await UserCRUD(auth).get(id=id) - if not user: - raise CustomException(msg="用户不存在") + user = await UserCRUD(auth).get_or_404(id=id) # 检查是否尝试修改超级管理员 if user.is_superuser: @@ -179,22 +179,22 @@ class UserService: # 检查用户名是否重复 exist_user = await UserCRUD(auth).get(username=data.username) if exist_user and exist_user.id != id: - raise CustomException(msg="已存在相同的账号") + raise CustomException(msg="更新失败,账号已存在") # 新增:检查手机号是否重复 if data.mobile: exist_mobile_user = await UserCRUD(auth).get(mobile=data.mobile) if exist_mobile_user and exist_mobile_user.id != id: - raise CustomException(msg="更新失败,手机号已存在") + raise CustomException(msg="该数据已存在") # 新增:检查邮箱是否重复 if data.email: exist_email_user = await UserCRUD(auth).get(email=data.email) if exist_email_user and exist_email_user.id != id: - raise CustomException(msg="更新失败,邮箱已存在") + raise CustomException(msg="该数据已存在") # 检查部门是否存在且可用 if data.dept_id: dept = await DeptCRUD(auth).get(id=data.dept_id) if not dept: - raise CustomException(msg="部门不存在") + raise CustomException(msg="该数据不存在") if dept.status == 1: raise CustomException(msg="部门已被禁用") @@ -206,25 +206,25 @@ class UserService: # 检查角色是否都存在且可用 roles = await RoleCRUD(auth).list(search={"id": ("in", data.role_ids)}) if len(roles) != len(data.role_ids): - raise CustomException(msg="部分角色不存在") + raise CustomException(msg="更新失败,部分角色不存在") if not all(role.status == 0 for role in roles): - raise CustomException(msg="部分角色已被禁用") + raise CustomException(msg="更新失败,部分角色已被禁用") await UserCRUD(auth).set_user_roles(user_ids=[id], role_ids=data.role_ids) if data.position_ids and len(data.position_ids) > 0: # 检查岗位是否都存在且可用 positions = await PositionCRUD(auth).list(search={"id": ("in", data.position_ids)}) if len(positions) != len(data.position_ids): - raise CustomException(msg="部分岗位不存在") + raise CustomException(msg="更新失败,部分岗位不存在") if not all(position.status == 0 for position in positions): - raise CustomException(msg="部分岗位已被禁用") + raise CustomException(msg="更新失败,部分岗位已被禁用") await UserCRUD(auth).set_user_positions(user_ids=[id], position_ids=data.position_ids) user_dict = UserOutSchema.model_validate(new_user) return user_dict @classmethod - async def delete_user_service(cls, auth: AuthSchema, ids: list[int]) -> None: + async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None: """ 删除用户 @@ -243,7 +243,7 @@ class UserService: for uid in ids: user = user_map.get(uid) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") if user.is_superuser: raise CustomException(msg="超级管理员不能删除") if user.status == 0: @@ -260,7 +260,7 @@ class UserService: await UserCRUD(auth).delete(ids=ids) @classmethod - async def get_current_user_info_service(cls, auth: AuthSchema) -> UserOutSchema: + async def current_info_service(cls, auth: AuthSchema) -> UserOutSchema: """ 获取当前用户信息 @@ -272,7 +272,7 @@ class UserService: """ # 获取用户基本信息 if not auth.user or not auth.user.id: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") user = await UserCRUD(auth).get(id=auth.user.id) user_dict = UserOutSchema.model_validate(user) # 获取部门名称 @@ -283,7 +283,7 @@ class UserService: _pc_only = {"client": "pc"} if auth.user and auth.user.is_superuser: # 使用树形结构查询,预加载children关系(含 type=3 按钮,供前端权限列表使用) - menu_all = await MenuCRUD(auth).get_tree_list( + menu_all = await MenuCRUD(auth).tree_list( search={"type": ("in", [1, 2, 3, 4]), "status": 0, **_pc_only}, order_by=[{"order": "asc"}], ) @@ -295,8 +295,6 @@ class UserService: # 租户菜单约束:非超管用户只能看到租户菜单权限内的菜单 if menu_ids and auth.tenant_id: - from app.api.v1.module_platform.package.service import PackageService - allowed_ids = await PackageService.get_tenant_available_menu_ids(auth, auth.tenant_id) allowed_set = set(allowed_ids) menu_ids = menu_ids & allowed_set @@ -305,7 +303,7 @@ class UserService: menus = ( [ MenuOutSchema.model_validate(menu) - for menu in await MenuCRUD(auth).get_tree_list( + for menu in await MenuCRUD(auth).tree_list( search={"id": ("in", list(menu_ids)), **_pc_only}, order_by=[{"order": "asc"}], ) @@ -317,7 +315,7 @@ class UserService: return user_dict @classmethod - async def update_current_user_info_service(cls, auth: AuthSchema, data: CurrentUserUpdateSchema) -> UserOutSchema: + async def update_current_info_service(cls, auth: AuthSchema, data: CurrentUserUpdateSchema) -> UserOutSchema: """ 更新当前用户信息 @@ -329,28 +327,28 @@ class UserService: - Dict: 更新后的当前用户详情字典 """ if not auth.user or not auth.user.id: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") user = await UserCRUD(auth).get(id=auth.user.id) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") if user.is_superuser: raise CustomException(msg="超级管理员不能修改个人信息") # 新增:检查手机号是否重复 if data.mobile: exist_mobile_user = await UserCRUD(auth).get(mobile=data.mobile) if exist_mobile_user and exist_mobile_user.id != auth.user.id: - raise CustomException(msg="更新失败,手机号已存在") + raise CustomException(msg="该数据已存在") # 新增:检查邮箱是否重复 if data.email: exist_email_user = await UserCRUD(auth).get(email=data.email) if exist_email_user and exist_email_user.id != auth.user.id: - raise CustomException(msg="更新失败,邮箱已存在") + raise CustomException(msg="该数据已存在") user_update_data = UserUpdateSchema(**data.model_dump()) new_user = await UserCRUD(auth).update(id=auth.user.id, data=user_update_data) return UserOutSchema.model_validate(new_user) @classmethod - async def set_user_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: + async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: """ 设置用户状态 @@ -362,15 +360,13 @@ class UserService: - None """ for id in data.ids: - user = await UserCRUD(auth).get(id=id) - if not user: - raise CustomException(msg=f"用户ID {id} 不存在") + user = await UserCRUD(auth).get_or_404(id=id) if user.is_superuser: raise CustomException(msg="超级管理员状态不能修改") await UserCRUD(auth).set(ids=data.ids, status=data.status) @classmethod - async def change_user_password_service(cls, auth: AuthSchema, data: UserChangePasswordSchema) -> UserOutSchema: + async def change_password_service(cls, auth: AuthSchema, data: UserChangePasswordSchema) -> UserOutSchema: """ 修改用户密码 @@ -382,14 +378,14 @@ class UserService: - Dict: 更新后的当前用户详情字典 """ if not auth.user or not auth.user.id: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") if not data.old_password or not data.new_password: raise CustomException(msg="密码不能为空") # 验证原密码 user = await UserCRUD(auth).get(id=auth.user.id) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") if not PwdUtil.verify_password(plain_password=data.old_password, password_hash=user.password): raise CustomException(msg="原密码输入错误") @@ -399,7 +395,7 @@ class UserService: return UserOutSchema.model_validate(new_user) @classmethod - async def reset_user_password_service(cls, auth: AuthSchema, data: ResetPasswordSchema) -> UserOutSchema: + async def reset_password_service(cls, auth: AuthSchema, data: ResetPasswordSchema) -> UserOutSchema: """ 重置用户密码 @@ -416,7 +412,7 @@ class UserService: # 验证用户 user = await UserCRUD(auth).get(id=data.id) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") # 检查是否是超级管理员 if user.is_superuser: @@ -428,7 +424,7 @@ class UserService: return UserOutSchema.model_validate(new_user) @classmethod - async def register_user_service(cls, auth: AuthSchema, data: UserRegisterSchema) -> UserOutSchema: + async def register_service(cls, auth: AuthSchema, data: UserRegisterSchema) -> UserOutSchema: """ 用户注册 @@ -442,7 +438,7 @@ class UserService: # 检查用户名是否存在 username_ok = await UserCRUD(auth).get(username=data.username) if username_ok: - raise CustomException(msg="账号已存在") + raise CustomException(msg="该数据已存在") data.password = PwdUtil.set_password_hash(password=data.password) data.name = data.username @@ -471,7 +467,7 @@ class UserService: """ user = await UserCRUD(auth).get(username=data.username) if not user: - raise CustomException(msg="用户不存在") + raise CustomException(msg="该数据不存在") if user.status == 1: raise CustomException(msg="用户已停用") @@ -488,7 +484,7 @@ class UserService: return UserOutSchema.model_validate(new_user) @classmethod - async def batch_import_user_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str: + async def batch_import_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str: """ 批量导入用户 @@ -600,7 +596,7 @@ class UserService: except Exception as e: logger.error(f"批量导入用户失败: {e!s}") - raise CustomException(msg=f"导入失败: {e!s}") + raise CustomException(msg=f"导入失败: {e!s}") from e @classmethod async def get_import_template_user_service(cls) -> bytes: @@ -631,7 +627,7 @@ class UserService: ) @classmethod - async def export_user_list_service(cls, user_list: list[dict[str, Any]]) -> bytes: + async def export_list_service(cls, user_list: list[dict[str, Any]]) -> bytes: """ 导出用户列表为Excel文件 diff --git a/backend/app/common/enums.py b/backend/app/common/enums.py index 347b36a9..2ba2916a 100644 --- a/backend/app/common/enums.py +++ b/backend/app/common/enums.py @@ -122,3 +122,32 @@ class PermissionFilterStrategy(str, Enum): DEPT_BASED = "dept_based" # 基于部门关联(部门、角色) SELF_ONLY = "self_only" # 仅本人数据 USER_ROLE = "user_role" # 当前用户绑定的角色 + + +@unique +class OrderTypeEnum(str, Enum): + """订单类型""" + + NEW = "new" + RENEW = "renew" + UPGRADE = "upgrade" + DOWNGRADE = "downgrade" + PLUGIN = "plugin" + + +@unique +class InvoiceTypeEnum(str, Enum): + """发票类型""" + + VAT_NORMAL = "vat_normal" + VAT_SPECIAL = "vat_special" + + +@unique +class TicketTypeEnum(str, Enum): + """工单类型""" + + SUGGESTION = "suggestion" + BUG = "bug" + OPTIMIZE = "optimize" + OTHER = "other" diff --git a/backend/app/core/base_crud.py b/backend/app/core/base_crud.py index 78cedc37..8c85cdb6 100644 --- a/backend/app/core/base_crud.py +++ b/backend/app/core/base_crud.py @@ -136,6 +136,37 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): """按主键查询""" return await self.get(id=model_id) + async def get_or_404( + self, + id: int | None = None, + msg: str = "该数据不存在", + preload: list[str | Any] | None = None, + 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) + if not obj: + raise CustomException(msg=msg) + return out_schema.model_validate(obj) if out_schema else obj + async def exists(self, **kwargs) -> bool: """ 检查是否存在符合条件的记录 @@ -206,7 +237,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): self, search: dict | None = None, order_by: builtins.list[dict[str, str]] | None = None, - children_attr: str = "children", + children_attr: str | None = None, preload: builtins.list[str | Any] | None = None, ) -> Sequence[ModelType]: """ @@ -215,12 +246,15 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): 参数: - search: 查询条件 - order_by: 排序字段 - - children_attr: 子节点属性名 + - children_attr: 子节点属性名(None 时自动从模型 __tree_children_attr__ 推断) - preload: 额外预加载关系 返回: - 树形结构数据列表 """ + # 自动从模型推断 children_attr + if children_attr is None: + children_attr = getattr(self.model, "__tree_children_attr__", "children") try: conditions = await self.__build_conditions(**(search or {})) order = order_by or [{"id": "asc"}] @@ -328,8 +362,10 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): obj = self.model(**obj_dict) if self.auth and self.auth.user: - if hasattr(obj, "tenant_id") and getattr(obj, "tenant_id", None) is None: - setattr(obj, "tenant_id", self.auth.user.tenant_id) + if hasattr(obj, "tenant_id"): + # 非超管始终使用当前租户;超管仅当未显式指定时自动填充 + if not self.auth.user.is_superuser or getattr(obj, "tenant_id", None) is None: + setattr(obj, "tenant_id", self.auth.tenant_id or self.auth.user.tenant_id) if hasattr(obj, "created_id"): setattr(obj, "created_id", self.auth.user.id) if hasattr(obj, "updated_id"): diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index dceafe44..8ce6eda8 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -399,3 +399,27 @@ class AuthPermission: raise CustomException(msg="无权限操作", code=10403, status_code=403) return auth + + +def require_superadmin(func): + """ + 装饰器:仅超级管理员可调用 Service 方法。 + + 自动校验 auth 参数的 is_superuser 属性,非超管直接抛出 403。 + 适用于 classmethod Service 方法,auth 必须是第二个参数(cls 之后)。 + + 用法: + @classmethod + @require_superadmin + async def create_service(cls, auth: AuthSchema, ...) -> ...: + ... + """ + from functools import wraps + + @wraps(func) + async def wrapper(cls, auth: AuthSchema, *args, **kwargs): + if not auth.user or not auth.user.is_superuser: + raise CustomException(msg="仅平台管理员可操作") + return await func(cls, auth, *args, **kwargs) + + return wrapper diff --git a/backend/app/plugin/module_ai/chat/service.py b/backend/app/plugin/module_ai/chat/service.py index 4eed6973..56a0b7c6 100644 --- a/backend/app/plugin/module_ai/chat/service.py +++ b/backend/app/plugin/module_ai/chat/service.py @@ -66,10 +66,10 @@ async def _format_session_data(session: TeamSession, auth: AuthSchema | None = N try: team_id = session_dict.get("team_id") if isinstance(team_id, str): - dept_name = await DeptService.get_dept_detail_service(auth=auth, id=int(team_id)) + dept_name = await DeptService.detail_service(auth=auth, id=int(team_id)) result["team_name"] = dept_name.get("name") elif isinstance(team_id, int): - dept_name = await DeptService.get_dept_detail_service(auth=auth, id=team_id) + dept_name = await DeptService.detail_service(auth=auth, id=team_id) result["team_name"] = dept_name.get("name") else: result["team_name"] = None diff --git a/backend/app/plugin/module_example/demo/schema.py b/backend/app/plugin/module_example/demo/schema.py index 899b3838..d9042105 100644 --- a/backend/app/plugin/module_example/demo/schema.py +++ b/backend/app/plugin/module_example/demo/schema.py @@ -94,7 +94,8 @@ class DemoQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.name = (QueueEnum.like.value, name) + if name: + self.name = (QueueEnum.like.value, name) if description: self.description = (QueueEnum.like.value, description) if status: diff --git a/backend/app/plugin/module_example/demo/service.py b/backend/app/plugin/module_example/demo/service.py index b0199ee0..e01f671a 100644 --- a/backend/app/plugin/module_example/demo/service.py +++ b/backend/app/plugin/module_example/demo/service.py @@ -58,8 +58,7 @@ class DemoService: 返回: - list[DemoOutSchema]: 示例列表 """ - search_dict = search.__dict__ if search else None - obj_list = await DemoCRUD(auth).list(search=search_dict, order_by=order_by) + obj_list = await DemoCRUD(auth).list(search=vars(search) if search else None, order_by=order_by) return [DemoOutSchema.model_validate(obj) for obj in obj_list] @classmethod @@ -84,18 +83,14 @@ class DemoService: 返回: - dict: 分页数据 """ - search_dict = search.__dict__ if search else {} - order_by_list = order_by or [{"id": "asc"}] offset = (page_no - 1) * page_size - - result = await DemoCRUD(auth).page( + return await DemoCRUD(auth).page( offset=offset, limit=page_size, - order_by=order_by_list, - search=search_dict, + order_by=order_by or [{"id": "asc"}], + search=vars(search) if search else None, out_schema=DemoOutSchema, ) - return result @classmethod async def create_service(cls, auth: AuthSchema, data: DemoCreateSchema) -> DemoOutSchema: @@ -153,12 +148,11 @@ class DemoService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - - for id in ids: - obj = await DemoCRUD(auth).get(id=id) - if not obj: - raise CustomException(msg=f"删除失败,ID为{id}的数据不存在") - + objs = await DemoCRUD(auth).list(search={"id": ("in", ids)}) + obj_map = {o.id: o for o in objs} + for id_ in ids: + if id_ not in obj_map: + raise CustomException(msg="删除失败,该数据不存在") await DemoCRUD(auth).delete(ids=ids) @classmethod diff --git a/backend/app/plugin/module_generator/gencode/crud.py b/backend/app/plugin/module_generator/gencode/crud.py index e7b52fe8..6d3ade9c 100644 --- a/backend/app/plugin/module_generator/gencode/crud.py +++ b/backend/app/plugin/module_generator/gencode/crud.py @@ -88,7 +88,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): - Sequence[GenTableModel]: 业务表列表信息。 """ return await self.list( - search=search.__dict__, + search=vars(search) if search else None, order_by=[{"created_time": "desc"}], preload=preload, ) diff --git a/backend/app/plugin/module_generator/gencode/model.py b/backend/app/plugin/module_generator/gencode/model.py index b4132d6b..4e059d52 100644 --- a/backend/app/plugin/module_generator/gencode/model.py +++ b/backend/app/plugin/module_generator/gencode/model.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, ForeignKey, Integer, String +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from sqlalchemy.sql import expression @@ -14,7 +14,7 @@ class GenTableModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "gen_table" __table_args__: dict[str, str] = {"comment": "代码生成表"} - __loader_options__: list[str] = ["columns", "created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = ["columns", "created_by", "updated_by", "deleted_by", "tenant_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="表描述") @@ -50,7 +50,7 @@ class GenTableColumnModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "gen_table_column" __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", "tenant_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/plugin/module_generator/gencode/schema.py b/backend/app/plugin/module_generator/gencode/schema.py index 54ad9d8d..fec7cff1 100644 --- a/backend/app/plugin/module_generator/gencode/schema.py +++ b/backend/app/plugin/module_generator/gencode/schema.py @@ -5,7 +5,8 @@ from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator from app.common.enums import QueueEnum -from app.core.base_schema import BaseSchema +from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam +from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema class GenDBTableSchema(BaseModel): @@ -254,7 +255,7 @@ class GenTableSchema(BaseModel): return s if s else None -class GenTableOutSchema(GenTableSchema, BaseSchema): +class GenTableOutSchema(GenTableSchema, BaseSchema, UserBySchema, TenantBySchema): """业务表输出模型(面向控制器/前端)。""" model_config = ConfigDict(from_attributes=True) @@ -300,7 +301,7 @@ class GenSyncPreviewSchema(BaseModel): @dataclass -class GenTableQueryParam: +class GenTableQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """代码生成业务表查询参数 - 支持按`table_name`、`table_comment`进行模糊检索(由CRUD层实现like)。 - 空值将被忽略,不参与过滤。 @@ -310,20 +311,10 @@ class GenTableQueryParam: self, table_name: str | None = Query(None, description="表名称"), table_comment: str | None = Query(None, description="表注释"), + *args, + **kwargs, ) -> None: + super().__init__(*args, **kwargs) # 模糊查询字段 - self.table_name = (QueueEnum.like.value, table_name) - self.table_comment = (QueueEnum.like.value, table_comment) - - -class GenTableColumnQueryParam: - """代码生成业务表字段查询参数 - - `column_name`按like规则模糊查询(透传到CRUD层) - """ - - def __init__( - self, - column_name: str | None = Query(None, description="列名称"), - ) -> None: - # 模糊查询字段:约定("like", 值)格式,便于CRUD解析 - self.column_name = (QueueEnum.like.value, column_name) + self.table_name = (QueueEnum.like.value, table_name) if table_name else None + self.table_comment = (QueueEnum.like.value, table_comment) if table_comment else None diff --git a/backend/app/plugin/module_generator/gencode/service.py b/backend/app/plugin/module_generator/gencode/service.py index 6d36916a..92c8c0a0 100644 --- a/backend/app/plugin/module_generator/gencode/service.py +++ b/backend/app/plugin/module_generator/gencode/service.py @@ -284,7 +284,7 @@ class GenTableService: offset=offset, limit=page_size, order_by=order, - search=search.__dict__, + search=vars(search) if search else None, out_schema=GenTableOutSchema, ) diff --git a/backend/app/plugin/module_task/cronjob/job/model.py b/backend/app/plugin/module_task/cronjob/job/model.py index 5ab3177c..41b8471e 100644 --- a/backend/app/plugin/module_task/cronjob/job/model.py +++ b/backend/app/plugin/module_task/cronjob/job/model.py @@ -11,6 +11,7 @@ class JobModel(ModelMixin, TenantMixin): __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="任务名称") diff --git a/backend/app/plugin/module_task/cronjob/job/schema.py b/backend/app/plugin/module_task/cronjob/job/schema.py index 27bac398..a99132ff 100644 --- a/backend/app/plugin/module_task/cronjob/job/schema.py +++ b/backend/app/plugin/module_task/cronjob/job/schema.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from fastapi import Query from pydantic import ( BaseModel, @@ -80,6 +82,7 @@ class JobOutSchema(JobCreateSchema, BaseSchema, UserBySchema, TenantBySchema): ... +@dataclass class JobQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """执行日志查询参数""" @@ -93,7 +96,7 @@ class JobQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): ) -> None: super().__init__(*args, **kwargs) # 确保 job_id 是字符串类型 - self.job_id = (QueueEnum.eq.value, str(job_id) if job_id is not None else None) + self.job_id = (QueueEnum.eq.value, str(job_id)) if job_id is not None else None # 只有当 job_name 不为空时才添加查询条件 self.job_name = (QueueEnum.like.value, job_name) if job_name else None - self.trigger_type = (QueueEnum.eq.value, trigger_type) + self.trigger_type = (QueueEnum.eq.value, trigger_type) if trigger_type else None diff --git a/backend/app/plugin/module_task/cronjob/job/service.py b/backend/app/plugin/module_task/cronjob/job/service.py index f3668bcb..028a1a17 100644 --- a/backend/app/plugin/module_task/cronjob/job/service.py +++ b/backend/app/plugin/module_task/cronjob/job/service.py @@ -53,7 +53,7 @@ class JobService: """ if order_by is None: order_by = [{"created_time": "desc"}] - obj_list = await JobCRUD(auth).get_obj_list_crud(search=search.__dict__ if search else None, order_by=order_by) + obj_list = await JobCRUD(auth).get_obj_list_crud(search=vars(search) if search else None, order_by=order_by) return [JobOutSchema.model_validate(obj) for obj in obj_list] @classmethod @@ -84,7 +84,7 @@ class JobService: offset=offset, limit=page_size, order_by=ob, - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=JobOutSchema, ) diff --git a/backend/app/plugin/module_task/cronjob/node/model.py b/backend/app/plugin/module_task/cronjob/node/model.py index 3a4a91d0..837d7c45 100644 --- a/backend/app/plugin/module_task/cronjob/node/model.py +++ b/backend/app/plugin/module_task/cronjob/node/model.py @@ -11,7 +11,7 @@ class NodeModel(ModelMixin, TenantMixin, UserMixin): __tablename__: str = "task_node" __table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "节点类型表"}) - __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_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/plugin/module_task/cronjob/node/schema.py b/backend/app/plugin/module_task/cronjob/node/schema.py index 6a158e13..527990fc 100644 --- a/backend/app/plugin/module_task/cronjob/node/schema.py +++ b/backend/app/plugin/module_task/cronjob/node/schema.py @@ -1,4 +1,5 @@ import re +from dataclasses import dataclass from fastapi import Query from pydantic import ( @@ -72,6 +73,7 @@ class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema, TenantBySchema): model_config = ConfigDict(from_attributes=True) +@dataclass class NodeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """节点查询参数""" @@ -82,7 +84,7 @@ class NodeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.name = (QueueEnum.like.value, name) + self.name = (QueueEnum.like.value, name) if name else None class NodeExecuteSchema(BaseModel): diff --git a/backend/app/plugin/module_task/cronjob/node/service.py b/backend/app/plugin/module_task/cronjob/node/service.py index 681a841e..9078165a 100644 --- a/backend/app/plugin/module_task/cronjob/node/service.py +++ b/backend/app/plugin/module_task/cronjob/node/service.py @@ -84,7 +84,7 @@ class NodeService: 返回: - List[Dict]: 节点详情字典列表 """ - obj_list = await NodeCRUD(auth).get_obj_list_crud(search=search.__dict__, order_by=order_by) + obj_list = await NodeCRUD(auth).get_obj_list_crud(search=vars(search) if search else None, order_by=order_by) return [NodeOutSchema.model_validate(obj) for obj in obj_list] @classmethod @@ -114,7 +114,7 @@ class NodeService: offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=NodeOutSchema, ) diff --git a/backend/app/plugin/module_task/workflow/flows/model.py b/backend/app/plugin/module_task/workflow/flows/model.py index 7ddda9a1..8cf669f4 100644 --- a/backend/app/plugin/module_task/workflow/flows/model.py +++ b/backend/app/plugin/module_task/workflow/flows/model.py @@ -14,7 +14,7 @@ class WorkflowModel(ModelMixin, TenantMixin, UserMixin): UniqueConstraint("tenant_id", "code", name="uq_task_workflow_code"), {"comment": "工作流定义表"}, ) - __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_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/plugin/module_task/workflow/flows/schema.py b/backend/app/plugin/module_task/workflow/flows/schema.py index 9f74b7c7..257c124b 100644 --- a/backend/app/plugin/module_task/workflow/flows/schema.py +++ b/backend/app/plugin/module_task/workflow/flows/schema.py @@ -1,4 +1,5 @@ import re +from dataclasses import dataclass from typing import Any from fastapi import Query @@ -6,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida from app.common.enums import QueueEnum from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam -from app.core.base_schema import TenantBySchema, UserBySchema +from app.core.base_schema import BaseSchema, TenantBySchema, UserBySchema from app.core.validator import DateTimeStr @@ -55,7 +56,7 @@ class WorkflowUpdateSchema(WorkflowCreateSchema): return v -class WorkflowOutSchema(UserBySchema, TenantBySchema): +class WorkflowOutSchema(BaseSchema, UserBySchema, TenantBySchema): """工作流输出(status 表示流程状态 draft/published/archived,与 ModelMixin.status 区分)""" model_config = ConfigDict(from_attributes=True) @@ -94,6 +95,7 @@ class WorkflowOutSchema(UserBySchema, TenantBySchema): return data +@dataclass class WorkflowQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """工作流查询""" @@ -121,11 +123,11 @@ class WorkflowExecuteSchema(BaseModel): class WorkflowExecuteResultSchema(BaseModel): """执行结果""" - workflow_id: int - workflow_name: str + workflow_id: int = Field(..., description="工作流ID") + workflow_name: str = Field(..., description="工作流名称") status: str = Field(description="completed/failed") - start_time: str | None = None - end_time: str | None = None - variables: dict | None = None - node_results: dict | None = None - error: str | None = None + start_time: str | None = Field(default=None, description="开始时间") + end_time: str | None = Field(default=None, description="结束时间") + variables: dict | None = Field(default=None, description="变量") + node_results: dict | None = Field(default=None, description="节点结果") + error: str | None = Field(default=None, description="错误信息") diff --git a/backend/app/plugin/module_task/workflow/flows/service.py b/backend/app/plugin/module_task/workflow/flows/service.py index 12a7733b..a8d6a96f 100644 --- a/backend/app/plugin/module_task/workflow/flows/service.py +++ b/backend/app/plugin/module_task/workflow/flows/service.py @@ -20,8 +20,8 @@ from .schema import ( class WorkflowService: """工作流:画布存储 + 发布校验 + Prefect 执行""" - @staticmethod - def _out(obj: Any) -> WorkflowOutSchema: + @classmethod + def _out(cls, obj: Any) -> WorkflowOutSchema: return WorkflowOutSchema.model_validate(obj) @classmethod @@ -65,7 +65,7 @@ class WorkflowService: if order_by is None: order_by = [{"updated_time": "desc"}] obj_list = await WorkflowCRUD(auth).get_obj_list_crud( - search=search.__dict__ if search else None, + search=vars(search) if search else None, order_by=order_by, ) return [cls._out(o) for o in obj_list] @@ -98,7 +98,7 @@ class WorkflowService: offset=offset, limit=page_size, order_by=order, - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=WorkflowOutSchema, ) result.items = [WorkflowOutSchema.model_validate(item).model_dump(mode="json") for item in result.items] diff --git a/backend/app/plugin/module_task/workflow/nodes/model.py b/backend/app/plugin/module_task/workflow/nodes/model.py index 24cc06e5..6f663698 100644 --- a/backend/app/plugin/module_task/workflow/nodes/model.py +++ b/backend/app/plugin/module_task/workflow/nodes/model.py @@ -14,7 +14,7 @@ class WorkflowNodeTypeModel(ModelMixin, TenantMixin, UserMixin): UniqueConstraint("tenant_id", "code"), {"comment": "工作流编排节点类型(非定时任务节点)"}, ) - __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"] + __loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_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/plugin/module_task/workflow/nodes/schema.py b/backend/app/plugin/module_task/workflow/nodes/schema.py index a1568362..4d27baa3 100644 --- a/backend/app/plugin/module_task/workflow/nodes/schema.py +++ b/backend/app/plugin/module_task/workflow/nodes/schema.py @@ -1,4 +1,5 @@ import re +from dataclasses import dataclass from fastapi import Query from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -64,6 +65,7 @@ class WorkflowNodeTypeOutSchema(WorkflowNodeTypeCreateSchema, BaseSchema, UserBy model_config = ConfigDict(from_attributes=True) +@dataclass class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): """查询""" @@ -77,7 +79,7 @@ class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQuery **kwargs, ) -> None: super().__init__(*args, **kwargs) - self.name = (QueueEnum.like.value, name) - self.code = (QueueEnum.like.value, code) - self.category = (QueueEnum.eq.value, category) - self.is_active = (QueueEnum.eq.value, is_active) + self.name = (QueueEnum.like.value, name) if name else None + self.code = (QueueEnum.like.value, code) if code else None + self.category = (QueueEnum.eq.value, category) if category else None + self.is_active = (QueueEnum.eq.value, is_active) if is_active is not None else None diff --git a/backend/app/plugin/module_task/workflow/nodes/service.py b/backend/app/plugin/module_task/workflow/nodes/service.py index a87cc5f3..82fb4272 100644 --- a/backend/app/plugin/module_task/workflow/nodes/service.py +++ b/backend/app/plugin/module_task/workflow/nodes/service.py @@ -82,7 +82,7 @@ class WorkflowNodeTypeService: if order_by is None: order_by = [{"sort_order": "asc"}, {"id": "asc"}] obj_list = await WorkflowNodeTypeCRUD(auth).get_obj_list_crud( - search=search.__dict__ if search else None, + search=vars(search) if search else None, order_by=order_by, ) return [cls._out(o) for o in obj_list] @@ -115,7 +115,7 @@ class WorkflowNodeTypeService: offset=offset, limit=page_size, order_by=order, - search=search.__dict__ if search else {}, + search=vars(search) if search else None, out_schema=WorkflowNodeTypeOutSchema, ) result.items = [WorkflowNodeTypeOutSchema.model_validate(item).model_dump(mode="json") for item in result.items]