Files
zTools2/app/services/upload_service.py
zikai 80b96d236f init: 从 /root/zikai 根目录迁入
把原本散落在 /root/zikai 的 FastAPI 服务整理到 server/ 子目录,
开启独立 git 与 venv。运行时数据(config.yaml / keys / uploads)
按 .gitignore 留在工作目录但不入仓。
2026-06-23 16:17:31 +00:00

123 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""上传服务:流式落盘 + 原子改名 + 元数据入库。"""
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 "")