
每年到了毕设季选题那个晚上我作为曾经的选题系统受害者到现在还记得当时页面转圈、群里刷屏、老师抱着Excel表手动清理重复数据的混乱场面。那会儿我就琢磨为什么就不能做个顺手的选题管理系统后来轮到自己做毕业设计我干脆把基于Python的毕业设计选题系统当成课题配合当时正火的Vue3前端技术把前端、后端、数据库、权限、并发防超选这些全部撸了一遍最终做出来的系统不仅答辩过了还被学院拿去用了一届。这篇就完整复盘这个项目的思路和实践过程。内容覆盖需求拆解、技术选型、数据库建模、后端接口设计、Vue3前端落地以及联调部署时踩过的一堆坑。适合正在做同类毕设、或者想用PythonVue3组合完成一个全栈项目的同学参考。1. 毕设选题这场抢购痛点比你想的深很多人一上来就写代码结果做了个选题管理后台却发现答辩时根本讲不清楚你解决了什么问题。我建议先花时间做需求分析——一个好的选题系统核心不是增删改查而是它能不能真正解决选题季的混乱。1.1 三个角色三种完全不同的焦灼选题系统里基本逃不出三类人学生最关心选题开没开放、心仪的题目还有没有名额、老师什么时候审核。真实场景里热门课题在一分钟内被抢光慢一步就只能捡别人挑剩下的。教师最关心自己发布的课题被选情况以及要不要通过或拒绝某个学生的申请。有的老师希望先到先得有的希望综合考虑后协调甚至会有私下拉学生的情况系统需要稍微灵活一点。管理员通常是教务或教学秘书最关心全局进度——哪些课题没人选、哪些学生还没定题、开放时间要不要调整、要不要临时关闭某些课题。以前这些东西全靠Excel整理起来能让人崩溃。所以我的系统把权限划分为三类角色student、teacher、admin。登录后看到的页面完全不同后端接口也按角色做了严格校验这是整个系统设计的第一条主线。1.2 业务规则才是系统的灵魂有些规则看着简单真正落到代码里全是细节。我这版系统归纳了五条核心业务规则一个学生最终只能确定一个选题但在待确认状态时不允许再选其他题。每个课题有容量上限一旦满员就不能再选。教师可以对学生的申请进行通过或拒绝拒绝后学生可以重新选题。管理员能控制整个选题阶段的时间窗口窗口外任何学生都禁止提交申请。所有状态变化都要有记录方便出问题时追溯。为了把这几条规则讲清楚后来论文里我还画了状态流转图选题大厅里课题处于开放/关闭两个状态学生的申请记录则经历待确认 → 已通过/已拒绝的流转学生如果选了题又被拒绝可以回到未选题状态继续选。这套状态机写明白之后前后端联调和测试都顺了很多。2. 技术选型Python 后端 Vue3 前端不是随便拍的选型这块我纠结过一阵因为网上方案太多了后端有 Flask、Django、FastAPI前端有 Vue2、Vue3、React。最后我锁定的是FastAPI SQLAlchemy SQLite/MySQL Vue3 Element Plus Pinia Vite。这套组合我现在依然认为是同类毕设里性价比极高的搭配。2.1 后端框架对比FastAPI 为什么是甜点区我最早用 Flask 写过小工具确实简单但要自己配置的部件太多做接口项目时尤其别扭。Django 功能大而全自带 Admin 后台很棒但框架的重量感对一个毕设项目来说有点过了而且如果你只是想要一套 REST APIDjango Rest Framework 的学习曲线反而占用了做业务逻辑的时间。FastAPI 的好处有几个是实打实的基于类型注解自动生成接口文档/docs页面直接能看到全部接口联调时省了写接口文档的时间。自带参数校验用 Pydantic 定义请求体类型错了前端立刻能知道。异步支持很顺配合并发模拟测试很方便。我推荐后端就用 FastAPI数据库访问层用 SQLAlchemy 2.x 的 ORM 写法。数据库我开发时用的 SQLite方便省事部署到服务器后我再切换到 MySQL连接串一换模型代码基本不用动。如果是多人并发量大的场景建议直接上 MySQL。2.2 前端为什么锁定 Vue3 Element Plus选 Vue3 有几个实际理由。首先Vue3 的 Composition API 在组织业务逻辑时比 Vue2 的 Options API 清晰太多尤其是多人协同写多个页面的场景setup函数里可以把一个功能的响应式数据和操作函数集中在一处可读性明显更好。其次Vue3 生态已经相当成熟Element Plus 组件库直接提供了表格、表单、标签页、下拉树、弹窗等现成组件几乎覆盖了管理系统的全部常见场景。另外Vite 的开发服务器热更新比 Webpack 那套快很多。我用npm create vitelatest初始化项目选择 Vue TypeScript 模板配好vue-router、pinia、axios和element-plus大概半小时就能把骨架搭出来。如果你之前只学过 Vue2建议去把 Vue3 的setup语法糖、ref、reactive、computed、watch过一遍。毕设答辩时大概率会被问到 Composition API 和 Options API 的差异这个问题几乎是 Vue3 方向必问的。2.3 数据访问与校验SQLAlchemy 与 Pydantic 的分工我用 SQLAlchemy 定义数据库模型和做查询用 Pydantic 定义接口的请求和响应模型。这两者偶尔会混在一起但职责一定要分开ORM 管数据库Pydantic 管接口层校验。比如前端传一个createTopic请求体我先用 Pydantic 校验字段是否合法、长度是否超标再转成 SQLAlchemy 模型写库。这样做的好处是接口层和存储层解耦后面改表结构时不会直接波及 API。3. 数据库建模把选题流程翻译成表与状态机建模这块是整篇设计文档里最花时间的部分。我一开始也想得简单觉得就是用户表 课题表 选课表三张表真正动手发现不够还得加状态、时间、归档信息。最终的核心表我设计成下面的样子。3.1 三张核心表的字段设计与关系先看用户表。字段设计上我会把角色限定为字符串而不是布尔值方便以后扩展第三类角色。密码用哈希存储绝不存明文class User(Base): __tablename__ users id Column(Integer, primary_keyTrue) username Column(String(64), uniqueTrue, indexTrue) hashed_password Column(String(128)) role Column(String(16)) # admin / teacher / student name Column(String(32)) department Column(String(64), nullableTrue) created_at Column(DateTime, server_defaultfunc.now())课题表里除了标题、描述、容量我加了一个selected_count冗余字段用来缓存当前已选人数。这样前端列表页查余量不用每次都count()一遍。还要一个status字段表示课题的开放状态以及deadline字段做单个课题的截止时间。最后加一个外键teacher_id关联到教师用户。选课表是整个系统的核心字段设计上要多想几步class Selection(Base): __tablename__ selections id Column(Integer, primary_keyTrue) student_id Column(Integer, ForeignKey(users.id)) topic_id Column(Integer, ForeignKey(topics.id)) status Column(String(16)) # pending / approved / rejected / cancelled created_at Column(DateTime, server_defaultfunc.now()) updated_at Column(DateTime, onupdatefunc.now())这里有个联合唯一索引很关键UniqueConstraint(student_id, topic_id)防止同一个人对同一课题重复提交申请。逻辑上我们用状态过滤但索引是数据库层面的兜底两条防线必须都有。3.2 选题状态机从开放到尘埃落定学生视角里状态流转大概是这样的课题开放学生提交选题申请 → 产生一条pending记录。教师通过 → 状态变approved学生课题定了。教师拒绝 → 状态变rejected学生回到待选状态。学生在待审核期间主动撤回 → 状态变cancelled名额释放。注意一个细节一个学生不能同时存在pending或approved的记录否则会出现脚踏两条船。这个规则我在接口层用一次查询判断在数据库层则用部分索引做约束——SQLite 不支持部分索引所以我退而求其次在接口层加锁判断同时在应用层维护一个students.current_topic_id字段直接指向已确认课题。这样的冗余字段做查询时很香代价是写操作需要多维护一次但对毕设级项目完全可控。3.3 时间窗口与并发控制的第一道防线管理员配置选题开放时间和选题截止时间后端在每个选题接口里先做窗口判断。这个判断逻辑是全局守卫而不是写到单个接口里用 FastAPI 的依赖注入实现def check_selection_window(): config get_config() now datetime.now() if now config.selection_start or now config.selection_end: raise HTTPException(status_code400, detail当前不在选题时间范围内)这种写法让所有受时间限制的接口只要在路由里加一个Depends(check_selection_window)就行不需要每个接口内部重复写。4. 后端核心逻辑鉴权、选题接口与并发下的安全边界后端接口我分了几个模块认证、用户、课题、选课、统计。这里挑最核心的几个讲。4.1 基于角色的访问控制登录我用 JWT前端登录后把 token 存到 localStorage后续请求在 axios 拦截器里自动附带Authorization: Bearer token。后端用一个依赖函数解析 token返回当前用户对象再写一个require_role依赖工厂实现角色控制def require_role(*roles): def checker(user: User Depends(get_current_user)): if user.role not in roles: raise HTTPException(status_code403, detail权限不足) return user return checker用法很干净router.post(/topics, dependencies[Depends(require_role(teacher))]) def create_topic(payload: TopicCreate, teacher: User Depends(require_role(teacher))): ...这里有一点经验权限校验最好放在依赖里而不是散落在业务代码里判断。我一开始图省事在学生选题接口里写if current_user.role ! student后来教师接口多了发现到处都是这种判断重构时痛不欲生。4.2 选题接口的业务编排选题接口可以说是整套系统里最容易出 bug 的地方因为一次操作要同时改三样东西router.post(/topics/{topic_id}/select) def select_topic(topic_id: int, student: User Depends(require_role(student))): with db.session.begin(): topic db.execute( select(Topic).where(Topic.id topic_id).with_for_update() ).scalar_one_or_none() if topic is None or topic.status ! open: raise HTTPException(400, 课题不存在或未开放) existing db.execute( select(Selection).where( Selection.student_id student.id, Selection.status.in_([pending, approved]), ) ).first() if existing: raise HTTPException(400, 你已有待确认或已确认的课题) if topic.selected_count topic.max_students: raise HTTPException(400, 该课题名额已满) selection Selection(student_idstudent.id, topic_idtopic.id, statuspending) db.add(selection) topic.selected_count 1 student.current_topic_id topic.id return {message: 选题申请提交成功}db.session.begin()包住这一整段业务只要任何一步抛异常整段回滚不会出现 选课记录插入成功但名额没加上 这种数据不一致问题。4.3 并发超选的最后一个保险丝这里要单独说一下with_for_update()的作用。如果不用行锁两个学生同时在选题名额只剩 1 个的课题上提交可能都查到selected_count max_students然后同时插入成功变成超选。with_for_update()是在数据库层面锁住这行记录只有第一个事务提交后第二个事务才能读到最新值从而发现名额已满并拒绝。我在开发时用 SQLite 测试这个逻辑发现 SQLite 对并发写锁的支持其实有限换到 MySQL InnoDB 之后并发表现才算真实。所以如果你在 SQLite 下测试并发不要被结果误导。除此之外还有一个兜底在数据库层面把课题表里的selected_count改为UNSIGNED并把max_students约束成CHECK (selected_count max_students)这样就算应用层漏了校验数据库也会拒绝写入。双保险非常重要因为实际项目里应用层逻辑 数据库约束缺一个都不够稳。5. 前端实战Vue3 组合式 API 下的三个角色页面前端我按角色拆成三个大区学生端、教师端、管理员端。每个大区有独立的页面和路由通过路由守卫判断登录状态和角色。整套前端代码量不少我讲几个关键部分。5.1 项目骨架与路由守卫我用 Vite 创建 Vue3 TS 项目引入 Element Plus、Pinia、Vue Router、Axios。路由结构这样设计const routes [ { path: /login, component: Login }, { path: /student, component: StudentLayout, meta: { role: student }, children: [...] }, { path: /teacher, component: TeacherLayout, meta: { role: teacher }, children: [...] }, { path: /admin, component: AdminLayout, meta: { role: admin }, children: [...] }, ]全局前置守卫里做鉴权router.beforeEach((to, from, next) { const auth useAuthStore() if (to.path ! /login !auth.token) return next(/login) if (to.meta.role auth.user.role ! to.meta.role) return next(/login) next() })这里有个小坑如果用户刷新页面Pinia 里的状态会被清空token 虽然还在 localStorage但用户信息没了。解决方式是在main.js初始化时根据 token 调用一次/me接口恢复用户信息再放行路由。5.2 学生端选题大厅余量要实时体验要流畅选题大厅是学生访问最多的页面我用onMounted拉取开放课题列表展示字段包括课题名称、教师、余量、截止时间并用标签展示状态。核心交互是点击选题按钮弹窗确认后调后端接口。一个关键设计是余量实时刷新。我最初是每次点击按钮后重新拉列表但 1 秒之内两个人同时抢相同课题时刷新慢的页面会显示旧数据用户点进去才发现被拒。后来我改成轮询进入页面后每 10 秒拉一次开放课题列表保证热点时段余量尽量接近实时。虽然 10 秒的间隔也会有一点点延迟但对毕设场景足够了。如果以后要提升体验可以用 WebSocket 或 SSE 推送但那就超出毕设的复杂度了。script setup const topics ref([]) const loading ref(false) const fetchOpenTopics async () { loading.value true const { data } await api.get(/topics/open) topics.value data loading.value false } onMounted(() { fetchOpenTopics() const timer setInterval(fetchOpenTopics, 10000) onBeforeUnmount(() clearInterval(timer)) }) /script学生我的选题页面会显示当前申请记录及状态。这个页面我在设计时额外加了一个撤回申请按钮只有在pending状态时才可点。因为常有学生选了题第二天又后悔老师还没审核直接撤回能减少老师的审核负担。5.3 教师端与管理员端审批流与全局看板教师端重点是两个页面课题管理、选题审核。课题管理就是简单的 CRUD列表展示自己发布的课题、当前已选人数和审核状态。选题审核页我会用 Element Plus 的el-tabs区分待审核和已处理方便老师快速处理排队中的申请。管理员端我做了用户管理、课题总览、系统配置三个页面。系统配置页面用来设置全局选题的时间窗口、公告通知等。此外我还加了一个统计看板按学院统计学生选题率、各课题热度排行、未定题学生名单。统计接口后端可以一次查出来前端用 Element Plus 的进度条和卡片组件展示。这里我推荐管理员端加一个一键导出未定题名单的功能后端生成 CSV 或 Excel 文件返回教务老师很喜欢这个功能答辩时演示效果也不错。5.4 Pinia 之外的本地状态管理方案Pinia 我只用来存登录用户的 token、用户信息和一些全局配置业务数据几乎都用组件内的ref加 API 请求管理。一开始我想把课题列表也放进 Pinia后来发现页面一多状态同步反而乱而且切页后需要强制刷新不如用页面级请求简单。这个取舍在答辩时可以大大方方讲状态管理只放全局共享数据服务端数据通过接口获取避免多页面异步状态不一致。这听起来比我全放 Pinia更严谨。6. 联调与部署那些文档里查不到的坑写代码只是开始真正花时间的联调和部署阶段这里面的坑我自己踩了一遍每个都浪费过一晚上。挑几个最典型的分享。6.1 CORS 与本地代理先分清谁背锅前端端口是5173后端是8000直接请求必然跨域。我的做法是后端先配 CORS 中间件允许前端来源app.add_middleware( CORSMiddleware, allow_origins[http://localhost:5173], allow_credentialsTrue, allow_methods[*], allow_headers[*], )但即便如此我还是建议生产环境用 Nginx 做反向代理把/api路径转发到后端尽量避免跨域问题。开发时也可以用 Vite 的server.proxy配置这样前端代码里请求/api就直接打到后端更干净。这里提醒一句上线部署时如果把 allow_origins 设成*同时 allow_credentials 又是 true浏览器会直接报错因为规范的约束。要么不要带 cookie用纯 token 认证这样*没问题要么就把域名写全。我吃过这个亏排查了半天。6.2 时间序列化不一致这是一个看起来很不起眼但前后端对不上就白屏的经典问题。后端返回的created_at是2026-01-15T09:30:00这样的 ISO 格式前端 JavaScript 的new Date()解析有时差问题导致界面显示的时间差 8 小时。实际上就是 UTC 和本地时区的问题后端存库时统一存 UTC返回时指定Asia/Shanghai时区转换。最简单的方式是后端统一返回YYYY-MM-DD HH:mm:ss字符串前端只做展示不做计算如果要算距离截止还有多久前端统一用时间戳处理。我的经验是数据库用 UTC接口返回本地化字符串前端只负责展示。这样职责清晰谁也不会坑谁。6.3 并发模拟脚本上线前的安全阀写完接口后我写了个简单的并发测试脚本模拟 20 个学生同时抢同一个只有 5 个名额的课题。这个测试非常有用我第一次跑就发现了超选问题——在没有数据库行锁的情况下返回值里出现了 8 个成功。加上with_for_update()和唯一约束之后再跑就是稳定的 5 个成功、15 个失败。import asyncio import httpx async def grab(client, topic_id): try: resp await client.post(f/api/topics/{topic_id}/select) return resp.status_code except Exception as e: return str(e) async def main(): async with httpx.AsyncClient(base_urlhttp://localhost:8000) as client: tasks [grab(client, 1) for _ in range(20)] results await asyncio.gather(*tasks) print(f成功数: {results.count(200)}) print(f失败数: {len(results) - results.count(200)}) asyncio.run(main())这个脚本当时在论文里被导师夸了说做了并发验证比很多应付的毕设强。所以建议大家不要省掉这一步既验证代码也是答辩亮点。6.4 给论文和答辩留几个可说点最后聊聊答辩层面的经验。做这个系统最容易在答辩翻车的地方是导师问你这个系统有什么难点回答用了 Vue3 和 Python——这不叫难点叫技术选型。真正的难点建议总结成三条并发防超选问题如何保证多个学生同时选同一个课题时名额不超卖。可以引出行锁、乐观锁、唯一约束的对比和取舍。状态机与权限设计选题流程中状态如何流转三种角色如何在接口层统一做鉴权。前后端分离架构下的工程化Vite 代理、JWT 鉴权、接口文档自动生成、部署时 Nginx 反向代理这些细节堆起来就是工程能力。把这些讲清楚答辩效果一般不会差。我答辩的时候评委老师就追着问并发那块因为这是系统里真正有区分度的设计。最后再分享一个小技巧整个系统开发完成后写了一个init_demo_data.py脚本自动生成几十个测试账号、一批课题和大量的随机选课记录方便随时演示。答辩前把这个脚本跑一遍现场演示时数据是真实的页面不至于空空荡荡观感完全不同。这个习惯我后来做任何项目都在用省了非常多的演示准备时间。