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:
parent
83b3fac62c
commit
7214649759
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue