|
@@ -0,0 +1,220 @@
|
|
|
|
|
+# 报告快速分析扩展:上传后即可用
|
|
|
|
|
+
|
|
|
|
|
+日期:2026-09-13
|
|
|
|
|
+状态:设计中
|
|
|
|
|
+
|
|
|
|
|
+## 1. 背景与目标
|
|
|
|
|
+
|
|
|
|
|
+当前「报告快速分析」功能(`/api/ai/report/analyze`)仅在 `report-detail.vue`(view 模式)可用。用户上传报告后:
|
|
|
|
|
+- 菌群报告 → 跳转 `gut-flora-detail.vue`(无快速分析入口)
|
|
|
|
|
+- 两阶段入库 → 停留 `report-confirm.vue`(无快速分析入口)
|
|
|
|
|
+
|
|
|
|
|
+**目标**:在 `gut-flora-detail.vue` 和 `report-confirm.vue` 也提供「快速分析」入口,支持已入库的 `reportId` 和未入库的 `draftId`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 2. 架构设计
|
|
|
|
|
+
|
|
|
|
|
+### 2.1 后端扩展(cfc-backend)
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+POST /api/ai/report/analyze
|
|
|
|
|
+Request: { query, reportId?, draftId?, conversationId?, memberId? }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- 端点同时接受 `reportId` 和 `draftId`(至少一个非空)
|
|
|
|
|
+- 后端判断:优先用 `reportId`(已入库,完整上下文),其次用 `draftId`(草稿,读 payloadJson)
|
|
|
|
|
+- 免费版 Redis 计数、会员限制、fail-open 逻辑保持不变
|
|
|
|
|
+- LangGraph `chat_graph` 零改动
|
|
|
|
|
+
|
|
|
|
|
+### 2.2 数据流
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+前端页面(gut-flora-detail / report-confirm)
|
|
|
|
|
+ │ POST /api/ai/report/analyze { query, reportId/draftId }
|
|
|
|
|
+ ▼
|
|
|
|
|
+AIChatController.reportAnalyze()
|
|
|
|
|
+ ├─ 1. 会员级别检查(user.memberLevel)
|
|
|
|
|
+ ├─ 2. FREE → Redis 计数(fail-open)
|
|
|
|
|
+ ├─ 3. buildChatInputs(userId, params)
|
|
|
|
|
+ │ ├─ 有 reportId → familyContextService.buildContext(userId, reportId)
|
|
|
|
|
+ │ └─ 只有 draftId → familyContextService.buildContextFromDraft(userId, draftId) [新增]
|
|
|
|
|
+ └─ 4. aiService.sendMessage() → LangGraph chat_graph
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2.3 前端扩展(cfc-frontend)
|
|
|
|
|
+
|
|
|
|
|
+| 页面 | 入口条件 | 传参 |
|
|
|
|
|
+|------|---------|------|
|
|
|
|
|
+| `report-detail.vue` | view 模式 + reportId | reportId(已有) |
|
|
|
|
|
+| `gut-flora-detail.vue` | (!isEditing && !isDraftMode && reportId) \|\| (isDraftMode && draftId) | reportId 或 draftId |
|
|
|
|
|
+| `report-confirm.vue` | !parsing && draftId | draftId |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 3. 改动清单
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 后端(cfc-backend)
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 改动 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `FamilyContextService.java` | 新增 `buildContextFromDraft(Long userId, Long draftId)`,注入 `HealthReportDraftService`,查草稿 → 解析 `payloadJson` → 返回含 `reportDetail` 的上下文 |
|
|
|
|
|
+| `AIChatController.java` | 1. `buildChatInputs`:检测 `params.get("draftId")`,调用新方法<br>2. `reportAnalyze`:校验改为 `reportId || draftId`,空则报错 |
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 前端(cfc-frontend)
|
|
|
|
|
+
|
|
|
|
|
+| 文件 | 改动 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| `utils/api.js` | `reportQuickAnalyze` 新增可选参数 `draftId` |
|
|
|
|
|
+| `pages/health/gut-flora-detail.vue` | 1. import 新增 `reportQuickAnalyze`、`getMyMembership`<br>2. data 新增 `quickAnalyze` 对象<br>3. computed 新增 `quickQuestions`<br>4. methods 新增 `openQuickAnalyze`、`closeQuickAnalyze`、`sendQuickQuestion`、`sendQuickAnalyze`<br>5. template:FAB 区域(第 229 行附近)加「🎯 快速分析」按钮 + 浮层结构<br>6. style:复用 report-detail.vue 样式(`.quick-analyze-*` 类) |
|
|
|
|
|
+| `pages/health/report-confirm.vue` | 同 gut-flora-detail.vue,但入口条件为 `!parsing && draftId` |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 4. 关键实现细节
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 FamilyContextService.buildContextFromDraft
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+/**
|
|
|
|
|
+ * 基于草稿 ID 构建上下文(用于快速分析)
|
|
|
|
|
+ * @param userId 当前用户
|
|
|
|
|
+ * @param draftId 草稿 ID
|
|
|
|
|
+ * @return 上下文 Map,包含 reportDetail(从 payloadJson 还原)
|
|
|
|
|
+ */
|
|
|
|
|
+public Map<String, Object> buildContextFromDraft(Long userId, Long draftId) {
|
|
|
|
|
+ Map<String, Object> ctx = buildContext(userId);
|
|
|
|
|
+
|
|
|
|
|
+ if (draftId == null) return ctx;
|
|
|
|
|
+
|
|
|
|
|
+ HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
|
|
|
|
|
+ if (draft == null || !draft.getUserId().equals(userId)) {
|
|
|
|
|
+ return ctx; // 权限不通过静默返回基础上下文
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 payloadJson
|
|
|
|
|
+ Map<String, Object> reportDetail = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (draft.getPayloadJson() != null && !draft.getPayloadJson().isEmpty()) {
|
|
|
|
|
+ reportDetail = objectMapper.readValue(draft.getPayloadJson(), Map.class);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析草稿 payload 失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (reportDetail != null) {
|
|
|
|
|
+ ctx.put("reportDetail", reportDetail);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ctx;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 AIChatController.buildChatInputs 修改
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+private Map<String, Object> buildChatInputs(Long userId, Map<String, String> params) {
|
|
|
|
|
+ // ... 现有 mascot、memberId、surveyId、selfCheckId 逻辑不变 ...
|
|
|
|
|
+
|
|
|
|
|
+ Long reportId = null;
|
|
|
|
|
+ Long draftId = null;
|
|
|
|
|
+ if (reportIdStr != null && !reportIdStr.trim().isEmpty()) {
|
|
|
|
|
+ reportId = Long.valueOf(reportIdStr);
|
|
|
|
|
+ }
|
|
|
|
|
+ String draftIdStr = params.get("draftId");
|
|
|
|
|
+ if (draftIdStr != null && !draftIdStr.trim().isEmpty()) {
|
|
|
|
|
+ draftId = Long.valueOf(draftIdStr);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> inputs;
|
|
|
|
|
+ if (reportId != null) {
|
|
|
|
|
+ inputs = familyContextService.buildContext(userId, reportId);
|
|
|
|
|
+ } else if (draftId != null) {
|
|
|
|
|
+ inputs = familyContextService.buildContextFromDraft(userId, draftId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ inputs = familyContextService.buildContext(userId);
|
|
|
|
|
+ }
|
|
|
|
|
+ // ... mascot、portrait、selfCheck、memory 后续逻辑不变 ...
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.3 AIChatController.reportAnalyze 校验修改
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+String reportIdStr = params.get("reportId");
|
|
|
|
|
+String draftIdStr = params.get("draftId");
|
|
|
|
|
+if ((reportIdStr == null || reportIdStr.trim().isEmpty())
|
|
|
|
|
+ && (draftIdStr == null || draftIdStr.trim().isEmpty())) {
|
|
|
|
|
+ return Result.error("报告ID或草稿ID不能为空");
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.4 api.js 扩展
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+// 报告快速分析(免费版每日1次,付费版多轮)
|
|
|
|
|
+export const reportQuickAnalyze = (query, reportId, conversationId, draftId) => {
|
|
|
|
|
+ var data = { query: query }
|
|
|
|
|
+ if (reportId) data.reportId = reportId
|
|
|
|
|
+ if (draftId) data.draftId = draftId
|
|
|
|
|
+ if (conversationId) data.conversationId = conversationId
|
|
|
|
|
+ return request('/api/ai/report/analyze', 'POST', data)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.5 gut-flora-detail.vue 入口条件
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<!-- 仅查看模式:reportId 或 draftId 存在时 -->
|
|
|
|
|
+<view class="quick-analyze-fab" v-if="!isEditing && (reportId || draftId)" @tap="openQuickAnalyze">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4.6 report-confirm.vue 入口条件
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<!-- 解析完成、非解析中 -->
|
|
|
|
|
+<view class="quick-analyze-fab" v-if="!parsing && draftId" @tap="openQuickAnalyze">
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 5. 会员限制与免费版策略
|
|
|
|
|
+
|
|
|
|
|
+- 完全复用现有逻辑:`user.memberLevel` 判定 FREE/FAMILY/PROVIDER
|
|
|
|
|
+- FREE:每日 1 次(Redis 计数 key 唯一,`report:analyze:free:userId:date`,无区分 reportId/draftId)
|
|
|
|
|
+- FREE:前端不传 `conversationId` → LangGraph 每次新建会话 → 锁死多轮
|
|
|
|
|
+- 付费版:可传 `conversationId` 实现多轮
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 6. 错误处理
|
|
|
|
|
+
|
|
|
|
|
+| 场景 | 行为 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| query 为空 | `Result.error("消息不能为空")` |
|
|
|
|
|
+| reportId 和 draftId 均空 | `Result.error("报告ID或草稿ID不能为空")` |
|
|
|
|
|
+| 草稿不存在/无权限 | 静默降级,仅返回基础上下文(AI 仅做通用回答) |
|
|
|
|
|
+| 免费版超限 | `Result.error("免费版每日仅限 1 次快速分析,升级会员解锁更多")` |
|
|
|
|
|
+| Redis 异常 | fail-open 放行 + warn 日志 |
|
|
|
|
|
+| AI 服务不可用 | `Result.error("AI 服务暂不可用,请稍后重试")` |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 7. 验收标准
|
|
|
|
|
+
|
|
|
|
|
+| 场景 | 预期 |
|
|
|
|
|
+|------|------|
|
|
|
|
|
+| gut-flora-detail(reportId)点击快速分析 | 正常返回 answer |
|
|
|
|
|
+| gut-flora-detail(draftId)点击快速分析 | 正常返回 answer(基于草稿 payload) |
|
|
|
|
|
+| report-confirm(draftId)点击快速分析 | 正常返回 answer |
|
|
|
|
|
+| 免费版第 2 次(任意页面) | 返回超限错误 |
|
|
|
|
|
+| 付费版多轮追问 | 带 conversationId,AI 记得上文 |
|
|
|
|
|
+| Redis 停掉 | 免费版仍可用(fail-open) |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 8. 风险与注意事项
|
|
|
|
|
+
|
|
|
|
|
+1. **草稿 payload 结构**:`payloadJson` 是 `ParsedReportPayload.Payload` 的 JSON,结构与 `getReportDetail` 返回的 `reportDetail` 兼容(含 summary/indicators/gutFlora/diseaseRisks 等)
|
|
|
|
|
+2. **权限校验**:草稿属于上传用户,`getDraftById` 后需校验 `draft.getUserId().equals(userId)`
|
|
|
|
|
+3. **Redis 计数 key**:复用现有 key,同一用户同一天无论 reportId 还是 draftId 共用 1 次额度
|
|
|
|
|
+4. **样式复用**:直接复制 report-detail.vue 的 `.quick-analyze-*` CSS 类名,避免重复维护
|