Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
分块上传API
"""
import hashlib
import mimetypes
import os
import shutil
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from utils.redis import RedisClient
from app.base_schema import ResponseModel
from core.file_manager.model import FileManager
from core.file_manager.schema import (
InitChunkUploadIn,
InitChunkUploadOut,
UploadChunkOut,
MergeChunksIn,
ChunkUploadStatusOut,
FileManagerResponse,
)
from core.file_manager.service import FileManagerService
from core.file_manager.storage_backends import get_storage_backend
router = APIRouter(prefix="/file_manager/chunk", tags=["分块上传"])
# 分块上传临时目录
CHUNK_UPLOAD_DIR = os.path.join('media', 'chunk_uploads')
os.makedirs(CHUNK_UPLOAD_DIR, exist_ok=True)
# 缓存过期时间(7天)
CACHE_EXPIRE_SECONDS = 7 * 24 * 3600
def get_chunk_upload_key(upload_id: str) -> str:
"""获取分块上传的缓存键"""
return f'chunk_upload:{upload_id}'
def get_chunk_set_key(upload_id: str) -> str:
"""获取分块上传的已上传分块集合键(使用Redis Set保证原子性)"""
return f'chunk_upload_set:{upload_id}'
def get_chunk_dir(upload_id: str) -> str:
"""获取分块存储目录"""
chunk_dir = os.path.join(CHUNK_UPLOAD_DIR, upload_id)
os.makedirs(chunk_dir, exist_ok=True)
return chunk_dir
def get_chunk_path(upload_id: str, chunk_index: int) -> str:
"""获取分块文件路径"""
return os.path.join(get_chunk_dir(upload_id), f'chunk_{chunk_index}')
def _build_file_response(item: FileManager) -> dict:
"""构建文件响应"""
return {
"id": item.id,
"name": item.name,
"file_type": item.type,
"parent_id": item.parent_id,
"parent_name": None,
"path": item.path,
"file_size": item.size,
"file_ext": item.file_ext,
"mime_type": item.mime_type,
"storage_type": item.storage_type,
"storage_path": item.storage_path,
"url": item.url,
"thumbnail_url": item.thumbnail_url,
"md5": item.md5,
"is_public": item.is_public,
"download_count": item.download_count,
"has_children": False,
"updated_time": item.sys_update_datetime.isoformat() if item.sys_update_datetime else (
item.sys_create_datetime.isoformat() if item.sys_create_datetime else None
),
"sys_create_datetime": item.sys_create_datetime,
"sys_update_datetime": item.sys_update_datetime,
}
@router.post("/init", response_model=InitChunkUploadOut, summary="初始化分块上传")
async def init_chunk_upload(
data: InitChunkUploadIn,
db: AsyncSession = Depends(get_db),
):
"""
初始化分块上传
- 检查文件是否已存在(秒传功能)
- 生成上传ID
- 计算分块数量
- 返回上传配置信息
"""
# 检查文件是否已存在(秒传)
if data.file_hash:
existing_file = await FileManagerService.get_by_md5(db, data.file_hash, data.total_size)
if existing_file:
# 文件已存在,秒传
return {
'upload_id': str(uuid.uuid4()),
'chunk_size': data.chunk_size,
'total_chunks': 0,
'uploaded_chunks': [],
'file_exists': True,
'file_id': existing_file.id,
}
# 生成上传ID
upload_id = str(uuid.uuid4())
# 计算总分块数
total_chunks = (data.total_size + data.chunk_size - 1) // data.chunk_size
# 在缓存中保存上传信息
upload_info = {
'upload_id': upload_id,
'filename': data.filename,
'total_size': data.total_size,
'chunk_size': data.chunk_size,
'total_chunks': total_chunks,
'uploaded_chunks': [],
'parent_id': data.parent_id,
'is_public': data.is_public,
'created_at': datetime.now().isoformat(),
}
cache_key = get_chunk_upload_key(upload_id)
await RedisClient.set(cache_key, upload_info, expire=CACHE_EXPIRE_SECONDS)
# 初始化已上传分块集合(使用Redis Set保证并发安全)
set_key = get_chunk_set_key(upload_id)
client = await RedisClient.get_client()
await client.delete(set_key)
await client.expire(set_key, CACHE_EXPIRE_SECONDS)
return {
'upload_id': upload_id,
'chunk_size': data.chunk_size,
'total_chunks': total_chunks,
'uploaded_chunks': [],
'file_exists': False,
'file_id': None,
}
@router.post("/upload", response_model=UploadChunkOut, summary="上传分块")
async def upload_chunk(
upload_id: str = Form(..., alias="uploadId"),
chunk_index: int = Form(..., alias="chunkIndex"),
chunk: UploadFile = File(...),
):
"""
上传单个分块
- 接收分块数据
- 保存到临时目录
- 更新上传进度
"""
# 获取上传信息
cache_key = get_chunk_upload_key(upload_id)
upload_info = await RedisClient.get(cache_key)
if not upload_info:
raise HTTPException(status_code=404, detail="上传会话不存在或已过期")
# 验证分块索引
if chunk_index < 0 or chunk_index >= upload_info['total_chunks']:
raise HTTPException(status_code=400, detail=f"无效的分块索引: {chunk_index}")
# 保存分块文件
chunk_path = get_chunk_path(upload_id, chunk_index)
try:
chunk_content = await chunk.read()
with open(chunk_path, 'wb') as f:
f.write(chunk_content)
# 使用 Redis SADD 原子操作更新已上传分块集合,保证并发安全
set_key = get_chunk_set_key(upload_id)
client = await RedisClient.get_client()
await client.sadd(set_key, str(chunk_index))
await client.expire(set_key, CACHE_EXPIRE_SECONDS)
return {
'chunk_index': chunk_index,
'uploaded': True,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"分块上传失败: {str(e)}")
@router.get("/status", response_model=ChunkUploadStatusOut, summary="获取分块上传状态")
async def get_chunk_upload_status(
upload_id: str = Query(..., alias="uploadId"),
):
"""
获取分块上传状态
- 查询已上传的分块
- 返回上传进度
"""
cache_key = get_chunk_upload_key(upload_id)
upload_info = await RedisClient.get(cache_key)
if not upload_info:
raise HTTPException(status_code=404, detail="上传会话不存在或已过期")
# 从 Redis Set 获取已上传分块列表
set_key = get_chunk_set_key(upload_id)
client = await RedisClient.get_client()
uploaded_set = await client.smembers(set_key)
uploaded_chunks = sorted([int(x) for x in uploaded_set])
completed = len(uploaded_chunks) == upload_info['total_chunks']
return {
'upload_id': upload_id,
'filename': upload_info['filename'],
'total_size': upload_info['total_size'],
'total_chunks': upload_info['total_chunks'],
'uploaded_chunks': uploaded_chunks,
'completed': completed,
}
@router.post("/merge", response_model=FileManagerResponse, summary="合并分块")
async def merge_chunks(
data: MergeChunksIn,
db: AsyncSession = Depends(get_db),
):
"""
合并分块文件
- 验证所有分块已上传
- 按顺序合并分块
- 计算文件MD5
- 保存到存储后端
- 创建数据库记录
- 清理临时文件
"""
upload_id = data.upload_id
cache_key = get_chunk_upload_key(upload_id)
upload_info = await RedisClient.get(cache_key)
if not upload_info:
raise HTTPException(status_code=404, detail="上传会话不存在或已过期")
# 从 Redis Set 获取已上传分块列表
set_key = get_chunk_set_key(upload_id)
client = await RedisClient.get_client()
uploaded_set = await client.smembers(set_key)
uploaded_chunks = [int(x) for x in uploaded_set]
# 验证所有分块已上传
if len(uploaded_chunks) != upload_info['total_chunks']:
missing_chunks = [
i for i in range(upload_info['total_chunks'])
if i not in uploaded_chunks
]
raise HTTPException(status_code=400, detail=f"分块上传未完成,缺少分块: {missing_chunks}")
try:
# 获取父文件夹路径
folder_path = ''
if upload_info['parent_id']:
parent = await FileManagerService.get_by_id(db, upload_info['parent_id'])
if parent and parent.type == 'folder':
folder_path = parent.path
# 创建临时合并文件
temp_merged_path = os.path.join(get_chunk_dir(upload_id), 'merged_file')
md5_hash = hashlib.md5()
# 按顺序合并分块
with open(temp_merged_path, 'wb') as merged_file:
for chunk_index in range(upload_info['total_chunks']):
chunk_path = get_chunk_path(upload_id, chunk_index)
if not os.path.exists(chunk_path):
raise HTTPException(status_code=500, detail=f"分块 {chunk_index} 不存在")
with open(chunk_path, 'rb') as chunk_file:
chunk_data = chunk_file.read()
merged_file.write(chunk_data)
md5_hash.update(chunk_data)
# 计算MD5
file_md5 = md5_hash.hexdigest()
# 检查是否已存在相同文件(合并后的秒传检查)
existing_file = await FileManagerService.get_by_md5(db, file_md5, upload_info['total_size'])
if existing_file:
# 清理临时文件和缓存
shutil.rmtree(get_chunk_dir(upload_id), ignore_errors=True)
await RedisClient.delete(cache_key)
await client.delete(set_key)
# 返回已存在的文件
return _build_file_response(existing_file)
# 获取存储后端
storage = get_storage_backend()
# 计算文件信息
filename = upload_info['filename']
file_ext = os.path.splitext(filename)[1].lower()
mime_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
# 保存到存储后端
with open(temp_merged_path, 'rb') as merged_file:
storage_path, url = storage.save(merged_file, filename, folder_path)
# 构建完整路径
full_path = os.path.join(folder_path, filename).replace('\\', '/') if folder_path else filename
# 创建数据库记录
file_obj = FileManager(
name=filename,
type='file',
parent_id=upload_info['parent_id'],
path=full_path,
size=upload_info['total_size'],
file_ext=file_ext,
mime_type=mime_type,
storage_type=storage.__class__.__name__.replace('StorageBackend', '').lower(),
storage_path=storage_path,
url=url,
md5=file_md5,
is_public=upload_info['is_public'],
)
db.add(file_obj)
await db.commit()
await db.refresh(file_obj)
# 清理临时文件和缓存
shutil.rmtree(get_chunk_dir(upload_id), ignore_errors=True)
await RedisClient.delete(cache_key)
await client.delete(set_key)
return _build_file_response(file_obj)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"合并文件失败: {str(e)}")
@router.delete("/cancel", response_model=ResponseModel, summary="取消分块上传")
async def cancel_chunk_upload(
upload_id: str = Query(..., alias="uploadId"),
):
"""
取消分块上传
- 清理临时文件
- 删除缓存信息
"""
try:
# 清理临时文件
shutil.rmtree(get_chunk_dir(upload_id), ignore_errors=True)
# 删除缓存和Set
cache_key = get_chunk_upload_key(upload_id)
await RedisClient.delete(cache_key)
set_key = get_chunk_set_key(upload_id)
client = await RedisClient.get_client()
await client.delete(set_key)
return ResponseModel(message="上传已取消")
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消上传失败: {str(e)}")
@@ -0,0 +1,36 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文件管理模型
"""
from sqlalchemy import Column, String, Text, Boolean, BigInteger, Integer, ForeignKey, Index
from app.base_model import BaseModel
class FileManager(BaseModel):
"""文件管理模型"""
__tablename__ = "core_file_manager"
name = Column(String(255), nullable=False, comment="文件/文件夹名称")
type = Column(String(10), default='file', comment="类型: file/folder")
parent_id = Column(String(36), nullable=True, comment="父文件夹ID")
path = Column(Text, nullable=False, default='', comment="文件路径")
size = Column(BigInteger, default=0, comment="文件大小(字节)")
file_ext = Column(String(50), nullable=True, comment="文件扩展名")
mime_type = Column(String(200), nullable=True, comment="MIME类型")
storage_type = Column(String(20), default='local', comment="存储类型: local/oss/minio/azure")
storage_path = Column(Text, nullable=False, default='', comment="存储路径")
url = Column(Text, nullable=True, comment="访问URL")
thumbnail_url = Column(Text, nullable=True, comment="缩略图URL")
md5 = Column(String(32), nullable=True, comment="文件MD5")
is_public = Column(Boolean, default=False, comment="是否公开")
download_count = Column(Integer, default=0, comment="下载次数")
is_system = Column(Boolean, default=False, comment="是否系统文件夹(不可删除/重命名)")
source = Column(String(50), nullable=True, comment="来源模块标识")
__table_args__ = (
Index('ix_file_manager_parent_type', 'parent_id', 'type'),
Index('ix_file_manager_storage_type', 'storage_type'),
Index('ix_file_manager_md5', 'md5'),
)
@@ -0,0 +1,14 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文件管理路由
"""
from fastapi import APIRouter
from core.file_manager.api import router as file_manager_router
from core.file_manager.chunk_upload_api import router as chunk_upload_router
router = APIRouter()
router.include_router(file_manager_router)
router.include_router(chunk_upload_router)
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文件管理Schema
"""
from datetime import datetime
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, Field
from app.base_schema import CSTDatetime
# ==================== 文件管理 Schema ====================
class FileManagerBase(BaseModel):
"""文件管理基础Schema"""
name: str = Field(..., description="文件/文件夹名称")
type: str = Field(default='file', description="类型: file/folder")
parent_id: Optional[str] = Field(None, description="父文件夹ID")
is_public: bool = Field(default=False, description="是否公开")
class FileManagerCreate(FileManagerBase):
"""创建文件记录Schema(内部使用)"""
path: str = Field(default='', description="文件路径")
size: int = Field(default=0, description="文件大小")
file_ext: Optional[str] = Field(None, description="文件扩展名")
mime_type: Optional[str] = Field(None, description="MIME类型")
storage_type: str = Field(default='local', description="存储类型")
storage_path: str = Field(default='', description="存储路径")
url: Optional[str] = Field(None, description="访问URL")
md5: Optional[str] = Field(None, description="文件MD5")
class FileManagerUpdate(BaseModel):
"""更新文件记录Schema"""
name: Optional[str] = None
is_public: Optional[bool] = None
class FileManagerResponse(BaseModel):
"""文件管理响应Schema"""
id: str
name: str
type: str = Field(alias="file_type")
parent_id: Optional[str] = None
parent_name: Optional[str] = None
path: str
size: int = Field(alias="file_size")
file_ext: Optional[str] = None
mime_type: Optional[str] = None
storage_type: str
storage_path: str
url: Optional[str] = None
thumbnail_url: Optional[str] = None
md5: Optional[str] = None
is_public: bool
download_count: int
is_system: bool = False
source: Optional[str] = None
sys_creator_id: Optional[str] = None
has_children: bool = False
updated_time: Optional[str] = None
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
class FileManagerSimpleResponse(BaseModel):
"""文件管理简单响应Schema"""
id: str
name: str
type: str
size: int
sys_create_datetime: Optional[CSTDatetime] = None
mime_type: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# ==================== 文件夹操作 Schema ====================
class CreateFolderIn(BaseModel):
"""创建文件夹输入Schema"""
name: str = Field(..., description="文件夹名称")
parent_id: Optional[str] = Field(None, alias="parentId", description="父文件夹ID")
model_config = ConfigDict(populate_by_name=True)
class MoveItemsIn(BaseModel):
"""移动文件/文件夹输入Schema"""
ids: List[str] = Field(..., description="要移动的文件/文件夹ID列表")
target_folder_id: Optional[str] = Field(None, alias="targetFolderId", description="目标文件夹ID")
model_config = ConfigDict(populate_by_name=True)
class RenameItemIn(BaseModel):
"""重命名输入Schema"""
name: str = Field(..., description="新名称")
class BatchDeleteIn(BaseModel):
"""批量删除输入Schema"""
ids: List[str] = Field(..., description="要删除的文件/文件夹ID列表")
# ==================== 存储配置 Schema ====================
class FileStorageConfigResponse(BaseModel):
"""文件存储配置响应Schema"""
storage_type: str = Field(default='local', alias="storageType", description="存储类型")
local_base_path: Optional[str] = Field(None, alias="localBasePath", description="本地存储路径")
oss_endpoint: Optional[str] = Field(None, alias="ossEndpoint", description="OSS端点")
oss_access_key_id: Optional[str] = Field(None, alias="ossAccessKeyId", description="OSS访问密钥ID")
oss_bucket_name: Optional[str] = Field(None, alias="ossBucketName", description="OSS存储桶名称")
minio_endpoint: Optional[str] = Field(None, alias="minioEndpoint", description="Minio端点")
minio_bucket_name: Optional[str] = Field(None, alias="minioBucketName", description="Minio存储桶名称")
azure_account_name: Optional[str] = Field(None, alias="azureAccountName", description="Azure存储账户名称")
azure_container_name: Optional[str] = Field(None, alias="azureContainerName", description="Azure容器名称")
model_config = ConfigDict(populate_by_name=True)
class FileStorageConfigUpdate(BaseModel):
"""更新存储配置Schema"""
storage_type: str = Field(default='local', alias="storageType", description="存储类型")
local_base_path: Optional[str] = Field(None, alias="localBasePath")
oss_endpoint: Optional[str] = Field(None, alias="ossEndpoint")
oss_access_key_id: Optional[str] = Field(None, alias="ossAccessKeyId")
oss_access_key_secret: Optional[str] = Field(None, alias="ossAccessKeySecret")
oss_bucket_name: Optional[str] = Field(None, alias="ossBucketName")
minio_endpoint: Optional[str] = Field(None, alias="minioEndpoint")
minio_access_key: Optional[str] = Field(None, alias="minioAccessKey")
minio_secret_key: Optional[str] = Field(None, alias="minioSecretKey")
minio_bucket_name: Optional[str] = Field(None, alias="minioBucketName")
azure_account_name: Optional[str] = Field(None, alias="azureAccountName")
azure_account_key: Optional[str] = Field(None, alias="azureAccountKey")
azure_container_name: Optional[str] = Field(None, alias="azureContainerName")
model_config = ConfigDict(populate_by_name=True)
# ==================== 分块上传 Schema ====================
class InitChunkUploadIn(BaseModel):
"""初始化分块上传输入Schema"""
filename: str = Field(..., description="文件名")
total_size: int = Field(..., alias="totalSize", description="文件总大小(字节)")
chunk_size: int = Field(default=5 * 1024 * 1024, alias="chunkSize", description="分块大小(字节),默认5MB")
parent_id: Optional[str] = Field(None, alias="parentId", description="父文件夹ID")
is_public: bool = Field(default=False, alias="isPublic", description="是否公开")
file_hash: Optional[str] = Field(None, alias="fileHash", description="文件MD5哈希,用于秒传")
model_config = ConfigDict(populate_by_name=True)
class InitChunkUploadOut(BaseModel):
"""初始化分块上传输出Schema"""
upload_id: str = Field(..., alias="uploadId", description="上传ID")
chunk_size: int = Field(..., alias="chunkSize", description="分块大小")
total_chunks: int = Field(..., alias="totalChunks", description="总分块数")
uploaded_chunks: List[int] = Field(default=[], alias="uploadedChunks", description="已上传的分块索引列表")
file_exists: bool = Field(default=False, alias="fileExists", description="文件是否已存在(秒传)")
file_id: Optional[str] = Field(None, alias="fileId", description="如果文件已存在,返回文件ID")
model_config = ConfigDict(populate_by_name=True)
class UploadChunkOut(BaseModel):
"""上传分块输出Schema"""
chunk_index: int = Field(..., alias="chunkIndex", description="分块索引")
uploaded: bool = Field(..., description="是否上传成功")
model_config = ConfigDict(populate_by_name=True)
class MergeChunksIn(BaseModel):
"""合并分块输入Schema"""
upload_id: str = Field(..., alias="uploadId", description="上传ID")
model_config = ConfigDict(populate_by_name=True)
class ChunkUploadStatusOut(BaseModel):
"""分块上传状态输出Schema"""
upload_id: str = Field(..., alias="uploadId", description="上传ID")
filename: str = Field(..., description="文件名")
total_size: int = Field(..., alias="totalSize", description="文件总大小")
total_chunks: int = Field(..., alias="totalChunks", description="总分块数")
uploaded_chunks: List[int] = Field(..., alias="uploadedChunks", description="已上传的分块索引")
completed: bool = Field(..., description="是否完成上传")
model_config = ConfigDict(populate_by_name=True)
# ==================== 文件URL Schema ====================
class FileUrlResponse(BaseModel):
"""文件URL响应Schema"""
url: str = Field(..., description="文件访问URL")
class BatchFileUrlsResponse(BaseModel):
"""批量文件URL响应Schema"""
urls: dict = Field(..., description="文件ID到URL的映射")
# ==================== 临时访问令牌 Schema ====================
class CreateAccessTokenIn(BaseModel):
"""创建临时访问令牌输入Schema"""
file_id: str = Field(..., alias="fileId", description="文件ID")
expires_in: int = Field(default=3600, alias="expiresIn", description="过期时间(秒),默认1小时")
model_config = ConfigDict(populate_by_name=True)
class AccessTokenResponse(BaseModel):
"""临时访问令牌响应Schema"""
token: str = Field(..., description="临时访问令牌")
expires_at: CSTDatetime = Field(..., alias="expiresAt", description="过期时间")
file_id: str = Field(..., alias="fileId", description="文件ID")
model_config = ConfigDict(populate_by_name=True)
class AccessTokenUrlResponse(BaseModel):
"""带令牌的文件URL响应Schema"""
url: str = Field(..., description="带临时令牌的文件访问URL")
token: str = Field(..., description="临时访问令牌")
expires_at: CSTDatetime = Field(..., alias="expiresAt", description="过期时间")
model_config = ConfigDict(populate_by_name=True)
# ==================== AI OCR 识别 Schema ====================
class OcrFieldMapping(BaseModel):
"""OCR字段映射配置"""
source: str = Field(..., description="Function返回的字段名")
target: str = Field(..., description="表单字段名")
transform: Optional[str] = Field(None, description="转换规则")
class OcrSchemaField(BaseModel):
"""OCR结构化输出字段定义(与LLM节点SchemaField一致)"""
name: str = Field(..., description="字段名")
type: str = Field(default="string", description="字段类型: string/number/integer/boolean/array/object")
description: str = Field(default="", description="字段描述")
required: bool = Field(default=False, description="是否必填")
items: Optional['OcrSchemaField'] = Field(None, description="array类型的元素定义")
properties: Optional[List['OcrSchemaField']] = Field(None, description="object类型的子属性")
enum: Optional[List[str]] = Field(None, description="枚举值(仅string类型)")
default: Optional[Any] = Field(None, description="默认值")
class OcrRecognizeRequest(BaseModel):
"""OCR智能识别请求Schema"""
file_id: str = Field(..., alias="fileId", description="文件ID")
output_schema: Optional[List[OcrSchemaField]] = Field(None, alias="outputSchema", description="结构化输出字段定义")
prompt: Optional[str] = Field(None, description="自定义提示词")
model_config = ConfigDict(populate_by_name=True)
class OcrRecognizeResponse(BaseModel):
"""OCR智能识别响应Schema"""
success: bool = Field(..., description="是否成功")
raw_text: Optional[str] = Field(None, alias="rawText", description="原始识别文字")
extracted_data: Optional[dict] = Field(None, alias="extractedData", description="提取的结构化数据")
error: Optional[str] = Field(None, description="错误信息")
model_config = ConfigDict(populate_by_name=True)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
签名令牌模型
用于手机扫码签名功能
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from app.base_model import BaseModel
class SignatureToken(BaseModel):
"""签名令牌"""
__tablename__ = "core_signature_token"
token = Column(String(128), unique=True, nullable=False, index=True, comment="令牌")
source = Column(String(50), nullable=True, comment="来源(form/workflow等)")
callback_key = Column(String(128), nullable=True, comment="回调标识(用于前端轮询)")
expired_at = Column(DateTime, nullable=False, comment="过期时间")
is_used = Column(Boolean, default=False, comment="是否已使用")
used_at = Column(DateTime, nullable=True, comment="使用时间")
signature_file_id = Column(String(36), nullable=True, comment="签名文件ID")
user_id = Column(String(36), nullable=True, comment="创建用户ID")
ip_address = Column(String(64), nullable=True, comment="签名IP地址")
user_agent = Column(Text, nullable=True, comment="签名设备信息")
@@ -0,0 +1,203 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
签名令牌服务
用于手机扫码签名功能
"""
import secrets
from datetime import datetime, timedelta
from typing import Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.file_manager.signature_token_model import SignatureToken
class SignatureTokenService:
"""签名令牌服务"""
@staticmethod
def generate_token() -> str:
"""生成随机令牌"""
return secrets.token_urlsafe(32)
@staticmethod
def generate_callback_key() -> str:
"""生成回调标识"""
return secrets.token_urlsafe(16)
@classmethod
async def create_token(
cls,
db: AsyncSession,
source: str = "form",
expire_minutes: int = 30,
user_id: Optional[str] = None,
) -> SignatureToken:
"""
创建签名令牌
Args:
db: 数据库会话
source: 来源(form/workflow等)
expire_minutes: 过期时间(分钟),默认30分钟
user_id: 创建用户ID
Returns:
SignatureToken: 签名令牌对象
"""
token = cls.generate_token()
callback_key = cls.generate_callback_key()
expired_at = datetime.now() + timedelta(minutes=expire_minutes)
token_obj = SignatureToken(
token=token,
callback_key=callback_key,
source=source,
expired_at=expired_at,
user_id=user_id,
)
db.add(token_obj)
await db.commit()
await db.refresh(token_obj)
return token_obj
@classmethod
async def get_by_token(
cls,
db: AsyncSession,
token: str,
) -> Optional[SignatureToken]:
"""
根据令牌获取签名令牌对象
Args:
db: 数据库会话
token: 令牌字符串
Returns:
Optional[SignatureToken]: 签名令牌对象
"""
query = select(SignatureToken).where(
SignatureToken.token == token,
SignatureToken.is_deleted == False, # noqa: E712
)
result = await db.execute(query)
return result.scalar_one_or_none()
@classmethod
async def get_by_callback_key(
cls,
db: AsyncSession,
callback_key: str,
) -> Optional[SignatureToken]:
"""
根据回调标识获取签名令牌对象
Args:
db: 数据库会话
callback_key: 回调标识
Returns:
Optional[SignatureToken]: 签名令牌对象
"""
query = select(SignatureToken).where(
SignatureToken.callback_key == callback_key,
SignatureToken.is_deleted == False, # noqa: E712
)
result = await db.execute(query)
return result.scalar_one_or_none()
@classmethod
def validate_token(cls, sign_token: SignatureToken) -> Tuple[bool, str]:
"""
验证令牌有效性
Args:
sign_token: 签名令牌对象
Returns:
Tuple[bool, str]: (是否有效, 错误信息)
"""
if sign_token.is_used:
return False, "该签名链接已被使用"
if sign_token.expired_at < datetime.now():
return False, "该签名链接已过期"
return True, ""
@classmethod
async def complete_signature(
cls,
db: AsyncSession,
sign_token: SignatureToken,
signature_file_id: str,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
) -> SignatureToken:
"""
完成签名
Args:
db: 数据库会话
sign_token: 签名令牌对象
signature_file_id: 签名文件ID
ip_address: IP地址
user_agent: 设备信息
Returns:
SignatureToken: 更新后的签名令牌对象
"""
sign_token.is_used = True
sign_token.used_at = datetime.now()
sign_token.signature_file_id = signature_file_id
sign_token.ip_address = ip_address
sign_token.user_agent = user_agent[:500] if user_agent else None
await db.commit()
await db.refresh(sign_token)
return sign_token
@classmethod
async def check_signature_status(
cls,
db: AsyncSession,
callback_key: str,
) -> dict:
"""
检查签名状态(用于前端轮询)
Args:
db: 数据库会话
callback_key: 回调标识
Returns:
dict: 签名状态信息
"""
sign_token = await cls.get_by_callback_key(db, callback_key)
if not sign_token:
return {
"status": "not_found",
"message": "签名令牌不存在",
}
if sign_token.is_used and sign_token.signature_file_id:
return {
"status": "completed",
"message": "签名已完成",
"file_id": sign_token.signature_file_id,
}
if sign_token.expired_at < datetime.now():
return {
"status": "expired",
"message": "签名链接已过期",
}
return {
"status": "pending",
"message": "等待签名",
}
@@ -0,0 +1,429 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
存储后端 - 支持本地存储、阿里云OSS、Minio、Azure Blob
"""
import hashlib
import os
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from typing import BinaryIO, Tuple, Optional
from app.config import settings
class StorageBackend(ABC):
"""存储后端抽象基类"""
@abstractmethod
def save(self, file: BinaryIO, filename: str, folder_path: str = '') -> Tuple[str, str]:
"""
保存文件
:param file: 文件对象
:param filename: 文件名
:param folder_path: 文件夹路径
:return: (存储路径, 访问URL)
"""
pass
@abstractmethod
def delete(self, file_path: str) -> bool:
"""删除文件"""
pass
@abstractmethod
def exists(self, file_path: str) -> bool:
"""检查文件是否存在"""
pass
@abstractmethod
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
pass
@abstractmethod
def get_size(self, file_path: str) -> int:
"""获取文件大小"""
pass
def calculate_md5(self, file: BinaryIO) -> str:
"""计算文件MD5"""
md5_hash = hashlib.md5()
file.seek(0)
for chunk in iter(lambda: file.read(4096), b""):
md5_hash.update(chunk)
file.seek(0)
return md5_hash.hexdigest()
def generate_filename(self, original_filename: str) -> str:
"""生成唯一文件名(使用UUID,防止路径可预测)"""
import uuid
name, ext = os.path.splitext(original_filename)
return f"{uuid.uuid4().hex}{ext}"
class LocalStorageBackend(StorageBackend):
"""本地存储后端"""
def __init__(self, base_path: str = None):
self.base_path = base_path or os.path.join(os.getcwd(), 'media', 'file_manager')
os.makedirs(self.base_path, exist_ok=True)
def save(self, file: BinaryIO, filename: str, folder_path: str = '') -> Tuple[str, str]:
# 生成唯一文件名
unique_filename = self.generate_filename(filename)
# 构建完整路径
relative_path = os.path.join(folder_path, unique_filename).replace('\\', '/')
full_path = os.path.join(self.base_path, relative_path)
# 确保目录存在
os.makedirs(os.path.dirname(full_path) if os.path.dirname(full_path) else self.base_path, exist_ok=True)
# 保存文件
with open(full_path, 'wb') as destination:
if hasattr(file, 'read'):
# 文件对象
chunk = file.read(8192)
while chunk:
destination.write(chunk)
chunk = file.read(8192)
else:
# 字节数据
destination.write(file)
# 返回相对路径和URL
url = relative_path
return relative_path, url
def delete(self, file_path: str) -> bool:
full_path = os.path.join(self.base_path, file_path)
if os.path.exists(full_path):
os.remove(full_path)
return True
return False
def exists(self, file_path: str) -> bool:
full_path = os.path.join(self.base_path, file_path)
return os.path.exists(full_path)
def get_url(self, file_path: str) -> str:
return f"/api/file_manager/file/download?path={file_path}"
def get_size(self, file_path: str) -> int:
full_path = os.path.join(self.base_path, file_path)
return os.path.getsize(full_path) if os.path.exists(full_path) else 0
def get_full_path(self, file_path: str) -> str:
"""获取文件完整路径"""
return os.path.join(self.base_path, file_path)
class OSSStorageBackend(StorageBackend):
"""阿里云OSS存储后端"""
def __init__(self, endpoint: str, access_key_id: str, access_key_secret: str, bucket_name: str):
self.endpoint = endpoint
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
self.bucket_name = bucket_name
self._client = None
@property
def client(self):
if self._client is None:
import oss2
auth = oss2.Auth(self.access_key_id, self.access_key_secret)
self._client = oss2.Bucket(auth, self.endpoint, self.bucket_name)
return self._client
def save(self, file: BinaryIO, filename: str, folder_path: str = '') -> Tuple[str, str]:
unique_filename = self.generate_filename(filename)
key = os.path.join('file_manager', folder_path, unique_filename).replace('\\', '/')
# 上传文件
self.client.put_object(key, file)
# 生成URL
url = f"https://{self.bucket_name}.{self.endpoint.replace('https://', '').replace('http://', '')}/{key}"
return key, url
def delete(self, file_path: str) -> bool:
try:
self.client.delete_object(file_path)
return True
except Exception:
return False
def exists(self, file_path: str) -> bool:
try:
self.client.head_object(file_path)
return True
except:
return False
def get_url(self, file_path: str) -> str:
return f"https://{self.bucket_name}.{self.endpoint.replace('https://', '').replace('http://', '')}/{file_path}"
def get_size(self, file_path: str) -> int:
try:
result = self.client.head_object(file_path)
return result.content_length
except:
return 0
class MinioStorageBackend(StorageBackend):
"""Minio存储后端"""
def __init__(self, endpoint: str, access_key: str, secret_key: str, bucket_name: str, secure: bool = False):
# 处理endpoint,确保没有协议前缀
if endpoint.startswith('http://'):
endpoint = endpoint[7:]
secure = False
elif endpoint.startswith('https://'):
endpoint = endpoint[8:]
secure = True
self.endpoint = endpoint
self.access_key = access_key
self.secret_key = secret_key
self.bucket_name = bucket_name
self.secure = secure
self._client = None
@property
def client(self):
if self._client is None:
from minio import Minio
self._client = Minio(
self.endpoint,
access_key=self.access_key,
secret_key=self.secret_key,
secure=self.secure
)
return self._client
def save(self, file: BinaryIO, filename: str, folder_path: str = '') -> Tuple[str, str]:
unique_filename = self.generate_filename(filename)
object_name = os.path.join('file_manager', folder_path, unique_filename).replace('\\', '/')
# 获取文件大小
file.seek(0, 2)
file_size = file.tell()
file.seek(0)
# 上传文件
self.client.put_object(
self.bucket_name,
object_name,
file,
file_size
)
# 生成URL
url = f"{self.bucket_name}/{object_name}"
return object_name, url
def delete(self, file_path: str) -> bool:
try:
self.client.remove_object(self.bucket_name, file_path)
return True
except Exception:
return False
def exists(self, file_path: str) -> bool:
try:
self.client.stat_object(self.bucket_name, file_path)
return True
except:
return False
def get_url(self, file_path: str) -> str:
protocol = 'https' if self.secure else 'http'
return f"{protocol}://{self.endpoint}/{self.bucket_name}/{file_path}"
def get_size(self, file_path: str) -> int:
try:
result = self.client.stat_object(self.bucket_name, file_path)
return result.size
except:
return 0
def get_presigned_url(self, file_path: str, expires: timedelta = None) -> str:
"""获取预签名临时URL"""
if expires is None:
expires = timedelta(hours=1)
try:
url = self.client.presigned_get_object(
self.bucket_name,
file_path,
expires=expires
)
return url
except Exception as e:
raise Exception(f"Failed to generate presigned URL: {str(e)}")
def get_presigned_upload_url(self, file_path: str, expires: timedelta = None) -> str:
"""获取预签名上传URL"""
if expires is None:
expires = timedelta(hours=1)
try:
url = self.client.presigned_put_object(
self.bucket_name,
file_path,
expires=expires
)
return url
except Exception as e:
raise Exception(f"Failed to generate presigned upload URL: {str(e)}")
def get_file_content(self, file_path: str):
"""获取文件内容"""
try:
response = self.client.get_object(self.bucket_name, file_path)
return response
except Exception as e:
raise Exception(f"Failed to get file content: {str(e)}")
def get_file_info(self, file_path: str) -> dict:
"""获取文件信息"""
try:
stat = self.client.stat_object(self.bucket_name, file_path)
return {
'size': stat.size,
'etag': stat.etag,
'content_type': stat.content_type,
'last_modified': stat.last_modified,
'metadata': stat.metadata
}
except Exception as e:
raise Exception(f"Failed to get file info: {str(e)}")
class AzureBlobStorageBackend(StorageBackend):
"""Azure Blob存储后端"""
def __init__(self, account_name: str, account_key: str, container_name: str):
self.account_name = account_name
self.account_key = account_key
self.container_name = container_name
self._client = None
@property
def client(self):
if self._client is None:
from azure.storage.blob import BlobServiceClient
connection_string = f"DefaultEndpointsProtocol=https;AccountName={self.account_name};AccountKey={self.account_key};EndpointSuffix=core.windows.net"
self._client = BlobServiceClient.from_connection_string(connection_string)
# 确保容器存在
container_client = self._client.get_container_client(self.container_name)
if not container_client.exists():
container_client.create_container()
return self._client
def save(self, file: BinaryIO, filename: str, folder_path: str = '') -> Tuple[str, str]:
unique_filename = self.generate_filename(filename)
blob_name = os.path.join('file_manager', folder_path, unique_filename).replace('\\', '/')
# 获取blob客户端
blob_client = self.client.get_blob_client(
container=self.container_name,
blob=blob_name
)
# 上传文件
blob_client.upload_blob(file, overwrite=True)
# 生成URL
url = f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{blob_name}"
return blob_name, url
def delete(self, file_path: str) -> bool:
try:
blob_client = self.client.get_blob_client(
container=self.container_name,
blob=file_path
)
blob_client.delete_blob()
return True
except Exception:
return False
def exists(self, file_path: str) -> bool:
try:
blob_client = self.client.get_blob_client(
container=self.container_name,
blob=file_path
)
blob_client.get_blob_properties()
return True
except:
return False
def get_url(self, file_path: str) -> str:
return f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{file_path}"
def get_size(self, file_path: str) -> int:
try:
blob_client = self.client.get_blob_client(
container=self.container_name,
blob=file_path
)
properties = blob_client.get_blob_properties()
return properties.size
except:
return 0
def get_storage_backend(config: dict = None) -> StorageBackend:
"""获取存储后端实例"""
if config is None:
# 从配置文件读取默认配置
config = {
'storage_type': getattr(settings, 'FILE_STORAGE_TYPE', 'local'),
'local_base_path': getattr(settings, 'FILE_STORAGE_LOCAL_PATH', None),
'oss_endpoint': getattr(settings, 'OSS_ENDPOINT', None),
'oss_access_key_id': getattr(settings, 'OSS_ACCESS_KEY_ID', None),
'oss_access_key_secret': getattr(settings, 'OSS_ACCESS_KEY_SECRET', None),
'oss_bucket_name': getattr(settings, 'OSS_BUCKET_NAME', None),
'minio_endpoint': getattr(settings, 'MINIO_ENDPOINT', None),
'minio_access_key': getattr(settings, 'MINIO_ACCESS_KEY', None),
'minio_secret_key': getattr(settings, 'MINIO_SECRET_KEY', None),
'minio_bucket_name': getattr(settings, 'MINIO_BUCKET_NAME', None),
'minio_secure': getattr(settings, 'MINIO_SECURE', False),
'azure_account_name': getattr(settings, 'AZURE_ACCOUNT_NAME', None),
'azure_account_key': getattr(settings, 'AZURE_ACCOUNT_KEY', None),
'azure_container_name': getattr(settings, 'AZURE_CONTAINER_NAME', None),
}
storage_type = config.get('storage_type', 'local')
if storage_type == 'local':
return LocalStorageBackend(config.get('local_base_path'))
elif storage_type == 'oss':
return OSSStorageBackend(
config['oss_endpoint'],
config['oss_access_key_id'],
config['oss_access_key_secret'],
config['oss_bucket_name']
)
elif storage_type == 'minio':
return MinioStorageBackend(
config['minio_endpoint'],
config['minio_access_key'],
config['minio_secret_key'],
config['minio_bucket_name'],
config.get('minio_secure', False)
)
elif storage_type == 'azure':
return AzureBlobStorageBackend(
config['azure_account_name'],
config['azure_account_key'],
config['azure_container_name']
)
else:
raise ValueError(f"Unsupported storage type: {storage_type}")
@@ -0,0 +1,27 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
临时访问令牌模型
"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Index
from app.base_model import BaseModel
class FileAccessToken(BaseModel):
"""文件临时访问令牌模型"""
__tablename__ = "core_file_access_token"
token = Column(String(64), unique=True, nullable=False, comment="临时访问令牌")
file_id = Column(String(36), nullable=False, comment="文件ID")
expires_at = Column(DateTime, nullable=False, comment="过期时间")
user_id = Column(String(36), nullable=True, comment="用户ID")
ip_address = Column(String(45), nullable=True, comment="IP地址")
user_agent = Column(String(500), nullable=True, comment="User Agent")
__table_args__ = (
Index('ix_file_access_token_token', 'token'),
Index('ix_file_access_token_file_id', 'file_id'),
Index('ix_file_access_token_expires_at', 'expires_at'),
)
@@ -0,0 +1,197 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
临时访问令牌服务
"""
import secrets
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from core.file_manager.temp_token_model import FileAccessToken
class FileAccessTokenService:
"""文件临时访问令牌服务"""
@staticmethod
def generate_token() -> str:
"""生成随机令牌"""
return secrets.token_urlsafe(48)
@classmethod
async def create_token(
cls,
db: AsyncSession,
file_id: str,
expires_in_seconds: int = 3600,
user_id: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
) -> FileAccessToken:
"""
创建临时访问令牌
Args:
db: 数据库会话
file_id: 文件ID
expires_in_seconds: 过期时间(秒),默认1小时
user_id: 用户ID
ip_address: IP地址
user_agent: User Agent
Returns:
FileAccessToken: 临时访问令牌对象
"""
token = cls.generate_token()
expires_at = datetime.utcnow() + timedelta(seconds=expires_in_seconds)
token_obj = FileAccessToken(
token=token,
file_id=file_id,
expires_at=expires_at,
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(token_obj)
await db.commit()
await db.refresh(token_obj)
return token_obj
@classmethod
async def verify_token(
cls,
db: AsyncSession,
token: str,
) -> Optional[FileAccessToken]:
"""
验证令牌并返回令牌对象
Args:
db: 数据库会话
token: 令牌字符串
Returns:
Optional[FileAccessToken]: 如果令牌有效返回令牌对象,否则返回None
"""
query = select(FileAccessToken).where(
FileAccessToken.token == token,
FileAccessToken.is_deleted == False, # noqa: E712
FileAccessToken.expires_at > datetime.utcnow(),
)
result = await db.execute(query)
return result.scalar_one_or_none()
@classmethod
async def revoke_token(
cls,
db: AsyncSession,
token: str,
) -> bool:
"""
撤销令牌(软删除)
Args:
db: 数据库会话
token: 令牌字符串
Returns:
bool: 是否成功撤销
"""
query = select(FileAccessToken).where(
FileAccessToken.token == token,
FileAccessToken.is_deleted == False, # noqa: E712
)
result = await db.execute(query)
token_obj = result.scalar_one_or_none()
if token_obj:
token_obj.is_deleted = True
await db.commit()
return True
return False
@classmethod
async def cleanup_expired_tokens(
cls,
db: AsyncSession,
) -> int:
"""
清理过期的令牌(物理删除)
Args:
db: 数据库会话
Returns:
int: 清理的令牌数量
"""
stmt = delete(FileAccessToken).where(
FileAccessToken.expires_at < datetime.utcnow()
)
result = await db.execute(stmt)
await db.commit()
return result.rowcount
@classmethod
async def get_file_tokens(
cls,
db: AsyncSession,
file_id: str,
include_expired: bool = False,
) -> list[FileAccessToken]:
"""
获取文件的所有令牌
Args:
db: 数据库会话
file_id: 文件ID
include_expired: 是否包含过期的令牌
Returns:
list[FileAccessToken]: 令牌列表
"""
conditions = [
FileAccessToken.file_id == file_id,
FileAccessToken.is_deleted == False, # noqa: E712
]
if not include_expired:
conditions.append(FileAccessToken.expires_at > datetime.utcnow())
query = select(FileAccessToken).where(*conditions)
result = await db.execute(query)
return result.scalars().all()
@classmethod
async def revoke_file_tokens(
cls,
db: AsyncSession,
file_id: str,
) -> int:
"""
撤销文件的所有令牌
Args:
db: 数据库会话
file_id: 文件ID
Returns:
int: 撤销的令牌数量
"""
query = select(FileAccessToken).where(
FileAccessToken.file_id == file_id,
FileAccessToken.is_deleted == False, # noqa: E712
)
result = await db.execute(query)
tokens = result.scalars().all()
count = 0
for token in tokens:
token.is_deleted = True
count += 1
await db.commit()
return count