每年到了毕业季,高校教务老师和教研室的老师们都要经历一轮折磨:上百个学生等着选题,几十个老师提报题目,双向选择之后还要处理调剂、补选、换题的各种幺蛾子。我去年帮某高校信息学院做过一个毕业设计选题分配系统,用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页面:题目列表/志愿填报/分配结果) — HTML+CSS+JavaScript原生Bootstrap5 |- 业务逻辑层 — Django视图+表单+服务类,核心是分配算法 |- 数据访问层 — Django ORM + SQLite开发环境/MySQL生产环境 |- 独立通知服务 — Flask轻应用,连同一个数据库,跑定时任务发提醒
从分层角度,我的目标很朴素:模型层只描述数据,业务层处理规则,视图层只做HTTP交互。分配算法单独拆成一个service模块,不写在视图函数里,这样逻辑可测试、可复用。后续如果想换分配策略,只需要改service,不用动页面。
2. 数据库设计与核心模型拆解
2.1 六张核心表的设计思路
数据库设计是整个系统的地基。我设计了六张核心表,对应六个Django model:
- User(用户表)—— 复用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_delete=models.CASCADE, related_name='topics') title = models.CharField('题目名称', max_length=200) description = models.TextField('题目描述') field = models.CharField('研究方向', max_length=100, blank=True) capacity = models.PositiveIntegerField('容量上限', default=6) enrolled = models.PositiveIntegerField('已选人数', default=0) status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='draft') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: indexes = [ models.Index(fields=['status', 'teacher']), ] def remaining_slots(self): return max(0, self.capacity - self.enrolled)几个字段设计上的讲究:
capacity和enrolled单独存,而不是每次count()聚合计算。选题高峰期每分钟几十次容量校验,聚合查询开销大而且容易造成锁等待。用“已选人数”字段配合事务更新,性能提升明显。status加了索引,查“当前可以选的有哪些题”这种高频查询可以走索引,数据量几千条时没感觉,但数据量到几万条差距就出来了。- 题目和教师用外键关联,
on_delete=models.CASCADE表示教师删账号则题目删除,但实际业务里我建议改成PROTECT,防止老师离职清号把历史数据带没了。
2.3 为什么用“乐观锁”而不是“悲观锁”
这里一定要讲并发问题。学生同时抢同一个题目名额的场景,就像双十一抢购。如果用最朴素写法:
t = Topic.objects.get(id=topic_id) if t.enrolled < t.capacity: t.enrolled += 1 t.save()两个请求同时读到enrolled=5、容量是6,都判断“可以选”,最后都加1,结果变成7,超员了。这就是经典的并发超卖。
解决办法有两种。悲观锁是select_for_update(),把行锁住直到事务结束;乐观锁是更新时带上版本条件。对于这个系统,我推荐乐观锁,理由很简单:并发量不算极端,但锁粒度要尽量小——用行锁会导致同一个教师的所有题目被锁住,其他学生等待时间长。
乐观锁的核心是更新语句带条件:
from django.db import transaction from django.db.models import F with transaction.atomic(): updated = Topic.objects.filter( id=topic_id, enrolled__lt=F('capacity') ).update(enrolled=F('enrolled') + 1) if updated == 0: raise ValueError('名额已满,请选择其他题目')关键在于enrolled__lt=F('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_assign=False): """ 学生列表按提交时间排序后的分配流程 返回分配结果列表 """ assignments = [] pending_students = [] for stu in student_list: prefs = Preference.objects.filter(student=stu).order_by('preference_order') assigned = False for pref in prefs: topic = pref.topic with transaction.atomic(): updated = Topic.objects.filter( id=topic.id, enrolled__lt=F('capacity') ).update(enrolled=F('enrolled') + 1) if updated == 0: continue assignments.append( Assignment( student=stu, topic=topic, assign_type='preference', preference_order=pref.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( status='published', enrolled__lt=F('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', hour=9, minute=0, id='reminder_job' ) scheduler.start() atexit.register(lambda: scheduler.shutdown())要提醒的是:定时任务在分布式部署下会重复执行,如果主服务和通知服务都跑着,就会有学生收到两封邮件。我当时的处理是:数据库里建一个notification_log表,发邮件前先查是否已发过同一批次的通知,有了就跳过。这种幂等处理在定时任务里必不可少。
4. 前端交互与页面实现经验
4.1 页面结构与核心交互设计
前端没有用前后端分离架构,直接用Django模板+原生JavaScript配合Bootstrap5。理由也简单:团队就两个人,前后端分离要同时维护两套代码、处理跨域、联调成本高;对选题系统这种中后台业务,服务端渲染完全够用。
页面一共六张:
- 登录页(角色自动跳转)
- 学生首页:题目列表(按方向筛选、按容量排序)、个人志愿填报页
- 教师工作台:我的题目、学生申请列表、确认接收/拒绝
- 管理员后台:用户管理、题目审核、选题时间窗口设置、分配结果总览
- 分配结果页:学生看自己的结果,管理员看全部统计
- 数据导出页:按班级、按题目导出Excel
有一个前端交互值得单独说:志愿填报的拖拽排序。学生在表格里拖拽行来调整志愿优先级,比一个个输入“1、2、3”数字直观得多。实现用的是原生HTML5拖拽API:
let 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(status='published').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_mode='STRICT_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(status='published').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通知服务里同时加上企业微信的机器人推送,到达率会高很多。
第三,数据可视化可以做得更多。选题结束后,学院领导最关心的往往是:哪些方向最热门、哪些方向的题目没人选、各专业的选题覆盖率。我后来在后台加了三个图表:按方向统计选题人数、按教师统计指导人数分布、按专业统计未选题人数,用原生图表库绘制,效果不错。这一步扩展空间很大,甚至可以做成实时看板。
最后说点走心的。做这种教务类系统,最大的挑战从来不是技术和编码,而是业务规则的边界条件。比如“某学生第一志愿选了个已经满员的题目,系统把他分到第二志愿,但第二志愿的老师看了学生信息表示不想要”,这种问题在需求文档里永远写不出来,只能上线后靠人肉处理。所以开发这类系统,一定要给管理员留手动改分配结果的入口,并且每次手动操作都要记日志。系统再智能,也替代不了人工决策,工具是帮人省力的,不是给人添堵的。
我个人实操下来最深的体会是:一个能稳定跑完整个选题季、不闹出舆情、老师学生都愿意用的系统,靠的不是某个炫酷算法,而是每一个边界条件都处理到位、每一步操作都有日志、每一次并发写入都不出错。把基础打牢,比追求技术领先重要得多。