☰
Django+微信小程序实现导师双选:状态管理与并发控制实战
2026/10/15 23:26:12 网站建设 项目流程

1. 导师双选,到底难在哪:从"两张Excel表"到"抢人现场"的升级

每年毕业设计季,很多高校都在做同一件事:把几百个学生分配给几十位导师。表面上看,这不就是发个通知、填个表、按成绩排个序吗?真去现场盯一轮流程,就知道完全不是这么回事。

我接手这个项目是在某高校计算机学院,当时教务老师给我看了上一年的"双选战况":几个热门导师的邮箱一天能收到四十多封申请邮件,学生在群里反复问"老师你收到我的申请了吗",导师一边上课一边手动回邮件回到手酸,最后还有一批学生没着落,教务处只能按专业方向硬调剂。整个过程大概持续了两周,期间充斥着乌龙、漏发、误解,以及学生反复催问的焦虑。

所谓"毕业设计导师选择系统",本质就是把这条混乱的流程搬上线,让双向选择变得透明、有序、可追踪。它不是做一个表格收集工具,而是一套包含发布计划、学生填报志愿、导师确认或拒绝、时间窗口控制、结果公示、异常调剂的完整业务系统。核心难点也不是CRUD,而是三个字:状态管理。谁选了谁、什么时候确认、满了没有、能不能反悔、窗口期过了怎么办——这些状态如果理不清,后面全是坑。

这篇文章适合三类人读:准备自己动手做类似毕设题目的学生,需要给学院或实验室搭一个双选流程的工具负责人,以及任何想了解"一个小程序+一套Django后端怎么落地成一整个业务流程"的开发者。我会把从需求分析到数据库设计、从接口实现到并发排障、从小程序联调到部署上线的完整链路拆开讲,重点写那些文档里查不到、只有真正跑过一版才会明白的细节。

先说结论:这个系统的成败,不取决于你用了多新的前端框架、多牛的云服务,而取决于你把双选规则吃透了多少。规则吃透了,代码随便写都是顺的;规则没吃透,堆再多功能上线也是一团乱麻。

1.1 高校双选的三个真实痛点,三个必须解决的业务问题

第一个痛点是信息不对称。学生不知道导师今年带几个名额、研究方向还收不收人、自己的选题跟老师的方向匹配不匹配。导师也不知道学生的成绩、兴趣、有没有已经心仪的课题。传统做法是发一个PDF名单,学生照着邮箱海投,导师逐个手动回。这个模式下信息是静态的、单向的、延迟的。

第二个痛点是流程不可见。学生交了申请表之后,唯一能做的就是等。等不到回复就焦虑,焦虑就催问,催问多了导师也烦。整套流程没有一个统一的"进度"概念——是提交了?被拒了?待确认?还是已经被调剂?每个环节都靠人肉同步,就会产生大量无效沟通。

第三个痛点是并发与资源争夺。热门导师名额可能只有5个,但提交申请的学生有80个。先到先得还是按成绩排序?如果同一瞬间五十个学生同时提交,系统怎么保证只有前5个能成功?这是技术问题,也是规则问题,处理不好就容易变成"抢课系统"一样的灾难现场。

我做需求分析的时候,没有直接去画用例图,而是把教务老师、几位导师代表、几个学生叫到一起,把上一年的流程从头到尾走了一遍,把每个角色在每个时间点要做什么、能看到什么、能改什么,一项项列出来。这一步特别重要——需求调研的输出不该是一堆功能列表,而应该是一张面向时间的流程状态图。

1.2 系统边界画到哪里:三个角色,四条核心流程

这个项目的用户角色很清楚,就是三类人:

  • 学生:查看导师信息、填报志愿(通常可以填多个)、查看确认结果、在允许时段内撤回或改志愿;
  • 导师:维护个人主页(方向、名额、简介)、查看学生志愿列表、逐条确认或拒绝、查看最终成组结果;
  • 管理员/教务处:创建双选批次、设置时间窗口、手动调整异常状态、导出最终名单。

四条核心流程分别是:

  1. 批次启动流程:管理员创建批次,设置填报开始/结束时间、确认开始/结束时间,批次内绑定参与导师和学生名单。
  2. 学生填报流程:学生在窗口期内提交志愿,志愿按优先级排序。系统限制志愿数量(比如每人最多填3个),限制一个导师录满后不再接收新志愿。
  3. 导师确认流程:导师在确认窗口期内处理收到的志愿。关键规则是,每个学生最终只能被一位导师确认,一旦确认,该学生在该批次中的其他志愿自动作废。
  4. 结果与调剂流程:确认窗口结束后,系统生成双选结果。没有任何录取结果的学生进入调剂池,由管理员手动分配或导师再次挑选。

这条流程里最容易出问题的是第3条。很多第一次做这类系统的人,会把它做成"导师直接抢人",也就是导师看到合适的学生就直接点录取。这个模式看起来效率高,实际会引发一个问题:热门导师第一时间把最好的学生全抢走,冷门方向的导师没得选,学生也不能表达自己的方向兴趣,整个过程会变成纯比手速的游戏。我最终采用了先提交志愿、再导师确认的模式,给双方都保留了选择空间。

1.3 为什么说"规则先行"才能避免返工:状态机是最重要的设计

在写第一行代码之前,我先画了一张完整的状态流转表。这个表是整个系统的地基,数据库设计、接口设计、前端交互全都挂在它上面。

志愿记录的状态字段我设计成五个枚举值:

状态含义允许的流向
PENDING学生已提交,导师未处理→ACCEPTED/REJECTED/WITHDRAWN
ACCEPTED导师已确认录取→WITHDRAWN(仅限管理员取消)
REJECTED导师已拒绝→ 无(不可重新激活)
WITHDRAWN学生撤回,或被系统自动作废→ 无
EXPIRED窗口结束仍未处理→ 由管理员批量处理

这张表定下来之后,我再去看需求,发现很多问题瞬间变清晰了。比如学生填了3个志愿,第一个被拒了,第二个是"PENDING",第三个是"ACCEPTED"——那么系统应该怎么展示?答案也变了:展示的顺序应该以状态优先级(ACCEPTED > PENDING > REJECTED)来决定,而不是以填报顺序。再比如,导师确认了一个学生之后,学生其他"PENDING"志愿要不要自动撤销?我的答案是"不自动撤销,但展示给其他导师时标记为已被录取"。如果你动态地去撤销,会出现一个可怕的竞态:两个导师同时对同一个学生点确认,前一个刚提交完事务,后一个发现学生已经被录走——如果按"撤销"逻辑走,后一个操作会白白把学生的记录删掉。

状态机定完,数据库表结构基本就有了一半。这也是我最想强调的一点:做流程类系统,先定状态,再谈接口,这是避免后期反复改表的不二法门。

2. 技术选型与数据建模:Django、小程序、还有一张救命的志愿表

2.1 为什么选Django而不是Flask或Spring Boot

后端框架,我直接选了Django,没有犹豫。原因有三条,每一条都跟这个项目的具体场景强相关。

第一,Django自带Admin后台。这个系统的"管理端"需求其实不复杂:创建批次、看统计、调状态、导Excel。用Django Admin稍加定制就能满足80%的需求,完全不需要再单独写一套Web管理界面。我做这个项目的时候,管理员端只额外写了两三个视图函数,其余全是靠Django Admin的list_display、list_filter、search_fields撑起来的,前后只花了半天时间就配好了。省下来的时间全部投入到核心的学生端和导师端开发里,性价比极高。

第二,ORM让多表关联的心智负担降到最低。导师信息、学生信息、志愿记录、批次、公告,这些表之间关系并不复杂,但数量多、关联多。用原生SQL写七八个联表查询,或者用Spring Data写一堆Entity映射,对我的项目节奏来说都偏重。Django ORM的select_related和prefetch_related用起来顺手,还能直接写aggregate统计。实际开发中我统计"每个导师收到多少志愿"这种需求,用一个annotate就搞定了。

第三,社区成熟度和部署经验丰富。Django的部署链路(uWSGI/nginx/MySQL)非常标准化,出问题一搜就有答案。这一点在项目后期上线阶段帮了大忙。我不是说Flask不好,而是双选系统这种偏业务管理、偏CRUD、后台需求重的项目,Django的"全家桶"模式天然匹配。

2.2 前端为什么不选H5,而是微信小程序

前端选择微信小程序,理由也很直接。

该校学生和导师都已经在微信生态里,小程序不需要安装、扫码或搜索就能进,比发一个网址让大家用手机浏览器打开(还要面对各种浏览器兼容问题)要顺滑得多。更关键的是微信小程序的登录体系比H5简单:调用wx.login()拿code,后端用code换openid,天然就有了一个稳定、不会重复的学生标识,不用自己再做一套手机号+密码注册体系。

当然小程序也有代价:审核是个麻烦事,类目如果选的不对被驳回很常见。我这里用的是个人/高校主体的小程序,不涉及支付,类目选"教育-教育信息服务",审核基本没遇到问题。另外,小程序对前端开发者的JS功底有一定要求,如果你完全不熟悉小程序语法,前期会有学习成本,但网上资料很多,跟着官方文档走很快就能上手。

还有一个很实际的考虑:在校园场景里,"触达"比"工具"重要。学生可能两三天才打开一次H5网站看有没有新消息,但微信每天都会打开。系统内产生的确认通知,通过订阅消息模板推送到微信,效果远比站内信好。这也是微信小程序方案的独特价值。

2.3 数据表设计:五张核心表,一张状态表承载整个业务

下面是这个系统中几张核心表的架构,我挑重点讲。

用户表:直接用Django自带的User扩展出一个Profile模型,增加role字段(student/teacher/admin)。学生和导师各有一个Profile,关联到同一个User实例,登录入口统一。

导师信息表:字段包括姓名、职称、研究方向、个人简介、今年可带名额quota、剩余名额remaining、是否接受调剂、状态(是否可被选)。有一个很重要的字段叫is_active,如果导师今年不带毕业设计,这个字段置False,学生端前端就不展示这位导师。

学生信息表:学号、姓名、专业、方向、联系电话、平均绩点(用于导师参考)、个人备注。

志愿表(核心表):这是整个系统的心脏,字段包括student_id(外键)、teacher_id(外键)、priority(第几志愿)、status(状态枚举)、created_at、updated_at、batch(所属批次)。我对这张表加了一个数据库唯一约束:unique_together = [('student_id', 'teacher_id', 'batch')],保证同一个学生在同一批次不能对同一个导师提交两次志愿。这个约束在并发场景下能挡住不少脏数据。

批次表:批次名称、填报开始时间、填报结束时间、确认开始时间、确认结束时间、状态(未开始/填报中/确认中/已结束)。所有的时间控制都挂在批次表上。

五张表之外,还有一个我当时特意加的字段,在后期排障帮了大忙:志愿表上额外记录了一次last_operated_by和last_operated_at。这个字段用来追踪"这个状态是谁在什么时候改的",方便出问题的时候回溯。虽然业务上不一定用得到,但运营阶段有它真的省心。

2.4 权限模型:Django自带权限体系加一层"角色判断"

Django自带的Group和Permission很适合做粗粒度的权限控制,但实际开发中我发现,双选系统的权限控制不需要太细,重要的是"角色区分 + 数据范围隔离"。

我的做法是写一个简单的装饰器,检查request.user.profile.role。学生端只能访问学生相关的API,导师端只能访问导师相关的API。数据范围隔离靠查询时强制加上用户ID过滤,比如导师查询志愿列表时,视图函数里强制filter(teacher_id=request.user.profile.teacher.id),而不是信任前端传过来的teacher_id参数。这个习惯帮我避免了很多权限越权问题。

Admin后台则是把用户分成"超级管理员"和"普通管理员",普通管理员只能看不能改,或者只能改自己批次的数据。我没有做特别复杂的行级权限,Django Admin的has_change_permission重写一下就能满足需求。

3. 核心接口与业务逻辑实现:志愿填报、导师确认、时间窗口与消息触达

3.1 学生端志愿填报接口:事务与唯一约束并行

学生端的核心接口是提交志愿列表。前端一次提交三个志愿(按优先级排序),后端用一个事务把整批志愿写入数据库。这里有个很重要的设计决策:不能一条一条插,必须一次事务提交一整批,并且要保证"要么全成功、要么全失败"。

我实际用的代码逻辑大概是这样的:

@api_view(['POST']) @permission_classes([IsAuthenticated]) def submit_application(request): student = request.user.profile.student batch = get_current_batch() # 检查时间窗口 if not batch.application_open(): return Response({'error': '不在填报时间内'}, status=400) # 检查是否已有志愿 if Application.objects.filter(student=student, batch=batch).exists(): return Response({'error': '你已经提交过志愿,请先撤回修改'}, status=400) data = request.data.get('applications') # 预期是list of teacher_id # 检查志愿数量 if len(data) > MAX_APPLICATIONS: return Response({'error': '志愿数量超限'}, status=400) # 事务提交 with transaction.atomic(): for idx, teacher_id in enumerate(data): teacher = Teacher.objects.select_for_update().get(pk=teacher_id) if teacher.remaining <= 0: raise ValidationError('导师名额已满') Application.objects.create( student=student, teacher_id=teacher_id, priority=idx + 1, status='PENDING', batch=batch ) return Response({'message': '提交成功'})

这段代码里有两个关键点。

第一个是select_for_update()。这个东西在并发抢选的时候特别重要。它会对导师记录加行锁,保证在事务提交前,其他事务不能修改这条记录。如果没有这行,两个学生同时提交同一个导师的志愿时,两个事务都先SELECT remaining发现还有名额,然后都UPDATE remaining-1,最后remaining只减了一次,名额却被占了两次——这就是经典的"超卖"问题。Django的ORM直接支持行锁,用起来很顺手。

第二个是"检查是否已提交过"的逻辑。这个检查不是绝对的,因为事务隔离级别不同,两个并发请求可能同时通过这个检查。但因为有唯一约束unique_together兜底,即使并发穿透了检查,也会在INSERT时报IntegrityError,事务回滚,不会产生脏数据。

这里有一个值得注意的坑:transaction.atomic()内如果抛出异常,Django会自动回滚整个事务。但如果你用的是try ... except把异常吞掉了,再返回一个错误响应,要注意此时事务已经被标记为"需要回滚",后续的数据库操作会一直报TransactionManagementError。我最初写的时候没注意,结果第二个请求进来直接报错。正确做法是在except里先调用transaction.atomic.__exit__或者直接让异常抛出去,由中间件统一处理。

3.2 导师端确认/拒绝逻辑:不是一键录取,而是"审核式录取"

导师端处理志愿的交互,我设计成列表+逐条确认/拒绝,而不是"抢人式"的一键录取。

列表页展示所有向自己提交志愿的学生,学生信息包括学号、姓名、专业、平均绩点、个人备注。导师逐条查看,点击"确认"或"拒绝"。确认操作后,该学生的状态变为ACCEPTED,同时该学生在本批次的其他待处理志愿自动标记为WITHDRAWN(由于状态机设计,这里的"标记"不是删除,而是状态字段更新)。

导师确认的接口,我加了两个重要校验:

@api_view(['POST']) @permission_classes([IsAuthenticated]) def confirm_application(request): application_id = request.data.get('application_id') with transaction.atomic(): app = Application.objects.select_for_update().get(pk=application_id) teacher = app.teacher # 校验:导师配额是否已满 if teacher.remaining <= 0: return Response({'error': '您的名额已用完'}, status=400) # 校验:学生是否已被其他导师录取 if Application.objects.filter( student=app.student, batch=app.batch, status='ACCEPTED' ).exclude(pk=app.pk).exists(): return Response({'error': '该学生已被其他导师录取'}, status=400) # 更新状态 app.status = 'ACCEPTED' app.last_operated_by = request.user.username app.last_operated_at = timezone.now() app.save() teacher.remaining -= 1 teacher.save() return Response({'message': '已确认'})

这里就体现出状态机价值了:冲突校验不是靠前端隐藏按钮,而是靠数据库和事务兜底。两个导师同时点确认,第二个会被"已被其他导师录取"挡住。因为select_for_update锁住了志愿记录,第二个事务会等待第一个提交之后再执行,这时候检查status='ACCEPTED'就会命中,返回错误。

remaining字段的更新,我采用的是在确认时直接减一,而不是实时重算统计结果。前者简单、实时、无并发压力;后者要用Count聚合,在大并发下会慢。实际运营中没有出现过不一致,因为所有更新都在事务里,select_for_update保证互斥。

3.3 时间窗口控制:定时任务与状态字段双重保障

时间窗口是个容易被忽视但极其重要的模块。如果学生能在截止时间之后提交,哪怕只晚了一秒,都会引发投诉。

我的方案是"双重保障":应用层校验 + 定时任务兜底。

应用层校验就是上述代码里写的,在提交接口、确认接口里都检查批次当前状态。但光靠请求时检查还不够——如果学生卡在23:59:59提交,请求到达服务器是00:00:00,批次状态已经切换,此时要保证拒绝。这个问题解决办法很直接:请求进来时,服务端获取的是服务器当前时间,而不是前端传的时间。前端的时间是客户端时间,不可靠,一切以服务器时间为准。

定时任务我用的是Django-Celery-Beat。跑两个任务:一个在填报结束时把批次状态从"填报中"切到"确认中",并给所有未提交志愿的学生推送提醒;另一个在确认结束时把批次置为"已结束",并批量把仍为PENDING的志愿置为EXPIRED,同时把所有未成功匹配的学生标记为"待调剂"。定时任务的精确度不需要到毫秒级,因为应用层校验已经把"晚一秒"的场景兜住了,定时任务只是个修复兜底。

这里有一个隐藏问题:Celery在开发环境是可用可不用的,但生产环境一定要配好. 如果服务器资源紧张,也可以写一个简单的cron脚本去调管理命令,效果一样。实际上我后期为了简化部署,把两个定时任务直接从Celery拆出来改成了两个Django management command + crontab,反而更省心,少维护一个redis。

3.4 消息触达:微信订阅消息的一次性授权难题

微信小程序的订阅消息,规则是这样的:用户必须主动点一次"允许发送订阅消息"按钮,你才能给他发一条模板消息。也就是说,你不能在用户没有交互的情况下直接推送。

这个规则对双选系统来说非常致命:学生填报之后不一定会主动点"允许订阅",即便点了,最多只能发一条。双选流程中,学生可能要收到"导师确认了你的申请""你的志愿被拒绝了""调剂结果出来了"等多条消息。

我的解法是:把订阅行为和关键操作绑定。在学生提交志愿时,弹窗请求订阅"确认结果通知";在导师确认时,请求订阅"有新志愿提醒"。每条消息对应一次授权,这样能保证授权数量与消息数量大致匹配。如果某个学生授权用完,系统退化为站内信通知(小程序里有一个"消息中心"模块,存消息记录,学生打开小程序就能看到),实际效果可接受。

这个模块最核心的经验是:不要试图绕过微信的订阅消息限制。网上有一些"滥用长期订阅消息"的做法,但这种灰色操作既不稳定也不合规。老老实实做一次性订阅+站内信兜底,是长期运营最稳妥的方案。

4. 并发抢选与冲突处理:热门导师被"秒光"的实测排障记录

4.1 问题场景:十秒内五十个志愿同时进来,接口直接卡住

这个系统第一年上线,第一次并发高峰出现在填报开放日中午12点整。教务老师设置12:00开放填报,我在后台监控面板上看着日志,12:00:00到12:00:10,十秒钟内收到了来自不同学生的50多个志愿提交请求。其中一个热门导师的名额是5个,意味着同时有20多个人在抢这5个名额。

刚开始的30秒一切正常,但很快日志里就出现了超时和TimeoutError,部分学生反馈"提交转圈圈半天没反应",还有好几个说"明明显示名额已满,但我提交成功后又弹出了错误提示"。

我立刻打开服务器,看到数据库的Threads_running飙到了30多,Innodb_row_lock_current_waits显示有大量行锁等待。这说明并发超过了数据库能承受的阈值,请求在排队等待锁资源,排队多了就把连接池挤满了。

4.2 排查链路:从接口响应时间到数据库锁等待的分析过程

排查这类问题,我习惯按三步走。

第一步,看接口响应时间的分布。用django-silk或者简单的日志中间件,记录每个API的耗时。结果发现submit_application接口P99耗时达到了3.2秒,而平时是80毫秒。这说明不是网络问题,是服务端处理慢。

第二步,开MySQL慢查询日志。结果抓到了几十条UPDATE teacher SET remaining = remaining - 1 WHERE id = ...的语句,每条耗时都在几百毫秒以上。问题很清楚了:更新同一行record时,锁等待导致耗时飙升。

第三步,故意用两个账号并发提交同一个导师的志愿,登录服务器手动复现,同时开启SHOW ENGINE INNODB STATUS,确认是LOCK WAIT。

到这里根因已经明确了:并发提交时,所有请求都在抢同一行导师记录的行锁,导致串行化执行。更关键的是,我的事务里除了更新导师记录,还做了"检查是否已提交过""循环插入多志愿记录"等操作,整个事务时间越长,锁持有的时间就越长,数据库就越拥堵。

4.3 方案落地:乐观锁、唯一索引与异步队列的组合拳

这个问题我最终不是靠一个方案解决的,而是三个措施配合。

第一,把"检查-更新"变成原子操作。原来代码是SELECT remaining、IF remaining > 0、然后UPDATE remaining-1,三步操作在事务里串行。我把它改成了带条件的UPDATE:

updated = Teacher.objects.filter( pk=teacher_id, remaining__gt=0 ).update(remaining=F('remaining') - 1) if updated == 0: raise ValidationError('名额已满')

UPDATE ... WHERE remaining > 0是数据库原生的原子操作,并发情况下只有remaining > 0的行能被更新成功。更新返回0说明条件不满足(名额已满),直接报错。这比select_for_update更轻量,因为它不需要先取锁再判断,是一个原子操作。当然,这只是针对"扣名额"这一步,整个事务还是保留的。

第二,加唯一索引守住"一个学生不能对一个导师重复提交"。前面已经说了,unique_together是最后一道防线。并发下即使业务逻辑没拦住,数据库也会报IntegrityError拒绝写入。

第三,引入Redis做前置流量削峰。这个措施是后期优化的时候加的,针对"前几秒大量请求打进来"的场景。具体做法是:在接口入口处,用redis的INCR记录每个导师ID在当前批次下的请求次数,超过某个阈值(比实际名额大一些,比如名额外加10)直接返回"已达访问高峰,请稍后再试",把超过处理能力的请求挡在数据库之外。这只是一种体验优化,真正抢到名额的判定仍然以数据库为准。

实测结果:改造后重新压测,用JMeter模拟100个并发请求抢5个名额,接口P99从3.2秒降到400毫秒,事务成功率100%,没有出现超卖或重复录取。之后正式开放,也没有再出现卡顿。

4.4 边界情况:漏选、误选、反悔撤回,还有最容易被忽略的"静默失败"

并发问题解决之后,还有一个意想不到的业务边界坑:学生提交时选了3个志愿,但导师确认阶段的耗时窗口长达48小时,这段时间里可能导师已经确认了第一志愿,而学生又提交了新的申请(虽然理论上应该撤回后再提交)。这里就涉及一个"撤回"操作的语义问题。

我做的撤回功能比较简单:学生在志愿状态全部为PENDING或REJECTED时,可以撤回整份志愿重新提交。如果已有ACCEPTED状态,不允许撤回。这个规则在需求阶段就和教务处确认过,因为一旦导师已经确认,反悔会造成流程混乱。撤回到提交之间有一个小时"冷静期",防止手滑误操作。

另外还有一个容易被忽略的场景:学生提交志愿时选了同一导师的两个名额(比如把同一导师填了第1志愿和第2志愿)。前端校验可以拦住,但API层也要校验。我在事务里增加了一个检查,如果data中有重复的teacher_id,直接拒绝提交。这个错误在代码审查时发现,后来写成了一个单元测试,防止回归。

静默失败是另一个大坑。有一次有学生反馈"为什么我提交成功了,但导师端看不到"。查了半天发现,是因为他提交时网络超时,前端显示失败,但后端事务实际已经提交了。学生以为是失败,就又提交了一次,结果第一次提交成功、第二次提交时被"已有志愿"挡回来,才出现混乱。这个问题的根本原因在于接口超时和结果返回的不可靠性,最终我用一个幂等键解决了:前端每次进入填报页生成一个submit_token,提交时带上这个token,后端只处理一次。重复提交相同token的请求直接返回上次结果,这样即使网络超时重复重试,也不会产生重复数据。

5. 小程序端联调与体验优化:从"能用"到"好用"的关键细节

5.1 登录态与Token刷新:静默登录不能省

小程序端的登录流程是:wx.login()拿到code,后端用code换openid,再生成一个自己的token(我用的Django REST framework的TokenAuthentication,后来换成JWT)。这个token的过期时间我设成7天,小程序每次启动时调用"静默登录"接口,刷新token有效期。

这里有个组件级的小坑:小程序冷启动时,wx.login()必须在App.onLaunch里尽早调用,否则页面请求会因token不存在而失败。我加了一个全局请求拦截器,在请求头里带上token,如果返回401,就先重新走一遍登录流程,再重试原请求。这个逻辑一定要在最底层处理好,否则会出现"用户眼看进来了,却所有接口报错"的诡异现象。

导师端登录时,我加了额外的一步:角色路由。一个账号可能既是学生又是导师(比如研究生助教兼任导师),登录之后根据profile.role决定跳转哪个首页,同时小程序端做一个"切换身份"的入口。实际使用中,这个功能被几个老师表扬了,因为很多系统默认一个账号只能是一种角色,用户体验很僵硬。

5.2 列表加载与缓存策略:导师列表上千条也不卡

导师列表是学生端首页的核心模块。几百位导师的信息一次性加载会很慢,我采用的方案是分页加载 + 本地缓存 + 增量更新。

分页采取"20条一页,下拉触底加载"的方式,后端用offset/limit或者Django REST framework的PageNumberPagination实现。前端拿到第一页数据后,存到wx.setStorageSync做本地缓存。

缓存更新的策略是:每次进入首页,先展示缓存数据,同时后台发一个轻量请求,如果后端返回的数据版本号(比如批次表更新时递增的version字段)跟缓存不一致,就刷新整页,否则沿用缓存。这个方法比每次进页面都重新拉数据体验好很多,特别是在校园WiFi信号不稳、接口时快时慢的场景下,缓存的价值非常明显。

导师端查看学生志愿列表时,我用了INFINITE滚动加载批次记录。每个批次下的志愿数量不定,多的有几十条,一页一页翻没问题。这里有一个体验细节:志愿列表的每一项,前端都要实时展示当前状态(待确认/已确认/已拒绝),状态变化要能反映到列表上,不能让学生导师生等刷新。我的做法是在每次导师操作完确认/拒绝后,本地更新一下对应项的UI文案,同时保留一个"下拉刷新"按钮,让用户手动拉取最新数据。

5.3 状态同步:轮询与下拉刷新的抉择

双选过程中,学生最关心的就是"导师确认了没有"。这属于典型的异步事件,前端没有能力实时收到通知,只能靠拉取。

解决方案有两个方向:轮询和WebSocket。我最终选择了轮询 + 下拉刷新的组合,没有上WebSocket。

原因很实际:第一,这个系统的实时性要求并不高,确认操作通常在导师点完按钮后几秒内发生,学生晚几分钟看到完全可接受;第二,WebSocket需要额外的长连接成本,小程序端的WebSocket在切后台时会被微信杀掉,维护成本高;第三,轮询逻辑极其简单——学生在停留在志愿详情页时,每隔30秒调一次接口查询状态,离开页面时清掉定时器,对数据库的压力在可控范围内。

我甚至还在后端做了个小型轮询聚合接口:一次返回该学生所有志愿的最新状态、剩余名额等关键字段,避免前端为了刷新状态发起五六个子请求。这个优化让前端代码清爽很多,也减少了对数据库的查询次数。

5.4 表单校验与防重复提交:前端不能只靠后端

前端表单校验,除了基本的"必填、长度合法"之外,双选系统里最关键的校验是提交锁。

学生点击"提交志愿"按钮后,按钮立刻进入loading状态并禁用,防止用户手抖连点两次导致重复请求。这只是第一步。更保险的是,我在前端用一个全局变量保证同一时刻只能有一个提交请求在进行,另一个用上述的submit_token幂等键兜底。这两个机制叠加,前端线上的重复提交几乎绝迹。

还有一个跟"手滑"相关的细节:确认弹窗。导师点击"确认"之后,会弹出一个二次确认框,文案是"确认后该学生将占用你的一个名额,确认操作不可撤回"。这个弹窗的文案是跟教务老师认真打磨过的,目的是让导师在操作前充分意识到后果,减少误操作。同理,学生撤回志愿时也弹这个风格的警示框。

6. 部署上线与运维避坑:一台2核4G服务器扛住整个毕业季

6.1 环境搭建:没有用Docker,直接裸机部署更省心

对于这种小型校园系统,我觉得Docker不是必需的。不是说Docker不好,而是对于一个维护者可能只有一两个人的项目,Docker会引入额外的排障成本。我最终采用的是最传统的裸机部署:一台2核4G的云服务器,装好Python 3.10、MySQL 8.0、nginx,用venv管理依赖,用uWSGI跑Django。整个部署脚本就几十行,非常稳定。

uWSGI配置的要点是processes=4 threads=2,harakiri=30表示每个请求最长30秒,超时自动杀死,防止个别慢请求卡死整个worker。nginx配置相对简单,静态文件交给nginx直接服务,动态请求走uwsgi_pass。HTTPS用的是免费证书,这个在微信小程序里是必须的——小程序要求所有请求必须是HTTPS,不能是明文HTTP。

6.2 数据库备份与崩溃恢复:一条命令保住整个毕业季

数据是这个系统的命根子。双选进行到一半如果数据库崩了,几百个学生的志愿数据全丢,那是灾难性的。我的备份策略是每天凌晨3点用mysqldump全量备份,保留最近7天的备份文件,同时每次批次状态切换(填报结束、确认结束)前手动再备份一次。

恢复演练我做过一次。当时是把备份文件在另一台机器上导入,模拟恢复流程,确认从备份到恢复完好的时间在20分钟以内。这个演练很有必要——第一年做的时候,我连备份文件的恢复命令都得翻半天文档,真出了事肯定手忙脚乱。后来我把恢复命令写成一个脚本,放在服务器固定路径下,并添加了README说明,万一出问题,任何人都能照着手册恢复。

6.3 监控告警与日志:别等到学生发截图了才发现系统挂了

监控方面,我没有用很重的监控平台,而是做了三件事:

第一,接口错误率告警。用一个简单的中间件统计每个API的错误率,如果5分钟内错误率超过10%,往自己的企业微信群机器人发一条告警。我用的是很原始的requests.post调群机器人Webhook,配合一个定时任务检查统计表。这块逻辑一共也就五六十行代码,但效果立竿见影——系统崩了5分钟我就知道了。

第二,数据库慢查询监控。MySQL的slow_query_log打开,慢查询阈值设为1秒,定期检查慢查询日志,分析是否有索引缺失或全表扫描。生产环境实际跑下来,偶尔出现几条超过1秒的查询,基本都是批次结束时的批量状态更新任务,我用索引和分批UPDATE解决了。

第三,业务日志。每个关键操作(提交志愿、导师确认、批次切换)都打一条结构化日志,包含操作用户、操作时间、操作对象、结果状态。出问题时,我直接grep相关日志就能还原整个操作链,不需要翻数据库临时拼。这个习惯我一直保留,后来帮本地教务处排查过几次"学生说没收到通知但系统显示已发送"的纠纷,全靠日志还原了真实情况。

6.4 上线前安全与合规检查:几个容易被忽略的细节

上线前做安全检查,我梳理了几个常见但容易被忽略的点。

接口鉴权。所有API都必须带有效的token,不允许有一个"无鉴权即可访问"的调试接口。我在代码审查时专门搜索了@api_view下面是否都挂了@permission_classes,防止漏写。

后台入口保护。Django Admin路径我用了一个不常见的URL前缀(比如/admin-panel-2023/),避免被扫描器直接撞到。同时给Admin加了二次验证,至少两步认证,防止密码泄露后被暴力登录。

隐私数据。学生的学号、手机号属于个人信息,导师的私人联系方式也不能随意展示。我的策略是:学生端列表只展示导师的研究方向和简介,不展示私人手机号;导师端看学生信息时,学生手机号默认星号隐藏,导师确认后才公开。这个小细节在需求评审时被特别强调,因为涉及个人隐私保护和高校数据合规。

接口限流。对登录接口和填报接口做了简单的IP/学号维度的限流,防止恶意脚本刷接口。虽然校园系统面对的威胁不算大,但养成这个习惯没有坏处。

做完这个项目,我最大的感受是:像导师双选这种看似普通的业务系统,真正考验人的不是某个技术难点,而是对流程规则的理解程度和对边界情况的敏感度。状态机定清楚、并发兜底做扎实、消息触达保底线、后台运维留记录——这四件事做完了,系统基本就不会出大乱子。如果你正准备动手做类似的毕设题目或校内工具,我强烈建议你先花几天时间把业务方拉过来聊透,把每一个状态、每一个例外场景都确认明白,然后再上手写代码。技术选型反而是最容易的部分,流程设计才是决定成败的胜负手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询