2026-09-10-report-quick-analyze.md 25 KB

报告快速分析功能实现计划

面向 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<String, Object> 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

<dependency> 块(spring-boot-starter-validation 之后,约 90 行)插入:

        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
  • 步骤 2:application.yml 添加 Redis 配置

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
  • 步骤 3:创建 RedisConfig.java

创建 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;
    }
}
  • 步骤 4:编译验证

运行: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 配置(报告快速分析计数用)"
    

任务 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 行):

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 行)之后添加:

    @Resource
    private RedisTemplate<String, Object> redisTemplate;
  • 步骤 3:抽出 buildChatInputs 私有方法

现有 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);
  • 步骤 4:新增 /report/analyze 端点

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);
    }
  • 步骤 5:编译验证

运行:cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true 预期:BUILD SUCCESS(无输出即成功) 注意:若 User.getMemberLevel() 不存在,检查 entity/User.java 是否有 memberLevel 字段(AGENTS.md 显示 User.java:136private 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次+付费版多轮)"
    

任务 3:前端 api.js 新增 reportQuickAnalyze 封装

文件:

  • 修改:cfc-frontend/utils/api.js:1426aiDeleteConversation 之后)

  • [ ] 步骤 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)
}
  • 步骤 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

    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

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    // 免费版今日已用(前端预判,最终以后端为准)
      }
  • 步骤 2:新增快捷问题数组(computed 或 data 常量)

computed 中添加(free 版 3 个预设问题):

    quickQuestions: function() {
      return [
        '这份报告最需要关注哪些异常指标?',
        '报告中提示了哪些健康风险?',
        '有哪些改善建议?'
      ]
    }
  • 步骤 3:新增浮层 methods

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.js import。若 report-detail.vue 当前未 import,加到 import 行。

  • 步骤 4:模板添加悬浮按钮 + 浮层

<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>
  • 步骤 5:添加样式

<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; }
  • 步骤 6:语法校验

运行: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): 报告详情页新增快速分析浮层(免费版单次/付费版多轮)"
    

任务 5:验证

文件:

  • 验证:cfc-backend 编译 + 手动验证清单

  • [ ] 步骤 1:后端全量编译

运行:cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true 预期:BUILD SUCCESS(无输出即成功)

  • 步骤 2:手动验证清单
场景 预期
免费版第 1 次提问 返回 answer
免费版第 2 次提问 返回「免费版每日仅限 1 次快速分析,升级会员解锁更多」
免费版次日 计数重置(Redis TTL 24h)
付费版多轮 追问带 conversationId,AI 记得上文
报告上下文注入 回答体现报告内容(指标/菌群)
Redis 停掉 免费版仍可用(fail-open),后端日志记 warn
  • [ ] 步骤 3:最终 Commit(若有无需变更则跳过)

    git status --short
    # 确认无遗漏
    

自检结果

规格覆盖度检查:

  • ✅ 会员限制(FREE 每日 1 次 Redis 计数)→ 任务 2 步骤 4
  • ✅ 付费版多轮(conversationId 回传)→ 任务 2 步骤 4 + 任务 4 步骤 3
  • ✅ 免费版锁死多轮(remove conversationId)→ 任务 2 步骤 4
  • ✅ Redis 依赖 + 配置 → 任务 1
  • ✅ 复用 buildChatInputs → 任务 2 步骤 3
  • ✅ 前端浮层(按钮/输入框/快捷问题/加载态/禁用态)→ 任务 4
  • ✅ api.js 封装 → 任务 3
  • ✅ Redis fail-open 降级 → 任务 2 步骤 4
  • ✅ 错误处理(空 query/空 reportId/超限/AI 不可用)→ 任务 2 步骤 4

占位符扫描: 无 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),一致