1. 合并 files-page 与 pdf-admin 为统一文件管理页 /api/files-page:
- 新增 GET /api/admin/files/with-pdf 接口,以 uploaded_files 为基础,
用 pdf_jobs.source_file_id / output_file_id 内存匹配,为关联文件标注
转换状态、用户软删标记与角色(源epub/产物PDF)
- PdfJobOut 补 source_file_id / output_file_id 字段
- file_browser 新增 PDF 任务列(状态徽标/软删标记/硬删任务按钮)
- 删除 /api/pdf-admin 路由及 static/pdf_admin.* 三个文件
2. 新增导航页 /api/index,卡片式收集所有页面入口;
各子页(upload/whiteboard/system_status/files-page)脚注加返回导航链接
3. 更新 docs/routes.md、docs/configuration.md 同步说明
测试: pytest tests/test_pdf_service.py 7 passed; 手动校验各页面路由与合并接口响应
188 lines
6.7 KiB
Python
188 lines
6.7 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.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))
|