From dd3ccff514514df5fa905d5f9a58b0d54e37864a Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Mon, 9 Feb 2026 03:11:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E9=80=BB=E8=BE=91;=20=E8=87=AA=E7=A0=94?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E4=B8=8A=E4=BC=A0=E7=AE=97=E6=B3=95;=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3gitignore;=20=E5=88=87=E6=8D=A2=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=8E=AF=E5=A2=83=E4=B8=BAmysql;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/business.mdc | 191 --- .cursor/rules/framework.mdc | 1 + .gitignore | 5 +- hatch.toml | 6 + iti/.env | 2 +- iti/.env.dev | 4 +- iti/.env.prod | 2 +- iti/applications/__init__.py | 36 +- iti/applications/common/crud.py | 4 +- iti/applications/common/enums/__init__.py | 1 + .../common/{enums.py => enums/sys.py} | 0 iti/applications/common/storage/aliyun_oss.py | 6 +- iti/applications/common/storage/huawei_obs.py | 31 +- iti/applications/common/storage/local.py | 16 +- iti/applications/common/storage/manager.py | 46 +- .../common/storage/minio_storage.py | 182 +++ iti/applications/common/storage/qiniu_kodo.py | 17 +- .../common/storage/tencent_cos.py | 5 +- iti/applications/common/utils/__init__.py | 2 + iti/applications/common/utils/schema.py | 49 +- iti/applications/common/utils/time.py | 56 + iti/applications/extensions/error_handler.py | 58 +- .../extensions/eventbus/event_bus.py | 6 + iti/applications/models/__init__.py | 23 +- .../models/{ => sys}/sys_config.py | 0 iti/applications/models/{ => sys}/sys_dept.py | 0 iti/applications/models/{ => sys}/sys_dict.py | 0 iti/applications/models/{ => sys}/sys_file.py | 37 +- iti/applications/models/{ => sys}/sys_log.py | 0 iti/applications/models/{ => sys}/sys_menu.py | 2 + .../models/{ => sys}/sys_rel_role_menu.py | 0 .../models/{ => sys}/sys_rel_user_dept.py | 0 .../models/{ => sys}/sys_rel_user_role.py | 0 iti/applications/models/{ => sys}/sys_role.py | 0 iti/applications/models/{ => sys}/sys_user.py | 78 +- .../models/sys/sys_user_attribute.py | 108 ++ iti/applications/routes/__init__.py | 6 +- iti/applications/routes/common/__init__.py | 6 + iti/applications/routes/common/file_access.py | 167 +++ .../routes/common/schemas/__init__.py | 2 + .../routes/common/schemas/upload.py | 71 + iti/applications/routes/common/upload.py | 205 +++ iti/applications/routes/sys/auth.py | 7 +- iti/applications/routes/sys/config.py | 5 + iti/applications/routes/sys/dept.py | 7 +- iti/applications/routes/sys/dict.py | 13 + iti/applications/routes/sys/file.py | 374 ++---- iti/applications/routes/sys/log.py | 3 + iti/applications/routes/sys/menu.py | 34 +- iti/applications/routes/sys/role.py | 7 +- iti/applications/routes/sys/schemas/auth.py | 2 +- iti/applications/routes/sys/user.py | 9 +- iti/applications/service/__init__.py | 10 + .../service/{ => sys}/sys_config.py | 0 .../service/{ => sys}/sys_dept.py | 2 +- iti/applications/service/sys/sys_file.py | 1137 +++++++++++++++++ .../service/sys/sys_file_config.py | 100 ++ .../service/sys/sys_file_directory.py | 182 +++ iti/applications/service/{ => sys}/sys_log.py | 0 .../service/{ => sys}/sys_menu.py | 8 +- .../service/sys/sys_user_attribute.py | 237 ++++ .../service/{ => sys}/verification_code.py | 0 iti/applications/service/sys_file.py | 373 ------ iti/config.py | 19 +- migrations/versions/.gitkeep | 0 migrations/versions/0c4f1f46e5ea_.py | 42 - migrations/versions/3a1d8599c640_.py | 32 - migrations/versions/3b3e7f8dd32f_.py | 36 - migrations/versions/5409f28814f9_.py | 88 -- migrations/versions/5c84e633f254_.py | 32 - migrations/versions/5eb37be53e1c_.py | 46 - migrations/versions/70dac20262ef_.py | 32 - migrations/versions/83b464bfff02_.py | 50 - migrations/versions/bfa0b0c7c62f_.py | 40 - migrations/versions/c46167ccbf4d_.py | 170 --- migrations/versions/e0addf88b922_.py | 38 - migrations/versions/eba4a4c12851_.py | 32 - migrations/versions/f02e03313631_.py | 38 - migrations/versions/f9f008bd64bf_.py | 149 --- migrations/versions/fffd191071bc_.py | 42 - 80 files changed, 3012 insertions(+), 1815 deletions(-) delete mode 100644 .cursor/rules/business.mdc create mode 100644 iti/applications/common/enums/__init__.py rename iti/applications/common/{enums.py => enums/sys.py} (100%) create mode 100644 iti/applications/common/storage/minio_storage.py create mode 100644 iti/applications/common/utils/time.py rename iti/applications/models/{ => sys}/sys_config.py (100%) rename iti/applications/models/{ => sys}/sys_dept.py (100%) rename iti/applications/models/{ => sys}/sys_dict.py (100%) rename iti/applications/models/{ => sys}/sys_file.py (83%) rename iti/applications/models/{ => sys}/sys_log.py (100%) rename iti/applications/models/{ => sys}/sys_menu.py (99%) rename iti/applications/models/{ => sys}/sys_rel_role_menu.py (100%) rename iti/applications/models/{ => sys}/sys_rel_user_dept.py (100%) rename iti/applications/models/{ => sys}/sys_rel_user_role.py (100%) rename iti/applications/models/{ => sys}/sys_role.py (100%) rename iti/applications/models/{ => sys}/sys_user.py (68%) create mode 100644 iti/applications/models/sys/sys_user_attribute.py create mode 100644 iti/applications/routes/common/__init__.py create mode 100644 iti/applications/routes/common/file_access.py create mode 100644 iti/applications/routes/common/schemas/__init__.py create mode 100644 iti/applications/routes/common/schemas/upload.py create mode 100644 iti/applications/routes/common/upload.py create mode 100644 iti/applications/service/__init__.py rename iti/applications/service/{ => sys}/sys_config.py (100%) rename iti/applications/service/{ => sys}/sys_dept.py (99%) create mode 100644 iti/applications/service/sys/sys_file.py create mode 100644 iti/applications/service/sys/sys_file_config.py create mode 100644 iti/applications/service/sys/sys_file_directory.py rename iti/applications/service/{ => sys}/sys_log.py (100%) rename iti/applications/service/{ => sys}/sys_menu.py (92%) create mode 100644 iti/applications/service/sys/sys_user_attribute.py rename iti/applications/service/{ => sys}/verification_code.py (100%) delete mode 100644 iti/applications/service/sys_file.py create mode 100644 migrations/versions/.gitkeep delete mode 100644 migrations/versions/0c4f1f46e5ea_.py delete mode 100644 migrations/versions/3a1d8599c640_.py delete mode 100644 migrations/versions/3b3e7f8dd32f_.py delete mode 100644 migrations/versions/5409f28814f9_.py delete mode 100644 migrations/versions/5c84e633f254_.py delete mode 100644 migrations/versions/5eb37be53e1c_.py delete mode 100644 migrations/versions/70dac20262ef_.py delete mode 100644 migrations/versions/83b464bfff02_.py delete mode 100644 migrations/versions/bfa0b0c7c62f_.py delete mode 100644 migrations/versions/c46167ccbf4d_.py delete mode 100644 migrations/versions/e0addf88b922_.py delete mode 100644 migrations/versions/eba4a4c12851_.py delete mode 100644 migrations/versions/f02e03313631_.py delete mode 100644 migrations/versions/f9f008bd64bf_.py delete mode 100644 migrations/versions/fffd191071bc_.py diff --git a/.cursor/rules/business.mdc b/.cursor/rules/business.mdc deleted file mode 100644 index 3aefe32..0000000 --- a/.cursor/rules/business.mdc +++ /dev/null @@ -1,191 +0,0 @@ ---- -alwaysApply: true ---- - -# 后端业务规则 (冲压排产系统) - -## 业务概述 - -本系统是**冲压排产系统**,用于将工单中的翅片冲压任务合理分配到不同的冲压设备上,同时协调模具使用和调模工的工作安排。 - -业务参考项目:`PunchSchedule` 目录 - -参考文档: -- @file:PunchSchedule/docs/README.md -- @file:PunchSchedule/docs/01-业务领域概述.md -- @file:PunchSchedule/docs/02-数据模型设计.md -- @file:PunchSchedule/docs/03-排产算法详解.md -- @file:PunchSchedule/docs/04-约束规则说明.md - -## 核心业务实体 - -### 1. 工单 (ManufacturingOrder) - -**定义**: 生产任务的顶层容器,来源于ERP系统 - -**关键属性**: 工单号、组件号、组件名称、计划数量、单件重量、翅片任务列表 - -**业务规则**: -- 工单号必须唯一 -- 一个工单可包含多个不同规格的翅片任务 -- 支持按组件进行任务分组 - -**参考代码**: -- @file:PunchSchedule/models/manufacturing_order.py - -### 2. 翅片 (Fin) - -**定义**: 具体的生产任务单位,定义了详细的加工要求 - -**关键属性**: 名称、冲压类型、数量、翅片类型、尺寸参数(高度、宽度、长度、波距、料厚)、计划开始时间、工时、ERP工序报告号、未排产原因 - -**业务规则**: -- 翅片类型和波距决定模具选择 -- 冲压类型决定设备兼容性 -- 尺寸参数影响设备选择和约束验证 -- BAT产品特殊处理:产品名包含"BAT"且冲压类型为"BST14"时,应转换为"BST2B2" - -### 3. 设备 (Machine/Equipment) - -**定义**: 冲压设备是生产的物理载体,具有特定的能力和约束 - -**关键属性**: 设备类型、设备编号、初始状态信息、允许的翅片类型/高度、波距范围限制、最大翅片长度限制 - -**业务规则**: -- 设备类型必须与翅片冲压类型匹配 -- 支持设备专业化配置 -- 初始状态用于处理排产开始时的在制品 - -**参考代码**: -- @file:PunchSchedule/models/machine.py - -### 4. 模具 (Module) - -**定义**: 模具是生产翅片的专用工装,决定了产品的规格 - -**关键属性**: 模具编号、模具类型(整体模具/分体模具)、适用的翅片类型、波距规格 - -**业务规则**: -- 模具与翅片通过翅片类型和波距精确匹配 -- 整体模具优先级高于分体模具 -- 模具在使用期间不能被其他设备占用 - -### 5. 调模工 (ModuleChanger) - -**定义**: 调模工负责执行换模和调模操作 - -**关键属性**: 调模工编号、姓名、班次信息、工作时间 - -**业务规则**: -- 分为白班和夜班两种班次 -- 每个调模工在排产周期内有多个班次 -- 支持跨天夜班处理(如21:00-次日05:30) - -### 6. 生产计划 (Plan) - -**定义**: 生产计划将翅片任务分配到具体设备上执行 - -**关键属性**: 工单、翅片、设备、模具、排产开始/结束时间、休息时间、调模工、调模任务 - -**业务规则**: -- 时间计算自动跳过休息时间 -- 支持关联调模任务 -- 计划按时间顺序排列 -- 同一设备上的计划时间不能重叠 - -### 7. 调模任务 (ModuleChangeMission) - -**定义**: 调模任务是调模工执行的具体操作 - -**关键属性**: 调模类型(fix/change)、调模时间、开始/结束时间、目标设备、目标计划、新旧模具 - -**业务规则**: -- fix调模约20分钟,change换模约120分钟 -- 调模时间会根据工时系数调整(默认0.6) -- 自动处理时间冲突和后续任务调整 -- 调模任务必须在调模工的工作时间内 - -## 业务约束规则 - -### 设备约束 - -**硬约束**: -1. 冲压类型匹配:设备类型必须与翅片冲压类型完全匹配 -2. 翅片长度限制:翅片长度不能超过设备的最大加工长度 -3. 翅片类型限制:如果设备配置了允许的翅片类型,翅片类型必须在列表中 -4. 翅片高度限制:如果设备配置了允许的翅片高度,翅片高度必须在列表中 -5. 波距范围限制:翅片波距必须在设备的加工范围内(min_fin_pitch <= pitch <= max_fin_pitch) - -**软约束**: -- 设备专业化优先:在多个设备都兼容的情况下,优先选择专业化设备 - -参考文档: -- @file:PunchSchedule/docs/04-约束规则说明.md - -### 模具约束 - -**硬约束**: -1. 翅片类型匹配:模具的翅片类型必须与任务要求完全匹配 -2. 波距精确匹配:模具的波距必须与翅片要求精确匹配 -3. 模具可用性:模具在使用时间段内不被其他任务占用 - -**软约束**: -- 整体模具优先:在多个模具都匹配的情况下,优先选择整体模具 - -### 时间约束 - -**硬约束**: -1. 排产时间窗口:所有任务必须在排产时间范围内 -2. 休息时间跳过:任务执行时间自动跳过所有休息时间段 -3. 时间顺序约束:同一设备上前一任务结束时间 <= 后一任务开始时间 -4. 调模时间约束:调模任务必须在调模工的工作时间内 - -**软约束**: -- 计划开始时间优先:优先安排计划开始时间较早的任务 - -### 人员约束 - -**硬约束**: -1. 班次时间限制:调模工只能在其班次时间内工作 -2. 同时性约束:一个调模工同一时间只能执行一个任务 - -**软约束**: -- 负载均衡:尽可能平均分配调模工的工作量 - -## 排产算法 - -系统采用四步递进式算法: - -1. **步骤0**: 初始化设备状态(处理排产开始时设备上正在进行的任务,建立初始状态) -2. **步骤1**: 贪心填充相同规格任务(为每台设备连续填充与最后任务相同规格的翅片任务,最小化换模次数) -3. **步骤2**: 调模排产(寻找可以通过调模继续生产的任务,在不换模具的情况下扩大排产范围) -4. **步骤3**: 换模排产(为无法通过调模解决的任务寻找新模具,实现更大范围的排产) - -算法特点: -- 递进式设计:从简单到复杂,逐步扩大排产范围 -- 贪心优化:每个步骤都追求局部最优 -- 资源协调:统筹考虑设备、模具、人员资源 -- 时间优化:最小化换模时间,最大化生产时间 - -参考文档: -- @file:PunchSchedule/docs/03-排产算法详解.md -- @file:PunchSchedule/calc.py - -## 业务逻辑实现注意事项 - -1. **事务管理**: 排产计算应在事务中执行,失败时回滚 -2. **性能优化**: 大量数据时使用批量查询,避免N+1问题 -3. **缓存策略**: 设备兼容性检查结果可以缓存 -4. **并发控制**: 排产计算时使用锁机制,避免并发修改 -5. **错误处理**: 详细的错误信息,便于诊断问题 -6. **日志记录**: 排产过程的关键步骤都要记录日志 -7. **数据验证**: 输入数据必须经过完整验证 -8. **时间处理**: 统一使用 datetime 对象,注意时区 - -## 开发优先级建议 - -1. **第一阶段**: 基础数据模型和CRUD接口(设备、模具、调模工、工单、翅片) -2. **第二阶段**: 约束验证逻辑实现 -3. **第三阶段**: 排产算法核心实现(步骤0-3) -4. **第四阶段**: 计划调整和优化功能 -5. **第五阶段**: 看板和报表接口 diff --git a/.cursor/rules/framework.mdc b/.cursor/rules/framework.mdc index 8127bd8..d425187 100644 --- a/.cursor/rules/framework.mdc +++ b/.cursor/rules/framework.mdc @@ -572,6 +572,7 @@ def create(json_data: CreateRequest): 6. **日志记录**: 重要操作都要记录日志,便于排查问题 7. **类型提示**: 使用 Type Hints 提高代码可读性 8. **代码复用**: 公共逻辑提取到 service 或 utils +9. **AI规则**: AI编写代码时,不要生成任何文档,也不要写繁琐的注释(除非特别需要) ## 测试规范 diff --git a/.gitignore b/.gitignore index 7b7f60c..55ffe1b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,7 @@ Thumbs.db Desktop.ini # 其他 -/.python-version \ No newline at end of file +/.python-version + +# migrations +migrations/versions/*.py \ No newline at end of file diff --git a/hatch.toml b/hatch.toml index 2346768..000fc17 100644 --- a/hatch.toml +++ b/hatch.toml @@ -24,6 +24,8 @@ dependencies = [ "python-dotenv>=1.0.0", "mypy>=1.0.0", "Pillow>=12.0.0", + "pandas>=2.3.3", + "openpyxl>=3.1.5", # 阿里云OSS "oss2>=2.19.1", # 腾讯云COS @@ -78,6 +80,8 @@ dependencies = [ "marshmallow-sqlalchemy>=1.4.0", "marshmallow-dataclass>=8.7.0", "Pillow>=12.0.0", + "pandas>=2.3.3", + "openpyxl>=3.1.5", # 阿里云OSS "oss2>=2.19.1", # 腾讯云COS @@ -129,6 +133,8 @@ dependencies = [ "python-dotenv>=1.0.0", "waitress>=2.1.0", # 跨平台生产服务器(支持 Windows) "Pillow>=12.0.0", + "pandas>=2.3.3", + "openpyxl>=3.1.5", # 阿里云OSS "oss2>=2.19.1", # 腾讯云COS diff --git a/iti/.env b/iti/.env index 9c58d13..4c204f7 100644 --- a/iti/.env +++ b/iti/.env @@ -1,7 +1,7 @@ FLASK_ENV=dev SECRET_KEY=iti-flask JWT_SECRET_KEY=iti-flask -DATABASE_URL=sqlite:///runtime/iti-flask_dev.db +DATABASE_URL=sqlite:///./../runtime/iti-flask_dev.db # 前端相关 # FRONTEND_ENABLED=False # 是否启用前端渲染 diff --git a/iti/.env.dev b/iti/.env.dev index e56a452..06fef2a 100644 --- a/iti/.env.dev +++ b/iti/.env.dev @@ -1,9 +1,7 @@ -BASE_DIR=D:/Projects/iTi/iTi-Flask/iti -# FLASK_ENV=dev SECRET_KEY=iti-flask JWT_SECRET_KEY=iti-flask -DATABASE_URL=sqlite:///${BASE_DIR}/runtime/iti-flask_dev.db +DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4 # 前端相关 FRONTEND_ENABLED=False # 是否启用前端渲染 FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写 diff --git a/iti/.env.prod b/iti/.env.prod index b1d943e..5d16d6d 100644 --- a/iti/.env.prod +++ b/iti/.env.prod @@ -1,7 +1,7 @@ FLASK_ENV=prod SECRET_KEY=zhSYJn577LgxyWDuQboM9wX3j2BHEFUP JWT_SECRET_KEY=8YD37VvM3WgdpmKNt7kVFNbKnya4hBRh -DATABASE_URL=sqlite:///runtime/iti-flask_dev.db +DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4 # 前端相关 FRONTEND_ENABLED=False # 是否启用前端渲染 FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写 \ No newline at end of file diff --git a/iti/applications/__init__.py b/iti/applications/__init__.py index 5de7542..2ccd770 100644 --- a/iti/applications/__init__.py +++ b/iti/applications/__init__.py @@ -1,5 +1,9 @@ import os +import warnings from apiflask import APIFlask + +from iti.applications.common.utils.schema import custom_schema_name_resolver +from iti.applications.service import init_services from .extensions import init_exts from .routes import init_routes from ..config import get_config @@ -20,6 +24,14 @@ def create_app(config_name=None): docs_ui: The UI of API documentation, one of `swagger-ui` (default), `redoc`, `elements`, `rapidoc`, and `rapipdf`. """ + # 忽略 apispec 的 schema 名称冲突警告 + warnings.filterwarnings( + "ignore", + message="Multiple schemas resolved to the name", + category=UserWarning, + module="apispec.ext.marshmallow.openapi", + ) + app = APIFlask( __name__.split(".")[0], title="iTi-Flask", @@ -32,9 +44,28 @@ def create_app(config_name=None): config_obj = get_config(config_name) app.config.from_object(config_obj) + # 配置自定义 schema 名称解析器 + # 参考:https://zh.apiflask.com/schema/#%E6%A8%A1%E5%BC%8F%E5%90%8D%E7%A7%B0%E8%A7%A3%E6%9E%90%E5%99%A8 + # 用于解决循环引用和嵌套 schema 导致的命名冲突警告 + app.schema_name_resolver = custom_schema_name_resolver + # 确保必要的目录存在 _ensure_directories(app) + # 使用第三方JWT,自定义Security,避免doc无法传递header + # 等同于 SECURITY_SCHEMES 配置 + app.security_schemes = { + "JWT": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + } + } + + # 保护doc文档(鉴权后才可访问) + # app.config['SPEC_DECORATORS'] = [jwt_required()] + # app.config['DOCS_DECORATORS'] = [jwt_required()] + # 初始化扩展 init_exts(app) @@ -44,6 +75,9 @@ def create_app(config_name=None): # 初始化路由 init_routes(app) + # 初始化Services + init_services(app) + # 打印当前环境信息 env = config_name or os.getenv("FLASK_ENV", "dev") print(f"🚀 应用启动 - 环境: {env}") @@ -66,4 +100,4 @@ def _ensure_directories(app): local_config = file_storage_config.get("LOCAL", {}) local_path = local_config.get("base_path") if local_path: - os.makedirs(local_path, exist_ok=True) \ No newline at end of file + os.makedirs(local_path, exist_ok=True) diff --git a/iti/applications/common/crud.py b/iti/applications/common/crud.py index 066adc7..5f8923c 100644 --- a/iti/applications/common/crud.py +++ b/iti/applications/common/crud.py @@ -29,7 +29,7 @@ class AuditModelMixin(object): def get_current_user_identity(): verify_jwt_in_request(True) - return current_user if current_user else None + return current_user.id if current_user else None created_by: Mapped[Optional[str]] = mapped_column( db.String(36), @@ -83,7 +83,7 @@ class RemarkModelMixin(object): ) -class BaseModelMixin(db.Model, IdModelMixin, TimeModelMixin, RemarkModelMixin): +class BaseModelMixin(db.Model, IdModelMixin, TimeModelMixin, RemarkModelMixin, AuditModelMixin): """ 基础模型混入类 """ diff --git a/iti/applications/common/enums/__init__.py b/iti/applications/common/enums/__init__.py new file mode 100644 index 0000000..2cb6622 --- /dev/null +++ b/iti/applications/common/enums/__init__.py @@ -0,0 +1 @@ +from .sys import * \ No newline at end of file diff --git a/iti/applications/common/enums.py b/iti/applications/common/enums/sys.py similarity index 100% rename from iti/applications/common/enums.py rename to iti/applications/common/enums/sys.py diff --git a/iti/applications/common/storage/aliyun_oss.py b/iti/applications/common/storage/aliyun_oss.py index cae2b60..c61e71a 100644 --- a/iti/applications/common/storage/aliyun_oss.py +++ b/iti/applications/common/storage/aliyun_oss.py @@ -28,12 +28,14 @@ class AliyunOSSStorage(StorageInterface): self.bucket = oss2.Bucket(auth, self.endpoint, self.bucket_name) def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: - """上传文件到阿里云OSS""" + """上传文件到阿里云OSS(流式上传,避免内存溢出)""" headers = {} if mime_type: headers["Content-Type"] = mime_type - result = self.bucket.put_object(key, file_stream.read(), headers=headers) + # OSS SDK 的 put_object 支持传入文件对象,会自动流式上传 + # 不需要调用 read() 一次性读取到内存 + result = self.bucket.put_object(key, file_stream, headers=headers) return { "etag": result.etag, diff --git a/iti/applications/common/storage/huawei_obs.py b/iti/applications/common/storage/huawei_obs.py index 81785d6..0144f58 100644 --- a/iti/applications/common/storage/huawei_obs.py +++ b/iti/applications/common/storage/huawei_obs.py @@ -33,16 +33,35 @@ class HuaweiOBSStorage(StorageInterface): ) def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: - """上传文件到华为云OBS""" - data = file_stream.read() - - resp = self.client.putContent( + """上传文件到华为云OBS(流式上传,避免内存溢出)""" + # 华为云 OBS SDK 支持传入文件对象进行流式上传 + resp = self.client.putFile( bucketName=self.bucket_name, objectKey=key, - content=data, - contentType=mime_type, + file_path=None, # 不使用文件路径 + content_type=mime_type, ) + # 如果 putFile 不支持流,则使用 putContent(需要读取) + # 但我们可以分块读取来减少内存压力 + if resp is None or resp.status >= 300: + # 使用分块读取方式 + BUFFER_SIZE = 8 * 1024 * 1024 # 8MB + chunks = [] + while True: + chunk = file_stream.read(BUFFER_SIZE) + if not chunk: + break + chunks.append(chunk) + + data = b''.join(chunks) + resp = self.client.putContent( + bucketName=self.bucket_name, + objectKey=key, + content=data, + contentType=mime_type, + ) + if resp.status >= 300: raise Exception(f"华为云OBS上传失败: {resp.errorMessage}") diff --git a/iti/applications/common/storage/local.py b/iti/applications/common/storage/local.py index 0ed51b9..aa29ee6 100644 --- a/iti/applications/common/storage/local.py +++ b/iti/applications/common/storage/local.py @@ -22,14 +22,20 @@ class LocalStorage(StorageInterface): return os.path.join(self.base_path, key) def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: - """上传文件到本地""" + """上传文件到本地(流式写入,避免内存溢出)""" # 本地文件存储需要移除前缀 key = key.lstrip(self.storage_type + ":") abs_path = self._abs_path(key) os.makedirs(os.path.dirname(abs_path), exist_ok=True) + # 流式写入,每次读取 8MB 缓冲区 + BUFFER_SIZE = 8 * 1024 * 1024 # 8MB with open(abs_path, "wb") as f: - f.write(file_stream.read()) + while True: + buffer = file_stream.read(BUFFER_SIZE) + if not buffer: + break + f.write(buffer) file_size = os.path.getsize(abs_path) return { @@ -49,6 +55,8 @@ class LocalStorage(StorageInterface): def download(self, key: str) -> BinaryIO: """下载文件""" + # 本地文件存储需要移除前缀 + key = key.lstrip(self.storage_type + ":") abs_path = self._abs_path(key) if not os.path.exists(abs_path): raise FileNotFoundError(f"文件不存在: {key}") @@ -56,12 +64,16 @@ class LocalStorage(StorageInterface): def delete(self, key: str) -> None: """删除文件""" + # 本地文件存储需要移除前缀 + key = key.lstrip(self.storage_type + ":") abs_path = self._abs_path(key) if os.path.exists(abs_path): os.remove(abs_path) def exists(self, key: str) -> bool: """检查文件是否存在""" + # 本地文件存储需要移除前缀 + key = key.lstrip(self.storage_type + ":") abs_path = self._abs_path(key) return os.path.exists(abs_path) diff --git a/iti/applications/common/storage/manager.py b/iti/applications/common/storage/manager.py index dd6bf4c..49b75bd 100644 --- a/iti/applications/common/storage/manager.py +++ b/iti/applications/common/storage/manager.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Dict, Optional +from typing import Dict, Optional, Union from flask import current_app @@ -16,23 +16,46 @@ class StorageManager: _instances: Dict[str, StorageInterface] = {} @classmethod - def get_storage(cls, storage_type: Optional[str] = None) -> StorageInterface: + def get_storage(cls, storage_type: Optional[Union[str, StorageTypeEnum]] = None) -> StorageInterface: """ 获取存储实例(单例模式) Args: - storage_type: 存储类型,为None时使用默认类型 + storage_type: 存储类型(支持字符串或 StorageTypeEnum),为None时使用默认类型 Returns: 存储实例 """ - config = current_app.config.get("FILE_STORAGE", {}) - default_type = config.get("DEFAULT_STORAGE_TYPE", StorageTypeEnum.LOCAL.value) - storage_type = storage_type or default_type + # 标准化存储类型为字符串 + storage_type_str = cls._normalize_storage_type(storage_type) + + if storage_type_str not in cls._instances: + config = current_app.config.get("FILE_STORAGE", {}) + cls._instances[storage_type_str] = cls._create_storage(storage_type_str, config) + return cls._instances[storage_type_str] - if storage_type not in cls._instances: - cls._instances[storage_type] = cls._create_storage(storage_type, config) - return cls._instances[storage_type] + @staticmethod + def _normalize_storage_type(storage_type: Optional[Union[str, StorageTypeEnum]]) -> str: + """ + 标准化存储类型为字符串 + + Args: + storage_type: 存储类型(字符串或 StorageTypeEnum) + + Returns: + 存储类型字符串 + """ + # 如果未指定,使用默认类型 + if storage_type is None: + config = current_app.config.get("FILE_STORAGE", {}) + return config.get("DEFAULT_STORAGE_TYPE", StorageTypeEnum.LOCAL.value) + + # 如果是 enum,转换为字符串 + if isinstance(storage_type, StorageTypeEnum): + return storage_type.value + + # 已经是字符串,直接返回 + return storage_type @staticmethod def _create_storage(storage_type: str, config: dict) -> StorageInterface: @@ -79,8 +102,9 @@ class StorageManager: return HuaweiOBSStorage(obs_config) elif storage_type == StorageTypeEnum.MINIO.value: - # MinIO 可以后续添加 - raise NotImplementedError("MinIO 适配器尚未实现") + from .minio_storage import MinIOStorage + minio_config = config.get("MINIO", {}) + return MinIOStorage(minio_config) raise ValueError(f"未支持的存储类型: {storage_type}") diff --git a/iti/applications/common/storage/minio_storage.py b/iti/applications/common/storage/minio_storage.py new file mode 100644 index 0000000..beafca3 --- /dev/null +++ b/iti/applications/common/storage/minio_storage.py @@ -0,0 +1,182 @@ +"""MinIO 对象存储适配器""" +from __future__ import annotations + +from typing import BinaryIO, Dict, Optional +from io import BytesIO + +from .interface import StorageInterface + + +class MinIOStorage(StorageInterface): + """MinIO 对象存储适配器""" + + storage_type = "minio" + + def __init__(self, config: dict): + """ + 初始化 MinIO 存储 + + Args: + config: 配置字典,包含以下字段: + - endpoint: MinIO 服务地址(如 localhost:9000) + - access_key: Access Key + - secret_key: Secret Key + - bucket: 存储桶名称 + - secure: 是否使用 HTTPS(默认 False) + - region: 区域(可选) + """ + try: + from minio import Minio + except ImportError: + raise ImportError( + "MinIO 存储需要安装 minio 库: pip install minio" + ) + + self.endpoint = config.get("endpoint") + self.access_key = config.get("access_key") + self.secret_key = config.get("secret_key") + self.bucket = config.get("bucket") + self.secure = config.get("secure", False) + self.region = config.get("region") + + if not all([self.endpoint, self.access_key, self.secret_key, self.bucket]): + raise ValueError( + "MinIO 存储需要配置: endpoint, access_key, secret_key, bucket" + ) + + # 创建 MinIO 客户端 + self.client = Minio( + self.endpoint, + access_key=self.access_key, + secret_key=self.secret_key, + secure=self.secure, + region=self.region, + ) + + # 确保存储桶存在 + if not self.client.bucket_exists(self.bucket): + self.client.make_bucket(self.bucket, location=self.region) + + def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: + """上传文件到 MinIO(流式上传,避免内存溢出)""" + # 移除存储类型前缀 + key = key.lstrip(self.storage_type + ":") + + # 获取文件大小(如果无法获取,使用 -1 让 MinIO 自动处理) + try: + current_pos = file_stream.tell() + file_stream.seek(0, 2) # 移动到文件末尾 + file_size = file_stream.tell() + file_stream.seek(current_pos) # 恢复原位置 + except (OSError, IOError): + # 如果流不支持 seek,使用 -1(MinIO 会使用分片上传) + file_size = -1 + + # 上传文件(MinIO SDK 会自动流式上传,不会一次性读取到内存) + result = self.client.put_object( + self.bucket, + key, + file_stream, + file_size, + content_type=mime_type or "application/octet-stream", + ) + + return { + "bucket": self.bucket, + "key": key, + "etag": result.etag, + "version_id": result.version_id, + } + + def append_chunk(self, key: str, chunk_stream: BinaryIO, offset: int) -> None: + """ + 追加写入数据块 + + 注意:MinIO 不支持追加写入,此方法用于分片上传的临时实现 + 实际使用时建议在内存或本地临时文件中合并后再上传 + """ + raise NotImplementedError( + "MinIO 不支持追加写入,请使用分片上传后合并的方式" + ) + + def download(self, key: str) -> BinaryIO: + """从 MinIO 下载文件""" + key = key.lstrip(self.storage_type + ":") + + try: + response = self.client.get_object(self.bucket, key) + data = response.read() + response.close() + response.release_conn() + return BytesIO(data) + except Exception as e: + raise FileNotFoundError(f"文件不存在: {key}") from e + + def delete(self, key: str) -> None: + """从 MinIO 删除文件""" + key = key.lstrip(self.storage_type + ":") + + try: + self.client.remove_object(self.bucket, key) + except Exception as e: + raise FileNotFoundError(f"文件不存在: {key}") from e + + def exists(self, key: str) -> bool: + """检查文件是否存在""" + key = key.lstrip(self.storage_type + ":") + + try: + self.client.stat_object(self.bucket, key) + return True + except Exception: + return False + + def get_url(self, key: str, expires: int = 3600) -> str: + """ + 获取文件访问 URL(预签名 URL) + + Args: + key: 对象键 + expires: 过期时间(秒),0 表示永久(实际会使用最大值 7 天) + + Returns: + 预签名 URL + """ + from datetime import timedelta + + key = key.lstrip(self.storage_type + ":") + + # MinIO 预签名 URL 最长 7 天 + if expires == 0 or expires > 7 * 24 * 3600: + expires = 7 * 24 * 3600 + + url = self.client.presigned_get_object( + self.bucket, + key, + expires=timedelta(seconds=expires), + ) + + return url + + def get_preview_url(self, key: str, expires: int = 3600) -> str: + """获取预览 URL(与 get_url 相同)""" + return self.get_url(key, expires) + + def get_thumbnail_url( + self, + key: str, + width: int = 200, + height: int = 200, + mode: str = "fit", + expires: int = 3600, + ) -> Optional[str]: + """ + 获取缩略图 URL + + 注意:MinIO 本身不支持图片处理,返回原图 URL + 如需缩略图功能,建议: + 1. 使用后端生成缩略图 + 2. 或在 MinIO 前端配置图片处理服务(如 thumbor) + """ + return self.get_url(key, expires) + diff --git a/iti/applications/common/storage/qiniu_kodo.py b/iti/applications/common/storage/qiniu_kodo.py index dbe629a..0b0d769 100644 --- a/iti/applications/common/storage/qiniu_kodo.py +++ b/iti/applications/common/storage/qiniu_kodo.py @@ -31,10 +31,23 @@ class QiniuKodoStorage(StorageInterface): self.put_data = put_data def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: - """上传文件到七牛云Kodo""" + """上传文件到七牛云Kodo(优化大文件上传)""" token = self.auth.upload_token(self.bucket_name, key) - data = file_stream.read() + # 对于大文件,使用分块读取来减少内存压力 + # 七牛 SDK 的 put_data 需要完整数据,所以我们分块读取后组合 + BUFFER_SIZE = 8 * 1024 * 1024 # 8MB + chunks = [] + total_size = 0 + + while True: + chunk = file_stream.read(BUFFER_SIZE) + if not chunk: + break + chunks.append(chunk) + total_size += len(chunk) + + data = b''.join(chunks) ret, info = self.put_data(token, key, data, mime_type=mime_type) if info.status_code != 200: diff --git a/iti/applications/common/storage/tencent_cos.py b/iti/applications/common/storage/tencent_cos.py index 8d1f98f..e202c85 100644 --- a/iti/applications/common/storage/tencent_cos.py +++ b/iti/applications/common/storage/tencent_cos.py @@ -34,11 +34,12 @@ class TencentCOSStorage(StorageInterface): self.client = CosS3Client(cos_config) def upload(self, file_stream: BinaryIO, key: str, mime_type: Optional[str] = None) -> Dict: - """上传文件到腾讯云COS""" + """上传文件到腾讯云COS(流式上传,避免内存溢出)""" + # 腾讯云 COS SDK 支持传入文件对象,会自动流式上传 response = self.client.put_object( Bucket=self.bucket_name, Key=key, - Body=file_stream.read(), + Body=file_stream, # 直接传入流对象,SDK会自动处理 ContentType=mime_type or "application/octet-stream", ) diff --git a/iti/applications/common/utils/__init__.py b/iti/applications/common/utils/__init__.py index f99c09c..2ed18a6 100644 --- a/iti/applications/common/utils/__init__.py +++ b/iti/applications/common/utils/__init__.py @@ -11,6 +11,7 @@ from .schema import ( page_schema, condition_schema, BaseSchema, + custom_schema_name_resolver ) from .tree import ( build_tree_from_list, @@ -23,3 +24,4 @@ from .tree import ( default_key_config, ) from .str import camel_case +from .time import parse_datetime_string diff --git a/iti/applications/common/utils/schema.py b/iti/applications/common/utils/schema.py index d36e2aa..2cb3159 100644 --- a/iti/applications/common/utils/schema.py +++ b/iti/applications/common/utils/schema.py @@ -138,5 +138,52 @@ def condition_schema(base_schema_cls: type, control_config): return temp_schema.dump(obj, many=many, **kwargs) else: return super().dump(obj, many=many, **kwargs) - + return _ConditionSchema + + +def custom_schema_name_resolver(schema): + """ + 自定义 schema 名称解析器,解决循环引用导致的命名冲突 + + 根据 APIFlask 官方文档: + - 函数接收一个 schema 对象作为参数 + - 返回一个字符串作为 schema 的名称 + - 用于解决多个 schema 解析为相同名称的问题 + + 处理策略: + 1. 优先使用 Meta.name(如果定义) + 2. 自动移除 Schema 后缀 + 3. 为带有 exclude 参数的嵌套 schema 生成唯一名称 + """ + schema_class = schema.__class__ + + # 1. 优先检查是否在 Meta 中定义了 name + if hasattr(schema_class, "Meta") and hasattr(schema_class.Meta, "name"): + base_name = schema_class.Meta.name + else: + # 2. 使用类名,移除 Schema 后缀 + base_name = schema_class.__name__ + if base_name.endswith("Schema"): + base_name = base_name[:-6] + if schema.partial: # 为部分模式添加 "Update" 后缀 + base_name += "Update" + + # 3. 处理嵌套时的 exclude 参数 + # 当使用 Nested("SomeSchema", exclude=["field1", "field2"]) 时 + # apispec 会创建新的 schema 实例,需要为其生成唯一名称 + if hasattr(schema, "exclude") and schema.exclude: + # 将 exclude 的字段排序,确保相同的 exclude 组合生成相同的名称 + excluded_fields = sorted(schema.exclude) + # 生成简洁的后缀:首字母大写拼接 + # 例如:exclude=["children", "parent"] -> "ChildrenParent" + suffix = "".join([field.capitalize() for field in excluded_fields]) + return f"{base_name}Exclude{suffix}" + + # 4. 处理 only 参数(如果使用) + if hasattr(schema, "only") and schema.only: + only_fields = sorted(schema.only) + suffix = "".join([field.capitalize() for field in only_fields]) + return f"{base_name}Only{suffix}" + + return base_name diff --git a/iti/applications/common/utils/time.py b/iti/applications/common/utils/time.py new file mode 100644 index 0000000..ae60c97 --- /dev/null +++ b/iti/applications/common/utils/time.py @@ -0,0 +1,56 @@ +""" +时间处理工具 +""" +from datetime import datetime + + +def parse_datetime_string(datetime_str: str) -> datetime: + """ + 解析时间字符串为 datetime 对象 + + 优先尝试前端常用的格式 %Y-%m-%d %H:%M:%S,然后尝试 ISO 格式 + + Args: + datetime_str: 时间字符串 + + Returns: + datetime 对象 + + Raises: + ValueError: 如果无法解析时间字符串 + """ + if not datetime_str: + raise ValueError("时间字符串不能为空") + + + # 优先尝试常用格式 + for fmt in ( + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + "%Y/%m/%d %H:%M:%S", + "%Y/%m/%d %H:%M", + "%Y/%m/%d", + ): + try: + return datetime.strptime(datetime_str, fmt) + except (ValueError, TypeError): + continue + + # 尝试 ISO 格式(带时区) + try: + # 处理带 Z 后缀的 ISO 格式 + iso_str = datetime_str.replace("Z", "+00:00") + return datetime.fromisoformat(iso_str) + except ValueError: + pass + + # 尝试 ISO 格式(不带时区) + try: + return datetime.fromisoformat(datetime_str) + except ValueError: + pass + + # 如果所有格式都失败,抛出异常 + raise ValueError(f"无法解析时间字符串: {datetime_str}") + diff --git a/iti/applications/extensions/error_handler.py b/iti/applications/extensions/error_handler.py index 03abafa..ec1afdc 100644 --- a/iti/applications/extensions/error_handler.py +++ b/iti/applications/extensions/error_handler.py @@ -15,15 +15,39 @@ def init_error_handler(app: APIFlask): """ 未处理的异常处理 """ - app.logger.error("服务器错误", error, stack_info=True) - return fail(message="服务器错误", code=500, data=str(error)) + try: + # 安全地获取错误信息 + error_msg = str(error) + error_type = type(error).__name__ + app.logger.error( + f"服务器错误 [{error_type}]: {error_msg}", + exc_info=True # 使用 exc_info 代替 stack_info,更安全 + ) + except Exception as log_error: + # 如果日志记录失败,使用最基本的方式记录 + try: + app.logger.error(f"服务器错误(日志记录失败): {type(error).__name__}") + except: + pass # 完全失败则放弃日志记录 + + # 安全地返回错误信息 + try: + error_data = str(error) + except: + error_data = type(error).__name__ + + return fail(message="服务器错误", code=500, data=error_data) @app.errorhandler(400) def handle_400(error): """ 参数错误 """ - app.logger.error("参数错误", error, stack_info=True) + try: + app.logger.error(f"参数错误: {error}", exc_info=True) + except: + app.logger.error("参数错误(日志记录失败)") + return fail( message=error.data.message if error.data and "message" in error.data @@ -37,7 +61,10 @@ def init_error_handler(app: APIFlask): """ 业务异常处理 """ - app.logger.error(f"业务异常: {error}") + try: + app.logger.error(f"业务异常: {error}") + except: + app.logger.error("业务异常(日志记录失败)") return fail(error.message, code=error.code, data=error.data) @app.errorhandler(PermissionDeniedException) @@ -45,7 +72,10 @@ def init_error_handler(app: APIFlask): """ 权限不足异常处理 """ - app.logger.error(f"权限不足: {error}") + try: + app.logger.error(f"权限不足: {error}") + except: + app.logger.error("权限不足(日志记录失败)") return fail(message=error.message, code=error.code) @app.error_processor @@ -53,13 +83,17 @@ def init_error_handler(app: APIFlask): """ http异常处理 """ - if isinstance(error, _ValidationError): - app.logger.error(f"参数验证错误: {error.detail}") - error.message = "参数验证错误" - else: - app.logger.error( - f"HTTP异常: {error.message} {error.status_code} {error.detail} {error.headers} {error.extra_data}" - ) + try: + if isinstance(error, _ValidationError): + app.logger.error(f"参数验证错误: {error.detail}") + error.message = "参数验证错误" + else: + app.logger.error( + f"HTTP异常: {error.message} {error.status_code} {error.detail} {error.headers} {error.extra_data}" + ) + except: + app.logger.error("HTTP异常(日志记录失败)") + return ( fail(message=error.message, code=error.status_code, data=error.detail), 200, diff --git a/iti/applications/extensions/eventbus/event_bus.py b/iti/applications/extensions/eventbus/event_bus.py index 3653413..88045ce 100644 --- a/iti/applications/extensions/eventbus/event_bus.py +++ b/iti/applications/extensions/eventbus/event_bus.py @@ -180,9 +180,15 @@ class EventBus: """ from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm.exc import UnmappedInstanceError + from werkzeug.local import LocalProxy result = [] for item in items: + # 跳过 LocalProxy 对象(如 current_user),避免触发 JWT 上下文检查 + if isinstance(item, LocalProxy): + result.append(item) + continue + # 检测是否是 SQLAlchemy ORM 对象 if hasattr(item, "__table__"): try: diff --git a/iti/applications/models/__init__.py b/iti/applications/models/__init__.py index 912fcfd..595c3a0 100644 --- a/iti/applications/models/__init__.py +++ b/iti/applications/models/__init__.py @@ -1,11 +1,12 @@ -from .sys_user import User, UserSchema -from .sys_role import Role, RoleSchema -from .sys_rel_user_role import sys_user_role -from .sys_rel_role_menu import sys_role_menu -from .sys_log import SysLog, LogSchema -from .sys_config import SysConfig, SysConfigSchema -from .sys_dict import SysDictType, SysDictTypeSchema, SysDictData, SysDictDataSchema -from .sys_dept import SysDept, SysDeptSchema -from .sys_rel_user_dept import sys_user_dept -from .sys_menu import SysMenu, SysMenuSchema, SysMenuMetaSchema -from .sys_file import SysFile, SysFileSchema, SysFileDirectory, SysFileDirectorySchema +from .sys.sys_user import User, UserSchema +from .sys.sys_user_attribute import SysUserAttribute, SysUserAttributeSchema +from .sys.sys_role import Role, RoleSchema +from .sys.sys_rel_user_role import sys_user_role +from .sys.sys_rel_role_menu import sys_role_menu +from .sys.sys_log import SysLog, LogSchema +from .sys.sys_config import SysConfig, SysConfigSchema +from .sys.sys_dict import SysDictType, SysDictTypeSchema, SysDictData, SysDictDataSchema +from .sys.sys_dept import SysDept, SysDeptSchema +from .sys.sys_rel_user_dept import sys_user_dept +from .sys.sys_menu import SysMenu, SysMenuSchema, SysMenuMetaSchema +from .sys.sys_file import SysFile, SysFileSchema, SysFileDirectory, SysFileDirectorySchema diff --git a/iti/applications/models/sys_config.py b/iti/applications/models/sys/sys_config.py similarity index 100% rename from iti/applications/models/sys_config.py rename to iti/applications/models/sys/sys_config.py diff --git a/iti/applications/models/sys_dept.py b/iti/applications/models/sys/sys_dept.py similarity index 100% rename from iti/applications/models/sys_dept.py rename to iti/applications/models/sys/sys_dept.py diff --git a/iti/applications/models/sys_dict.py b/iti/applications/models/sys/sys_dict.py similarity index 100% rename from iti/applications/models/sys_dict.py rename to iti/applications/models/sys/sys_dict.py diff --git a/iti/applications/models/sys_file.py b/iti/applications/models/sys/sys_file.py similarity index 83% rename from iti/applications/models/sys_file.py rename to iti/applications/models/sys/sys_file.py index e481d64..1ba25e9 100644 --- a/iti/applications/models/sys_file.py +++ b/iti/applications/models/sys/sys_file.py @@ -33,6 +33,17 @@ class SysFile(BaseModelMixin): metadata_ = db.Column("metadata", db.JSON, nullable=True, comment="扩展元数据") + # 回收站相关 + is_deleted = db.Column(db.Boolean, default=False, nullable=False, comment="是否已删除(回收站)") + deleted_at = db.Column(db.DateTime, nullable=True, comment="删除时间") + deleted_by = db.Column(db.String(36), nullable=True, comment="删除人ID") + + # 分享相关 + share_code = db.Column(db.String(64), nullable=True, unique=True, index=True, comment="分享码") + share_password = db.Column(db.String(64), nullable=True, comment="分享密码") + share_expire_at = db.Column(db.DateTime, nullable=True, comment="分享过期时间") + share_count = db.Column(db.Integer, default=0, nullable=False, comment="分享访问次数") + status = db.Column( db.Enum(StatusEnum, values_callable=lambda x: [e.value for e in x]), nullable=False, @@ -54,18 +65,18 @@ class SysFileDirectory(BaseModelMixin): """文件目录""" __tablename__ = "sys_file_directory" + __table_args__ = ( + db.Index('ix_sys_file_directory_path', 'path', mysql_length=255), + ) name = db.Column(db.String(255), nullable=False, comment="目录名称") - path = db.Column(db.String(1024), nullable=False, index=True, comment="完整路径") + path = db.Column(db.String(1024), nullable=False, comment="完整路径") parent_id = db.Column(db.String(36), nullable=True, comment="父目录ID") level = db.Column(db.Integer, default=0, comment="层级") sort = db.Column(db.Integer, default=0, comment="排序") icon = db.Column(db.String(128), nullable=True, comment="目录图标") color = db.Column(db.String(32), nullable=True, comment="颜色标记") description = db.Column(db.Text, nullable=True, comment="目录描述") - default_storage_type = db.Column( - db.String(32), nullable=True, comment="默认存储类型" - ) status = db.Column( db.Enum(StatusEnum, values_callable=lambda x: [e.value for e in x]), nullable=False, @@ -112,6 +123,18 @@ class SysFileSchema(BaseSchema): storage_info = DictField() directory_id = String() metadata_ = DictField(data_key="metadata") + + # 回收站相关 + is_deleted = String() + deleted_at = DateTime(format="%Y-%m-%d %H:%M:%S") + deleted_by = String() + + # 分享相关 + share_code = String() + share_password = String() + share_expire_at = DateTime(format="%Y-%m-%d %H:%M:%S") + share_count = Integer() + status = Enum(StatusEnum, by_value=True) created_at = DateTime(format="%Y-%m-%d %H:%M:%S") updated_at = DateTime(format="%Y-%m-%d %H:%M:%S") @@ -128,14 +151,15 @@ class SysFileSchema(BaseSchema): for key, value in metadata.items(): data.setdefault(key, value) - from ..service.sys_file import SysFileService + from iti.applications.service.sys.sys_file import SysFileService file_id = data.get("id") if file_id: try: data["url"] = SysFileService.get_file_url(file_id) data["previewUrl"] = SysFileService.get_preview_url(file_id) - data["thumbnailUrl"] = SysFileService.get_thumbnail_url(file_id) + # thumbnailUrl 不附带参数,由前端自行添加 + data["thumbnailUrl"] = SysFileService.get_thumbnail_url(file_id, include_params=False) except Exception: data["url"] = None data["previewUrl"] = None @@ -185,7 +209,6 @@ class SysFileDirectorySchema(BaseSchema): icon = String() color = String() description = String() - default_storage_type = String() status = Enum(StatusEnum, by_value=True) created_at = DateTime(format="%Y-%m-%d %H:%M:%S") updated_at = DateTime(format="%Y-%m-%d %H:%M:%S") diff --git a/iti/applications/models/sys_log.py b/iti/applications/models/sys/sys_log.py similarity index 100% rename from iti/applications/models/sys_log.py rename to iti/applications/models/sys/sys_log.py diff --git a/iti/applications/models/sys_menu.py b/iti/applications/models/sys/sys_menu.py similarity index 99% rename from iti/applications/models/sys_menu.py rename to iti/applications/models/sys/sys_menu.py index fa8ecbe..b4acfd3 100644 --- a/iti/applications/models/sys_menu.py +++ b/iti/applications/models/sys/sys_menu.py @@ -119,6 +119,8 @@ class SysMenuSchema(BaseSchema): """ 菜单表响应结构 """ + class Meta: + name = "SysMenu" id = String() name = String() diff --git a/iti/applications/models/sys_rel_role_menu.py b/iti/applications/models/sys/sys_rel_role_menu.py similarity index 100% rename from iti/applications/models/sys_rel_role_menu.py rename to iti/applications/models/sys/sys_rel_role_menu.py diff --git a/iti/applications/models/sys_rel_user_dept.py b/iti/applications/models/sys/sys_rel_user_dept.py similarity index 100% rename from iti/applications/models/sys_rel_user_dept.py rename to iti/applications/models/sys/sys_rel_user_dept.py diff --git a/iti/applications/models/sys_rel_user_role.py b/iti/applications/models/sys/sys_rel_user_role.py similarity index 100% rename from iti/applications/models/sys_rel_user_role.py rename to iti/applications/models/sys/sys_rel_user_role.py diff --git a/iti/applications/models/sys_role.py b/iti/applications/models/sys/sys_role.py similarity index 100% rename from iti/applications/models/sys_role.py rename to iti/applications/models/sys/sys_role.py diff --git a/iti/applications/models/sys_user.py b/iti/applications/models/sys/sys_user.py similarity index 68% rename from iti/applications/models/sys_user.py rename to iti/applications/models/sys/sys_user.py index 1829f90..bcf2be6 100644 --- a/iti/applications/models/sys_user.py +++ b/iti/applications/models/sys/sys_user.py @@ -10,7 +10,7 @@ from .sys_menu import SysMenu from sqlalchemy.orm import joinedload from iti.applications.common.enums import GenderEnum, StatusEnum from iti.applications.common.utils import BaseSchema -from apiflask.fields import String, DateTime, Enum, Nested, List +from apiflask.fields import Dict, String, DateTime, Enum, Nested, List from iti.applications.common.crud import BaseModelMixin from .sys_role import Role @@ -36,7 +36,11 @@ def load_with_cache(identity, exp): dbUser = db.session.scalar( select(User) .filter_by(id=identity) - .options(joinedload(User.roles).noload(Role.menus), joinedload(User.depts)) + .options( + joinedload(User.roles).noload(Role.menus), + joinedload(User.depts), + joinedload(User.user_attributes), + ) ) if dbUser is None: return None @@ -94,6 +98,13 @@ class User(BaseModelMixin): secondaryjoin="and_(SysDept.id == sys_user_dept.c.dept_id, SysDept.status == 'enabled')", back_populates="users", ) + user_attributes = db.relationship( + "SysUserAttribute", + back_populates="user", + lazy="selectin", + cascade="all, delete-orphan", + order_by="SysUserAttribute.sort", + ) @hybrid_property def password(self): @@ -126,13 +137,73 @@ class User(BaseModelMixin): .join(sys_user_role, sys_role_menu.c.role_id == sys_user_role.c.role_id) .filter( sys_user_role.c.user_id == self.id, - SysMenu.status == StatusEnum.ENABLED.value, + SysMenu.status == StatusEnum.ENABLED, SysMenu.auth_code.isnot(None), ) .order_by(SysMenu.auth_code.asc()) ).all() return permissions + _attributes = None + + @hybrid_property + def attributes(self): + """ + 获取用户扩展属性,按分组组织成字典结构 + 返回格式: {"erp": {"erp_username": "xxx", ...}, "custom": {...}} + """ + if self._attributes is None: + self._attributes = self.get_attributes() + return self._attributes + + @attributes.setter + def attributes(self, value): + self._attributes = value + + def get_attributes(self): + """ + 将用户扩展属性转换为分组字典 + """ + result = {} + for attr in self.user_attributes: + if attr.attr_group not in result: + result[attr.attr_group] = {} + result[attr.attr_group][attr.attr_key] = attr.get_typed_value() + return result + + def set_attributes(self, attributes_dict: dict): + """ + 批量设置用户扩展属性 + :param attributes_dict: {"erp": {"erp_username": "xxx", ...}, ...} + """ + from .sys_user_attribute import SysUserAttribute + + for group, attrs in attributes_dict.items(): + for key, value in attrs.items(): + # 查找是否已存在 + existing = next( + ( + attr + for attr in self.user_attributes + if attr.attr_group == group and attr.attr_key == key + ), + None, + ) + if existing: + existing.set_typed_value(value) + else: + # 创建新属性 + new_attr = SysUserAttribute( + user_id=self.id, + attr_group=group, + attr_key=key, + attr_type="string", # 默认类型 + ) + new_attr.set_typed_value(value) + self.user_attributes.append(new_attr) + # 清除缓存 + self._attributes = None + class UserSchema(BaseSchema): def __init__(self, *args, **kwargs): @@ -161,6 +232,7 @@ class UserSchema(BaseSchema): exclude=["users", "children", "parent"], ) permissions = List(String()) + attributes = Dict(dump_only=True) @post_dump def patch_roles(self, data, **kwargs): diff --git a/iti/applications/models/sys/sys_user_attribute.py b/iti/applications/models/sys/sys_user_attribute.py new file mode 100644 index 0000000..bdaa37f --- /dev/null +++ b/iti/applications/models/sys/sys_user_attribute.py @@ -0,0 +1,108 @@ +from iti.applications.extensions import db +from iti.applications.common.crud import BaseModelMixin +from iti.applications.common.utils import BaseSchema +from apiflask.fields import String, Integer + + +class SysUserAttribute(BaseModelMixin): + """ + 用户扩展属性表 (Key-Value 列存储模式) + """ + + __tablename__ = "sys_user_attribute" + + user_id = db.Column( + db.String(36), + db.ForeignKey("sys_user.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="用户ID", + ) + attr_group = db.Column( + db.String(64), nullable=False, index=True, comment="属性分组(如: erp, custom)" + ) + attr_key = db.Column(db.String(128), nullable=False, comment="属性键") + attr_value = db.Column(db.Text, nullable=True, comment="属性值") + attr_type = db.Column( + db.String(32), + nullable=False, + default="string", + comment="值类型(string/int/float/bool/json/encrypted)", + ) + description = db.Column(db.String(255), nullable=True, comment="属性描述") + sort = db.Column(db.Integer, nullable=False, default=0, comment="排序") + + # 关系 + user = db.relationship("User", back_populates="user_attributes") + + # 联合唯一索引:一个用户的同一分组下不能有重复的键 + __table_args__ = ( + db.Index("idx_user_group_key", "user_id", "attr_group", "attr_key"), + db.UniqueConstraint( + "user_id", "attr_group", "attr_key", name="uk_user_group_key" + ), + ) + + def get_typed_value(self): + """ + 根据 attr_type 返回类型化的值 + """ + if self.attr_value is None: + return None + + if self.attr_type == "int": + try: + return int(self.attr_value) + except (ValueError, TypeError): + return None + elif self.attr_type == "float": + try: + return float(self.attr_value) + except (ValueError, TypeError): + return None + elif self.attr_type == "bool": + return self.attr_value.lower() in ("true", "1", "yes", "on") + elif self.attr_type == "json": + import json + + try: + return json.loads(self.attr_value) + except (ValueError, TypeError): + return None + elif self.attr_type == "encrypted": + # 加密字段,返回时不解密(需要时在业务层处理) + return "******" + else: # string + return self.attr_value + + def set_typed_value(self, value): + """ + 根据 attr_type 设置类型化的值 + """ + if value is None: + self.attr_value = None + return + + if self.attr_type == "json": + import json + + self.attr_value = json.dumps(value, ensure_ascii=False) + elif self.attr_type == "bool": + self.attr_value = "true" if value else "false" + else: + self.attr_value = str(value) + + +class SysUserAttributeSchema(BaseSchema): + """ + 用户扩展属性 Schema + """ + + id = String() + user_id = String(data_key="userId") + attr_group = String(data_key="attrGroup") + attr_key = String(data_key="attrKey") + attr_value = String(data_key="attrValue") + attr_type = String(data_key="attrType") + description = String() + sort = Integer() diff --git a/iti/applications/routes/__init__.py b/iti/applications/routes/__init__.py index c644d98..ff2c1af 100644 --- a/iti/applications/routes/__init__.py +++ b/iti/applications/routes/__init__.py @@ -1,4 +1,5 @@ from iti.applications.extensions import broadcast_execute +from iti.applications.routes.common import register_common_bp from iti.applications.routes.sys import register_sys_bp from iti.applications.routes.index import bp as index_bp from iti.applications.routes.front import bp as frontend_bp @@ -10,7 +11,10 @@ def init_routes(app): if app.config.get("FRONTEND_ENABLED", False): app.register_blueprint(frontend_bp) - # 系统蓝图注册 + # 通用API蓝图注册 + register_common_bp(app) + + # 系统API蓝图注册 register_sys_bp(app) # 插件初始化 diff --git a/iti/applications/routes/common/__init__.py b/iti/applications/routes/common/__init__.py new file mode 100644 index 0000000..05a78eb --- /dev/null +++ b/iti/applications/routes/common/__init__.py @@ -0,0 +1,6 @@ +from .upload import bp as upload_bp +from .file_access import bp as file_access_bp + +def register_common_bp(app): + app.register_blueprint(upload_bp) + app.register_blueprint(file_access_bp) \ No newline at end of file diff --git a/iti/applications/routes/common/file_access.py b/iti/applications/routes/common/file_access.py new file mode 100644 index 0000000..29eefd4 --- /dev/null +++ b/iti/applications/routes/common/file_access.py @@ -0,0 +1,167 @@ +""" +通用文件访问模块 +提供文件下载、预览、缩略图、分享访问等接口 +""" + +from __future__ import annotations + +from apiflask import APIBlueprint +from flask import request, send_file +from flask_jwt_extended import jwt_required + +from iti.applications.common.exceptions.biz_exp import BizException +from iti.applications.common.utils import success +from iti.applications.common.enums import StorageTypeEnum +from iti.applications.service.sys.sys_file import SysFileService + +bp = APIBlueprint( + "common_file_access", __name__, url_prefix="/file", tag="通用.文件访问" +) + + +# --------------------------------------------------------------------------- +# 文件访问接口 +# --------------------------------------------------------------------------- + + +@bp.get("//download") +@jwt_required(optional=True) +def download_file(file_id: str): + """下载文件""" + try: + file_obj, file_stream = SysFileService.download_file(file_id) + + # 对文件名进行 URL 编码以支持中文等特殊字符 + from urllib.parse import quote + encoded_filename = quote(file_obj.filename) + + response = send_file( + file_stream, + mimetype=file_obj.mime_type or "application/octet-stream", + as_attachment=True, + ) + + # 使用 RFC 5987 标准格式支持 UTF-8 文件名 + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + + return response + except Exception as e: + raise BizException(f"文件下载失败: {str(e)}") + + +@bp.get("//preview") +@jwt_required(optional=True) +def preview_file(file_id: str): + """预览文件""" + try: + file_obj, file_stream = SysFileService.download_file(file_id) + except Exception as e: + raise BizException(f"预览失败: {str(e)}") + + if file_obj.storage_type != StorageTypeEnum.LOCAL: + from flask import redirect + from iti.applications.common.storage import StorageManager + + storage = StorageManager.get_storage(file_obj.storage_type) + oss_url = storage.get_url(file_obj.file_key, expires=3600) + return redirect(oss_url) + + # 本地文件直接返回 + try: + response = send_file( + file_stream, + mimetype=file_obj.mime_type or "application/octet-stream", + as_attachment=False, # 预览模式 + ) + + # 对文件名进行 URL 编码以支持中文等特殊字符 + from urllib.parse import quote + encoded_filename = quote(file_obj.filename) + + # 统一设置预览响应头,防止 IDM 等下载管理器拦截 + # 使用 RFC 5987 标准格式支持 UTF-8 文件名 + response.headers["Content-Disposition"] = f"inline; filename*=UTF-8''{encoded_filename}" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "public, max-age=3600" + + return response + except Exception as e: + raise BizException(f"文件预览失败: {str(e)}") + + +@bp.get("//thumbnail") +@jwt_required(optional=True) +def thumbnail_file(file_id: str): + """获取缩略图""" + # 获取缩略图参数(参考阿里云OSS风格) + width = request.args.get("w", type=int) or 200 + height = request.args.get("h", type=int) or 200 + mode = request.args.get("mode", "fit") # fit/fill/pad + + thumbnail_stream = SysFileService.get_thumbnail( + file_id, width=width, height=height, mode=mode + ) + + return send_file( + thumbnail_stream, + mimetype="image/jpeg", + as_attachment=False, + ) + + +# --------------------------------------------------------------------------- +# 分享文件访问接口 +# --------------------------------------------------------------------------- + + +@bp.get("/share/") +def access_share(share_code: str): + """ + 访问分享文件 + + 查询参数: + - password: 分享密码(如果需要) + + 响应示例: + ```json + { + "success": true, + "data": { + "id": "xxx", + "filename": "example.pdf", + "fileSize": 1024000, + "url": "http://...", + ... + } + } + ``` + """ + password = request.args.get("password") + file_obj = SysFileService.get_file_by_share_code(share_code, password) + return success(file_obj) + + +@bp.get("/share//download") +def download_share(share_code: str): + """下载分享文件""" + try: + password = request.args.get("password") + file_obj = SysFileService.get_file_by_share_code(share_code, password) + _, file_stream = SysFileService.download_file(file_obj.id) + + # 对文件名进行 URL 编码以支持中文等特殊字符 + from urllib.parse import quote + encoded_filename = quote(file_obj.filename) + + response = send_file( + file_stream, + mimetype=file_obj.mime_type or "application/octet-stream", + as_attachment=True, + ) + + # 使用 RFC 5987 标准格式支持 UTF-8 文件名 + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + + return response + except Exception as e: + raise BizException(f"分享文件下载失败: {str(e)}") diff --git a/iti/applications/routes/common/schemas/__init__.py b/iti/applications/routes/common/schemas/__init__.py new file mode 100644 index 0000000..79e1709 --- /dev/null +++ b/iti/applications/routes/common/schemas/__init__.py @@ -0,0 +1,2 @@ +from .upload import * + diff --git a/iti/applications/routes/common/schemas/upload.py b/iti/applications/routes/common/schemas/upload.py new file mode 100644 index 0000000..1632df6 --- /dev/null +++ b/iti/applications/routes/common/schemas/upload.py @@ -0,0 +1,71 @@ +"""上传相关 Schema""" + +from apiflask.fields import String, File, Integer, Dict as DictField, Enum +from iti.applications.common.enums.sys import StorageTypeEnum +from iti.applications.common.utils import BaseSchema + + +class UploadIn(BaseSchema): + """文件直接上传输入""" + + file = File(required=True) + directory_id = String(required=False, load_default=None, data_key="directoryId") + storage_type = Enum(StorageTypeEnum, required=False, load_default=None, data_key="storageType") + + +class ChunkUploadInitIn(BaseSchema): + """分片上传初始化输入""" + + filename = String(required=True, metadata={"description": "文件名"}) + file_size = Integer(required=True, metadata={"description": "文件总大小(字节)"}) + file_hash = String( + required=False, + load_default=None, + metadata={"description": "文件MD5哈希(用于秒传)"}, + ) + chunk_size = Integer( + required=False, + load_default=2097152, + metadata={"description": "分片大小(字节),默认2MB"}, + ) + total_chunks = Integer( + required=False, load_default=None, metadata={"description": "总分片数"} + ) + directory_id = String( + required=False, load_default=None, metadata={"description": "目录ID"} + ) + storage_type = Enum( + StorageTypeEnum, + required=False, + load_default=None, + metadata={"description": "存储类型"}, + ) + metadata = DictField( + required=False, load_default=dict, metadata={"description": "扩展元数据"} + ) + + +class ChunkUploadIn(BaseSchema): + """分片上传输入""" + + upload_id = String(required=True, metadata={"description": "上传任务ID"}) + chunk_index = Integer( + required=True, metadata={"description": "分片索引(从0开始)"} + ) + chunk_hash = String( + required=False, + load_default=None, + metadata={"description": "分片MD5哈希(用于校验)"}, + ) + file = File(required=True, metadata={"description": "分片数据"}) + + +class ChunkUploadMergeIn(BaseSchema): + """分片合并输入""" + + upload_id = String(required=True, metadata={"description": "上传任务ID"}) + file_hash = String( + required=False, + load_default=None, + metadata={"description": "文件MD5哈希(用于最终校验)"}, + ) diff --git a/iti/applications/routes/common/upload.py b/iti/applications/routes/common/upload.py new file mode 100644 index 0000000..2b57457 --- /dev/null +++ b/iti/applications/routes/common/upload.py @@ -0,0 +1,205 @@ +""" +通用文件上传模块 +支持: +1. 小文件直接上传 +2. 大文件分片上传(自定义协议) +3. 秒传(基于文件哈希) +4. 支持多存储类型(local/oss/minio) +""" + +from __future__ import annotations + +import hashlib +from io import BytesIO + +from apiflask import APIBlueprint +from flask import request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from iti.applications.common.utils import success +from iti.applications.common.exceptions.biz_exp import BizException +from iti.applications.models import SysFileSchema +from iti.applications.service.sys.sys_file import SysFileService +from .schemas.upload import ( + UploadIn, + ChunkUploadInitIn, + ChunkUploadIn, + ChunkUploadMergeIn, +) + +bp = APIBlueprint("common_upload", __name__, url_prefix="/upload", tag="通用.上传") + + +# --------------------------------------------------------------------------- +# 小文件直接上传 +# --------------------------------------------------------------------------- + + +@bp.post("") +@jwt_required() +@bp.doc(security="JWT") +@bp.input(UploadIn, location="form_and_files") +def upload_file(form_and_files_data): + """文件直接上传""" + file = form_and_files_data["file"] + directory_id = form_and_files_data.get("directoryId") + storage_type = form_and_files_data.get("storageType") + + # 从 UploadIn Schema 中获取所有已定义的字段名 + schema_fields = set(UploadIn().fields.keys()) + + # 收集所有非 Schema 定义的额外字段作为 metadata + metadata = {} + for key, value in form_and_files_data.items(): + if key not in schema_fields: + metadata[key] = value + + result = SysFileService.upload_file( + file, + directory_id=directory_id, + metadata=metadata, + storage_type=storage_type, + ) + + # result 是一个字典,包含 file_record 和 instantUpload + file_record = result["file"] + instant_upload = result["instantUpload"] + + # 使用 Schema 序列化文件记录 + from iti.applications.models import SysFileSchema + + schema = SysFileSchema() + file_data = schema.dump(file_record) + + # 添加秒传标识 + file_data["instantUpload"] = instant_upload + + return success(file_data) + + +# --------------------------------------------------------------------------- +# 分片上传 +# --------------------------------------------------------------------------- + + +@bp.post("/chunk/init") +@jwt_required() +@bp.doc(security="JWT") +@bp.input(ChunkUploadInitIn) +def chunk_upload_init(json_data): + """初始化分片上传""" + result = SysFileService.init_chunk_upload( + filename=json_data["filename"], + file_size=json_data["file_size"], + file_hash=json_data.get("file_hash"), + chunk_size=json_data.get("chunk_size", 2 * 1024 * 1024), + total_chunks=json_data.get("total_chunks"), + directory_id=json_data.get("directory_id"), + storage_type=json_data.get("storage_type"), + metadata=json_data.get("metadata", {}), + ) + + # 如果是秒传,需要序列化文件对象 + if result.get("instantUpload"): + from iti.applications.models import SysFileSchema + + schema = SysFileSchema() + file_data = schema.dump(result["file"]) + file_data["instantUpload"] = True + return success(file_data) + + # 非秒传,返回上传ID和已上传分片列表(已经是驼峰命名) + return success(result) + + +@bp.post("/chunk/upload") +@jwt_required() +@bp.doc(security="JWT") +@bp.input(ChunkUploadIn, location="form_and_files") +def chunk_upload(form_and_files_data): + """上传分片""" + upload_id = form_and_files_data["upload_id"] + chunk_index = form_and_files_data["chunk_index"] + chunk_hash = form_and_files_data.get("chunk_hash") + chunk_file = form_and_files_data["file"] + + chunk_data = chunk_file.read() + + if chunk_hash: + actual_hash = hashlib.md5(chunk_data).hexdigest() + if actual_hash != chunk_hash: + raise BizException("分片数据校验失败", code=400) + + result = SysFileService.upload_chunk( + upload_id=upload_id, + chunk_index=chunk_index, + chunk_data=chunk_data, + ) + + return success(result) + + +@bp.post("/chunk/merge") +@jwt_required() +@bp.doc(security="JWT") +@bp.input(ChunkUploadMergeIn) +def chunk_upload_merge(json_data): + """合并分片""" + upload_id = json_data["upload_id"] + file_hash = json_data.get("file_hash") + + file_record = SysFileService.merge_chunks( + upload_id=upload_id, + file_hash=file_hash, + ) + + # 使用 Schema 序列化文件记录 + from iti.applications.models import SysFileSchema + + schema = SysFileSchema() + file_data = schema.dump(file_record) + + # 分片上传合并后不是秒传 + file_data["instantUpload"] = False + + return success(file_data) + + +@bp.delete("/chunk/") +@jwt_required() +@bp.doc(security="JWT") +def chunk_upload_abort(upload_id: str): + """取消分片上传""" + SysFileService.abort_chunk_upload(upload_id) + return success(message="上传已取消") + + +@bp.get("/chunk//progress") +@jwt_required() +@bp.doc(security="JWT") +def chunk_upload_progress(upload_id: str): + """查询上传进度""" + progress = SysFileService.get_chunk_upload_progress(upload_id) + return success(progress) + + +@bp.post("/chunk/cleanup") +@jwt_required() +@bp.doc(security="JWT") +def cleanup_expired_chunks(): + """清理过期的分片上传临时文件(管理员接口)""" + from flask_jwt_extended import get_jwt + + # 检查是否有管理员权限(可选,根据你的权限系统调整) + # claims = get_jwt() + # if not claims.get("is_admin"): + # raise BizException("需要管理员权限", code=403) + + # 默认清理7天前的文件 + days = request.args.get("days", 7, type=int) + result = SysFileService.cleanup_expired_chunk_uploads(days) + + return success( + result, + message=f"清理完成,删除 {result['cleaned_dirs']} 个目录,释放 {result['cleaned_size']} 字节空间", + ) diff --git a/iti/applications/routes/sys/auth.py b/iti/applications/routes/sys/auth.py index 5eb071a..1136963 100644 --- a/iti/applications/routes/sys/auth.py +++ b/iti/applications/routes/sys/auth.py @@ -16,7 +16,7 @@ from datetime import timedelta from flask_jwt_extended import current_user from iti.applications.extensions import db, eventbus, sys_log from iti.applications.common.enums import GenderEnum, StatusEnum, LoginType -from iti.applications.service.sys_config import ( +from iti.applications.service.sys.sys_config import ( get_default_user_password, get_default_user_roles, get_default_user_depts, @@ -27,7 +27,7 @@ from .schemas.auth import ( RegisterRequest, SendVerificationCodeRequest, ) -from iti.applications.service.verification_code import ( +from iti.applications.service.sys.verification_code import ( check_verification_code, VerificationCodeUsage, set_verification_code, @@ -121,6 +121,7 @@ def loginByCode(json_data: CodeLoginRequest): @bp.post("/logout") @jwt_required() +@bp.doc(security="JWT") @sys_log( name="退出登录", desc="退出登录", @@ -139,6 +140,7 @@ def logout(): @bp.post("/refresh") @jwt_required(refresh=True) +@bp.doc(security="JWT") @sys_log( name="刷新令牌", desc="刷新令牌", @@ -215,6 +217,7 @@ def sendVerificationCode(json_data: SendVerificationCodeRequest): @bp.get("/codes") @jwt_required() +@bp.doc(security="JWT") def get_user_permissions(): """ 获取用户权限编码 diff --git a/iti/applications/routes/sys/config.py b/iti/applications/routes/sys/config.py index 46712c4..6924623 100644 --- a/iti/applications/routes/sys/config.py +++ b/iti/applications/routes/sys/config.py @@ -19,6 +19,7 @@ bp = APIBlueprint("sys_config", __name__, url_prefix="/config", tag="系统.配 @bp.get("/list") @jwt_required() +@bp.doc(security="JWT") @permission("system:config:list") @bp.input(SysConfigQuery.Schema, location="query") @bp.output(SysConfigSchema(many=True)) @@ -31,6 +32,7 @@ def get_sys_config_list(query_data: SysConfigQuery): @bp.get("/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:config:list") @bp.input(SysConfigQuery.Schema, location="query") @bp.output(page_schema(SysConfigSchema(many=True))) @@ -83,6 +85,7 @@ def get_list_or_page(query_data: SysConfigQuery): @bp.post("") @jwt_required() +@bp.doc(security="JWT") @permission("system:config:create") @bp.input(SysConfigCreateRequest, location="json") @sys_log( @@ -104,6 +107,7 @@ def create_sys_config(json_data: dict): @bp.put("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:config:edit") @bp.input(SysConfigUpdateRequest(partial=True), location="json") @sys_log( @@ -129,6 +133,7 @@ def update_sys_config(id: str, json_data: dict): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:config:delete") @sys_log( name="删除系统配置", diff --git a/iti/applications/routes/sys/dept.py b/iti/applications/routes/sys/dept.py index cfe5a92..e33238e 100644 --- a/iti/applications/routes/sys/dept.py +++ b/iti/applications/routes/sys/dept.py @@ -4,7 +4,7 @@ from iti.applications.common.exceptions.biz_exp import BizException from iti.applications.extensions import db from iti.applications.models import SysDept, SysDeptSchema, sys_user_dept from .schemas.dept import SysDeptQuery, SysDeptCreateRequest, SysDeptUpdateRequest -from iti.applications.service.sys_dept import get_dept_tree_or_page +from iti.applications.service.sys.sys_dept import get_dept_tree_or_page from iti.applications.common.utils import page, page_schema, success from iti.applications.common import permission from sqlalchemy import select, delete @@ -14,6 +14,7 @@ bp = APIBlueprint("sys_dept", __name__, url_prefix="/dept", tag="系统.部门 @bp.get("/list") @jwt_required() +@bp.doc(security="JWT") @permission("system:dept:list") @bp.input(SysDeptQuery.Schema, location="query") @bp.output(SysDeptSchema(many=True, exclude=["parent"])) @@ -27,6 +28,7 @@ def list_dept(query_data: SysDeptQuery): @bp.get("/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:dept:list") @bp.input(SysDeptQuery.Schema, location="query") @bp.output(page_schema(SysDeptSchema(many=True, exclude=["parent"]))) @@ -40,6 +42,7 @@ def page_dept(query_data: SysDeptQuery): @bp.post("") @jwt_required() +@bp.doc(security="JWT") @permission("system:dept:create") @bp.input(SysDeptCreateRequest, location="json") def create_dept(json_data: dict): @@ -54,6 +57,7 @@ def create_dept(json_data: dict): @bp.put("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dept:edit") @bp.input(SysDeptUpdateRequest(partial=True), location="json") def update_dept(id: str, json_data: dict): @@ -72,6 +76,7 @@ def update_dept(id: str, json_data: dict): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dept:delete") def delete_dept(id: str): """ diff --git a/iti/applications/routes/sys/dict.py b/iti/applications/routes/sys/dict.py index 3a097d2..b099b0f 100644 --- a/iti/applications/routes/sys/dict.py +++ b/iti/applications/routes/sys/dict.py @@ -28,6 +28,7 @@ bp = APIBlueprint("sys_dict", __name__, url_prefix="/dict", tag="系统.字典 @bp.get("/type/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.input(SysDictTypeQuery.Schema, location="query") @bp.output( @@ -78,6 +79,7 @@ def page_sys_dict_type(query_data: SysDictTypeQuery): @bp.get("/type") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.input(SysDictTypeQuery.Schema(exclude=pagination_fields), location="query") @bp.output(condition_schema(SysDictTypeSchema, {"withDataList": ["data_list"]})) @@ -108,6 +110,7 @@ def get_sys_dict_type(query_data: SysDictTypeQuery): @bp.post("/type") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:create") @bp.input(SysDictTypeCreateRequest, location="json") def create_sys_dict_type(json_data: dict): @@ -122,6 +125,7 @@ def create_sys_dict_type(json_data: dict): @bp.put("/type/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:edit") @bp.input(SysDictTypeUpdateRequest(partial=True), location="json") def update_sys_dict_type(id: str, json_data: dict): @@ -141,6 +145,7 @@ def update_sys_dict_type(id: str, json_data: dict): @bp.delete("/type/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:delete") def delete_sys_dict_type(id: str): """ @@ -166,6 +171,7 @@ def delete_sys_dict_type(id: str): ########################## 字典数据管理 ####################### @bp.get("/data/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.input(SysDictDataQuery.Schema, location="query") @bp.output(page_schema(SysDictDataSchema(exclude=["type"]))) @@ -178,6 +184,7 @@ def page_sys_dict_data(query_data: SysDictDataQuery): @bp.get("/data/list") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.input(SysDictDataQuery.Schema(exclude=pagination_fields), location="query") @bp.output(SysDictDataSchema(exclude=["type"], many=True)) @@ -233,6 +240,7 @@ def get_list_or_page(query_data: SysDictDataQuery): @bp.get("/data/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.output(SysDictDataSchema(exclude=["type"])) def get_sys_dict_data(id: str): @@ -244,6 +252,7 @@ def get_sys_dict_data(id: str): @bp.get("/data") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:list") @bp.input( SysDictDataQuery.Schema( @@ -266,6 +275,7 @@ def get_sys_dict_data_by_query(query_data: SysDictDataQuery): @bp.post("/data") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:create") @bp.input(SysDictDataCreateRequest, location="json") def create_sys_dict_data(json_data: dict): @@ -280,6 +290,7 @@ def create_sys_dict_data(json_data: dict): @bp.put("/data/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:edit") @bp.input(SysDictDataUpdateRequest(partial=True), location="json") def update_sys_dict_data(id: str, json_data: dict): @@ -299,6 +310,7 @@ def update_sys_dict_data(id: str, json_data: dict): @bp.delete("/data/") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:delete") def delete_sys_dict_data(id: str): """ @@ -314,6 +326,7 @@ def delete_sys_dict_data(id: str): @bp.delete("/data/batch") @jwt_required() +@bp.doc(security="JWT") @permission("system:dict:delete") @bp.input(SysDictDataBatchDeleteRequest.Schema, location="query") def batch_delete_sys_dict_data(query_data: SysDictDataBatchDeleteRequest): diff --git a/iti/applications/routes/sys/file.py b/iti/applications/routes/sys/file.py index 655f409..a37eab5 100644 --- a/iti/applications/routes/sys/file.py +++ b/iti/applications/routes/sys/file.py @@ -1,239 +1,18 @@ from __future__ import annotations -import base64 - from apiflask import APIBlueprint -from flask import make_response, request, send_file, url_for -from flask_jwt_extended import jwt_required +from flask import request +from flask_jwt_extended import jwt_required, get_jwt_identity from iti.applications.common.utils import success -from iti.applications.common.exceptions.biz_exp import BizException from iti.applications.models import SysFileSchema -from iti.applications.service.sys_file import SysFileService -from .schemas.file import FileUploadIn +from iti.applications.service.sys.sys_file import SysFileService bp = APIBlueprint("sys_file", __name__, url_prefix="/file", tag="系统.文件管理") # --------------------------------------------------------------------------- -# 普通上传(Uppy XHR / UniApp) -# -# CORS 说明: -# - OPTIONS 预检请求需要手动处理(APIFlask 不自动处理) -# - 生产环境建议使用 Flask-CORS 扩展统一配置 CORS -# - 或在 Nginx/网关层处理 CORS 头部 -# --------------------------------------------------------------------------- - - -@bp.route("/upload", methods=["OPTIONS"]) -def upload_options(): - """处理 CORS 预检请求""" - response = make_response("", 204) - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" - response.headers["Access-Control-Expose-Headers"] = "Content-Length" - return response - - -@bp.post("/upload") -@jwt_required(optional=True) -@bp.input(FileUploadIn, location="form_and_files") -@bp.output(SysFileSchema) -def upload_file(form_and_files_data): - """普通文件上传(Uppy XHR / UniApp) - - 符合APIFlask推荐的文件上传方式,使用 @bp.input 装饰器声明输入。 - 参考:https://zh.apiflask.com/request/#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0 - """ - file = form_and_files_data["file"] - directory_id = form_and_files_data.get("directory_id") - storage_type = form_and_files_data.get("storage_type") - - # 提取额外的metadata字段(任何不在FileUploadIn schema中定义的字段) - # 支持前端传入 width、height 等扩展属性 - schema_fields = {"file", "directory_id", "directoryId", "storage_type", "storageType"} - metadata = {} - - # 从表单数据和查询参数中提取额外字段 - form_data = request.form.to_dict() - query_data = request.args.to_dict() - combined = {**query_data, **form_data} - - for key, value in combined.items(): - # 跳过schema中已定义的字段 - if key not in schema_fields: - metadata[key] = value - - file_record = SysFileService.upload_file( - file, - directory_id=directory_id, - metadata=metadata, - storage_type=storage_type, - ) - - return success(file_record) - - -# --------------------------------------------------------------------------- -# TUS 协议 -# 注意:TUS协议接口未使用框架统一返回结构,原因如下: -# 1. TUS 1.0.0 协议有严格的响应头要求(Tus-Resumable, Upload-Offset, Location等) -# 2. 部分请求需要返回空响应体(HEAD, OPTIONS) -# 3. 响应状态码有特殊含义(201创建, 204无内容, 409冲突等) -# 4. 需要精确控制响应格式以兼容 Uppy 等客户端 -# --------------------------------------------------------------------------- - - -@bp.route("/upload/tus", methods=["OPTIONS"]) -def tus_options(): - response = make_response("", 204) - response.headers.update( - { - "Tus-Resumable": "1.0.0", - "Tus-Version": "1.0.0", - "Tus-Extension": "creation,termination,creation-with-upload", - "Tus-Max-Size": str(100 * 1024 * 1024 * 1024), - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, HEAD, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Upload-Offset, Upload-Length, Tus-Resumable, Upload-Metadata, Content-Type, Authorization", - "Access-Control-Expose-Headers": "Upload-Offset, Location, Upload-Length, Tus-Resumable", - } - ) - return response - - -@bp.post("/upload/tus") -@jwt_required(optional=True) -def tus_create_upload(): - if request.headers.get("Tus-Resumable") != "1.0.0": - return _tus_error("不支持的 TUS 版本", 412) - - upload_length = request.headers.get("Upload-Length") - if not upload_length: - return _tus_error("缺少 Upload-Length 头", 400) - - metadata = _parse_tus_metadata(request.headers.get("Upload-Metadata")) - filename = metadata.pop("filename", "unknown") - directory_id = metadata.pop("directoryId", None) or metadata.pop( - "directory_id", None - ) - file_hash = metadata.pop("fileHash", None) or metadata.pop("file_hash", None) - storage_type = metadata.pop("storageType", None) or metadata.pop( - "storage_type", None - ) - - result = SysFileService.init_tus_upload( - filename=filename, - file_size=int(upload_length), - file_hash=file_hash, - directory_id=directory_id, - metadata=metadata, - storage_type=storage_type, - ) - - if result.get("instant_upload"): - schema = SysFileSchema() - data = schema.dump(result["file"]) - response = make_response({"success": True, "data": data}, 201) - response.headers["Tus-Resumable"] = "1.0.0" - response.headers["Access-Control-Allow-Origin"] = "*" - return response - - upload_id = result["upload_id"] - location = url_for("sys_file.tus_upload_chunk", upload_id=upload_id, _external=True) - response = make_response("", 201) - response.headers.update( - { - "Location": location, - "Tus-Resumable": "1.0.0", - "Upload-Offset": "0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Expose-Headers": "Location, Upload-Offset", - } - ) - return response - - -@bp.route("/upload/tus/", methods=["HEAD"]) -@jwt_required(optional=True) -def tus_get_offset(upload_id: str): - if request.headers.get("Tus-Resumable") != "1.0.0": - return _tus_error("不支持的 TUS 版本", 412) - - try: - progress = SysFileService.get_tus_upload_progress(upload_id) - except BizException as exc: - return _tus_error(str(exc), getattr(exc, "code", 404)) - - response = make_response("", 200) - response.headers.update( - { - "Upload-Offset": str(progress["offset"]), - "Upload-Length": str(progress["total_size"]), - "Tus-Resumable": "1.0.0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Expose-Headers": "Upload-Offset, Upload-Length", - } - ) - return response - - -@bp.patch("/upload/tus/") -@jwt_required(optional=True) -def tus_upload_chunk(upload_id: str): - if request.headers.get("Tus-Resumable") != "1.0.0": - return _tus_error("不支持的 TUS 版本", 412) - - upload_offset = request.headers.get("Upload-Offset") - if upload_offset is None: - return _tus_error("缺少 Upload-Offset 头", 400) - - chunk_data = request.get_data() - try: - result = SysFileService.upload_tus_chunk( - upload_id, int(upload_offset), chunk_data - ) - except BizException as exc: - return _tus_error(str(exc), getattr(exc, "code", 500)) - - response_headers = { - "Upload-Offset": str(result["new_offset"]), - "Tus-Resumable": "1.0.0", - "Access-Control-Allow-Origin": "*", - "Access-Control-Expose-Headers": "Upload-Offset", - } - - if result.get("completed"): - schema = SysFileSchema() - data = schema.dump(result["file"]) - response = make_response({"success": True, "data": data}, 200) - response.headers.update(response_headers) - return response - - response = make_response("", 204) - response.headers.update(response_headers) - return response - - -@bp.delete("/upload/tus/") -@jwt_required(optional=True) -def tus_delete_upload(upload_id: str): - if request.headers.get("Tus-Resumable") != "1.0.0": - return _tus_error("不支持的 TUS 版本", 412) - try: - SysFileService.abort_tus_upload(upload_id) - except BizException as exc: - return _tus_error(str(exc), getattr(exc, "code", 404)) - - response = make_response("", 204) - response.headers["Tus-Resumable"] = "1.0.0" - response.headers["Access-Control-Allow-Origin"] = "*" - return response - - -# --------------------------------------------------------------------------- -# 文件访问 +# 文件信息查询 # --------------------------------------------------------------------------- @@ -246,87 +25,100 @@ def get_file(file_id: str): return success(file_obj) -@bp.get("//download") -@jwt_required(optional=True) -def download_file(file_id: str): - """下载文件""" - file_obj = SysFileService.get_file_by_id(file_id) - file_stream = SysFileService.download_file(file_id) - return send_file( - file_stream, - mimetype=file_obj.mime_type or "application/octet-stream", - as_attachment=True, - download_name=file_obj.filename, - ) +# --------------------------------------------------------------------------- +# 回收站功能 +# --------------------------------------------------------------------------- -@bp.get("//preview") -@jwt_required(optional=True) -def preview_file(file_id: str): - """预览文件""" - file_obj = SysFileService.get_file_by_id(file_id) +@bp.delete("/") +@jwt_required() +def delete_file(file_id: str): + """ + 删除文件(移动到回收站) - # OSS文件直接重定向到OSS URL - if file_obj.storage_type != "local": - oss_url = SysFileService.get_file_url(file_id, expires=3600) - from flask import redirect + 如果回收站功能启用,文件将被移动到回收站;否则直接物理删除 + """ + user_id = get_jwt_identity() + SysFileService.move_to_recycle(file_id, user_id) + return success(message="文件已删除") - return redirect(oss_url) - # 本地文件直接返回 - file_stream = SysFileService.download_file(file_id) - return send_file( - file_stream, - mimetype=file_obj.mime_type or "application/octet-stream", - as_attachment=False, # 预览模式 - ) +@bp.post("//restore") +@jwt_required() +def restore_file(file_id: str): + """从回收站恢复文件""" + SysFileService.restore_from_recycle(file_id) + return success(message="文件已恢复") -@bp.get("//thumbnail") -@jwt_required(optional=True) -def thumbnail_file(file_id: str): - """获取缩略图""" - from flask import request +@bp.delete("//permanent") +@jwt_required() +def delete_file_permanent(file_id: str): + """永久删除文件(物理删除)""" + SysFileService.delete_file_permanently(file_id) + return success(message="文件已永久删除") - # 获取缩略图参数(参考阿里云OSS风格) - width = request.args.get("w", type=int) or 200 - height = request.args.get("h", type=int) or 200 - mode = request.args.get("mode", "fit") # fit/fill/pad - thumbnail_stream = SysFileService.get_thumbnail( - file_id, width=width, height=height, mode=mode - ) +@bp.post("/recycle/clear") +@jwt_required() +def clear_recycle_bin(): + """ + 清空回收站 - return send_file( - thumbnail_stream, - mimetype="image/jpeg", - as_attachment=False, - ) + 删除30天前的回收站文件 + """ + count = SysFileService.clear_recycle_bin(days=30) + return success(message=f"已清理 {count} 个文件") # --------------------------------------------------------------------------- -# 工具函数 +# 分享管理功能 # --------------------------------------------------------------------------- -def _parse_tus_metadata(metadata_header: str) -> dict: - if not metadata_header: - return {} - result = {} - for item in metadata_header.split(","): - if " " not in item: - continue - key, value = item.split(" ", 1) - try: - decoded = base64.b64decode(value).decode("utf-8") - except Exception: - decoded = value - result[key] = decoded - return result +@bp.post("//share") +@jwt_required() +def create_share(file_id: str): + """ + 创建文件分享 + + 请求示例: + ```json + { + "password": "1234", // 可选 + "expireHours": 24 // 可选,不传表示永久 + } + ``` + + 响应示例: + ```json + { + "success": true, + "data": { + "shareCode": "abc123", + "shareUrl": "http://example.com/share/abc123", + "password": "1234", + "expireAt": "2024-01-01 12:00:00" + } + } + ``` + """ + data = request.get_json() or {} + password = data.get("password") + expire_hours = data.get("expireHours") or data.get("expire_hours") + + result = SysFileService.create_share( + file_id=file_id, + password=password, + expire_hours=expire_hours, + ) + + return success(result) -def _tus_error(message: str, status: int): - response = make_response({"error": message}, status) - response.headers["Tus-Resumable"] = "1.0.0" - response.headers["Access-Control-Allow-Origin"] = "*" - return response +@bp.delete("//share") +@jwt_required() +def cancel_share(file_id: str): + """取消文件分享""" + SysFileService.cancel_share(file_id) + return success(message="分享已取消") diff --git a/iti/applications/routes/sys/log.py b/iti/applications/routes/sys/log.py index 0601cb2..fde2763 100644 --- a/iti/applications/routes/sys/log.py +++ b/iti/applications/routes/sys/log.py @@ -13,6 +13,7 @@ bp = APIBlueprint("sys_log", __name__, url_prefix="/log", tag="系统.日志管 @bp.get("/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:log:list") @bp.input(LogQuery.Schema, location="query") @bp.output(page_schema(LogSchema)) @@ -46,6 +47,7 @@ def page_log(query_data: LogQuery): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:log:delete") def delete_log(id: str): """ @@ -58,6 +60,7 @@ def delete_log(id: str): @bp.delete("/batch") @jwt_required() +@bp.doc(security="JWT") @permission("system:log:delete") @bp.input(LogBatchDeleteRequest.Schema, location="query") def batch_delete_log(query_data: LogBatchDeleteRequest): diff --git a/iti/applications/routes/sys/menu.py b/iti/applications/routes/sys/menu.py index e9a20f1..20979b1 100644 --- a/iti/applications/routes/sys/menu.py +++ b/iti/applications/routes/sys/menu.py @@ -10,7 +10,7 @@ from iti.applications.routes.sys.schemas.menu import ( MenuUpdateRequest, MenuExistsRequest, ) -from iti.applications.service.sys_menu import get_menu_tree, get_user_menu_ids +from iti.applications.service.sys.sys_menu import get_menu_tree, get_user_menu_ids from iti.applications.models import SysMenuSchema from iti.applications.common.enums import MenuTypeEnum from sqlalchemy import select, delete, func, exists @@ -23,6 +23,7 @@ bp = APIBlueprint("sys_menu", __name__, url_prefix="/menu", tag="系统.菜单 @bp.get("/list") @jwt_required() +@bp.doc(security="JWT") @bp.output(SysMenuSchema(many=True)) def get_menu_list(): """ @@ -33,6 +34,7 @@ def get_menu_list(): @bp.get("/tree") @jwt_required() +@bp.doc(security="JWT") @bp.output(SysMenuSchema(many=True)) def get_menu_tree_api(): """ @@ -59,6 +61,7 @@ def get_menu_tree_api(): @bp.post("") @jwt_required() +@bp.doc(security="JWT") @permission("system:menu:create") @bp.input(MenuCreateRequest, location="json") def create_menu(json_data: dict): @@ -83,6 +86,7 @@ def create_menu(json_data: dict): @bp.put("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:menu:edit") @bp.input(MenuUpdateRequest(partial=True), location="json") def update_menu(id: str, json_data: dict): @@ -106,24 +110,39 @@ def update_menu(id: str, json_data: dict): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:menu:delete") def delete_menu(id: str): """ 删除菜单 + 基本规则: + 1. 删除菜单需同时删除其绑定关系 + 2. 若菜单包含子菜单,则需要删除所有子孙菜单(包括其绑定关系) """ menu = db.session.scalar(select(SysMenu).filter_by(id=id)) if not menu: raise BizException("菜单不存在") try: - # 删除菜单,需要同时删除 菜单-角色 关联关系 - db.session.execute(delete(sys_role_menu).filter_by(menu_id=id)) - # 删除菜单 - db.session.execute(menu) + # 获取当前菜单及其所有子孙菜单 + from iti.applications.service.sys.sys_menu import build_descendants_cte + descendants_query = build_descendants_cte(SysMenu.id == id) + descendant_menus = db.session.scalars(descendants_query).all() + descendant_ids = [m.id for m in descendant_menus] + + # 批量删除所有菜单的角色绑定关系 + db.session.execute(delete(sys_role_menu).where(sys_role_menu.c.menu_id.in_(descendant_ids))) + + # 批量删除所有子孙菜单 + db.session.execute(delete(SysMenu).where(SysMenu.id.in_(descendant_ids))) + # 提交事务 db.session.commit() - # 触发菜单删除事件 - eventbus.emit(MenuEvents.MENU_DELETED.value, menu) + + # 触发菜单删除事件(为每个被删除的菜单触发) + for descendant_menu in descendant_menus: + eventbus.emit(MenuEvents.MENU_DELETED.value, descendant_menu) + except Exception as e: db.session.rollback() raise BizException(f"删除菜单失败: {str(e)}") @@ -132,6 +151,7 @@ def delete_menu(id: str): @bp.get("/exists") @jwt_required() +@bp.doc(security="JWT") @bp.input(MenuExistsRequest, location="query") def menu_exists(query_data: dict): """ diff --git a/iti/applications/routes/sys/role.py b/iti/applications/routes/sys/role.py index 78b6251..48e23a6 100644 --- a/iti/applications/routes/sys/role.py +++ b/iti/applications/routes/sys/role.py @@ -3,7 +3,7 @@ from apiflask import APIBlueprint from flask_jwt_extended import jwt_required from iti.applications.common.utils.schema import pagination_fields from iti.applications.extensions import db, sys_log -from iti.applications.models.sys_menu import SysMenu +from iti.applications.models import SysMenu from .schemas.role import RoleCreateRequest, RoleQuery, RoleUpdateRequest from iti.applications.models import Role, RoleSchema, sys_user_role, sys_role_menu from iti.applications.common.utils import success, page_schema, page @@ -20,6 +20,7 @@ bp = APIBlueprint("sys_role", __name__, url_prefix="/role", tag="系统.角色 @bp.get("/list") @jwt_required() +@bp.doc(security="JWT") @permission("system:role:list") @bp.input(RoleQuery.Schema(exclude=pagination_fields), location="query") @bp.output(RoleSchema(exclude=["users"], many=True)) @@ -58,6 +59,7 @@ def get_list_or_page(query_data: RoleQuery): @bp.get("/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:role:list") @bp.input(RoleQuery.Schema, location="query") @bp.output(page_schema(RoleSchema(exclude=["users"]))) @@ -70,6 +72,7 @@ def page_role(query_data: RoleQuery): @bp.post("") @jwt_required() +@bp.doc(security="JWT") @permission("system:role:create") @bp.input(RoleCreateRequest.Schema, location="json") @bp.output(RoleSchema) @@ -93,6 +96,7 @@ def create_role(json_data: RoleCreateRequest): @bp.put("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:role:edit") @bp.input(RoleUpdateRequest.Schema(partial=True), location="json") @sys_log( @@ -139,6 +143,7 @@ def update_role(id: str, json_data: RoleUpdateRequest): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:role:delete") @sys_log( name="删除角色", diff --git a/iti/applications/routes/sys/schemas/auth.py b/iti/applications/routes/sys/schemas/auth.py index 39e82e2..90f95cb 100644 --- a/iti/applications/routes/sys/schemas/auth.py +++ b/iti/applications/routes/sys/schemas/auth.py @@ -3,7 +3,7 @@ from marshmallow_dataclass import dataclass from marshmallow import validates_schema, ValidationError from iti.applications.common.enums import GenderEnum, StatusEnum from iti.applications.common.utils.schema import BaseSchema -from iti.applications.service.verification_code import VerificationCodeUsage +from iti.applications.service.sys.verification_code import VerificationCodeUsage from typing import ClassVar diff --git a/iti/applications/routes/sys/user.py b/iti/applications/routes/sys/user.py index 26671f6..2d3f746 100644 --- a/iti/applications/routes/sys/user.py +++ b/iti/applications/routes/sys/user.py @@ -20,7 +20,7 @@ from .schemas.user import ( ) from iti.applications.common.enums import LogType from iti.applications.common.exceptions.biz_exp import BizException -from iti.applications.service.verification_code import ( +from iti.applications.service.sys.verification_code import ( check_verification_code, VerificationCodeUsage, ) @@ -35,6 +35,7 @@ bp = APIBlueprint("sys_user", __name__, url_prefix="/user", tag="系统.用户 @bp.get("/current") @jwt_required() +@bp.doc(security="JWT") @bp.output(UserSchema) def get_current_user(): """ @@ -45,6 +46,7 @@ def get_current_user(): @bp.get("/list") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:list") @bp.input(UserQuery.Schema(partial=True), location="query") @bp.output(UserSchema(many=True)) @@ -58,6 +60,7 @@ def list_user(query_data: UserQuery): @bp.get("/page") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:list") @bp.input(UserQuery.Schema(partial=True), location="query") @bp.output(page_schema(UserSchema(roles_type="id"))) @@ -111,6 +114,7 @@ def get_list_or_page(query_data: UserQuery): @bp.post("") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:create") @bp.input(UserCreateRequest, location="json") @bp.output(UserSchema) @@ -153,6 +157,7 @@ def create_user(json_data: dict): @bp.put("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:edit") @bp.input(UserUpdateRequest(partial=True), location="json") def update_user(id: str, json_data: dict): @@ -222,6 +227,7 @@ def update_user(id: str, json_data: dict): @bp.delete("/") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:delete") @sys_log( name="删除用户", @@ -252,6 +258,7 @@ def delete_user(id: str): @bp.put("/password") @jwt_required() +@bp.doc(security="JWT") @permission("system:user:resetpwd") @bp.input(UserUpdatePasswordRequest.Schema, location="json") @sys_log( diff --git a/iti/applications/service/__init__.py b/iti/applications/service/__init__.py new file mode 100644 index 0000000..01a915e --- /dev/null +++ b/iti/applications/service/__init__.py @@ -0,0 +1,10 @@ + +def init_services(app) -> None: + """初始化Services""" + # 初始化文件系统配置 + from iti.applications.service.sys.sys_file_config import init_app as init_file_config + init_file_config(app) + + # 初始化文件目录 + from iti.applications.service.sys.sys_file_directory import init_app as init_file_directory + init_file_directory(app) \ No newline at end of file diff --git a/iti/applications/service/sys_config.py b/iti/applications/service/sys/sys_config.py similarity index 100% rename from iti/applications/service/sys_config.py rename to iti/applications/service/sys/sys_config.py diff --git a/iti/applications/service/sys_dept.py b/iti/applications/service/sys/sys_dept.py similarity index 99% rename from iti/applications/service/sys_dept.py rename to iti/applications/service/sys/sys_dept.py index b9b1e6a..80748a4 100644 --- a/iti/applications/service/sys_dept.py +++ b/iti/applications/service/sys/sys_dept.py @@ -15,7 +15,7 @@ def get_dept_tree_or_page(query_data: SysDeptQuery, with_leader: bool = False): return ( SysDept.query.filter(SysDept.leader_id == query_data.leader_id) .order_by(SysDept.sort) - .paginate(page=query_data.page, per_page=query_data.page_size) + .paginate(page=query_data.page, per_page=query_data.size) ) # 若根据 Parent 查询,查询该 Parent 的子部门树,否则查询所有部门树 diff --git a/iti/applications/service/sys/sys_file.py b/iti/applications/service/sys/sys_file.py new file mode 100644 index 0000000..b3306d3 --- /dev/null +++ b/iti/applications/service/sys/sys_file.py @@ -0,0 +1,1137 @@ +from __future__ import annotations + +import hashlib +import mimetypes +import os +import shutil +import tempfile +from datetime import datetime, timedelta +from io import BytesIO +from pathlib import Path +from typing import Dict, Optional, Union + +from flask import current_app +from sqlalchemy import select, exists + +from iti.applications.common.enums import StatusEnum +from iti.applications.common.enums.sys import StorageTypeEnum +from iti.applications.common.exceptions.biz_exp import BizException +from iti.applications.common.storage import StorageManager +from iti.applications.extensions import cache_simple, db +from iti.applications.models import SysFile, SysFileDirectory + + +class SysFileService: + CHUNK_UPLOAD_CACHE_PREFIX = "chunk_upload:" + CHUNK_TEMP_DIR = "chunk_uploads" # 分片临时目录 + + # ------------------------------------------------------------------ + # 工具方法 + # ------------------------------------------------------------------ + @staticmethod + def _guess_mime_type( + filename: str, provided_mime: Optional[str] = None + ) -> Optional[str]: + """ + 推断文件 MIME 类型 + + Args: + filename: 文件名 + provided_mime: 提供的 MIME 类型(优先使用) + + Returns: + MIME 类型 + """ + # 优先使用提供的 MIME 类型 + if provided_mime: + return provided_mime + + # 根据文件扩展名推断 + mime_type, _ = mimetypes.guess_type(filename) + return mime_type + + @staticmethod + def _get_backend_url() -> str: + """ + 获取后端访问地址 + + Returns: + 后端 URL(不包含尾部斜杠) + """ + from iti.applications.service.sys.sys_config import get_str + + backend_url = get_str( + "BACKEND_URL", type="SYSTEM", default="http://localhost:5000" + ) + return backend_url.rstrip("/") + + @classmethod + def _get_chunk_temp_dir(cls) -> Path: + """ + 获取分片临时目录 + + Returns: + 临时目录路径 + """ + # 优先使用配置的临时目录 + temp_base = current_app.config.get("UPLOAD_TEMP_DIR") + if not temp_base: + # 使用系统临时目录 + temp_base = tempfile.gettempdir() + + chunk_dir = Path(temp_base) / cls.CHUNK_TEMP_DIR + chunk_dir.mkdir(parents=True, exist_ok=True) + return chunk_dir + + @classmethod + def _get_upload_temp_dir(cls, upload_id: str) -> Path: + """ + 获取指定上传任务的临时目录 + + Args: + upload_id: 上传任务ID + + Returns: + 上传任务临时目录路径 + """ + upload_dir = cls._get_chunk_temp_dir() / upload_id + upload_dir.mkdir(parents=True, exist_ok=True) + return upload_dir + + @classmethod + def _get_chunk_file_path(cls, upload_id: str, chunk_index: int) -> Path: + """ + 获取分片文件路径 + + Args: + upload_id: 上传任务ID + chunk_index: 分片索引 + + Returns: + 分片文件路径 + """ + upload_dir = cls._get_upload_temp_dir(upload_id) + return upload_dir / f"chunk_{chunk_index}" + + @classmethod + def _cleanup_upload_temp_dir(cls, upload_id: str) -> None: + """ + 清理上传任务的临时目录 + + Args: + upload_id: 上传任务ID + """ + upload_dir = cls._get_chunk_temp_dir() / upload_id + if upload_dir.exists(): + try: + shutil.rmtree(upload_dir) + except Exception as e: + # 记录日志但不抛出异常 + current_app.logger.warning(f"清理临时目录失败: {upload_dir}, 错误: {e}") + + @classmethod + def cleanup_expired_chunk_uploads(cls, days: int = 7) -> Dict[str, int]: + """ + 清理过期的分片上传临时文件(定期任务调用) + + Args: + days: 保留天数,默认7天 + + Returns: + {"cleaned_dirs": int, "cleaned_size": int} 清理的目录数和释放的空间(字节) + """ + chunk_temp_dir = cls._get_chunk_temp_dir() + if not chunk_temp_dir.exists(): + return {"cleaned_dirs": 0, "cleaned_size": 0} + + threshold = datetime.now() - timedelta(days=days) + cleaned_dirs = 0 + cleaned_size = 0 + + try: + for upload_dir in chunk_temp_dir.iterdir(): + if not upload_dir.is_dir(): + continue + + # 检查目录修改时间 + dir_mtime = datetime.fromtimestamp(upload_dir.stat().st_mtime) + if dir_mtime < threshold: + # 计算目录大小 + dir_size = sum( + f.stat().st_size for f in upload_dir.rglob("*") if f.is_file() + ) + + # 删除目录 + try: + shutil.rmtree(upload_dir) + cleaned_dirs += 1 + cleaned_size += dir_size + current_app.logger.info( + f"清理过期分片上传目录: {upload_dir.name}, 大小: {dir_size} 字节" + ) + except Exception as e: + current_app.logger.warning( + f"清理目录失败: {upload_dir}, 错误: {e}" + ) + + except Exception as e: + current_app.logger.error(f"清理过期分片上传失败: {e}") + + return {"cleaned_dirs": cleaned_dirs, "cleaned_size": cleaned_size} + + # ------------------------------------------------------------------ + # 普通上传 + # ------------------------------------------------------------------ + @classmethod + def upload_file( + cls, + file, + directory_id: Optional[str] = None, + metadata: Optional[Dict] = None, + storage_type: Optional[str] = None, + ) -> Dict: + """ + 上传文件 + + Returns: + {"file": SysFile, "instantUpload": bool} + """ + metadata = metadata or {} + + # 如果未指定目录,使用默认目录 + if not directory_id: + from .sys_file_directory import SysFileDirectoryService + + directory_id = SysFileDirectoryService.get_default_directory_id() + + file.seek(0) + file_bytes = file.read() + file_hash = hashlib.md5(file_bytes).hexdigest() + file.seek(0) + + storage_type_enum = cls._resolve_storage_type(storage_type) + + existing = db.session.scalar( + select(SysFile).where( + SysFile.file_hash == file_hash, + SysFile.status == StatusEnum.ENABLED, + ) + ) + if existing and existing.storage_type == storage_type_enum: + # 秒传:更新已有记录 + existing.filename = file.filename + existing.directory_id = directory_id + existing.metadata_ = metadata if metadata else None + db.session.commit() + return {"file": existing, "instantUpload": True} + + storage = StorageManager.get_storage(storage_type_enum) + ext = os.path.splitext(file.filename or "")[1] + # 推断 MIME 类型 + mime_type = cls._guess_mime_type(file.filename, getattr(file, "mimetype", None)) + # 为支持多存储类型,前缀加上存储类型,使用冒号分隔,避免唯一索引冲突 + file_key = f"{storage_type_enum.value}:{datetime.now():%Y%m%d}/{file_hash}{ext}" + upload_result = storage.upload(BytesIO(file_bytes), file_key, mime_type) + + new_file = SysFile( + filename=file.filename, + file_key=file_key, + file_hash=file_hash, + mime_type=mime_type, + file_size=len(file_bytes), + extension=ext, + storage_type=storage_type_enum, + storage_info=upload_result + if storage_type_enum != StorageTypeEnum.LOCAL + else None, + directory_id=directory_id, + metadata_=metadata if metadata else None, + status=StatusEnum.ENABLED, + ) + db.session.add(new_file) + db.session.commit() + return {"file": new_file, "instantUpload": False} + + # ------------------------------------------------------------------ + # 分片上传(自定义协议,参考 hotgo) + # ------------------------------------------------------------------ + @classmethod + def init_chunk_upload( + cls, + filename: str, + file_size: int, + file_hash: Optional[str] = None, + chunk_size: int = 2 * 1024 * 1024, + total_chunks: Optional[int] = None, + directory_id: Optional[str] = None, + metadata: Optional[Dict] = None, + storage_type: Optional[str] = None, + ) -> Dict: + """ + 初始化分片上传任务 + + Args: + filename: 文件名 + file_size: 文件总大小 + file_hash: 文件MD5哈希(用于秒传和断点续传) + chunk_size: 分片大小 + total_chunks: 总分片数 + directory_id: 目录ID + metadata: 扩展元数据 + storage_type: 存储类型 + + Returns: + {"instantUpload": bool, "uploadId": str, "file": SysFile, "uploadedChunks": list} + """ + metadata = metadata or {} + + # 如果未指定目录,使用默认目录 + if not directory_id: + from .sys_file_directory import SysFileDirectoryService + + directory_id = SysFileDirectoryService.get_default_directory_id() + + resolved_storage_type = cls._resolve_storage_type(storage_type) + + # 秒传检测 + if file_hash: + existing = db.session.scalar( + select(SysFile) + .filter_by(file_hash=file_hash, status=StatusEnum.ENABLED) + .limit(1) + ) + if existing and existing.storage_type == resolved_storage_type: + # 秒传:更新已有记录 + existing.filename = filename + existing.directory_id = directory_id + existing.metadata_ = metadata if metadata else None + db.session.commit() + return {"instantUpload": True, "file": existing} + + import uuid + + upload_id = str(uuid.uuid4()) + ext = os.path.splitext(filename or "")[1] + + # 计算总分片数 + if total_chunks is None: + total_chunks = (file_size + chunk_size - 1) // chunk_size + + # 检查是否存在未完成的上传任务(断点续传) + existing_chunks = [] + if file_hash: + # 尝试通过文件哈希查找已存在的上传任务 + # 遍历所有上传任务,查找匹配的 file_hash + chunk_temp_dir = cls._get_chunk_temp_dir() + if chunk_temp_dir.exists(): + for existing_upload_dir in chunk_temp_dir.iterdir(): + if not existing_upload_dir.is_dir(): + continue + + # 检查缓存中的上传任务信息 + existing_upload_id = existing_upload_dir.name + cache_key = cls._chunk_upload_cache_key(existing_upload_id) + cached_data = cache_simple.get(cache_key) + + if cached_data and cached_data.get("file_hash") == file_hash: + # 找到匹配的上传任务,复用该 upload_id + upload_id = existing_upload_id + current_app.logger.info( + f"断点续传 - 复用上传任务: {upload_id}, file_hash: {file_hash}" + ) + break + + # 检查临时目录中已存在的分片文件 + upload_dir = cls._get_chunk_temp_dir() / upload_id + if upload_dir.exists(): + for chunk_file in upload_dir.glob("chunk_*"): + try: + # 从文件名中提取分片索引 + chunk_index = int(chunk_file.name.split("_")[1]) + if 0 <= chunk_index < total_chunks: + existing_chunks.append(chunk_index) + except (ValueError, IndexError): + # 忽略无效的文件名 + current_app.logger.warning(f"无效的分片文件名: {chunk_file.name}") + continue + + # 排序分片索引 + existing_chunks.sort() + + if existing_chunks: + current_app.logger.info( + f"断点续传 - upload_id: {upload_id}, " + f"已上传分片: {len(existing_chunks)}/{total_chunks}, " + f"分片列表: {existing_chunks}" + ) + + upload_data = { + "upload_id": upload_id, + "filename": filename, + "file_size": file_size, + "file_hash": file_hash, + "chunk_size": chunk_size, + "total_chunks": total_chunks, + "uploaded_chunks": existing_chunks, # 已上传的分片索引列表 + "storage_type": resolved_storage_type.value, # 存储为字符串值 + "directory_id": directory_id, + "metadata": metadata, + "extension": ext, + "created_at": datetime.now().isoformat(), + } + + cache_simple.set( + cls._chunk_upload_cache_key(upload_id), upload_data, timeout=7 * 24 * 3600 + ) + + return { + "instantUpload": False, + "uploadId": upload_id, + "uploadedChunks": existing_chunks, + } + + @classmethod + def upload_chunk( + cls, + upload_id: str, + chunk_index: int, + chunk_data: bytes, + ) -> Dict: + """ + 上传单个分片 + + Args: + upload_id: 上传任务ID + chunk_index: 分片索引(从0开始) + chunk_data: 分片数据 + + Returns: + {"chunkIndex": int, "uploaded": bool} + """ + cache_key = cls._chunk_upload_cache_key(upload_id) + upload_data = cache_simple.get(cache_key) + if not upload_data: + raise BizException("上传任务不存在或已过期", code=404) + + total_chunks = upload_data.get("total_chunks", 0) + if chunk_index < 0 or chunk_index >= total_chunks: + raise BizException(f"分片索引无效: {chunk_index}", code=400) + + # 检查分片文件是否已存在(基于文件系统,避免缓存并发问题) + chunk_file_path = cls._get_chunk_file_path(upload_id, chunk_index) + if chunk_file_path.exists(): + # 分片已上传,跳过 + current_app.logger.debug(f"分片 {chunk_index} 文件已存在,跳过") + return {"chunkIndex": chunk_index, "uploaded": True} + + # 将分片数据写入临时文件 + temp_file_path = None + try: + # 使用临时文件 + 原子重命名来避免并发写入问题 + temp_file_path = chunk_file_path.with_suffix(".tmp") + with open(temp_file_path, "wb") as f: + f.write(chunk_data) + # 原子重命名 + temp_file_path.replace(chunk_file_path) + temp_file_path = None # 重命名成功,标记为None + except Exception as e: + # 清理临时文件 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + except: + pass + raise BizException(f"保存分片失败: {str(e)}", code=500) + + current_app.logger.debug(f"分片上传成功 - chunk_index: {chunk_index}") + + return {"chunkIndex": chunk_index, "uploaded": True} + + @classmethod + def merge_chunks( + cls, + upload_id: str, + file_hash: Optional[str] = None, + ) -> SysFile: + """ + 合并分片,生成最终文件 + + Args: + upload_id: 上传任务ID + file_hash: 文件MD5哈希(用于最终校验) + + Returns: + SysFile 记录 + """ + cache_key = cls._chunk_upload_cache_key(upload_id) + upload_data = cache_simple.get(cache_key) + if not upload_data: + raise BizException("上传任务不存在或已过期", code=404) + + total_chunks = upload_data.get("total_chunks", 0) + + # 直接检查文件系统中的分片文件,而不是依赖缓存 + existing_chunks = [] + for chunk_index in range(total_chunks): + chunk_file_path = cls._get_chunk_file_path(upload_id, chunk_index) + if chunk_file_path.exists(): + existing_chunks.append(chunk_index) + + # 记录详细信息用于调试 + current_app.logger.info( + f"合并分片 - upload_id: {upload_id}, " + f"total_chunks: {total_chunks}, " + f"existing_chunks_count: {len(existing_chunks)}, " + f"existing_chunks: {existing_chunks}" + ) + + # 检查是否所有分片都已上传 + if len(existing_chunks) != total_chunks: + # 找出缺失的分片 + expected_chunks = set(range(total_chunks)) + actual_chunks = set(existing_chunks) + missing_chunks = sorted(expected_chunks - actual_chunks) + + current_app.logger.error( + f"分片未上传完整 - upload_id: {upload_id}, 缺失分片: {missing_chunks}" + ) + + raise BizException( + f"分片未上传完整: {len(existing_chunks)}/{total_chunks}, 缺失分片: {missing_chunks}", + code=400, + ) + + # 流式合并分片 - 避免内存溢出 + # 使用临时文件而不是内存来合并大文件 + temp_merged_file = None + temp_merged_file_path = None + md5_hash = hashlib.md5() + total_size = 0 + file_record = None + + try: + # 创建临时合并文件 + temp_merged_file = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") + temp_merged_file_path = temp_merged_file.name + current_app.logger.info(f"创建临时合并文件: {temp_merged_file_path}") + + # 流式读取并合并分片(每次只读取一个分片到内存) + BUFFER_SIZE = 8 * 1024 * 1024 # 8MB 缓冲区 + chunk_file = None + + try: + for chunk_index in range(total_chunks): + chunk_file_path = cls._get_chunk_file_path(upload_id, chunk_index) + if not chunk_file_path.exists(): + raise BizException(f"分片文件丢失: {chunk_index}", code=500) + + # 流式复制分片内容 + chunk_file = open(chunk_file_path, "rb") + try: + while True: + buffer = chunk_file.read(BUFFER_SIZE) + if not buffer: + break + temp_merged_file.write(buffer) + md5_hash.update(buffer) + total_size += len(buffer) + finally: + chunk_file.close() + chunk_file = None + + # 定期记录进度 + if (chunk_index + 1) % 50 == 0 or chunk_index == total_chunks - 1: + current_app.logger.info( + f"合并进度: {chunk_index + 1}/{total_chunks} " + f"({(chunk_index + 1) / total_chunks * 100:.1f}%)" + ) + finally: + # 确保所有分片文件句柄都被关闭 + if chunk_file: + try: + chunk_file.close() + except: + pass + + # 刷新并关闭临时文件 + temp_merged_file.flush() + os.fsync(temp_merged_file.fileno()) # 强制写入磁盘 + temp_merged_file.close() + temp_merged_file = None + + actual_hash = md5_hash.hexdigest() + + current_app.logger.info( + f"分片合并完成 - 文件大小: {total_size} 字节, " + f"MD5: {actual_hash}" + ) + + # 校验文件哈希 + if file_hash and actual_hash != file_hash: + raise BizException("文件哈希校验失败", code=400) + + # 上传到存储(从缓存中获取的是字符串值,需要转换为 enum) + storage_type_enum = cls._resolve_storage_type( + upload_data.get("storage_type") + ) + storage = StorageManager.get_storage(storage_type_enum) + + filename = upload_data.get("filename") + ext = upload_data.get("extension", "") + # 推断 MIME 类型 + mime_type = cls._guess_mime_type(filename) + file_key = ( + f"{storage_type_enum.value}:{datetime.now():%Y%m%d}/{actual_hash}{ext}" + ) + + # 重新打开临时文件用于上传(流式传输) + merged_stream = None + try: + merged_stream = open(temp_merged_file_path, "rb") + current_app.logger.info(f"开始上传到存储: {file_key}") + upload_result = storage.upload(merged_stream, file_key, mime_type) + current_app.logger.info(f"上传完成: {file_key}") + finally: + if merged_stream: + try: + merged_stream.close() + except: + pass + + # 创建文件记录 + metadata = upload_data.get("metadata", {}) + file_record = SysFile( + filename=filename, + file_key=file_key, + file_hash=actual_hash, + file_size=total_size, + extension=ext, + mime_type=mime_type, + storage_type=storage_type_enum, + storage_info=upload_result + if storage_type_enum != StorageTypeEnum.LOCAL + else None, + directory_id=upload_data.get("directory_id"), + metadata_=metadata if metadata else None, + status=StatusEnum.ENABLED, + ) + db.session.add(file_record) + db.session.commit() + + return file_record + + except Exception as e: + # 发生异常时回滚数据库事务 + try: + db.session.rollback() + except: + pass + + current_app.logger.error( + f"合并分片失败 - upload_id: {upload_id}, 错误: {str(e)}" + ) + raise + + finally: + # 清理临时合并文件(无论成功还是失败) + if temp_merged_file: + try: + temp_merged_file.close() + except: + pass + + if temp_merged_file_path and os.path.exists(temp_merged_file_path): + try: + os.remove(temp_merged_file_path) + current_app.logger.info(f"清理临时合并文件: {temp_merged_file_path}") + except Exception as e: + current_app.logger.warning(f"清理临时合并文件失败: {e}") + + # 只有成功时才清理缓存和分片文件 + # 失败时保留分片文件,支持断点续传 + if file_record: + try: + cache_simple.delete(cache_key) + cls._cleanup_upload_temp_dir(upload_id) + except Exception as e: + current_app.logger.warning(f"清理上传临时数据失败: {e}") + + @classmethod + def get_chunk_upload_progress(cls, upload_id: str) -> Dict: + """ + 获取分片上传进度 + + Args: + upload_id: 上传任务ID + + Returns: + {"uploadId": str, "totalChunks": int, "uploadedChunks": list, "progress": float} + """ + cache_key = cls._chunk_upload_cache_key(upload_id) + upload_data = cache_simple.get(cache_key) + if not upload_data: + raise BizException("上传任务不存在或已过期", code=404) + + total_chunks = upload_data.get("total_chunks", 0) + uploaded_chunks = upload_data.get("uploaded_chunks", []) + progress = ( + (len(uploaded_chunks) / total_chunks * 100) if total_chunks > 0 else 0 + ) + + return { + "uploadId": upload_id, + "totalChunks": total_chunks, + "uploadedChunks": uploaded_chunks, + "progress": round(progress, 2), + } + + @classmethod + def abort_chunk_upload(cls, upload_id: str) -> None: + """ + 取消分片上传,清理临时数据 + + Args: + upload_id: 上传任务ID + """ + cache_key = cls._chunk_upload_cache_key(upload_id) + upload_data = cache_simple.get(cache_key) + if not upload_data: + raise BizException("上传任务不存在或已过期", code=404) + + # 清理上传任务缓存 + cache_simple.delete(cache_key) + + # 清理临时文件 + cls._cleanup_upload_temp_dir(upload_id) + + # ------------------------------------------------------------------ + # 文件访问工具 + # ------------------------------------------------------------------ + @staticmethod + def get_file_by_id(file_id: str) -> SysFile: + file_obj = db.session.get(SysFile, file_id) + if not file_obj or file_obj.status != StatusEnum.ENABLED: + raise BizException("文件不存在", code=404) + return file_obj + + @classmethod + def get_file_url(cls, file_id: str, expires: int = 3600) -> str: + """ + 获取文件访问URL + + Args: + file_id: 文件ID + expires: 过期时间(秒),0表示永久,仅对OSS生效 + + Returns: + 文件访问URL + """ + file_obj = cls.get_file_by_id(file_id) + storage = StorageManager.get_storage(file_obj.storage_type) + + # 本地存储返回后端下载路由 + if file_obj.storage_type == StorageTypeEnum.LOCAL: + backend_url = cls._get_backend_url() + return f"{backend_url}/file/{file_id}/download" + + # OSS存储返回直接访问URL + return storage.get_url(file_obj.file_key, expires=expires) + + @classmethod + def get_preview_url(cls, file_id: str) -> str: + """ + 获取预览URL + + - local: 返回后端预览路由 + - 非local: 委托存储适配器生成签名直链 + """ + file_obj = cls.get_file_by_id(file_id) + if file_obj.storage_type == StorageTypeEnum.LOCAL: + backend_url = cls._get_backend_url() + return f"{backend_url}/file/{file_id}/preview" + storage = StorageManager.get_storage(file_obj.storage_type) + return storage.get_preview_url(file_obj.file_key, expires=3600) + + @classmethod + def get_thumbnail_url( + cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit", include_params: bool = False + ) -> Optional[str]: + """ + 获取缩略图URL + + 策略: + - local: 返回后端缩略图路由(实时生成) + - 阿里云OSS: 使用 OSS 图片处理能力(外链) + - 其他存储: 返回后端缩略图路由(下载后本地生成) + + Args: + file_id: 文件ID + width: 宽度 + height: 高度 + mode: 模式(fit/fill/pad) + include_params: 是否在URL中包含参数(默认True,设为False时返回不带参数的基础URL) + + Returns: + 缩略图URL,非图片返回 None + """ + file_obj = cls.get_file_by_id(file_id) + if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"): + return None + + backend_url = cls._get_backend_url() + + # 本地存储:返回后端缩略图路由 + if file_obj.storage_type == StorageTypeEnum.LOCAL: + base_url = f"{backend_url}/file/{file_id}/thumbnail" + if include_params: + return f"{base_url}?w={width}&h={height}&mode={mode}" + return base_url + + storage = StorageManager.get_storage(file_obj.storage_type) + + # 阿里云 OSS:支持图片处理,返回外链 + if file_obj.storage_type == StorageTypeEnum.ALIYUN_OSS: + if include_params: + return storage.get_thumbnail_url( + file_obj.file_key, width=width, height=height, mode=mode, expires=3600 + ) + # 阿里云OSS不带参数时返回原图URL + return storage.get_url(file_obj.file_key, expires=3600) + + # 其他存储(MinIO、腾讯云等):不支持图片处理,返回后端路由 + # 后端会下载原图后生成缩略图 + base_url = f"{backend_url}/file/{file_id}/thumbnail" + if include_params: + return f"{base_url}?w={width}&h={height}&mode={mode}" + return base_url + + @classmethod + def get_thumbnail( + cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit" + ) -> BytesIO: + """ + 生成缩略图 + + 策略: + - local: 直接从本地读取并生成 + - 阿里云OSS: 使用 OSS 图片处理(不应该调用此方法) + - 其他存储: 下载原图后生成缩略图 + + Args: + file_id: 文件ID + width: 宽度 + height: 高度 + mode: 模式(fit/fill/pad) + + Returns: + 缩略图数据流 + """ + file_obj = cls.get_file_by_id(file_id) + + # 非图片类型不支持缩略图 + if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"): + raise BizException("该文件类型不支持缩略图", code=400) + + # 检查 Pillow 是否安装 + try: + from PIL import Image + except ImportError: + raise BizException("需要安装 Pillow: pip install Pillow", code=500) + + # 下载原图(无论是本地还是 OSS) + _, file_stream = cls.download_file(file_id) + img = Image.open(file_stream) + + # 转换为 RGB(处理 PNG 透明通道等) + if img.mode in ("RGBA", "LA", "P"): + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + + # 根据模式调整大小 + if mode == "fill": + # 填充模式:裁剪居中 + img.thumbnail((width * 2, height * 2), Image.Resampling.LANCZOS) + left = (img.width - width) / 2 + top = (img.height - height) / 2 + img = img.crop((left, top, left + width, top + height)) + elif mode == "pad": + # 填充模式:保持比例,添加白边 + img.thumbnail((width, height), Image.Resampling.LANCZOS) + background = Image.new("RGB", (width, height), (255, 255, 255)) + offset = ((width - img.width) // 2, (height - img.height) // 2) + background.paste(img, offset) + img = background + else: # fit (默认) + # 适应模式:保持比例 + img.thumbnail((width, height), Image.Resampling.LANCZOS) + + # 保存为 JPEG + output = BytesIO() + img.save(output, format="JPEG", quality=85, optimize=True) + output.seek(0) + + return output + + @classmethod + def download_file(cls, file_id: str) -> tuple[SysFile, BytesIO]: + """ + 下载文件 + + Args: + file_id: 文件ID + + Returns: + (文件对象, 文件流) + """ + file_obj = cls.get_file_by_id(file_id) + storage = StorageManager.get_storage(file_obj.storage_type) + file_stream = storage.download(file_obj.file_key) + return file_obj, file_stream + + # ------------------------------------------------------------------ + # 回收站功能 + # ------------------------------------------------------------------ + @classmethod + def move_to_recycle(cls, file_id: str, user_id: Optional[str] = None) -> None: + """ + 移动文件到回收站 + + Args: + file_id: 文件ID + user_id: 操作用户ID + """ + from iti.applications.service.sys.sys_config import get_bool + + # 检查回收站功能是否启用 + if not get_bool("FILE_RECYCLE_ENABLED", type="SYSTEM", default=True): + # 回收站未启用,直接物理删除 + cls.delete_file_permanently(file_id) + return + + file_obj = cls.get_file_by_id(file_id) + file_obj.is_deleted = True + file_obj.deleted_at = datetime.now() + file_obj.deleted_by = user_id + file_obj.status = StatusEnum.DISABLED + db.session.commit() + + @classmethod + def restore_from_recycle(cls, file_id: str) -> None: + """ + 从回收站恢复文件 + + Args: + file_id: 文件ID + """ + file_obj = db.session.get(SysFile, file_id) + if not file_obj: + raise BizException("文件不存在", code=404) + + file_obj.is_deleted = False + file_obj.deleted_at = None + file_obj.deleted_by = None + file_obj.status = StatusEnum.ENABLED + db.session.commit() + + @classmethod + def delete_file_permanently(cls, file_id: str) -> None: + """ + 永久删除文件(物理删除) + + Args: + file_id: 文件ID + """ + file_obj = db.session.get(SysFile, file_id) + if not file_obj: + raise BizException("文件不存在", code=404) + + # 删除存储中的文件 + storage = StorageManager.get_storage(file_obj.storage_type) + try: + storage.delete(file_obj.file_key) + except FileNotFoundError: + pass + + # 删除数据库记录 + db.session.delete(file_obj) + db.session.commit() + + @classmethod + def clear_recycle_bin(cls, days: int = 30) -> int: + """ + 清空回收站(删除指定天数前的文件) + + Args: + days: 保留天数,默认30天 + + Returns: + 删除的文件数量 + """ + from datetime import timedelta + + threshold = datetime.now() - timedelta(days=days) + files = db.session.scalars( + select(SysFile).filter( + SysFile.is_deleted == True, SysFile.deleted_at < threshold + ) + ).all() + + count = 0 + for file_obj in files: + try: + cls.delete_file_permanently(file_obj.id) + count += 1 + except Exception: + continue + + return count + + # ------------------------------------------------------------------ + # 分享功能 + # ------------------------------------------------------------------ + @classmethod + def create_share( + cls, + file_id: str, + password: Optional[str] = None, + expire_hours: Optional[int] = None, + ) -> Dict: + """ + 创建文件分享 + + Args: + file_id: 文件ID + password: 分享密码(可选) + expire_hours: 过期小时数(可选,None表示永久) + + Returns: + {"share_code": str, "share_url": str} + """ + from iti.applications.service.sys.sys_config import get_bool + + # 检查分享功能是否启用 + if not get_bool("FILE_SHARE_ENABLED", type="SYSTEM", default=True): + raise BizException("文件分享功能未启用", code=403) + + file_obj = cls.get_file_by_id(file_id) + + # 生成分享码 + import secrets + + share_code = secrets.token_urlsafe(8) + + # 计算过期时间 + expire_at = None + if expire_hours: + from datetime import timedelta + + expire_at = datetime.now() + timedelta(hours=expire_hours) + + file_obj.share_code = share_code + file_obj.share_password = password + file_obj.share_expire_at = expire_at + file_obj.share_count = 0 + db.session.commit() + + # 生成分享链接 + backend_url = cls._get_backend_url() + share_url = f"{backend_url}/file/share/{share_code}" + + return { + "share_code": share_code, + "share_url": share_url, + "password": password, + "expire_at": expire_at.isoformat() if expire_at else None, + } + + @classmethod + def cancel_share(cls, file_id: str) -> None: + """ + 取消文件分享 + + Args: + file_id: 文件ID + """ + file_obj = cls.get_file_by_id(file_id) + file_obj.share_code = None + file_obj.share_password = None + file_obj.share_expire_at = None + db.session.commit() + + @classmethod + def get_file_by_share_code( + cls, share_code: str, password: Optional[str] = None + ) -> SysFile: + """ + 通过分享码获取文件 + + Args: + share_code: 分享码 + password: 分享密码 + + Returns: + 文件对象 + """ + file_obj = db.session.scalar( + select(SysFile).filter_by( + share_code=share_code, status=StatusEnum.ENABLED + ) + ) + + if not file_obj: + raise BizException("分享不存在或已失效", code=404) + + # 检查是否过期 + if file_obj.share_expire_at and file_obj.share_expire_at < datetime.now(): + raise BizException("分享已过期", code=403) + + # 检查密码 + if file_obj.share_password and file_obj.share_password != password: + raise BizException("分享密码错误", code=403) + + # 增加访问次数 + file_obj.share_count += 1 + db.session.commit() + + return file_obj + + @staticmethod + def _chunk_upload_cache_key(upload_id: str) -> str: + """分片上传任务缓存键""" + return f"{SysFileService.CHUNK_UPLOAD_CACHE_PREFIX}{upload_id}" + + @staticmethod + def _resolve_storage_type( + storage_type: Optional[str | StorageTypeEnum], + ) -> StorageTypeEnum: + """ + 解析存储类型 + + Args: + storage_type: 用户指定的存储类型(可以是字符串或 StorageTypeEnum) + + Returns: + StorageTypeEnum 枚举对象 + """ + # 如果已经是枚举对象,直接返回 + if isinstance(storage_type, StorageTypeEnum): + return storage_type + + # 如果未指定,从配置读取默认值 + if not storage_type: + file_storage_config = current_app.config.get("FILE_STORAGE", {}) + storage_type = file_storage_config.get("DEFAULT_STORAGE_TYPE", "local") + + # 将字符串转换为枚举 + try: + return StorageTypeEnum(storage_type) + except ValueError: + current_app.logger.warning( + f"存储类型 '{storage_type}' 无效,使用 'local' 作为后备" + ) + return StorageTypeEnum.LOCAL diff --git a/iti/applications/service/sys/sys_file_config.py b/iti/applications/service/sys/sys_file_config.py new file mode 100644 index 0000000..d51e2fd --- /dev/null +++ b/iti/applications/service/sys/sys_file_config.py @@ -0,0 +1,100 @@ +"""文件系统配置服务""" + +from __future__ import annotations + + +def init_app(app): + """初始化文件系统配置(系统启动时自动执行)""" + with app.app_context(): + # pass + _init_file_system_config() + + +def _init_file_system_config(): + """初始化文件系统配置""" + from iti.applications.extensions import db + from iti.applications.models import SysConfig + from iti.applications.common.enums import StatusEnum + + configs = [ + { + "type": "SYSTEM", + "name": "后端访问地址", + "code": "BACKEND_URL", + "value": "http://localhost:5000", + "desc": "后端访问地址。应配置为前端可访问的后端地址(通常是Nginx反向代理的公网地址)", + "sort": 90, + }, + { + "type": "SYSTEM", + "name": "文件回收站功能", + "code": "FILE_RECYCLE_ENABLED", + "value": "true", + "desc": "是否启用文件回收站功能。启用后,删除文件会移动到回收站而不是直接删除", + "sort": 100, + }, + { + "type": "SYSTEM", + "name": "回收站保留天数", + "code": "FILE_RECYCLE_DAYS", + "value": "30", + "desc": "回收站文件保留天数,超过此天数的文件将被自动清理", + "sort": 101, + }, + { + "type": "SYSTEM", + "name": "文件分享功能", + "code": "FILE_SHARE_ENABLED", + "value": "true", + "desc": "是否启用文件分享功能", + "sort": 102, + }, + { + "type": "SYSTEM", + "name": "分享默认过期时间", + "code": "FILE_SHARE_DEFAULT_EXPIRE_HOURS", + "value": "168", + "desc": "文件分享默认过期时间(小时),168小时=7天,0表示永久", + "sort": 103, + }, + { + "type": "SYSTEM", + "name": "分片上传阈值", + "code": "FILE_CHUNK_THRESHOLD", + "value": "104857600", + "desc": "文件大小超过此阈值(字节)时使用分片上传,默认100MB。前端根据此值判断使用直接上传还是分片上传", + "sort": 104, + }, + { + "type": "SYSTEM", + "name": "分片上传分片大小", + "code": "FILE_CHUNK_SIZE", + "value": "2097152", + "desc": "分片上传时每个分片的大小(字节),默认2MB", + "sort": 105, + }, + ] + + for config_data in configs: + # 检查配置是否已存在 + existing = SysConfig.query.filter_by( + type=config_data["type"], code=config_data["code"] + ).first() + + if not existing: + config = SysConfig( + type=config_data["type"], + name=config_data["name"], + code=config_data["code"], + value=config_data["value"], + desc=config_data["desc"], + sort=config_data["sort"], + status=StatusEnum.ENABLED, + ) + db.session.add(config) + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + print(f"⚠️ 文件系统配置初始化失败: {e}") diff --git a/iti/applications/service/sys/sys_file_directory.py b/iti/applications/service/sys/sys_file_directory.py new file mode 100644 index 0000000..2e57fef --- /dev/null +++ b/iti/applications/service/sys/sys_file_directory.py @@ -0,0 +1,182 @@ +""" +文件目录服务 +""" + +from __future__ import annotations + +from typing import Optional, List +from sqlalchemy import select + +from iti.applications.extensions import db +from iti.applications.models import SysFileDirectory +from iti.applications.common.enums import StatusEnum +from iti.applications.common.exceptions.biz_exp import BizException + + +def init_app(app): + """初始化文件目录(系统启动时自动执行)""" + with app.app_context(): + try: + SysFileDirectoryService.ensure_default_directory() + except Exception as e: + print(f"⚠️ 默认目录初始化失败: {e}") + + +class SysFileDirectoryService: + """文件目录服务""" + + # 默认目录固定ID(使用标准UUID格式) + DEFAULT_DIRECTORY_ID = "00000000000000000000000000000001" + + @classmethod + def ensure_default_directory(cls) -> SysFileDirectory: + """ + 确保默认目录存在 + + Returns: + 默认目录对象 + """ + # 使用固定ID查找默认目录 + default_dir = db.session.get(SysFileDirectory, cls.DEFAULT_DIRECTORY_ID) + + if not default_dir: + # 创建默认目录,使用固定ID + default_dir = SysFileDirectory( + id=cls.DEFAULT_DIRECTORY_ID, + name="默认目录", + path="/default", + parent_id=None, + level=0, + sort=0, + icon="folder", + description="系统默认文件目录", + status=StatusEnum.ENABLED, + ) + db.session.add(default_dir) + db.session.commit() + + return default_dir + + @classmethod + def get_default_directory_id(cls) -> str: + """ + 获取默认目录ID(直接返回固定ID,无需查询数据库) + + Returns: + 默认目录ID + """ + return cls.DEFAULT_DIRECTORY_ID + + @classmethod + def create_directory( + cls, + name: str, + parent_id: Optional[str] = None, + icon: Optional[str] = None, + color: Optional[str] = None, + description: Optional[str] = None, + ) -> SysFileDirectory: + """ + 创建目录 + + Args: + name: 目录名称 + parent_id: 父目录ID + icon: 图标 + color: 颜色 + description: 描述 + + Returns: + 目录对象 + """ + # 计算路径和层级 + if parent_id: + parent = db.session.get(SysFileDirectory, parent_id) + if not parent: + raise BizException("父目录不存在", code=404) + path = f"{parent.path}/{name}" + level = parent.level + 1 + else: + path = f"/{name}" + level = 0 + + # 检查路径是否已存在 + existing = db.session.scalar(select(SysFileDirectory).filter_by(path=path)) + if existing: + raise BizException("目录路径已存在", code=400) + + directory = SysFileDirectory( + name=name, + path=path, + parent_id=parent_id, + level=level, + sort=0, + icon=icon, + color=color, + description=description, + status=StatusEnum.ENABLED, + ) + db.session.add(directory) + db.session.commit() + + return directory + + @classmethod + def get_directory_tree( + cls, parent_id: Optional[str] = None + ) -> List[SysFileDirectory]: + """ + 获取目录树 + + Args: + parent_id: 父目录ID,None表示获取根目录 + + Returns: + 目录列表 + """ + query = ( + select(SysFileDirectory) + .filter_by(parent_id=parent_id, status=StatusEnum.ENABLED) + .order_by(SysFileDirectory.sort, SysFileDirectory.created_at) + ) + + directories = db.session.scalars(query).all() + return list(directories) + + @classmethod + def delete_directory(cls, directory_id: str, force: bool = False) -> None: + """ + 删除目录 + + Args: + directory_id: 目录ID + force: 是否强制删除(包括子目录和文件) + """ + directory = db.session.get(SysFileDirectory, directory_id) + if not directory: + raise BizException("目录不存在", code=404) + + # 检查是否为默认目录 + if directory.path == "/default": + raise BizException("不能删除默认目录", code=403) + + if not force: + # 检查是否有子目录 + has_children = db.session.scalar( + select(SysFileDirectory).filter_by(parent_id=directory_id).exists() + ) + if has_children: + raise BizException("目录下有子目录,无法删除", code=400) + + # 检查是否有文件 + from iti.applications.models import SysFile + + has_files = db.session.scalar( + select(SysFile).filter_by(directory_id=directory_id).exists() + ) + if has_files: + raise BizException("目录下有文件,无法删除", code=400) + + # 删除目录 + db.session.delete(directory) + db.session.commit() diff --git a/iti/applications/service/sys_log.py b/iti/applications/service/sys/sys_log.py similarity index 100% rename from iti/applications/service/sys_log.py rename to iti/applications/service/sys/sys_log.py diff --git a/iti/applications/service/sys_menu.py b/iti/applications/service/sys/sys_menu.py similarity index 92% rename from iti/applications/service/sys_menu.py rename to iti/applications/service/sys/sys_menu.py index 2ba959c..f6130b3 100644 --- a/iti/applications/service/sys_menu.py +++ b/iti/applications/service/sys/sys_menu.py @@ -3,8 +3,7 @@ from sqlalchemy.sql._typing import ColumnExpressionArgument from typing import List, Dict, Any, Optional, Set from iti.applications.extensions import db from iti.applications.models import SysMenu -from iti.applications.models.sys_rel_role_menu import sys_role_menu -from iti.applications.models.sys_rel_user_role import sys_user_role +from iti.applications.models import sys_role_menu, sys_user_role from iti.applications.common.enums import StatusEnum, MenuTypeEnum from iti.applications.common.utils import ( build_tree_from_list, @@ -45,7 +44,10 @@ def get_menu_tree( user_menu_ids: 用户拥有的菜单ID集合,用于过滤用户实际拥有的菜单 """ # 获取所有菜单数据 - cte_query = build_descendants_cte(SysMenu.parent_id == parent_id) + if parent_id is None: + cte_query = build_descendants_cte(SysMenu.parent_id.is_(None)) + else: + cte_query = build_descendants_cte(SysMenu.parent_id == parent_id) if not include_disabled: cte_query = cte_query.filter(SysMenu.status == StatusEnum.ENABLED) if type_filter: diff --git a/iti/applications/service/sys/sys_user_attribute.py b/iti/applications/service/sys/sys_user_attribute.py new file mode 100644 index 0000000..29744c9 --- /dev/null +++ b/iti/applications/service/sys/sys_user_attribute.py @@ -0,0 +1,237 @@ +""" +用户扩展属性服务层 +""" +from typing import Dict, List, Optional +from sqlalchemy import select, delete +from iti.applications.extensions import db +from iti.applications.models import User, SysUserAttribute +from iti.applications.common.exceptions.biz_exp import BizException + + +def get_user_attributes(user_id: str, group: Optional[str] = None) -> Dict: + """ + 获取用户扩展属性 + :param user_id: 用户ID + :param group: 属性分组(可选,不传则返回所有分组) + :return: 属性字典 + """ + query = select(SysUserAttribute).filter_by(user_id=user_id) + if group: + query = query.filter_by(attr_group=group) + + attributes = db.session.scalars(query.order_by(SysUserAttribute.sort)).all() + + result = {} + for attr in attributes: + if attr.attr_group not in result: + result[attr.attr_group] = {} + result[attr.attr_group][attr.attr_key] = attr.get_typed_value() + + # 如果指定了分组,只返回该分组的数据 + if group: + return result.get(group, {}) + + return result + + +def set_user_attributes( + user_id: str, attributes: Dict, group: Optional[str] = None +) -> None: + """ + 设置用户扩展属性(批量更新或创建) + :param user_id: 用户ID + :param attributes: 属性字典 + - 如果指定了 group,则 attributes 格式为 {"key1": "value1", "key2": "value2"} + - 如果未指定 group,则 attributes 格式为 {"group1": {"key1": "value1"}, "group2": {...}} + :param group: 属性分组(可选) + """ + # 验证用户是否存在 + user = db.session.scalar(select(User).filter_by(id=user_id)) + if not user: + raise BizException("用户不存在") + + # 如果指定了分组,将属性包装成分组格式 + if group: + attributes = {group: attributes} + + for grp, attrs in attributes.items(): + for key, value in attrs.items(): + # 查找是否已存在 + existing = db.session.scalar( + select(SysUserAttribute).filter_by( + user_id=user_id, attr_group=grp, attr_key=key + ) + ) + + if existing: + # 更新现有属性 + existing.set_typed_value(value) + else: + # 创建新属性 + new_attr = SysUserAttribute( + user_id=user_id, + attr_group=grp, + attr_key=key, + attr_type="string", # 默认类型 + ) + new_attr.set_typed_value(value) + db.session.add(new_attr) + + db.session.commit() + + +def delete_user_attribute( + user_id: str, group: str, key: Optional[str] = None +) -> None: + """ + 删除用户扩展属性 + :param user_id: 用户ID + :param group: 属性分组 + :param key: 属性键(可选,不传则删除整个分组) + """ + query = delete(SysUserAttribute).filter_by(user_id=user_id, attr_group=group) + + if key: + query = query.filter_by(attr_key=key) + + db.session.execute(query) + db.session.commit() + + +def get_user_attribute_value(user_id: str, group: str, key: str): + """ + 获取单个属性值 + :param user_id: 用户ID + :param group: 属性分组 + :param key: 属性键 + :return: 属性值 + """ + attr = db.session.scalar( + select(SysUserAttribute).filter_by( + user_id=user_id, attr_group=group, attr_key=key + ) + ) + + if not attr: + return None + + return attr.get_typed_value() + + +def set_user_attribute_value( + user_id: str, group: str, key: str, value, attr_type: str = "string" +) -> None: + """ + 设置单个属性值 + :param user_id: 用户ID + :param group: 属性分组 + :param key: 属性键 + :param value: 属性值 + :param attr_type: 属性类型 + """ + # 验证用户是否存在 + user = db.session.scalar(select(User).filter_by(id=user_id)) + if not user: + raise BizException("用户不存在") + + # 查找是否已存在 + attr = db.session.scalar( + select(SysUserAttribute).filter_by( + user_id=user_id, attr_group=group, attr_key=key + ) + ) + + if attr: + # 更新现有属性 + attr.attr_type = attr_type + attr.set_typed_value(value) + else: + # 创建新属性 + new_attr = SysUserAttribute( + user_id=user_id, + attr_group=group, + attr_key=key, + attr_type=attr_type, + ) + new_attr.set_typed_value(value) + db.session.add(new_attr) + + db.session.commit() + + +def batch_set_user_attributes_with_type( + user_id: str, attributes: List[Dict] +) -> None: + """ + 批量设置用户扩展属性(支持指定类型) + :param user_id: 用户ID + :param attributes: 属性列表,格式: + [ + { + "attr_group": "erp", + "attr_key": "erp_username", + "attr_value": "ERP001", + "attr_type": "string", + "description": "ERP用户名", + "sort": 1 + }, + ... + ] + """ + # 验证用户是否存在 + user = db.session.scalar(select(User).filter_by(id=user_id)) + if not user: + raise BizException("用户不存在") + + for attr_data in attributes: + group = attr_data.get("attr_group") + key = attr_data.get("attr_key") + value = attr_data.get("attr_value") + attr_type = attr_data.get("attr_type", "string") + description = attr_data.get("description") + sort = attr_data.get("sort", 0) + + if not group or not key: + continue + + # 查找是否已存在 + existing = db.session.scalar( + select(SysUserAttribute).filter_by( + user_id=user_id, attr_group=group, attr_key=key + ) + ) + + if existing: + # 更新现有属性 + existing.attr_type = attr_type + existing.set_typed_value(value) + if description: + existing.description = description + existing.sort = sort + else: + # 创建新属性 + new_attr = SysUserAttribute( + user_id=user_id, + attr_group=group, + attr_key=key, + attr_type=attr_type, + description=description, + sort=sort, + ) + new_attr.set_typed_value(value) + db.session.add(new_attr) + + db.session.commit() + + +def get_user_erp_credentials(user_id: str) -> Dict[str, Optional[str]]: + """ + 获取用户的 ERP 凭证(便捷方法) + :param user_id: 用户ID + :return: {"erp_username": "xxx", "erp_password": "xxx"} + """ + erp_attrs = get_user_attributes(user_id, group='erp') + return { + "erp_username": erp_attrs.get('erp_username'), + "erp_password": erp_attrs.get('erp_password'), + } diff --git a/iti/applications/service/verification_code.py b/iti/applications/service/sys/verification_code.py similarity index 100% rename from iti/applications/service/verification_code.py rename to iti/applications/service/sys/verification_code.py diff --git a/iti/applications/service/sys_file.py b/iti/applications/service/sys_file.py deleted file mode 100644 index 3e41836..0000000 --- a/iti/applications/service/sys_file.py +++ /dev/null @@ -1,373 +0,0 @@ -from __future__ import annotations - -import hashlib -import os -from datetime import datetime -from io import BytesIO -from typing import Dict, Optional - -from flask import current_app, url_for -from sqlalchemy import select, exists - -from iti.applications.common.enums import StatusEnum -from iti.applications.common.exceptions.biz_exp import BizException -from iti.applications.common.storage import StorageManager -from iti.applications.extensions import cache_simple, db -from iti.applications.models import SysFile, SysFileDirectory - - -class SysFileService: - TUS_CACHE_PREFIX = "tus_upload:" - - # ------------------------------------------------------------------ - # 普通上传 - # ------------------------------------------------------------------ - @classmethod - def upload_file( - cls, - file, - directory_id: Optional[str] = None, - metadata: Optional[Dict] = None, - storage_type: Optional[str] = None, - ) -> SysFile: - metadata = metadata or {} - - file.seek(0) - file_bytes = file.read() - file_hash = hashlib.md5(file_bytes).hexdigest() - file.seek(0) - - resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id) - - existing = db.session.scalar( - select( - exists(SysFile).where( - SysFile.file_hash == file_hash, - SysFile.status == StatusEnum.ENABLED.value, - ) - ) - ) - if existing and existing.storage_type == resolved_storage_type: - # 秒传:更新已有记录 - existing.filename = file.filename - existing.directory_id = directory_id - existing.metadata_ = metadata if metadata else None - db.session.commit() - return existing - - storage = StorageManager.get_storage(resolved_storage_type) - ext = os.path.splitext(file.filename or "")[1] - # 为支持多存储类型,前缀加上存储类型,使用冒号分隔,避免唯一索引冲突 - file_key = f"{resolved_storage_type}:{datetime.now():%Y%m%d}/{file_hash}{ext}" - upload_result = storage.upload( - BytesIO(file_bytes), file_key, getattr(file, "mimetype", None) - ) - - new_file = SysFile( - filename=file.filename, - file_key=file_key, - file_hash=file_hash, - mime_type=getattr(file, "mimetype", None), - file_size=len(file_bytes), - extension=ext, - storage_type=storage.storage_type, - storage_info=upload_result if resolved_storage_type != "local" else None, - directory_id=directory_id, - metadata_=metadata if metadata else None, - status=StatusEnum.ENABLED.value, - ) - db.session.add(new_file) - db.session.commit() - return new_file - - # ------------------------------------------------------------------ - # TUS 上传 - # ------------------------------------------------------------------ - @classmethod - def init_tus_upload( - cls, - filename: str, - file_size: int, - file_hash: Optional[str] = None, - directory_id: Optional[str] = None, - metadata: Optional[Dict] = None, - storage_type: Optional[str] = None, - ) -> Dict: - metadata = metadata or {} - - resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id) - - if file_hash: - existing = db.session.scalar( - select(SysFile) - .filter_by(file_hash=file_hash, status=StatusEnum.ENABLED.value) - .limit(1) - ) - if existing and existing.storage_type == resolved_storage_type: - # 秒传:更新已有记录 - existing.filename = filename - existing.directory_id = directory_id - existing.metadata_ = metadata if metadata else None - db.session.commit() - return {"instant_upload": True, "file": existing} - - import uuid - - upload_id = str(uuid.uuid4()) - ext = os.path.splitext(filename or "")[1] - # TUS 任务阶段同样加上存储类型前缀 - file_key = f"{resolved_storage_type}:{datetime.now():%Y%m%d}/{upload_id}{ext}" - - resolved_storage_type = cls._resolve_storage_type(storage_type, directory_id) - storage = StorageManager.get_storage(resolved_storage_type) - - upload_data = { - "upload_id": upload_id, - "filename": filename, - "file_key": file_key, - "file_hash": file_hash, - "file_size": file_size, - "offset": 0, - "storage_type": storage.storage_type, - "directory_id": directory_id, - "metadata": metadata, - "created_at": datetime.now().isoformat(), - } - - cache_simple.set(cls._cache_key(upload_id), upload_data, timeout=7 * 24 * 3600) - - return {"instant_upload": False, "upload_id": upload_id, "file_key": file_key} - - @classmethod - def get_tus_upload_progress(cls, upload_id: str) -> Dict: - upload_data = cache_simple.get(cls._cache_key(upload_id)) - if not upload_data: - raise BizException("上传任务不存在或已过期", code=404) - return { - "offset": upload_data.get("offset", 0), - "total_size": upload_data.get("file_size", 0), - } - - @classmethod - def upload_tus_chunk(cls, upload_id: str, offset: int, chunk_data: bytes) -> Dict: - cache_key = cls._cache_key(upload_id) - upload_data = cache_simple.get(cache_key) - if not upload_data: - raise BizException("上传任务不存在或已过期", code=404) - - current_offset = upload_data.get("offset", 0) - if offset != current_offset: - raise BizException( - f"偏移量不匹配: 期望 {current_offset}, 实际 {offset}", code=409 - ) - - storage = StorageManager.get_storage(upload_data["storage_type"]) - - chunk_stream = BytesIO(chunk_data) - storage.append_chunk(upload_data["file_key"], chunk_stream, offset) - - new_offset = offset + len(chunk_data) - upload_data["offset"] = new_offset - - file_size = upload_data.get("file_size", 0) - if new_offset >= file_size: - storage_type = upload_data.get("storage_type") - metadata = upload_data.get("metadata") - file_record = SysFile( - filename=upload_data.get("filename"), - file_key=upload_data.get("file_key"), - file_hash=upload_data.get("file_hash"), - file_size=file_size, - extension=os.path.splitext(upload_data.get("filename") or "")[1], - storage_type=storage_type, - storage_info=upload_data.get("storage_info") - if storage_type != "local" - else None, - directory_id=upload_data.get("directory_id"), - metadata_=metadata if metadata else None, - status=StatusEnum.ENABLED.value, - ) - db.session.add(file_record) - db.session.commit() - cache_simple.delete(cache_key) - return {"completed": True, "new_offset": new_offset, "file": file_record} - - cache_simple.set(cache_key, upload_data, timeout=7 * 24 * 3600) - return {"completed": False, "new_offset": new_offset} - - @classmethod - def abort_tus_upload(cls, upload_id: str) -> None: - cache_key = cls._cache_key(upload_id) - upload_data = cache_simple.get(cache_key) - if not upload_data: - raise BizException("上传任务不存在或已过期", code=404) - - storage = StorageManager.get_storage(upload_data["storage_type"]) - try: - storage.delete(upload_data["file_key"]) - except FileNotFoundError: - pass - cache_simple.delete(cache_key) - - # ------------------------------------------------------------------ - # 文件访问工具 - # ------------------------------------------------------------------ - @staticmethod - def get_file_by_id(file_id: str) -> SysFile: - file_obj = db.session.get(SysFile, file_id) - if not file_obj or file_obj.status != StatusEnum.ENABLED.value: - raise BizException("文件不存在", code=404) - return file_obj - - @classmethod - def get_file_url(cls, file_id: str, expires: int = 3600) -> str: - """ - 获取文件访问URL - - Args: - file_id: 文件ID - expires: 过期时间(秒),0表示永久,仅对OSS生效 - - Returns: - 文件访问URL - """ - file_obj = cls.get_file_by_id(file_id) - storage = StorageManager.get_storage(file_obj.storage_type) - - # 本地存储返回后端下载路由 - if file_obj.storage_type == "local": - return url_for( - "sys.sys_file.download_file", file_id=file_id, _external=True - ) - - # OSS存储返回直接访问URL - return storage.get_url(file_obj.file_key, expires=expires) - - @classmethod - def get_preview_url(cls, file_id: str) -> str: - """获取预览URL - - - local: 返回后端预览路由 - - 非local: 委托存储适配器生成签名直链(可包含预览处理能力) - """ - file_obj = cls.get_file_by_id(file_id) - if file_obj.storage_type == "local": - return url_for("sys.sys_file.preview_file", file_id=file_id, _external=True) - storage = StorageManager.get_storage(file_obj.storage_type) - return storage.get_preview_url(file_obj.file_key, expires=3600) - - @classmethod - def get_thumbnail_url( - cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit" - ) -> Optional[str]: - """获取缩略图URL - - - local: 返回后端缩略图路由 - - 阿里云OSS: 使用 x-oss-process 生成处理后直链 - - 其它OSS: 暂返回原文件直链(后续可分别接入各自图片处理能力) - """ - file_obj = cls.get_file_by_id(file_id) - if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"): - return None - - if file_obj.storage_type == "local": - return url_for( - "sys.sys_file.thumbnail_file", - file_id=file_id, - w=width, - h=height, - mode=mode, - _external=True, - ) - - storage = StorageManager.get_storage(file_obj.storage_type) - # 委托存储适配器生成缩略图直链(包含签名与处理参数) - return storage.get_thumbnail_url( - file_obj.file_key, width=width, height=height, mode=mode, expires=3600 - ) - - @classmethod - def get_thumbnail( - cls, file_id: str, width: int = 200, height: int = 200, mode: str = "fit" - ) -> BytesIO: - """生成缩略图""" - file_obj = cls.get_file_by_id(file_id) - - # 非图片类型不支持缩略图 - if not file_obj.mime_type or not file_obj.mime_type.startswith("image/"): - raise BizException("该文件类型不支持缩略图", code=400) - - # OSS 存储:使用 OSS 图片处理 - if file_obj.storage_type != "local": - # 对于 OSS,直接重定向到带图片处理参数的URL - # TODO: 各OSS需要实现 get_thumbnail_url 方法 - return cls.download_file(file_id) - - # 本地存储:使用 Pillow 生成缩略图 - try: - from PIL import Image - from io import BytesIO as PILBytesIO - except ImportError: - raise BizException("需要安装 Pillow: pip install Pillow", code=500) - - # 下载原图 - file_stream = cls.download_file(file_id) - img = Image.open(file_stream) - - # 转换为 RGB(处理 PNG 透明通道等) - if img.mode in ("RGBA", "LA", "P"): - background = Image.new("RGB", img.size, (255, 255, 255)) - if img.mode == "P": - img = img.convert("RGBA") - background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) - img = background - elif img.mode != "RGB": - img = img.convert("RGB") - - # 根据模式调整大小 - if mode == "fill": - # 填充模式:裁剪居中 - img.thumbnail((width * 2, height * 2), Image.Resampling.LANCZOS) - left = (img.width - width) / 2 - top = (img.height - height) / 2 - img = img.crop((left, top, left + width, top + height)) - elif mode == "pad": - # 填充模式:保持比例,添加白边 - img.thumbnail((width, height), Image.Resampling.LANCZOS) - background = Image.new("RGB", (width, height), (255, 255, 255)) - offset = ((width - img.width) // 2, (height - img.height) // 2) - background.paste(img, offset) - img = background - else: # fit (默认) - # 适应模式:保持比例 - img.thumbnail((width, height), Image.Resampling.LANCZOS) - - # 保存为 JPEG - output = PILBytesIO() - img.save(output, format="JPEG", quality=85, optimize=True) - output.seek(0) - - return output - - @classmethod - def download_file(cls, file_id: str) -> BytesIO: - file_obj = cls.get_file_by_id(file_id) - storage = StorageManager.get_storage(file_obj.storage_type) - return storage.download(file_obj.file_key) - - @staticmethod - def _cache_key(upload_id: str) -> str: - return f"{SysFileService.TUS_CACHE_PREFIX}{upload_id}" - - @staticmethod - def _resolve_storage_type( - storage_type: Optional[str], directory_id: Optional[str] - ) -> str: - if storage_type: - return storage_type - if directory_id: - directory = db.session.get(SysFileDirectory, directory_id) - if directory and directory.default_storage_type: - return directory.default_storage_type - config = current_app.config.get("FILE_STORAGE", {}) - return config.get("DEFAULT_STORAGE_TYPE", "local") diff --git a/iti/config.py b/iti/config.py index 5659f23..5de9bcf 100644 --- a/iti/config.py +++ b/iti/config.py @@ -6,20 +6,22 @@ import json # 项目根目录 BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + def _get_bool_env(key: str, default: bool = False) -> bool: """从环境变量获取布尔值 - + Args: key: 环境变量名 default: 默认值 - + Returns: 布尔值 """ value = os.getenv(key) if value is None: return default - return value.lower() in ('true', '1', 'yes', 'on') + return value.lower() in ("true", "1", "yes", "on") + def _load_env_file(): """加载 .env 文件(模块级别调用)""" @@ -38,13 +40,13 @@ def _load_env_file(): env_path = os.path.join(os.path.dirname(__file__), env_file) if os.path.exists(env_path): loaded = load_dotenv(env_path) - print(f"📝 加载环境配置: {env_path} - {loaded}") + print(f"[ENV] 加载环境配置: {env_path} - {loaded}") return True - - print("⚠️ 未找到 .env 文件") + + print("[WARN] 未找到 .env 文件") return False except ImportError: - print("⚠️ python-dotenv 未安装,跳过 .env 文件加载") + print("[WARN] python-dotenv 未安装,跳过 .env 文件加载") return False @@ -93,6 +95,9 @@ class BaseConfig: JSON_ENSURE_ASCII = False # 支持中文,不转义为 Unicode JSON_SORT_KEYS = False # 不对 key 排序 + # 文件上传配置 + MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200MB,Flask 上传大小限制 + # 缓存配置 CACHE_SIMPLE = { # 类型 NullCache | SimpleCache | FileSystemCache | RedisCache | RedisSentinelCache | RedisClusterCache | UWSGICache | MemcachedCache | SASLMemcachedCache | SpreadSASLMemcachedCache diff --git a/migrations/versions/.gitkeep b/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/versions/0c4f1f46e5ea_.py b/migrations/versions/0c4f1f46e5ea_.py deleted file mode 100644 index 20d067a..0000000 --- a/migrations/versions/0c4f1f46e5ea_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""empty message - -Revision ID: 0c4f1f46e5ea -Revises: c46167ccbf4d -Create Date: 2025-10-21 19:31:35.196178 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0c4f1f46e5ea' -down_revision = 'c46167ccbf4d' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_dept', - sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'), - sa.Column('parent_id', sa.String(length=36), nullable=False, comment='父部门ID'), - sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('sys_dept') - # ### end Alembic commands ### diff --git a/migrations/versions/3a1d8599c640_.py b/migrations/versions/3a1d8599c640_.py deleted file mode 100644 index ea2c1e9..0000000 --- a/migrations/versions/3a1d8599c640_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: 3a1d8599c640 -Revises: 70dac20262ef -Create Date: 2025-10-24 15:28:07.476192 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '3a1d8599c640' -down_revision = '70dac20262ef' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.drop_column('json_value') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.add_column(sa.Column('json_value', sa.TEXT(), nullable=True)) - - # ### end Alembic commands ### diff --git a/migrations/versions/3b3e7f8dd32f_.py b/migrations/versions/3b3e7f8dd32f_.py deleted file mode 100644 index a95d3d2..0000000 --- a/migrations/versions/3b3e7f8dd32f_.py +++ /dev/null @@ -1,36 +0,0 @@ -"""empty message - -Revision ID: 3b3e7f8dd32f -Revises: e0addf88b922 -Create Date: 2025-10-22 23:47:18.790303 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '3b3e7f8dd32f' -down_revision = 'e0addf88b922' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_dept', schema=None) as batch_op: - batch_op.alter_column('parent_id', - existing_type=sa.VARCHAR(length=36), - nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_dept', schema=None) as batch_op: - batch_op.alter_column('parent_id', - existing_type=sa.VARCHAR(length=36), - nullable=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/5409f28814f9_.py b/migrations/versions/5409f28814f9_.py deleted file mode 100644 index 1187443..0000000 --- a/migrations/versions/5409f28814f9_.py +++ /dev/null @@ -1,88 +0,0 @@ -"""empty message - -Revision ID: 5409f28814f9 -Revises: 83b464bfff02 -Create Date: 2025-10-30 22:31:50.159590 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5409f28814f9' -down_revision = '83b464bfff02' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_file_directory', - sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'), - sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'), - sa.Column('parent_id', sa.String(length=36), nullable=True), - sa.Column('level', sa.Integer(), nullable=True, comment='层级'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'), - sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'), - sa.Column('description', sa.Text(), nullable=True, comment='目录描述'), - sa.Column('default_storage_type', sa.String(length=32), nullable=True, comment='默认存储类型'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.ForeignKeyConstraint(['parent_id'], ['sys_file_directory.id'], name=op.f('fk_sys_file_directory_parent_id_sys_file_directory'), ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_file_directory_path'), ['path'], unique=False) - - op.create_table('sys_file', - sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'), - sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'), - sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'), - sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'), - sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'), - sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'), - sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'), - sa.Column('storage_bucket', sa.String(length=128), nullable=True, comment='存储桶'), - sa.Column('storage_region', sa.String(length=64), nullable=True, comment='存储区域'), - sa.Column('storage_endpoint', sa.String(length=255), nullable=True, comment='存储端点'), - sa.Column('storage_meta', sa.JSON(), nullable=True, comment='存储元信息'), - sa.Column('directory_id', sa.String(length=36), nullable=True), - sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'), - sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'), - sa.ForeignKeyConstraint(['directory_id'], ['sys_file_directory.id'], name=op.f('fk_sys_file_directory_id_sys_file_directory'), ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')), - mysql_charset='utf8mb4', - mysql_collate='utf8mb4_general_ci' - ) - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False) - batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_file_file_key')) - batch_op.drop_index(batch_op.f('ix_sys_file_file_hash')) - batch_op.drop_index(batch_op.f('ix_sys_file_directory_id')) - - op.drop_table('sys_file') - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_file_directory_path')) - - op.drop_table('sys_file_directory') - # ### end Alembic commands ### diff --git a/migrations/versions/5c84e633f254_.py b/migrations/versions/5c84e633f254_.py deleted file mode 100644 index c4bf118..0000000 --- a/migrations/versions/5c84e633f254_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: 5c84e633f254 -Revises: 5eb37be53e1c -Create Date: 2025-10-21 16:32:48.572116 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5c84e633f254' -down_revision = '5eb37be53e1c' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.add_column(sa.Column('desc', sa.Text(), nullable=True, comment='描述')) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.drop_column('desc') - - # ### end Alembic commands ### diff --git a/migrations/versions/5eb37be53e1c_.py b/migrations/versions/5eb37be53e1c_.py deleted file mode 100644 index 29213f9..0000000 --- a/migrations/versions/5eb37be53e1c_.py +++ /dev/null @@ -1,46 +0,0 @@ -"""empty message - -Revision ID: 5eb37be53e1c -Revises: fffd191071bc -Create Date: 2025-10-20 13:47:47.890488 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '5eb37be53e1c' -down_revision = 'fffd191071bc' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.alter_column('component', - existing_type=sa.VARCHAR(length=255), - nullable=True) - batch_op.alter_column('parent_id', - existing_type=sa.VARCHAR(length=36), - nullable=True) - batch_op.create_unique_constraint(batch_op.f('uq_sys_menu_name'), ['name']) - batch_op.create_unique_constraint(batch_op.f('uq_sys_menu_path'), ['path']) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('uq_sys_menu_path'), type_='unique') - batch_op.drop_constraint(batch_op.f('uq_sys_menu_name'), type_='unique') - batch_op.alter_column('parent_id', - existing_type=sa.VARCHAR(length=36), - nullable=False) - batch_op.alter_column('component', - existing_type=sa.VARCHAR(length=255), - nullable=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/70dac20262ef_.py b/migrations/versions/70dac20262ef_.py deleted file mode 100644 index 076b853..0000000 --- a/migrations/versions/70dac20262ef_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: 70dac20262ef -Revises: 3b3e7f8dd32f -Create Date: 2025-10-23 10:05:55.795788 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '70dac20262ef' -down_revision = '3b3e7f8dd32f' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_role_menu', - sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'), - sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'), - sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu')) - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('sys_role_menu') - # ### end Alembic commands ### diff --git a/migrations/versions/83b464bfff02_.py b/migrations/versions/83b464bfff02_.py deleted file mode 100644 index 485ce24..0000000 --- a/migrations/versions/83b464bfff02_.py +++ /dev/null @@ -1,50 +0,0 @@ -"""empty message - -Revision ID: 83b464bfff02 -Revises: 3a1d8599c640 -Create Date: 2025-10-27 20:02:41.075217 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '83b464bfff02' -down_revision = '3a1d8599c640' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.alter_column('type', - existing_type=sa.VARCHAR(length=64), - type_=sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), - nullable=False) - batch_op.drop_index(batch_op.f('ix_sys_log_type')) - - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.alter_column('realname', - existing_type=sa.VARCHAR(length=32), - nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.alter_column('realname', - existing_type=sa.VARCHAR(length=32), - nullable=False) - - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_log_type'), ['type'], unique=False) - batch_op.alter_column('type', - existing_type=sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), - type_=sa.VARCHAR(length=64), - nullable=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/bfa0b0c7c62f_.py b/migrations/versions/bfa0b0c7c62f_.py deleted file mode 100644 index 837d843..0000000 --- a/migrations/versions/bfa0b0c7c62f_.py +++ /dev/null @@ -1,40 +0,0 @@ -"""empty message - -Revision ID: bfa0b0c7c62f -Revises: f02e03313631 -Create Date: 2025-10-30 23:31:12.508052 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import sqlite - -# revision identifiers, used by Alembic. -revision = 'bfa0b0c7c62f' -down_revision = 'f02e03313631' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.add_column(sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)')) - batch_op.drop_column('storage_meta') - batch_op.drop_column('storage_bucket') - batch_op.drop_column('storage_region') - batch_op.drop_column('storage_endpoint') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.add_column(sa.Column('storage_endpoint', sa.VARCHAR(length=255), nullable=True)) - batch_op.add_column(sa.Column('storage_region', sa.VARCHAR(length=64), nullable=True)) - batch_op.add_column(sa.Column('storage_bucket', sa.VARCHAR(length=128), nullable=True)) - batch_op.add_column(sa.Column('storage_meta', sqlite.JSON(), nullable=True)) - batch_op.drop_column('storage_info') - - # ### end Alembic commands ### diff --git a/migrations/versions/c46167ccbf4d_.py b/migrations/versions/c46167ccbf4d_.py deleted file mode 100644 index c74d8a3..0000000 --- a/migrations/versions/c46167ccbf4d_.py +++ /dev/null @@ -1,170 +0,0 @@ -"""empty message - -Revision ID: c46167ccbf4d -Revises: 5c84e633f254 -Create Date: 2025-10-21 19:31:04.980171 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'c46167ccbf4d' -down_revision = '5c84e633f254' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_dept', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_dict_data', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_dict_type', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_role', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.add_column(sa.Column('remark', sa.String(length=255), nullable=True, comment='备注')) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=False) - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=False) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_user', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_role', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_dict_type', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_dict_data', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_dept', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - with op.batch_alter_table('sys_config', schema=None) as batch_op: - batch_op.alter_column('updated_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.alter_column('created_at', - existing_type=sa.DATETIME(), - nullable=True) - batch_op.drop_column('remark') - - # ### end Alembic commands ### diff --git a/migrations/versions/e0addf88b922_.py b/migrations/versions/e0addf88b922_.py deleted file mode 100644 index 8403cce..0000000 --- a/migrations/versions/e0addf88b922_.py +++ /dev/null @@ -1,38 +0,0 @@ -"""empty message - -Revision ID: e0addf88b922 -Revises: eba4a4c12851 -Create Date: 2025-10-22 01:09:43.193921 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'e0addf88b922' -down_revision = 'eba4a4c12851' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.alter_column('path', - existing_type=sa.VARCHAR(length=255), - nullable=True) - batch_op.drop_constraint(batch_op.f('uq_sys_menu_path'), type_='unique') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.create_unique_constraint(batch_op.f('uq_sys_menu_path'), ['path']) - batch_op.alter_column('path', - existing_type=sa.VARCHAR(length=255), - nullable=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/eba4a4c12851_.py b/migrations/versions/eba4a4c12851_.py deleted file mode 100644 index 9e8b695..0000000 --- a/migrations/versions/eba4a4c12851_.py +++ /dev/null @@ -1,32 +0,0 @@ -"""empty message - -Revision ID: eba4a4c12851 -Revises: 0c4f1f46e5ea -Create Date: 2025-10-21 23:01:50.526401 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'eba4a4c12851' -down_revision = '0c4f1f46e5ea' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.add_column(sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码')) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_menu', schema=None) as batch_op: - batch_op.drop_column('auth_code') - - # ### end Alembic commands ### diff --git a/migrations/versions/f02e03313631_.py b/migrations/versions/f02e03313631_.py deleted file mode 100644 index 6359c8e..0000000 --- a/migrations/versions/f02e03313631_.py +++ /dev/null @@ -1,38 +0,0 @@ -"""empty message - -Revision ID: f02e03313631 -Revises: 5409f28814f9 -Create Date: 2025-10-30 22:36:59.048268 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'f02e03313631' -down_revision = '5409f28814f9' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('fk_sys_file_directory_id_sys_file_directory'), type_='foreignkey') - - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.drop_constraint(batch_op.f('fk_sys_file_directory_parent_id_sys_file_directory'), type_='foreignkey') - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('sys_file_directory', schema=None) as batch_op: - batch_op.create_foreign_key(batch_op.f('fk_sys_file_directory_parent_id_sys_file_directory'), 'sys_file_directory', ['parent_id'], ['id'], ondelete='CASCADE') - - with op.batch_alter_table('sys_file', schema=None) as batch_op: - batch_op.create_foreign_key(batch_op.f('fk_sys_file_directory_id_sys_file_directory'), 'sys_file_directory', ['directory_id'], ['id'], ondelete='SET NULL') - - # ### end Alembic commands ### diff --git a/migrations/versions/f9f008bd64bf_.py b/migrations/versions/f9f008bd64bf_.py deleted file mode 100644 index f3a4900..0000000 --- a/migrations/versions/f9f008bd64bf_.py +++ /dev/null @@ -1,149 +0,0 @@ -"""empty message - -Revision ID: f9f008bd64bf -Revises: -Create Date: 2025-10-19 11:02:21.153893 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'f9f008bd64bf' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_config', - sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'), - sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'), - sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'), - sa.Column('value', sa.Text(), nullable=True, comment='配置值'), - sa.Column('json_value', sa.Text(), nullable=True, comment='配置值(JSON格式)'), - sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')) - ) - op.create_table('sys_dept', - sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'), - sa.Column('parent_id', sa.String(length=36), nullable=False, comment='父部门ID'), - sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')) - ) - op.create_table('sys_dict_data', - sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'), - sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'), - sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'), - sa.Column('value', sa.Text(), nullable=True, comment='数据值'), - sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')) - ) - op.create_table('sys_dict_type', - sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'), - sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'), - sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')), - sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')) - ) - op.create_table('sys_log', - sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'), - sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'), - sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'), - sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'), - sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'), - sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'), - sa.Column('headers', sa.Text(), nullable=True, comment='请求头'), - sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'), - sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'), - sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'), - sa.Column('response', sa.Text(), nullable=True, comment='响应结果'), - sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'), - sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'), - sa.Column('desc', sa.Text(), nullable=True, comment='描述'), - sa.Column('type', sa.String(length=64), nullable=True, comment='日志类型'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')) - ) - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_sys_log_type'), ['type'], unique=False) - - op.create_table('sys_role', - sa.Column('name', sa.String(length=64), nullable=False, comment='名称'), - sa.Column('code', sa.String(length=64), nullable=False, comment='编码'), - sa.Column('desc', sa.Text(), nullable=True, comment='描述'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')), - sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')) - ) - op.create_table('sys_user', - sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'), - sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'), - sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'), - sa.Column('password', sa.String(length=255), nullable=False, comment='密码'), - sa.Column('realname', sa.String(length=32), nullable=False, comment='真实姓名'), - sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'), - sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')) - ) - op.create_table('sys_user_dept', - sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'), - sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'), - sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept')) - ) - op.create_table('sys_user_role', - sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'), - sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'), - sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role')) - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('sys_user_role') - op.drop_table('sys_user_dept') - op.drop_table('sys_user') - op.drop_table('sys_role') - with op.batch_alter_table('sys_log', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_sys_log_type')) - - op.drop_table('sys_log') - op.drop_table('sys_dict_type') - op.drop_table('sys_dict_data') - op.drop_table('sys_dept') - op.drop_table('sys_config') - # ### end Alembic commands ### diff --git a/migrations/versions/fffd191071bc_.py b/migrations/versions/fffd191071bc_.py deleted file mode 100644 index f743e8d..0000000 --- a/migrations/versions/fffd191071bc_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""empty message - -Revision ID: fffd191071bc -Revises: f9f008bd64bf -Create Date: 2025-10-20 11:27:19.426979 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'fffd191071bc' -down_revision = 'f9f008bd64bf' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('sys_menu', - sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'), - sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'), - sa.Column('path', sa.String(length=255), nullable=False, comment='菜单路径'), - sa.Column('component', sa.String(length=255), nullable=False, comment='菜单组件'), - sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'), - sa.Column('sort', sa.Integer(), nullable=False, comment='排序'), - sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'), - sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'), - sa.Column('parent_id', sa.String(length=36), nullable=False, comment='父菜单ID'), - sa.Column('id', sa.String(length=36), nullable=False, comment='标识'), - sa.Column('created_at', sa.DateTime(), nullable=True, comment='创建时间'), - sa.Column('updated_at', sa.DateTime(), nullable=True, comment='更新时间'), - sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')) - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('sys_menu') - # ### end Alembic commands ###