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 构建文件,包含 runtime 和 test 两个目标。 |
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 后缀
如果上传 txt、exe、zip 等文件,系统会返回 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
文件安全边界
文件访问有几个关键保护点:
- 上传前会清理文件名,只保留安全字符、中文、数字、下划线、横线和点。
- 上传时检查扩展名必须是
.pdf。 - 读取文件时会使用
Path(filename).name去掉路径部分。 resolve_upload_path()会确认目标文件确实在uploads/目录下。- 下载路由必须通过管理员 JWT 校验。
- 预览路由不需要登录,但只返回
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/pdf 和 inline 响应头。 |
| 游客下载 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 部署。博客能力可以在这个基础上逐步加,不需要推倒重来。