把原本散落在 /root/zikai 的 FastAPI 服务整理到 server/ 子目录, 开启独立 git 与 venv。运行时数据(config.yaml / keys / uploads) 按 .gitignore 留在工作目录但不入仓。
123 lines
4.2 KiB
Python
123 lines
4.2 KiB
Python
"""上传服务:流式落盘 + 原子改名 + 元数据入库。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from fastapi import UploadFile
|
||
|
||
from ..config import get_settings
|
||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||
from ..models.uploaded_file import UploadedFile
|
||
from ..schemas.file import FileUploadResponse, UploadedFileOut
|
||
|
||
|
||
class UploadService:
|
||
def __init__(self, dao: UploadedFileDAO) -> None:
|
||
s = get_settings()
|
||
self.dao = dao
|
||
self.upload_root = s.resolved_upload_dir()
|
||
self.chunk_bytes = s.storage.chunk_bytes
|
||
self.hash_on_upload = s.storage.sha256_on_upload
|
||
|
||
# ---------------- 上传 ----------------
|
||
|
||
def stream_to_disk(
|
||
self, file: UploadFile, source: str, uploaded_by: str,
|
||
) -> FileUploadResponse:
|
||
"""流式落盘并写元数据。
|
||
|
||
先写 ``<final>.part``,DB 行提交成功后再 ``os.replace`` 成正式名。
|
||
任何失败仅会留下可识别的 ``.part``,由 start.sh 启动时自动清理。
|
||
"""
|
||
rel_dir = self._relative_dir()
|
||
(self.upload_root / rel_dir).mkdir(parents=True, exist_ok=True)
|
||
|
||
ext = self._safe_ext(file.filename or "")
|
||
rel_path = rel_dir / f"{uuid.uuid4().hex}{ext}"
|
||
abs_path = self.upload_root / rel_path
|
||
part_path = abs_path.with_name(abs_path.name + ".part")
|
||
|
||
size, digest = self._write_part(file, part_path)
|
||
|
||
entity = UploadedFile(
|
||
storage_path=str(rel_path),
|
||
original_filename=os.path.basename(file.filename or rel_path.name),
|
||
content_type=file.content_type or "",
|
||
size_bytes=size,
|
||
sha256=digest,
|
||
source=source,
|
||
uploaded_by=uploaded_by,
|
||
)
|
||
try:
|
||
saved = self.dao.create(entity)
|
||
except Exception:
|
||
part_path.unlink(missing_ok=True)
|
||
raise
|
||
|
||
try:
|
||
os.replace(part_path, abs_path)
|
||
except Exception:
|
||
try:
|
||
self.dao.delete(saved.id)
|
||
finally:
|
||
part_path.unlink(missing_ok=True)
|
||
raise
|
||
|
||
return FileUploadResponse(
|
||
id=saved.id,
|
||
filename=saved.original_filename,
|
||
size_bytes=saved.size_bytes,
|
||
sha256=saved.sha256,
|
||
storage_path=saved.storage_path,
|
||
uploaded_at=saved.uploaded_at,
|
||
)
|
||
|
||
# ---------------- 查询 ----------------
|
||
|
||
def list_files(self, limit: int = 100, offset: int = 0) -> tuple[int, list[UploadedFileOut]]:
|
||
rows = self.dao.list(limit=limit, offset=offset)
|
||
items = [UploadedFileOut.model_validate(r) for r in rows]
|
||
return len(items), items
|
||
|
||
def get_out(self, file_id: int) -> UploadedFileOut | None:
|
||
row = self.dao.get_by_id(file_id)
|
||
return UploadedFileOut.model_validate(row) if row else None
|
||
|
||
def resolve_disk_path(self, file_id: int) -> Path | None:
|
||
row = self.dao.get_by_id(file_id)
|
||
return (self.upload_root / row.storage_path).resolve() if row else None
|
||
|
||
# ---------------- 内部 ----------------
|
||
|
||
@staticmethod
|
||
def _relative_dir() -> Path:
|
||
now = datetime.now(timezone.utc)
|
||
return Path(f"{now:%Y}/{now:%m}")
|
||
|
||
@staticmethod
|
||
def _safe_ext(filename: str) -> str:
|
||
return os.path.splitext(os.path.basename(filename))[1]
|
||
|
||
def _write_part(self, file: UploadFile, part_path: Path) -> tuple[int, str]:
|
||
"""流式写到 part_path 并 fsync;返回 (size, sha256)。失败时清理残品。"""
|
||
hasher = hashlib.sha256() if self.hash_on_upload else None
|
||
size = 0
|
||
try:
|
||
with part_path.open("wb") as out:
|
||
while chunk := file.file.read(self.chunk_bytes):
|
||
out.write(chunk)
|
||
size += len(chunk)
|
||
if hasher is not None:
|
||
hasher.update(chunk)
|
||
out.flush()
|
||
os.fsync(out.fileno())
|
||
except Exception:
|
||
part_path.unlink(missing_ok=True)
|
||
raise
|
||
return size, (hasher.hexdigest() if hasher is not None else "")
|