今天下班前,我把Tlias智能学习辅助系统的第三天代码合进了主干分支。说实话,day01和day02把工程骨架、课程管理、用户注册登录都跑通之后,我一直觉得这套系统“能用了但不够聪明”。今天狠下心来做三件事:把登录态从Session换成JWT、把学习计划模块从设计落到代码、给首页和看板接口接入Redis缓存与定时落库。这篇文章就记录一下今天实际动代码的过程,哪些设计决策是后来被验证正确或者说想多了的,以及排过的三个比较典型的坑。如果你也在写类似的“学习辅助类”系统,或者正在做前后端分离项目的登录态改造、缓存设计,这一篇应该能帮你省下不少调试时间。
1. 今日任务拆解:为什么第三天才碰“智能”这两个字
1.1 day01和day02留下的基础底座
day01做的是工程初始化:Spring Boot后端、Vue3前端、MySQL数据库三件套,把课程管理模块的增删改查跑通。day02补上了用户注册登录功能,用的是最传统的Session方案——登录成功往Session里塞userId,前端靠Cookie携带会话标识。到day02结束时,系统已经有“能注册、能登录、能看课程”的完整闭环。
但我在day02收尾测试时就发现了问题:前端跑在localhost:5173,后端跑在localhost:8080,跨域请求下Cookie这玩意儿太折腾。开发环境里每次请求都要处理跨域携带凭证的问题,要么配代理,要么折腾withCredentials。而且Tlias后续规划里有小程序端,要是继续抱着Session不放,小程序那边对接起来会很别扭。所以在day03开工前,我给自己列了三件事:
- 登录态从Session迁移到JWT,后端拦截器统一校验;
- 完成“学习计划”核心模块——用户选择课程和每日学习时间,系统自动生成每天的学习任务;
- 把首页看板、今日任务等热点接口接入Redis,并用定时任务把当日的统计数据落库。
1.2 “学习计划”到底解决什么需求
Tlias面向的是职业培训场景,用户的核心痛点不是“没有课程”,而是“买了课学不完”。传统在线教育平台把课程章节往那儿一摆,用户自己安排时间,结果就是前三天热情高涨,第四天开始吃灰。学习计划模块想解决的问题是:用户输入“我每天能学30分钟、每周周一到周五晚上有空”,系统自动把课程章节拆成每天的任务清单,包含新学内容和复习任务,用户只需要每天打开首页看“今天要干嘛”就行。
这个模块之所以放在day03,是因为它依赖day02的登录用户体系,又需要在day03提前把缓存底座铺好。从依赖关系上说,学习计划是整个“智能辅助”概念的载体,也是后续学情报表的数据来源,属于核心主干逻辑,必须尽早落地。
1.3 为什么把JWT升级放在业务开发之前
这里有一个排序问题:是先写学习计划的业务接口,还是先换登录态?我的决策是先换JWT。原因很简单——学习计划接口几乎全部需要登录态,如果先用Session把业务写完,回头再改JWT,意味着所有Controller层的HttpSession.getAttribute都要动一遍,拦截器也要重写。与其写两遍,不如半天时间把地基换成JWT,后面所有业务接口直接复用“解析token拿到userId”的工具方法。
而且说实话,Session方案在开发后期换起来比想象中痛苦得多:代码里到处穿插着sesssion.getAttribute,单元测试也得跟着改。先换JWT虽然牺牲了半天的业务开发时间,但后面每个接口的写代码体验都顺畅很多。这个先后顺序,我建议所有做前后端分离项目的同学都认真想一想。
2. 学习计划排期的核心算法与表结构设计
2.1 第一版表结构:计划主表加任务明细表
学习计划的数据结构其实很简单,核心就两张表:计划主表study_plan和任务明细表study_task。我一开始想过只用一张任务表,把计划名冗余到每个任务里,后来否了——计划级的状态(比如“已完成”“已终止”)和用户维度的汇总信息需要一个独立载体,拆开之后计划表的更新压力很小,任务表则承担高频的打卡更新,职责分离。
CREATE TABLE study_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '计划ID', user_id BIGINT NOT NULL COMMENT '用户ID', course_id BIGINT NOT NULL COMMENT '课程ID', plan_name VARCHAR(100) NOT NULL COMMENT '计划名称', daily_tasks INT DEFAULT 2 COMMENT '每日任务数上限', status TINYINT DEFAULT 0 COMMENT '0-进行中 1-已完成 2-已终止', start_date DATE NOT NULL COMMENT '计划开始日期', end_date DATE DEFAULT NULL COMMENT '计划结束日期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_course (user_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习计划主表'; CREATE TABLE study_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID', plan_id BIGINT NOT NULL COMMENT '计划ID', chapter_id BIGINT NOT NULL COMMENT '课程章节ID', task_date DATE NOT NULL COMMENT '执行日期', task_type TINYINT DEFAULT 1 COMMENT '1-新学 2-复习', difficulty TINYINT DEFAULT 1 COMMENT '难度系数 1-简单 2-中等 3-困难', estimated_minutes INT DEFAULT 30 COMMENT '预计学习分钟', status TINYINT DEFAULT 0 COMMENT '0-待完成 1-已完成 2-已过期', actual_minutes INT DEFAULT 0 COMMENT '实际学习分钟', finish_time DATETIME DEFAULT NULL COMMENT '完成时间', KEY idx_plan_date (plan_id, task_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习任务明细表';建表时有两个细节值得说。一是task_date用了DATE而不是DATETIME,因为任务是按天颗粒度拆分的,不需要时分秒;二是status预留了“已过期”状态,用于每天早上定时任务扫描后把昨天未完成的任务批量置为过期,这样看板上的数据不会出现昨天欠的债今天还在“待完成”里挂着。
2.2 排期算法:难度系数加每日预算的贪心分配
学习计划的核心是“自动排期”。我的做法是对每个章节计算一个分值,然后按照用户设置的每日学习时长做贪心分配。章节分值不是简单用课时长度,而是预计耗时 × 难度系数——简单章节系数1.0,中等1.5,困难2.0。这样一节30分钟的高难度课,实际占用的“能力值”是60,相当于两节简单课。
伪代码大致长这样:
List<Chapter> chapters = chapterService.listByCourseId(req.getCourseId()); List<ChapterScore> scores = chapters.stream() .map(ch -> new ChapterScore(ch, ch.getDuration() * difficultyFactor(ch.getDifficulty()))) .sorted(Comparator.comparing(ChapterScore::getScore).reversed()) .toList(); LocalDate cursor = req.getStartDate(); int taskIndex = 0; while (taskIndex < scores.size()) { WeekDay weekDay = cursor.getDayOfWeek(); if (!req.getStudyDays().contains(weekDay)) { cursor = cursor.plusDays(1); continue; } int dayBudget = req.getDailyMinutes(); // 先排新学任务,从分值大的开始 while (taskIndex < scores.size() && dayBudget >= scores.get(taskIndex).getScore()) { assignNewTask(cursor, scores.get(taskIndex)); dayBudget -= scores.get(taskIndex).getScore(); taskIndex++; } // 剩余时间安排复习任务 addReviewTasksForDate(cursor, dayBudget); cursor = cursor.plusDays(1); }这个算法有两个值得展开的点。一是排序方式:我把分值大的章节排在前面的学习日,因为用户刚建计划时积极性最高,把硬骨头放在前面,后面学起来会轻松很多。二是“剩余时间安排复习任务”:我引入了一个复习队列,每个新学任务完成后,会在第2天、第4天、第7天分别插入复习任务,复习任务的分值只有原章节的一半,用碎片时间就能完成。这个灵感来自记忆曲线,也是“智能辅助”这个标题里比较能体现“智能”二字的地方。
2.3 为什么表结构里要留end_date
排期结束后,计划的实际结束日期是算法算出来的,但我在计划表里允许它为空。原因是用户可能会中途调整学习节奏,比如从“每天30分钟”改成“每天40分钟”,这时候预计结束日期会提前,但不能直接更新计划主表的start_date,否则日程会乱。处理方式是在计划详情里动态计算:根据所有任务的最大task_date得出“预计结束日期”。主表里的end_date只在计划状态变为“已完成”时才落一次值,作为归档字段。这种“计算字段不落库”的习惯,能少踩不少数据不一致的坑。
3. 后端接口落地的关键细节
3.1 接口设计:三个核心端点
学习计划模块我设计了三个接口:POST /api/plan/generate(生成计划)、GET /api/plan/today(查看今日任务)、POST /api/task/finish(完成任务打卡)。生成计划的请求体长这样:
{ "courseId": 12, "startDate": "2026-05-21", "studyDays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"], "dailyMinutes": 40 }响应只需要返回planId和生成的今日任务数量,前端拿到后用GET /api/plan/today拉详情。打卡接口的参数是taskId和actualMinutes——实际学习时长由用户自己填,系统不做计时,因为用户可能用碎片时间在手机App外学习,强制计时反而制造焦虑。
3.2 Service层的五个步骤
生成计划的Service方法我拆成了五个步骤,每一步都是独立的私有方法:
public PlanGenerateVO generatePlan(GeneratePlanRequest req) { checkDuplicatePlan(req.getUserId(), req.getCourseId()); // 1. 防重复 List<Chapter> chapters = loadChapters(req.getCourseId()); // 2. 加载章节 List<StudyTask> tasks = schedulingAlgorithm(req, chapters); // 3. 排期 saveTaskBatch(tasks); // 4. 批量落库 cacheTodayTasks(req.getUserId()); // 5. 缓存今日任务 return PlanGenerateVO.of(tasks); }防重复这一步很容易漏。我第一次写完直接调接口,连续点了两次“生成计划”,结果产生了两个计划、两套任务,首页看板的任务数直接翻倍。后来在checkDuplicatePlan里加了规则:同一课程下存在“进行中”状态的计划时,不允许再次生成。用户要是手滑生成了,得先去终止旧计划。
批量落库用的MyBatis-Plus的saveBatch,这里有一点值得注意:saveBatch默认是分批执行,每批1000条,底层还是单条INSERT。学习计划一个用户最多也就几十个任务,用saveBatch已经够了,没必要上foreach拼大SQL。
3.3 MyBatis动态SQL里容易被忽略的两个坑
任务打卡的SQL我用了<update>加<where>标签,专门防止无意的全表更新:
<update id="finishTask"> UPDATE study_task <set> status = 1, actual_minutes = #{actualMinutes}, finish_time = NOW() </set> WHERE id = #{taskId} AND user_id = #{userId} </update>AND user_id = #{userId}这个条件极其重要。如果只按taskId更新而忽略归属校验,那么用户只要知道任务ID就能把别人的任务标记为完成,这是越权操作。很多新手项目在debug阶段图省事,觉得“任务ID又不会被猜到”,等到上线被刷接口就晚了。
另一个坑是Mapper接口多参数时必须加@Param注解。我昨天写这个接口时就犯了错——方法签名是finishTask(Long taskId, Integer actualMinutes),XML里写#{taskId}和#{actualMinutes},启动不报错,运行时报BindingException: Parameter 'taskId' not found。教训很简单:MyBatis里只要超过一个参数,一律显式加@Param,不要依赖默认的arg0、arg1,否则后续调整参数顺序时,SQL里的引用会莫名其妙错位。
4. 用JWT替换Session踩过的三道坎
4.1 跨域Cookie问题才是换JWT的真正原因
day02的Session方案在开发环境里最烦人的就是跨域。前端localhost:5173调后端localhost:8080,浏览器的SameSite策略直接把Cookie拦下。我尝试过配置Access-Control-Allow-Credentials: true加上后端CookieSameSite=None,Chrome还要求Secure属性,意味着本地HTTP环境根本不给你用。折腾到最后我实在忍不了,决定换JWT。
JWT的好处是“无状态”,服务端不需要维护会话,登录接口签一个token返回给前端,前端存到localStorage里,每次请求在Authorization: Bearer <token>头带上,后端拦截器解析token拿到用户身份。跨域?不存在的,自定义header天然不受Cookie SameSite约束。后面的小程序端就更方便了,小程序请求本来就不太会处理Cookie,直接用header传token是最通用的方案。
4.2 拦截器放行配置与OPTIONS预检
JWT校验我用了Spring MVC的拦截器,配置里有几个路径必须放行:登录接口本身、注册接口、错误页、Knife4j文档页、静态资源。其中最容易踩坑的是OPTIONS请求——前端发跨域请求前会先发一个预检请求,预检请求不会带Authorization头,如果你的拦截器对所有路径拦截,预检请求会被当成“未登录”挡住,前端永远拿不到真实响应。
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析token,设置ThreadLocal }放行OPTIONS这个细节,我见过的项目里有不下三个因为漏掉它导致前端请求“神秘失败”。排查方法也简单:打开浏览器控制台Network,看到请求类型是OPTIONS且状态码401,基本就是这个原因。
4.3 token过期处理:不要悄悄让用户迷失
JWT本身无法主动失效,token到期后解析就会抛异常。我在拦截器里统一捕获ExpiredJwtException并返回401,前端axios的响应拦截器里收到401就弹“登录已过期”,清掉本地token,跳转登录页。这里想提醒一句:不要为了“少打扰用户”把token有效期设成30天,学习类系统的安全等级不需要那么高,但也不能太低。我设的是2小时,配合前端在每次路由切换时检查token字段是否齐全,体验基本顺畅。
还有一个细节:token密钥不要硬编码到代码里。我在application.yml里单独配了jwt.secret和jwt.expire-hours两个配置项,不同环境用不同值。开发环境密钥无所谓,生产环境如果沿用默认密钥,等于给攻击者开了一扇门。
5. Redis统计与定时落库:从临时方案到抗住高峰
5.1 缓存key怎么设计,TTL怎么定
首页看板和今日任务接口的特点是“同一用户短时间内反复请求”。我用Redis缓存了两个高频数据:今日任务列表和看板统计数据。key的设计统一用业务:实体:维度格式:
tlias:task:today:{userId}:今日任务列表,值直接放JSON数组,TTL 30分钟;tlias:task:finished:{userId}:已完成任务数,用字符串存数字,完成打卡时INCR;tlias:plan:info:{planId}:计划详情,TTL 1小时;tlias:chapter:detail:{chapterId}:章节详情,TTL 1小时。
TTL策略的基本逻辑是:用户访问越频繁的key,TTL越短,防止“永久key+过期数据”堆积;越接近静态数据的key,TTL越长。今日任务列表虽然只有30分钟TTL,但用户在打卡动作后我会主动删除这个缓存,下一次请求重新查库并回填,保证新鲜度。
5.2 缓存穿透的完整排查过程:一次数据库CPU飙高事件
下午联调时,测试同学反映首页偶尔加载很慢,我一看数据库监控,CPU飙到90%以上。当时的第一反应是慢SQL,开了MyBatis慢日志,发现大量查询落在chapter_detail表上,而且查的章节ID基本都是不存在的——这些章节是运营在后台下架的,库里根本没有记录。
问题根因很典型:请求一个不存在的章节时,缓存和数据库都没有数据,缓存不会回填,于是每次请求都直接打到数据库,这是教科书级别的缓存穿透。复现方式很简单,用curl循环请求一个不存在的ID:
for i in $(seq 1 200); do curl -s "http://localhost:8080/api/chapter/999999"; done数据库连接数肉眼可见地涨。修复方案分两步:第一步,查询结果为null时也写缓存,值设为空字符串,TTL设为5分钟;第二步,在代码里判断缓存拿到空字符串时直接返回null,不再查库。
String key = "tlias:chapter:detail:" + chapterId; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return StringUtils.isBlank(cached) ? null : JSON.parseObject(cached, Chapter.class); } Chapter chapter = chapterMapper.selectById(chapterId); redisTemplate.opsForValue().set(key, chapter == null ? "" : JSON.toJSONString(chapter), chapter == null ? 5 : 60, TimeUnit.MINUTES); return chapter;布隆过滤器也能解决这个问题,但我评估了一下项目体量,暂时用空值缓存就够。布隆过滤器本身有误判率,还得维护数据结构,对当前规模来说属于过度设计。
5.3 定时落库:Spring Schedule加Redis分布式锁
Redis里的统计数字只适合短期展示,长期报表还是得落库。我在study_task表里加了两个冗余字段来存每日统计结果,然后在每天凌晨2点执行一个定时任务,把前一天Redis里的完成数和实际学习分钟批量更新到数据库。
定时任务必须防重复执行。单机部署时用@Scheduled加一个静态布尔标志就够了,但我考虑到后续可能部署多实例,直接用Redis的SETNX实现分布式锁:
@Scheduled(cron = "0 0 2 * * ?") public void syncTaskStats() { String lockKey = "tlias:job:sync:task:stats:lock"; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(30)); if (!Boolean.TRUE.equals(locked)) { log.warn("定时任务已在其他实例执行,本次跳过"); return; } try { // 查询昨日的任务统计数据,批量更新 } finally { stringRedisTemplate.delete(lockKey); } }cron表达式0 0 2 * * ?表示每天凌晨2点执行。选凌晨2点是因为这个时间点用户基本不在线,对业务影响最小。锁的过期时间设为30分钟,是给极端情况留的兜底——如果任务执行超过30分钟,锁自动释放,虽然极端情况下可能重复执行,但统计类任务即使重复跑一遍,用REPLACE INTO或先删后插也能保证幂等。
6. 遇到的三个坑:时区、绑定异常、日期格式化
6.1 任务日期平白少了一天,数据库时区惹的祸
上午写完生成计划接口,用Postman创建一个从5月21日开始的学习计划,返回的数据里task_date全变成了5月20日。日志打印出来的LocalDate还是5月21日,但只要insert到数据库再查出来,日期就少了一天。
排查链路如下:
- 第一步,确认入参:Postman传的
"startDate": "2026-05-21",Controller里接收正常; - 第二步,确认Service里的计算逻辑:排期循环时
cursor打印出来正确; - 第三步,确认SQL参数:把MyBatis打印的SQL拿到,
task_date绑定的值显示5月21日; - 第四步,直接进数据库查询,发现存储的值是5月20日;
- 第五步,检查数据库时区,执行
SHOW VARIABLES LIKE '%time_zone%',发现system_time_zone是UTC,MySQL容器用的默认时区不是北京时间; - 第六步,检查JDBC连接串,发现
serverTimezone参数没配,驱动按JVM默认时区(东八区)做转换,MySQL容器在UTC下存储,一来一回差8小时,日期自然少一天。
根因其实在“实体字段用了java.util.Date”上。Date是一个绝对时间戳,跨时区存储会转换;而LocalDate本身不含时区概念,理论上不该受影响。但MyBatis把LocalDate写入DATETIME列时,还是会走驱动的时间转换逻辑。解决方式我做了三层:一是把所有纯日期字段统一收成LocalDate类型;二是在JDBC URL里显式指定serverTimezone=Asia/Shanghai;三是MySQL容器启动时挂载/etc/localtime,让容器内时区与宿主机一致。
这三步做完,任务日期就稳定了。这个问题的教训是:分布式环境下,时区问题优先级很高,项目第一天就该统一,后补的代价是排查时把数据库、驱动、容器三个环节全部怀疑一遍。
6.2 MyBatis多参数报错:BindingException的完整现场
下午写打卡接口时,运行单元测试直接抛了异常:
org.apache.ibatis.binding.BindingException: Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2]看到这个异常,经验判断就是Mapper方法的参数没加@Param注解。MyBatis允许你在只有一个参数时直接写#{paramName},但如果有两个及以上参数,编译器根本拿不到参数名到映射层的引用,只能按arg0、arg1处理。所以要改的地方很明确,方法签名加注解就行:
int finishTask(@Param("taskId") Long taskId, @Param("actualMinutes") Integer actualMinutes);这类问题虽然解决简单,但每次都能浪费十分钟。我的建议是项目里从一开始就约定:Mapper接口方法只要参数不是单个对象,一律全部加@Param,宁可多写几个注解,也绝不依赖参数位置。参数顺序调整时,注解还能帮你快速发现哪里引用了错误的逻辑参数。
6.3 LocalDate序列化成数组,前端日历组件显示NaN
最后一个坑是前后端联调时发现的。前端用Element Plus的日历组件展示任务,结果日期显示NaN-NaN-NaN。打开Network一看,后端返回的task_date是[2026, 5, 21]这样的数组。
原因很简单:Spring Boot默认的Jackson序列化,对LocalDate的处理方式是输出成数组,而不是友好的字符串。修复方式是在工程里注册一个全局的Jackson2ObjectMapperBuilderCustomizer:
@Bean public Jackson2ObjectMapperBuilderCustomizer localDateCustomizer() { return builder -> builder.serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE)) .deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE)); }配置好之后,task_date就会序列化成"2026-05-21",前端日历组件直接显示正常。这个坑的出现频率非常高,尤其当项目里有人开始用Java 8时间类型时,几乎都会遇到一次。建议在day01的全局配置里就加上这个自定义序列化器,能省去后面所有接口联调时的日期问题。
结尾
今天这版代码给我的最大体会是:看似是“功能开发”的day03,其实一半时间花在了基础设施的替换和加固上。JWT迁移、Redis缓存穿透修复、时区统一,这些都不是用户能直接看到的功能,但它们决定了系统能不能稳定地跑下去。做学习辅助系统这类产品,功能上线只是开始,数据可靠性和接口健壮性才是真正能留住用户的东西。
最后分享一个小技巧:写系列开发日志时,多记录“当时为什么这么做”,少记录“今天写了哪个接口”。接口代码回头看很容易理解,但决策背后的理由——比如为什么拒绝布隆过滤器、为什么把end_date设计成归档字段——才是下一篇日志里真正有价值的内容。我写Tlias系列的习惯是每天留30分钟,把白天判断错的、排错绕弯路的过程单独记一段,后面翻起来比代码注释好用得多。
明天计划做学情分析报表模块,涉及多表聚合查询,到时候大概率还会遇到新的问题,等写完了再来分享。