ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Tlias day03:JWT登录改造、学习计划排期与Redis缓存实战

Tlias day03:JWT登录改造、学习计划排期与Redis缓存实战 今天下班前我把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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习任务明细表;建表时有两个细节值得说。一是task_date用了DATE而不是DATETIME因为任务是按天颗粒度拆分的不需要时分秒二是status预留了“已过期”状态用于每天早上定时任务扫描后把昨天未完成的任务批量置为过期这样看板上的数据不会出现昨天欠的债今天还在“待完成”里挂着。2.2 排期算法难度系数加每日预算的贪心分配学习计划的核心是“自动排期”。我的做法是对每个章节计算一个分值然后按照用户设置的每日学习时长做贪心分配。章节分值不是简单用课时长度而是预计耗时 × 难度系数——简单章节系数1.0中等1.5困难2.0。这样一节30分钟的高难度课实际占用的“能力值”是60相当于两节简单课。伪代码大致长这样ListChapter chapters chapterService.listByCourseId(req.getCourseId()); ListChapterScore 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. 防重复 ListChapter chapters loadChapters(req.getCourseId()); // 2. 加载章节 ListStudyTask 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 idfinishTask UPDATE study_task set status 1, actual_minutes #{actualMinutes}, finish_time NOW() /set WHERE id #{taskId} AND user_id #{userId} /updateAND 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加上后端CookieSameSiteNoneChrome还要求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}已完成任务数用字符串存数字完成打卡时INCRtlias:plan:info:{planId}计划详情TTL 1小时tlias:chapter:detail:{chapterId}章节详情TTL 1小时。TTL策略的基本逻辑是用户访问越频繁的keyTTL越短防止“永久key过期数据”堆积越接近静态数据的keyTTL越长。今日任务列表虽然只有30分钟TTL但用户在打卡动作后我会主动删除这个缓存下一次请求重新查库并回填保证新鲜度。5.2 缓存穿透的完整排查过程一次数据库CPU飙高事件下午联调时测试同学反映首页偶尔加载很慢我一看数据库监控CPU飙到90%以上。当时的第一反应是慢SQL开了MyBatis慢日志发现大量查询落在chapter_detail表上而且查的章节ID基本都是不存在的——这些章节是运营在后台下架的库里根本没有记录。问题根因很典型请求一个不存在的章节时缓存和数据库都没有数据缓存不会回填于是每次请求都直接打到数据库这是教科书级别的缓存穿透。复现方式很简单用curl循环请求一个不存在的IDfor 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-21Controller里接收正常第二步确认Service里的计算逻辑排期循环时cursor打印出来正确第三步确认SQL参数把MyBatis打印的SQL拿到task_date绑定的值显示5月21日第四步直接进数据库查询发现存储的值是5月20日第五步检查数据库时区执行SHOW VARIABLES LIKE %time_zone%发现system_time_zone是UTCMySQL容器用的默认时区不是北京时间第六步检查JDBC连接串发现serverTimezone参数没配驱动按JVM默认时区东八区做转换MySQL容器在UTC下存储一来一回差8小时日期自然少一天。根因其实在“实体字段用了java.util.Date”上。Date是一个绝对时间戳跨时区存储会转换而LocalDate本身不含时区概念理论上不该受影响。但MyBatis把LocalDate写入DATETIME列时还是会走驱动的时间转换逻辑。解决方式我做了三层一是把所有纯日期字段统一收成LocalDate类型二是在JDBC URL里显式指定serverTimezoneAsia/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的处理方式是输出成数组而不是友好的字符串。修复方式是在工程里注册一个全局的Jackson2ObjectMapperBuilderCustomizerBean 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分钟把白天判断错的、排错绕弯路的过程单独记一段后面翻起来比代码注释好用得多。明天计划做学情分析报表模块涉及多表聚合查询到时候大概率还会遇到新的问题等写完了再来分享。
返回列表