"""文件管理接口(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))