每年到了毕业季高校教务老师和教研室的老师们都要经历一轮折磨上百个学生等着选题几十个老师提报题目双向选择之后还要处理调剂、补选、换题的各种幺蛾子。我去年帮某高校信息学院做过一个毕业设计选题分配系统用Python的Django框架从零搭建跑完整个选题季六千多条分配记录零差错。这篇文章把整个设计和实现过程完整复盘一遍从需求分析、数据库设计到核心算法、部署踩坑全部摊开来讲。这套系统解决的问题很直接把传统“纸质提报Excel汇总人工调剂”的选题流程搬到线上。学生在线查看题目、提交志愿教师在线审核、确认接收系主任一键完成智能分配整个过程有记录、可追溯、能统计。如果你正在筹备类似的教务系统、选课系统或者任何需要“双向匹配志愿排序”的业务系统这篇整理的思路和代码可以直接抄。适合谁看一是正在做毕业设计、课题恰好是Web管理系统方向的同学参考价值最大二是高校信息化部门想自建选题流程的同行三是想了解Django在实际业务中怎么设计模型、怎么写业务逻辑、怎么避免并发问题的人。下面是完整复盘。1. 选题分配的核心逻辑与技术选型1.1 业务需求到底在解决什么问题先说清楚业务场景。高校毕业设计选题通常分几个角色系统管理员一般是教务秘书、教师出题人指导教师、学生选题人有的学校还有系主任环节做最终审批。核心流程是这样的教师在系统里提交选题每个题目限制人数比如最多带6人教务审核后发布学生在规定时间内填报志愿通常允许填3到5个按优先级排序收志愿后系统按规则分配——优先满足第一志愿名额满了就看第二志愿以此类推未匹配上的学生进入调剂池由管理员手动或系统自动安排。听起来简单实际上最难的不是页面而是分配算法。因为每个题目有容量上限学生有志愿优先级教师也有倾向性比如有的老师希望优先带某类题目的学生多对多匹配不能简单按“先到先得”否则对网络慢、手机差的学生不公平。还有一个痛点并发高峰。选题系统开放的前10分钟几百个学生会同时提交志愿和刷新页面如果业务代码写得粗糙就会出现“名额显示还有提交却说已满”的问题。这个后面专门讲。所以我对系统的定位是分配公平性第一流程完整第二并发稳定第三。1.2 为什么选Django而不是Flask或Spring项目名称里写了“django-flask”实际操作上我选了Django做主体一小部分服务用Flask搭了个独立的轮询通知服务。这里把选型逻辑说明白避免有人被标题迷惑。Django的优势在于**“全家桶”自带管理后台**。选题分配系统里有大量数据维护工作教师账户的导入、题目信息的批量编辑、选题时间窗口的设置这些如果全部自己写页面工作量得翻一倍。Django的admin后台改一改配置就能直接用省掉大量CRUD页面开发时间。它的ORM对象关系映射对这类强关系型数据也友好——题目表、学生表、志愿表、分配结果表之间有清晰的外键关联用django model定义起来非常顺手。Flask在这类项目里更适合做轻量API服务。我单独用Flask搭了一个“选题截止前30分钟提醒”的邮件/短信发送服务因为这部分跟主流程解耦不需要加载Django的ORM和中间件独立部署、按需扩容都方便。所以标题里“django-flask”其实是个组合方案Django扛主体Flask做边缘服务。如果你做的是纯单机小项目Flask全家桶也能完成但数据关系复杂后模型管理、迁移、后台这三个东西会消耗你大量时间这是过来人的真实体感。另外一个务实的理由网上Django的中文资料、第三方包生态最全学生或者新手做毕设遇到问题好查。毕设项目不是工业级产品稳定跑完整个流程、代码逻辑清晰能答辩比技术栈“先进”更重要。1.3 系统整体架构与模块划分|- 前端展示层Browser页面题目列表/志愿填报/分配结果 — HTMLCSSJavaScript原生Bootstrap5 |- 业务逻辑层 — Django视图表单服务类核心是分配算法 |- 数据访问层 — Django ORM SQLite开发环境/MySQL生产环境 |- 独立通知服务 — Flask轻应用连同一个数据库跑定时任务发提醒从分层角度我的目标很朴素模型层只描述数据业务层处理规则视图层只做HTTP交互。分配算法单独拆成一个service模块不写在视图函数里这样逻辑可测试、可复用。后续如果想换分配策略只需要改service不用动页面。2. 数据库设计与核心模型拆解2.1 六张核心表的设计思路数据库设计是整个系统的地基。我设计了六张核心表对应六个Django modelUser用户表—— 复用Django自带的User通过Profile扩展角色字段teacher / student / admin。TeacherProfile教师信息—— 关联教师工号、职称、研究方向、可指导学生总数。Topic题目表—— 题目标题、描述、所属方向、容量上限、已选人数、状态草稿/待审核/已发布/已满/已归档。StudentProfile学生信息—— 学号、姓名、专业、班级、成绩排名可选用于同分决策、状态。Preference志愿表—— 学生ID、题目ID、志愿序号1/2/3、填报时间。关键字段是preference_order这就是分配算法的主要输入。Assignment分配结果表—— 学生ID、题目ID、分配类型第一志愿命中/调剂/手动分配、分配时间、操作人。这张表结构看起来常规但有一个地方特别容易被忽略每张表都要留status状态字段和created_at/updated_at时间戳。选题系统最大的特点是“流程状态多”——题目要经过提报、审核、发布、满员、分配完成、归档学生有未选题、已填报、已分配、已确认状态。如果不设计状态机业务逻辑会糊成一团后面加个“退回重选”功能你都找不到下手的地方。2.2 关键模型代码与字段约束用Django的model定义比较直观。题目表的核心字段我写成这样from django.db import models from django.contrib.auth.models import User class Topic(models.Model): STATUS_CHOICES [ (draft, 草稿), (pending, 待审核), (published, 已发布), (full, 已满员), (closed, 已结束), ] teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_nametopics) title models.CharField(题目名称, max_length200) description models.TextField(题目描述) field models.CharField(研究方向, max_length100, blankTrue) capacity models.PositiveIntegerField(容量上限, default6) enrolled models.PositiveIntegerField(已选人数, default0) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultdraft) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class Meta: indexes [ models.Index(fields[status, teacher]), ] def remaining_slots(self): return max(0, self.capacity - self.enrolled)几个字段设计上的讲究capacity和enrolled单独存而不是每次count()聚合计算。选题高峰期每分钟几十次容量校验聚合查询开销大而且容易造成锁等待。用“已选人数”字段配合事务更新性能提升明显。status加了索引查“当前可以选的有哪些题”这种高频查询可以走索引数据量几千条时没感觉但数据量到几万条差距就出来了。题目和教师用外键关联on_deletemodels.CASCADE表示教师删账号则题目删除但实际业务里我建议改成PROTECT防止老师离职清号把历史数据带没了。2.3 为什么用“乐观锁”而不是“悲观锁”这里一定要讲并发问题。学生同时抢同一个题目名额的场景就像双十一抢购。如果用最朴素写法t Topic.objects.get(idtopic_id) if t.enrolled t.capacity: t.enrolled 1 t.save()两个请求同时读到enrolled5、容量是6都判断“可以选”最后都加1结果变成7超员了。这就是经典的并发超卖。解决办法有两种。悲观锁是select_for_update()把行锁住直到事务结束乐观锁是更新时带上版本条件。对于这个系统我推荐乐观锁理由很简单并发量不算极端但锁粒度要尽量小——用行锁会导致同一个教师的所有题目被锁住其他学生等待时间长。乐观锁的核心是更新语句带条件from django.db import transaction from django.db.models import F with transaction.atomic(): updated Topic.objects.filter( idtopic_id, enrolled__ltF(capacity) ).update(enrolledF(enrolled) 1) if updated 0: raise ValueError(名额已满请选择其他题目)关键在于enrolled__ltF(capacity)这会让数据库在更新时再次校验容量并且F表达式把“当前值”交给数据库计算不是Python算完再传值避免了读改写竞争。这个写法是整套系统里最关键的一个细节。3. 核心功能模块实现细节3.1 学生志愿填报的数据校验志愿填报不是简单地插入几条记录至少要校验四件事选题窗口是否开放管理员配start_time和end_time。学生是否已分配过题目防止重复选。题目状态是否为“已发布”且未满。同一题目不能重复出现在志愿列表中。这些校验写在哪我写在Form的clean()方法里而不是视图函数里。因为同一个Form既被Web页面用也被后续的API接口用校验写在Form层可以保证所有入口的规则一致。填志愿时我用一个原子事务包裹先删掉学生旧的志愿记录再批量插入新志愿。为什么先删再插而不是“没有就insert、有就update”因为学生可以调整志愿顺序比如把第三志愿提到第一逐条对比太麻烦直接清空重插最干净。反正有事务保护中间出错会回滚不会留下半新半旧的数据。3.2 分配算法的设计与实现这是全系统含金量最高的模块。我把分配算法拆成两轮。第一轮按志愿优先分配。对学生按“提交时间”排序先提交的先获得机会依次处理。每个学生按志愿顺序第一、第二、第三逐个尝试如果题目剩余名额0直接分配成功。第二轮调剂分配。第一轮没分配到任何题目的学生进入调剂池。系统尝试分配未满的题目优先匹配专业方向相近的。方向匹配我用一个简单的关键词交集判断题目field和学生专业方向字段做关键词匹配命中多的优先。算法用Python实现不依赖复杂框架def assign_topics(student_list, must_assignFalse): 学生列表按提交时间排序后的分配流程 返回分配结果列表 assignments [] pending_students [] for stu in student_list: prefs Preference.objects.filter(studentstu).order_by(preference_order) assigned False for pref in prefs: topic pref.topic with transaction.atomic(): updated Topic.objects.filter( idtopic.id, enrolled__ltF(capacity) ).update(enrolledF(enrolled) 1) if updated 0: continue assignments.append( Assignment( studentstu, topictopic, assign_typepreference, preference_orderpref.preference_order, ) ) assigned True break if not assigned: pending_students.append(stu) # 第二轮调剂 for stu in pending_students: if not must_assign: break avail_topics Topic.objects.filter( statuspublished, enrolled__ltF(capacity) ).order_by(field) # 简化的方向匹配实际代码里按方向关键词打分排序 topic pick_best_match(stu, avail_topics) if topic: # 同样用乐观锁更新 ... return assignments有个细节为什么按提交时间排序因为志愿序号是学生主动排的优先级同一天内提交的学生谁先提交谁占优势。这一条要在选题通知里写清楚否则学生觉得“我第一志愿明明填了A为什么没分到”舆情就会砸过来。后来我把排序规则改成了“提交时间为主学号末位随机扰动为辅”——具体原因是有的学生守在电脑前秒提交有的学生因为网络原因慢几分钟纯粹按时间对后者不公平。增加一个小的随机因子可以在“公平”和“先到先得”之间找平衡。这个改动被系里接受实际效果不错。3.3 教师端审核与确认流程教师端的核心两个操作出题和确认接收学生。出题流程里有一个状态推进教师保存草稿 → 提交审核 → 管理员通过 → 题目变为“已发布”。题目状态变化我用了一个简单的状态机函数def advance_topic_status(topic, target_status, user): allowed_transitions { draft: [pending, closed], pending: [published, draft, rejected], published: [full, closed], full: [closed], closed: [], } if target_status not in allowed_transitions[topic.status]: raise PermissionDenied(非法的状态变更) topic.status target_status topic.save()这个写法简陋但实用。状态机显式列出“从哪到哪”是合法的有效避免代码各处随意改状态导致数据混乱。后来有老师说“我要把已发布但还没人选满的题目撤回”按状态机是不允许的published只能去full或closed于是我加了published: [full, closed, draft]让管理员可以撤回。这类业务规则用状态机管理比散落在页面代码里的if status published清晰一百倍。3.4 Flask独立通知服务怎么搭通知服务的需求是系统要在选题开放前30分钟给所有已填志愿的学生发邮件提醒“你的志愿已生效请勿重复修改”在分配完成后给每个学生发通知告知分配到的题目。我建了一个独立的Flask应用连同一个MySQL数据库用APScheduler做定时任务。为什么不用Django自带的manage.py crontab因为Django admin后台已经占用了大量进程我担心定时任务和Web请求争抢内存而且Flask服务可以独立部署到另一台机器万一崩了不影响主流程。核心代码很短from flask import Flask from apscheduler.schedulers.background import BackgroundScheduler import atexit app Flask(__name__) scheduler BackgroundScheduler() def send_reminder_emails(): # 查询当天选题截止前30分钟的学生 pass scheduler.add_job( send_reminder_emails, cron, hour9, minute0, idreminder_job ) scheduler.start() atexit.register(lambda: scheduler.shutdown())要提醒的是定时任务在分布式部署下会重复执行如果主服务和通知服务都跑着就会有学生收到两封邮件。我当时的处理是数据库里建一个notification_log表发邮件前先查是否已发过同一批次的通知有了就跳过。这种幂等处理在定时任务里必不可少。4. 前端交互与页面实现经验4.1 页面结构与核心交互设计前端没有用前后端分离架构直接用Django模板原生JavaScript配合Bootstrap5。理由也简单团队就两个人前后端分离要同时维护两套代码、处理跨域、联调成本高对选题系统这种中后台业务服务端渲染完全够用。页面一共六张登录页角色自动跳转学生首页题目列表按方向筛选、按容量排序、个人志愿填报页教师工作台我的题目、学生申请列表、确认接收/拒绝管理员后台用户管理、题目审核、选题时间窗口设置、分配结果总览分配结果页学生看自己的结果管理员看全部统计数据导出页按班级、按题目导出Excel有一个前端交互值得单独说志愿填报的拖拽排序。学生在表格里拖拽行来调整志愿优先级比一个个输入“1、2、3”数字直观得多。实现用的是原生HTML5拖拽APIlet draggingRow null; table.addEventListener(dragstart, (e) { draggingRow e.target.closest(tr); e.target.closest(tr).classList.add(dragging); }); table.addEventListener(dragover, (e) { e.preventDefault(); const currentRow e.target.closest(tr); if (currentRow currentRow ! draggingRow) { const rect currentRow.getBoundingClientRect(); const after (e.clientY - rect.top) rect.height / 2; table.tBodies[0].insertBefore( draggingRow, after ? currentRow.nextSibling : currentRow ); } });拖拽完再遍历表格行把每行的序号写回去一并提交。这个交互花了一天时间写但学生对系统的第一印象就从“老师又发了个破表格”变成了“这系统还挺好用”值得。4.2 高频查询优化经验选题开放期间学生首页的题目列表会被大量刷新。此时如果列表接口每次都实时统计“已选人数”数据库会扛不住。我的优化方案是列表页展示的数据用topic表里冗余的enrolled字段不聚合统计实时名额判断只在提交志愿事务里做。另外列表页加了一层cache_page缓存缓存时间设成5分钟。因为题目列表是最典型的“读多写少”数据——几百个人看同一个列表没必要每个人触发一次数据库查询。Django的cache_page装饰器直接用from django.views.decorators.cache import cache_page cache_page(60 * 5) def topic_list(request): topics Topic.objects.filter(statuspublished).select_related(teacher) return render(request, topic_list.html, {topics: topics})注意加了缓存之后教师新发布题目无法立刻出现在学生端最多延迟5分钟。在选题季这个问题影响不大反而避免了学生反复刷新看到的数据闪烁。真要做到“秒开”效果这个方案成本最低。5. 部署上线与环境踩坑实录5.1 开发环境与生产环境的差异开发阶段我用Django自带的SQLite省事零配置。生产环境切到MySQL差别主要体现在两点一是SQLite的布尔字段和MySQL的差异二是数据库连接池配置。用MySQL时django.db.backends.mysql配置比较简单但要注意并发量大时配置CONN_MAX_AGE。默认情况下每个请求都新建一个数据库连接效率很低。设置CONN_MAX_AGE 600可以让连接在10分钟内复用。但这里有个坑如果MySQL的wait_timeout小于连接复用时间连接已经断了Django还在用会报InterfaceError: (0, )。解决方法是加一行OPTIONS配置DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: thesis_db, USER: thesis_user, PASSWORD: ***, HOST: 127.0.0.1, PORT: 3306, CONN_MAX_AGE: 600, OPTIONS: { init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }5.2 部署环境与静态文件处理部署用的是最常见的方案Nginx Gunicorn Django MySQL。Gunicorn配置三到四个worker每个worker跑一个应用实例。这里要提醒Gunicorn的worker数量不是越多越好。如果worker数超过CPU核心数的2倍线程上下文切换反而拖慢速度。一般的服务器环境4个worker足够应付几百人同时在线。Django的静态文件处理是新手最容易卡住的地方。开发环境STATIC_URL /static/没问题生产环境下如果直接用Django提供静态文件性能很差还可能泄露目录结构。我当时的做法是python manage.py collectstatic收集到统一目录再用Nginx直接alias指向。Nginx配置片段location /static/ { alias /var/www/thesis/static/; expires 7d; }还有一个容易踩的坑上传的文件和导出的Excel文件存哪我起初存在服务器本地目录结果有两类问题一是Nginx需要额外配置location /media/二是服务器重启可能丢文件。后来干脆把导出文件存到服务器统一目录同时数据库只存文件名和路径及时做备份。对这个项目规模本地文件系统够用不必要引入对象存储。5.3 上线前后的测试重点上线前我做了三轮测试每轮都有收获第一轮是功能测试主要验证角色权限。教师不能改学生的志愿学生不能审核题目管理员不能填报志愿。Django的login_required和自定义role_required装饰器基本搞定但要注意admin后台的权限需要单独配置Django自带的admin不继承你自定义的权限装饰器。第二轮是并发测试。我用多线程模拟50个学生同时抢同一个只剩1个名额的题目验证乐观锁是否生效。第一版代码确实出现了超卖——原因是我在视图层先查了remaining_slots()判断再进入事务更新这个判断在事务外两个请求都能通过。改成把判断挪到事务内的filter().update()里之后问题解决。这个经验非常典型不要在事务外做容量校验要在SQL条件里做。第三轮是流程完整测试。从教师提题 → 管理员审核 → 学生填报 → 系统分配 → 结果确认全流程跑了三遍边跑边补状态机的非法分支。6. 常见问题与排查技巧实录6.1 典型Bug与解决过程问题一学生提交志愿时提示“参数错误”。排查过程前端用表单POST提交志愿列表服务器返回400。看日志发现是preference_order字段解析失败——前端把序号传成了字符串“1,2,3”的数组而后端预期是整数。用print(request.POST)打印了一下发现字段名是preference_order[]Django解析后变成QueryDict里带后缀的键。最后把前端的字段统一改为preferences[0][topic_id]这种嵌套结构用request.POST.getlist()取值问题解决。问题二定时提醒邮件重复发送。学生反馈说“同一个选题提醒收了两封邮件”。排查发现Flask服务的两个worker同时触发了APScheduler任务两个进程都读了未发送的日志记录。修复方案是给notification_log表加UniqueConstraint约束插入时用get_or_create让数据库去重。教训定时任务必须设计幂等不能假设只有一个实例在跑。问题三管理员在后台修改题目容量实时分配结果不变。这是设计上的缺陷Assignment表里存了题目ID如果管理员把容量从6改成4已经分配的第5、6名学生不会自动“踢出”。我的处理是在容量修改的表单中增加校验如果新容量 enrolled禁止直接保存提示管理员先处理超额学生调剂或联系学生换题。这个逻辑虽然看起来“死板”但避免了分配结果和题目容量不一致的脏数据。6.2 性能问题排查记录选题高峰日出现了题目列表接口响应时间从200ms飙升到2.5秒的问题。用django-debug-toolbar定位到瓶颈是Topic.objects.filter(statuspublished).count()被频繁调用——前端页面里为了显示“共X个题目”每次刷新都触发一次全表扫描。优化方案显示总数改为在教师发布题目/管理员审核时将总数写入Redis缓存前端直接读缓存。这个改动把接口响应时间降回250ms以内。另外还有一次导出Excel报表时数据量大6000条分配记录导致脚本超时。解决办法是分批查询流式写入Excel每查1000条写出1000条内存占用从几百MB降到几十MB。6.3 排查工具与调试技巧给还在开发阶段的同学几个建议开发环境一定要装django-debug-toolbar它的SQL面板能直接告诉你每个请求调了多少条SQL、哪些走了全表扫描对优化非常有价值。日志不要只打print用Python标准库logging配置按天滚动文件上线后排查问题全靠日志不打日志等于裸奔。复现并发问题可以用wrk或locust压测工具多线程模拟同时提交。我第一次压测就复现了超卖bug比手工点页面快得多。数据库的慢查询日志要开MySQL配置slow_query_log 1执行超过500ms的SQL都会记录隔几天看一次能发现很多隐藏的问题。7. 经验总结与后续优化空间这套系统在设计时踩过的坑、打磨过的地方如果重新做一遍有几处我会直接换方案第一分配算法值得用更标准的方法重写。目前用的是自己定义的时间优先方向匹配规则简单但边界情况多。更严谨的做法是用稳定婚姻匹配Gale-Shapley算法或者匈牙利算法处理志愿匹配学生和题目互为偏好列表能保证匹配结果没有“改进对”。我当时没有用主要是因为题目侧没有真正的“偏好”——教师基本不挑学生算法的复杂优势体现不出来。但如果你们的学校要求教师也填写“希望指导的学生类型”那Gale-Shapley就是必须的研究方向。第二通知方式应该直接集成企业微信或钉钉Webhook。邮件提醒最大的问题是学生不看邮箱尤其是现在的学生邮箱十天半个月不打开一次。如果重新做我会在Flask通知服务里同时加上企业微信的机器人推送到达率会高很多。第三数据可视化可以做得更多。选题结束后学院领导最关心的往往是哪些方向最热门、哪些方向的题目没人选、各专业的选题覆盖率。我后来在后台加了三个图表按方向统计选题人数、按教师统计指导人数分布、按专业统计未选题人数用原生图表库绘制效果不错。这一步扩展空间很大甚至可以做成实时看板。最后说点走心的。做这种教务类系统最大的挑战从来不是技术和编码而是业务规则的边界条件。比如“某学生第一志愿选了个已经满员的题目系统把他分到第二志愿但第二志愿的老师看了学生信息表示不想要”这种问题在需求文档里永远写不出来只能上线后靠人肉处理。所以开发这类系统一定要给管理员留手动改分配结果的入口并且每次手动操作都要记日志。系统再智能也替代不了人工决策工具是帮人省力的不是给人添堵的。我个人实操下来最深的体会是一个能稳定跑完整个选题季、不闹出舆情、老师学生都愿意用的系统靠的不是某个炫酷算法而是每一个边界条件都处理到位、每一步操作都有日志、每一次并发写入都不出错。把基础打牢比追求技术领先重要得多。