feat: 修复优化一些逻辑; 自研一个上传算法; 修正gitignore; 切换开发环境为mysql;

main
NoahLan 4 months ago
parent 269d453e07
commit dd3ccff514

@ -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. **第五阶段**: 看板和报表接口

@ -572,6 +572,7 @@ def create(json_data: CreateRequest):
6. **日志记录**: 重要操作都要记录日志,便于排查问题
7. **类型提示**: 使用 Type Hints 提高代码可读性
8. **代码复用**: 公共逻辑提取到 service 或 utils
9. **AI规则**: AI编写代码时不要生成任何文档也不要写繁琐的注释除非特别需要
## 测试规范

5
.gitignore vendored

@ -84,4 +84,7 @@ Thumbs.db
Desktop.ini
# 其他
/.python-version
/.python-version
# migrations
migrations/versions/*.py

@ -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

@ -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 # 是否启用前端渲染

@ -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则无需填写

@ -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则无需填写

@ -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)
os.makedirs(local_path, exist_ok=True)

@ -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):
"""
基础模型混入类
"""

@ -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,

@ -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}")

@ -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)

@ -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}")

@ -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使用 -1MinIO 会使用分片上传)
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)

@ -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:

@ -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",
)

@ -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

@ -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

@ -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}")

@ -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,

@ -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:

@ -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

@ -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")

@ -119,6 +119,8 @@ class SysMenuSchema(BaseSchema):
"""
菜单表响应结构
"""
class Meta:
name = "SysMenu"
id = String()
name = String()

@ -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):

@ -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()

@ -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)
# 插件初始化

@ -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)

@ -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("/<string:file_id>/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("/<string:file_id>/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("/<string:file_id>/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/<string:share_code>")
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/<string:share_code>/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)}")

@ -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哈希用于最终校验"},
)

@ -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/<string:upload_id>")
@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/<string:upload_id>/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']} 字节空间",
)

@ -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():
"""
获取用户权限编码

@ -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("/<string:id>")
@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("/<string:id>")
@jwt_required()
@bp.doc(security="JWT")
@permission("system:config:delete")
@sys_log(
name="删除系统配置",

@ -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("/<string:id>")
@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("/<string:id>")
@jwt_required()
@bp.doc(security="JWT")
@permission("system:dept:delete")
def delete_dept(id: str):
"""

@ -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/<string:id>")
@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/<string:id>")
@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/<string:id>")
@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/<string:id>")
@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/<string:id>")
@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):

@ -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/<string:upload_id>", 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/<string:upload_id>")
@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/<string:upload_id>")
@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("/<string:file_id>/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("/<string:file_id>/preview")
@jwt_required(optional=True)
def preview_file(file_id: str):
"""预览文件"""
file_obj = SysFileService.get_file_by_id(file_id)
@bp.delete("/<string:file_id>")
@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("/<string:file_id>/restore")
@jwt_required()
def restore_file(file_id: str):
"""从回收站恢复文件"""
SysFileService.restore_from_recycle(file_id)
return success(message="文件已恢复")
@bp.get("/<string:file_id>/thumbnail")
@jwt_required(optional=True)
def thumbnail_file(file_id: str):
"""获取缩略图"""
from flask import request
@bp.delete("/<string:file_id>/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("/<string:file_id>/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("/<string:file_id>/share")
@jwt_required()
def cancel_share(file_id: str):
"""取消文件分享"""
SysFileService.cancel_share(file_id)
return success(message="分享已取消")

@ -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("/<string:id>")
@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):

@ -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("/<string:id>")
@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("/<string:id>")
@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):
"""

@ -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("/<string:id>")
@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("/<string:id>")
@jwt_required()
@bp.doc(security="JWT")
@permission("system:role:delete")
@sys_log(
name="删除角色",

@ -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

@ -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("/<string:id>")
@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("/<string:id>")
@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(

@ -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)

@ -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 的子部门树,否则查询所有部门树

File diff suppressed because it is too large Load Diff

@ -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}")

@ -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: 父目录IDNone表示获取根目录
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()

@ -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:

@ -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'),
}

@ -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")

@ -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 # 200MBFlask 上传大小限制
# 缓存配置
CACHE_SIMPLE = {
# 类型 NullCache | SimpleCache | FileSystemCache | RedisCache | RedisSentinelCache | RedisClusterCache | UWSGICache | MemcachedCache | SASLMemcachedCache | SpreadSASLMemcachedCache

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###

@ -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 ###
Loading…
Cancel
Save