做完了,不代表讲得清;讲得清,才算真的掌握。这一阶段把整个项目收口成两样东西:一份能讲 10 分钟的答辩 PPT 和 一份写给自己的项目总结。答辩不是复述代码,而是回答评委真正关心的问题——你做了什么、为什么这么做、遇到什么问题、怎么解决的。走完这一步,你手里就有了一件「可以拿出去说」的完整作品。
P1 封面 项目名 · 姓名 · 学号 · 指导老师 · 日期
P2 选题背景 为什么要做古典文学网(1 张图 + 1 句话)
P3 需求分析 3 类角色 · 功能清单(用户/作品/书评/收藏四大模块)
P4 技术架构 一张架构图:Vue 3 → Nginx → Gunicorn → Flask → MySQL 8
P5 数据库设计 ER 图 + 核心表(article / comment / favorite)
P6 接口设计 18 个接口总览 + 统一响应格式 {code, msg, data}
P7 技术难点 1 前后端分离与跨域(代理 + Nginx 反代)
P8 技术难点 2 JWT 鉴权 + 越权的后端校验
P9 技术难点 3 部署上线(Gunicorn + systemd + Nginx)
P10 项目演示 现场走一遍主流程(另附演示脚本)
P11 总结与展望 收获 · 不足 · 后续计划
【0. 准备】提前打开浏览器:标签1 = 网站首页;标签2 = journalctl 日志(可选)
【1. 浏览首页】约 30 秒
「这是首页,轮播图、五个文学分类和六篇推荐作品,数据都来自后端接口。」
动作:指向轮播图 → 点「宋词」分类
「点分类可以直接筛选,这是查询参数传给后端、由数据库筛出来的。」
【2. 打开详情】约 30 秒
动作:点「念奴娇·赤壁怀古」
「详情页有正文、浏览量、点赞数。注意浏览量每次访问 +1,是后端做的。」
动作:切到 Network 面板,指一下 /api/article/detail/3 这条请求
「这样评委能直观看到前端是真的在调后端,不是写死的假数据。」
【3. 写书评】约 40 秒
「我现在没有登录,先点发表书评看看会发生什么。」
动作:在书评框输入内容 → 点「发表」
「它提示我要先登录,并跳到了登录页 —— 这是前端的登录守卫
加后端的 @login_required 双重保护。我登录一下。」
动作:登录 tester / 123456 → 回到详情页 → 发表书评
「书评立刻出现在列表首位,总数也同步变了。」
【4. 点赞 + 收藏】约 40 秒
动作:点作品的「赞」,数字 +1;再点一次,撤回(说明是 toggle)
动作:点「收藏」,按钮变「已收藏」
「点赞和收藏都是 toggle 语义:同一个人再点一次就是取消。
后端用唯一键挡重复插入,连点两次不会产生两条记录。」
【5. 个人中心】约 40 秒
动作:点顶栏昵称 → 用户中心
「刚才的操作结果都在这里:我的收藏里能看到刚收藏的那篇,
个人中心还有我发表过的书评。」
动作:刷新页面(F5)
「刷新也不会掉登录态,因为 token 存在 localStorage,
进页面时用 /api/user/profile 恢复了一次用户信息。」
【6. 收尾】约 20 秒
「最后,这个网站已经部署上线,地址是 http://example.com,
大家现在用手机就能打开。」(指向屏幕上已打开的网址)
Q1 为什么做前后端分离?
答:前端是 Vue 3 的 SPA、后端只提供 JSON 接口,两者通过 HTTP 通信。
为什么:① 前后端可并行开发,靠接口文档先约定契约,互不等待;
② 前端能独立打包上线,后端能独立扩容升级,改一端不影响另一端;
③ 体验更好,页面切换不用整页刷新。
对比:不分离(Jinja2 直出模板)适合小站点、SEO 要求高的场景,
但前后端耦合,一个人改模板另一个人改接口容易互相踩。
Q2 为什么用 JWT,不用 Session?
答:登录成功后后端签发 token,前端存起来,之后每个请求带在
Authorization 头里,后端验签取出 user_id。
为什么:① 前后端分离下,跨域携带 Session Cookie 很麻烦
(要配 withCredentials 和 Cookie 的跨域策略);
② JWT 是无状态的,服务端不用存用户会话,
多台后端服务器扩展时不用做会话共享;
③ 手机 App、小程序也能用同一套鉴权。
代价:JWT 签发后服务端难以主动作废(这就是「退出登录」由前端
清 token 完成的原因),需要黑名单才能彻底解决。
Q3 为什么给 article 表加索引?
答:① 外键列 category_id、user_id 加索引,加速关联查询;
② 搜索用的 title/summary 加 FULLTEXT 全文索引;
③ favorite 表加联合唯一键 (user_id, article_id)。
为什么:索引把「全表扫描」变成「按结构定位」,数据量越大差距越明显。
联合唯一键不只是性能问题,它还从数据层杜绝了重复收藏。
代价:索引占空间、拖慢写入 —— 所以不要给从不用于查询的列乱加索引。
Q4 怎么防 SQL 注入?
答:用 SQLAlchemy 的查询构造器(filter_by / filter),条件值作为参数
交给数据库驱动做参数化处理。例如 Article.query.filter_by(title=keyword),
keyword 里写 ' OR 1=1 -- 也只会被当成一个普通字符串。
为什么:参数化查询让「数据」永远不会被当成「SQL 语句的一部分」。
注意:图省事用 text() 拼接字符串(f-string 拼 SQL),
就等于亲手打开了注入的口子 —— 课程里明确禁止这么写。
Q5 密码是怎么存的?
答:只存哈希,不存明文。注册时 user.set_password(pwd) 生成哈希入库,
登录时 user.check_password(pwd) 用同样的算法比对。
为什么:数据库被拖走时,攻击者拿到的是一堆不可逆的哈希值,
无法直接还原出密码。哈希算法必须是为密码专门设计的
(werkzeug 的 generate_password_hash),带随机盐,防彩虹表。
注意:响应里永远不返回 password 字段(测试用例里有专门一条断言)。
Q6 项目里最难的地方是什么?
答:(讲一个真实故事,别讲「都挺难的」)
示例:最难的是部署时的 502 —— 页面能开但接口全挂。
我的排查顺序是:先 systemctl status 看后端 → 发现是 failed →
再 journalctl 看堆栈 → 发现 ModuleNotFoundError: No module named 'app'
→ 顺着「gunicorn 在哪找模块」想到 WorkingDirectory 配错了。
改完 daemon-reload + restart 就恢复了。
为什么这样答:评委想听的是【排查思路】,不是结论。
能讲清「我先看什么、为什么看它、看到了什么」,比「我修好了」高分得多。
# 古典文学网 · 项目总结
## 一、项目概况
用 Vue 3 + Flask 3 + MySQL 8 实现的前后端分离古典文学网站,
含用户、作品、书评、收藏四大模块,共 18 个接口,已部署上线。
开发周期:2026-09-20 ~ 2026-10-16(约 4 周)
## 二、技术收获
1. 数据库:原来我以为建完表就完事,后来发现索引才是查询性能的关键。
现在我会在写查询前先看执行计划,给外键和搜索列加索引。
2. 后端:原来我以为 @app.route 一行就能写接口,后来发现
「统一响应格式 + 全局异常处理」能让前端少写一半判断。
3. 前端:原来我以为 axios.get 到处写就行,后来发现
放在拦截器里统一加 token 和拆包,18 个接口的代码省了三分之一。
4. 部署:原来我以为 python app.py 就是上线,后来发现
生产环境要用 Gunicorn + systemd + Nginx,这是完全不同的三件事。
## 三、工程收获(可迁移到任何项目)
1. 接口先行:先写接口文档,前后端并行开发,联调时几乎没有理解偏差。
2. 分层思维:request.js(通用)→ api(目录)→ 组件(消费)→ Pinia(共享),
每一层只干一件事,出问题时能快速定位在哪一层。
3. 证据意识:报错先看 Network 和日志,不靠猜;
测试要有用例编号、有可复现步骤,而不是「我点过了」。
4. 自下而上排查:502 先查后端活没活,别一上来就改 Nginx 配置。
## 四、待改进项
1. 收藏和书评列表没有做加载态与空状态,弱网下体验不好。
2. 移动端筛选栏在 375px 下会重叠(BUG-03),尚未修复。
3. 评论还没做分页缓存,作品多了之后需要引入 Redis。
4. 没有做后台管理,内容只能通过 SQL 维护。
5. 测试只覆盖了用户/书评/收藏三个模块,作品模块靠手工走查。
【第一步:回顾目标】当初打算做到什么?
· 完成一个前后端分离的古典文学网,覆盖用户/作品/书评/收藏
· 18 个接口全部实现并自测通过
· 部署到公网,手机能打开
· 交出一份测试报告
【第二步:对比结果】实际做到了什么?(实事求是,别美化)
· 功能:四大模块全部完成,18 个接口全部实现 —— 达成
· 部署:已上线 http://example.com —— 达成
· 测试:26 条用例通过 26 条,遗留 1 个移动端布局缺陷 —— 基本达成
· 进度:比计划晚了 3 天 —— 未达成
· 一个意外:原以为「部署是最简单的收尾」,结果卡了整整两天
【第三步:分析原因】为什么会这样?(每个差异都追到根因)
· 为什么部署卡了两天?
→ 直接原因:502 报错,我不知道从哪查
→ 根本原因:我对「请求经过 Nginx → Gunicorn → Flask」这条链路
没有整体认识,出问题时不知道先查哪一层
→ 还有一个原因:我开始直接用 root 跑、没配 systemd,
环境混乱,报错信息互相干扰
· 为什么进度晚了 3 天?
→ 接口联调的时间被低估了,CORS、代理、token 透传这些小问题
每个都要查半小时
· 为什么本该简单的「上传头像」也要花半天?
→ 忘了改 Nginx 的 client_max_body_size,一直返回 413,
而 Flask 日志里什么都没有,方向找错了半天
【第四步:提炼经验】下次怎么做?(要写成可执行的规则)
· 部署前先「画一遍请求链路图」,出错时按链路自下而上查
· 联调阶段给自己预留 3 天缓冲,不要按「写代码的时间」估算
· 遇到「前端有响应但后端无日志」→ 先怀疑 Nginx(请求没转发过去)
· 所有环境配置(.env、systemd、Nginx)在本地先演练一遍再上服务器
· 结论:部署不是「收尾」,它是一个独立的、需要专门学习的环节
from functools import wraps
from flask import g
from utils.response import fail
from utils.auth import login_required
def admin_required(view_func):
"""在 login_required 之上再叠一层「角色校验」。
用法:
@admin_bp.post("/article/add")
@admin_required
def article_add(): ...
"""
@wraps(view_func)
@login_required # 先确认登录,再确认角色
def wrapper(*args, **kwargs):
# user 表里需要新增一个 role 字段(1=普通用户,9=管理员)
if g.current_user.role != 9:
return fail("无权访问,仅管理员可操作", code=403)
return view_func(*args, **kwargs)
return wrapper
# 后端镜像:把「Python 环境 + 依赖 + 代码」一起打包
FROM python:3.10-slim
# 换国内源加速(可选),并装编译 psycopg2 / cryptography 需要的系统库
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先装依赖:依赖没变时这层会被缓存,构建更快
COPY hiflask/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# 再拷代码
COPY hiflask/ .
EXPOSE 5000
# 容器里可以监听 0.0.0.0(外面还有 compose 网络隔离),
# 但优先级最高的做法仍是只暴露给同一 compose 网络里的 Nginx 容器
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
services:
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: 换成强密码
MYSQL_DATABASE: hiwen
volumes:
- db_data:/var/lib/mysql
- ./hiwen.sql:/docker-entrypoint-initdb.d/hiwen.sql:ro
# 不写 ports —— 数据库只在 compose 内网可达,不对外暴露
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
retries: 5
backend:
build: .
environment:
FLASK_CONFIG: prod
DB_HOST: db # 用服务名当主机名,compose 自动解析
DB_NAME: hiwen
DB_USER: hiwen
DB_PASSWORD: 换成应用账号密码
depends_on:
db:
condition: service_healthy
nginx:
image: nginx:1.24
ports:
- "80:80"
- "443:443"
volumes:
- ./dist:/usr/share/nginx/html:ro
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
volumes:
db_data:
【难点 1】连点两次收藏报 500
现象:快速双击收藏按钮,接口返回 500,页面无提示
排查:看 Network → 状态 500;看 Flask 日志 → IntegrityError:
Duplicate entry '3-1' for key 'uk_fav_user_article'
根因:两个请求几乎同时到达,都判断「没收藏过」走了 INSERT 分支,
第二个撞上唯一键
修复:favorite_toggle 里 try/except IntegrityError → rollback,
并发下第二次当作「已收藏」处理,不报错
经验:唯一键既是数据保障,也会成为并发下的异常源 ——
凡是「先查再插」的写法,都要考虑并发
【难点 2】刷新详情页 404
现象:从首页点进详情正常,但在详情页按 F5 就 404
排查:curl -I http://example.com/article/1 → 404;
看 Nginx 配置 → location / 里没写 try_files
根因:Nginx 把 /article/1 当成磁盘上的文件去找,找不到就 404,
根本轮不到 Vue Router
修复:location / { try_files $uri $uri/ /index.html; }
经验:只要用了 history 模式的路由,就必须配 try_files 兜底
【难点 3】上传头像一直失败
现象:本地能上传,线上点上传就报错
排查:Network 显示 413 Request Entity Too Large;
但 Flask 日志里一条记录都没有
根因:Nginx 的 client_max_body_size 默认只有 1m,
请求被 Nginx 直接拒绝,压根没转发给 Flask
修复:client_max_body_size 4m; 并 reload nginx
经验:「前端有响应、后端无日志」→ 先怀疑 Nginx,别去改 Python
【可以直接沿用(不用改)】
· 技术架构页:Vue 3 → Nginx → Gunicorn → Flask → MySQL 8,一张图照搬
· 接口设计页:{code, msg, data} 统一格式、分页约定、RESTful 风格照搬
· 技术难点:前后端分离与跨域、JWT 鉴权、部署上线 —— 完全一致
· 测试方法:用例表结构、pytest 写法、兼容性清单 —— 完全一致
· 复盘四步法与项目总结模板 —— 换几个名词即可
【必须重新准备】
· 选题背景:为什么做电影赏析(文学 → 电影,动机完全不同)
· 需求分析:新增「评分」「想看/看过」「按类型/年份筛选」等需求
· 数据库设计:ER 图重画 —— movie 表多出 director / duration / release_year,
还有专门的 rating 表(一个用户对一部电影只能有一个评分)
· 演示脚本:演示内容换成「找电影 → 看详情 → 打分 → 写短评 → 我的想看」
· 数据:种子数据、演示数据全部要造(不能再讲「静夜思」)
【2 个贴合电影主题的答辩提问】
Q 你的评分功能,一个用户对同一部电影重复打分怎么处理?
答:rating 表加了联合唯一键 (user_id, movie_id),重复提交就是更新
已有记录(而不是新增一条)。做法是先查有没有、有就 UPDATE、
没有就 INSERT,并 try/except 捕获并发下的唯一键冲突。
为什么要有唯一键:否则同一个用户可以给自己喜欢的电影打 100 次
10 分,把平均分刷上去 —— 这是业务规则,靠数据层保证最可靠。
Q 平均分是实时算还是存字段?为什么?
答:存冗余字段。movie 表加一个 rating_avg 和 rating_count,
每次打分后重算并写回。
为什么:电影列表要显示每部电影的评分,如果每次列表都去
rating 表 JOIN + AVG,数据量一大就慢;存成冗余字段后列表查询
只是一次简单读取。
代价:要保证一致性 —— 打分、取消打分、删除评分这几个入口
都要记得更新它,这是典型的「用写入复杂度换查询性能」,
和 article 表里存 likes 冗余计数是同一个思路。
第 3 题的关键:把「知识」和「知识在具体项目里的用法」分开看。架构、部署、测试方法这些是可迁移的通用能力;选题、需求、数据这些是项目专属内容。能一眼分清这两类,你在下一个项目里就能复用一半的经验、只花一半的时间——这正是整个综合实战想教给你的东西。