
我第三次做 Web 大作业的时候终于想明白了一件事前两次的代码根本算不上“项目”顶多叫“页面拼图”。第一次用 Table 布局第二次用 Bootstrap 套模板到第三次如果还停留在“把页面做出来”的水平那这课等于白上了。所以这次我给自己定的目标很明确——做一个真正前后端分离、有数据交互、能部署上线、还能禁得住简单攻击自测的完整 Web 项目。这篇文章会把我的完整思路、技术选型原因、核心代码实现、部署踩坑记录、安全自检方法全部摊开来讲。如果你也正在为 Web 课设、期末大作业、结课项目头疼想从“页面仔”进阶到能写完整系统的人这篇文章应该能帮你少走不少弯路。1. 先回答“这次到底要做什么”选题与功能边界的确定第三次作业最容易犯的错不是做不出来而是根本不知道做什么。我见过太多人最后交上去的是一个“图书管理系统”——这当然没问题问题是所有人都写图书管理系统所有系统长得还都一样老师扫两眼就关了。1.1 一个观点关键词是“第三次”不是“大作业”这句话怎么理解第一次大作业是入门能跑就行第二次大作业是熟练结构规范、交互合理第三次大作业必须体现“系统思维”。也就是说这次作业的核心考核点大概率不是“你会不会写标签”而是“你有没有全栈视角、有没有工程化意识、有没有安全意识、有没有项目部署能力”。所以我最终把选题定成了一个小型内容管理平台核心功能包括用户注册、登录、会话保持Token 认证文章列表、详情、分类筛选后台发布 / 编辑 / 删除文章仅管理员评论功能用户登录后可用访问量统计基于 Redis 缓存 数据库持久化数据可视化面板ECharts 展示文章热度趋势听起来功能不少但拆开看每一个模块都不会超过课程大纲范围用户系统对应 Cookie/Session/Token文章模块对应增删改查和分页评论对应表关联统计对应 SQL 聚合。真正拉开差距的是“把这些功能粘合起来的方式”。1.2 素材管理交互设计比视觉堆砌重要很多同学的页面素材丰富到爆炸轮播图、动画、粒子特效、渐变背景。但交互一塌糊涂——点按钮没反馈表单不校验页面跳转重新加载所有资源。我这次刻意把视觉做“轻”把交互做“重”。CSS 只用了自定义变量 Flex/Grid 布局没有引任何 UI 框架为了演示对布局原理的掌握不用 Bootstrap但每个按钮都有 loading 状态、每次提交都有防抖、每个删除操作都有二次确认。老师在现场演示时操作体验上的顺滑程度比视觉上的炫酷更说明问题。1.3 交付物清单做完这三样才算完整可运行的源码Git 仓库含 README 和环境要求数据库初始化脚本含测试数据部署文档 线上可访问地址我用了一个月的轻量服务器如果你能主动把这三样交齐平时分基本就已经到手了。2. 技术选型为什么是 Vue3 FastAPI而不是别的组合第三次大作业的技术栈大概率学校不会强制指定或者只给一个模糊范围。这时候选型就是在“稳妥”和“展现能力”之间找平衡。我最终选了 Vue 3 FastAPI MySQL Redis 的组合理由我会逐个说清楚。2.1 前端方案Vue 3 在前端框架里的生态之位备选方案不是没有React 更流行但在课程作业这个体量下Vue 的渐进式上手路线更友好原生 JavaScript 当然也可以但等你要维护一个十几个组件的单页应用时你就会明白为什么不推荐纯手写 DOM。我用 Vue 3 Vite Pinia Vue Router。组件化让代码结构清晰到每一块都“所见即所得”。比如文章列表拆成一个ArticleCard组件列表页只是一个数据源 排列组合。以后想改成网格视图只需要加一个grid/list状态的切换类不需要动结构。2.2 后端方案FastAPI 到底好在哪这里我专门把“python web框架有哪些”这个问题重新捋了一遍。大三做 Web 作业主流方案无非那么几条框架上手难度适合场景我的判断Flask低小型应用、学习用太自由速度稍慢适合入门Django低~中大型应用、自带后台管理太重作业周期内发挥空间小FastAPI中API 服务、前后端分离、高性能现代、清晰、文档自动生成适合展示FastAPI 最吸引我的一点是它天然支持异步和现代 Python 类型注解接口文档靠 swagger 自动生成。这个特性在答辩时特别加分——你什么都不用做只要打开/docs路径老师就能看到你的所有 API 结构和参数说明这比对着 PPT 里手截图强的多。2.3 数据库怎么做MySQL 负责存储Redis 负责加速有人贪图省事用 SQLite一个文件搞定方便是方便但并发下会有锁问题而且“部署到服务器”这个环节的演示效果会大打折扣。我用的 MySQL 8.0结构上建了用户表、文章表、分类表、评论表、访问统计表。Redis 则用来做 Token 黑名单、文章阅读量缓存、防重复提交的窗口计数。2.4 为什么还要上 Nginx传统大作业做完就是python manage.py runserver然后浏览器访问127.0.0.1:8000截图交差。我这次特意多走一步用 Nginx 做了静态资源服务 反向代理前端打包产物由 Nginx 直接托管所有/api请求反向代理到 FastAPI 进程Uvicorn 启动。这个架构一句话就能说清楚Nginx 在外面守门FastAPI 在后面干活各司其职。3. 前端页面实现清单从路由到组件、从状态到请求前面说了那么多设计层面的东西这里开始上真格的。前端看起来页面不少但核心套路只有“组件化 状态管理 API 封装”这三个词。3.1 页面路由规划6 个页面是怎么组织进来的我按“游客 / 普通用户 / 管理员”三种身份规划了页面/首页文章流 分类导航 轮播推荐位/article/:id文章详情内容展示 评论列表 发评论入口/login与/register认证页面/admin后台文章管理表格 新增/编辑表单/dashboard数据面板ECharts 可视化按月统计发文量、阅读量 Top5 文章在 Vue Router 里注册这些路由时我把 meta 字段作为权限判断的依据。{ meta: { requiresAuth: true } }表示需要登录{ meta: { requiresAdmin: true } }表示必须是管理员。前端每次路由跳转前查一下当前用户状态不符合条件就跳登录页。这个功能每次作业都有人做但做明白的人不多——多半是只写了判断没有考虑“登录之后应该回到原来想去的页面”。我处理方式是跳登录页时在 query 里带上redirect参数登录成功后再router.replace(redirect)体验直接拉满。3.2 状态管理Pinia 里到底存了什么我把用户信息、Token、文章列表缓存、全局加载状态都放进了 Pinia。比如用户模块的示例代码// stores/user.js import { defineStore } from pinia import { loginApi, getUserInfoApi } from /api/auth export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), getters: { isLoggedIn: (state) !!state.token, isAdmin: (state) state.userInfo?.role admin }, actions: { async login(form) { const res await loginApi(form) this.token res.token localStorage.setItem(token, res.token) this.userInfo await getUserInfoApi() }, logout() { this.token this.userInfo null localStorage.removeItem(token) } } })为什么要用 Pinia 而不是每次在组件里直接发请求因为文章的阅读量统计、评论后数量刷新、管理员编辑文章后列表更新这些操作会直接改变多个页面的数据状态。如果你还停留在“父子组件传参 $emit”的思路这些联动会写到你怀疑人生。集中管理之后store里一个 action 一改所有依赖该状态的组件自动刷新。3.3 请求封装与错误处理这是后台反馈的“隐形加分项”我封装了一个request.js核心做了四件事带上 Token 请求头、响应拦截统一处理401过期、错误信息用ElMessage弹出、加载中状态统一管理。import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const service axios.create({ baseURL: /api, timeout: 8000 }) service.interceptors.request.use((config) { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) service.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { const userStore useUserStore() userStore.logout() router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } ElMessage.error(error.response?.data?.detail || 请求异常请稍后重试) return Promise.reject(error) } ) export default service这个封装的逻辑是从“CTF 比赛里 XSS 要把 Cookie 偷出来才能绕认证”这类攻防思路里反向学的——认证信息必须放在请求头而不是 URL 参数里同时要把失效后的处理流程做干净。平时看安全题的时候觉得是花架子写工程代码时才发现全是基本功。3.4 文章列表页的组件化结构页面最终结构是这样分的ArticleList.vue负责拉数据、传参ArticleCard.vue负责单篇文章的展示PaginationBar.vue负责分页CategoryNav.vue负责分类筛选每个组件只干一件事调试时不需要翻几百行代码找 bug。尤其注意列表页的分页参数page和category要放进 URL query 里。这样做的原因是刷新页面后用户的浏览位置不会丢把链接分享给朋友对方打开也是同一个列表状态。这是我以前做纯data状态管理时完全没想到的细节直到被老师问了一句“你这个刷新之后怎么回到第一页了”才发现问题。4. 后端设计与数据库建模如何让表关系既简洁又禁得住问后端是整个项目的核心骨架。FastAPI 速度确实快但真正体现系统设计水平的还是那几个 JWT 认证的接口和数据库表的设计。如果只是堆接口答辩时老师随便问一个“为什么评论要关联两次用户表”就能把你问住。4.1 用户表设计的细节密码加密与角色字段用户表结构CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名登录用, password_hash VARCHAR(255) NOT NULL COMMENT 密码哈希值, role ENUM(user,admin) NOT NULL DEFAULT user, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像路径, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码不用明文也不用简单的 MD5而是用passlib配合bcrypt算法。工作因子设为 12意思是每次哈希计算要故意消耗一定的计算时间。这听起来像是性能浪费但这就是安全成本攻击者拿数据库之后想暴力跑出密码每试一个都要付出同样的代价成本直接翻十几倍。4.2 JWT 认证流程从注册到请求带 Token 的完整链路认证逻辑我写成了一套注册时后端校验用户名唯一性密码通过bcrypt哈希登录成功后生成JWT Token有效期 2 小时前端把 Token 存到localStorage每次请求通过Authorization: Bearer token发送后端依赖OAuth2PasswordBearer解析 Token 并获取当前用户后端核心代码from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext SECRET_KEY your-secret-key-change-in-production ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 120 pwd_context CryptContext(schemes[bcrypt], deprecatedauto) def create_access_token(data: dict, expires_delta: timedelta | None None): to_encode data.copy() expire datetime.utcnow() (expires_delta or timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES)) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) def verify_token(token: str): try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) return payload.get(sub) except JWTError: return None需要注意SECRET_KEY我放在.env里并通过pydantic-settings读取没有写在代码里。虽然只是作业但养成“配置不进代码”的习惯老师问起来也是一个亮点。4.3 文章的增删改查为什么说 RESTful 风格套路一样文章模块接口严格按 RESTful 风格写方法路径功能GET/api/articles文章列表分页、分类筛选GET/api/articles/{id}文章详情POST/api/articles新增文章仅管理员PUT/api/articles/{id}编辑文章仅管理员DELETE/api/articles/{id}删除文章仅管理员列表接口的 SQL 用SQLAlchemy的select().where().order_by()链式写法配合limit/offset做分页。这类代码看起来简单但有一个细节值得注意详情接口返回文章正文时不能把正文全量塞进列表接口里。否则列表页一次性拉 100 篇文章的全文内容性能和流量都很浪费。正确的做法是列表接口只返回summary摘要字段详情接口才返回content全文。4.4 评论统计的 SQL 优化JOIN 查询背后的分组思维评论表需要与文章表关联核心 SQLSELECT a.id, a.title, COUNT(c.id) AS comment_count FROM article a LEFT JOIN comment c ON a.id c.article_id GROUP BY a.id, a.title ORDER BY comment_count DESC LIMIT 5;这条语句为什么用LEFT JOIN而不是INNER JOIN因为INNER JOIN会自动过滤掉没有评论的文章而我的列表需要显示“0 条评论”的文章LEFT JOIN能保留这些行再用COUNT(c.id)统计结果为 0。这就是很多前端同学学 SQL 时最容易忽略的区别。4.5 种子数据老师不会告诉你但查作业会用到的技巧作业交上去之后老师是不会有耐心先注册一个账号、再登录、再发 30 篇文章测试的。我准备了一个seed.py脚本跑一遍就会自动创建管理员账号、生成 8 个分类、往文章表里插入 50 篇带有 lorem ipsum 中文摘要的文章评论和阅读量也都有随机分布的数据。这 50 篇数据让分页、分类、图表统计这些功能看起来“活”了起来。如果把系统交上去空空如也就算功能都实现了观感也会差一个档次。5. 联调、调试与报错排查从登录态丢失到端口占用的完整链路作业做得再规整运行环境不配合也白搭。我在整个开发过程中踩了无数个坑把几个印象最深的报错和排查思路记录下来这些大概率也是你们要遇到的。5.1 CORS 跨域问题的三个解决方法我的前端跑在5173端口后端跑在8000端口前端直接请求后端的/api路径第一反应就是浏览器报跨域错误。解决方式有三层建议按顺序排查后端CORSMiddleware配置allow_origins[http://localhost:5173]前端 Vite 配置server.proxy代理把/api代理到http://localhost:8000生产环境让 Nginx 统一反向代理同源访问从根上消灭跨域# FastAPI 后端 CORS 配置 from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_credentialsTrue, allow_methods[*], allow_headers[*], )5.2 Token 登录状态丢失问题刷新页面后回到登录页调试时遇到一个很诡异的现象登录成功跳转到首页一切正常但只要按 F5 刷新前端立刻跳回登录页。排查链路很重要先看localStorage里 Token 在不在——如果在再看 Pinia 初始化时有没有从localStorage恢复 Token如果没恢复看用户信息是不是在页面刷新后被重置了。最后发现问题Pinia 初始化时只恢复了 Token却请求用户信息的动作放在了登录函数里刷新后 Token 在但userInfo是null路由守卫一看isLoggedIn为 false直接拦截。解决办法是写了一个init()方法在App.vue的onMounted里调用如果token存在就拉一次用户信息放进 store。然后再走路由守卫逻辑就通了。// App.vue onMounted(async () { const userStore useUserStore() if (userStore.token !userStore.userInfo) { try { await userStore.fetchUserInfo() } catch (e) { userStore.logout() } } })5.3 后端 403 而不是 401权限审计的大坑调试时管理员接口返回 403我一度以为 Token 解析出问题。后来才发现我的路由里get_current_user依赖声明在/admin路由后面而权限判断依赖current_user.role。问题的根因是管理员账号在数据库里的role字段默认值是空字符串并不是没有权限而是角色字段写错了。排查这类问题有个好习惯先把当前 Token 解析出来的用户信息print出来看看数据库里的字段到底是什么。很多时候不是代码逻辑错而是数据脏了。5.4 端口占用问题的快速定位有一次后端怎么都起不来报错[Errno 98] Address already in use。很多人第一反应是重启电脑其实一行命令就能找到罪魁祸首lsof -i :8000 kill -9 pid如果是在 Windows 上则用netstat -ano | findstr :8000再taskkill /PID pid /F。5.5 WebGL 报错引起的反思开发过程中我还遇到了一个和项目没什么直接关系的报错three.webglrenderer: a webgl context could not be created。当时只是想在首页加一个 Three.js 的 3D 背景效果结果发现浏览器加载不出来。排查后确认是浏览器扩展和显卡加速设置的问题而不是代码问题。这件事给我的启发是作业里的视觉效果一定要在答辩用的机器上提前测试不要赌老师教室电脑的硬件配置。3D 特效在微信小游戏和互动网站上很酷但如果环境不同意临时换方案比现场死磕容易得多。6. 部署与上线怎么把作业变成别人可以访问的网站本地跑通的作业严格意义上还不算“网站”。部署这一步能淘汰掉一半人因为涉及 Linux 命令、进程管理、Nginx 配置对很多平时只写代码的同学来说相当于另一个世界。6.1 服务器环境准备从零到 Nginx我用的是 2 核 2G 的轻量云服务器系统选了 Ubuntu 22.04。核心步骤# 更新源 sudo apt update sudo apt upgrade -y # 安装 Nginx、MySQL、Redis sudo apt install nginx mysql-server redis-server -y # 安装 Python 虚拟环境和项目依赖 python3 -m venv venv source venv/bin/activate pip install -r requirements.txt前端构建产物放在/var/www/frontend后端项目放在/home/app/server。为了让服务在断网、挂机后还能自动重启我用了 systemd 来管理 Uvicorn 进程# /etc/systemd/system/webapp.service [Unit] DescriptionFastAPI Web Application Afternetwork.target [Service] Userwww-data WorkingDirectory/home/app/server EnvironmentPATH/home/app/server/venv/bin ExecStart/home/app/server/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 Restartalways [Install] WantedBymulti-user.target这个配置最容易被忽略的是Restartalways。没有它服务器重启一次服务就永久掉线等老师访问时四五个 502 直接挂掉。6.2 Nginx 配置静态资源与 API 转发server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/frontend; index index.html; # 前端单页应用路由重写 location / { try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html是 SPA单页应用路由的核心打开/admin时服务器找不到这个物理路径就回退到根目录的index.html然后由 Vue Router 接管渲染。没有这一行刷新后台页面就会 404。6.3 HTTPS 配置原来一张证书这么简单现在主流浏览器对 HTTP 站点的标记越来越不友好好在部署 HTTPS 已经不是麻烦事。我用certbot申请了免费的证书sudo apt install certbot python3-certbot-nginx -y sudo certbot --nginx -d your-domain.com它自动修改 Nginx 配置并开启 HTTPS 跳转。前后五分钟搞定。评分时“地址栏那个安全锁”也是一种隐蔽的加分项。6.4 数据备份被老师检查之前必须做的事我写了一个backup.sh脚本每天凌晨 3 点用 crontab 执行一次把 MySQL 的数据 dump 出来然后上传到对象存储。脚本核心就两行mysqldump -u root -ppassword webapp /backup/webapp_$(date %Y%m%d).sql find /backup -name *.sql -mtime 7 -delete第二行是保留最近 7 天的备份旧自动删除。免得到时候磁盘被备份文件塞满又成为一个新的故障点。7. 从 CTF 思路看 Web 安全自检作业里不能只有功能没有防御相关热词里出现了大量 CTF、Web 安全的关键词的迭代。我本身也玩过一些 CTF在“查找 flag 夺旗赛”里积累了不少攻防视角。做这次作业时我特意用 CTF 的思维给自己的项目做了一轮完整的安全自检。建议每个做 Web 作业的认真跑一遍下面的检查单比你答辩时吹“系统安全”有说服力得多。7.1 SQL 注入第一必修课常规参数接口是最容易触发问题的地方。我的自检方式是直接用curl模拟常见攻击 payloadcurl http://localhost:8000/api/articles?category1 OR 11 -- 如果接口返回了所有分类的文章说明输入的category拼接进了 SQL。正确做法是全程用 SQLAlchemy 的参数化查询像下面这样stmt select(Article).where(Article.category_id category_id)参数化意味着数据库驱动会把“值”和“命令”分开处理任何输入都只是数据不会成为 SQL 指令的一部分。7.2 XSS 防御接口转义 前端转义两边都要做很多同学以为 XSS 是前端的事其实后端接口也要做输出编码。我在接口返回文章正文时对 HTML 做了特殊字符转义前端展示时再通过框架的插值语法{{ }}而不是v-html渲染。两头都设防即使某条数据违规了也弹不出脚本。import html def safe_render(content: str) - str: return html.escape(content)7.3 越权测试把非管理员用户的 Token 用到管理接口用普通用户登录后把 Token 复制出来直接PUT /api/articles/1。如果返回 200说明权限校验是假的。我实现的require_admin依赖会在请求进来时校验current_user.role。这一步是真的能拦住攻击而不是只在前端把后台菜单藏起来。7.4 文件上传与路径穿越我的项目没有开放文件上传功能——头像用的是外链 URL。这个决定有赛博层面的考量文件上传是 Web 安全里高危区涉及后缀白名单、MIME 嗅探、存储路径随机化、图片内容二次校验。作业周期内把这些做对非常难做了错反而可能成为安全隐患。所以在功能边界上我选择“宁缺毋滥”。7.5 一个真实的失败案例我代码里的 Token 曾经被泄露开发时我图方便在前端代码的后台接口里硬编码了一个管理员的 Token想着反正数据库是自己电脑上的不会被发现。结果代码提交到了 Git 仓库发布在网上后Bot 扫描工具在半小时内就拿到了那个 Token。它试了一次管理接口失败的日志在服务器上清清楚楚。这件事让我把“硬编码密钥”列为自己写代码的高压线。如果老师能扫一眼你的 Git 仓库发现里面有明文密码这个印象分基本就没了。7.6 快速安全自检清单提交前跑一遍用 Navicat 或命令行直接执行常见 SQL 注入 payload确认返回结果不受影响用普通用户管理员的 Token 分别请求管理接口确认能拦截登录后点击“退出”确认 localStorage 里 Token 被清除无登录状态访问后台路由确认被重定向到登录页查看 Nginx 日志确认没有异常扫描请求把 500 刷爆8. FastAPI 性能与接口设计进阶如果你想让成绩更进一步到这里作业的完整链路已经能跑通了。但如果你的目标是拿高分或者想展示“我真的懂了 Web 开发”下面这几个进阶点是绝对不能错过的加分项。8.1 FastAPI 异步接口一次查询与两次查询的差别FastAPI 默认支持async def路由配合httpx.AsyncClient或直接SELECT查询高并发下吞吐量明显优于同步写法。我在统计数据面板时就用了并发请求三个独立统计接口from asyncio import gather app.get(/api/dashboard) async def dashboard(): article_count, user_count, comment_avg await gather( get_article_count(), get_user_count(), get_comment_stats() ) return {articles: article_count, users: user_count, comments: comment_avg}这个接口在页面打开时的响应速度比串行请求三个接口快了一倍左右。答辩时随口说一句“这里用了 asyncio.gather 做并发查询”懂行的老师就会点头。8.2 Redis 缓存点击量统计的并发策略文章阅读量的高频更新如果每次都直接写 MySQL会被锁拖垮。我的思路是第一次访问文章时把阅读量从数据库加载到 Redis初始值1后续每次访问在 Redis 对article:{id}:views执行INCR后台每 10 分钟把 Redis 里的增量批量刷回 MySQLimport redis.asyncio as redis r redis.from_url(redis://localhost:6379) async def increment_views(article_id: int): key farticle:{article_id}:views await r.incr(key)这样即使用户疯狂刷新数据库的写压力也很小。把这个逻辑写进 README是比堆砌功能更高级的架构思维。8.3 分页、搜索、排序的综合接口设计列表接口支持了多个查询参数参数类型说明pageint页码page_sizeint每页数量keywordstr标题模糊搜索categoryint分类筛选sortstrlatest/hottest/most_commented通过Query()声明默认值并封装成一个PaginationParams依赖from fastapi import Query, Depends class PaginationParams: def __init__( self, page: int Query(1, ge1), page_size: int Query(10, ge1, le50), keyword: str Query(None), category: int Query(None), sort: str Query(latest) ): self.page page self.page_size page_size self.keyword keyword self.category category self.sort sort这种设计让接口既灵活又安全ge1保证页码不会为负数le50避免一次拉走全表。我在写前端列表页时直接把 URL query 参数原样转发给后端刷新、分享、回退全部自动保留状态体验非常顺畅。8.4 邮件通知不该做的功能坚决不做有人建议我加一个“用户注册后发欢迎邮件”的功能。我的判断是不加。因为邮件服务需要额外的 SMTP 配置、验证码、有效期内倒计时这些功能加不加都不会影响这个项目的核心评分点。相反多一个功能就多一个攻击面、多一个部署故障点。做 Web 作业功能边界意识和代码质量同等重要。9. 写文档、录演示视频这些形式主义其实很加分项目代码写完之后真正花精力的地方不是再写功能而是把一个可演示、可复现的完整交付物做好。文档和演示视频恰恰是很多技术能力不错但不懂展示的同学最容易丢分的地方。9.1 README 应该怎么写很多 README 一行“这是一个 Web 作业”就完了。我的 README 结构是项目简介一句话说清楚功能功能清单分用户端、管理端、统计端每个功能对着一个截图技术栈前端、后端、数据库、部署工具本地运行步骤clone、建库、装依赖、启动三步以内能跑起来线上地址只要服务器不关老师点开链接就能看写“本地运行步骤”时我特意找了一个完全没配过前端环境的室友来按文档走一遍。他卡在哪文档就在哪补。这份文档的价值在自己电脑上体现不出来等换一台电脑配置环境时就懂了。9.2 演示视频的节奏安排每个功能 10 秒作业如果需要演示视频我建议按以下节奏开场 5 秒项目名 技术栈 页面整体展示注册登录30 秒浏览文章20 秒发表评论20 秒管理员发布文章40 秒数据统计面板30 秒安全测试演示30 秒结束服务器地址 域名总时长控制在三分钟内。视频里不要展示“实现思路总结”的 PPT而是展示“能看的结果”。老师不太可能在视频上看你讲五分钟原理他能记住“这个系统能登录、能发文章、有统计图”就够了。9.3 答辩环节最容易被问的问题预判老师会问什么提前准备好答案。以下是我整理的五个高频问题“为什么用 Token 而不是 Session”——答前后端分离后 Token 天然跨域、服务端无状态、便于移动端复用。但作业里为了增加可靠性我把 Session 和 JWT 做了对比说明。“数据库表之间是什么关系”——答用户与文章一对多文章与评论一对多分类与文章一对多。“高并发量大的时候哪里会先瓶颈”——答数据库写操作。目前用 Redis 做了缓存后期可以加消息队列削峰。“部署用的什么”——答Nginx 反向代理 Uvicorn 进程 MySQL/Redissystemd 守护进程。“上线之后如果发现内存溢出怎么排查”——答先看htop定位进程再查 Uvicorn 日志用tracemalloc/memory_profiler做栈分析。这些问题我在答辩前对着镜子练了三遍确保每句话能在 30 秒内说清楚。等老师真的问出来的时候你会发现平时所有调试的辛苦都在这一刻兑现了。10. 写在最后的心得体会第三次 Web 大作业我一个月前开始做真正交付给老师那天回想整段过程最大的收获其实不是某一道题解出来了而是我终于把 Web 开发“从浏览器到数据库”这条链路摸了一遍写过前端组件、调过后端接口、查过数据库表、配了 Nginx、申请过 HTTPS 证书、在服务器上看到自己的系统被访问时日志一格一格地滚动——这种感觉和平时在本地runserver完全不一样。如果你现在也正在赶大作业我唯一的建议是先别急着敲代码花一天时间把技术栈选清楚、把表结构画出来、把接口清单列出来。磨刀不误砍柴工这句话在 Web 开发里前所未有的准确。另外一个很实用的技巧项目代码一定从第一天就放进 Git 仓库。我每天做完一个小功能就 commit 一次出问题随时能回退。最后交作业时Git 的提交记录本身就是一份完整的“开发日志”比任何口头描述都有说服力。我给自己定了一条规矩每一次 commit message 写清楚“我改了什么、为什么改、出了什么问题”。答辩的时候老师翻一遍提交记录就知道你不是三天熬夜赶出来的而是认认真真做了整一个月。最后一个也许对你有用的经验如果你觉得可视化面板、用户系统、后台管理这些功能都能独立实现就是合在一起时不知所措那就用一个最简单的办法——拆分。把一个“大系统”拆成“登录功能”和“文章功能”两个部分独立开发每个部分做完先跑通再通过路由和状态管理把两个部分衔接起来。拆到不能再拆复杂系统就变成了多个简单系统的组装。这个思路在课程作业上管用在真实企业级项目开发里同样管用。