# 报告快速分析功能实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 在报告详情页(`report-detail.vue`)提供「快速分析」功能——用户输入问题,AI 基于当前报告内容做初步解读。免费版(FREE)每日 1 次(Redis 计数),付费版(FAMILY/PROVIDER)多轮对话。 **架构:** 后端在 `AIChatController` 新增 `POST /api/ai/report/analyze` 端点:查 `User.memberLevel` → 免费版 Redis 计数校验 → 复用 `sendMessage` 的上下文组装 + `aiService.sendMessage()` 调 LangGraph `chat_graph`(零 Python 改动)。前端 `report-detail.vue` 底部新增悬浮按钮 + 浮层(消息区/输入框/快捷问题),免费版禁用多轮(不带 conversationId)。 **技术栈:** Spring Boot 2.7.18 (Java 8) + Redis (spring-boot-starter-data-redis) + uni-app Vue 2 小程序(Options API,禁可选链/禁 CSS Grid/禁 `:key` 表达式)。 **设计规格:** `docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md` --- ## 文件结构 ### 后端(cfc-backend) | 文件 | 操作 | 职责 | |------|------|------| | `pom.xml` | 修改 | 新增 `spring-boot-starter-data-redis` 依赖 | | `src/main/resources/application.yml` | 修改 | Redis 连接配置(默认 localhost:6379) | | `src/main/java/com/etotem/cfc/config/RedisConfig.java` | 创建 | `RedisTemplate` bean,String 序列化 | | `src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 修改 | 抽出 `buildChatInputs()` 私有方法 + 新增 `POST /report/analyze` 端点(会员检查 + Redis 计数 + 调 aiService.sendMessage) | ### 前端(cfc-frontend) | 文件 | 操作 | 职责 | |------|------|------| | `utils/api.js` | 修改 | 新增 `reportQuickAnalyze(query, reportId, conversationId)` 封装 | | `pages/health/report-detail.vue` | 修改 | 底部悬浮按钮 + 浮层(消息区/输入框/3 个快捷问题/加载态/免费版禁用态) | --- ## 任务 1:后端引入 Redis 依赖与配置 **文件:** - 修改:`cfc-backend/pom.xml:90`(Validation 依赖之后) - 修改:`cfc-backend/src/main/resources/application.yml` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java` - [ ] **步骤 1:pom.xml 新增 redis starter** 在 `` 块(`spring-boot-starter-validation` 之后,约 90 行)插入: ```xml org.springframework.boot spring-boot-starter-data-redis ``` - [ ] **步骤 2:application.yml 添加 Redis 配置** 在 `spring:` 配置节点下添加(保持 YAML 缩进与现有 `datasource` 平级): ```yaml redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 ``` - [ ] **步骤 3:创建 RedisConfig.java** 创建 `cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java`: ```java package com.etotem.cfc.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } } ``` - [ ] **步骤 4:编译验证** 运行:`cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true` 预期:BUILD SUCCESS(无输出即成功) - [ ] **步骤 5:Commit** ```bash git add cfc-backend/pom.xml cfc-backend/src/main/resources/application.yml cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java git commit -m "feat: 引入 Redis 依赖与 RedisTemplate 配置(报告快速分析计数用)" ``` --- ## 任务 2:AIChatController 抽出 buildChatInputs + 新增 report/analyze 端点 **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` **背景:** 现有 `sendMessage`(72-166 行)已包含完整的上下文组装逻辑(mascot/portrait/自检/记忆层),需抽出为 `buildChatInputs(userId, params)` 供两个端点复用。新端点 `POST /report/analyze` 只做会员检查 + Redis 计数 + 调 `aiService.sendMessage`。 - [ ] **步骤 1:新增 import(RedisTemplate + LocalDate)** 在 `AIChatController.java` 头部 import 区添加(现有 import 在 10-27 行): ```java import org.springframework.data.redis.core.RedisTemplate; import java.time.LocalDate; import java.time.Duration; ``` - [ ] **步骤 2:注入 RedisTemplate 依赖** 在 `private com.fasterxml.jackson.databind.ObjectMapper objectMapper;`(约 72 行)之后添加: ```java @Resource private RedisTemplate redisTemplate; ``` - [ ] **步骤 3:抽出 buildChatInputs 私有方法** 现有 `sendMessage` 内 93-155 行(从 `// 获取用户mascot设置` 到 `inputs = aiService.enrichInputsWithMemory(...)`)是上下文组装逻辑。抽出为新私有方法(保留原有 `sendMessage` 调用它): ```java /** * 组装 AI 对话上下文(mascot + 家庭/报告 + 画像 + 自检 + 记忆层) * 供 /chat/send 和 /report/analyze 共用 */ private Map buildChatInputs(Long userId, Map params) { String conversationId = params.get("conversationId"); String reportIdStr = params.get("reportId"); String surveyIdStr = params.get("surveyId"); String selfCheckIdStr = params.get("selfCheckId"); // 获取用户mascot设置 com.etotem.cfc.entity.User user = userService.getUserInfo(userId); String mascotCode = user != null ? user.getMascot() : null; // 组装家庭上下文(如果指定reportId/surveyId,注入报告+问卷数据) Long reportId = null; Long surveyId = null; if (reportIdStr != null && !reportIdStr.trim().isEmpty()) { reportId = Long.valueOf(reportIdStr); } if (surveyIdStr != null && !surveyIdStr.trim().isEmpty()) { surveyId = Long.valueOf(surveyIdStr); } Map inputs; if (reportId != null && surveyId != null) { inputs = familyContextService.buildContext(userId, reportId, surveyId); } else if (reportId != null) { inputs = familyContextService.buildContext(userId, reportId); } else { inputs = familyContextService.buildContext(userId); } // 注入mascot信息到LangGraph inputs if (mascotCode != null && !mascotCode.isEmpty()) { com.etotem.cfc.enums.MascotEnum mascot = com.etotem.cfc.enums.MascotEnum.fromCode(mascotCode); if (mascot != null) { inputs.put("mascot_name", mascot.name()); inputs.put("mascot_gender", mascot.gender()); inputs.put("mascot_persona", mascot.persona()); } } else { inputs.put("mascot_name", "小助手"); inputs.put("mascot_gender", ""); inputs.put("mascot_persona", ""); } // 画像注入 String chatMemberIdStr = params.get("memberId"); Long chatMemberId = chatMemberIdStr != null && !chatMemberIdStr.trim().isEmpty() ? Long.valueOf(chatMemberIdStr) : null; String chatPortrait = portraitService.buildPortraitPrompt(userId, chatMemberId); if (chatPortrait != null) { inputs.put("portrait_prompt", chatPortrait); } // 注入自检上下文(P1-2) if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) { try { Long selfCheckId = Long.valueOf(selfCheckIdStr); com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId); if (selfCheck != null) { java.util.Map sc = new java.util.LinkedHashMap<>(); sc.put("totalScore", selfCheck.getTotalScore()); sc.put("scores", selfCheck.getScoresJson()); sc.put("createdAt", selfCheck.getCreatedAt() != null ? selfCheck.getCreatedAt().toString() : ""); inputs.put("self_check_result", objectMapper.writeValueAsString(sc)); } } catch (Exception e) { log.warn("注入自检上下文失败: {}", e.getMessage()); } } // AI记忆层注入(会话摘要+关键事实) inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs); return inputs; } ``` **重构 sendMessage:** 将原 `sendMessage` 中 93-155 行替换为: ```java // 复用上下文组装(mascot + 家庭/报告 + 画像 + 自检 + 记忆层) Map inputs = buildChatInputs(userId, params); ``` - [ ] **步骤 4:新增 /report/analyze 端点** 在 `sendMessage` 方法结束后(`}` 之后)添加: ```java @Operation(summary = "报告快速分析(免费版每日1次,付费版多轮)") @PostMapping("/report/analyze") public Result> reportAnalyze( @RequestAttribute("userId") Long userId, @RequestBody Map params) { String query = params.get("query"); String reportIdStr = params.get("reportId"); if (query == null || query.trim().isEmpty()) { return Result.error("消息不能为空"); } if (reportIdStr == null || reportIdStr.trim().isEmpty()) { return Result.error("报告ID不能为空"); } Long reportId = Long.valueOf(reportIdStr); // 1. 会员级别检查 com.etotem.cfc.entity.User user = userService.getUserInfo(userId); String memberLevel = user != null ? user.getMemberLevel() : "FREE"; boolean isFree = memberLevel == null || "FREE".equals(memberLevel); // 2. 免费版 Redis 计数(每日 1 次;Redis 异常时 fail-open 放行) if (isFree) { try { String key = "report:analyze:free:" + userId + ":" + LocalDate.now(); Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { redisTemplate.expire(key, Duration.ofDays(1)); } if (count != null && count > 1L) { return Result.error("免费版每日仅限 1 次快速分析,升级会员解锁更多"); } } catch (Exception e) { log.warn("Redis 计数失败,放行: {}", e.getMessage()); } } // 3. 组装上下文(免费版不带 conversationId → LangGraph 每次新建会话,锁死多轮) if (isFree) { params.remove("conversationId"); } Map inputs = buildChatInputs(userId, params); // 4. 调 LangGraph(经 aiService.sendMessage 含记忆层+镜像) Map difyResp = aiService.sendMessage( query, String.valueOf(userId), params.get("conversationId"), inputs); if (difyResp == null || difyResp.isEmpty()) { return Result.error("AI 服务暂不可用,请稍后重试"); } String answer = (String) difyResp.getOrDefault("answer", ""); String cleanAnswer = taskParseService.stripTaskMarkers(answer); Map result = new LinkedHashMap<>(); result.put("answer", cleanAnswer); result.put("conversationId", difyResp.getOrDefault("conversationId", "")); return Result.success(result); } ``` - [ ] **步骤 5:编译验证** 运行:`cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true` 预期:BUILD SUCCESS(无输出即成功) 注意:若 `User.getMemberLevel()` 不存在,检查 `entity/User.java` 是否有 `memberLevel` 字段(AGENTS.md 显示 `User.java:136` 有 `private String memberLevel;`) - [ ] **步骤 6:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java git commit -m "feat: 新增报告快速分析端点 /api/ai/report/analyze(免费版Redis每日1次+付费版多轮)" ``` --- ## 任务 3:前端 api.js 新增 reportQuickAnalyze 封装 **文件:** - 修改:`cfc-frontend/utils/api.js:1426`(`aiDeleteConversation` 之后) - [ ] **步骤 1:新增封装函数** 在 `aiDeleteConversation` 函数后添加: ```javascript // 报告快速分析(免费版每日1次,付费版多轮) export const reportQuickAnalyze = (query, reportId, conversationId) => { var data = { query: query, reportId: reportId } if (conversationId) data.conversationId = conversationId return request('/api/ai/report/analyze', 'POST', data) } ``` - [ ] **步骤 2:语法校验** 运行:`node -e "const fs=require('fs');const src=fs.readFileSync('cfc-frontend/utils/api.js','utf8');const m=src.match(/export const reportQuickAnalyze[\s\S]*?\n}/);if(!m){console.log('FAIL');process.exit(1)}console.log('OK')"` 预期:OK - [ ] **步骤 3:Commit** ```bash git add cfc-frontend/utils/api.js git commit -m "feat(frontend): api.js 新增 reportQuickAnalyze 封装" ``` --- ## 任务 4:report-detail.vue 底部悬浮按钮 + 浮层 **文件:** - 修改:`cfc-frontend/pages/health/report-detail.vue` **背景:** `report-detail.vue` 共 2162+ 行(大文件),本次只做增量添加,不重构。按钮显示在 view 模式(`pageMode==='view'`)且 `reportId` 存在时。浮层为固定定位弹层,含消息区、输入框、3 个快捷问题。 - [ ] **步骤 1:script 引入 reportQuickAnalyze + 新增 data** `import` 行(394 行)添加 `reportQuickAnalyze`: ```javascript import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge, reportQuickAnalyze } from '../../utils/api.js' ``` `data()` 内 `groupExpandList: []` 附近添加: ```javascript // 报告快速分析浮层 quickAnalyze: { show: false, sending: false, messages: [], // [{role:'user'|'ai', content}] inputText: '', conversationId: '', usedToday: false // 免费版今日已用(前端预判,最终以后端为准) } ``` - [ ] **步骤 2:新增快捷问题数组(computed 或 data 常量)** 在 `computed` 中添加(free 版 3 个预设问题): ```javascript quickQuestions: function() { return [ '这份报告最需要关注哪些异常指标?', '报告中提示了哪些健康风险?', '有哪些改善建议?' ] } ``` - [ ] **步骤 3:新增浮层 methods** 在 `methods` 中(`loadData` 附近)添加: ```javascript // ===== 报告快速分析 ===== openQuickAnalyze: function() { this.quickAnalyze.show = true var self = this // 预判免费版今日额度(仅用于 UI 展示,最终以后端校验为准) getMyMembership().then(function(res) { var level = res.data && res.data.memberLevel if (level && level !== 'FREE') { self.quickAnalyze.usedToday = false } }).catch(function() {}) }, closeQuickAnalyze: function() { this.quickAnalyze.show = false }, sendQuickQuestion: function(q) { if (q && q.trim()) { this.quickAnalyze.inputText = q this.sendQuickAnalyze() } }, sendQuickAnalyze: function() { var self = this var text = (this.quickAnalyze.inputText || '').trim() if (!text || this.quickAnalyze.sending) return this.quickAnalyze.messages.push({ role: 'user', content: text }) this.quickAnalyze.inputText = '' this.quickAnalyze.sending = true reportQuickAnalyze(text, this.reportId, this.quickAnalyze.conversationId).then(function(res) { if (res.code === 200 && res.data) { self.quickAnalyze.messages.push({ role: 'ai', content: res.data.answer || '(无回答)' }) if (res.data.conversationId) { self.quickAnalyze.conversationId = res.data.conversationId } } else { var msg = res.message || '分析失败' if (msg.indexOf('每日仅限') !== -1) { self.quickAnalyze.usedToday = true } self.quickAnalyze.messages.push({ role: 'ai', content: msg }) } }).catch(function() { self.quickAnalyze.messages.push({ role: 'ai', content: 'AI 服务暂不可用,请稍后重试' }) }).finally(function() { self.quickAnalyze.sending = false }) } ``` > **注意:** `getMyMembership` 需从 `../../utils/api.js` import。若 `report-detail.vue` 当前未 import,加到 import 行。 - [ ] **步骤 4:模板添加悬浮按钮 + 浮层** 在 `