当前位置: 首页 > news >正文

山东大学软件学院项目实训——计科智伴(六)——前后端接口全面对齐、成就体系与 RAG 兜底

上一篇《接通真实大模型、RAG 检索与多 Agent 调度》把系统"跑起来"了。但跑起来和"前后端真正说上话"之间还差一大截——前端调的路径是/api/auth/api/chat/api/exercise,而后端只有/users/api/ai/chat/api/exercises(复数),双方从来没对上过。

本期的核心任务就是消灭这条裂缝:沿着前端接口文档逐模块补齐后端缺口,同时把成就系统、RAG 关键词兜底、登录多方式支持等几块积压需求一并落地。两个 commit,30+ 个文件,2200+ 行有效代码。

总览

板块交付物状态
认证层对齐/api/auth/*新控制器 + Token 续期接口✅ 已交付
练习流程对齐/api/exercise/*(Redis 会话管理)+ 批量提交✅ 已交付
聊天接口对齐/api/chat/*(SSE 流式 + 历史 + 推荐)✅ 已交付
全局异常处理12 类异常统一翻译为前端可读响应✅ 已交付
成就 & 等级系统/api/achievements/*(基于真实刷题数据)✅ 已交付
RAG 关键词兜底向量检索无结果时回退本地爬取数据搜索✅ 已交付
登录多方式支持邮箱 / 手机号 / 用户名 三路登录 + 注册放宽字段✅ 已交付
打卡 & 统计接口签到、学习统计、成长曲线✅ 已交付

一、认证层:路径不对,什么都白搭

前端文档里认证路径是/api/auth/login/api/auth/register,但后端实现在/users/login/users/register。每次调用都 404,前端根本跑不起来。

1.1 问题根因

不是逻辑错了,是路径对不上。原来后端按"领域对象"组织路径,前端按"行为"组织路径。改动路由会破坏已有调用,所以选择新建一个专门面向前端文档的控制器,把路由委托给原有 Service,不动原有逻辑

1.2 解决方案

新建AuthController,挂在/api/auth/*,所有逻辑转发给IUserService

// AuthController.java @PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) { return userService.login(loginForm, session); // 复用原有逻辑 } @PostMapping("/refresh") public Result refresh(HttpServletRequest request) { String token = request.getHeader("authorization"); stringRedisTemplate.expire(LOGIN_USER_KEY + token, 30, TimeUnit.MINUTES); return Result.ok(token); // 静默续期:TTL 重置为 30 分钟 }

Token 续期这个接口以前是缺的——前端快过期时调一次,不用强制登出重来。

1.3 顺带修的登录兼容问题

原来登录只支持邮箱和手机号,username不行。但注册时必填username,新用户注册完自然会尝试用username登录,结果直接报"账号格式有误"。三行逻辑改成三路匹配:

// UserServiceImpl.java 改造前后对比 // 改造前:不是邮箱不是手机号 → 直接拒绝 if (!looksLikeEmail && !looksLikePhone) { return Result.fail("账号格式有误(请使用手机号或邮箱)"); } // 改造后:邮箱 > 手机号 > 用户名,三路兜底 if (looksLikeEmail) user = query().eq("email", account).one(); else if (looksLikePhone) user = query().eq("phone", account).one(); else user = query().eq("username", account).one();

同时把注册的phone字段改为选填,grade未填时默认0(原来 NOT NULL 约束会直接报错)。


二、练习流程:从"拿题"到"会话制"

原来练习接口只有/api/exercises(复数),是简单的"拿题"接口。前端文档要求的是/api/exercise(单数)+ 完整会话流程:开始 → 答题 → 提交 → 结束。

2.1 会话管理设计

用 Redis 管理练习会话,TTL 2 小时,Key 格式exercise:session:{sessionId}

POST /api/exercise/start → 生成 sessionId,随机抽题写入 Redis POST /api/exercise/answer → 判题,记录 UserQuestionRecord POST /api/exercise/submit → 批量提交,统计正确率 GET /api/exercise/subjects → 学科列表(含每科题量和已完成数) GET /api/exercise/subjects/{id}/topics → 知识点列表

2.2 题量统计的两个子查询

/subjects接口需要实时算每个学科的"总题数"和"当前用户已做题数"。知识点 ID 通过kp_id关联,不能直接 join,用inSql内嵌子查询:

// 已完成题数:通过 question 表关联 kp_id long done = userQuestionRecordMapper.selectCount( new LambdaQueryWrapper<UserQuestionRecord>() .eq(UserQuestionRecord::getUserId, userId) .inSql(UserQuestionRecord::getQuestionId, "SELECT q_id FROM question WHERE kp_id IN (" + kpIdStr + ")") );

2.3 批量提交 + 错题自动入库

原来每题答完单独提交,前端页面刷新就丢了。改成批量提交接口,答题结果统一在/submit时持久化,答错的题目自动写入错题本,不需要前端再额外调错题接口:

// 答错自动记录错题 if (!isCorrect) { WrongQuestion wq = new WrongQuestion(); wq.setUserId(userId); wq.setQuestionId(qId); wq.setErrorReason("练习答错"); wrongQuestionService.save(wq); }

三、聊天接口:SSE 路径对齐 + 推荐问题落地

前端调/api/chat/send,后端实现在/api/ai/chat。新建ChatApiController桥接:

3.1 接口映射

// ChatApiController.java // POST /api/chat/send → SSE 流式问答 @PostMapping(value = "/send", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sendMessage(@RequestBody Map<String, Object> body) { ChatRequest req = new ChatRequest(); req.setPrompt(body.getOrDefault("message", "").toString()); // 从 context.conversationId 取会话 ID Map<String, Object> ctx = (Map<String, Object>) body.get("context"); if (ctx != null && ctx.get("conversationId") != null) req.setSessionId(ctx.get("conversationId").toString()); return aiChatService.streamChat(userId, req); }

3.2 推荐问题:先读画像薄弱点,再兜底 CS 通用题

GET /api/chat/recommend原来是占位。现在逻辑是:

  1. 读用户画像的weakPoints字段,取前 4 条薄弱知识点拼成推荐问题
  2. 画像为空或薄弱点不足 4 条,用CrawledDataService从本地爬取数据按学科关键词补足
  3. 兜底:CS 通用题库固定 4 条

三级兜底,保证前端推荐区永远不为空。


四、全局异常处理:前端不再看 500 堆栈

原来后端抛任何未捕获异常,前端收到的都是 500 + Spring 默认白页。现在统一用GlobalExceptionHandler翻译成{ok:0, msg:"…", data:null},前端只判ok字段就够。

4.1 覆盖的 12 类异常

优先级异常类型前端看到的 msg
1BusinessException(业务规则)直接透传业务描述
2MethodArgumentNotValidException(@Valid 校验)拼接所有字段的校验失败原因
3BindException(表单绑定)同上
4MissingServletRequestParameterException"缺少参数: xxx"
5MethodArgumentTypeMismatchException"参数类型错误: xxx"
6HttpMessageNotReadableException"请求体格式有误"
7HttpRequestMethodNotSupportedException"请求方法不支持: xxx"
8NoHandlerFoundException"接口不存在: xxx"
9MaxUploadSizeExceededException"文件过大"
10DuplicateKeyException"数据已存在"
11DataIntegrityViolationException"数据完整性校验失败"
12Exception(兜底)"服务器内部错误" + error 日志

五、成就 & 等级系统

5.1 数据来源

成就全部基于数据库真实数据计算,不是硬编码:

// 从三张表聚合计算成就进度 List<UserQuestionRecord> qrs = userQuestionRecordMapper.selectList(...); int total = qrs.size(); int correct = (int) qrs.stream().filter(r -> TRUE.equals(r.getIsCorrect())).count(); long mistakes = wrongQuestionMapper.selectCount(...); long distinctDays = studyRecordMapper.selectList(...) .stream().map(r -> r.getStudyTime().toLocalDate()).distinct().count();

5.2 成就列表(6 枚)

成就 ID名称解锁条件
first_exercise初出茅庐完成第一次练习
100_questions百题达人累计完成 100 道题
accuracy_king准确率王者正确率 ≥ 90%(至少答 10 题)
streak_7一周打卡累计学习天数 ≥ 7 天
streak_30月度坚持累计学习天数 ≥ 30 天
mistake_master错题终结者错题本清空至 0

5.3 等级计算

GET /api/achievements/level → { level: 3, title: "进阶学者", totalAnswered: 85, correctRate: 0.82, ... }

等级按总答题量分段:0→学习新手,50→初级学者,200→进阶学者,500→资深学者,1000→学习大师。


六、RAG 关键词兜底:向量检索的保险丝

上一期 RAG 检索用 pgvector 做向量相似度召回。但有个隐患:向量库里只导了 150 条(18,908 条的 0.8%),大多数查询向量检索都返回空,AI 没有参考资料,回答质量下降。

6.1 兜底策略

新建CrawledDataService,启动时把data_crawler/crawl/cleaned/下的所有 JSON chunk 加载进内存(无需调 embedding API),提供关键词搜索能力:

// 启动加载:@PostConstruct,扫描目录下所有 .json @PostConstruct public void init() { Files.list(Paths.get(crawlDataPath)) .filter(p -> p.toString().endsWith(".json")) .forEach(f -> loadFile(f, loaded)); log.info("爬取数据加载完成,共 {} 条 chunk", allChunks.size()); } // 搜索:对 title + chunkText 做分词命中数排序 public List<Chunk> search(List<String> terms, String subject, int topK) { return allChunks.stream() .filter(c -> subject == null || subject.equals(c.subject)) .map(c -> score(c, terms)) .filter(e -> e.getValue() > 0) .sorted(Comparator.comparingInt(Map.Entry<Chunk, Integer>::getValue).reversed()) .limit(topK) .map(Map.Entry::getKey) .collect(Collectors.toList()); }

6.2 接入 RAG 检索流程

RetrievalServiceImpl的向量召回后插一段兜底逻辑:

// 向量检索无结果时,回退关键词搜索 if (filtered.isEmpty() && crawledDataService.totalCount() > 0) { log.info("向量检索无结果,回退关键词搜索 query='{}'", query); List<String> terms = Arrays.asList(query.split("[\\s,,、。!?]+")); List<CrawledDataService.Chunk> kwChunks = crawledDataService.search(terms, subject, topK); return kwChunks.stream() .map(c -> RetrievalChunk.builder() .content(c.chunkText.length() > 1500 ? c.chunkText.substring(0, 1500) : c.chunkText) .kpName(c.subject).source(c.source + ": " + c.title).score(0.5).build()) .collect(Collectors.toList()); }

这样在向量库没导满之前,AI 问答仍然有本地 18,908 条数据兜底,不至于"裸奔"回答。


七、复盘经验

路径裂缝是前后端联调的头号杀手。前端文档路径和后端实现路径不一致,100% 404,任何功能都跑不起来。新建桥接控制器、委托给原有 Service 是代价最小的修复方式——不动原有逻辑,不破坏已有调用。

成就系统不要硬编码进度,要查真实数据。一开始想直接用固定值返回,省掉 DB 查询。但用户刷题数是动态变化的,硬编码出来的进度永远是 0% 或 100%,根本没意义。三张表 join 聚合才是正确做法。

向量库未填满时,关键词搜索是最便宜的 RAG 兜底。不需要 embedding API,不需要额外数据库,加载进内存做字符串匹配,100 行代码就能让 AI 回答有料可引。

注册字段越宽松越好,必填项越少越好。phone改选填、grade给默认值这两个改动,把注册成功率从"必须填手机号"变成"填用户名和密码就能注册",前端引导流程立刻顺畅很多。


八、阶段成果数据自检

指标本期实际
新增 / 改写文件30+ 个
新增有效代码行~2,200 行
新增 Controller9 个(AuthController、ChatApiController、ExerciseApiController、ProfileApiController、MistakeController、AchievementController、UploadApiController、TaskController、UserApiController)
覆盖异常类型12 类
成就枚举6 枚(全基于真实 DB 数据)
RAG 兜底数据量~18,908 条本地 chunk 常驻内存
登录方式3 种(邮箱 / 手机号 / 用户名)

九、下一步

当前下一步
前端真实联调接口已补齐,未实测本地起前端 H5,逐模块走 E2E
文件上传UploadApiController 已接 Minio测试图片上传 → 多模态问答链路
RAG 全量导入仅内存关键词搜索兜底分批跑/api/admin/rag/import-dir把 18,908 条向量化写库
成就解锁通知达成条件判断已有,未推通知接入NotificationService.push()触发站内消息
打卡连续天数统计的是累计天数改成真正的"连续天数"逻辑(中断归零)

本期把"路径对齐"这件基础工作做完,前后端总算能真正说上话了。下一篇进入联调和 RAG 全量导入阶段。

http://www.rkmt.cn/news/1442440.html

相关文章:

  • 我找到的国内直连 GPT 5.5 / Claude Opus 低成本方案
  • WindowResizer终极指南:3分钟掌握窗口强制调整技巧
  • Fusion360一体化设计:从零打造自定义按钮盒的完整指南
  • 【C++基础】循环嵌套
  • 2026 折叠棋牌桌选购避坑,拆机测评选材,稳固低故障棋牌桌源头品牌推荐 - 品牌榜中榜
  • 精通GTA5高级游戏增强:YimMenu架构深度解析与实战配置
  • 原神与崩坏:星穹铁道帧率解锁完整指南:如何轻松突破60帧限制
  • 【Sora 2时尚设计视频实战指南】:零基础7天生成高商业价值AI时装秀视频
  • Sora 2视频放大效果翻车率高达63%?资深CV架构师紧急发布「增强可信度评估协议v1.2」
  • ZLToolKit 源码分析(二):线程同步原语 semaphore 与 onceToken
  • 【Agent智能体15 | 工具使用-现代的LLM请求调用工具的语法】
  • 郑州市 高新区 厨卫改造翻新上门施工|维小达厨房改造、卫生间翻新、厨卫防水重做、下水管道改造一站式施工服务 - 维小达科技
  • 2026 广州黄金回收避坑,五家口碑好店,收的顶专业合规排名第一 - 奢侈品回收测评
  • 2026最新济南短视频运营平台排行:5家机构实力实测对比 - 奔跑123
  • 如何掌控你的惠普OMEN游戏本:OmenSuperHub完全使用指南
  • 2026年薪酬设计:这3个公平性原则让团队心服口服
  • 083、医学影像 CT/MRI 窗宽窗位应用不当?DICOM 解析、HU 值映射与多窗显示方案
  • 用Python和Tensorly复现经典PARAFAC论文:从荧光光谱数据到三维张量分解实战
  • 2026 年论文降 AI 工具硬核横评:16 款实测谁在保命谁在毁稿
  • 山东采暖炉品牌排行:实测性能与服务维度客观对比 - 奔跑123
  • [开源] 科室二次分配公平感模拟器:用博弈论算出护士长敢拍板的奖金方案,让夜班、年资、技术难度全进模型
  • NCMconverter:如何轻松解锁网易云音乐NCM格式音频文件
  • 零基础速存!最新 Kali Linux 全套详细教程,从下载安装到上手使用完整指南
  • 纸电路入门:用导电胶带和纽扣电池点亮创意世界
  • Sora 2实时渲染交互瓶颈突破:GPU内存占用降低63%的关键3步调优法(附NVidia CUDA Profile诊断模板)
  • DIY电池电量指示器:从分压原理到三极管开关电路的实践指南
  • 如何快速修复机械键盘连击问题:开源工具的完整解决方案
  • 新手也能懂:IGBT驱动电路里的‘退饱和’到底是什么?用UCC21750和BM6101FV-E2芯片实测讲解
  • 【Sora 2动画短片创作实战指南】:20年AIGC专家亲授5大不可外泄的提示词工程心法
  • 基于Google Charts与树莓派的物联网数据可视化实战