每年选课季,教务系统的工单量几乎都会翻一倍。学生问得最多的问题不是“课在哪里查”,而是“这课到底难不难、老师讲得怎么样、我这学期该选哪些”。做过教务平台的人都知道,选课从来不是一个纯展示型功能,它要同时解决信息查找、资源竞争和决策辅助三件事。
这篇是项目实现案例系列的第五篇,完整复盘一个高校选课查询与推荐系统的设计与实现过程。项目核心分两条线:一条是选课查询链路,覆盖课程检索、教学班筛选、时间冲突检测;另一条是课程推荐链路,基于专业培养方案、历史选课记录和课程热度,用混合推荐策略输出个性化课程建议。技术栈是Spring Boot + MyBatis-Plus + MySQL + Redis,前端用Vue 3,整体前后端分离,上线后跑完了一整个选课季。
如果你正在做课程设计、毕业设计,或者手头刚好有个类似的信息管理系统要落地推荐功能,这期的很多细节可以直接借鉴。我会把项目里踩过的坑、做过的权衡、能复用的代码思路一起写出来。
1. 选课查询推荐系统,到底在解决什么问题
1.1 高校选课场景和普通商品检索的差别
我第一次拿到这个需求时,教务老师的原话是:“我们不要做一个只能看课程列表的系统,学生根本看不完。”这句话点出了核心矛盾。高校的选课和电商平台的商品浏览有本质差别,不能照搬那套产品逻辑。
首先是约束条件多。课程不是想选就能选的。专业有培养方案,课程有先修关系,学分有上限,上课时间不能冲突,教学班有名额限制。这些约束条件直接决定了一个推荐结果是否“可用”,如果只按兴趣排序,推荐出一堆时间冲突或者先修没修的课程,学生只会觉得系统在添乱。
其次是决策周期长。用户在电商平台看到一件商品,可能几十秒内就决定加入购物车;选课则是一个典型的多轮决策过程,学生通常要对比几个课程大纲、看评价、问学长,最后才提交选课。这意味着系统需要给出的不只是“把你可能喜欢的课程列出来”,而是要帮用户把“为什么推荐这门课”说清楚,这是可解释性在选课场景里特别重要的原因。
第三是资源竞争性。热门课程容量有限,不是所有推荐结果都能选上。一个只按兴趣排序的推荐系统,在抢课高峰期会变成一纸空文。所以推荐候选里必须包含“热度”信号,但热度又要适当地被培养方案约束,避免清一色推荐公共热门课。
基于这几个特点,我在设计目标上收敛成两句话:查询要快、准,推荐要短、可信。“快准”针对课程检索效率,“短可信”则针对推荐结果的数量和解释。
1.2 从需求到模块:查询、选课、推荐三条链路
从整体功能上看,系统可以拆成四个大模块:
- 课程目录与教学班管理:维护课程基础信息、排课时间、教师信息、开课学期;
- 多条件课程查询:包含关键字搜索、组合筛选、余量状态展示和课程详情查看;
- 选课与退课:承载容量扣减、冲突检测、学分校验等核心事务逻辑;
- 个性化推荐:以课程目录数据和用户行为数据为基础,输出推荐候选集和推荐理由。
这四个模块不是平级的。课程目录和选课事务是底座,查询是入口,推荐是加分项。实际开发时我建议先把查询和选课做成稳定闭环,再在查询结果页旁边挂推荐位,这样即使推荐系统出了问题,也不影响正常选课流程。项目上线后我们对推荐接口做了独立降级开关,这个策略后面会展开讲。
1.3 用户角色与权限边界
系统涉及三类用户:学生、教师、教务管理员。教师只能查看自己教学班的选课名单和人数;教务管理员可以做课程维护、开课申请审核、选课轮次配置;学生端则是完整的查询、选课、退课、推荐功能。权限这块没有引入重型安全框架,Spring Security只开了最基础的认证和角色控制,关键接口用注解做权限校验,够用就行。
角色边界清楚之后,接口设计也会简单很多。学生端接口全部以 /student/api/ 开头,管理端以 /admin/api/ 开头,网关层做统一前缀路由,日志记录也按前缀分类,排障时非常方便。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 Spring Boot 到 Vue 3:这套架构选型的账怎么算
2.1 后端技术栈:稳定与效率的平衡
先给结论:后端用了 Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis 6 + Vue 3。这套组合在高校项目里算比较“中规中矩”,但中规中矩在这个场景恰好是优点。
Spring Boot 的生态成熟,事务管理、参数校验、安全框架都是开箱即用。课程查询和选课流程都是典型的事务密集型业务,Spring Boot 的声明式事务足够覆盖,而且团队招人容易、出问题时排查路径清晰,不会因为框架本身太复杂把项目拖垮。
用 MyBatis-Plus 而不是 JPA,主要考虑是项目里有大量组合筛选、动态更新的场景,MyBatis-Plus 的 QueryWrapper 可以灵活组合条件,同时保留手写 SQL 的能力。课程查询的动态条件,代码写起来非常自然:
java复制LambdaQueryWrapper<Course> wrapper = Wrappers.lambdaQuery();
wrapper.eq(StringUtils.hasText(form.getTermId()), Course::getTermId, form.getTermId())
.eq(form.getDeptId() != null, Course::getDeptId, form.getDeptId())
.eq(form.getCourseType() != null, Course::getCourseType, form.getCourseType())
.ge(form.getMinCredit() != null, Course::getCredits, form.getMinCredit())
.le(form.getMaxCredit() != null, Course::getCredits, form.getMaxCredit());
List<Course> courses = courseMapper.selectList(wrapper);
如果换 JPA,虽然也能写 Specification,但团队的学习成本和 SQL 调优的直观性都不如 MyBatis 系。选课系统的查询模式相对固定,用 MyBatis 手写优化过的 SQL 更可控。
Redis 在这个项目里承担三件事:课程余量的缓存、选课操作的分布式锁、推荐候选集的临时存储。MySQL 是源头数据,Redis 只做加速和短期状态存储,这个边界一定要清楚。我之前见过不少项目把关键业务状态也放进 Redis,一旦缓存丢失就丢数据,这种设计在选课场景里是绝对不能接受的。
2.2 前端与部署结构
前端使用 Vue 3 + Element Plus + Vite,通过 Axios 调用后端接口。Vue 3 的组合式 API 对列表页这种状态较多的场景非常友好,课程筛选条件、分页、排序、推荐结果都用响应式状态管理,代码可维护性比 Options API 时代好了不少。
部署结构上,我用了最简的三层:
- Nginx 托管 Vue 打包后的静态资源,同时做 API 反向代理;
- 后端是单个 Spring Boot 应用,打成 jar 部署在服务器上;
- MySQL 与 Redis 分别部署,数据库定时备份。
这里有个经验:很多课设项目喜欢一上来就拆微服务,但选课系统的峰值压力集中在选课接口,单机完全扛得住,不需要拆微服务。把推荐引擎的代码做成分离的 service 包,将来如果推荐计算量大了再抽出独立服务。代码层面的模块解耦,比物理层面的服务拆分更实际。
2.3 接口设计与统一返回结构
接口统一返回 Result 结构,包含 code、message、data 三个字段,业务异常统一用 @RestControllerAdvice 全局处理。学生端查询接口用 POST + JSON body 接收参数,虽然理论上 GET 更符合查询语义,但选课筛选条件有十来个字段,GET 会把 URL 拉得很长,而且分页、排序参数组合不方便扩展,所以还是采用了 POST。
3. 课程查询模块:筛选条件、索引设计与性能优化
3.1 多条件组合查询的接口设计
查询接口的设计目标很明确:学生打开选课页,能在 1 秒内完成一次条件筛选。筛选条件分成两组:
基础关键字组:课程名称、课程代码、教师姓名,三者之间用 OR 匹配;
结构化筛选组:学期、开课学院、课程类型、学分区间、上课星期、上课节次、是否有余量。
一个容易踩的坑是:关键字搜索和结构化筛选放在同一个 OR 条件里,导致索引失效。正确做法是拆成两部分:先用结构化条件过滤出候选集,再在候选集内做关键字匹配。因为课程名称上通常建了普通索引或全文索引,混在一个 SQL 里反而让优化器无从下手。
关键字搜索这块,最常见的问题是 LIKE '%关键词%' 导致全表扫描。课程名称、教师姓名的检索对中文环境非常敏感,简单方案是在 MySQL 里对课程名称建 FULLTEXT 索引,配合 BOOLEAN MODE 用。但全文索引在中文环境下的分词精度有限,在课程数据量超过几十万条之后还是建议升级到 Elasticsearch。我们的课程数据在十万级别,用现有方案完全足够。
3.2 组合索引顺序与分页查询
课程筛选最常见的高频场景是:某个学期的某类型课程,再按学分过滤。所以我们建立了这样的组合索引:
sql复制CREATE INDEX idx_term_dept_type ON course(term_id, dept_id, course_type);
CREATE INDEX idx_term_type_credit ON course(term_id, course_type, credits);
组合索引的列顺序遵循最左前缀原则,同时把区分度高的列放前面。对于“学期 + 学院 + 课程类型”这种条件,第一个索引已经覆盖了大部分查询。学分是范围条件,放在组合索引最后一位,能继续利用索引内排序,避免 filesort。
分页场景下,很多人直接 offset 深分页,但实际上选课查询的一般规律是:学生会不断调整筛选条件,通常只看前几页。我的处理是:默认只允许查前 10 页,超过 10 页提示用户用关键字缩小范围。这一招在课程数据量较大时效果显著,避免了一次查询扫几十万行再丢弃的问题。
3.3 余量状态实时性与列表接口的缓存策略
查询列表还要展示“已选人数/容量”的余量状态。如果每次点击列表都去数选课记录,数据库压力会非常大。我们的做法是:把余量数值冗余在教学班表里,用一个 selected_count 字段维护,列表查询直接读取该字段,详情页也是同一份数据。
同时,对“学期 + 学院 + 课程类型”的常见筛选维度做 Redis 缓存,缓存 key 为查询条件的 MD5,缓存时间 30 秒。选课高峰期,热门课程的余量变化非常快,30 秒的延迟在查询列表场景下可接受。用户真正点“选课”按钮时,走的是实时校验,不受缓存影响。
缓存更新只能由“选课成功”和“退课成功”两个入口触发,不要用定时任务去扫全表更新缓存,否则会产生大量无效写。我在项目里用的 cache-aside 模式:先更新数据库,再删除 Redis 缓存。读取时若缓存没有再回源数据库,这个模式虽然简单,但在选课场景下足够可靠。
4. 时间冲突检测与选课核心流程的实现
4.1 排课时间的数据模型:怎么存才方便冲突判断
选课的核心硬约束是时间冲突。在设计排课表之前,我先确认了学校的作息时间:上午 1-5 节、下午 6-8 节、晚上 9-12 节,每节课有固定起止时间。系统内部只需要记录“星期几 + 第几节到第几节”。
排课表结构:
sql复制CREATE TABLE class_schedule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
class_id BIGINT NOT NULL,
weekday TINYINT NOT NULL, -- 1=周一, 7=周日
start_section TINYINT NOT NULL, -- 开始节次
end_section TINYINT NOT NULL, -- 结束节次
location VARCHAR(64),
KEY idx_class_id (class_id),
KEY idx_weekday_section (weekday, start_section, end_section)
);
为什么用“节次”而不是“开始时间”?节次取值范围固定(1-12),枚举简单、索引高效、冲突判断直观;导入课表时把时间转成节次,展示时根据节次映射回时间字符串即可。如果直接用“08:00 - 09:40”这种字符串,比较逻辑会非常痛苦。
冲突检测的 SQL 如下:
sql复制SELECT COUNT(*) FROM class_schedule cs
LEFT JOIN student_class sc ON sc.class_id = cs.class_id AND sc.student_id = #{studentId}
WHERE sc.selection_status = 1
AND cs.weekday = #{weekday}
AND cs.start_section < #{endSection}
AND cs.end_section > #{startSection}
两段区间重叠的条件是“新课程的开始节次小于已选课程的结束节次,并且新课程的结束节次大于已选课程的开始节次”。这个条件在数据库层直接
