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 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 = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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(documents.router, prefix="/documents", tags=["documents"])
api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"]) 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(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 pathlib import Path
from uuid import uuid4 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) return self.save_bytes(file.file.read(), suffix=Path(file.filename or "upload.bin").suffix)
def save_bytes(self, raw: bytes, suffix: str = "") -> str: def save_bytes(self, raw: bytes, suffix: str = "") -> str:
settings = get_settings() base = self._base_dir()
storage_dir = Path(settings.local_storage_dir) date_seg = datetime.now().strftime("%Y/%m/%d")
storage_dir.mkdir(parents=True, exist_ok=True) target_dir = base / date_seg
key = f"{uuid4().hex}{suffix}" target_dir.mkdir(parents=True, exist_ok=True)
(storage_dir / key).write_bytes(raw) filename = f"{uuid4().hex}{suffix}"
return key (target_dir / filename).write_bytes(raw)
return f"{date_seg}/{filename}"
def temporary_url(self, key: str) -> str: 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)