From 7d2367e34ebceb3431f3663c1020de065afa6ac9 Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Sun, 21 Jun 2026 17:34:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E5=A4=84=E7=B1=BB=E5=9E=8B=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交包含多项优化: 1. 移除大量冗余的文件头注释与过时的from __future__导入 2. 将CRUD的list方法统一重命名为get_list保持接口一致 3. 修复前后端状态字段类型不匹配问题,将string类型status改为number 4. 修正前端文案错别字,将"代办事项"修正为标准写法 5. 更新sqlalchemy版本并调整依赖配置 6. 新增缓存工具类替代fastapi-cache2,重构缓存调用逻辑 7. 新增开源授权函生成相关工具与数据库字段支持 8. 为多个业务模块添加防重复提交loading状态 9. 修复邮件模型的外键关联缺失问题 10. 优化pdf生成工具的导入时机与文档注释 --- backend/README.md | 4 + .../api/v1/module_common/monitoring/schema.py | 4 - .../api/v1/module_monitor/server/service.py | 1 + .../app/api/v1/module_platform/email/model.py | 8 +- .../api/v1/module_platform/email/service.py | 2 +- .../v1/module_platform/invoice/controller.py | 18 +- .../api/v1/module_platform/invoice/model.py | 3 +- .../module_platform/invoice/oss_licenses.json | 527 ++++++++++++++++++ .../invoice/oss_licenses_helper.py | 51 ++ .../v1/module_platform/invoice/pdf_helper.py | 36 +- .../api/v1/module_platform/invoice/schema.py | 9 +- .../api/v1/module_platform/invoice/service.py | 26 +- .../api/v1/module_platform/menu/controller.py | 12 +- .../api/v1/module_platform/menu/service.py | 4 +- .../v1/module_platform/order/controller.py | 2 - .../app/api/v1/module_platform/order/model.py | 2 - .../api/v1/module_platform/order/schema.py | 7 +- .../api/v1/module_platform/order/service.py | 4 +- .../v1/module_platform/package/controller.py | 12 +- .../api/v1/module_platform/package/service.py | 1 + .../v1/module_platform/plugin/controller.py | 18 +- .../self_service/controller.py | 2 - .../v1/module_platform/self_service/schema.py | 2 - .../module_platform/self_service/service.py | 1 - .../v1/module_platform/tenant/controller.py | 22 +- .../api/v1/module_platform/tenant/service.py | 9 +- .../api/v1/module_system/auth/controller.py | 6 +- .../v1/module_system/auth/oauth_service.py | 2 - .../app/api/v1/module_system/auth/service.py | 1 + .../api/v1/module_system/dept/controller.py | 12 +- .../app/api/v1/module_system/dept/service.py | 5 +- .../api/v1/module_system/dict/controller.py | 18 +- backend/app/api/v1/module_system/dict/crud.py | 4 +- .../app/api/v1/module_system/dict/service.py | 30 +- .../app/api/v1/module_system/log/service.py | 5 +- .../api/v1/module_system/notice/controller.py | 18 +- .../api/v1/module_system/notice/service.py | 6 +- .../api/v1/module_system/params/controller.py | 4 +- .../api/v1/module_system/params/service.py | 12 +- .../v1/module_system/position/controller.py | 14 +- .../api/v1/module_system/position/service.py | 8 +- .../api/v1/module_system/role/controller.py | 16 +- backend/app/api/v1/module_system/role/crud.py | 8 +- .../app/api/v1/module_system/role/service.py | 8 +- .../api/v1/module_system/ticket/service.py | 1 + .../api/v1/module_system/user/controller.py | 2 +- backend/app/api/v1/module_system/user/crud.py | 8 +- .../app/api/v1/module_system/user/service.py | 10 +- backend/app/core/ap_scheduler.py | 54 +- backend/app/core/base_crud.py | 35 +- backend/app/core/cache_util.py | 54 ++ backend/app/core/request_context.py | 4 - backend/app/init_app.py | 12 +- backend/app/plugin/module_ai/chat/service.py | 1 + .../plugin/module_example/demo/controller.py | 2 +- .../app/plugin/module_example/demo/service.py | 6 +- .../plugin/module_generator/gencode/crud.py | 8 +- .../module_generator/gencode/service.py | 5 +- .../plugin/module_task/cronjob/job/crud.py | 2 +- .../plugin/module_task/cronjob/job/service.py | 1 + .../module_task/cronjob/node/controller.py | 2 +- .../plugin/module_task/cronjob/node/crud.py | 2 +- .../module_task/cronjob/node/service.py | 4 +- .../plugin/module_task/workflow/flows/crud.py | 2 +- .../module_task/workflow/flows/service.py | 1 + .../workflow/handlers/workflow_engine.py | 4 - .../plugin/module_task/workflow/nodes/crud.py | 2 +- .../module_task/workflow/nodes/service.py | 1 + .../app/scripts/data/platform_invoice.json | 12 + backend/app/utils/jinja2_template_util.py | 2 +- backend/app/utils/payment.py | 4 - backend/app/utils/pdf_generator.py | 5 +- backend/pyproject.toml | 4 +- backend/requirements.txt | 2 +- backend/run_linux.sh | 378 ------------- backend/run_win.bat | 267 --------- .../templates/{ => invoice}/invoice.jinja2 | 0 backend/templates/invoice/oss_license.jinja2 | 177 ++++++ backend/tests/scripts/seed_invoice_pdfs.py | 97 ++++ backend/tests/test_init_data_integrity.py | 249 +++++++++ backend/uv.lock | 58 +- frontend/web/src/api/module_system/ticket.ts | 2 +- .../src/components/forms/fa-form/index.vue | 5 + .../tables/fa-table-header-left/index.vue | 17 +- frontend/web/src/hooks/core/useConfirm.ts | 4 +- frontend/web/src/hooks/core/useLoading.ts | 147 +++++ frontend/web/src/locales/langs/zh.json | 2 +- frontend/web/src/types/global.d.ts | 2 +- .../dashboard/home/modules/todo-list.vue | 2 +- .../web/src/views/module_ai/memory/index.vue | 14 +- .../src/views/module_example/demo/index.vue | 18 +- .../src/views/module_platform/email/index.vue | 28 +- .../src/views/module_platform/menu/index.vue | 22 +- .../views/module_platform/package/index.vue | 18 +- .../views/module_platform/tenant/index.vue | 14 +- .../src/views/module_system/dept/index.vue | 21 +- .../dict/components/DataDrawer.vue | 21 +- .../src/views/module_system/dict/index.vue | 21 +- .../src/views/module_system/notice/index.vue | 23 +- .../src/views/module_system/params/index.vue | 14 +- .../views/module_system/position/index.vue | 21 +- .../src/views/module_system/role/index.vue | 21 +- .../src/views/module_system/ticket/index.vue | 21 +- .../src/views/module_system/user/index.vue | 21 +- .../views/module_task/cronjob/node/index.vue | 13 +- image.png | Bin 24084 -> 0 bytes 106 files changed, 1968 insertions(+), 966 deletions(-) create mode 100644 backend/app/api/v1/module_platform/invoice/oss_licenses.json create mode 100644 backend/app/api/v1/module_platform/invoice/oss_licenses_helper.py create mode 100644 backend/app/core/cache_util.py delete mode 100755 backend/run_linux.sh delete mode 100755 backend/run_win.bat rename backend/templates/{ => invoice}/invoice.jinja2 (100%) create mode 100644 backend/templates/invoice/oss_license.jinja2 create mode 100644 backend/tests/scripts/seed_invoice_pdfs.py create mode 100644 backend/tests/test_init_data_integrity.py create mode 100644 frontend/web/src/hooks/core/useLoading.ts delete mode 100644 image.png diff --git a/backend/README.md b/backend/README.md index 48278233..61bef087 100644 --- a/backend/README.md +++ b/backend/README.md @@ -116,6 +116,10 @@ ruff check --watch uv run ruff check uv run ruff check --fix uv run ruff check --watch + +# 生成开源授权函 JSON 文件 +#uv run --with pip-licenses pip-licenses --format=json \ + > app/api/v1/module_platform/invoice/oss_licenses.json ``` ## 后端约定(日期与序列化) diff --git a/backend/app/api/v1/module_common/monitoring/schema.py b/backend/app/api/v1/module_common/monitoring/schema.py index 3d999371..e628e827 100644 --- a/backend/app/api/v1/module_common/monitoring/schema.py +++ b/backend/app/api/v1/module_common/monitoring/schema.py @@ -1,7 +1,3 @@ -"""健康检查 Schema""" - -from __future__ import annotations - from pydantic import BaseModel, Field diff --git a/backend/app/api/v1/module_monitor/server/service.py b/backend/app/api/v1/module_monitor/server/service.py index bee5ec8a..6d3f27fb 100644 --- a/backend/app/api/v1/module_monitor/server/service.py +++ b/backend/app/api/v1/module_monitor/server/service.py @@ -1,3 +1,4 @@ + import platform import socket import time diff --git a/backend/app/api/v1/module_platform/email/model.py b/backend/app/api/v1/module_platform/email/model.py index c072cb19..94c81104 100644 --- a/backend/app/api/v1/module_platform/email/model.py +++ b/backend/app/api/v1/module_platform/email/model.py @@ -95,7 +95,13 @@ class EmailLogModel(ModelMixin, TenantMixin, UserMixin): biz_type: Mapped[str] = mapped_column(String(50), nullable=False, default="other", comment="业务类型(register/reset_password/invite/expiry_warning/ticket_reply/other)") error_msg: Mapped[str | None] = mapped_column(Text, nullable=True, default=None, comment="失败原因") retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="重试次数") - tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True, comment="关联租户 ID(可为空,如平台注册邮件)") + tenant_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("platform_tenant.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + comment="关联租户 ID(可为空,如平台注册邮件)", + ) 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="备注") diff --git a/backend/app/api/v1/module_platform/email/service.py b/backend/app/api/v1/module_platform/email/service.py index 7a8f483c..d10a0c4d 100644 --- a/backend/app/api/v1/module_platform/email/service.py +++ b/backend/app/api/v1/module_platform/email/service.py @@ -67,7 +67,7 @@ class EmailConfigService: if not ids: raise CustomException(msg="删除对象不能为空") crud = EmailConfigCRUD(self.auth) - configs = await crud.list(search={"id": ("in", ids)}) + configs = await crud.get_list(search={"id": ("in", ids)}) for obj in configs: if obj.is_default: raise CustomException(msg=f"配置「{obj.name}」是默认配置,请先将其他配置设为默认后再删除") diff --git a/backend/app/api/v1/module_platform/invoice/controller.py b/backend/app/api/v1/module_platform/invoice/controller.py index 4639bf2a..7a9818e8 100644 --- a/backend/app/api/v1/module_platform/invoice/controller.py +++ b/backend/app/api/v1/module_platform/invoice/controller.py @@ -42,13 +42,26 @@ async def invoice_list_my_controller( ) return SuccessResponse(data=result, msg="查询成功") -@TenantInvoiceRouter.get("/{id}/download", summary="下载发票PDF", response_model=ResponseSchema[dict]) +@TenantInvoiceRouter.get("/{id}/download", summary="下载发票PDF与授权函", response_model=ResponseSchema[dict]) async def invoice_download_controller( id: Annotated[int, Path(ge=1)], auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], ) -> JSONResponse: pdf_url = await InvoiceTenantService.download(auth, id, auth.tenant_id) - return SuccessResponse(msg="下载地址", data={"pdf_url": pdf_url}) + oss_license_pdf_url = await InvoiceTenantService.download_license(auth, id, auth.tenant_id) + return SuccessResponse( + msg="下载地址", + data={"pdf_url": pdf_url, "oss_license_pdf_url": oss_license_pdf_url}, + ) + + +@TenantInvoiceRouter.get("/{id}/license/download", summary="下载开源授权函PDF", response_model=ResponseSchema[dict]) +async def invoice_license_download_controller( + id: Annotated[int, Path(ge=1)], + auth: Annotated[AuthSchema, Depends(AuthPermission(["*:*:*"]))], +) -> JSONResponse: + oss_license_pdf_url = await InvoiceTenantService.download_license(auth, id, auth.tenant_id) + return SuccessResponse(msg="授权函下载地址", data={"oss_license_pdf_url": oss_license_pdf_url}) PlatformInvoiceRouter = APIRouter(prefix="/invoice", route_class=OperationLogRoute, tags=["平台管理", "发票管理"]) @@ -78,6 +91,7 @@ async def invoice_issue_controller( id, data.pdf_url or "", data.api_response or "", + data.oss_license_pdf_url or "", ) return SuccessResponse(data=result, msg="发票开具成功") diff --git a/backend/app/api/v1/module_platform/invoice/model.py b/backend/app/api/v1/module_platform/invoice/model.py index f88933e1..54927134 100644 --- a/backend/app/api/v1/module_platform/invoice/model.py +++ b/backend/app/api/v1/module_platform/invoice/model.py @@ -29,7 +29,8 @@ class InvoiceModel(ModelMixin, TenantMixin, UserMixin): address_info: Mapped[str | None] = mapped_column(Text, nullable=True, comment="注册地址及电话") amount: Mapped[int] = mapped_column(Integer, nullable=False, comment="发票金额(分)") tax_amount: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="税额(分)") - pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="PDF下载地址") + pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="发票PDF下载地址") + oss_license_pdf_url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="开源授权函PDF下载地址") api_response: Mapped[str | None] = mapped_column(Text, nullable=True, comment="第三方API响应") status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待开票 1:已开票 2:开票失败 3:已作废)", index=True) description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注") diff --git a/backend/app/api/v1/module_platform/invoice/oss_licenses.json b/backend/app/api/v1/module_platform/invoice/oss_licenses.json new file mode 100644 index 00000000..91e02fe2 --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/oss_licenses.json @@ -0,0 +1,527 @@ +[ + { + "License": "MIT License", + "Name": "APScheduler", + "Version": "3.11.0" + }, + { + "License": "BSD-3-Clause", + "Name": "GitPython", + "Version": "3.1.46" + }, + { + "License": "BSD License", + "Name": "Jinja2", + "Version": "3.1.6" + }, + { + "License": "MIT License", + "Name": "Mako", + "Version": "1.3.10" + }, + { + "License": "BSD-3-Clause", + "Name": "MarkupSafe", + "Version": "3.0.3" + }, + { + "License": "MIT", + "Name": "PyJWT", + "Version": "2.13.0" + }, + { + "License": "MIT", + "Name": "PyMySQL", + "Version": "1.2.0" + }, + { + "License": "MIT License", + "Name": "PyYAML", + "Version": "6.0.3" + }, + { + "License": "BSD License", + "Name": "Pygments", + "Version": "2.19.2" + }, + { + "License": "MIT", + "Name": "SQLAlchemy", + "Version": "2.0.45" + }, + { + "License": "Apache Software License", + "Name": "agno", + "Version": "2.5.8" + }, + { + "License": "Apache Software License", + "Name": "aiofiles", + "Version": "24.1.0" + }, + { + "License": "MIT License", + "Name": "aioredis", + "Version": "2.0.1" + }, + { + "License": "MIT", + "Name": "aiosmtplib", + "Version": "3.0.2" + }, + { + "License": "MIT License", + "Name": "aiosqlite", + "Version": "0.22.1" + }, + { + "License": "MIT", + "Name": "alembic", + "Version": "1.18.4" + }, + { + "License": "MIT", + "Name": "annotated-doc", + "Version": "0.0.4" + }, + { + "License": "MIT License", + "Name": "annotated-types", + "Version": "0.7.0" + }, + { + "License": "MIT", + "Name": "anyio", + "Version": "4.12.1" + }, + { + "License": "Apache Software License", + "Name": "async-timeout", + "Version": "5.0.1" + }, + { + "License": "Apache-2.0", + "Name": "asyncmy", + "Version": "0.2.11" + }, + { + "License": "Apache-2.0", + "Name": "asyncpg", + "Version": "0.31.0" + }, + { + "License": "Apache Software License", + "Name": "bleach", + "Version": "6.4.0" + }, + { + "License": "MIT License", + "Name": "blinker", + "Version": "1.9.0" + }, + { + "License": "MIT", + "Name": "brotli", + "Version": "1.2.0" + }, + { + "License": "Mozilla Public License 2.0 (MPL 2.0)", + "Name": "certifi", + "Version": "2026.1.4" + }, + { + "License": "MIT", + "Name": "cffi", + "Version": "2.0.0" + }, + { + "License": "BSD License", + "Name": "click", + "Version": "8.1.7" + }, + { + "License": "MIT License", + "Name": "croniter", + "Version": "4.0.0" + }, + { + "License": "Apache-2.0 OR BSD-3-Clause", + "Name": "cryptography", + "Version": "49.0.0" + }, + { + "License": "BSD License", + "Name": "cssselect2", + "Version": "0.9.0" + }, + { + "License": "Apache Software License", + "Name": "distro", + "Version": "1.9.0" + }, + { + "License": "ISC License (ISCL)", + "Name": "dnspython", + "Version": "2.8.0" + }, + { + "License": "MIT License", + "Name": "docstring_parser", + "Version": "0.17.0" + }, + { + "License": "The Unlicense (Unlicense)", + "Name": "email-validator", + "Version": "2.3.0" + }, + { + "License": "MIT License", + "Name": "et_xmlfile", + "Version": "2.0.0" + }, + { + "License": "BSD-3-Clause", + "Name": "fakeredis", + "Version": "2.34.1" + }, + { + "License": "MIT", + "Name": "fastapi", + "Version": "0.138.0" + }, + { + "License": "Apache Software License", + "Name": "fastapi-cache2", + "Version": "0.1.8" + }, + { + "License": "Other/Proprietary License", + "Name": "fastapi-limiter", + "Version": "0.1.6" + }, + { + "License": "MIT License", + "Name": "fastapi-mail", + "Version": "1.5.1" + }, + { + "License": "MIT", + "Name": "fonttools", + "Version": "4.63.0" + }, + { + "License": "BSD License", + "Name": "gitdb", + "Version": "4.0.12" + }, + { + "License": "MIT AND PSF-2.0", + "Name": "greenlet", + "Version": "3.5.2" + }, + { + "License": "MIT License", + "Name": "h11", + "Version": "0.16.0" + }, + { + "License": "MIT License", + "Name": "h2", + "Version": "4.3.0" + }, + { + "License": "MIT License", + "Name": "hpack", + "Version": "4.1.0" + }, + { + "License": "BSD-3-Clause", + "Name": "httpcore", + "Version": "1.0.9" + }, + { + "License": "BSD License", + "Name": "httpx", + "Version": "0.28.1" + }, + { + "License": "MIT License", + "Name": "hyperframe", + "Version": "6.1.0" + }, + { + "License": "BSD-3-Clause", + "Name": "idna", + "Version": "3.11" + }, + { + "License": "MIT", + "Name": "iniconfig", + "Version": "2.3.0" + }, + { + "License": "MIT License", + "Name": "jiter", + "Version": "0.12.0" + }, + { + "License": "MIT License", + "Name": "loguru", + "Version": "0.7.3" + }, + { + "License": "MIT License", + "Name": "markdown-it-py", + "Version": "4.0.0" + }, + { + "License": "MIT License", + "Name": "mdurl", + "Version": "0.1.2" + }, + { + "License": "BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0", + "Name": "numpy", + "Version": "2.4.1" + }, + { + "License": "Apache Software License", + "Name": "openai", + "Version": "2.28.0" + }, + { + "License": "MIT License", + "Name": "openpyxl", + "Version": "3.1.5" + }, + { + "License": "Apache Software License; BSD License", + "Name": "packaging", + "Version": "25.0" + }, + { + "License": "BSD License", + "Name": "pandas", + "Version": "3.0.3" + }, + { + "License": "MIT License", + "Name": "pendulum", + "Version": "3.2.0" + }, + { + "License": "MIT-CMU", + "Name": "pillow", + "Version": "12.2.0" + }, + { + "License": "MIT License", + "Name": "pluggy", + "Version": "1.6.0" + }, + { + "License": "BSD-3-Clause", + "Name": "psutil", + "Version": "7.2.2" + }, + { + "License": "LGPL-3.0-only", + "Name": "psycopg", + "Version": "3.3.2" + }, + { + "License": "LGPL-3.0-only", + "Name": "psycopg-binary", + "Version": "3.3.2" + }, + { + "License": "BSD License", + "Name": "pycparser", + "Version": "2.23" + }, + { + "License": "MIT", + "Name": "pydantic", + "Version": "2.12.5" + }, + { + "License": "MIT", + "Name": "pydantic-settings", + "Version": "2.14.1" + }, + { + "License": "MIT", + "Name": "pydantic_core", + "Version": "2.41.5" + }, + { + "License": "BSD License", + "Name": "pydyf", + "Version": "0.12.1" + }, + { + "License": "GNU General Public License v2 or later (GPLv2+); GNU Lesser General Public License v2 or later (LGPLv2+); Mozilla Public License 1.1 (MPL 1.1)", + "Name": "pyphen", + "Version": "0.17.2" + }, + { + "License": "MIT", + "Name": "pytest", + "Version": "9.0.2" + }, + { + "License": "Apache-2.0", + "Name": "pytest-asyncio", + "Version": "1.4.0" + }, + { + "License": "Apache Software License; BSD License", + "Name": "python-dateutil", + "Version": "2.9.0.post0" + }, + { + "License": "BSD-3-Clause", + "Name": "python-dotenv", + "Version": "1.2.1" + }, + { + "License": "Apache-2.0", + "Name": "python-multipart", + "Version": "0.0.32" + }, + { + "License": "MIT License", + "Name": "pytz", + "Version": "2025.2" + }, + { + "License": "MIT", + "Name": "redis", + "Version": "7.1.0" + }, + { + "License": "MIT License", + "Name": "rich", + "Version": "15.0.0" + }, + { + "License": "MIT License", + "Name": "ruff", + "Version": "0.14.13" + }, + { + "License": "ISC License (ISCL)", + "Name": "shellingham", + "Version": "1.5.4" + }, + { + "License": "MIT License", + "Name": "six", + "Version": "1.17.0" + }, + { + "License": "BSD License", + "Name": "smmap", + "Version": "5.0.3" + }, + { + "License": "Apache Software License; MIT License", + "Name": "sniffio", + "Version": "1.3.1" + }, + { + "License": "Apache Software License", + "Name": "sortedcontainers", + "Version": "2.4.0" + }, + { + "License": "UNKNOWN", + "Name": "sqlglot", + "Version": "27.8.0" + }, + { + "License": "MIT License", + "Name": "sqlglotrs", + "Version": "0.6.1" + }, + { + "License": "BSD-3-Clause", + "Name": "starlette", + "Version": "0.52.1" + }, + { + "License": "BSD License", + "Name": "tinycss2", + "Version": "1.5.1" + }, + { + "License": "MIT License", + "Name": "tinyhtml5", + "Version": "2.1.0" + }, + { + "License": "MIT License; Mozilla Public License 2.0 (MPL 2.0)", + "Name": "tqdm", + "Version": "4.67.1" + }, + { + "License": "MIT", + "Name": "typer", + "Version": "0.26.7" + }, + { + "License": "MIT", + "Name": "typing-inspection", + "Version": "0.4.2" + }, + { + "License": "PSF-2.0", + "Name": "typing_extensions", + "Version": "4.15.0" + }, + { + "License": "Apache-2.0", + "Name": "tzdata", + "Version": "2025.3" + }, + { + "License": "MIT License", + "Name": "tzlocal", + "Version": "5.3.1" + }, + { + "License": "Apache-2.0", + "Name": "ua-parser", + "Version": "1.0.2" + }, + { + "License": "Apache Software License", + "Name": "ua-parser-builtins", + "Version": "202601" + }, + { + "License": "BSD-3-Clause", + "Name": "uvicorn", + "Version": "0.49.0" + }, + { + "License": "BSD License", + "Name": "weasyprint", + "Version": "69.0" + }, + { + "License": "BSD License", + "Name": "webencodings", + "Version": "0.5.1" + }, + { + "License": "BSD-3-Clause", + "Name": "websockets", + "Version": "16.0" + }, + { + "License": "Apache Software License", + "Name": "zopfli", + "Version": "0.4.3" + } +] diff --git a/backend/app/api/v1/module_platform/invoice/oss_licenses_helper.py b/backend/app/api/v1/module_platform/invoice/oss_licenses_helper.py new file mode 100644 index 00000000..7ff00480 --- /dev/null +++ b/backend/app/api/v1/module_platform/invoice/oss_licenses_helper.py @@ -0,0 +1,51 @@ +"""辅助函数 — 把 pip-licenses 输出的原始 JSON 转换成模板友好的精简数据。 + +为什么不直接渲染原始 JSON: +- LicenseText 全文可能数 MB +- 模板只需要摘要信息 (Name/Version/License) +- 按 License 分组便于阅读 +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import TypedDict + + +class PackageInfo(TypedDict): + name: str + version: str + license: str + + +class LicenseGroup(TypedDict): + license: str + packages: list[PackageInfo] + + +_LICENSES_JSON_PATH = Path(__file__).parent / "oss_licenses.json" + + +def load_oss_licenses() -> list[LicenseGroup]: + """ + 加载项目依赖的开源许可证清单,按许可证类型分组。 + + 返回: + - list[LicenseGroup]: 形如 [{"license": "MIT", "packages": [{"name": ..., "version": ...}, ...]}, ...] + """ + raw = json.loads(_LICENSES_JSON_PATH.read_text(encoding="utf-8")) + groups: dict[str, list[PackageInfo]] = {} + for pkg in raw: + license_name = pkg.get("License") or "Unknown" + groups.setdefault(license_name, []).append( + PackageInfo( + name=pkg["Name"], + version=pkg.get("Version", "-"), + license=license_name, + ) + ) + sorted_groups: list[LicenseGroup] = [] + for license_name in sorted(groups.keys(), key=str.lower): + pkgs = sorted(groups[license_name], key=lambda p: p["name"].lower()) + sorted_groups.append(LicenseGroup(license=license_name, packages=pkgs)) + return sorted_groups diff --git a/backend/app/api/v1/module_platform/invoice/pdf_helper.py b/backend/app/api/v1/module_platform/invoice/pdf_helper.py index c473b517..a120cc46 100644 --- a/backend/app/api/v1/module_platform/invoice/pdf_helper.py +++ b/backend/app/api/v1/module_platform/invoice/pdf_helper.py @@ -11,6 +11,7 @@ from datetime import datetime from app.config.path_conf import INVOICE_DIR, TEMPLATE_DIR from app.utils.pdf_generator import amount_to_cn_uppercase, amount_to_yuan, generate_pdf_from_template +from .oss_licenses_helper import load_oss_licenses from .schema import InvoiceOutSchema _INVOICE_TYPE_LABEL = { @@ -73,9 +74,42 @@ def _render_invoice_pdf(invoice: InvoiceOutSchema) -> str: output_dir = INVOICE_DIR / str(invoice.tenant_id) output_path = output_dir / f"{invoice.invoice_no}.pdf" generate_pdf_from_template( - template_name="invoice.jinja2", + template_name="invoice/invoice.jinja2", template_dir=TEMPLATE_DIR, variables=variables, output_path=output_path, ) return f"/static/invoice/{invoice.tenant_id}/{invoice.invoice_no}.pdf" + + +def _render_oss_license_pdf(invoice: InvoiceOutSchema) -> str: + """ + 渲染并保存开源项目授权声明函 PDF(与发票 PDF 独立存储) + + 参数: + - invoice (InvoiceOutSchema): 发票对象(用于在授权函中展示关联发票号) + + 返回: + - str: PDF 的相对 URL 路径(形如 /static/invoice/{tenant_id}/{invoice_no}_license.pdf) + """ + groups = load_oss_licenses() + total_packages = sum(len(g["packages"]) for g in groups) + + variables = { + "invoice_no": invoice.invoice_no, + "invoice_date": datetime.now().strftime("%Y-%m-%d"), + "product_version": "v1.0.0", + "groups": groups, + "total_packages": total_packages, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + + output_dir = INVOICE_DIR / str(invoice.tenant_id) + output_path = output_dir / f"{invoice.invoice_no}_license.pdf" + generate_pdf_from_template( + template_name="invoice/oss_license.jinja2", + template_dir=TEMPLATE_DIR, + variables=variables, + output_path=output_path, + ) + return f"/static/invoice/{invoice.tenant_id}/{invoice.invoice_no}_license.pdf" diff --git a/backend/app/api/v1/module_platform/invoice/schema.py b/backend/app/api/v1/module_platform/invoice/schema.py index 5733f705..38d47629 100644 --- a/backend/app/api/v1/module_platform/invoice/schema.py +++ b/backend/app/api/v1/module_platform/invoice/schema.py @@ -29,7 +29,8 @@ class InvoiceUpdateSchema(BaseModel): """更新发票(内部使用)""" status: int | None = Field(default=None, ge=0, le=3, description="状态(0:待开票 1:已开票 2:开票失败 3:已作废)") - pdf_url: str | None = Field(default=None, max_length=500, description="PDF 下载地址") + pdf_url: str | None = Field(default=None, max_length=500, description="发票 PDF 下载地址") + oss_license_pdf_url: str | None = Field(default=None, max_length=500, description="开源授权函 PDF 下载地址") api_response: str | None = Field(default=None, description="第三方 API 响应") description: str | None = Field(default=None, description="备注") @@ -50,7 +51,8 @@ class InvoiceIssueSchema(BaseModel): """超管开票""" api_response: str | None = Field(default=None, description="第三方 API 响应(手动填入)") - pdf_url: str | None = Field(default=None, max_length=500, description="PDF 下载地址") + pdf_url: str | None = Field(default=None, max_length=500, description="发票 PDF 下载地址") + oss_license_pdf_url: str | None = Field(default=None, max_length=500, description="开源授权函 PDF 下载地址") class InvoiceVoidSchema(BaseModel): @@ -64,7 +66,8 @@ class InvoiceOutSchema(InvoiceCreateSchema, BaseSchema, UserBySchema, TenantBySc model_config = ConfigDict(from_attributes=True) - pdf_url: str | None = Field(default=None, description="PDF 下载地址") + pdf_url: str | None = Field(default=None, description="发票 PDF 下载地址") + oss_license_pdf_url: str | None = Field(default=None, description="开源授权函 PDF 下载地址") api_response: str | None = Field(default=None, description="第三方 API 响应") diff --git a/backend/app/api/v1/module_platform/invoice/service.py b/backend/app/api/v1/module_platform/invoice/service.py index 61ede355..031398fb 100644 --- a/backend/app/api/v1/module_platform/invoice/service.py +++ b/backend/app/api/v1/module_platform/invoice/service.py @@ -8,7 +8,7 @@ from app.core.exceptions import CustomException from app.core.logger import logger from .crud import InvoiceCRUD -from .pdf_helper import _render_invoice_pdf +from .pdf_helper import _render_invoice_pdf, _render_oss_license_pdf from .schema import ( InvoiceApplySchema, InvoiceCreateSchema, @@ -134,6 +134,16 @@ class InvoiceTenantService: raise CustomException(msg="发票未开具或无PDF") return invoice.pdf_url + @classmethod + async def download_license(cls, auth: AuthSchema, invoice_id: int, tenant_id: int) -> str: + crud = InvoiceCRUD(auth) + invoice = await crud.get_or_404(id=invoice_id, msg="发票不存在") + if hasattr(invoice, "tenant_id") and invoice.tenant_id != tenant_id: + raise CustomException(msg="发票不存在") + if not invoice.oss_license_pdf_url: + raise CustomException(msg="开源授权函 PDF 不存在,请先开票") + return invoice.oss_license_pdf_url + class InvoicePlatformService: """平台端发票服务""" @@ -166,7 +176,14 @@ class InvoicePlatformService: ) @classmethod - async def issue(cls, auth: AuthSchema, invoice_id: int, pdf_url: str, api_response: str) -> InvoiceOutSchema: + async def issue( + cls, + auth: AuthSchema, + invoice_id: int, + pdf_url: str, + api_response: str, + oss_license_pdf_url: str = "", + ) -> InvoiceOutSchema: """ 平台开具发票 @@ -175,6 +192,7 @@ class InvoicePlatformService: - invoice_id (int): 发票 ID - pdf_url (str): PDF 下载地址 - api_response (str): 第三方 API 响应 + - oss_license_pdf_url (str): 授权函 PDF 地址(手动模式可空,自动模式自动渲染) 返回: - InvoiceOutSchema: 发票信息 @@ -186,9 +204,10 @@ class InvoicePlatformService: if invoice.status != 0: raise CustomException(msg="仅待开票状态可操作") - # 调用开票服务:本地 WeasyPrint 渲染 PDF(对接百望云/票通时替换为远程调用) + # 调用开票服务:本地 WeasyPrint 渲染发票 PDF + 开源授权函 PDF(独立两个文件) try: pdf_url_result = _render_invoice_pdf(invoice) + oss_license_pdf_url_result = _render_oss_license_pdf(invoice) api_response_result = json.dumps( { "code": "SUCCESS", @@ -216,6 +235,7 @@ class InvoicePlatformService: InvoiceUpdateSchema( status=1, pdf_url=pdf_url or pdf_url_result, + oss_license_pdf_url=oss_license_pdf_url or oss_license_pdf_url_result, api_response=api_response or api_response_result, ), ) diff --git a/backend/app/api/v1/module_platform/menu/controller.py b/backend/app/api/v1/module_platform/menu/controller.py index 7fe73575..d720b75b 100644 --- a/backend/app/api/v1/module_platform/menu/controller.py +++ b/backend/app/api/v1/module_platform/menu/controller.py @@ -2,11 +2,11 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, SuccessResponse +from app.core import cache_util from app.core.base_schema import AuthSchema, BatchSetAvailable +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute @@ -56,7 +56,7 @@ async def create_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:menu:create"]))], ) -> JSONResponse: result_dict = await MenuService(auth).create(data=data) - await FastAPICache.clear(namespace=_MENU_NS) + await cache_util.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="创建菜单成功") @@ -71,7 +71,7 @@ async def update_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:menu:update"]))], ) -> JSONResponse: result_dict = await MenuService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_MENU_NS) + await cache_util.clear(namespace=_MENU_NS) return SuccessResponse(data=result_dict, msg="修改菜单成功") @@ -85,7 +85,7 @@ async def delete_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:menu:delete"]))], ) -> JSONResponse: await MenuService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_MENU_NS) + await cache_util.clear(namespace=_MENU_NS) return SuccessResponse(msg="删除菜单成功") @@ -99,5 +99,5 @@ async def batch_set_available_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:menu:patch"]))], ) -> JSONResponse: await MenuService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_MENU_NS) + await cache_util.clear(namespace=_MENU_NS) return SuccessResponse(msg="批量修改菜单状态成功") diff --git a/backend/app/api/v1/module_platform/menu/service.py b/backend/app/api/v1/module_platform/menu/service.py index d90be7a0..f002ce00 100644 --- a/backend/app/api/v1/module_platform/menu/service.py +++ b/backend/app/api/v1/module_platform/menu/service.py @@ -129,7 +129,7 @@ class MenuService: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - all_menus = await MenuCRUD(self.auth).list() + all_menus = await MenuCRUD(self.auth).get_list() child_id_map = get_child_id_map(model_list=all_menus) delete_ids_set = set() @@ -142,7 +142,7 @@ class MenuService: @require_superadmin async def set_available(self, data: BatchSetAvailable) -> None: - menu_list = await MenuCRUD(self.auth).list() + menu_list = await MenuCRUD(self.auth).get_list() total_ids = [] if data.status == 0: diff --git a/backend/app/api/v1/module_platform/order/controller.py b/backend/app/api/v1/module_platform/order/controller.py index 293dff06..4dbdc6f2 100644 --- a/backend/app/api/v1/module_platform/order/controller.py +++ b/backend/app/api/v1/module_platform/order/controller.py @@ -1,5 +1,3 @@ -"""订单与支付 Controller""" - from typing import Annotated from fastapi import APIRouter, Body, Depends, Path, Query, Request diff --git a/backend/app/api/v1/module_platform/order/model.py b/backend/app/api/v1/module_platform/order/model.py index 2f1c490c..0a3b37f4 100644 --- a/backend/app/api/v1/module_platform/order/model.py +++ b/backend/app/api/v1/module_platform/order/model.py @@ -1,5 +1,3 @@ -"""订单与支付 Model""" - from datetime import datetime from typing import TYPE_CHECKING diff --git a/backend/app/api/v1/module_platform/order/schema.py b/backend/app/api/v1/module_platform/order/schema.py index 9b5c84df..4004702c 100644 --- a/backend/app/api/v1/module_platform/order/schema.py +++ b/backend/app/api/v1/module_platform/order/schema.py @@ -1,7 +1,3 @@ -"""订单与支付 Schema""" - -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime from typing import Literal @@ -99,14 +95,13 @@ class OrderCreateSchema(BaseModel): return v @model_validator(mode="after") - def check_target(self) -> OrderCreateSchema: + def check_target(self) -> None: if self.order_type == "plugin": if not self.plugin_id or self.plugin_id <= 0: raise ValueError("插件订单必须指定 plugin_id") else: if not self.package_id or self.package_id <= 0: raise ValueError("套餐订单必须指定 package_id") - return self class OrderOutSchema(BaseSchema, TenantBySchema): diff --git a/backend/app/api/v1/module_platform/order/service.py b/backend/app/api/v1/module_platform/order/service.py index 3dcf5a3b..ea6a5994 100644 --- a/backend/app/api/v1/module_platform/order/service.py +++ b/backend/app/api/v1/module_platform/order/service.py @@ -1,5 +1,3 @@ -"""订单与支付 Service""" - import random from datetime import datetime, timedelta @@ -474,7 +472,7 @@ class PaymentService: raise CustomException(msg=f"降级失败:当前租户已有 {current} 个{label},超过目标套餐限额 {limit}") @classmethod - async def _send_order_email(cls, auth: AuthSchema, order: "OrderModel", product: object, tenant: object, order_type_label: str = "") -> None: + async def _send_order_email(cls, auth: AuthSchema, order: OrderModel, product: object, tenant: object, order_type_label: str = "") -> None: """ 发送购买确认邮件(失败静默降级) diff --git a/backend/app/api/v1/module_platform/package/controller.py b/backend/app/api/v1/module_platform/package/controller.py index 7d4d7103..a3581179 100644 --- a/backend/app/api/v1/module_platform/package/controller.py +++ b/backend/app/api/v1/module_platform/package/controller.py @@ -2,12 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute @@ -66,7 +66,7 @@ async def create_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:create"]))], ) -> JSONResponse: result_dict = await PackageService(auth).create(data=data) - await FastAPICache.clear(namespace=_PKG_NS) + await cache_util.clear(namespace=_PKG_NS) return SuccessResponse(data=result_dict, msg="创建成功") @PackageRouter.put( @@ -80,7 +80,7 @@ async def update_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:update"]))], ) -> JSONResponse: result_dict = await PackageService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_PKG_NS) + await cache_util.clear(namespace=_PKG_NS) return SuccessResponse(data=result_dict, msg="更新成功") @PackageRouter.delete( @@ -93,7 +93,7 @@ async def delete_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_package:package:delete"]))], ) -> JSONResponse: await PackageService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_PKG_NS) + await cache_util.clear(namespace=_PKG_NS) return SuccessResponse(msg="删除成功") @PackageRouter.patch( @@ -107,7 +107,7 @@ async def set_available_controller( ) -> JSONResponse: for id in data.ids: await PackageService(auth).update(id=id, data=PackageUpdateSchema(status=data.status)) - await FastAPICache.clear(namespace=_PKG_NS) + await cache_util.clear(namespace=_PKG_NS) return SuccessResponse(msg="状态设置成功") @PackageRouter.get( diff --git a/backend/app/api/v1/module_platform/package/service.py b/backend/app/api/v1/module_platform/package/service.py index b4c387d4..ee9f181d 100644 --- a/backend/app/api/v1/module_platform/package/service.py +++ b/backend/app/api/v1/module_platform/package/service.py @@ -1,3 +1,4 @@ + import sqlalchemy as sa from sqlalchemy import func, select diff --git a/backend/app/api/v1/module_platform/plugin/controller.py b/backend/app/api/v1/module_platform/plugin/controller.py index 3c01a16c..2db667ec 100644 --- a/backend/app/api/v1/module_platform/plugin/controller.py +++ b/backend/app/api/v1/module_platform/plugin/controller.py @@ -2,12 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path, Query from fastapi.responses import JSONResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute @@ -67,7 +67,7 @@ async def plugin_create_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:create"]))], ) -> JSONResponse: r = await PluginService(auth).create(data=data) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(data=r, msg="创建成功") @@ -82,7 +82,7 @@ async def plugin_update_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:update"]))], ) -> JSONResponse: r = await PluginService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(data=r, msg="更新成功") @@ -96,7 +96,7 @@ async def plugin_delete_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:delete"]))], ) -> JSONResponse: await PluginService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="删除成功") @@ -125,7 +125,7 @@ async def plugin_install_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:install"]))], ) -> JSONResponse: await PluginService(auth).install(plugin_id=data.plugin_id) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="安装成功") @@ -139,7 +139,7 @@ async def plugin_uninstall_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:uninstall"]))], ) -> JSONResponse: await PluginService(auth).uninstall(plugin_id=data.plugin_id) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="卸载成功") @@ -153,7 +153,7 @@ async def plugin_toggle_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:toggle"]))], ) -> JSONResponse: await PluginService(auth).toggle(plugin_id=data.plugin_id) - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(msg="操作成功") @@ -178,5 +178,5 @@ async def plugin_reload_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_platform:plugin:reload"]))], ) -> JSONResponse: msg = PluginService.reload() - await FastAPICache.clear(namespace=_PLUGIN_NS) + await cache_util.clear(namespace=_PLUGIN_NS) return SuccessResponse(data=msg, msg="重载成功") 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 b543dd90..069d0038 100644 --- a/backend/app/api/v1/module_platform/self_service/controller.py +++ b/backend/app/api/v1/module_platform/self_service/controller.py @@ -1,5 +1,3 @@ -"""租户自助服务 Controller — 对应 PRD §20.20""" - from typing import Annotated from fastapi import APIRouter, Depends, Path, Query 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 70de6b38..f1a5b205 100644 --- a/backend/app/api/v1/module_platform/self_service/schema.py +++ b/backend/app/api/v1/module_platform/self_service/schema.py @@ -1,5 +1,3 @@ -"""租户自助服务 Schema""" - from typing import Literal from pydantic import BaseModel, ConfigDict, Field 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 6200f86c..d6cf8b3e 100644 --- a/backend/app/api/v1/module_platform/self_service/service.py +++ b/backend/app/api/v1/module_platform/self_service/service.py @@ -1,4 +1,3 @@ -"""租户自助服务 Service""" from datetime import datetime, timedelta diff --git a/backend/app/api/v1/module_platform/tenant/controller.py b/backend/app/api/v1/module_platform/tenant/controller.py index 211604a6..dabec466 100644 --- a/backend/app/api/v1/module_platform/tenant/controller.py +++ b/backend/app/api/v1/module_platform/tenant/controller.py @@ -2,13 +2,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path, Query from fastapi.responses import JSONResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from redis.asyncio.client import Redis from app.common.response import ResponseSchema, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission, redis_getter from app.core.router_class import OperationLogRoute @@ -74,7 +74,7 @@ async def create_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))], ) -> JSONResponse: result_dict = await TenantService(auth).create(data=data) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(data=result_dict, msg="创建租户成功") @TenantRouter.put( @@ -88,7 +88,7 @@ async def update_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], ) -> JSONResponse: result_dict = await TenantService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(data=result_dict, msg="修改租户成功") @TenantRouter.delete( @@ -101,7 +101,7 @@ async def delete_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))], ) -> JSONResponse: await TenantService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(msg="删除租户成功") @TenantRouter.patch( @@ -114,7 +114,7 @@ async def batch_set_available_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))], ) -> JSONResponse: await TenantService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(msg="批量修改租户状态成功") @TenantRouter.put( @@ -127,7 +127,7 @@ async def toggle_tenant_status_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))], ) -> JSONResponse: await TenantService(auth).toggle_status(id=id) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(msg="修改租户状态成功") @TenantRouter.get( @@ -154,7 +154,7 @@ async def add_tenant_user_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))], ) -> JSONResponse: await TenantService(auth).add_tenant_user(tenant_id=id, data=data) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(msg="添加用户成功") @TenantRouter.delete( @@ -168,7 +168,7 @@ async def remove_tenant_user_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))], ) -> JSONResponse: await TenantService(auth).remove_tenant_user(tenant_id=id, user_id=uid) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(msg="移除用户成功") @TenantRouter.get( @@ -207,7 +207,7 @@ async def update_tenant_config_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], ) -> JSONResponse: result = await TenantService(auth).update_config(redis=redis, tenant_id=id, config=data) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(data=result, msg="更新租户配置成功") @TenantRouter.put( @@ -221,7 +221,7 @@ async def renew_tenant_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))], ) -> JSONResponse: result = await TenantService(auth).renew(tenant_id=id, end_time=data.end_time) - await FastAPICache.clear(namespace=_TENANT_NS) + await cache_util.clear(namespace=_TENANT_NS) return SuccessResponse(data=result, msg="租户续期成功") @TenantRouter.get( diff --git a/backend/app/api/v1/module_platform/tenant/service.py b/backend/app/api/v1/module_platform/tenant/service.py index 8e18ff42..ea834db7 100644 --- a/backend/app/api/v1/module_platform/tenant/service.py +++ b/backend/app/api/v1/module_platform/tenant/service.py @@ -1,3 +1,4 @@ + import json import random import string @@ -206,13 +207,13 @@ class TenantService: for tid in ids: reasons: list[str] = [] - if await UserCRUD(self.auth).list(search={"tenant_id": tid}): + if await UserCRUD(self.auth).get_list(search={"tenant_id": tid}): reasons.append("用户") - if await DeptCRUD(self.auth).list(search={"tenant_id": tid}): + if await DeptCRUD(self.auth).get_list(search={"tenant_id": tid}): reasons.append("部门") - if await RoleCRUD(self.auth).list(search={"tenant_id": tid}): + if await RoleCRUD(self.auth).get_list(search={"tenant_id": tid}): reasons.append("角色") - if await PositionCRUD(self.auth).list(search={"tenant_id": tid}): + if await PositionCRUD(self.auth).get_list(search={"tenant_id": tid}): reasons.append("岗位") if reasons: raise CustomException(msg=f"租户下已存在{'/'.join(reasons)},操作失败") diff --git a/backend/app/api/v1/module_system/auth/controller.py b/backend/app/api/v1/module_system/auth/controller.py index 459e1b3b..c500235a 100644 --- a/backend/app/api/v1/module_system/auth/controller.py +++ b/backend/app/api/v1/module_system/auth/controller.py @@ -4,19 +4,19 @@ from typing import Annotated from fastapi import APIRouter, Depends, Path, Query, Request from fastapi.responses import JSONResponse, RedirectResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from redis.asyncio.client import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.common.response import ErrorResponse, ResponseSchema, SuccessResponse from app.config.setting import settings +from app.core import cache_util from app.core.base_schema import ( AuthSchema, JWTOutSchema, LogoutPayloadSchema, RefreshTokenPayloadSchema, ) +from app.core.cache_util import cache from app.core.dependencies import db_getter, get_current_user, redis_getter from app.core.exceptions import CustomException from app.core.logger import logger @@ -180,7 +180,7 @@ async def select_tenant_controller( redis: Annotated[Redis, Depends(redis_getter)], ) -> JSONResponse: result = await LoginService(auth).select_tenant(request=request, redis=redis, tenant_id=data.tenant_id) - await FastAPICache.clear(namespace=_AUTH_TENANTS_NS) + await cache_util.clear(namespace=_AUTH_TENANTS_NS) return SuccessResponse(data=result, msg="租户切换成功") 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 afd93e23..44ddc1c8 100644 --- a/backend/app/api/v1/module_system/auth/oauth_service.py +++ b/backend/app/api/v1/module_system/auth/oauth_service.py @@ -8,8 +8,6 @@ 环境变量见 Settings 中 OAUTH_* 字段。 """ -from __future__ import annotations - import json import secrets from typing import Any, Literal diff --git a/backend/app/api/v1/module_system/auth/service.py b/backend/app/api/v1/module_system/auth/service.py index 4c0388bf..a366efca 100644 --- a/backend/app/api/v1/module_system/auth/service.py +++ b/backend/app/api/v1/module_system/auth/service.py @@ -1,3 +1,4 @@ + import json import secrets import uuid diff --git a/backend/app/api/v1/module_system/dept/controller.py b/backend/app/api/v1/module_system/dept/controller.py index 6a6490ec..114f85c9 100644 --- a/backend/app/api/v1/module_system/dept/controller.py +++ b/backend/app/api/v1/module_system/dept/controller.py @@ -2,11 +2,11 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, SuccessResponse +from app.core import cache_util from app.core.base_schema import AuthSchema, BatchSetAvailable +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute @@ -53,7 +53,7 @@ async def create_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:create"]))], ) -> JSONResponse: result_dict = await DeptService(auth).create(data=data) - await FastAPICache.clear(namespace=_DEPT_NS) + await cache_util.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="创建部门成功") @DeptRouter.put( @@ -67,7 +67,7 @@ async def update_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:update"]))], ) -> JSONResponse: result_dict = await DeptService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_DEPT_NS) + await cache_util.clear(namespace=_DEPT_NS) return SuccessResponse(data=result_dict, msg="修改部门成功") @DeptRouter.delete( @@ -80,7 +80,7 @@ async def delete_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:delete"]))], ) -> JSONResponse: await DeptService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_DEPT_NS) + await cache_util.clear(namespace=_DEPT_NS) return SuccessResponse(msg="删除部门成功") @DeptRouter.patch( @@ -93,5 +93,5 @@ async def batch_set_available_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:patch"]))], ) -> JSONResponse: await DeptService(auth).batch_set_available(data=data) - await FastAPICache.clear(namespace=_DEPT_NS) + await cache_util.clear(namespace=_DEPT_NS) return SuccessResponse(msg="批量修改部门状态成功") diff --git a/backend/app/api/v1/module_system/dept/service.py b/backend/app/api/v1/module_system/dept/service.py index ec858aae..31d103ee 100644 --- a/backend/app/api/v1/module_system/dept/service.py +++ b/backend/app/api/v1/module_system/dept/service.py @@ -1,3 +1,4 @@ + from app.core.base_schema import AuthSchema, BatchSetAvailable from app.core.exceptions import CustomException from app.utils.common_util import ( @@ -83,7 +84,7 @@ class DeptService: raise CustomException(msg="删除失败,删除对象不能为空") # 获取所有部门列表,用于构建树形关系 - all_depts = await DeptCRUD(self.auth).list() + all_depts = await DeptCRUD(self.auth).get_list() # 构建子部门ID映射 child_id_map = get_child_id_map(model_list=all_depts) @@ -95,7 +96,7 @@ class DeptService: await DeptCRUD(self.auth).delete(ids=ids) async def batch_set_available(self, data: BatchSetAvailable) -> None: - dept_list = await DeptCRUD(self.auth).list() + dept_list = await DeptCRUD(self.auth).get_list() total_ids = [] if data.status == 0: diff --git a/backend/app/api/v1/module_system/dict/controller.py b/backend/app/api/v1/module_system/dict/controller.py index 4106fa30..b1be6a8c 100644 --- a/backend/app/api/v1/module_system/dict/controller.py +++ b/backend/app/api/v1/module_system/dict/controller.py @@ -2,13 +2,13 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from redis.asyncio.client import Redis from app.common.response import ResponseSchema, StreamResponse, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission, redis_getter from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -68,7 +68,7 @@ async def get_type_list_controller( async def get_type_optionselect_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:query"]))], ) -> JSONResponse: - result_dict_list = await DictTypeService(auth).list() + result_dict_list = await DictTypeService(auth).get_list() return SuccessResponse(data=result_dict_list, msg="获取字典类型列表成功") @DictRouter.post( @@ -82,7 +82,7 @@ async def create_type_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:create"]))], ) -> JSONResponse: result_dict = await DictTypeService(auth).create(redis=redis, data=data) - await FastAPICache.clear(namespace=_DICT_TYPE_NS) + await cache_util.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="创建字典类型成功") @DictRouter.put( @@ -97,7 +97,7 @@ async def update_type_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:update"]))], ) -> JSONResponse: result_dict = await DictTypeService(auth).update(redis=redis, id=id, data=data) - await FastAPICache.clear(namespace=_DICT_TYPE_NS) + await cache_util.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(data=result_dict, msg="修改字典类型成功") @DictRouter.delete( @@ -111,7 +111,7 @@ async def delete_type_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:delete"]))], ) -> JSONResponse: await DictTypeService(auth).delete(redis=redis, ids=ids) - await FastAPICache.clear(namespace=_DICT_TYPE_NS) + await cache_util.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="删除字典类型成功") @DictRouter.patch( @@ -124,7 +124,7 @@ async def batch_set_available_dict_type_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:patch"]))], ) -> JSONResponse: await DictTypeService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_DICT_TYPE_NS) + await cache_util.clear(namespace=_DICT_TYPE_NS) return SuccessResponse(msg="批量修改字典类型状态成功") @DictRouter.post( @@ -137,7 +137,7 @@ async def export_type_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:export"]))], ) -> StreamingResponse: # 获取全量数据并转为dict列表 - result_dict_list = await DictTypeService(auth).list(search=search) + result_dict_list = await DictTypeService(auth).get_list(search=search) export_data = [item.model_dump() for item in result_dict_list] export_result = DictTypeService.export(data_list=export_data) @@ -242,7 +242,7 @@ async def export_data_list_controller( page: Annotated[PaginationQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:export"]))], ) -> StreamingResponse: - result_dict_list = await DictDataService(auth).list(search=search, order_by=page.order_by) + result_dict_list = await DictDataService(auth).get_list(search=search, order_by=page.order_by) export_data = [item.model_dump() for item in result_dict_list] export_result = DictDataService.export(data_list=export_data) diff --git a/backend/app/api/v1/module_system/dict/crud.py b/backend/app/api/v1/module_system/dict/crud.py index 4d490ce2..6acc979f 100644 --- a/backend/app/api/v1/module_system/dict/crud.py +++ b/backend/app/api/v1/module_system/dict/crud.py @@ -54,7 +54,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS - int: 删除的记录数量 """ if exclude_system: - system_data = await self.list( + system_data = await self.get_list( search={ "id__in": ids, "remark__contains": "系统默认", @@ -81,4 +81,4 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS search = {"dict_type": dict_type} if status is not None: search["status"] = status - return await self.list(search=search, order_by=[{"id": "asc"}]) + return await self.get_list(search=search, order_by=[{"id": "asc"}]) diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index 03cba9c9..ad6766e0 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -47,7 +47,7 @@ class DictTypeService: """ return await DictTypeCRUD(self.auth).get_or_404(id=id, out_schema=DictTypeOutSchema) - async def list( + async def get_list( self, search: DictTypeQueryParam | None = None, order_by: list[dict] | None = None, @@ -62,7 +62,7 @@ class DictTypeService: 返回: - list[DictTypeOutSchema]: 字典类型响应模型列表 """ - obj_list = await DictTypeCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + obj_list = await DictTypeCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [DictTypeOutSchema.model_validate(obj) for obj in obj_list] async def page( @@ -149,7 +149,7 @@ class DictTypeService: # 如果字典类型修改或状态变更,则修改对应字典数据的类型和状态 if exist_obj.dict_type != data.dict_type or exist_obj.status != data.status: - exist_obj_type_list = await DictDataCRUD(self.auth).list(search={"dict_type": exist_obj.dict_type}) + exist_obj_type_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": exist_obj.dict_type}) if exist_obj_type_list: for item in exist_obj_type_list: item.dict_type = data.dict_type @@ -174,7 +174,7 @@ class DictTypeService: redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}" try: # 获取当前字典类型的所有字典数据,确保包含最新状态 - dict_data_list = await DictDataCRUD(self.auth).list(search={"dict_type": data.dict_type}) + dict_data_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": data.dict_type}) dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row] value = json.dumps(dict_data, ensure_ascii=False) @@ -203,14 +203,14 @@ class DictTypeService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - existing = await DictTypeCRUD(self.auth).list(search={"id": ("in", ids)}) + existing = await DictTypeCRUD(self.auth).get_list(search={"id": ("in", ids)}) existing_map = {obj.id: obj for obj in existing} for nid in ids: if nid not in existing_map: raise CustomException(msg="删除失败,该数据不存在") exist_obj = existing_map[nid] # 检查是否有字典数据 - exist_obj_type_list = await DictDataCRUD(self.auth).list(search={"dict_type": exist_obj.dict_type}) + exist_obj_type_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": exist_obj.dict_type}) if len(exist_obj_type_list) > 0: # 如果有字典数据,不能删除 raise CustomException(msg="删除失败,该数据字典类型下存在字典数据") @@ -290,7 +290,7 @@ class DictDataService: """ return await DictDataCRUD(self.auth).get_or_404(id=id, out_schema=DictDataOutSchema) - async def list( + async def get_list( self, search: DictDataQueryParam | None = None, order_by: list[dict] | None = None, @@ -305,7 +305,7 @@ class DictDataService: 返回: - list[DictDataOutSchema]: 字典数据响应模型列表 """ - obj_list = await DictDataCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + obj_list = await DictDataCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [DictDataOutSchema.model_validate(obj) for obj in obj_list] async def page( @@ -351,7 +351,7 @@ class DictDataService: async with async_db_session() as session: async with session.begin(): init_auth = AuthSchema(db=session, check_data_scope=False) - obj_list = await DictTypeCRUD(init_auth).list() + obj_list = await DictTypeCRUD(init_auth).get_list() if not obj_list: logger.warning("未找到任何字典类型数据") return @@ -360,7 +360,7 @@ class DictDataService: dict_type = obj.dict_type tenant_id = obj.tenant_id try: - dict_data_list = await DictDataCRUD(init_auth).list( + dict_data_list = await DictDataCRUD(init_auth).get_list( search={"dict_type": dict_type, "tenant_id": tenant_id} ) dict_data = [ @@ -452,7 +452,7 @@ class DictDataService: redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}" try: # 获取当前字典类型的所有字典数据 - dict_data_list = await DictDataCRUD(self.auth).list(search={"dict_type": data.dict_type}) + dict_data_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": data.dict_type}) dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row] value = json.dumps(dict_data, ensure_ascii=False) @@ -505,7 +505,7 @@ class DictDataService: if dict_type: redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{dict_type.dict_type}" try: - dict_data_list = await DictDataCRUD(self.auth).list(search={"dict_type": dict_type.dict_type}) + dict_data_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": dict_type.dict_type}) dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row] value = json.dumps(dict_data, ensure_ascii=False) await RedisCURD(redis).set( @@ -522,7 +522,7 @@ class DictDataService: # 刷新新字典类型缓存 redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}" try: - dict_data_list = await DictDataCRUD(self.auth).list(search={"dict_type": data.dict_type}) + dict_data_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": data.dict_type}) dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row] value = json.dumps(dict_data, ensure_ascii=False) await RedisCURD(redis).set( @@ -550,7 +550,7 @@ class DictDataService: """ if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - existing = await DictDataCRUD(self.auth).list(search={"id": ("in", ids)}) + existing = await DictDataCRUD(self.auth).get_list(search={"id": ("in", ids)}) existing_map = {obj.id: obj for obj in existing} for nid in ids: if nid not in existing_map: @@ -560,7 +560,7 @@ class DictDataService: redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{exist_obj.dict_type}" try: # 重新拉取该类型所有字典数据并写回缓存(保持一致) - dict_data_list = await DictDataCRUD(self.auth).list(search={"dict_type": exist_obj.dict_type}) + dict_data_list = await DictDataCRUD(self.auth).get_list(search={"dict_type": exist_obj.dict_type}) dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row] value = json.dumps(dict_data, ensure_ascii=False) await RedisCURD(redis).set( diff --git a/backend/app/api/v1/module_system/log/service.py b/backend/app/api/v1/module_system/log/service.py index 203e9ff7..23e11011 100644 --- a/backend/app/api/v1/module_system/log/service.py +++ b/backend/app/api/v1/module_system/log/service.py @@ -1,3 +1,4 @@ + from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from app.core.logger import logger @@ -49,7 +50,7 @@ class LoginLogService: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - existing = await LoginLogCRUD(self.auth).list(search={"id": ("in", ids)}) + existing = await LoginLogCRUD(self.auth).get_list(search={"id": ("in", ids)}) existing_map = {obj.id for obj in existing} for nid in ids: if nid not in existing_map: @@ -128,7 +129,7 @@ class OperationLogService: async def delete(self, ids: list[int]) -> None: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - existing = await OperationLogCRUD(self.auth).list(search={"id": ("in", ids)}) + existing = await OperationLogCRUD(self.auth).get_list(search={"id": ("in", ids)}) existing_map = {obj.id for obj in existing} for nid in ids: if nid not in existing_map: diff --git a/backend/app/api/v1/module_system/notice/controller.py b/backend/app/api/v1/module_system/notice/controller.py index 2f03fcb2..ce03f50b 100644 --- a/backend/app/api/v1/module_system/notice/controller.py +++ b/backend/app/api/v1/module_system/notice/controller.py @@ -2,12 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, StreamResponse, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission, get_current_user from app.core.logger import logger from app.core.router_class import OperationLogRoute @@ -66,7 +66,7 @@ async def create_notice_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:create"]))], ) -> JSONResponse: result_dict = await NoticeService(auth).create(data=data) - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="创建公告成功") @NoticeRouter.put( @@ -80,7 +80,7 @@ async def update_notice_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:update"]))], ) -> JSONResponse: result_dict = await NoticeService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) return SuccessResponse(data=result_dict, msg="修改公告成功") @NoticeRouter.delete( @@ -93,7 +93,7 @@ async def delete_notice_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:delete"]))], ) -> JSONResponse: await NoticeService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="删除公告成功") @NoticeRouter.patch( @@ -106,7 +106,7 @@ async def batch_set_available_notice_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:patch"]))], ) -> JSONResponse: await NoticeService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) return SuccessResponse(msg="批量修改公告状态成功") @NoticeRouter.post( @@ -117,7 +117,7 @@ async def export_notice_list_controller( search: Annotated[NoticeQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:export"]))], ) -> StreamingResponse: - result_dict_list = await NoticeService(auth).list(search=search) + result_dict_list = await NoticeService(auth).get_list(search=search) export_data = [item.model_dump() for item in result_dict_list] export_result = NoticeService.export(notice_list=export_data) @@ -163,7 +163,7 @@ async def mark_read_controller( ) -> JSONResponse: """标记已读。通过 `sys_notice_read` 表记录已读时间。""" await NoticeService(auth).mark_read(notice_id=id) - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) logger.info(f"用户[{auth.user.id}]标记通知[{id}]已读") return SuccessResponse(msg="标记已读成功") @@ -177,7 +177,7 @@ async def mark_all_read_controller( ) -> JSONResponse: """全部标记已读。返回本次操作标记的数量。""" count = await NoticeService(auth).mark_all_read() - await FastAPICache.clear(namespace=_NOTICE_NS) + await cache_util.clear(namespace=_NOTICE_NS) logger.info(f"用户[{auth.user.id}]全部已读, 数量={count}") return SuccessResponse(data=count, msg=f"全部标记已读成功,共标记 {count} 条") diff --git a/backend/app/api/v1/module_system/notice/service.py b/backend/app/api/v1/module_system/notice/service.py index b47aff32..5caa6976 100644 --- a/backend/app/api/v1/module_system/notice/service.py +++ b/backend/app/api/v1/module_system/notice/service.py @@ -28,12 +28,12 @@ class NoticeService: async def detail(self, id: int) -> NoticeOutSchema: return await NoticeCRUD(self.auth).get_or_404(id=id, out_schema=NoticeOutSchema) - async def list( + async def get_list( self, search: NoticeQueryParam | None = None, order_by: list[dict] | None = None, ) -> list[NoticeOutSchema]: - notice_obj_list = await NoticeCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + notice_obj_list = await NoticeCRUD(self.auth).get_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] async def page( @@ -79,7 +79,7 @@ class NoticeService: async def delete(self, ids: list[int]) -> None: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - notices = await NoticeCRUD(self.auth).list(search={"id": ("in", ids)}) + notices = await NoticeCRUD(self.auth).get_list(search={"id": ("in", ids)}) notice_map = {n.id: n for n in notices} for nid in ids: if nid not in notice_map: diff --git a/backend/app/api/v1/module_system/params/controller.py b/backend/app/api/v1/module_system/params/controller.py index ef3d4654..e4e19e1e 100644 --- a/backend/app/api/v1/module_system/params/controller.py +++ b/backend/app/api/v1/module_system/params/controller.py @@ -117,7 +117,7 @@ async def delete_param_controller( ) async def batch_set_status_controller( ids: Annotated[list[int], Body(description="参数ID列表")], - status: Annotated[str, Body(description="状态值")], + status: Annotated[int, Body(description="状态值")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:patch"]))], ) -> JSONResponse: await ParamsService(auth).batch_set_status(ids=ids, status=status) @@ -132,7 +132,7 @@ async def export_param_list_controller( search: Annotated[ParamsQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:export"]))], ) -> StreamingResponse: - result_dict_list = await ParamsService(auth).list(search=search) + result_dict_list = await ParamsService(auth).get_list(search=search) export_data = [item.model_dump() for item in result_dict_list] export_result = ParamsService.export(data_list=export_data) diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 23a5bf46..61619615 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -78,7 +78,7 @@ class ParamsService: raise CustomException(msg="该数据不存在") return obj.config_value - async def list( + async def get_list( self, search: ParamsQueryParam | None = None, order_by: list[dict] | None = None, @@ -93,7 +93,7 @@ class ParamsService: 返回: - list[ParamsOutSchema]: 参数响应模型列表 """ - obj_list = await ParamsCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + obj_list = await ParamsCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [ParamsOutSchema.model_validate(obj) for obj in obj_list] async def page( @@ -215,7 +215,7 @@ class ParamsService: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") # 批量校验参数存在性 - objs = await ParamsCRUD(self.auth).list(search={"id": ("in", ids)}) + objs = await ParamsCRUD(self.auth).get_list(search={"id": ("in", ids)}) obj_map = {o.id: o for o in objs} for pid in ids: obj = obj_map.get(pid) @@ -235,7 +235,7 @@ class ParamsService: logger.error(f"删除系统配置失败: {e}") raise CustomException(msg="同步删除缓存失败") from e - async def batch_set_status(self, ids: list[int], status: str) -> None: + async def batch_set_status(self, ids: list[int], status: int) -> None: """ 批量设置系统参数状态 @@ -297,7 +297,7 @@ class ParamsService: async with async_db_session() as session: async with session.begin(): init_auth = AuthSchema(db=session, check_data_scope=False) - config_obj = await ParamsCRUD(init_auth).list() + config_obj = await ParamsCRUD(init_auth).get_list() if not config_obj: raise CustomException(msg="该数据不存在") try: @@ -349,7 +349,7 @@ class ParamsService: async with async_db_session() as session: async with session.begin(): init_auth = AuthSchema(db=session, check_data_scope=False) - config_obj = await ParamsCRUD(init_auth).list() + config_obj = await ParamsCRUD(init_auth).get_list() if config_obj: try: for config in config_obj: diff --git a/backend/app/api/v1/module_system/position/controller.py b/backend/app/api/v1/module_system/position/controller.py index 25735913..6849b8b8 100644 --- a/backend/app/api/v1/module_system/position/controller.py +++ b/backend/app/api/v1/module_system/position/controller.py @@ -2,12 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, StreamResponse, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -68,7 +68,7 @@ async def create_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:create"]))], ) -> JSONResponse: result_dict = await PositionService(auth).create(data=data) - await FastAPICache.clear(namespace=_POS_NS) + await cache_util.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="创建岗位成功") @PositionRouter.put( @@ -82,7 +82,7 @@ async def update_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:update"]))], ) -> JSONResponse: result_dict = await PositionService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_POS_NS) + await cache_util.clear(namespace=_POS_NS) return SuccessResponse(data=result_dict, msg="修改岗位成功") @PositionRouter.delete( @@ -95,7 +95,7 @@ async def delete_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:delete"]))], ) -> JSONResponse: await PositionService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_POS_NS) + await cache_util.clear(namespace=_POS_NS) return SuccessResponse(msg="删除岗位成功") @PositionRouter.patch( @@ -108,7 +108,7 @@ async def batch_set_available_obj_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:patch"]))], ) -> JSONResponse: await PositionService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_POS_NS) + await cache_util.clear(namespace=_POS_NS) return SuccessResponse(msg="批量修改岗位状态成功") @PositionRouter.get( @@ -120,7 +120,7 @@ async def export_obj_list_controller( search: Annotated[PositionQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:export"]))], ) -> StreamingResponse: - position_query_result = await PositionService(auth).list(search=search) + position_query_result = await PositionService(auth).get_list(search=search) position_export_result = PositionService.export_list(position_list=position_query_result) return StreamResponse( diff --git a/backend/app/api/v1/module_system/position/service.py b/backend/app/api/v1/module_system/position/service.py index 3cda1c7e..fdd49342 100644 --- a/backend/app/api/v1/module_system/position/service.py +++ b/backend/app/api/v1/module_system/position/service.py @@ -24,12 +24,12 @@ class PositionService: async def detail(self, id: int) -> PositionOutSchema: return await PositionCRUD(self.auth).get_or_404(id=id, out_schema=PositionOutSchema) - async def list( + async def get_list( self, search: PositionQueryParam | None = None, order_by: list[dict] | None = None, ) -> list[PositionOutSchema]: - position_list = await PositionCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + position_list = await PositionCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [PositionOutSchema.model_validate(position) for position in position_list] async def page( @@ -66,7 +66,7 @@ class PositionService: async def delete(self, ids: list[int]) -> None: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - positions = await PositionCRUD(self.auth).list(search={"id": ("in", ids)}) + positions = await PositionCRUD(self.auth).get_list(search={"id": ("in", ids)}) position_map = {p.id: p for p in positions} for pid in ids: if pid not in position_map: @@ -74,7 +74,7 @@ class PositionService: await PositionCRUD(self.auth).delete(ids=ids) async def set_available(self, data: BatchSetAvailable) -> None: - positions = await PositionCRUD(self.auth).list(search={"id": ("in", data.ids)}) + positions = await PositionCRUD(self.auth).get_list(search={"id": ("in", data.ids)}) position_map = {p.id: p for p in positions} for pid in data.ids: if pid not in position_map: diff --git a/backend/app/api/v1/module_system/role/controller.py b/backend/app/api/v1/module_system/role/controller.py index 5dc30bfa..9080ea7d 100644 --- a/backend/app/api/v1/module_system/role/controller.py +++ b/backend/app/api/v1/module_system/role/controller.py @@ -2,12 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Body, Depends, Path from fastapi.responses import JSONResponse, StreamingResponse -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache from app.common.response import ResponseSchema, StreamResponse, SuccessResponse +from app.core import cache_util from app.core.base_params import PaginationQueryParam from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema +from app.core.cache_util import cache from app.core.dependencies import AuthPermission from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response @@ -69,7 +69,7 @@ async def create_role_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:create"]))], ) -> JSONResponse: result_dict = await RoleService(auth).create(data=data) - await FastAPICache.clear(namespace=_ROLE_NS) + await cache_util.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="创建角色成功") @RoleRouter.put( @@ -83,7 +83,7 @@ async def update_role_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:update"]))], ) -> JSONResponse: result_dict = await RoleService(auth).update(id=id, data=data) - await FastAPICache.clear(namespace=_ROLE_NS) + await cache_util.clear(namespace=_ROLE_NS) return SuccessResponse(data=result_dict, msg="修改角色成功") @RoleRouter.delete( @@ -96,7 +96,7 @@ async def delete_role_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:delete"]))], ) -> JSONResponse: await RoleService(auth).delete(ids=ids) - await FastAPICache.clear(namespace=_ROLE_NS) + await cache_util.clear(namespace=_ROLE_NS) return SuccessResponse(msg="删除角色成功") @RoleRouter.patch( @@ -109,7 +109,7 @@ async def batch_set_available_role_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:patch"]))], ) -> JSONResponse: await RoleService(auth).set_available(data=data) - await FastAPICache.clear(namespace=_ROLE_NS) + await cache_util.clear(namespace=_ROLE_NS) return SuccessResponse(msg="批量修改角色状态成功") @RoleRouter.put( @@ -122,7 +122,7 @@ async def set_role_permission_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:permission"]))], ) -> JSONResponse: await RoleService(auth).set_permission(data=data) - await FastAPICache.clear(namespace=_ROLE_NS) + await cache_util.clear(namespace=_ROLE_NS) return SuccessResponse(msg="授权角色成功") @RoleRouter.get( @@ -134,7 +134,7 @@ async def export_role_list_controller( search: Annotated[RoleQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:export"]))], ) -> StreamingResponse: - role_query_result = await RoleService(auth).list(search=search) + role_query_result = await RoleService(auth).get_list(search=search) role_export_result = RoleService.export_list(role_list=role_query_result) return StreamResponse( diff --git a/backend/app/api/v1/module_system/role/crud.py b/backend/app/api/v1/module_system/role/crud.py index a9a87fd3..08ad32d3 100644 --- a/backend/app/api/v1/module_system/role/crud.py +++ b/backend/app/api/v1/module_system/role/crud.py @@ -25,8 +25,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): 返回: - None """ - roles = await self.list(search={"id": ("in", role_ids)}) - menus = [] if not menu_ids else await MenuCRUD(self.auth).list(search={"id": ("in", menu_ids)}) + roles = await self.get_list(search={"id": ("in", role_ids)}) + menus = [] if not menu_ids else await MenuCRUD(self.auth).get_list(search={"id": ("in", menu_ids)}) from app.api.v1.module_platform.package.service import PackageService @@ -54,8 +54,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]): 返回: - None """ - roles = await self.list(search={"id": ("in", role_ids)}) - depts = [] if not dept_ids else await DeptCRUD(self.auth).list(search={"id": ("in", dept_ids)}) + roles = await self.get_list(search={"id": ("in", role_ids)}) + depts = [] if not dept_ids else await DeptCRUD(self.auth).get_list(search={"id": ("in", dept_ids)}) for obj in roles: relationship = obj.depts diff --git a/backend/app/api/v1/module_system/role/service.py b/backend/app/api/v1/module_system/role/service.py index 34f25db9..8e71a27e 100644 --- a/backend/app/api/v1/module_system/role/service.py +++ b/backend/app/api/v1/module_system/role/service.py @@ -37,7 +37,7 @@ class RoleService: """ return await RoleCRUD(self.auth).get_or_404(id=id, out_schema=RoleOutSchema) - async def list( + async def get_list( self, search: RoleQueryParam | None = None, order_by: list[dict[str, str]] | None = None, @@ -52,7 +52,7 @@ class RoleService: 返回: - list[RoleOutSchema]: 角色响应模型列表 """ - role_list = await RoleCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + role_list = await RoleCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [RoleOutSchema.model_validate(role) for role in role_list] async def page( @@ -141,7 +141,7 @@ class RoleService: raise CustomException(msg="删除失败,删除对象不能为空") # 批量校验角色存在性 - roles = await RoleCRUD(self.auth).list(search={"id": ("in", ids)}) + roles = await RoleCRUD(self.auth).get_list(search={"id": ("in", ids)}) if len(roles) != len(ids): raise CustomException(msg="删除失败,部分ID不存在") @@ -179,7 +179,7 @@ class RoleService: 返回: - None """ - roles = await RoleCRUD(self.auth).list(search={"id": ("in", data.ids)}) + roles = await RoleCRUD(self.auth).get_list(search={"id": ("in", data.ids)}) role_map = {r.id: r for r in roles} for rid in data.ids: if rid not in role_map: diff --git a/backend/app/api/v1/module_system/ticket/service.py b/backend/app/api/v1/module_system/ticket/service.py index 458c9f8b..1df4ca67 100644 --- a/backend/app/api/v1/module_system/ticket/service.py +++ b/backend/app/api/v1/module_system/ticket/service.py @@ -1,3 +1,4 @@ + from sqlalchemy import select from app.api.v1.module_system.user.model import UserModel diff --git a/backend/app/api/v1/module_system/user/controller.py b/backend/app/api/v1/module_system/user/controller.py index 20e3f129..d2d30fbf 100644 --- a/backend/app/api/v1/module_system/user/controller.py +++ b/backend/app/api/v1/module_system/user/controller.py @@ -212,7 +212,7 @@ async def export_user_list_controller( search: Annotated[UserQueryParam, Depends()], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:export"]))], ) -> StreamingResponse: - user_list = await UserService(auth).list(search=search, order_by=page.order_by) + user_list = await UserService(auth).get_list(search=search, order_by=page.order_by) user_export_result = UserService.export_list(user_list=user_list) return StreamResponse( diff --git a/backend/app/api/v1/module_system/user/crud.py b/backend/app/api/v1/module_system/user/crud.py index 88f672cb..866f484c 100644 --- a/backend/app/api/v1/module_system/user/crud.py +++ b/backend/app/api/v1/module_system/user/crud.py @@ -38,9 +38,9 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): 返回: - None """ - user_objs = await self.list(search={"id": ("in", user_ids)}) + user_objs = await self.get_list(search={"id": ("in", user_ids)}) if role_ids: - role_objs = await RoleCRUD(self.auth).list(search={"id": ("in", role_ids)}) + role_objs = await RoleCRUD(self.auth).get_list(search={"id": ("in", role_ids)}) else: role_objs = [] @@ -61,9 +61,9 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]): 返回: - None """ - user_objs = await self.list(search={"id": ("in", user_ids)}) + user_objs = await self.get_list(search={"id": ("in", user_ids)}) if position_ids: - position_objs = await PositionCRUD(self.auth).list(search={"id": ("in", position_ids)}) + position_objs = await PositionCRUD(self.auth).get_list(search={"id": ("in", position_ids)}) else: position_objs = [] diff --git a/backend/app/api/v1/module_system/user/service.py b/backend/app/api/v1/module_system/user/service.py index 4290ad0b..88fcb156 100644 --- a/backend/app/api/v1/module_system/user/service.py +++ b/backend/app/api/v1/module_system/user/service.py @@ -46,12 +46,12 @@ class UserService: result.dept_name = dept.name if dept else None return result - async def list( + async def get_list( self, search: UserQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[UserOutSchema]: - user_list = await UserCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + user_list = await UserCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [UserOutSchema.model_validate(user) for user in user_list] async def page( @@ -125,7 +125,7 @@ class UserService: new_user = await UserCRUD(self.auth).update(id=id, data=data) if data.role_ids and len(data.role_ids) > 0: - roles = await RoleCRUD(self.auth).list(search={"id": ("in", data.role_ids)}) + roles = await RoleCRUD(self.auth).get_list(search={"id": ("in", data.role_ids)}) if len(roles) != len(data.role_ids): raise CustomException(msg="更新失败,部分角色不存在") if not all(role.status == 0 for role in roles): @@ -133,7 +133,7 @@ class UserService: await UserCRUD(self.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(self.auth).list(search={"id": ("in", data.position_ids)}) + positions = await PositionCRUD(self.auth).get_list(search={"id": ("in", data.position_ids)}) if len(positions) != len(data.position_ids): raise CustomException(msg="更新失败,部分岗位不存在") if not all(position.status == 0 for position in positions): @@ -145,7 +145,7 @@ class UserService: async def delete(self, ids: list[int]) -> None: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - users = await UserCRUD(self.auth).list(search={"id": ("in", ids)}) + users = await UserCRUD(self.auth).get_list(search={"id": ("in", ids)}) user_map = {u.id: u for u in users} for uid in ids: user = user_map.get(uid) diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index 5d0762cc..6353ba73 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -47,6 +47,14 @@ from app.core.logger import logger from app.plugin.module_task.cronjob.node.model import NodeModel from app.utils.cron_util import CronUtil +# 任务状态常量(与 JobModel.status 注释保持一致:0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消) +JOB_STATUS_PENDING = 0 +JOB_STATUS_RUNNING = 1 +JOB_STATUS_SUCCESS = 2 +JOB_STATUS_FAILED = 3 +JOB_STATUS_TIMEOUT = 4 +JOB_STATUS_CANCELLED = 5 + scheduler = AsyncIOScheduler() scheduler.configure( jobstores={ @@ -149,7 +157,7 @@ class SchedulerUtil: if trigger_type in ("cron", "interval"): cls._update_job_log( job_id=job_id, - status="running", + status=JOB_STATUS_RUNNING, ) else: # 一次性任务(manual/date):创建新的 running 状态日志 @@ -157,7 +165,7 @@ class SchedulerUtil: job_id=job_id, job_name=job.name, trigger_type=trigger_type, - status="running", + status=JOB_STATUS_RUNNING, ) else: # 任务可能已经被移除(一次性任务执行完毕后自动移除) @@ -174,7 +182,7 @@ class SchedulerUtil: job_id=original_job_id, job_name=job_name, trigger_type="manual", - status="running", + status=JOB_STATUS_RUNNING, ) if result: logger.info(f"任务 {original_job_id} 日志创建成功,id={result}") @@ -199,7 +207,7 @@ class SchedulerUtil: # 更新执行日志 cls._update_latest_job_log( job_id=job_id, - status="success", + status=JOB_STATUS_SUCCESS, result=str(retval) if retval else None, ) @@ -212,7 +220,7 @@ class SchedulerUtil: job_id=job_id, job_name=job.name, trigger_type=trigger_type, - status="pending", + status=JOB_STATUS_PENDING, ) logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @@ -235,7 +243,7 @@ class SchedulerUtil: # 更新执行日志 cls._update_latest_job_log( job_id=job_id, - status="failed", + status=JOB_STATUS_FAILED, result="failed", error=str(exception) if exception else "未知错误", ) @@ -249,7 +257,7 @@ class SchedulerUtil: job_id=job_id, job_name=job.name, trigger_type=trigger_type, - status="pending", + status=JOB_STATUS_PENDING, ) logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @@ -268,7 +276,7 @@ class SchedulerUtil: # 更新执行日志 cls._update_latest_job_log( job_id=job_id, - status="timeout", + status=JOB_STATUS_TIMEOUT, result="timeout", error="任务错过执行时间", ) @@ -281,7 +289,7 @@ class SchedulerUtil: job_id=job_id, job_name=job.name, trigger_type=trigger_type, - status="pending", + status=JOB_STATUS_PENDING, ) logger.debug(f"任务 {job_id} 已创建新的 pending 状态日志,等待下次执行") @@ -335,7 +343,7 @@ class SchedulerUtil: job_id=job_id, job_name=job.name, trigger_type=trigger_type, - status="pending", + status=JOB_STATUS_PENDING, ) logger.info(f"任务 {job_id} 已创建初始 pending 状态日志") else: @@ -609,8 +617,8 @@ class SchedulerUtil: from app.plugin.module_task.cronjob.job.model import JobModel with Session(engine) as session: - session.query(JobModel).filter(JobModel.status == "pending").update({ - "status": "cancelled" + session.query(JobModel).filter(JobModel.status == JOB_STATUS_PENDING).update({ + "status": JOB_STATUS_CANCELLED }) session.commit() logger.info("所有待执行任务日志已标记为已取消") @@ -871,7 +879,7 @@ class SchedulerUtil: with Session(engine) as session: deleted = ( session.query(JobModel) - .filter(JobModel.job_id == job_id, JobModel.status == "pending") + .filter(JobModel.job_id == job_id, JobModel.status == JOB_STATUS_PENDING) .delete(synchronize_session=False) ) session.commit() @@ -886,7 +894,7 @@ class SchedulerUtil: job_id: str, job_name: str | None = None, trigger_type: str = "manual", - status: str = "running", + status: int = JOB_STATUS_RUNNING, ) -> int | None: """ 创建执行日志 @@ -922,7 +930,7 @@ class SchedulerUtil: @classmethod def _update_job_log( - cls, job_id: str, status: str, result: str | None = None, error: str | None = None + cls, job_id: str, status: int, result: str | None = None, error: str | None = None ) -> None: """ 更新执行日志(更新该 job_id 最新的 pending 状态日志) @@ -940,7 +948,7 @@ class SchedulerUtil: job_log = ( session .query(JobModel) - .filter(JobModel.job_id == job_id, JobModel.status == "pending") + .filter(JobModel.job_id == job_id, JobModel.status == JOB_STATUS_PENDING) .order_by(JobModel.created_time.desc()) .first() ) @@ -960,7 +968,7 @@ class SchedulerUtil: @classmethod def _update_latest_job_log( - cls, job_id: str, status: str, result: str | None = None, error: str | None = None + cls, job_id: str, status: int, result: str | None = None, error: str | None = None ) -> None: """ 更新最新的执行日志(更新该 job_id 最新的一条日志) @@ -980,7 +988,7 @@ class SchedulerUtil: job_log = ( session .query(JobModel) - .filter(JobModel.job_id == job_id, JobModel.status == "running") + .filter(JobModel.job_id == job_id, JobModel.status == JOB_STATUS_RUNNING) .order_by(JobModel.created_time.desc()) .first() ) @@ -1003,7 +1011,7 @@ class SchedulerUtil: job_log = ( session .query(JobModel) - .filter(JobModel.job_id == job_id, JobModel.status == "cancelled") + .filter(JobModel.job_id == job_id, JobModel.status == JOB_STATUS_CANCELLED) .order_by(JobModel.created_time.desc()) .first() ) @@ -1076,12 +1084,12 @@ class SchedulerUtil: job_log = ( session .query(JobModel) - .filter(JobModel.job_id == job_id, JobModel.status.in_(["pending", "running"])) + .filter(JobModel.job_id == job_id, JobModel.status.in_([JOB_STATUS_PENDING, JOB_STATUS_RUNNING])) .order_by(JobModel.created_time.desc()) .first() ) if job_log: - job_log.status = "cancelled" + job_log.status = JOB_STATUS_CANCELLED session.commit() logger.info(f"任务 {job_id} 的执行日志已标记为已取消") @@ -1501,7 +1509,7 @@ class SchedulerUtil: existing_log = ( session .query(JobModel) - .filter(JobModel.job_id == str(job.id), JobModel.status == "pending") + .filter(JobModel.job_id == str(job.id), JobModel.status == JOB_STATUS_PENDING) .first() ) if not existing_log: @@ -1509,7 +1517,7 @@ class SchedulerUtil: job_id=str(job.id), job_name=job.name, trigger_type=cls._get_trigger_type(str(job.id)), - status="pending", + status=JOB_STATUS_PENDING, next_run_time=str(job.next_run_time) if job.next_run_time else None, job_state=cls._get_job_state(job), ) diff --git a/backend/app/core/base_crud.py b/backend/app/core/base_crud.py index ce15122f..6c062935 100644 --- a/backend/app/core/base_crud.py +++ b/backend/app/core/base_crud.py @@ -15,7 +15,6 @@ super().__init__(model=OrderModel, session=session) """ -import builtins from collections.abc import Sequence from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, TypeVar @@ -193,7 +192,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT except Exception as e: raise CustomException(msg=f"统计失败: {e!s}") - async def list( + async def get_list( self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, @@ -227,9 +226,9 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT async def tree_list( self, search: dict | None = None, - order_by: builtins.list[dict[str, str]] | None = None, + order_by: list[dict[str, str]] | None = None, children_attr: str | None = None, - preload: builtins.list[str | Any] | None = None, + preload: list[str | Any] | None = None, ) -> Sequence[ModelType]: """ 获取树形结构数据列表(复用请求级事务会话) @@ -271,10 +270,10 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT self, offset: int, limit: int, - order_by: builtins.list[dict[str, str]], + order_by: list[dict[str, str]], search: dict, out_schema: type[OutSchemaType] | None = None, - preload: builtins.list[str | Any] | None = None, + preload: list[str | Any] | None = None, ) -> PageResultSchema: """ 获取分页数据(复用请求级事务会话;count 与 data 共享同一会话) @@ -413,7 +412,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT except Exception as e: raise CustomException(msg=f"更新失败: {e!s}") - async def delete(self, ids: builtins.list[int]) -> None: + async def delete(self, ids: list[int]) -> None: """软删除对象(有认证时填充删除人 + 租户隔离)""" try: pk = self._get_pk_col() @@ -449,7 +448,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT except Exception as e: raise CustomException(msg=f"清空失败: {e!s}") - async def set(self, ids: builtins.list[int], **kwargs) -> None: + async def set(self, ids: list[int], **kwargs) -> None: """批量更新字段(带租户隔离)""" try: pk = self._get_pk_col() @@ -461,7 +460,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT except Exception as e: raise CustomException(msg=f"批量更新失败: {e!s}") - async def restore(self, ids: builtins.list[int]) -> None: + async def restore(self, ids: list[int]) -> None: """恢复软删除对象(带租户隔离)""" try: if not self._supports_soft_delete: @@ -487,7 +486,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT filter_obj = Permission(model=self.model, auth=self.auth) return await filter_obj.filter_query(sql) - def _platform_shared_conditions(self) -> builtins.list[ColumnElement]: + def _platform_shared_conditions(self) -> list[ColumnElement]: if not self.auth or not self.auth.user: return [] tid = self.auth.user.tenant_id @@ -507,8 +506,8 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT return sql.where(getattr(self.model, "tenant_id") == tid) return sql - async def __build_conditions(self, **kwargs) -> builtins.list[ColumnElement]: - conditions: builtins.list[ColumnElement] = [] + async def __build_conditions(self, **kwargs) -> list[ColumnElement]: + conditions: list[ColumnElement] = [] if hasattr(self.model, "is_deleted"): conditions.append(getattr(self.model, "is_deleted") == False) # noqa: E712 @@ -572,8 +571,8 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT return conditions def _parse_order( - self, order: builtins.list[dict[str, str]] - ) -> builtins.list[ColumnElement]: + self, order: list[dict[str, str]] + ) -> list[ColumnElement]: """ 解析排序参数 @@ -583,7 +582,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT 返回: - 排序表达式列表 """ - columns: builtins.list[ColumnElement] = [] + columns: list[ColumnElement] = [] for item in order: for field, direction in item.items(): column = getattr(self.model, field) @@ -591,8 +590,8 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT return columns def __loader_options( - self, preload: builtins.list[str | Any] | None = None - ) -> builtins.list[Any]: + self, preload: list[str | Any] | None = None + ) -> list[Any]: """ 构建预加载选项 @@ -602,7 +601,7 @@ class CRUDBase[ModelType: MappedBase, CreateSchemaType: BaseModel, UpdateSchemaT 返回: - 预加载选项列表 """ - options: builtins.list[Any] = [] + options: list[Any] = [] model_loader_options = getattr(self.model, "__loader_options__", []) all_preloads: set[str | Any] = set(model_loader_options) diff --git a/backend/app/core/cache_util.py b/backend/app/core/cache_util.py new file mode 100644 index 00000000..9a9f866e --- /dev/null +++ b/backend/app/core/cache_util.py @@ -0,0 +1,54 @@ +"""轻量 Redis 缓存工具(替代 fastapi-cache2,兼容 redis-py)""" +import hashlib +import json +from collections.abc import Callable +from functools import wraps +from typing import Any + +from redis.asyncio.client import Redis + +_ENABLE: bool = True +_EXPIRE: int = 300 +_PREFIX: str = "fastapi-admin-cache" +_REDIS: Redis | None = None + + +async def init(redis: Redis, prefix: str = "fastapi-admin-cache", expire: int = 300, enable: bool = True) -> None: + global _REDIS, _PREFIX, _EXPIRE, _ENABLE + _REDIS = redis + _PREFIX = prefix + _EXPIRE = expire + _ENABLE = enable + + +def _build_key(namespace: str, func: Callable, *args: Any, **kwargs: Any) -> str: + raw = f"{func.__module__}:{func.__qualname__}:{args}:{kwargs}" + return f"{_PREFIX}:{namespace}:{hashlib.md5(raw.encode()).hexdigest()}" + + +def cache(expire: int | None = None, namespace: str = "default"): + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + if not _ENABLE or _REDIS is None: + return await func(*args, **kwargs) + key = _build_key(namespace, func, *args, **kwargs) + cached = await _REDIS.get(key) + if cached: + return json.loads(cached) + result = await func(*args, **kwargs) + await _REDIS.set(key, json.dumps(result), ex=expire or _EXPIRE) + return result + + return wrapper + + return decorator + + +async def clear(namespace: str | None = None) -> None: + if _REDIS is None: + return + pattern = f"{_PREFIX}:{namespace}:*" if namespace else f"{_PREFIX}:*" + keys = [key async for key in _REDIS.scan_iter(match=pattern)] + if keys: + await _REDIS.delete(*keys) diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py index 50fb29fa..bc9f7642 100644 --- a/backend/app/core/request_context.py +++ b/backend/app/core/request_context.py @@ -1,7 +1,3 @@ -"""请求级上下文""" - -from __future__ import annotations - from contextvars import ContextVar, Token from dataclasses import dataclass from typing import Any diff --git a/backend/app/init_app.py b/backend/app/init_app.py index eb24e451..e937df2d 100644 --- a/backend/app/init_app.py +++ b/backend/app/init_app.py @@ -6,11 +6,11 @@ from fastapi.concurrency import asynccontextmanager from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles -from fastapi_cache import FastAPICache -from fastapi_cache.backends.redis import RedisBackend from fastapi_limiter import FastAPILimiter from fastapi_limiter.depends import RateLimiter, WebSocketRateLimiter +from app.core import cache_util + from .config.setting import settings from .core.exceptions import handle_exception from .core.http_limit import http_limit_callback, ws_limit_callback @@ -40,6 +40,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: logger.info("✅ Redis租户配置初始化完成") await SchedulerUtil.init_scheduler(redis=app.state.redis) logger.info("✅ 定时任务调度器初始化完成") + await cache_util.init(redis=app.state.redis) + logger.info("✅ fastapi-admin-cache 初始化完成") await FastAPILimiter.init( redis=app.state.redis, prefix=settings.REQUEST_LIMITER_REDIS_PREFIX, @@ -47,8 +49,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: ws_callback=ws_limit_callback, ) logger.info("✅ 请求限流器初始化完成") - FastAPICache.init(backend=RedisBackend(app.state.redis), prefix="fastapi-admin-cache", expire=300, enable=True) - logger.info("✅ fastapi-cache2 初始化完成") console_start( host=settings.SERVER_HOST, port=settings.SERVER_PORT, @@ -65,8 +65,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]: try: await SchedulerUtil.shutdown(wait=True) logger.info("✅ 定时任务调度器已关闭") - await FastAPICache.clear() - logger.info("✅ fastapi-cache2 已关闭") + await cache_util.clear() + logger.info("✅ fastapi-admin-cache 已关闭") await FastAPILimiter.close() logger.info("✅ 请求限制器已关闭") await import_modules_async(modules=settings.EVENT_LIST, desc="全局事件", app=app, status=False) diff --git a/backend/app/plugin/module_ai/chat/service.py b/backend/app/plugin/module_ai/chat/service.py index e4b86521..f4b2c811 100644 --- a/backend/app/plugin/module_ai/chat/service.py +++ b/backend/app/plugin/module_ai/chat/service.py @@ -1,3 +1,4 @@ + from collections.abc import AsyncGenerator from datetime import datetime from typing import Any diff --git a/backend/app/plugin/module_example/demo/controller.py b/backend/app/plugin/module_example/demo/controller.py index e74e6295..ae33559f 100644 --- a/backend/app/plugin/module_example/demo/controller.py +++ b/backend/app/plugin/module_example/demo/controller.py @@ -117,7 +117,7 @@ async def export_obj_list_controller( auth: Annotated[AuthSchema, Depends(AuthPermission(["module_example:demo:export"]))], ) -> StreamingResponse: service = DemoService(auth) - result_dict_list = await service.list(search=search) + result_dict_list = await service.get_list(search=search) export_result = DemoService.batch_export(obj_list=result_dict_list) return StreamResponse( diff --git a/backend/app/plugin/module_example/demo/service.py b/backend/app/plugin/module_example/demo/service.py index c0873d9d..ce7d8507 100644 --- a/backend/app/plugin/module_example/demo/service.py +++ b/backend/app/plugin/module_example/demo/service.py @@ -30,12 +30,12 @@ class DemoService: raise CustomException(msg="该数据不存在") return DemoOutSchema.model_validate(obj) - async def list( + async def get_list( self, search: DemoQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[DemoOutSchema]: - obj_list = await DemoCRUD(self.auth).list(search=vars(search) if search else None, order_by=order_by) + obj_list = await DemoCRUD(self.auth).get_list(search=vars(search) if search else None, order_by=order_by) return [DemoOutSchema.model_validate(obj) for obj in obj_list] async def page( @@ -76,7 +76,7 @@ class DemoService: async def delete(self, ids: list[int]) -> None: if len(ids) < 1: raise CustomException(msg="删除失败,删除对象不能为空") - objs = await DemoCRUD(self.auth).list(search={"id": ("in", ids)}) + objs = await DemoCRUD(self.auth).get_list(search={"id": ("in", ids)}) obj_map = {o.id: o for o in objs} for id_ in ids: if id_ not in obj_map: diff --git a/backend/app/plugin/module_generator/gencode/crud.py b/backend/app/plugin/module_generator/gencode/crud.py index 6d3ade9c..531a4505 100644 --- a/backend/app/plugin/module_generator/gencode/crud.py +++ b/backend/app/plugin/module_generator/gencode/crud.py @@ -70,7 +70,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): 返回: - Sequence[GenTableModel]: 所有业务表信息列表。 """ - return await self.list(preload=preload) + return await self.get_list(preload=preload) async def get_gen_table_list( self, @@ -87,7 +87,7 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): 返回: - Sequence[GenTableModel]: 业务表列表信息。 """ - return await self.list( + return await self.get_list( search=vars(search) if search else None, order_by=[{"created_time": "desc"}], preload=preload, @@ -511,7 +511,7 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen 返回: - Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。 """ - return await self.list(search={"table_id": table_id}, order_by=order_by, preload=preload) + return await self.get_list(search={"table_id": table_id}, order_by=order_by, preload=preload) async def get_gen_db_table_columns_by_name(self, table_name: str | None) -> list[GenTableColumnOutSchema]: """ @@ -560,7 +560,7 @@ class GenTableColumnCRUD(CRUDBase[GenTableColumnModel, GenTableColumnSchema, Gen 返回: - Sequence[GenTableColumnModel]: 业务表字段列表信息对象序列。 """ - return await self.list(search=search, order_by=order_by, preload=preload) + return await self.get_list(search=search, order_by=order_by, preload=preload) async def create_gen_table_column_crud(self, data: GenTableColumnSchema) -> GenTableColumnModel | None: """创建业务表字段。 diff --git a/backend/app/plugin/module_generator/gencode/service.py b/backend/app/plugin/module_generator/gencode/service.py index 9763c971..46e9b52b 100644 --- a/backend/app/plugin/module_generator/gencode/service.py +++ b/backend/app/plugin/module_generator/gencode/service.py @@ -1,3 +1,4 @@ + import io import os import re @@ -26,6 +27,8 @@ from app.config.setting import settings from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException from app.core.logger import logger +from app.utils.gen_util import GenUtils +from app.utils.jinja2_template_util import Jinja2TemplateUtil from .crud import GenTableColumnCRUD, GenTableCRUD from .schema import ( @@ -37,8 +40,6 @@ from .schema import ( GenTableQueryParam, GenTableSchema, ) -from .tools.gen_util import GenUtils -from .tools.jinja2_template_util import Jinja2TemplateUtil def handle_service_exception(func: Callable) -> Callable: diff --git a/backend/app/plugin/module_task/cronjob/job/crud.py b/backend/app/plugin/module_task/cronjob/job/crud.py index adcd43b3..54d45fa8 100644 --- a/backend/app/plugin/module_task/cronjob/job/crud.py +++ b/backend/app/plugin/module_task/cronjob/job/crud.py @@ -51,7 +51,7 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]): 返回: - Sequence[JobModel]: 执行日志模型序列 """ - return await self.list(search=search, order_by=order_by, preload=preload) + return await self.get_list(search=search, order_by=order_by, preload=preload) async def create_obj_crud(self, data: JobCreateSchema) -> JobModel | None: """ diff --git a/backend/app/plugin/module_task/cronjob/job/service.py b/backend/app/plugin/module_task/cronjob/job/service.py index 38a3a96b..74612ceb 100644 --- a/backend/app/plugin/module_task/cronjob/job/service.py +++ b/backend/app/plugin/module_task/cronjob/job/service.py @@ -1,3 +1,4 @@ + from app.core.ap_scheduler import SchedulerUtil from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException diff --git a/backend/app/plugin/module_task/cronjob/node/controller.py b/backend/app/plugin/module_task/cronjob/node/controller.py index 76f538ac..dc602165 100644 --- a/backend/app/plugin/module_task/cronjob/node/controller.py +++ b/backend/app/plugin/module_task/cronjob/node/controller.py @@ -146,7 +146,7 @@ async def execute_job_controller( ) async def batch_set_status_controller( ids: Annotated[list[int], Body(description="节点ID列表")], - status: Annotated[str, Body(description="状态值")], + status: Annotated[int, Body(description="状态值")], auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:cronjob:node:update"]))], ) -> JSONResponse: service = NodeService(auth) diff --git a/backend/app/plugin/module_task/cronjob/node/crud.py b/backend/app/plugin/module_task/cronjob/node/crud.py index c91e37da..9dd658f7 100644 --- a/backend/app/plugin/module_task/cronjob/node/crud.py +++ b/backend/app/plugin/module_task/cronjob/node/crud.py @@ -53,7 +53,7 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]): 返回: - Sequence[NodeModel]: 节点模型序列 """ - return await self.list(search=search, order_by=order_by, preload=preload) + return await self.get_list(search=search, order_by=order_by, preload=preload) async def create_obj_crud(self, data: NodeCreateSchema) -> NodeModel | None: """ diff --git a/backend/app/plugin/module_task/cronjob/node/service.py b/backend/app/plugin/module_task/cronjob/node/service.py index f4574830..1f7f1bd2 100644 --- a/backend/app/plugin/module_task/cronjob/node/service.py +++ b/backend/app/plugin/module_task/cronjob/node/service.py @@ -39,7 +39,7 @@ class NodeService: obj = await NodeCRUD(self.auth).get_obj_by_id_crud(id=id) return NodeOutSchema.model_validate(obj) - async def list( + async def get_list( self, search: NodeQueryParam | None = None, order_by: list[dict[str, str]] | None = None, @@ -141,7 +141,7 @@ class NodeService: return {"job_id": id, "status": "executed", "trigger": trigger} - async def batch_set_status(self, ids: list[int], status: str) -> None: + async def batch_set_status(self, ids: list[int], status: int) -> None: if not ids: raise CustomException(msg="请选择要操作的数据") diff --git a/backend/app/plugin/module_task/workflow/flows/crud.py b/backend/app/plugin/module_task/workflow/flows/crud.py index d6ca6fd6..03beb221 100644 --- a/backend/app/plugin/module_task/workflow/flows/crud.py +++ b/backend/app/plugin/module_task/workflow/flows/crud.py @@ -53,7 +53,7 @@ class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateS 返回: - Sequence[WorkflowModel]: 工作流列表。 """ - return await self.list(search=search, order_by=order_by, preload=preload) + return await self.get_list(search=search, order_by=order_by, preload=preload) async def create_obj_crud(self, data: WorkflowCreateSchema) -> WorkflowModel | None: """ diff --git a/backend/app/plugin/module_task/workflow/flows/service.py b/backend/app/plugin/module_task/workflow/flows/service.py index fadd6e22..8534a6a2 100644 --- a/backend/app/plugin/module_task/workflow/flows/service.py +++ b/backend/app/plugin/module_task/workflow/flows/service.py @@ -1,3 +1,4 @@ + import asyncio from typing import Any diff --git a/backend/app/plugin/module_task/workflow/handlers/workflow_engine.py b/backend/app/plugin/module_task/workflow/handlers/workflow_engine.py index f74534ee..6eeafb35 100644 --- a/backend/app/plugin/module_task/workflow/handlers/workflow_engine.py +++ b/backend/app/plugin/module_task/workflow/handlers/workflow_engine.py @@ -1,7 +1,3 @@ -"""工作流 DAG 执行引擎 — 拓扑分层 + 并行执行""" - -from __future__ import annotations - import json from collections import defaultdict, deque from concurrent.futures import ThreadPoolExecutor diff --git a/backend/app/plugin/module_task/workflow/nodes/crud.py b/backend/app/plugin/module_task/workflow/nodes/crud.py index f30ca4db..804f9ad2 100644 --- a/backend/app/plugin/module_task/workflow/nodes/crud.py +++ b/backend/app/plugin/module_task/workflow/nodes/crud.py @@ -54,7 +54,7 @@ class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreat 返回: - Sequence[WorkflowNodeTypeModel]: 列表。 """ - return await self.list(search=search, order_by=order_by, preload=preload) + return await self.get_list(search=search, order_by=order_by, preload=preload) async def create_obj_crud(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeModel | None: """ diff --git a/backend/app/plugin/module_task/workflow/nodes/service.py b/backend/app/plugin/module_task/workflow/nodes/service.py index a2699c18..86e2e737 100644 --- a/backend/app/plugin/module_task/workflow/nodes/service.py +++ b/backend/app/plugin/module_task/workflow/nodes/service.py @@ -1,3 +1,4 @@ + from app.core.base_schema import AuthSchema from app.core.exceptions import CustomException diff --git a/backend/app/scripts/data/platform_invoice.json b/backend/app/scripts/data/platform_invoice.json index fe15c023..8effc7b9 100644 --- a/backend/app/scripts/data/platform_invoice.json +++ b/backend/app/scripts/data/platform_invoice.json @@ -11,6 +11,9 @@ "tax_amount": 4485, "status": 1, "tenant_id": 3, + "pdf_url": "/static/invoice/3/INV20260101001.pdf", + "oss_license_pdf_url": "/static/invoice/3/INV20260101001_license.pdf", + "api_response": null, "description": "星辰科技-标准版年付发票(已开具)" }, { @@ -25,6 +28,9 @@ "tax_amount": 1485, "status": 1, "tenant_id": 3, + "pdf_url": "/static/invoice/3/INV20260315001.pdf", + "oss_license_pdf_url": "/static/invoice/3/INV20260315001_license.pdf", + "api_response": null, "description": "星辰科技-AI助手发票(已开具)" }, { @@ -39,6 +45,9 @@ "tax_amount": 4485, "status": 0, "tenant_id": 4, + "pdf_url": null, + "oss_license_pdf_url": null, + "api_response": null, "description": "创新工坊-标准版月付发票(待开具)" }, { @@ -53,6 +62,9 @@ "tax_amount": 735, "status": 0, "tenant_id": 4, + "pdf_url": null, + "oss_license_pdf_url": null, + "api_response": null, "description": "创新工坊-数据大屏发票(待开具)" } ] diff --git a/backend/app/utils/jinja2_template_util.py b/backend/app/utils/jinja2_template_util.py index 7617391e..5f5e8565 100644 --- a/backend/app/utils/jinja2_template_util.py +++ b/backend/app/utils/jinja2_template_util.py @@ -11,8 +11,8 @@ from app.plugin.module_generator.gencode.schema import ( GenTableColumnOutSchema, GenTableOutSchema, ) -from app.plugin.module_generator.gencode.tools.gen_util import GenUtils from app.utils.common_util import CamelCaseUtil, SnakeCaseUtil +from app.utils.gen_util import GenUtils from app.utils.string_util import StringUtil diff --git a/backend/app/utils/payment.py b/backend/app/utils/payment.py index 5d4093fd..652b497b 100644 --- a/backend/app/utils/payment.py +++ b/backend/app/utils/payment.py @@ -1,7 +1,3 @@ -"""支付网关 — 抽象基类 + 支付宝 + Mock + 工厂""" - -from __future__ import annotations - import base64 import json import uuid diff --git a/backend/app/utils/pdf_generator.py b/backend/app/utils/pdf_generator.py index 3ec34ca3..bd3b1841 100644 --- a/backend/app/utils/pdf_generator.py +++ b/backend/app/utils/pdf_generator.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader, StrictUndefined -from weasyprint import CSS, HTML def render_html_template( @@ -37,6 +36,8 @@ def html_to_pdf(html_str: str, css_str: str | None = None) -> bytes: """ 将 HTML 字符串转换为 PDF 字节流 + 需要 weasyprint + 系统 libgobject(安装: brew install glib pango)。 + 参数: - html_str (str): HTML 内容 - css_str (str | None): 可选的内联 CSS 字符串 @@ -44,6 +45,8 @@ def html_to_pdf(html_str: str, css_str: str | None = None) -> bytes: 返回: - bytes: PDF 字节流 """ + from weasyprint import CSS, HTML + html = HTML(string=html_str, base_url=".") if css_str: return html.write_pdf(stylesheets=[CSS(string=css_str)]) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fdcf44a4..375615e6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -35,16 +35,16 @@ dependencies = [ "python-multipart==0.0.32", # request.form() 对表单进行「解析」时安装 "redis==7.1.0", # redis 同/异步操作数据库(用户celery配套使用)redis 异步操作数据库 redis已经完全具备了aioredis的功能,无需重复安全,且aioredis已经不再维护也不兼容3.10+的版本 "rich==15.0.0", # 终端打印美化 - "sqlalchemy==2.0.45", # 数据库ORM + "sqlalchemy>=2.0.51,<2.1", # 数据库ORM "sqlglot[rs]==27.8.0", # sql 解析 "typer==0.26.7", # 命令行工具 "ua-parser==1.0.2", # 解析 User-Agent 获取 OS/浏览器 "uvicorn==0.49.0", # uvicorn web 框架 "websockets>=16.0,<17.0", # websocket 通信 - "fastapi-cache2[redis]>=0.1.8", "fastapi-mail==1.5.1", # 邮件发送(简化 EmailSendService 底层) "tinycss2==1.5.1", "weasyprint==69.0", + "fastapi-cache2[redis]>=0.1.8", ] [dependency-groups] diff --git a/backend/requirements.txt b/backend/requirements.txt index 6a84a07f..ab66ad46 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -32,7 +32,7 @@ pymysql==1.2.0 # MySQL 异步操作数据库 python-multipart==0.0.32 # request.form() 对表单进行「解析」时安装 redis==7.1.0 # Redis 异步操作数据库 rich==15.0.0 # 终端打印美化 -sqlalchemy==2.0.45 # 数据库ORM +sqlalchemy==2.0.51 # 数据库ORM sqlglot[rs]==27.8.0 # sql 解析 typer==0.26.7 # 命令行工具 ua-parser==1.0.2 # 获取用户UA diff --git a/backend/run_linux.sh b/backend/run_linux.sh deleted file mode 100755 index 302defce..00000000 --- a/backend/run_linux.sh +++ /dev/null @@ -1,378 +0,0 @@ -#!/bin/bash - -set -e - -# 固定脚本所在目录 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -# ========== 彩色输出定义 ========== -if [[ -t 1 ]]; then - tty_red="\033[0;31m" - tty_green="\033[0;32m" - tty_yellow="\033[0;33m" - tty_blue="\033[0;34m" - tty_cyan="\033[0;36m" - tty_purple="\033[0;35m" - tty_bold="\033[1m" - tty_reset="\033[0m" -else - tty_red="" tty_green="" tty_yellow="" tty_blue="" tty_cyan="" tty_purple="" tty_bold="" tty_reset="" -fi - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[38;5;226m[!]' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -LIGHT_GRAY='\033[0;37m' -PURPLE='\033[0;35m' -BOLD='\033[1m' -RESET='\033[0m' - -# ========== 彩色输出函数 ========== -function info() { - echo -e "${tty_green}✅ $1${tty_reset}" -} - -function warn() { - echo -e "${tty_yellow}⚠️ $1${tty_reset}" -} - -function error() { - echo -e "${tty_red}❌ $1${tty_reset}" -} - -function pause() { - read -n1 -r -p "按任意键继续..." key -} - -# 判断执行是否成功 -JudgeSuccess() { - if [ $? -ne 0 ]; then - error "步骤失败: $1" - exit 1 - else - info "步骤成功: $1" - fi -} - -# 分割线输出函数 -print_separator() { - printf "${LIGHT_GRAY}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" -} - -show_banner() { - echo "" - echo -e "╭──────────────────────────────────────────────╮" - echo -e "│ \033[1;34m👋 欢迎使用 FastAPI 初始化脚本\033[0m │" - echo -e "╰──────────────────────────────────────────────╯" - echo "版本: 3.0.0" - echo "作者:coderxslee" - echo "" -} - -# ========== 从 .env.dev 文件中读取数据库配置 ========== -function load_db_config() { - local env_file="$SCRIPT_DIR/env/.env.dev" - - if [ ! -f "$env_file" ]; then - error "未找到 $env_file 文件" - return 1 - fi - - # 读取数据库配置(忽略注释和空行) - # 使用 sed 去掉注释部分,然后提取值 - DATABASE_HOST=$(grep "^DATABASE_HOST" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - DATABASE_PORT=$(grep "^DATABASE_PORT" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - DATABASE_USER=$(grep "^DATABASE_USER" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - DATABASE_PASSWORD=$(grep "^DATABASE_PASSWORD" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - DATABASE_NAME=$(grep "^DATABASE_NAME" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - DATABASE_TYPE=$(grep "^DATABASE_TYPE" "$env_file" | cut -d'=' -f2 | sed 's/#.*//' | xargs) - - # 验证必要的配置 - if [[ -z "$DATABASE_HOST" ]] || [[ -z "$DATABASE_PORT" ]] || [[ -z "$DATABASE_USER" ]] || [[ -z "$DATABASE_NAME" ]]; then - error "数据库配置不完整,请检查 $env_file 文件" - return 1 - fi - - return 0 -} - -# ========== FastAPI 功能函数 ========== - -# 1. 启动开发服务器 -function start_dev_server() { - print_separator - echo -e "${tty_cyan}🚀 启动(uv run dev)...${tty_reset}" - - load_db_config || return 1 - - echo -e "${tty_blue}🗄️ 检查并创建数据库...${tty_reset}" - - # 检查数据库是否存在 - DB_EXISTS=$(PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "postgres" -tAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME';" 2>/dev/null || echo "") - - if [ "$DB_EXISTS" != "1" ]; then - warn "数据库 '$DATABASE_NAME' 不存在,正在创建..." - CREATE_DB_OUTPUT=$(PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "postgres" -c "CREATE DATABASE \"$DATABASE_NAME\" WITH ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8' TEMPLATE template0 OWNER $DATABASE_USER;" 2>&1) - if [ $? -ne 0 ]; then - error "数据库创建失败" - echo -e "${tty_red}错误详情:${tty_reset}" - echo -e "${tty_red}$CREATE_DB_OUTPUT${tty_reset}" - return 1 - fi - info "数据库 '$DATABASE_NAME' 创建成功 (UTF8 编码, C.UTF-8 排序规则)" - else - info "数据库 '$DATABASE_NAME' 已存在" - fi - - print_separator - cd "$SCRIPT_DIR" - uv run main.py run --env=dev - JudgeSuccess "开发服务器启动" - - echo -e "${tty_green}🎉 开发服务器启动完成!${tty_reset}" - print_separator -} - -# 2. 生成迁移文件 -function create_migration() { - print_separator - echo -e "${tty_cyan}📝 生成迁移文件(模型变更后)...${tty_reset}" - - cd "$SCRIPT_DIR" - uv run main.py revision --env=dev - JudgeSuccess "迁移文件生成" - - echo -e "${tty_green}🎉 迁移文件生成完成!${tty_reset}" - print_separator -} - -# 3. 应用迁移 -function apply_migration() { - print_separator - echo -e "${tty_cyan}⚡ 应用迁移...${tty_reset}" - - cd "$SCRIPT_DIR" - uv run main.py upgrade --env=dev - JudgeSuccess "迁移应用" - - echo -e "${tty_green}🎉 迁移应用完成!${tty_reset}" - print_separator -} - -# 4. 重置数据库中的迁移记录 -function reset_migration_records() { - print_separator - echo -e "${tty_cyan}🔄 重置数据库中的迁移记录...${tty_reset}" - - echo -e "${tty_yellow}⚠️ 警告:此操作将重置数据库中的迁移记录!${tty_reset}" - read -p "确认继续吗?(y/N): " confirm - if [[ $confirm != [yY] ]]; then - echo -e "${tty_yellow}操作已取消${tty_reset}" - return - fi - - load_db_config || return 1 - - cd "$SCRIPT_DIR" - echo -e "${tty_blue}🔄 正在重置迁移记录...${tty_reset}" - - # 使用 psql 连接到数据库并重置迁移记录 - PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c "DELETE FROM alembic_version;" 2>/dev/null - JudgeSuccess "迁移记录重置" - - echo -e "${tty_green}🎉 迁移记录重置完成!${tty_reset}" - print_separator -} - -# 5. 清理数据库(删除所有表) -function clean_database() { - print_separator - echo -e "${tty_cyan}🗄️ 清理数据库(删除所有表)...${tty_reset}" - - echo -e "${tty_yellow}⚠️ 警告:此操作将删除数据库中的所有数据!${tty_reset}" - read -p "确认继续吗?(y/N): " confirm - if [[ $confirm != [yY] ]]; then - echo -e "${tty_yellow}操作已取消${tty_reset}" - return - fi - - load_db_config || return 1 - - cd "$SCRIPT_DIR" - echo -e "${tty_blue}🗄️ 清理数据库,删除所有现有的表...${tty_reset}" - - # 使用 psql 连接到数据库并删除所有表 - PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" 2>/dev/null - JudgeSuccess "数据库清理" - - echo -e "${tty_green}🎉 数据库清理完成!${tty_reset}" - print_separator -} - -# 6. 删除数据库 -function drop_database() { - print_separator - echo -e "${tty_cyan}🗑️ 删除数据库...${tty_reset}" - - # 获取数据库名称 - echo -e "${tty_blue}📝 请输入要删除的数据库名称:${tty_reset}" - read -p "数据库名称: " db_name - - # 验证数据库名称不为空 - if [[ -z "$db_name" ]]; then - error "数据库名称不能为空!" - return - fi - - # 显示警告信息 - echo -e "${tty_red}⚠️ 警告:此操作将永久删除数据库 '$db_name' 及其所有数据!${tty_reset}" - echo -e "${tty_yellow}此操作不可撤销!${tty_reset}" - - # 第一次确认 - read -p "确认要删除数据库 '$db_name' 吗?(y/N): " confirm1 - if [[ $confirm1 != [yY] ]]; then - echo -e "${tty_yellow}操作已取消${tty_reset}" - return - fi - - # 第二次确认 - echo -e "${tty_red}⚠️ 最后确认:您真的要删除数据库 '$db_name' 吗?${tty_reset}" - read -p "请输入 'DELETE' 来确认删除: " confirm2 - if [[ $confirm2 != "DELETE" && $confirm2 != "delete" ]]; then - echo -e "${tty_yellow}操作已取消${tty_reset}" - return - fi - - load_db_config || return 1 - - cd "$SCRIPT_DIR" - echo -e "${tty_blue}🗑️ 正在删除数据库 '$db_name'...${tty_reset}" - - # 先断开所有连接到该数据库的连接 - PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "postgres" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" 2>/dev/null - - # 删除数据库 - PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "postgres" -c "DROP DATABASE IF EXISTS \"$db_name\";" 2>/dev/null - JudgeSuccess "数据库删除" - - echo -e "${tty_green}🎉 数据库 '$db_name' 删除完成!${tty_reset}" - print_separator -} - -# 7. 初始化数据(执行 sql 目录下脚本) -function init_sql_data() { - print_separator - echo -e "${tty_cyan}🧰 初始化数据...${tty_reset}" - - local sql_dir - sql_dir="$REPO_ROOT/backend/sql/postgres/init_data" - - if [ ! -d "$sql_dir" ]; then - error "未找到 SQL 目录: $sql_dir" - return 1 - fi - - local sql_files=() - while IFS= read -r file; do - sql_files+=("$file") - done < <(find "$sql_dir" -maxdepth 1 -type f -name "*.sql" 2>/dev/null | sort) - - if [ ${#sql_files[@]} -eq 0 ]; then - warn "SQL 目录下没有可执行的 .sql 文件: $sql_dir" - return 0 - fi - - echo -e "${tty_purple}可用 SQL 文件:${tty_reset}" - echo -e "${tty_yellow}0. 执行全部 SQL 文件${tty_reset}" - - local i - for i in "${!sql_files[@]}"; do - echo "$((i + 1)). $(basename "${sql_files[$i]}")" - done - - local choice - read -p "请选择要初始化的 SQL(输入序号): " choice - - if ! [[ "$choice" =~ ^[0-9]+$ ]]; then - error "输入无效,请输入数字序号" - return 1 - fi - - load_db_config || return 1 - - if [ "$choice" -eq 0 ]; then - echo -e "${tty_blue}🚀 开始执行全部 SQL 文件...${tty_reset}" - local file - for file in "${sql_files[@]}"; do - echo -e "${tty_blue}执行: $(basename "$file")${tty_reset}" - if PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f "$file"; then - info "执行成功: $(basename "$file")" - else - error "执行失败: $(basename "$file")" - return 1 - fi - done - info "全部 SQL 文件执行完成" - else - local index=$((choice - 1)) - if [ "$index" -lt 0 ] || [ "$index" -ge ${#sql_files[@]} ]; then - error "序号超出范围" - return 1 - fi - - local selected_file="${sql_files[$index]}" - echo -e "${tty_blue}执行: $(basename "$selected_file")${tty_reset}" - if PGPASSWORD="$DATABASE_PASSWORD" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" -d "$DATABASE_NAME" -f "$selected_file"; then - info "执行成功: $(basename "$selected_file")" - info "SQL 初始化完成" - else - error "执行失败: $(basename "$selected_file")" - return 1 - fi - fi - - print_separator -} - -# 主菜单 -function main_menu() { - clear - show_banner - echo -e "\033[1;34m📦 请选择要执行的操作:\033[0m" - echo "" - echo -e "\033[1;33m1. 🚀 启动(uv run dev)\033[0m" - echo -e "\033[1;32m2. 📝 生成迁移文件(模型变更后)\033[0m" - echo -e "\033[1;36m3. ⚡ 应用迁移\033[0m" - echo -e "\033[1;36m4. 🔄 重置数据库中的迁移记录\033[0m" - echo -e "\033[1;31m5. 🗄️ 清理数据库(删除所有表)\033[0m" - echo -e "\033[1;31m6. 🗑️ 删除数据库\033[0m" - echo -e "\033[1;36m7. 🧰 初始化数据(执行 sql 脚本)\033[0m" - echo -e "\033[1;31m0. ❌ 退出\033[0m" - echo "" - - read -p "请选择你要执行的操作: " option - case $option in - 1) start_dev_server && pause ;; - 2) create_migration && pause ;; - 3) apply_migration && pause ;; - 4) reset_migration_records && pause ;; - 5) clean_database && pause ;; - 6) drop_database && pause ;; - 7) init_sql_data && pause ;; - 0) exit 0 ;; - *) error "未知选项: $option" && pause ;; - esac -} - -# 非交互模式入口 -if [[ "$1" == "--start-dev" ]]; then - start_dev_server - exit $? -fi - -# 默认进入交互菜单 -main_menu diff --git a/backend/run_win.bat b/backend/run_win.bat deleted file mode 100755 index e22b5357..00000000 --- a/backend/run_win.bat +++ /dev/null @@ -1,267 +0,0 @@ -@echo off -setlocal EnableDelayedExpansion - -set "SCRIPT_DIR=%~dp0" -set "REPO_ROOT=%SCRIPT_DIR%.." - -set "tty_red=" -set "tty_green=" -set "tty_yellow=" -set "tty_blue=" -set "tty_cyan=" -set "tty_purple=" -set "tty_bold=" -set "tty_reset=" - -set "RED=[0;31m" -set "GREEN=[0;32m" -set "YELLOW=[33m" -set "BLUE=[0;34m" -set "CYAN=[0;36m" -set "LIGHT_GRAY=[0;37m" -set "PURPLE=[0;35m" -set "BOLD=[1m" -set "RESET=[0m" - -set "ERROR_COUNT=0" - -goto :main_menu - -:info -echo !tty_green!OK: %~1!tty_reset! -goto :eof - -:warn -echo !tty_yellow!WARNING: %~1!tty_reset! -goto :eof - -:error -echo !tty_red!ERROR: %~1!tty_reset! -goto :eof - -:pause -pause -goto :eof - -:print_separator -echo !LIGHT_GRAY!---------------------------------------------------------------!RESET! -goto :eof - -:show_banner -echo. -echo Welcome to FastAPI Init Script -echo Version: 1.0.0 -echo Author: coderxslee -echo. -goto :eof - -:load_db_config -set "env_file=%SCRIPT_DIR%env\.env.dev" - -if not exist "%env_file%" ( - call :error "Not found: %env_file%" - exit /b 1 -) - -for /f "usebackq tokens=1,* delims==" %%a in ("%env_file%") do ( - set "line=%%a" - set "value=%%b" - set "line=!line:~0,13!" - if "!line!"=="DATABASE_HOST" set "DATABASE_HOST=%%b" - if "!line!"=="DATABASE_PORT" set "DATABASE_PORT=%%b" - if "!line!"=="DATABASE_USER" set "DATABASE_USER=%%b" - if "!line!"=="DATABASE_PASSWORD" set "DATABASE_PASSWORD=%%b" - if "!line!"=="DATABASE_NAME" set "DATABASE_NAME=%%b" - if "!line!"=="DATABASE_TYPE" set "DATABASE_TYPE=%%b" -) - -if "!DATABASE_HOST!"=="" ( - call :error "Database configuration incomplete" - exit /b 1 -) -exit /b 0 - -:start_dev_server -call :print_separator -echo Starting dev server... -call :load_db_config -if errorlevel 1 exit /b 1 - -echo Checking database... -echo Database host: !DATABASE_HOST! -echo Database port: !DATABASE_PORT! -echo Database user: !DATABASE_USER! -echo Database name: !DATABASE_NAME! - -call :print_separator -cd /d "%SCRIPT_DIR%" -uv run main.py run --env=dev -exit /b 0 - -:create_migration -call :print_separator -echo Generating migration files... -cd /d "%SCRIPT_DIR%" -uv run main.py revision --env=dev -exit /b 0 - -:apply_migration -call :print_separator -echo Applying migrations... -cd /d "%SCRIPT_DIR%" -uv run main.py upgrade --env=dev -exit /b 0 - -:reset_migration_records -call :print_separator -echo Resetting migration records... -echo WARNING: This will reset migration records in the database! -set /p confirm="Continue? (y/N): " -if /i not "!confirm!"=="y" ( - echo Operation cancelled - exit /b 0 -) - -call :load_db_config -if errorlevel 1 exit /b 1 - -cd /d "%SCRIPT_DIR%" -echo Resetting migration records... -echo DELETE FROM alembic_version; | PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d %DATABASE_NAME% -exit /b 0 - -:clean_database -call :print_separator -echo Cleaning database... -echo WARNING: This will delete all tables in the database! -set /p confirm="Continue? (y/N): " -if /i not "!confirm!"=="y" ( - echo Operation cancelled - exit /b 0 -) - -call :load_db_config -if errorlevel 1 exit /b 1 - -cd /d "%SCRIPT_DIR%" -echo Cleaning database, dropping all tables... -echo DROP SCHEMA public CASCADE; CREATE SCHEMA public; | PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d %DATABASE_NAME% -exit /b 0 - -:drop_database -call :print_separator -echo Deleting database... -set /p db_name="Enter database name: " - -if "!db_name!"=="" ( - call :error "Database name cannot be empty" - exit /b 1 -) - -echo WARNING: This will permanently delete database '!db_name!' and all its data! -echo This action cannot be undone! - -set /p confirm1="Confirm deletion of '!db_name!'? (y/N): " -if /i not "!confirm1!"=="y" ( - echo Operation cancelled - exit /b 0 -) - -set /p confirm2="Enter 'DELETE' to confirm: " -if not "!confirm2!"=="DELETE" ( - echo Operation cancelled - exit /b 0 -) - -call :load_db_config -if errorlevel 1 exit /b 1 - -cd /d "%SCRIPT_DIR%" -echo Dropping database '!db_name!'... -echo SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '!db_name!' AND pid <> pg_backend_pid(); | PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d postgres -echo DROP DATABASE IF EXISTS "!db_name!"; | PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d postgres -exit /b 0 - -:init_sql_data -call :print_separator -echo Initializing SQL data... - -set "sql_dir=%REPO_ROOT%\backend\sql\postgres\init_data" - -if not exist "%sql_dir%" ( - call :error "SQL directory not found: %sql_dir%" - exit /b 1 -) - -echo Available SQL files: -echo 0. Execute all SQL files - -set "i=0" -set "file_count=0" -for %%f in ("%sql_dir%\*.sql") do ( - set /a file_count+=1 - echo !file_count!. %%~nxf -) - -set /p choice="Select SQL to initialize (enter number): " - -call :load_db_config -if errorlevel 1 exit /b 1 - -if "!choice!"=="0" ( - echo Executing all SQL files... - for %%f in ("%sql_dir%\*.sql") do ( - echo Executing: %%~nxf - PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d %DATABASE_NAME% -f "%%f" - ) -) else ( - set /a index=choice-1 - set "selected_file=" - set "j=0" - for %%f in ("%sql_dir%\*.sql") do ( - if "!j!"=="!index!" ( - set "selected_file=%%f" - ) - set /a j+=1 - ) - if "!selected_file!"=="" ( - call :error "Invalid selection" - exit /b 1 - ) - echo Executing: !selected_file! - PGPASSWORD=%DATABASE_PASSWORD% psql -h %DATABASE_HOST% -p %DATABASE_PORT% -U %DATABASE_USER% -d %DATABASE_NAME% -f "!selected_file!" -) -exit /b 0 - -:main_menu -cls -call :show_banner -echo Please select an operation: -echo. -echo 1. Start dev server (uv run dev) -echo 2. Generate migration files -echo 3. Apply migrations -echo 4. Reset migration records -echo 5. Clean database (drop all tables) -echo 6. Delete database -echo 7. Initialize SQL data -echo 0. Exit -echo. - -set /p option="Select operation: " - -if "!option!"=="1" call :start_dev_server -if "!option!"=="2" call :create_migration -if "!option!"=="3" call :apply_migration -if "!option!"=="4" call :reset_migration_records -if "!option!"=="5" call :clean_database -if "!option!"=="6" call :drop_database -if "!option!"=="7" call :init_sql_data -if "!option!"=="0" exit /b 0 - -call :pause -goto :main_menu - -:end -endlocal -exit /b 0 diff --git a/backend/templates/invoice.jinja2 b/backend/templates/invoice/invoice.jinja2 similarity index 100% rename from backend/templates/invoice.jinja2 rename to backend/templates/invoice/invoice.jinja2 diff --git a/backend/templates/invoice/oss_license.jinja2 b/backend/templates/invoice/oss_license.jinja2 new file mode 100644 index 00000000..d4d45b30 --- /dev/null +++ b/backend/templates/invoice/oss_license.jinja2 @@ -0,0 +1,177 @@ + + + + +开源项目授权声明 - {{ invoice_no }} + + + +
+ +

开源项目授权声明函

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

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

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

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

+ + + +
+ + diff --git a/backend/tests/scripts/seed_invoice_pdfs.py b/backend/tests/scripts/seed_invoice_pdfs.py new file mode 100644 index 00000000..94ed52ef --- /dev/null +++ b/backend/tests/scripts/seed_invoice_pdfs.py @@ -0,0 +1,97 @@ +"""开发辅助脚本:为 platform_invoice.json 中所有已开票(status=1)的种子发票生成示例 PDF。 + +使用场景: +- 首次启动开发环境后,初始化数据已导入,但 PDF 文件并不存在 +- 前端访问 /static/invoice/...pdf 会 404 +- 跑此脚本批量生成,发票 PDF + 授权函 PDF,路径与 JSON 中 pdf_url / oss_license_pdf_url 完全一致 + +用法: + cd backend + uv run python tests/scripts/seed_invoice_pdfs.py + +依赖: +- weasyprint(pyproject.toml 已包含) +- macOS 上需先安装系统库:brew install glib pango libffi(Linux 部署环境正常) +""" + +import asyncio +import json +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +from app.api.v1.module_platform.invoice.pdf_helper import ( # noqa: E402 + _render_invoice_pdf, + _render_oss_license_pdf, +) +from app.api.v1.module_platform.invoice.schema import InvoiceOutSchema # noqa: E402 + +SEED_JSON = BACKEND_DIR / "app" / "scripts" / "data" / "platform_invoice.json" + + +def _to_out_schema(record: dict) -> InvoiceOutSchema: + """把 JSON 字典转为 InvoiceOutSchema,缺失的字段用合理默认填充""" + return InvoiceOutSchema( + id=record.get("id", 0), + invoice_no=record["invoice_no"], + order_id=record["order_id"], + tenant_id=record["tenant_id"], + invoice_type=record["invoice_type"], + title=record["title"], + tax_no=record.get("tax_no"), + bank_info=record.get("bank_info"), + address_info=record.get("address_info"), + amount=record["amount"], + tax_amount=record.get("tax_amount", 0), + status=record.get("status", 1), + description=record.get("description"), + created_time=record.get("created_time", "2026-01-01 00:00:00"), + updated_time=record.get("updated_time", "2026-01-01 00:00:00"), + created_by=record.get("created_by", {"id": 1, "name": "admin"}), + updated_by=record.get("updated_by", {"id": 1, "name": "admin"}), + ) + + +async def main() -> int: + if not SEED_JSON.exists(): + print(f"[ERR] 找不到种子数据: {SEED_JSON}") + return 1 + + records = json.loads(SEED_JSON.read_text(encoding="utf-8")) + issued = [r for r in records if r.get("status") == 1] + print(f"种子数据共 {len(records)} 条,其中 status=1(已开票){len(issued)} 条\n") + + if not issued: + print("无需生成") + return 0 + + success = 0 + failed = 0 + for rec in issued: + invoice_no = rec["invoice_no"] + try: + invoice = _to_out_schema(rec) + invoice_url = _render_invoice_pdf(invoice) + license_url = _render_oss_license_pdf(invoice) + inv_full = BACKEND_DIR / invoice_url.lstrip("/") + lic_full = BACKEND_DIR / license_url.lstrip("/") + print(f" ✓ {invoice_no}") + print(f" 发票 PDF: {invoice_url} ({inv_full.stat().st_size} bytes)") + print(f" 授权函 PDF: {license_url} ({lic_full.stat().st_size} bytes)") + success += 1 + except Exception as e: + print(f" ✗ {invoice_no}: {type(e).__name__}: {e}") + failed += 1 + + print(f"\n=== 完成:成功 {success} 条,失败 {failed} 条 ===") + if failed: + print("\n如果提示缺少 libgobject/pango:") + print(" macOS: brew install glib pango libffi") + print(" Ubuntu: sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0") + return 0 if failed == 0 else 2 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/backend/tests/test_init_data_integrity.py b/backend/tests/test_init_data_integrity.py new file mode 100644 index 00000000..342bc2af --- /dev/null +++ b/backend/tests/test_init_data_integrity.py @@ -0,0 +1,249 @@ +"""验证 scripts/data/*.json 与数据库表名一致性。 + +核心规则(只要求 1 项): +- JSON 文件名(去 .json)= 数据库表名(Model.__tablename__) +- 例:platform_invoice.json ↔ InvoiceModel.__tablename__ = "platform_invoice" + +补充检查(每对 JSON-Model): +- JSON 出现的字段必须存在于 Model 列中(多余字段 = ERROR) +- JSON 缺失必填无默认字段 = ERROR + +违规时 fail。 + +用法:uv run python tests/test_init_data_integrity.py +""" + +import importlib +import inspect +import json +import sys +from pathlib import Path +from typing import Any + +BACKEND_ROOT = Path(__file__).resolve().parent.parent +SCRIPT_DIR = BACKEND_ROOT / "app" / "scripts" / "data" + +# 非种子数据文件,校验脚本跳过 +_EXCLUDED_JSON: set[str] = {"oss_licenses.json"} + +# 运行时产生数据的表,无需种子 JSON(白名单) +# 业务说明:以下表的数据由系统运行产生,不在 initialize 时导入 +_NO_SEED_TABLES: set[str] = { + "gen_table", # 代码生成器-业务表(用户在线创建) + "gen_table_column", # 代码生成器-字段表(随 gen_table 产生) + "platform_email_log", # 邮件发送日志(运行时累计) + "platform_package_plugin", # 套餐-插件关联(运行时由超管配置) + "sys_role_depts", # 角色-部门关联(运行时由超管配置) + "sys_role_menus", # 角色-菜单关联(运行时由超管配置) + "sys_user_positions", # 用户-岗位关联(运行时由 HR 配置) + "task_job", # 定时任务(运行时由用户配置) + "task_workflow", # 工作流(运行时由用户配置) +} + +# 已知会被 initialize.py 特殊处理的字段(树形结构 children) +# initialize.py 的 _RECURSIVE_TABLES = {"platform_menu", "sys_dept"} 会 pop children 再传给 Model +_ALLOWED_EXTRA_FIELDS: set[str] = {"children"} + +sys.path.insert(0, str(BACKEND_ROOT)) + + +# ──────────────────────────────────────────────────────────── +# 1. 扫描所有 Model,自动建立"表名 → Model 类"映射 +# ──────────────────────────────────────────────────────────── + +def _discover_table_to_model() -> dict[str, type]: + """ + 扫描 app/ 下所有 model.py,提取 *Model 类的 __tablename__ + + 返回: {tablename: ModelClass} + """ + table_to_model: dict[str, type] = {} + for model_file in sorted(BACKEND_ROOT.rglob("model.py")): + rel = model_file.relative_to(BACKEND_ROOT) + if "app" not in rel.parts: + continue + module_path = ".".join(rel.with_suffix("").parts) + try: + mod = importlib.import_module(module_path) + except Exception as e: # noqa: BLE001 + print(f"[IMPORT ERR] {module_path}: {type(e).__name__}: {e}") + continue + for name, obj in vars(mod).items(): + if not inspect.isclass(obj): + continue + if obj.__module__ != module_path: + continue + if not name.endswith("Model"): + continue + if name.startswith("_"): + continue + table_name = getattr(obj, "__tablename__", None) + if not table_name: + continue + if table_name in table_to_model: + # 同名表名不应存在多个 Model + print(f"[WARN] 表名 {table_name!r} 被多个 Model 声明: {obj} 与 {table_to_model[table_name]}") + continue + table_to_model[table_name] = obj + return table_to_model + + +# ──────────────────────────────────────────────────────────── +# 2. 字段对应检查 +# ──────────────────────────────────────────────────────────── + +def _model_columns(model: type) -> dict[str, dict[str, Any]]: + cols: dict[str, dict[str, Any]] = {} + for col in model.__table__.columns: + cols[col.name] = { + "type": col.type.__class__.__name__, + "nullable": col.nullable, + "default": col.default.arg if col.default else None, + "server_default": col.server_default.arg if col.server_default else None, + "primary_key": col.primary_key, + } + return cols + + +def _check_field_compat(json_path: Path, model: type) -> dict: + """检查 JSON 字段 vs Model 列的兼容性""" + cols = _model_columns(model) + + data = json.loads(json_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + records = [data] + else: + records = data + + extra_fields: set[str] = set() + missing_required: set[str] = set() + type_mismatches: list[str] = [] + sample = records[0] if records else {} + + for k in sample.keys(): + if k not in cols and k not in _ALLOWED_EXTRA_FIELDS: + extra_fields.add(k) + + for col_name, info in cols.items(): + if info["primary_key"]: + continue + if info["nullable"] or info["default"] is not None or info["server_default"] is not None: + continue + for rec in records: + if col_name not in rec: + missing_required.add(col_name) + break + + type_map = { + "INTEGER": int, + "BIGINTEGER": int, + "SMALLINTEGER": int, + "VARCHAR": str, + "String": str, + "TEXT": str, + "JSON": (dict, list), + "BOOLEAN": bool, + "DATETIME": str, + "DATE": str, + "TIME": str, + "FLOAT": (int, float), + "NUMERIC": (int, float), + } + for col_name, info in cols.items(): + py_type = type_map.get(info["type"]) + if py_type is None: + continue + for rec in records[:5]: + v = rec.get(col_name) + if v is None: + continue + if not isinstance(v, py_type): + type_mismatches.append(f"{col_name}({info['type']}): 实际 {type(v).__name__}={v!r}") + break + + return { + "json_count": len(records), + "extra_in_json": sorted(extra_fields), + "missing_required": sorted(missing_required), + "type_mismatches": type_mismatches, + "ok": not extra_fields and not missing_required and not type_mismatches, + } + + +# ──────────────────────────────────────────────────────────── +# 3. 主流程:表名对应 + 字段对应 +# ──────────────────────────────────────────────────────────── + +def main() -> int: + table_to_model = _discover_table_to_model() + print(f"扫描到 {len(table_to_model)} 个 Model 表\n") + + # 收集所有 JSON 文件 + json_files = sorted(p for p in SCRIPT_DIR.glob("*.json") if p.name not in _EXCLUDED_JSON) + json_table_names = {p.stem for p in json_files} + model_table_names = set(table_to_model.keys()) + + # 规则 1: JSON 文件名(去 .json)必须 = Model.__tablename__ + orphan_jsons = json_table_names - model_table_names + orphan_tables = model_table_names - json_table_names + + # 规则 2: 字段对应 + print("─" * 70) + print("【规则 1】JSON 文件名 ↔ 数据库表名对应") + print("─" * 70) + bad = 0 + if orphan_jsons: + print("✗ 下列 JSON 文件找不到对应 Model 表:") + for name in sorted(orphan_jsons): + print(f" - {name}.json") + bad += len(orphan_jsons) + if orphan_tables: + # 过滤掉白名单内的"运行时产生数据"表 + real_orphan = orphan_tables - _NO_SEED_TABLES + whitelisted = orphan_tables & _NO_SEED_TABLES + if real_orphan: + print("✗ 下列 Model 表没有对应 JSON 种子数据(initialize 时会被跳过):") + for name in sorted(real_orphan): + print(f" - {name} ({table_to_model[name].__name__})") + bad += len(real_orphan) + if whitelisted: + print(f" ⊙ 跳过白名单表(运行时产生数据): {len(whitelisted)} 个") + for name in sorted(whitelisted): + print(f" - {name}") + if not orphan_jsons and not orphan_tables: + print(f"✓ 全部 {len(json_table_names)} 个 JSON 都对应了 Model 表") + + print() + print("─" * 70) + print("【规则 2】JSON 字段 ↔ Model 列兼容性") + print("─" * 70) + + field_bad = 0 + for json_path in json_files: + table_name = json_path.stem + if table_name not in table_to_model: + continue # 上面已报 + model = table_to_model[table_name] + r = _check_field_compat(json_path, model) + mark = "✓" if r["ok"] else "✗" + print(f" {mark} {json_path.name} ({r['json_count']} 条) ↔ {model.__name__}") + if r["extra_in_json"]: + print(f" JSON 多余字段: {r['extra_in_json']}") + if r["missing_required"]: + print(f" 缺失必填字段: {r['missing_required']}") + for t in r["type_mismatches"]: + print(f" 类型不匹配: {t}") + if not r["ok"]: + field_bad += 1 + + print() + total_bad = bad + field_bad + if total_bad: + print(f"=== 失败:表名不符 {bad} 个 + 字段不符 {field_bad} 个 ===") + return 1 + print(f"=== 全部通过:{len(json_table_names)} 个 JSON ↔ {len(model_table_names)} 个 Model 表 ===") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/uv.lock b/backend/uv.lock index d57dbf97..e560ea3e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -296,7 +296,7 @@ requires-dist = [ { name = "python-multipart", specifier = "==0.0.32" }, { name = "redis", specifier = "==7.1.0" }, { name = "rich", specifier = "==15.0.0" }, - { name = "sqlalchemy", specifier = "==2.0.45" }, + { name = "sqlalchemy", specifier = ">=2.0.51,<2.1" }, { name = "sqlglot", extras = ["rs"], specifier = "==27.8.0" }, { name = "tinycss2", specifier = "==1.5.1" }, { name = "typer", specifier = "==0.26.7" }, @@ -1827,37 +1827,43 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.45" +version = "2.0.51" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [[package]] diff --git a/frontend/web/src/api/module_system/ticket.ts b/frontend/web/src/api/module_system/ticket.ts index 9fbe2362..f2cd4f97 100644 --- a/frontend/web/src/api/module_system/ticket.ts +++ b/frontend/web/src/api/module_system/ticket.ts @@ -30,7 +30,7 @@ const TicketAPI = { responseType: "blob", }); }, - batchTicket(body: { ids: number[]; status: string }) { + batchTicket(body: { ids: number[]; status: number }) { return request({ url: `${API_PATH}/batch`, method: "put", data: body }); }, }; diff --git a/frontend/web/src/components/forms/fa-form/index.vue b/frontend/web/src/components/forms/fa-form/index.vue index 9752c046..197b0117 100644 --- a/frontend/web/src/components/forms/fa-form/index.vue +++ b/frontend/web/src/components/forms/fa-form/index.vue @@ -90,6 +90,7 @@ @click="handleSubmit" v-ripple :disabled="disabledSubmit" + :loading="loading" > {{ t("table.form.submit") }} @@ -177,6 +178,7 @@ @click="handleSubmit" v-ripple :disabled="disabledSubmit" + :loading="loading" > {{ t("table.form.submit") }} @@ -296,6 +298,8 @@ interface Props { showSubmit?: boolean; /** 是否禁用提交按钮 */ disabledSubmit?: boolean; + /** 提交按钮 loading(防止重复提交) */ + loading?: boolean; /** 提交时是否清洗空值 */ sanitizeOutput?: Partial; /** 是否需要内置 ElScrollbar 包裹 */ @@ -329,6 +333,7 @@ const props = withDefaults(defineProps(), { showReset: true, showSubmit: true, disabledSubmit: false, + loading: false, sanitizeOutput: () => ({}), scrollbar: false, maxHeight: "75vh", diff --git a/frontend/web/src/components/tables/fa-table-header-left/index.vue b/frontend/web/src/components/tables/fa-table-header-left/index.vue index 08ce9677..14b73052 100644 --- a/frontend/web/src/components/tables/fa-table-header-left/index.vue +++ b/frontend/web/src/components/tables/fa-table-header-left/index.vue @@ -21,6 +21,7 @@ v-hasPerm="permCreate" type="primary" :icon="Plus" + :loading="createLoading" @click="$emit('add')" plain > @@ -63,7 +64,7 @@ 批量删除 - + @@ -71,8 +72,8 @@ @@ -247,6 +248,8 @@ const editingTitle = ref(""); const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null); const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); + async function deleteSessionRow(id: string) { try { await confirmDelete(); @@ -437,6 +440,15 @@ async function handleCloseDialog() { await resetForm(); } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog(type: "create" | "detail", id?: string) { await resetForm(); dialogVisible.type = type; diff --git a/frontend/web/src/views/module_example/demo/index.vue b/frontend/web/src/views/module_example/demo/index.vue index 40911c8c..ca183e7b 100644 --- a/frontend/web/src/views/module_example/demo/index.vue +++ b/frontend/web/src/views/module_example/demo/index.vue @@ -38,7 +38,8 @@ :perm-delete="['module_example:demo:delete']" :perm-patch="['module_example:demo:patch']" :delete-loading="batchDeleting" - @add="openEditDialog('add')" + :create-loading="createLoading" + @add="handleAdd" @import="openImport" @export="openExport" @delete="handleBatchDelete" @@ -250,6 +251,8 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); + const { columns, columnChecks, @@ -580,6 +583,15 @@ async function openDetailDialog(row: DemoTable) { dialogVisible.visible = true; } +async function handleAdd() { + createLoading.value = true; + try { + await openEditDialog("add"); + } finally { + createLoading.value = false; + } +} + async function openEditDialog(type: "add" | "edit", row?: DemoTable) { dialogVisible.type = type === "add" ? "create" : "update"; if (type === "add") { @@ -679,7 +691,7 @@ async function handleBatchDelete() { } } -async function runBatchStatus(status: string) { +async function runBatchStatus(status: number) { const ids = selectedIds.value; if (ids.length === 0) { ElMessage.warning("请先在列表中勾选数据"); @@ -687,7 +699,7 @@ async function runBatchStatus(status: string) { } try { await confirmAction( - `确认对选中的 ${ids.length} 条数据${status === "0" ? "启用" : "停用"}?`, + `确认对选中的 ${ids.length} 条数据${status === 0 ? "启用" : "停用"}?`, "批量设置" ); await DemoAPI.batchDemo({ ids, status }); diff --git a/frontend/web/src/views/module_platform/email/index.vue b/frontend/web/src/views/module_platform/email/index.vue index 96bcc510..9998e485 100644 --- a/frontend/web/src/views/module_platform/email/index.vue +++ b/frontend/web/src/views/module_platform/email/index.vue @@ -36,7 +36,8 @@ :perm-create="['module_platform:email:update']" :perm-delete="['module_platform:email:update']" :delete-loading="configBatchDeleting" - @add="openConfigDialog('create')" + :create-loading="configCreateLoading" + @add="handleConfigAdd" @delete="handleConfigBatchDelete" /> @@ -89,7 +90,8 @@ :perm-create="['module_platform:email:update']" :perm-delete="['module_platform:email:update']" :delete-loading="templateBatchDeleting" - @add="openTemplateDialog('create')" + :create-loading="templateCreateLoading" + @add="handleTemplateAdd" @delete="handleTemplateBatchDelete" /> @@ -324,6 +326,8 @@ const { onTableSelectionChange: onConfigSelectionChange, } = useTableSelection(); +const configCreateLoading = ref(false); + const { columns: configColumns, columnChecks: configColumnChecks, @@ -448,6 +452,8 @@ const { onTableSelectionChange: onTemplateSelectionChange, } = useTableSelection(); +const templateCreateLoading = ref(false); + const { columns: templateColumns, columnChecks: templateColumnChecks, @@ -699,6 +705,15 @@ const configDialogFormItems = computed(() => [ }, ]); +async function handleConfigAdd() { + configCreateLoading.value = true; + try { + await openConfigDialog("create"); + } finally { + configCreateLoading.value = false; + } +} + async function openConfigDialog(type: "create" | "update", row?: EmailConfigTable) { configDialogVisible.type = type; editingConfigId.value = row?.id ?? null; @@ -832,6 +847,15 @@ const templateDialogFormItems = computed(() => [ }, ]); +async function handleTemplateAdd() { + templateCreateLoading.value = true; + try { + await openTemplateDialog("create"); + } finally { + templateCreateLoading.value = false; + } +} + async function openTemplateDialog(type: "create" | "update", row?: EmailTemplateTable) { templateDialogVisible.type = type; editingTemplateId.value = row?.id ?? null; diff --git a/frontend/web/src/views/module_platform/menu/index.vue b/frontend/web/src/views/module_platform/menu/index.vue index 35a972ca..427f5078 100644 --- a/frontend/web/src/views/module_platform/menu/index.vue +++ b/frontend/web/src/views/module_platform/menu/index.vue @@ -41,7 +41,9 @@ :perm-delete="['module_platform:menu:delete']" :perm-patch="['module_platform:menu:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @delete="handleBatchDelete" @more="handleMoreClick" /> @@ -497,6 +499,8 @@ const selectedIds = computed(() => ); const batchDeleting = ref(false); const submitLoading = ref(false); +const createLoading = ref(false); +const moreLoading = ref(false); const menuOptions = ref([]); const fullMenuTree = ref([]); @@ -1124,6 +1128,15 @@ async function handleCloseDialog() { await resetForm(); } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog( type: "create" | "update" | "detail", id?: number, @@ -1222,13 +1235,13 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); return; } - ElMessageBox.confirm(`确认${status === "0" ? "启用" : "停用"}该项数据?`, "警告", { + ElMessageBox.confirm(`确认${status === 0 ? "启用" : "停用"}该项数据?`, "警告", { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning", @@ -1239,10 +1252,13 @@ async function handleMoreClick(status: string) { cancelButtonText: "取消", type: "warning", }); + moreLoading.value = true; await MenuAPI.batchMenu({ ids, status }); await loadMenuData(); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_platform/package/index.vue b/frontend/web/src/views/module_platform/package/index.vue index c281e715..050bbe46 100644 --- a/frontend/web/src/views/module_platform/package/index.vue +++ b/frontend/web/src/views/module_platform/package/index.vue @@ -35,7 +35,9 @@ :perm-delete="['module_package:package:delete']" :perm-patch="['module_package:package:update']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @delete="handleBatchDelete" @more="handleMoreClick" /> @@ -279,6 +281,9 @@ const pkgSearchItems = computed(() => [ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null); const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + const opCtx = { onDetail: (id: number) => void handleOpenDialog("detail", id), onEdit: (id: number) => void handleOpenDialog("update", id), @@ -591,6 +596,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = onUpdateSuccess: refreshCreate, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function deletePkgRow(id: number) { try { await confirmDelete(); @@ -606,7 +620,7 @@ async function togglePkgStatus(row: PackageTable) { const newStatus = row.status === 0 ? 1 : 0; const label = newStatus === 0 ? "启用" : "禁用"; try { - await confirmToggleStatus(label); + await confirmToggleStatus(newStatus); await PackageAPI.batchPackageStatus({ ids: [row.id!], status: Number(newStatus) }); ElMessage.success(`${label}成功`); await refreshData(); diff --git a/frontend/web/src/views/module_platform/tenant/index.vue b/frontend/web/src/views/module_platform/tenant/index.vue index 0aa8cd83..e2867636 100644 --- a/frontend/web/src/views/module_platform/tenant/index.vue +++ b/frontend/web/src/views/module_platform/tenant/index.vue @@ -34,7 +34,8 @@ :perm-create="['module_system:tenant:create']" :perm-delete="['module_system:tenant:delete']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + @add="handleAdd" @delete="handleBatchDelete" /> @@ -355,6 +356,8 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); + async function deleteTenantRow(id: number) { try { await confirmDelete(); @@ -600,6 +603,15 @@ function brandCropBind(key: string) { } } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) { dialogVisible.type = type; if (id) { diff --git a/frontend/web/src/views/module_system/dept/index.vue b/frontend/web/src/views/module_system/dept/index.vue index 183cc171..1bde2dc1 100644 --- a/frontend/web/src/views/module_system/dept/index.vue +++ b/frontend/web/src/views/module_system/dept/index.vue @@ -36,7 +36,9 @@ :perm-delete="['module_system:dept:delete']" :perm-patch="['module_system:dept:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @delete="handleBatchDelete" @more="handleMoreClick" /> @@ -267,6 +269,9 @@ const deptOptions = ref([]); const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + async function loadDeptData() { loading.value = true; try { @@ -378,6 +383,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const opCtx = { onAddChild: (parentId: number) => void handleOpenDialog("create", undefined, { parent_id: parentId }), @@ -513,7 +527,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -521,11 +535,14 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await DeptAPI.batchDept({ ids, status }); await loadDeptData(); await userStore.getUserInfo(); } catch { // 用户取消或操作失败 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/dict/components/DataDrawer.vue b/frontend/web/src/views/module_system/dict/components/DataDrawer.vue index 65a3ad67..6555ba85 100644 --- a/frontend/web/src/views/module_system/dict/components/DataDrawer.vue +++ b/frontend/web/src/views/module_system/dict/components/DataDrawer.vue @@ -41,7 +41,9 @@ :perm-delete="['module_system:dict_data:delete']" :perm-patch="['module_system:dict_data:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -369,6 +371,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + const { columns, columnChecks, @@ -671,6 +676,15 @@ async function handleCloseDialog() { await resetForm(); } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) { dialogVisible.type = type; if (id) { @@ -786,7 +800,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -794,12 +808,15 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await DictAPI.batchDictData({ ids, status }); await refreshData(); dictStore.clearDictData(); if (props.dictType) await dictStore.getDict([props.dictType]); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/dict/index.vue b/frontend/web/src/views/module_system/dict/index.vue index 1f75a6df..d4755962 100644 --- a/frontend/web/src/views/module_system/dict/index.vue +++ b/frontend/web/src/views/module_system/dict/index.vue @@ -36,7 +36,9 @@ :perm-delete="['module_system:dict_type:delete']" :perm-patch="['module_system:dict_type:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -234,6 +236,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + // ─── 对话框状态 ─── const { dialogVisible } = useCrudDialog(); @@ -306,6 +311,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const dictDialogFormItems = computed(() => [ { label: "字典名称", @@ -548,7 +562,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -556,6 +570,7 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await DictAPI.batchDictType({ ids, status }); await refreshData(); dictStore.clearDictData(); @@ -563,6 +578,8 @@ async function handleMoreClick(status: string) { if (dictTypes.length > 0) await dictStore.getDict(dictTypes); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/notice/index.vue b/frontend/web/src/views/module_system/notice/index.vue index c4933ca0..83225b38 100644 --- a/frontend/web/src/views/module_system/notice/index.vue +++ b/frontend/web/src/views/module_system/notice/index.vue @@ -45,7 +45,9 @@ :perm-delete="['module_system:notice:delete']" :perm-patch="['module_system:notice:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -284,6 +286,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + // ─── 对话框状态 ─── const { dialogVisible } = useCrudDialog(); @@ -382,6 +387,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const noticeDialogFormItems = computed(() => [ { label: "标题", @@ -648,7 +662,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -656,11 +670,14 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await NoticeAPI.batchNotice({ ids, status }); await refreshData(); await noticeStore.getNotice(); } catch { - ElMessage.info("操作取消"); + // 用户取消或操作失败 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/params/index.vue b/frontend/web/src/views/module_system/params/index.vue index 07bb4c89..f5dec860 100644 --- a/frontend/web/src/views/module_system/params/index.vue +++ b/frontend/web/src/views/module_system/params/index.vue @@ -35,7 +35,8 @@ :perm-export="['module_system:param:export']" :perm-delete="['module_system:param:delete']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" /> @@ -233,6 +234,8 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); + // ─── 对话框状态 ─── const { dialogVisible } = useCrudDialog(); @@ -308,6 +311,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const paramDialogFormItems = computed(() => [ { label: "配置名称", diff --git a/frontend/web/src/views/module_system/position/index.vue b/frontend/web/src/views/module_system/position/index.vue index 297d311d..10ea4eef 100644 --- a/frontend/web/src/views/module_system/position/index.vue +++ b/frontend/web/src/views/module_system/position/index.vue @@ -45,7 +45,9 @@ :perm-delete="['module_system:position:delete']" :perm-patch="['module_system:position:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -355,6 +357,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + const opCtx = { onDetail: (id: number) => void handleOpenDialog("detail", id), onEdit: (id: number) => void handleOpenDialog("update", id), @@ -528,6 +533,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const positionDialogFormItems = computed(() => [ { label: "岗位名称", @@ -622,7 +636,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -630,11 +644,14 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await PositionAPI.batchPosition({ ids, status }); await refreshData(); await userStore.getUserInfo(); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/role/index.vue b/frontend/web/src/views/module_system/role/index.vue index fb2fe4e8..5a7197f2 100644 --- a/frontend/web/src/views/module_system/role/index.vue +++ b/frontend/web/src/views/module_system/role/index.vue @@ -36,7 +36,9 @@ :perm-delete="['module_system:role:delete']" :perm-patch="['module_system:role:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -360,6 +362,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + const drawerVisible = ref(false); const checkedRole = ref({ id: 0, name: "" }); @@ -466,6 +471,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const opCtx = { onPerm: handleOpenAssignPermDialog, onDetail: (id: number) => void handleOpenDialog("detail", id), @@ -659,7 +673,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -667,12 +681,15 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await RoleAPI.batchRole({ ids, status }); await refreshData(); const userStore = useUserStore(); await userStore.getUserInfo(); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/ticket/index.vue b/frontend/web/src/views/module_system/ticket/index.vue index 72dccef0..eef823a7 100644 --- a/frontend/web/src/views/module_system/ticket/index.vue +++ b/frontend/web/src/views/module_system/ticket/index.vue @@ -53,7 +53,9 @@ :perm-delete="['module_system:ticket:delete']" :perm-patch="['module_system:ticket:patch']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @export="openExport" @delete="handleBatchDelete" @more="handleMoreClick" @@ -358,6 +360,9 @@ const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(n const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } = useTableSelection(); +const createLoading = ref(false); +const moreLoading = ref(false); + // ─── 对话框状态 ─── const { dialogVisible } = useCrudDialog(); @@ -469,6 +474,15 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = use }, }); +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + const ticketDialogFormItems = computed(() => [ { label: "工单标题", @@ -753,7 +767,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -761,10 +775,13 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); + moreLoading.value = true; await TicketAPI.batchTicket({ ids, status }); await refreshData(); } catch { // 用户取消 + } finally { + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_system/user/index.vue b/frontend/web/src/views/module_system/user/index.vue index 75f6650e..c57304a8 100644 --- a/frontend/web/src/views/module_system/user/index.vue +++ b/frontend/web/src/views/module_system/user/index.vue @@ -66,7 +66,9 @@ :perm-patch="['module_system:user:patch']" :import-loading="uploadLoading" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + :more-loading="moreLoading" + @add="handleAdd" @import="openImport" @export="openExport" @delete="handleBatchDelete" @@ -352,6 +354,8 @@ const dataFormRef = ref | null>(null); const userFormRenderKey = ref(0); const submitLoading = ref(false); const uploadLoading = ref(false); +const createLoading = ref(false); +const moreLoading = ref(false); const deptFilterId = ref(undefined); const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "450px" : "90%")); @@ -844,6 +848,15 @@ async function handleCloseDialog() { await resetForm(); } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog(type: "create" | "update" | "detail", id?: number) { dialogVisible.type = type; if (id) { @@ -944,7 +957,7 @@ async function handleBatchDelete() { } } -async function handleMoreClick(status: string) { +async function handleMoreClick(status: number) { const ids = selectedIds.value; if (!ids.length) { ElMessage.warning("请先选择要操作的数据"); @@ -952,13 +965,13 @@ async function handleMoreClick(status: string) { } try { await confirmToggleStatus(status); - batchDeleting.value = true; + moreLoading.value = true; await UserAPI.batchUser({ ids, status }); await refreshData(); } catch { // 用户取消 } finally { - batchDeleting.value = false; + moreLoading.value = false; } } diff --git a/frontend/web/src/views/module_task/cronjob/node/index.vue b/frontend/web/src/views/module_task/cronjob/node/index.vue index 6b061dac..3d7f1e50 100644 --- a/frontend/web/src/views/module_task/cronjob/node/index.vue +++ b/frontend/web/src/views/module_task/cronjob/node/index.vue @@ -35,7 +35,8 @@ :perm-create="['module_task:cronjob:node:create']" :perm-delete="['module_task:cronjob:node:delete']" :delete-loading="batchDeleting" - @add="handleOpenDialog('create')" + :create-loading="createLoading" + @add="handleAdd" @delete="handleBatchDelete" /> @@ -528,6 +529,7 @@ const executeFormRef = ref | null>(null); const nodeFormRenderKey = ref(0); const executeFormRenderKey = ref(0); const submitLoading = ref(false); +const createLoading = ref(false); const openCron = ref(false); const openInterval = ref(false); const codeEditorRef = ref(); @@ -785,6 +787,15 @@ async function handleCloseDialog() { resetForm(); } +async function handleAdd() { + createLoading.value = true; + try { + await handleOpenDialog("create"); + } finally { + createLoading.value = false; + } +} + async function handleOpenDialog(type: "create" | "update", id?: number) { dialogVisible.type = type; if (id) { diff --git a/image.png b/image.png deleted file mode 100644 index 39365120d15c84b81f9d1b4110fab30fd448e46d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24084 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