"""文件管理接口(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.pdf_job_dao import PdfJobDAO from ..dao.uploaded_file_dao import UploadedFileDAO from ..schemas.file import ( FileListResponse, FileWithPdfListResponse, PdfJobBrief, UploadedFileOut, UploadedFileWithPdfOut, ) 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.get( "/with-pdf", response_model=FileWithPdfListResponse, summary="列出已上传文件并附带 PDF 转换任务关联(需鉴权)", description=( "合并管理页使用:以 uploaded_files 为基础分页拉取,再内存匹配 pdf_jobs," "为每个文件附带它作为 PDF 任务「源文件(epub)」或「产物(PDF)」时的状态、" "进度与用户软删标记。匹配键为 pdf_job.source_file_id / output_file_id -> uploaded_file.id。" "返回前会触发磁盘扫描(频率限制 3s),把 SFTP 上传但未入库的文件补录进来。" ), ) def list_files_with_pdf( limit: int = Query(100, ge=1, le=10000, description="每页条数,1-10000"), offset: int = Query(0, ge=0, description="偏移量"), service: UploadService = Depends(_service), db: Session = Depends(get_db), _: str = Depends(require_docs_auth), ) -> FileWithPdfListResponse: service.scan_sftp_files() # 频率限制内置:3s 内重复只返回 DB 缓存 total, items = service.list_files(limit=limit, offset=offset) # 拉全部 pdf_jobs(数据量小,不分页),建反查 map jobs = PdfJobDAO(db).list_all(limit=10000, offset=0) by_source: dict[int, list] = {} by_output: dict[int, list] = {} for j in jobs: by_source.setdefault(j.source_file_id, []).append(j) if j.output_file_id is not None: by_output.setdefault(j.output_file_id, []).append(j) out_items: list[UploadedFileWithPdfOut] = [] for f in items: briefs: list[PdfJobBrief] = [] for j in by_source.get(f.id, []): briefs.append(PdfJobBrief( job_id=j.id, role="source", status=j.status, progress=j.progress, user_deleted=j.user_deleted, deleted_at=j.deleted_at, )) for j in by_output.get(f.id, []): briefs.append(PdfJobBrief( job_id=j.id, role="output", status=j.status, progress=j.progress, user_deleted=j.user_deleted, deleted_at=j.deleted_at, )) out_items.append(UploadedFileWithPdfOut(**f.model_dump(), pdf_jobs=briefs)) return FileWithPdfListResponse(total=total, items=out_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))