Files
沃巴蒂 ff22ed3a8b feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
2026-10-03 22:44:56 +08:00

242 lines
8.6 KiB
Python

import base64
import uuid
import io
import urllib.parse
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Query, HTTPException, Body
from fastapi.responses import StreamingResponse
from pydantic_settings import BaseSettings, SettingsConfigDict
from minio import Minio
from minio.error import S3Error
import argparse
import base64
import uuid
import io
import urllib.parse
import mimetypes
import os
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Query, HTTPException, Body
from fastapi.responses import StreamingResponse
from pydantic_settings import BaseSettings, SettingsConfigDict
from minio import Minio
from minio.error import S3Error
# 加载环境变量
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8")
minio_endpoint: str
minio_access_key: str
minio_secret_key: str
minio_secure: bool
minio_bucket: str
public_prefix: str
settings = Settings()
# MinIO Client
minio_client = Minio(
endpoint=settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=settings.minio_secure
)
# 初始化FastAPI
app = FastAPI(
title="MinIO 文件服务",
description="原始文件名存入MinIO对象元数据;上传仅返回MD图片相对路径,访问时自动读取元数据作为下载名称",
version="1.0.0"
)
def get_ext(filename: str) -> str:
if "." in filename:
return filename.split(".")[-1]
return ""
def build_relative_path(obj_key: str) -> str:
"""生成用于Markdown图片的相对路径"""
return f"/file/{obj_key}"
@app.post("/upload/form", summary="Form表单上传,仅返回相对路径,原始文件名存入MinIO元数据")
async def upload_form_file(
file: UploadFile = File(..., description="文件"),
filename: Optional[str] = Query(
default=None, description="自定义文件名,不传使用原始文件名")
):
use_filename = filename or file.filename or "file"
ext = get_ext(use_filename)
obj_key = f"{settings.public_prefix}/{uuid.uuid4()}.{ext}"
# ✅ URL编码中文文件名,解决header latin1编码报错
metadata = {
"X-Amz-Meta-Original-Name": urllib.parse.quote(use_filename)
}
try:
minio_client.put_object(
bucket_name=settings.minio_bucket,
object_name=obj_key,
data=file.file,
length=-1,
part_size=10*1024*1024,
content_type=file.content_type,
metadata=metadata
)
path = build_relative_path(obj_key)
return {"path": path}
except S3Error as e:
raise HTTPException(status_code=500, detail=f"MinIO上传失败: {str(e)}")
@app.post("/upload/base64", summary="Base64上传,仅返回相对路径,原始文件名存入MinIO元数据")
async def upload_base64(
base64_data: str = Body(),
filename: str = Body()
):
if base64_data.startswith("data:"):
base64_data = base64_data.split(",")[-1]
try:
raw_bytes = base64.b64decode(base64_data)
except Exception:
raise HTTPException(status_code=400, detail="base64数据解码失败")
ext = get_ext(filename)
obj_key = f"{settings.public_prefix}/{uuid.uuid4()}.{ext}"
# ✅ URL编码中文文件名
metadata = {
"X-Amz-Meta-Original-Name": urllib.parse.quote(filename)
}
# 猜测content-type
content_type = "application/octet-stream"
if ext.lower() in ["jpg", "jpeg"]:
content_type = "image/jpeg"
elif ext.lower() == "png":
content_type = "image/png"
elif ext.lower() == "gif":
content_type = "image/gif"
elif ext.lower() == "pdf":
content_type = "application/pdf"
elif ext.lower() == "txt":
content_type = "text/plain"
stream = io.BytesIO(raw_bytes)
try:
minio_client.put_object(
bucket_name=settings.minio_bucket,
object_name=obj_key,
data=stream,
length=len(raw_bytes),
content_type=content_type,
metadata=metadata
)
path = build_relative_path(obj_key)
return {"path": path}
except S3Error as e:
raise HTTPException(status_code=500, detail=f"MinIO上传失败: {str(e)}")
@app.get("/file/{object_key:path}", summary="文件访问:默认预览,dl=1强制下载,自动读取MinIO元数据中的原始文件名")
def file_access(
object_key: str,
dl: int = Query(default=0, description="dl=1强制下载;默认0在线预览"),
name: str = Query(default="", description="手动指定下载名称,优先级高于元数据")
):
content_type = "application/octet-stream"
original_name = ""
stream = None
# 1. 尝试使用 HEAD 请求获取元数据 (快,不消耗流量)
try:
stat = minio_client.stat_object(settings.minio_bucket, object_key)
content_type = stat.content_type
# 提取原始文件名 (SDK 通常会去掉 x-amz-meta- 前缀并转小写)
original_name = stat.metadata.get("original-name", "")
stream = minio_client.get_object(settings.minio_bucket, object_key)
except S3Error as e:
# 如果是文件不存在,直接 404
if e.code == "NoSuchKey":
raise HTTPException(status_code=404, detail=f"文件不存在: {str(e)}")
# 2. 降级策略:如果 HEAD 失败 (如 Nginx 代理丢弃鉴权头导致 AccessDenied),尝试 GET 请求
# 这能解决 stat_object 报错但实际有权限读取文件的问题
try:
response = minio_client.get_object(
settings.minio_bucket, object_key)
stream = response
# 从 HTTP Header 中提取信息
if response.headers.get("content-type"):
content_type = response.headers["content-type"]
# 提取原始文件名 (Header 中通常包含完整前缀 x-amz-meta-*)
# 兼容大小写
meta_val = response.headers.get("x-amz-meta-original-name") or \
response.headers.get("X-Amz-Meta-Original-Name")
if meta_val:
original_name = meta_val
except S3Error as get_err:
# 如果 GET 也失败,抛出错误
raise HTTPException(status_code=404 if get_err.code == "NoSuchKey" else 500,
detail=f"S3 操作失败: {str(get_err)}")
except Exception as get_err:
raise HTTPException(
status_code=500, detail=f"读取文件流失败: {str(get_err)}")
# 3. Content-Type 兜底推断 (处理未设置 Content-Type 的文件)
if content_type == "application/octet-stream":
guessed_type, _ = mimetypes.guess_type(object_key)
if guessed_type:
content_type = guessed_type
else:
# 针对常见后缀手动映射,防止 mimetypes 未覆盖
ext = object_key.split(".")[-1].lower()
type_map = {
'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime',
'zip': 'application/zip', 'rar': 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed', 'tar': 'application/x-tar',
'gz': 'application/gzip', 'pdf': 'application/pdf'
}
content_type = type_map.get(ext, 'application/octet-stream')
# 4. 设置响应头
resp_headers = {}
# 确定最终下载文件名
# 优先级:URL参数 name > 元数据/HTTP Header > 对象 Key 中的文件名
final_name = name or urllib.parse.unquote(
original_name) or object_key.split("/")[-1]
if dl == 1:
# 强制下载
encoded_name = urllib.parse.quote(final_name)
resp_headers["Content-Disposition"] = f'attachment; filename="{final_name}"; filename*=utf-8\'\'{encoded_name}'
else:
# 在线预览 (浏览器默认行为,显式设置 inline 可避免某些浏览器强制下载)
pass
return StreamingResponse(
stream,
media_type=content_type,
headers=resp_headers
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='')
parser.add_argument("--port", type=int,
default=8000, help="缺省: 8000")
args = parser.parse_args()
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=args.port)