PDF 工作记录门户:项目结构与请求响应流程

这篇文档用于说明当前工作记录博客的实现方式。项目目标很明确:游客从左侧目录选择 Markdown 文章并阅读网页内容,文章中的附件链接可以预览 PDF 或 Word 转换后的 PDF;管理员登录后可以上传 Markdown 文章、PDF/Word 附件,并下载附件原始文件。系统不使用数据库,所有文件都保存在服务器本地的 uploads/ 目录中。

项目结构

当前目录结构如下:

WorkRecord/
├── main.py
├── requirements.txt
├── requirements-dev.txt
├── Dockerfile
├── docker-compose.yml
├── deploy.sh
├── .env
├── templates/
│   ├── index.html
│   ├── login.html
│   └── admin.html
├── uploads/
│   ├── originals/
│   ├── previews/
│   ├── posts/
│   └── .gitkeep
├── tests/
│   └── test_app.py
└── docs/
    └── current-project-structure-and-request-flow.md

各文件职责:

文件或目录 作用
main.py FastAPI 主程序,包含路由、JWT 鉴权、文件上传、Markdown 渲染、Word 转 PDF、文件预览、文件下载逻辑。
templates/index.html 游客导航首页,左侧展示文章目录,右侧展示站点摘要和近期文章。
templates/login.html 管理员登录页面。
templates/admin.html 管理员后台页面,包含上传表单、文章列表、附件列表、预览和原始文件下载入口。
templates/post.html Markdown 文章阅读页面。
uploads/posts/ Markdown 文章存储目录。
uploads/originals/ 管理员上传的原始文件。
uploads/previews/ Word 转换后的 PDF 预览文件。
uploads/ 文件存储根目录,Docker 部署时挂载到宿主机,保证容器重启后文件不丢失。
.env 敏感配置,包括管理员密码、JWT 密钥、Cookie 安全开关。
requirements.txt 生产环境依赖。
requirements-dev.txt 测试环境依赖。
Dockerfile 多阶段 Docker 构建文件,包含 runtimetest 两个目标。
docker-compose.yml 容器编排文件,暴露 5002 端口并挂载 uploads/
deploy.sh 一键部署脚本。
tests/test_app.py 自动化测试,覆盖游客预览、游客禁止下载、管理员上传下载、非 PDF 拒绝上传。

技术栈

项目使用的核心技术如下:

技术 作用
FastAPI 处理 HTTP 请求和路由分发。
Jinja2 服务端渲染 HTML 页面。
python-jose 生成和校验 JWT。
python-multipart 解析上传表单中的 PDF 文件。
aiofiles 异步读写本地文件。
Markdown 将 Markdown 文本转换成 HTML。
bleach 清理 Markdown 渲染后的 HTML,避免脚本注入。
LibreOffice 将 Word 文档转换成 PDF 预览文件。
python-dotenv .env 读取配置。
Uvicorn ASGI 应用服务器。
Docker Compose 云服务器一键部署和持久化挂载。

核心配置

.env 中保存运行时配置:

ADMIN_PASSWORD=cuilongfei
JWT_SECRET_KEY=...
COOKIE_SECURE=false

其中:

配置 说明
ADMIN_PASSWORD 管理员登录密码。
JWT_SECRET_KEY JWT 签名密钥,用来防止 Cookie 中的 Token 被伪造。
COOKIE_SECURE 是否只允许 HTTPS 发送 Cookie。公网 HTTPS 部署后建议改成 true

应用启动时会读取 .env。如果管理员密码或 JWT 密钥为空,应用会直接报错,不会带着不完整配置运行。

页面和路由

当前主要路由如下:

路由 方法 权限 作用
/ GET 游客可访问 展示 PDF 列表,只提供在线预览链接。
/login GET 游客可访问 展示管理员登录页面。
/login POST 游客可访问 校验密码,登录成功后写入 JWT Cookie。
/logout POST 管理员 删除 Cookie 并退出登录。
/admin GET 管理员 展示上传表单、文件列表、预览和下载入口。
/upload POST 管理员 接收 PDF 上传并保存到 uploads/
/view/{filename} GET 游客可访问 以内联方式返回 PDF,浏览器用于在线预览。
/download/{filename} GET 管理员 以附件方式返回 PDF,浏览器触发下载。

游客访问首页流程

游客打开首页时,流程如下:

浏览器
  -> GET /
FastAPI
  -> 调用 list_pdf_files()
  -> 扫描 uploads/*.pdf
  -> 读取文件名、大小、修改时间
Jinja2
  -> 渲染 templates/index.html
浏览器
  <- 返回 HTML 页面

这个页面不会出现下载按钮,只会出现在线预览入口:

/view/{filename}

因此,游客即使看到文件列表,也只能按系统设计进入浏览器预览。

PDF 在线预览流程

游客点击在线预览时,流程如下:

浏览器
  -> GET /view/20250627_100000_report.pdf
FastAPI
  -> resolve_upload_path(filename)
  -> 确认文件名不是路径穿越
  -> 确认文件存在于 uploads/
  -> stream_file(path)
aiofiles
  -> 分块读取 PDF
浏览器
  <- Content-Type: application/pdf
  <- Content-Disposition: inline

关键响应头是:

Content-Disposition: inline
Content-Type: application/pdf

inline 表示浏览器应尽量在页面内打开 PDF,而不是直接弹出下载窗口。

管理员登录流程

管理员访问 /login 并提交密码后:

浏览器
  -> POST /login
  -> form password=cuilongfei
FastAPI
  -> 读取 .env 中的 ADMIN_PASSWORD
  -> 比对用户提交的密码
  -> 密码正确,调用 create_access_token()
python-jose
  -> 生成 JWT,内容包含 sub=admin 和 exp=24小时后
FastAPI
  -> Set-Cookie: admin_token=...
  -> HttpOnly
  -> SameSite=Lax
浏览器
  <- 303 Redirect /admin

登录成功后,浏览器会自动保存 admin_token Cookie。后续访问受保护路由时,浏览器会自动带上这个 Cookie。

管理员后台访问流程

管理员访问后台时:

浏览器
  -> GET /admin
  -> Cookie: admin_token=...
FastAPI
  -> redirect_if_not_admin()
  -> verify_token()
  -> JWT 有效,允许继续
  -> list_pdf_files()
Jinja2
  -> 渲染 templates/admin.html
浏览器
  <- 管理后台 HTML

如果 Cookie 缺失、过期或伪造,系统会返回 303 并跳转到:

/login

管理员上传 PDF 流程

管理员在后台上传 PDF 时:

浏览器
  -> POST /upload
  -> multipart/form-data
  -> Cookie: admin_token=...
FastAPI
  -> require_admin()
  -> 校验 JWT
  -> sanitize_filename()
  -> ensure_pdf_name()
  -> 生成时间戳文件名
aiofiles
  -> 分块写入 uploads/
FastAPI
  <- 303 Redirect /admin

保存文件时会加时间戳,例如:

20260627_123000_工作周报.pdf

这样可以减少重名覆盖的风险。若同一秒内出现同名文件,系统会追加计数:

20260627_123000_1_工作周报.pdf

上传限制:

只允许 .pdf 后缀

如果上传 txtexezip 等文件,系统会返回 400 Bad Request

管理员下载 PDF 流程

管理员点击下载时:

浏览器
  -> GET /download/{filename}
  -> Cookie: admin_token=...
FastAPI
  -> require_admin()
  -> resolve_upload_path(filename)
  -> stream_file(path)
aiofiles
  -> 分块读取 PDF
浏览器
  <- Content-Disposition: attachment

关键响应头是:

Content-Disposition: attachment
Content-Type: application/pdf

attachment 表示浏览器应该把文件作为附件下载。

游客访问下载路由时,因为没有有效的管理员 Cookie,会得到:

401 Unauthorized

文件安全边界

文件访问有几个关键保护点:

  1. 上传前会清理文件名,只保留安全字符、中文、数字、下划线、横线和点。
  2. 上传时检查扩展名必须是 .pdf
  3. 读取文件时会使用 Path(filename).name 去掉路径部分。
  4. resolve_upload_path() 会确认目标文件确实在 uploads/ 目录下。
  5. 下载路由必须通过管理员 JWT 校验。
  6. 预览路由不需要登录,但只返回 inline 的 PDF 响应。

这些设计用于防止常见问题,例如:

../../etc/passwd
恶意脚本文件上传
游客绕过页面直接访问下载地址
伪造管理员 Cookie

Docker 部署流程

部署时运行:

./deploy.sh

脚本做两件事:

1. 构建 runtime 镜像:pdf-portal:local
2. 使用 docker compose 启动容器

docker-compose.yml 中最重要的是端口和卷挂载:

ports:
  - "5002:5002"
volumes:
  - ./uploads:/app/uploads

这表示:

配置 说明
5002:5002 宿主机 5002 端口转发到容器 5002 端口。
./uploads:/app/uploads 宿主机 uploads/ 挂载到容器内部,保证 PDF 持久化。

容器重启、重建后,只要宿主机 uploads/ 还在,已经上传的 PDF 就不会丢。

测试覆盖

当前测试命令:

DOCKER_BUILDKIT=0 docker build --target test -t pdf-portal:test .
docker run --rm --env-file .env pdf-portal:test

测试覆盖以下场景:

测试场景 预期
游客访问首页 能看到 PDF 列表和预览链接。
游客预览 PDF 返回 application/pdfinline 响应头。
游客下载 PDF 被拒绝。
管理员登录 成功后写入 admin_token Cookie。
管理员上传 PDF 文件保存到 uploads/
管理员下载 PDF 返回文件内容和 attachment 响应头。
上传非 PDF 返回 400,文件不会保存。
未登录访问后台 跳转到 /login

当前架构的特点

这个项目现在是一个非常轻的文件门户:

浏览器
  -> FastAPI
  -> Jinja2 页面
  -> uploads 文件系统

它没有数据库、没有用户注册、没有复杂后台,也没有标签分类。所有状态只有两类:

状态 保存位置
管理员登录状态 浏览器 HttpOnly Cookie 中的 JWT。
PDF 文件 服务器 uploads/ 目录。

这种结构适合个人工作记录,因为它简单、容易部署、容易备份,也容易在后续扩展成博客。

后续扩展成博客的自然方向

如果要把它从 PDF 门户扩展成个人博客,最自然的下一步是增加 Markdown 文章支持:

uploads/
├── pdf/
│   └── 20260627_工作周报.pdf
└── posts/
    └── 20260627_项目结构说明.md

对应路由可以变成:

路由 作用
/posts 文章列表。
/posts/{slug} 渲染 Markdown 文章。
/admin/posts/upload 管理员上传 Markdown。
/view/{filename} 继续预览 PDF。

这样仍然不需要数据库,文章元数据可以先从文件名和文件修改时间读取。等内容变多以后,再考虑 front matter、标签、搜索等功能。

当前项目最重要的基础已经具备:身份认证、文件上传、文件读取、模板渲染、Docker 部署。博客能力可以在这个基础上逐步加,不需要推倒重来。