Files
zTools2/app/controllers/file_admin_controller.py
zikai 7c193ca46e 前端整理: 合并文件管理页 + 新增导航页
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; 手动校验各页面路由与合并接口响应
2026-07-28 14:04:12 +08:00

188 lines
6.7 KiB
Python
Raw Permalink 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.

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