背景:毕业季的“三座大山”
每年三月,高校的毕设管理群都会准时上演“三大名场面”:
- 选题冲突——同一课题被 30 人同时点击,数据库瞬间出现多条
student_id指向同一topic_id的脏数据; - 流程黑盒——学生看不到导师的审核进度,导师看不到学生的最新文档,双方靠微信“盲聊”;
- 权限混乱——辅导员、教研室主任、教务秘书都想看数据,结果一个
root账号被传来传去,谁改了配置没人知道。
「计算机毕业设计之家」就是在这样的“修罗场”里孵化出来的。目标只有一个:用最小开发成本,让毕设流程可管、可控、可回溯。
技术选型:为什么不是 Django + PostgreSQL?
| 维度 | Spring Boot 2.7 | Django 4.1 |
|---|---|---|
| 事务粒度 | 依赖声明式@Transactional,可精确到方法级 | 默认自动提交,手动控制需显式transaction.atomic() |
| 依赖注入 | 原生 IoC,AOP 无侵入 | 需第三方django-river或信号量实现,代码分散 |
| 微校生态 | 教务系统多基于 Java,对接 SOAP/ bod 方便 | Python 胶水语言优势,但需额外桥接层 |
| 学习曲线 | 对只写 CRUD 的应届生略陡 | 上手快,后期性能调优经验资料偏少 |
数据库层面,MySQL 8.0 的SELECT ... FOR UPDATE与innodb_deadlock_detect足以应付千级并发选题;PostgreSQL 的SKIP LOCKED固然香,但校园机房只给了 4C8G 的 CentOS,DBA 对 MySQL 更熟,最终保守选了 MySQL。
核心实现细节
1. 选题幂等性校验
场景:N 个学生同时抢 1 个课题。
方案:Redis 分布式锁 + 数据库唯一索引兜底。
// TopicService.java public String chooseTopic(Long studentId, Long topicId) { String lockKey = "topic_lock::" + topicId; String requestId = UUID.randomUUID().toString(); try { // 1. 尝试获取锁,超时 3s,防止羊群效应 boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(3)); if (!locked) { return "选题人数过多,请稍后再试"; } // 2. 双重检查:缓存穿透场景下仍有并发 Topic topic = topicMapper.selectById(topicId); if (topic.getRemain() <= 0) { return "名额已满"; } // 3. 扣减库存与插入选题记录放在同一事务 topicMapper.decrementRemain(topicId); // SQL: UPDATE topic SET remain=remain-1 ... chooseRecordMapper.insert(studentId, topicId); return "选题成功"; } finally { // 4. 仅当当前线程持有锁才释放,避免误删 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }关键点:
- 锁超时 < 事务超时,防止锁已释但事务回滚导致超卖;
- 使用
requestId做 value,杜绝 A 线程误删 B 线程锁。
2. 导师-学生双向匹配算法
需求:导师最多带 5 人,学生可填 3 个志愿,需最大化整体满意度(志愿序越小越好)。
建模:二分带权匹配 → 转化为最小费用最大流。
- 节点:源点
S→ 学生 → 导师 → 汇点T - 边容量:学生到导师为 1;导师到
T为 5 - 边费用:学生第一志愿 1 分,第二志愿 3 分,第三志愿 5 分
使用 Google OR-Tools 的MinCostFlow求解,10 毫秒级完成 800 学生×200 导师规模运算。
3. WebSocket 进度通知
技术栈:Spring 原生@MessageMapping+ STOMP + Redis Pub/Sub。
当导师点击“通过”按钮,后端发布一条消息到 Redis channel,网关层(Spring Gateway)里的 WebSocket 微服务订阅并推送给指定studentId,前端 Vue3 使用@stomp/stompjs实时刷新进度条。
性能与安全考量
- SQL 注入:MyBatis-Plus 一律
#{}占位,禁止$拼接; - JWT 刷新:AccessToken 5min + RefreshToken 2h,刷新时旧 RefreshToken 加入 Redis 黑名单,防止回放;
- 冷启动延迟:Spring AOT + GraalVM 静态编译把启动时间从 8s 降到 1.3s,K8s 滚动发布不再杀旧 Pod;
- 文件上传:限制 10MB,使用
commons-io检查 Magic Number,禁止../路径穿越,统一存至 MinIO,返回 UUID 链接,数据库只存对象名。
生产环境踩坑实录
- 时区陷阱:服务器默认 UTC,教务要求“截止时间 23:59” 被误判成第二天凌晨。解决:Dockerfile 里加
ENV TZ=Asia/Shanghai,MySQL 连接串追加&serverTimezone=Asia/Shanghai; - 路径硬编码:早期把上传目录写成
/home/upload,上 K8s 后节点重启丢失。改为 MinIO + 可配置spring.servlet.multipart.location; - 事务嵌套:选题方法里调用
sendWebsocket(),后者又读库,导致Connection is read-only异常。解决:把通知逻辑放到事务提交后,用TransactionSynchronizationManager.registerAfterCompletion()。
完整可运行片段:防并发选题锁
@Configuration public class RedisLockConfig { @Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); return template; } }@RestController @RequestMapping("/topic") public class TopicController { @Autowired private TopicService topicService; @PostMapping("/choose") public R choose(@RequestParam Long studentId, @RequestParam Long topicId) { return R.ok(topicService.chooseTopic(studentId, topicId)); } }下一步:从“毕设之家”到“学术协作平台”
当前系统只服务计算机学院,但模型层(用户、角色、学院、流程模板)已预留tenant_id。后续要做:
- 多租户隔离:在 MyBatis-Plus 拦截器统一追加
AND tenant_id = ?; - 流程编排:把“选题-开题-中期-答辩”做成 BPMN,嵌入 Camunda,各学院自定义环节;
- 微服务拆分:将通知、文件、匹配独立成 Service,配合 Nacos 注册中心,支持弹性扩缩;
- 开放接口:OAuth2 授权,对接学校一站式平台,实现单点登录。
如果你也在做校务系统,不妨先跑通最小闭环,再横向扩展——毕竟,毕业设计只是学术协作的起点,而不是终点。