Build lightweight AI agent admin
This commit is contained in:
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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user