feat: storage 改按日期分组,加 FastAPI 代理路由

- 上传落到 storage/YYYY/MM/DD/{uuid}.ext,storage_key 记录相对路径
- 新增 GET /api/files/{key:path} 读文件,防目录穿越;旧的扁平 key 也兼容
- temporary_url 返回 /api/files/{key},前端可直接用
- storage 目录已在 .gitignore(backend/storage/),无需再改

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shijing 2026-07-14 09:51:05 +08:00
parent 83b3fac62c
commit 7214649759
3 changed files with 39 additions and 8 deletions

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api import audit, auth, chat, documents, permissions, roles, stats, units, users
from app.api import audit, auth, chat, documents, files, permissions, roles, stats, units, users
api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -12,3 +12,4 @@ api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(documents.router, prefix="/documents", tags=["documents"])
api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(files.router, prefix="/files", tags=["files"])

17
backend/app/api/files.py Normal file
View File

@ -0,0 +1,17 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from app.integrations.storage_client import StorageClient
router = APIRouter()
@router.get("/{key:path}")
def get_file(key: str) -> FileResponse:
try:
path = StorageClient().resolve(key)
except ValueError:
raise HTTPException(status_code=400, detail="非法路径")
if not path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(path)

View File

@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
from uuid import uuid4
@ -11,12 +12,24 @@ class StorageClient:
return self.save_bytes(file.file.read(), suffix=Path(file.filename or "upload.bin").suffix)
def save_bytes(self, raw: bytes, suffix: str = "") -> str:
settings = get_settings()
storage_dir = Path(settings.local_storage_dir)
storage_dir.mkdir(parents=True, exist_ok=True)
key = f"{uuid4().hex}{suffix}"
(storage_dir / key).write_bytes(raw)
return key
base = self._base_dir()
date_seg = datetime.now().strftime("%Y/%m/%d")
target_dir = base / date_seg
target_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid4().hex}{suffix}"
(target_dir / filename).write_bytes(raw)
return f"{date_seg}/{filename}"
def temporary_url(self, key: str) -> str:
return f"local://{key}"
return f"/api/files/{key}"
def resolve(self, key: str) -> Path:
base = self._base_dir().resolve()
target = (base / key).resolve()
if base != target and base not in target.parents:
raise ValueError("非法存储路径")
return target
@staticmethod
def _base_dir() -> Path:
return Path(get_settings().local_storage_dir)