Răsfoiți Sursa

fix(health): 修复健康自检问卷→方案生成链路 5 处缺陷

- A 成员数据从未传入 LangGraph:后端改发 member_ids/family_id(snake_case),
  LangGraph HealthPlanRequest/RegenerateRequest 加 camelCase 别名容错(AliasChoices)
- B 方案「确认保存」不可达:three sections 仅 nutrition 可确认,改为逐板块推进,
  全部确认后退出编辑显示「确认保存」
- C 多成员方案前端未适配:用 common(+individual 追加)填充三大板块,
  并修正模板中未定义的 secTitles/secIcons(应为 sectionTitles/sectionIcons)
- D 等级重复:9.9 元/月健康会员统一复用既有 BASIC「体验会员」,
  迁移 366 改为清理误建的 LITE 等级并迁移存量记录(三段独立 try)
- E 自适应问卷答案未进 Prompt:_format_health_status 输出月经周期/特殊时期/
  生长发育/挑食/骨质疏松/跌倒/屏幕时间/情绪等性别年龄专属项

验证:mvn -q clean compile EXIT=0;node --check 通过;py_compile 通过;
audit-duplicate-api-calls 0 error/104 warning
Sisyphus 1 săptămână în urmă
părinte
comite
7cf97d892c

+ 16 - 5
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3042,13 +3042,24 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
         ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
 
-        // 迁移366: 新增 LITE 轻享会员等级(9.9元/月,解锁健康自检问卷与个性化方案)
+        // 迁移366: 9.9 元/月健康会员复用既有 BASIC「体验会员」等级(迁移360/schema.sql 已创建),
+        // 移除早期误建的重复等级 LITE(与 BASIC 同为 9.9 元/月),并迁移可能已落库的 LITE 记录。
+        // 三段独立 try,确保任一段异常都不会阻断后续的等级删除。
         try {
-            jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
-                    "('LITE', '轻享会员', '9.9元/月,解锁健康自检问卷与个性化方案', 990, 2970, 11880, 2, 10, 1, 0, 0)");
-            log.info("迁移366: 已插入 LITE 轻享会员等级");
+            jdbcTemplate.update("UPDATE family_memberships SET level_code = 'BASIC' WHERE level_code = 'LITE'");
+        } catch (Exception e) {
+            log.warn("迁移366: 迁移 family_memberships 中的 LITE 记录失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.update("UPDATE users SET member_level = 'BASIC' WHERE member_level = 'LITE'");
+        } catch (Exception e) {
+            log.warn("迁移366: 迁移 users.member_level 中的 LITE 记录失败: {}", e.getMessage());
+        }
+        try {
+            int removed = jdbcTemplate.update("DELETE FROM membership_levels WHERE level_code = 'LITE'");
+            log.info("迁移366: 9.9 元/月健康会员统一使用 BASIC 体验会员,已移除重复的 LITE 等级({} 行)", removed);
         } catch (Exception e) {
-            log.warn("迁移366: 插入 LITE 会员等级失败或已存在: {}", e.getMessage());
+            log.warn("迁移366: 清理 LITE 等级失败或不存在: {}", e.getMessage());
         }
 
         // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -229,8 +229,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         try {
             RestTemplate restTemplate = new RestTemplate();
             Map<String, Object> body = new HashMap<>();
-            body.put("familyId", familyId);
-            body.put("memberIds", memberIds);
+            body.put("family_id", familyId);
+            body.put("member_ids", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
             String url = langgraphBaseUrl + "/api/v1/health/plan/generate";
@@ -1114,8 +1114,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         try {
             RestTemplate restTemplate = new RestTemplate();
             Map<String, Object> body = new HashMap<>();
-            body.put("familyId", familyId);
-            body.put("memberIds", memberIds);
+            body.put("family_id", familyId);
+            body.put("member_ids", memberIds);
             body.put("dimensions", dimensions);
             body.put("goal", goal);
             String url = langgraphBaseUrl + "/api/v1/health/plan/generate";

+ 59 - 21
cfc-frontend/pages/health/health-plan-summary.vue

@@ -93,9 +93,9 @@
         <view class="section-title" style="margin-top:20rpx">方案结构</view>
         <view class="section-preview-list">
           <view v-for="(sec, i) in sectionKeys" :key="i" class="section-preview-item">
-            <view class="sec-icon">{{ secIcons[i] }}</view>
+            <view class="sec-icon">{{ sectionIcons[i] }}</view>
             <view class="sec-info">
-              <text class="sec-title">{{ secTitles[i] }}</text>
+              <text class="sec-title">{{ sectionTitles[i] }}</text>
               <text class="sec-status" :class="{ done: sections[sec].confirmed }">{{ sections[sec].confirmed ? '已确认' : '待确认' }}</text>
             </view>
           </view>
@@ -137,7 +137,7 @@
       <view class="hint-text">所有板块已确认,点击保存生成最终方案</view>
       <view class="final-review">
         <view v-for="(sec, i) in sectionKeys" :key="i" class="review-item">
-          <text class="review-title">{{ secTitles[i] }}</text>
+          <text class="review-title">{{ sectionTitles[i] }}</text>
           <text class="review-preview">{{ sections[sec].content.substring(0, 80) }}{{ sections[sec].content.length > 80 ? '...' : '' }}</text>
         </view>
       </view>
@@ -212,12 +212,14 @@ export default {
       // 多人方案结构化数据(common/individual),保存时原样写入 planJson
       multiMemberPlanJson: null,
       sections: {
-        nutrition: { content: '', confirmed: false },
-        diet: { content: '', confirmed: false },
-        exercise: { content: '', confirmed: false }
+        nutrition: { content: '', confirmed: false, items: [], tasks: [] },
+        diet: { content: '', confirmed: false, items: [], tasks: [] },
+        exercise: { content: '', confirmed: false, items: [], tasks: [] }
       },
       sectionFeedback: '',
       editing: false,
+      // 当前正在编辑的板块下标(0=nutrition,1=diet,2=exercise),逐板块确认时递增
+      editIndex: 0,
       // 查看模式
       viewMode: false,
       planDetail: null,
@@ -248,16 +250,13 @@ export default {
       '包含有氧运动、力量训练等运动计划'
     ]},
     currentSectionKey: function() {
-      var idx = this.step - 3
-      return this.sectionKeys[idx] || ''
+      return this.sectionKeys[this.editIndex] || ''
     },
     currentSectionTitle: function() {
-      var idx = this.step - 3
-      return this.sectionTitles[idx] || ''
+      return this.sectionTitles[this.editIndex] || ''
     },
     currentSectionHint: function() {
-      var idx = this.step - 3
-      return this.sectionHints[idx] || ''
+      return this.sectionHints[this.editIndex] || ''
     },
 selectedMembers: function() {
       var self = this
@@ -689,7 +688,32 @@ selectedMembers: function() {
           var data = res.data
           // 多人方案:后端返回 {planContent, common, individual} 结构化对象
           if (data && typeof data === 'object' && data.common && data.individual) {
-            self.planData = { overview: data.planContent || '' }
+            // 用 common 填充三大板块;再把每位成员的 individual 内容追加到同名板块后
+            var commonPart = data.common || {}
+            var commonSections = (commonPart.sections instanceof Array) ? commonPart.sections : []
+            commonSections.forEach(function(sec) {
+              if (sec && sec.key && self.sections[sec.key]) {
+                self.sections[sec.key].content = sec.content || ''
+                self.sections[sec.key].items = sec.items || []
+                self.sections[sec.key].tasks = sec.tasks || []
+              }
+            })
+            var individualPart = data.individual || {}
+            Object.keys(individualPart).forEach(function(mid) {
+              var part = individualPart[mid] || {}
+              var secs = (part.sections instanceof Array) ? part.sections : []
+              secs.forEach(function(sec) {
+                if (sec && sec.key && self.sections[sec.key]) {
+                  var block = '### 成员 ' + mid + '\n\n' + (sec.content || '')
+                  var prev = self.sections[sec.key].content
+                  self.sections[sec.key].content = prev ? (prev + '\n\n' + block) : block
+                  if (sec.tasks && sec.tasks.length) {
+                    self.sections[sec.key].tasks = (self.sections[sec.key].tasks || []).concat(sec.tasks)
+                  }
+                }
+              })
+            })
+            self.planData = { overview: commonPart.overview || ((commonSections.length || Object.keys(individualPart).length) ? '' : (data.planContent || '')) }
             self.multiMemberPlanJson = { common: data.common, individual: data.individual }
             self.step = 3
           } else if (typeof data === 'string') {
@@ -735,13 +759,13 @@ selectedMembers: function() {
         var level = m.memberLevel || m.levelCode || ''
         if (level && level !== 'FREE') return
         uni.showModal({
-          title: '开通 9.9 元/月健康会员',
-          content: '填写健康自检问卷需先开通月度健康会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
+          title: '开通 9.9 元/月体验会员',
+          content: '填写健康自检问卷需先开通体验会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
           confirmText: '去开通',
           cancelText: '暂不',
           success: function(r) {
             if (r.confirm) {
-              uni.navigateTo({ url: '/pages/membership/pay?levelCode=LITE&period=monthly' })
+              uni.navigateTo({ url: '/pages/membership/pay?levelCode=BASIC&period=monthly' })
             } else {
               uni.navigateBack({ delta: 1 })
             }
@@ -801,15 +825,29 @@ selectedMembers: function() {
     },
     confirmCurrentSection: function() {
       var self = this
-      self.sections[self.currentSectionKey].confirmed = true
-      self.editing = false
+      var key = self.currentSectionKey
+      if (!key || !self.sections[key]) return
+      self.sections[key].confirmed = true
       self.sectionFeedback = ''
-      // 检查是否全部确认
-      if (self.allConfirmed) {
-        // 保持在 step 3,显示最终确认区域
+      // 定位下一个尚未确认的板块;有则继续编辑,全部确认后退出编辑显示「确认保存」
+      var nextIdx = -1
+      for (var i = 0; i < self.sectionKeys.length; i++) {
+        if (!self.sections[self.sectionKeys[i]].confirmed) { nextIdx = i; break }
+      }
+      if (nextIdx >= 0) {
+        self.editIndex = nextIdx
+        self.editing = true
+      } else {
+        self.editing = false
       }
     },
     startEdit: function() {
+      // 进入编辑:定位第一个未确认的板块(全部已确认时从第一个开始,便于重改)
+      var idx = 0
+      for (var i = 0; i < this.sectionKeys.length; i++) {
+        if (!this.sections[this.sectionKeys[i]].confirmed) { idx = i; break }
+      }
+      this.editIndex = idx
       this.editing = true
       this.sectionFeedback = ''
     },

+ 25 - 5
cfc-langgraph/app/api/adapter.py

@@ -1,5 +1,5 @@
 from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel, ConfigDict
+from pydantic import BaseModel, ConfigDict, AliasChoices, Field
 from typing import Optional, List, Dict, Any
 from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
@@ -547,20 +547,23 @@ async def health_coach_generate(req: HealthCoachGenerateRequest):
 # ===== 健康方案生成(结构化 JSON)=====
 
 class HealthPlanRequest(BaseModel):
-    member_ids: Optional[str] = None
+    # 兼容 camelCase(Java 侧历史写法)与 snake_case(本服务规范)两种入参键名
+    model_config = ConfigDict(populate_by_name=True)
+    member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
     dimensions: Optional[str] = None
     goal: str = ""
-    family_id: Optional[int] = None
+    family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
 
 
 class HealthPlanRegenerateRequest(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
     section: str  # nutrition | diet | exercise
     feedback: str = ""
     existing_section_content: str = ""
-    member_ids: Optional[str] = None
+    member_ids: Optional[str] = Field(default=None, validation_alias=AliasChoices("member_ids", "memberIds"))
     dimensions: Optional[str] = None
     goal: str = ""
-    family_id: Optional[int] = None
+    family_id: Optional[int] = Field(default=None, validation_alias=AliasChoices("family_id", "familyId"))
 
 
 TEXT_PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
@@ -800,6 +803,23 @@ def _format_health_status(hs: dict) -> str:
                 if ls.get("allergySymptoms") and ls["allergySymptoms"]:
                     sym = ", ".join(ls["allergySymptoms"]) if isinstance(ls["allergySymptoms"], list) else str(ls["allergySymptoms"])
                     parts.append(f"过敏表现:{sym}")
+                # 按性别/年龄动态采集的专属项
+                if ls.get("menstrualCycle"):
+                    parts.append(f"月经周期:{ls['menstrualCycle']}")
+                if ls.get("specialPeriod"):
+                    parts.append(f"特殊时期:{ls['specialPeriod']}")
+                if ls.get("growthDevelopment"):
+                    parts.append(f"生长发育:{ls['growthDevelopment']}")
+                if ls.get("pickyEating"):
+                    parts.append(f"挑食情况:{ls['pickyEating']}")
+                if ls.get("osteoporosisRisk"):
+                    parts.append(f"骨质疏松风险:{ls['osteoporosisRisk']}")
+                if ls.get("fallRisk"):
+                    parts.append(f"跌倒风险:{ls['fallRisk']}")
+                if ls.get("screenTime"):
+                    parts.append(f"屏幕时间:{ls['screenTime']}")
+                if ls.get("teenMood"):
+                    parts.append(f"情绪状态:{ls['teenMood']}")
             if parts:
                 out.append("生活方式:" + "; ".join(parts))
         except Exception: