面向 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
| 文件 | 操作 | 职责 |
|---|---|---|
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<String, Object> bean,String 序列化 |
src/main/java/com/etotem/cfc/controller/ai/AIChatController.java |
修改 | 抽出 buildChatInputs() 私有方法 + 新增 POST /report/analyze 端点(会员检查 + Redis 计数 + 调 aiService.sendMessage) |
| 文件 | 操作 | 职责 |
|---|---|---|
utils/api.js |
修改 | 新增 reportQuickAnalyze(query, reportId, conversationId) 封装 |
pages/health/report-detail.vue |
修改 | 底部悬浮按钮 + 浮层(消息区/输入框/3 个快捷问题/加载态/免费版禁用态) |
文件:
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
在 <dependency> 块(spring-boot-starter-validation 之后,约 90 行)插入:
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
在 spring: 配置节点下添加(保持 YAML 缩进与现有 datasource 平级):
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
创建 cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.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<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> 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;
}
}
运行:cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true
预期:BUILD SUCCESS(无输出即成功)
[ ] 步骤 5:Commit
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 配置(报告快速分析计数用)"
文件:
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。
在 AIChatController.java 头部 import 区添加(现有 import 在 10-27 行):
import org.springframework.data.redis.core.RedisTemplate;
import java.time.LocalDate;
import java.time.Duration;
在 private com.fasterxml.jackson.databind.ObjectMapper objectMapper;(约 72 行)之后添加:
@Resource
private RedisTemplate<String, Object> redisTemplate;
现有 sendMessage 内 93-155 行(从 // 获取用户mascot设置 到 inputs = aiService.enrichInputsWithMemory(...))是上下文组装逻辑。抽出为新私有方法(保留原有 sendMessage 调用它):
/**
* 组装 AI 对话上下文(mascot + 家庭/报告 + 画像 + 自检 + 记忆层)
* 供 /chat/send 和 /report/analyze 共用
*/
private Map<String, Object> buildChatInputs(Long userId, Map<String, String> 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<String, Object> 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<String, Object> 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 行替换为:
// 复用上下文组装(mascot + 家庭/报告 + 画像 + 自检 + 记忆层)
Map<String, Object> inputs = buildChatInputs(userId, params);
在 sendMessage 方法结束后(} 之后)添加:
@Operation(summary = "报告快速分析(免费版每日1次,付费版多轮)")
@PostMapping("/report/analyze")
public Result<Map<String, Object>> reportAnalyze(
@RequestAttribute("userId") Long userId,
@RequestBody Map<String, String> 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<String, Object> inputs = buildChatInputs(userId, params);
// 4. 调 LangGraph(经 aiService.sendMessage 含记忆层+镜像)
Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("answer", cleanAnswer);
result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
return Result.success(result);
}
运行: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
git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
git commit -m "feat: 新增报告快速分析端点 /api/ai/report/analyze(免费版Redis每日1次+付费版多轮)"
文件:
修改:cfc-frontend/utils/api.js:1426(aiDeleteConversation 之后)
[ ] 步骤 1:新增封装函数
在 aiDeleteConversation 函数后添加:
// 报告快速分析(免费版每日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)
}
运行: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
git add cfc-frontend/utils/api.js
git commit -m "feat(frontend): api.js 新增 reportQuickAnalyze 封装"
文件:
cfc-frontend/pages/health/report-detail.vue背景: report-detail.vue 共 2162+ 行(大文件),本次只做增量添加,不重构。按钮显示在 view 模式(pageMode==='view')且 reportId 存在时。浮层为固定定位弹层,含消息区、输入框、3 个快捷问题。
import 行(394 行)添加 reportQuickAnalyze:
import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge, reportQuickAnalyze } from '../../utils/api.js'
data() 内 groupExpandList: [] 附近添加:
// 报告快速分析浮层
quickAnalyze: {
show: false,
sending: false,
messages: [], // [{role:'user'|'ai', content}]
inputText: '',
conversationId: '',
usedToday: false // 免费版今日已用(前端预判,最终以后端为准)
}
在 computed 中添加(free 版 3 个预设问题):
quickQuestions: function() {
return [
'这份报告最需要关注哪些异常指标?',
'报告中提示了哪些健康风险?',
'有哪些改善建议?'
]
}
在 methods 中(loadData 附近)添加:
// ===== 报告快速分析 =====
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.jsimport。若report-detail.vue当前未 import,加到 import 行。
在 <template> 的 </scroll-view> 之后、</view>(根容器)之前插入:
<!-- 报告快速分析入口(仅查看模式) -->
<view class="quick-analyze-fab" v-if="pageMode==='view' && reportId" @tap="openQuickAnalyze">
<text class="quick-analyze-fab-icon">🎯</text>
<text class="quick-analyze-fab-text">快速分析</text>
</view>
<!-- 快速分析浮层 -->
<view class="quick-analyze-overlay" v-if="quickAnalyze.show" @tap="closeQuickAnalyze"></view>
<view class="quick-analyze-panel" v-if="quickAnalyze.show">
<view class="quick-analyze-header">
<text class="quick-analyze-title">报告快速分析</text>
<text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
</view>
<view class="quick-analyze-hint" v-if="quickAnalyze.usedToday">免费版今日次数已用完,升级会员解锁更多分析</view>
<scroll-view class="quick-analyze-messages" scroll-y>
<view class="quick-analyze-message" v-for="(msg, qidx) in quickAnalyze.messages" :key="qidx" :class="'msg-' + msg.role">
<text class="quick-analyze-msg-text">{{ msg.content }}</text>
</view>
<view class="quick-analyze-loading" v-if="quickAnalyze.sending">
<text class="quick-analyze-loading-text">AI 正在分析中...</text>
</view>
</scroll-view>
<view class="quick-analyze-chips" v-if="quickAnalyze.messages.length === 0">
<view class="quick-analyze-chip" v-for="(q, qi) in quickQuestions" :key="qi" @tap="sendQuickQuestion(q)">
<text class="quick-analyze-chip-text">{{ q }}</text>
</view>
</view>
<view class="quick-analyze-input-bar">
<input class="quick-analyze-input" v-model="quickAnalyze.inputText" placeholder="输入你想问的问题..."
:disabled="quickAnalyze.sending || quickAnalyze.usedToday" confirm-type="send" @confirm="sendQuickAnalyze" />
<view class="quick-analyze-send" :class="{ 'send-disabled': quickAnalyze.sending || quickAnalyze.usedToday }" @tap="sendQuickAnalyze">
<text class="quick-analyze-send-text">发送</text>
</view>
</view>
</view>
在 <style scoped> 末尾(.bottom-spacer 样式之后)添加:
/* ===== 报告快速分析 ===== */
.quick-analyze-fab {
position: fixed;
right: 30rpx;
bottom: 60rpx;
display: flex;
flex-direction: row;
align-items: center;
background: linear-gradient(135deg, #5B9BD5, #3B82F6);
border-radius: 44rpx;
padding: 18rpx 30rpx;
box-shadow: 0 4rpx 16rpx rgba(59, 130, 246, 0.4);
z-index: 90;
}
.quick-analyze-fab-icon { font-size: 32rpx; margin-right: 10rpx; }
.quick-analyze-fab-text { font-size: 28rpx; color: #fff; font-weight: 500; }
.quick-analyze-fab:active { opacity: 0.85; }
.quick-analyze-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 98;
}
.quick-analyze-panel {
position: fixed;
left: 30rpx; right: 30rpx;
bottom: 40rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
z-index: 99;
display: flex;
flex-direction: column;
max-height: 70vh;
}
.quick-analyze-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.quick-analyze-title { font-size: 30rpx; font-weight: bold; color: #333; }
.quick-analyze-close { font-size: 32rpx; color: #999; padding: 8rpx; }
.quick-analyze-hint {
font-size: 24rpx;
color: #E65100;
background: #FFF3E0;
border-radius: 12rpx;
padding: 12rpx 16rpx;
margin-bottom: 12rpx;
}
.quick-analyze-messages {
max-height: 50vh;
margin-bottom: 16rpx;
}
.quick-analyze-message {
margin-bottom: 12rpx;
display: flex;
flex-direction: column;
}
.quick-analyze-message.msg-user { align-items: flex-end; }
.quick-analyze-message.msg-ai { align-items: flex-start; }
.quick-analyze-msg-text {
font-size: 26rpx;
color: #333;
background: #F3F4F6;
border-radius: 14rpx;
padding: 14rpx 18rpx;
max-width: 80%;
line-height: 1.5;
}
.msg-user .quick-analyze-msg-text { background: #5B9BD5; color: #fff; }
.quick-analyze-loading { display: flex; justify-content: center; padding: 12rpx 0; }
.quick-analyze-loading-text { font-size: 24rpx; color: #999; }
.quick-analyze-chips { display: flex; flex-direction: column; margin-bottom: 16rpx; }
.quick-analyze-chip {
background: #EFF6FF;
border: 1rpx solid #BFDBFE;
border-radius: 12rpx;
padding: 14rpx 18rpx;
margin-bottom: 10rpx;
}
.quick-analyze-chip:active { opacity: 0.8; }
.quick-analyze-chip-text { font-size: 26rpx; color: #1D4ED8; }
.quick-analyze-input-bar {
display: flex;
flex-direction: row;
align-items: center;
}
.quick-analyze-input {
flex: 1;
height: 72rpx;
background: #F3F4F6;
border-radius: 36rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
margin-right: 16rpx;
}
.quick-analyze-send {
background: #5B9BD5;
border-radius: 36rpx;
padding: 16rpx 34rpx;
}
.quick-analyze-send.send-disabled { opacity: 0.5; }
.quick-analyze-send-text { font-size: 26rpx; color: #fff; }
运行:cd cfc-frontend && node -e "const fs=require('fs');const src=fs.readFileSync('pages/health/report-detail.vue','utf8');const m=src.match(/<script>([\s\S]*?)<\/script>/);new Function(m[1].replace(/import[\s\S]*?(?=\n)/g,'').replace(/export default/,'const x='));console.log('OK')"
预期:OK
[ ] 步骤 7:Commit
git add cfc-frontend/pages/health/report-detail.vue
git commit -m "feat(frontend): 报告详情页新增快速分析浮层(免费版单次/付费版多轮)"
文件:
验证:cfc-backend 编译 + 手动验证清单
[ ] 步骤 1:后端全量编译
运行:cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true
预期:BUILD SUCCESS(无输出即成功)
| 场景 | 预期 |
|---|---|
| 免费版第 1 次提问 | 返回 answer |
| 免费版第 2 次提问 | 返回「免费版每日仅限 1 次快速分析,升级会员解锁更多」 |
| 免费版次日 | 计数重置(Redis TTL 24h) |
| 付费版多轮 | 追问带 conversationId,AI 记得上文 |
| 报告上下文注入 | 回答体现报告内容(指标/菌群) |
| Redis 停掉 | 免费版仍可用(fail-open),后端日志记 warn |
[ ] 步骤 3:最终 Commit(若有无需变更则跳过)
git status --short
# 确认无遗漏
规格覆盖度检查:
占位符扫描: 无 TODO/待定/后续实现 等占位符。
类型一致性:
buildChatInputs(Long userId, Map<String, String> params) → 任务 2 步骤 3 定义,步骤 4 调用,签名一致reportQuickAnalyze(query, reportId, conversationId) → 任务 3 定义,任务 4 步骤 3 调用,签名一致quickAnalyze.usedToday → 任务 4 步骤 1 定义,步骤 3/4 使用,一致reportId → 任务 4 步骤 3 使用 this.reportId,data 已有(563 行 reportId: null),一致