SFTP 上传的文件直接落盘到 upload_root(chroot 根),不经 HTTP 路径,无 DB 记录, 文件浏览页(读 uploaded_file 表)看不到。新增磁盘扫描机制: - UploadService.scan_sftp_files(force=False):遍历 uploads/ 下所有文件(排除 .work/ 与 .part 残品),与 DB 已有 storage_path 比对,磁盘有 DB 无的补录(source="sftp", 流式算 size + sha256)。 - 频率限制:模块级 _last_scan_time,两次扫描间隔 < 3s 则跳过返回 0(只读 DB 缓存), force=True 强制扫。 - file_admin_controller 的 list 接口返回前触发 scan_sftp_files(),文件浏览页打开/ 刷新即自动同步 SFTP 文件(受 3s 频率限制,不会每次翻页都扫磁盘)。 - UploadedFileDAO 加 list_storage_paths() 批量查已记录路径集合供比对。 SFTP 文件补录后可正常下载/删除(与 HTTP 上传文件一致)。
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
"""文件管理接口(Basic Auth,鉴权同 docs)。
|
||
|
||
与公开的 /api/files 区分:本路由面向「文件浏览页」,提供列表 / 查询 / 下载 / 删除,
|
||
均需 docs 凭据。公开路由(user.py 依赖的查重、查询、下载)保留不变。
|
||
|
||
硬删除策略:删 DB 行 + 删磁盘文件(unlink missing_ok),列表只展示仍存在的行。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from fastapi.responses import FileResponse
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||
from ..schemas.file import FileListResponse, UploadedFileOut
|
||
from ..security import require_docs_auth
|
||
from ..services.upload_service import UploadService
|
||
|
||
router = APIRouter(prefix="/api/admin/files", tags=["files-admin"])
|
||
|
||
|
||
def _service(db: Session = Depends(get_db)) -> UploadService:
|
||
return UploadService(UploadedFileDAO(db))
|
||
|
||
|
||
class DeleteResult(BaseModel):
|
||
deleted: bool
|
||
|
||
|
||
class BatchDeleteRequest(BaseModel):
|
||
ids: list[int] = Field(..., description="要删除的文件 id 列表")
|
||
|
||
|
||
class BatchDeleteResult(BaseModel):
|
||
deleted: int = Field(..., description="实际删除的条数")
|
||
not_found: list[int] = Field(default_factory=list, description="未找到的 id")
|
||
|
||
|
||
@router.get(
|
||
"",
|
||
response_model=FileListResponse,
|
||
summary="列出已上传文件(需鉴权)",
|
||
description="供文件浏览页使用;字段同公开 /api/files,但要求 docs Basic Auth。支持分页。"
|
||
"返回前会触发磁盘扫描(频率限制 3s),把 SFTP 上传但未入库的文件补录进来。",
|
||
)
|
||
def list_files(
|
||
limit: int = Query(100, ge=1, le=10000, description="每页条数,1-10000"),
|
||
offset: int = Query(0, ge=0, description="偏移量"),
|
||
service: UploadService = Depends(_service),
|
||
_: str = Depends(require_docs_auth),
|
||
) -> FileListResponse:
|
||
service.scan_sftp_files() # 频率限制内置:3s 内重复只返回 DB 缓存
|
||
total, items = service.list_files(limit=limit, offset=offset)
|
||
return FileListResponse(total=total, items=items)
|
||
|
||
|
||
@router.post(
|
||
"/batch-delete",
|
||
response_model=BatchDeleteResult,
|
||
summary="批量硬删除文件(需鉴权)",
|
||
description="一次删除多个文件;返回实际删除条数与未命中的 id 列表。",
|
||
)
|
||
def batch_delete_files(
|
||
body: BatchDeleteRequest,
|
||
service: UploadService = Depends(_service),
|
||
_: str = Depends(require_docs_auth),
|
||
) -> BatchDeleteResult:
|
||
if not body.ids:
|
||
raise HTTPException(400, "ids 不能为空")
|
||
deleted = 0
|
||
not_found: list[int] = []
|
||
for fid in body.ids:
|
||
ok = service.delete_file(fid)
|
||
if ok:
|
||
deleted += 1
|
||
else:
|
||
not_found.append(fid)
|
||
return BatchDeleteResult(deleted=deleted, not_found=not_found)
|
||
|
||
|
||
@router.get(
|
||
"/{file_id}",
|
||
response_model=UploadedFileOut,
|
||
summary="查询单个文件元数据(需鉴权)",
|
||
)
|
||
def get_file(
|
||
file_id: int,
|
||
service: UploadService = Depends(_service),
|
||
_: str = Depends(require_docs_auth),
|
||
) -> UploadedFileOut:
|
||
out = service.get_out(file_id)
|
||
if out is None:
|
||
raise HTTPException(404, "文件不存在")
|
||
return out
|
||
|
||
|
||
@router.get(
|
||
"/{file_id}/download",
|
||
summary="下载文件(需鉴权,校验磁盘存在)",
|
||
description="文件实体不在磁盘上时返回 410 Gone。",
|
||
)
|
||
def download_file(
|
||
file_id: int,
|
||
service: UploadService = Depends(_service),
|
||
_: str = Depends(require_docs_auth),
|
||
) -> FileResponse:
|
||
out, path = service.get_out_with_disk_path(file_id)
|
||
if out is None:
|
||
raise HTTPException(404, "文件不存在")
|
||
if path is None or not path.exists():
|
||
raise HTTPException(410, "文件实体已不在磁盘上")
|
||
return FileResponse(
|
||
path=str(path),
|
||
media_type=out.content_type or "application/octet-stream",
|
||
filename=out.original_filename,
|
||
)
|
||
|
||
|
||
@router.delete(
|
||
"/{file_id}",
|
||
response_model=DeleteResult,
|
||
summary="硬删除文件(需鉴权)",
|
||
description="删除 DB 行与磁盘文件实体;不可恢复。列表随后不再显示。",
|
||
)
|
||
def delete_file(
|
||
file_id: int,
|
||
service: UploadService = Depends(_service),
|
||
_: str = Depends(require_docs_auth),
|
||
) -> DeleteResult:
|
||
return DeleteResult(deleted=service.delete_file(file_id))
|