瀏覽代碼

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

E2E Test Bot 2 天之前
父節點
當前提交
f310938a73
共有 100 個文件被更改,包括 8335 次插入 和 386 次删除
  1. 197 74
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 203 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/GutHealthProjectController.java
  4. 118 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java
  5. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java
  6. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java
  7. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  8. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java
  9. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  10. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java
  11. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java
  12. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMembership.java
  13. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GutHealthProject.java
  14. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java
  15. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java
  16. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java
  17. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java
  18. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  19. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SsoTicket.java
  20. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
  21. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GutHealthProjectMapper.java
  22. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java
  23. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java
  24. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java
  25. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SsoTicketMapper.java
  26. 196 1
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  27. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  28. 3 83
      cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java
  29. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  30. 865 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GutHealthProjectService.java
  31. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  32. 267 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java
  33. 275 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java
  34. 370 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java
  35. 29 1
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  36. 22 4
      cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java
  37. 3 37
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  38. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java
  39. 61 11
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  40. 16 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  41. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java
  42. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java
  43. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SsoService.java
  44. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  45. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java
  46. 9 1
      cfc-backend/src/main/resources/application.yml
  47. 104 26
      cfc-backend/src/main/resources/schema.sql
  48. 33 0
      cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java
  49. 68 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java
  50. 9 9
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ActivityControllerTest.java
  51. 57 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
  52. 93 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
  53. 100 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
  54. 30 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
  55. 86 0
      cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
  56. 92 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
  57. 67 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java
  58. 60 0
      cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
  59. 30 2
      cfc-frontend/App.vue
  60. 75 0
      cfc-frontend/__tests__/api/api.indicator.test.js
  61. 64 0
      cfc-frontend/__tests__/utils/indicator-change.test.js
  62. 193 21
      cfc-frontend/components/ContentSharePoster.vue
  63. 302 0
      cfc-frontend/components/TrendChart.vue
  64. 12 0
      cfc-frontend/pages.json
  65. 20 1
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  66. 136 5
      cfc-frontend/pages/growth/diet-checkin.vue
  67. 118 2
      cfc-frontend/pages/health/gut-flora-detail.vue
  68. 444 0
      cfc-frontend/pages/health/indicator-curve.vue
  69. 154 0
      cfc-frontend/pages/health/indicator-quicklook.vue
  70. 75 29
      cfc-frontend/pages/index-home/index.vue
  71. 35 0
      cfc-frontend/pages/profile/index.vue
  72. 20 0
      cfc-frontend/utils/api.js
  73. 64 0
      cfc-frontend/utils/indicator-change.js
  74. 34 0
      cfc-frontend/utils/sso.js
  75. 21 8
      cfc-langgraph/app/agents/report_parse_agent.py
  76. 64 0
      cfc-langgraph/app/api/indicator_classify.py
  77. 4 2
      cfc-langgraph/app/api/report_parse.py
  78. 2 1
      cfc-langgraph/app/main.py
  79. 27 37
      cfc-langgraph/deploy.sh
  80. 24 1
      cfc-langgraph/tests/test_report_parse.py
  81. 1 1
      cfc-web/.last_build_commit
  82. 2 2
      cfc-web/package-lock.json
  83. 1 1
      cfc-web/package.json
  84. 748 0
      cfc-web/public/CHANGELOG-v1.0.md
  85. 749 1
      cfc-web/public/CHANGELOG.md
  86. 79 0
      cfc-web/src/__tests__/api/indicator.test.js
  87. 57 0
      cfc-web/src/__tests__/utils/indicatorSeeds.test.js
  88. 25 0
      cfc-web/src/api/indicator.js
  89. 15 0
      cfc-web/src/router/index.js
  90. 86 0
      cfc-web/src/styles/admin-tokens.css
  91. 45 0
      cfc-web/src/utils/indicatorSeeds.js
  92. 3 1
      cfc-web/src/views/Children.vue
  93. 3 1
      cfc-web/src/views/Dashboard.vue
  94. 12 5
      cfc-web/src/views/Families.vue
  95. 8 4
      cfc-web/src/views/GuideFamilyTask.vue
  96. 5 2
      cfc-web/src/views/GuidePackages.vue
  97. 2 0
      cfc-web/src/views/Layout.vue
  98. 3 1
      cfc-web/src/views/OperationLogs.vue
  99. 3 1
      cfc-web/src/views/Points.vue
  100. 9 5
      cfc-web/src/views/Rewards.vue

+ 197 - 74
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2984,11 +2984,20 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         runSystemMigrations();
         registerCollectExpectedIndicators();
 
-        // 减脂专题表创建
+        // 减脂专题表创建(远程 fatloss 功能)
         createFatWeightRecordTable();
         createFatDietRecordTable();
         createFatExerciseRecordTable();
         createFatGoalTable();
+
+        // 指标统一层迁移(356~362)
+        migrateIndicatorDefinitionsColumns();
+        migrateIndicatorValuesColumns();
+        addIndicatorValuesKeys();
+        createIndicatorMappingRulesTable();
+        createIndicatorCorrelationsTable();
+        ensureIndicatorCollectionDate();
+        seedDimensionDefinitions();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -6044,7 +6053,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_levels (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/BASIC/PROFESSIONAL/ENTERPRISE', " +
+                    "level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE', " +
                     "level_name VARCHAR(100) NOT NULL COMMENT '等级名称', " +
                     "level_desc TEXT COMMENT '等级描述', " +
                     "price_monthly INT COMMENT '月费(分)', " +
@@ -8126,7 +8135,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS membership_benefits (" +
                     "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                    "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/BASIC/PROFESSIONAL/ENTERPRISE', " +
+                    "level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE', " +
                     "benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等', " +
                     "benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称', " +
                     "benefit_desc VARCHAR(500) COMMENT '权益描述', " +
@@ -10952,15 +10961,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         createTaskReminderConfigTable();
         addMissingColumnsToRecommendationLog();
         createDietFamilySelectedIngredientsTable();
-
-        // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
-        try {
-            jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
-                    "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
-            log.info("迁移341: 已为 BASIC 体验会员补充 referral_commission 功能");
-        } catch (Exception e) {
-            log.warn("迁移341: 更新 BASIC features 失败: {}", e.getMessage());
-        }
+        createSsoTicketTable();
 
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
@@ -11027,55 +11028,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移314: meal_configs 添加索引 idx_config_date 失败(可能已存在): {}", e.getMessage());
         }
 
-// 迁移315: membership_levels 新增 BASIC 体验会员等级(9.9元/月)
-        try {
-            jdbcTemplate.execute(
-                "INSERT IGNORE INTO membership_levels " +
-                "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " +
-                "VALUES ('BASIC', '体验会员', " +
-                "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " +
-                "990, " +
-                "'[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"assessment_quota_lite\",\"health_report_upload_lite\",\"growth_plan_lite\",\"task_template_lite\"]', " +
-                "3, 3)");
-            log.info("迁移315: 已初始化BASIC体验会员等级");
-        } catch (Exception e) {
-            // 等级已存在,忽略
-        }
-
-        // 迁移316: membership_benefits 新增 BASIC 会员权益
-        try {
-            jdbcTemplate.execute(
-                "INSERT IGNORE INTO membership_benefits " +
-                "(level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
-                "('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1), " +
-                "('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " +
-                "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " +
-                "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8)");
-            log.info("迁移316: 已初始化BASIC会员权益列表");
-        } catch (Exception e) {
-            // 已存在,忽略
-        }
-
-        // 迁移317: 创建 basic_quota_usage 表(BASIC会员月度配额使用记录)
-        try {
-            jdbcTemplate.execute(
-                "CREATE TABLE IF NOT EXISTS basic_quota_usage (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
-                "user_id BIGINT NOT NULL COMMENT '家长用户ID', " +
-                "quota_type VARCHAR(30) NOT NULL COMMENT '配额类型', " +
-                "used_count INT DEFAULT 0 COMMENT '已用次数', " +
-                "total_count INT NOT NULL COMMENT '月度总配额', " +
-                "period_start DATE NOT NULL COMMENT '周期开始日期', " +
-                "period_end DATE NOT NULL COMMENT '周期结束日期', " +
-                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
-                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
-                "UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start), " +
-                "INDEX idx_user_period (user_id, period_start) " +
-                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录'");
-            log.info("迁移317: 已创建basic_quota_usage表");
-        } catch (Exception e) {
-            // 表已存在,忽略
-        }
 
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" +
@@ -11201,15 +11153,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
 
         // 迁移321: 商品/分类可见性由「角色」改为「按会员级别」控制
-        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6
+        // - membership_levels.level_order: 级别数字(数字越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5
         // - products.min_visible_level: 最低可见会员级别编码(NULL=所有用户含未登录可见)
         // - shop_categories.min_visible_level: 分类最低可见会员级别编码(NULL=所有用户含未登录可见)
         ensureColumn("membership_levels", "level_order",
-                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6'");
+                "INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5'");
         try {
             jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
-                    "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
-                    "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
+                    "WHEN 'FREE' THEN 1 WHEN 'FAMILY' THEN 2 WHEN 'PROVIDER' THEN 3 " +
+                    "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END");
             log.info("迁移321: 已回填 membership_levels.level_order");
         } catch (Exception e) {
             log.warn("迁移321: 回填 membership_levels.level_order 失败: {}", e.getMessage());
@@ -11800,7 +11752,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void addProductMinDiscountLevel() {
         try {
             jdbcTemplate.execute("ALTER TABLE products ADD COLUMN min_discount_level VARCHAR(50) DEFAULT NULL " +
-                    "COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
+                    "COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)' AFTER min_visible_level");
             log.info("迁移350: 已添加 products.min_discount_level 列");
         } catch (Exception e) {
             // 列已存在,忽略
@@ -11813,20 +11765,20 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
     private void backfillMembershipLevelOrder() {
         try {
             int updated = jdbcTemplate.update("UPDATE membership_levels SET level_order = CASE level_code " +
-                    "WHEN 'FREE' THEN 1 WHEN 'BASIC' THEN 2 WHEN 'FAMILY' THEN 3 WHEN 'PROVIDER' THEN 4 " +
-                    "WHEN 'PREMIUM' THEN 5 WHEN 'LIFETIME' THEN 6 ELSE level_order END");
+                    "WHEN 'FREE' THEN 1 WHEN 'FAMILY' THEN 2 WHEN 'PROVIDER' THEN 3 " +
+                    "WHEN 'PREMIUM' THEN 4 WHEN 'LIFETIME' THEN 5 ELSE level_order END");
             log.info("迁移351: 已顺延 membership_levels.level_order({} 行),BASIC=2", updated);
         } catch (Exception e) {
             log.warn("迁移351: 顺延 membership_levels.level_order 失败: {}", e.getMessage());
         }
     }
 
-    // 迁移352: 健康报告上传改为对所有登录用户免费无限,移除 REPORT_UPLOAD 配额与 BASIC 权益文案
+    // 迁移352: 健康报告上传改为对所有登录用户免费无限
     private void removeReportUploadQuota() {
         try {
             int deletedBenefits = jdbcTemplate.update(
                     "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'REPORT_UPLOAD_LITE'");
-            log.info("迁移352: 已移除 BASIC 权益 REPORT_UPLOAD_LITE({} 行)", deletedBenefits);
+            log.info("迁移352: 已清理遗留权益记录({} 行)", deletedBenefits);
         } catch (Exception e) {
             log.warn("迁移352: 移除 REPORT_UPLOAD_LITE 权益失败: {}", e.getMessage());
         }
@@ -11859,6 +11811,29 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移359: 跨小程序单点登录一次性票据表(cfc ↔ train 双向免登)
+    // 详见 schema.sql。此处自愈建表,避免 schema.sql 建表语句因故静默失败导致登录报错。
+    private void createSsoTicketTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS sso_ticket (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)'," +
+                    "source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train'," +
+                    "target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train'," +
+                    "source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)'," +
+                    "cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据'," +
+                    "used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用'," +
+                    "expire_at DATETIME NOT NULL COMMENT '过期时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "UNIQUE KEY uk_ticket (ticket)," +
+                    "INDEX idx_expire (expire_at)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据'");
+            log.info("迁移359: 已创建 sso_ticket 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 sso_ticket 表失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移355: product_recommendation_log 补齐缺失列
     // 旧库该表由更早版本的建表语句创建(CREATE TABLE IF NOT EXISTS 不会补列),
     // 缺 family_id/was_clicked/was_purchased/clicked_at/purchased_at/updated_at,
@@ -12000,14 +11975,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    /** 写入类型「应采集指标项」定义(每次启动覆盖,保证与代码一致) */
-    private void seedExpectedIndicator(String typeId, String expectedJson) {
+    /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */
+    void seedExpectedIndicator(String typeId, String expectedJson) {
         try {
             int updated = jdbcTemplate.update(
-                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() WHERE type_id = ?",
+                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " +
+                    "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')",
                     expectedJson, typeId);
             if (updated == 0) {
-                log.warn("迁移354: 类型 {} 不存在,跳过指标项定义", typeId);
+                log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId);
             } else {
                 log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
             }
@@ -12055,6 +12031,153 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
+    private void migrateIndicatorDefinitionsColumns() {
+        ensureColumn("indicator_definitions", "source_kind",
+                "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
+        ensureColumn("indicator_definitions", "report_type_scope",
+                "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
+        ensureColumn("indicator_definitions", "value_type",
+                "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
+        ensureColumn("indicator_definitions", "good_direction",
+                "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
+        ensureColumn("indicator_definitions", "dimension_code",
+                "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
+        log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
+    }
+
+    // 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
+    private void migrateIndicatorValuesColumns() {
+        ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
+        ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
+        ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
+        ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
+        ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
+        ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
+        log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
+    }
+
+    // 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
+    private void addIndicatorValuesKeys() {
+        try {
+            // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
+            jdbcTemplate.execute(
+                    "DELETE v FROM indicator_values v " +
+                    "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
+                    "      FROM indicator_values " +
+                    "      GROUP BY source_type, source_id, definition_id " +
+                    "      HAVING COUNT(*) > 1) d " +
+                    "  ON v.source_type = d.source_type AND v.source_id = d.source_id " +
+                    " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
+            log.info("迁移358: 已清理 indicator_values 重复行(如有)");
+        } catch (Exception e) {
+            log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
+            log.info("迁移358: 已添加 uk_source_definition 唯一键");
+        } catch (Exception e) {
+            log.info("迁移358: uk_source_definition 已存在,跳过");
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
+            log.info("迁移358: 已添加 idx_subject_def_date 索引");
+        } catch (Exception e) {
+            log.info("迁移358: idx_subject_def_date 已存在,跳过");
+        }
+    }
+
+    // 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
+    private void createIndicatorMappingRulesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
+                    "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
+                    "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
+                    "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
+                    "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
+                    "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
+                    "status TINYINT DEFAULT 1," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
+                    "INDEX idx_def (definition_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
+            log.info("迁移359: 已创建 indicator_mapping_rules 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
+    private void createIndicatorCorrelationsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
+                    "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
+                    "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
+                    "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
+                    "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
+                    "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
+                    "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
+                    "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
+                    "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
+                    "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
+                    "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
+                    "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
+                    "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
+                    "computed_at DATETIME NULL COMMENT '最后校准时间'," +
+                    "created_by BIGINT NULL COMMENT '创建者'," +
+                    "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
+                    "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
+                    "INDEX idx_def_a (definition_id_a)," +
+                    "INDEX idx_def_b (definition_id_b)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
+            log.info("迁移360: 已创建 indicator_correlations 表");
+        } catch (Exception e) {
+            log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
+    private void ensureIndicatorCollectionDate() {
+        ensureColumn("health_indicators", "collection_date",
+                "DATETIME COMMENT '指标采集日期(报告检测日期)'");
+        log.info("迁移361: health_indicators.collection_date 已确认存在");
+    }
+
+    // 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
+    private void seedDimensionDefinitions() {
+        Object[][] dims = {
+            {"dim.body",     "身", "身体",  "higher_is_better", "higher_is_better"},
+            {"dim.mind",     "心", "心理",  "higher_is_better", "higher_is_better"},
+            {"dim.wisdom",   "智", "智慧",  "higher_is_better", "higher_is_better"},
+            {"dim.action",   "行", "行动",  "higher_is_better", "higher_is_better"},
+            {"dim.wealth",   "富", "财富",  "higher_is_better", "higher_is_better"},
+        };
+        for (Object[] d : dims) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.update(
+                            "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
+                            "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
+                            "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
+                            d[1], d[0], d[2], d[4], d[3]);
+                    log.info("迁移362: 已预置维度定义 {}", d[0]);
+                }
+            } catch (Exception e) {
+                log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
+            }
+        }
+    }
+
     // 迁移355: 创建减脂专题体重记录表
     private void createFatWeightRecordTable() {
         try {

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -34,6 +34,7 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/phone-login",
         "/api/auth/wechat-phone-login",
         "/api/auth/silent-login",
+        "/api/auth/sso-login",
         "/api/auth/auto-login",
         "/api/auth/register-with-idcard",
         "/api/auth/wechat-login",

+ 203 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/GutHealthProjectController.java

@@ -0,0 +1,203 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GutHealthProjectService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 菌群健康调研项目(单次付费问卷)
+ *
+ * 业务流程:
+ * 1. 用户付费 19.9 元创建项目
+ * 2. 填写健康问卷(睡眠/饮食/体重/消化等菌群相关指标)
+ * 3. 生成健康建议 + 风险评分 + 一周行动任务
+ * 4. 用户获得「会员价购买菌群检测」+「一年家庭会员」资格(需手动激活)
+ * 5. 用户在小程序打卡完成一周任务后,返还同等金额的 CF 值
+ */
+@Tag(name = "菌群健康调研项目", description = "单次付费问卷 + 行动打卡 + 奖励体系")
+@RestController
+@RequestMapping("/api/gut-health")
+public class GutHealthProjectController {
+
+    @Resource
+    private GutHealthProjectService gutHealthProjectService;
+
+    /**
+     * 查询当前用户的项目状态
+     */
+    @Operation(summary = "查询项目状态")
+    @PostMapping("/project/status")
+    public Result<Map<String, Object>> getProjectStatus(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        return Result.success(gutHealthProjectService.getProjectStatus(userId));
+    }
+
+    /**
+     * 创建付费订单
+     */
+    @Operation(summary = "创建项目付费订单")
+    @PostMapping("/project/pay")
+    public Result<Map<String, Object>> createOrder(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            return Result.success(gutHealthProjectService.createProjectOrder(userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 支付回调(微信/虚拟支付回调后调用)
+     */
+    @Operation(summary = "支付回调")
+    @PostMapping("/project/pay-notify")
+    public Result<Boolean> payNotify(@RequestBody Map<String, String> params) {
+        String orderNo = params.get("orderNo");
+        String transactionId = params.get("transactionId");
+        String payMethod = params.get("payMethod");
+        if (orderNo == null || transactionId == null || payMethod == null) {
+            return Result.error(400, "缺少必要参数");
+        }
+        return Result.success(gutHealthProjectService.processPaymentCallback(orderNo, transactionId, payMethod));
+    }
+
+    /**
+     * 提交问卷答案,生成健康建议 + 行动任务
+     */
+    @Operation(summary = "提交问卷")
+    @PostMapping("/project/submit")
+    public Result<Map<String, Object>> submitQuestionnaire(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> answers = (List<Map<String, Object>>) params.get("answers");
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        if (answers == null || answers.isEmpty()) {
+            return Result.error(400, "请完整填写问卷答案");
+        }
+        try {
+            return Result.success(gutHealthProjectService.submitQuestionnaire(userId, projectId, answers));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取行动任务列表
+     */
+    @Operation(summary = "获取行动任务")
+    @PostMapping("/project/tasks")
+    public Result<List<Map<String, Object>>> getActionTasks(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        return Result.success(gutHealthProjectService.getActionTasks(userId, projectId));
+    }
+
+    /**
+     * 打卡某个任务
+     */
+    @Operation(summary = "任务打卡")
+    @PostMapping("/project/task/checkin")
+    public Result<Map<String, Object>> checkinTask(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        int taskIndex = params.get("taskIndex") != null ? ((Number) params.get("taskIndex")).intValue() : -1;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        if (taskIndex < 0) {
+            return Result.error(400, "缺少 taskIndex");
+        }
+        try {
+            return Result.success(gutHealthProjectService.checkinTask(userId, projectId, taskIndex));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取打卡进度
+     */
+    @Operation(summary = "获取打卡进度")
+    @PostMapping("/project/checkin-status")
+    public Result<Map<String, Object>> getCheckinStatus(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        return Result.success(gutHealthProjectService.getCheckinStatus(userId, projectId));
+    }
+
+    /**
+     * 购买菌群检测后激活一年家庭会员
+     */
+    @Operation(summary = "购买后激活家庭会员")
+    @PostMapping("/project/activate-after-purchase")
+    public Result<Map<String, Object>> activateAfterPurchase(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        try {
+            return Result.success(gutHealthProjectService.activateAfterPurchase(userId, projectId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取菌群检测会员价购买凭证
+     */
+    @Operation(summary = "获取会员价购买凭证")
+    @PostMapping("/project/discount-voucher")
+    public Result<Map<String, Object>> getDiscountVoucher(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        try {
+            return Result.success(gutHealthProjectService.getMicrobeDiscountVoucher(userId, projectId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 118 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -0,0 +1,118 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.service.IndicatorTrendService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/indicator")
+public class IndicatorController {
+
+    @Resource
+    private IndicatorTrendService indicatorTrendService;
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /** 校验 subjectId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
+    private Result<Void> checkSubjectBelongs(Long subjectId, Long familyId) {
+        if (subjectId == null) {
+            return Result.error("subjectId不能为空");
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getId, subjectId)
+                .eq(FamilyMember::getFamilyId, familyId)
+                .last("LIMIT 1"));
+        if (member == null) {
+            return Result.error("成员不属于当前家庭");
+        }
+        return null;
+    }
+
+    /** 指标曲线(设计 6.1) */
+    @PostMapping("/trend")
+    public Result<?> trend(@RequestBody Map<String, Object> params,
+                           @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        @SuppressWarnings("unchecked")
+        List<Object> rawIds = (List<Object>) params.get("definitionIds");
+        List<Long> definitionIds = new ArrayList<>();
+        if (rawIds != null) {
+            for (Object o : rawIds) {
+                if (o instanceof Number) {
+                    definitionIds.add(((Number) o).longValue());
+                } else if (o != null) {
+                    definitionIds.add(Long.valueOf(o.toString()));
+                }
+            }
+        }
+        return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
+    }
+
+    /** 变化幅度排名(设计 6.2) */
+    @PostMapping("/change-ranking")
+    public Result<?> changeRanking(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String reportType = params.get("reportType") != null ? params.get("reportType").toString() : null;
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        String scope = params.get("scope") != null ? params.get("scope").toString() : "adjacent";
+        String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
+        Integer limit = params.get("limit") != null
+                ? Integer.valueOf(params.get("limit").toString()) : 50;
+        return Result.success(indicatorTrendService.changeRanking(
+                subjectId, reportType, category, scope, orderBy, limit, familyId));
+    }
+
+    /** 指标速查(设计 6.3) */
+    @PostMapping("/quick-lookup")
+    public Result<?> quickLookup(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
+        return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
+    }
+
+    /** 指标关联查询(设计 6.4) */
+    @PostMapping("/correlation/list")
+    public Result<?> correlationList(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Long definitionId = com.etotem.cfc.util.ParamUtils.getLong(params.get("definitionId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorCorrelationService.listRelations(subjectId, definitionId));
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/indicator/correlation")
+public class IndicatorCorrelationAdminController {
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    @Resource
+    private IndicatorCorrelationMapper indicatorCorrelationMapper;
+
+    /** 人工定义/编辑关联对 */
+    @PostMapping("/save")
+    public Result<?> save(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long userId,
+                          @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Long defA = ParamUtils.getLong(params.get("definitionIdA"));
+        Long defB = ParamUtils.getLong(params.get("definitionIdB"));
+        if (defA == null || defB == null) {
+            return Result.error("definitionIdA/definitionIdB 不能为空");
+        }
+        String direction = params.get("direction") != null ? params.get("direction").toString() : "positive";
+        BigDecimal manualStrength = params.get("manualStrength") != null
+                ? new BigDecimal(params.get("manualStrength").toString()) : BigDecimal.ZERO;
+        String description = params.get("description") != null ? params.get("description").toString() : null;
+        Boolean manualLocked = params.get("manualLocked") != null
+                && Boolean.parseBoolean(params.get("manualLocked").toString());
+        String sourceNote = params.get("sourceNote") != null ? params.get("sourceNote").toString() : null;
+        try {
+            indicatorCorrelationService.saveCorrelation(
+                    defA, defB, direction, manualStrength, description, manualLocked, sourceNote, userId);
+            return Result.success(null);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /** 关联对分页列表(含校准状态) */
+    @PostMapping("/list")
+    public Result<Page<IndicatorCorrelation>> list(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
+        Page<IndicatorCorrelation> p = new Page<>(page, size);
+        indicatorCorrelationMapper.selectPage(p,
+                new LambdaQueryWrapper<IndicatorCorrelation>()
+                        .orderByDesc(IndicatorCorrelation::getUpdatedAt));
+        return Result.success(p);
+    }
+
+    /** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */
+    @PostMapping("/calibrate")
+    public Result<?> calibrate(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Long pairId = ParamUtils.getLong(params.get("pairId"));
+        if (pairId != null) {
+            IndicatorCorrelation pair = indicatorCorrelationMapper.selectById(pairId);
+            if (pair == null) {
+                return Result.error("关联对不存在");
+            }
+            // 单对校准通过全量校准的按对逻辑实现(此处简化为触发全量,全量内部逐对幂等)
+            int n = indicatorCorrelationService.calibrateAll();
+            return Result.success("校准完成,共更新 " + n + " 对");
+        }
+        int n = indicatorCorrelationService.calibrateAll();
+        return Result.success("校准完成,共更新 " + n + " 对");
+    }
+}

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import com.etotem.cfc.service.IndicatorMappingService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/indicator/mapping")
+public class IndicatorMappingAdminController {
+
+    @Resource
+    private IndicatorMappingService indicatorMappingService;
+
+    @Resource
+    private IndicatorMappingRuleMapper indicatorMappingRuleMapper;
+
+    /** 批量导入映射种子(JSON/CSV 数组:[{rawName, definitionId?}]) */
+    @PostMapping("/import")
+    public Result<?> importSeeds(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        if (sourceType == null || sourceType.isEmpty()) {
+            return Result.error("sourceType 不能为空");
+        }
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> seeds = (List<Map<String, Object>>) params.get("seeds");
+        if (seeds == null || seeds.isEmpty()) {
+            return Result.error("seeds 不能为空");
+        }
+        int count = indicatorMappingService.importSeeds(sourceType, seeds);
+        return Result.success("导入完成,共 " + count + " 条");
+    }
+
+    /** 映射规则分页(含未匹配 raw 的 review 列表:status=0 为 ai_fallback 待确认) */
+    @PostMapping("/list")
+    public Result<Page<IndicatorMappingRule>> list(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
+        Page<IndicatorMappingRule> p = new Page<>(page, size);
+        LambdaQueryWrapper<IndicatorMappingRule> qw = new LambdaQueryWrapper<IndicatorMappingRule>()
+                .orderByAsc(IndicatorMappingRule::getSourceType)
+                .orderByAsc(IndicatorMappingRule::getRawName);
+        Object status = params.get("status");
+        if (status != null) {
+            qw.eq(IndicatorMappingRule::getStatus, Integer.valueOf(status.toString()));
+        }
+        Object sourceType = params.get("sourceType");
+        if (sourceType != null && !sourceType.toString().isEmpty()) {
+            qw.eq(IndicatorMappingRule::getSourceType, sourceType.toString());
+        }
+        indicatorMappingRuleMapper.selectPage(p, qw);
+        return Result.success(p);
+    }
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -83,6 +83,10 @@ public class ReportParserAdminController {
         if (type.getDisplayName() == null || type.getDisplayName().isEmpty()) {
             return Result.error("displayName 不能为空");
         }
+        if (type.getExpectedIndicators() != null && !type.getExpectedIndicators().trim().isEmpty()
+                && !isValidJsonStringArray(type.getExpectedIndicators())) {
+            return Result.error("expectedIndicators 必须为 JSON 字符串数组");
+        }
         if (type.getId() != null) {
             type.setUpdatedAt(new Date());
             typeRegistryMapper.updateById(type);
@@ -105,6 +109,24 @@ public class ReportParserAdminController {
         return Result.success();
     }
 
+    /** 校验字符串是否为 JSON 字符串数组,如 ["总分","百分位"] */
+    private boolean isValidJsonStringArray(String json) {
+        try {
+            com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json);
+            if (node == null || !node.isArray()) {
+                return false;
+            }
+            for (com.fasterxml.jackson.databind.JsonNode el : node) {
+                if (!el.isTextual()) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     @PostMapping("/types/toggle")
     public Result<Void> toggleType(@RequestBody Map<String, Object> params) {
         String typeId = (String) params.get("typeId");

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.SsoService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
@@ -39,6 +40,9 @@ public class AuthController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private SsoService ssoService;
+
     /**
      * 发送验证码
      */
@@ -336,4 +340,55 @@ public class AuthController {
         }
     }
 
+    /**
+     * 跨小程序单点登录 - 签发一次性票据(需登录)。
+     * 请求体: { "target": "train" };返回 { ticket, target }。
+     * 前端拿到 ticket 后通过 uni.navigateToMiniProgram 跳转到目标小程序的落地页。
+     */
+    @Operation(summary = "SSO 签发一次性票据")
+    @PostMapping("/sso-ticket")
+    public Result<Map<String, Object>> ssoTicket(@RequestBody Map<String, String> body,
+                                                 @RequestAttribute("userId") Long userId) {
+        if (userId == null || userId <= 0) {
+            return Result.error(401, "请先登录");
+        }
+        String target = body != null ? body.get("target") : null;
+        if (!SsoService.SYS_TRAIN.equals(target)) {
+            return Result.error("不支持的目标系统");
+        }
+        try {
+            String ticket = ssoService.issueTicket(SsoService.SYS_CFC, target, userId, userId);
+            Map<String, Object> data = new HashMap<>();
+            data.put("ticket", ticket);
+            data.put("target", target);
+            return Result.success(data);
+        } catch (Exception e) {
+            log.error("签发 SSO 票据失败: userId={}", userId, e);
+            return Result.error("签发票据失败,请稍后重试");
+        }
+    }
+
+    /**
+     * 跨小程序单点登录 - 消费一次性票据换取 cfc 登录态(公开接口)。
+     * 请求体: { "ticket": "..." }。
+     */
+    @Operation(summary = "SSO 票据换取登录态")
+    @PostMapping("/sso-login")
+    public Result<LoginResultDTO> ssoLogin(@RequestBody Map<String, String> body) {
+        String ticket = body != null ? body.get("ticket") : null;
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return Result.error("票据不能为空");
+        }
+        Long cfcUserId = ssoService.consumeTicket(ticket, SsoService.SYS_CFC);
+        if (cfcUserId == null) {
+            return Result.error("票据无效或已过期,请重新进入");
+        }
+        try {
+            LoginResultDTO result = userService.ssoLogin(cfcUserId);
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -18,6 +18,7 @@ public class CreateProductOrderDTO {
     private Integer deliveryMethod = 1; // 1=快递 2=自提
     private Integer pointsUsed; // 积分抵扣数量
     private Long consigneeId;         // 收货人ID
+    private Long addressId;             // 收货地址ID(user_address.id),与 addressSnapshot 二选一
     private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
 
     private List<Long> giftItemIds; // 选中的赠品项ID列表(来自 product_gift_items.id)
@@ -42,5 +43,7 @@ public class CreateProductOrderDTO {
     public Integer getDeliveryMethod() { return deliveryMethod; }
     public Integer getPointsUsed() { return pointsUsed; }
     public Long getConsigneeId() { return consigneeId; }
+    public Long getAddressId() { return addressId; }
+    public void setAddressId(Long addressId) { this.addressId = addressId; }
     public Map<String, String> getPurchaseInfo() { return purchaseInfo; }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java

@@ -104,6 +104,7 @@ public class ParsedReportPayload {
         private String description;
         private String category;
         private String level;
+        private String status;
 
         public String getBacteriaName() { return bacteriaName; }
         public void setBacteriaName(String bacteriaName) { this.bacteriaName = bacteriaName; }
@@ -121,6 +122,8 @@ public class ParsedReportPayload {
         public void setCategory(String category) { this.category = category; }
         public String getLevel() { return level; }
         public void setLevel(String level) { this.level = level; }
+        public String getStatus() { return status; }
+        public void setStatus(String status) { this.status = status; }
     }
 
     public static class FoodItem {

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java

@@ -22,6 +22,7 @@ public class ReportSummaryVO {
   private String status;
   private String detailRoute;
   private Date createdAt;
+  private String collectValidation;
 
   public static String getReportTypeName(String reportType) {
     if (reportType == null) return "未知";

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMembership.java

@@ -17,7 +17,7 @@ public class FamilyMembership implements Serializable {
 
     private Long familyId;
 
-    private String levelCode;  // FREE/BASIC/PROFESSIONAL/ENTERPRISE
+    private String levelCode;  // FREE/PROFESSIONAL/ENTERPRISE
 
     private Date startDate;
 

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GutHealthProject.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("gut_health_projects")
+public class GutHealthProject implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long familyId;
+
+    private Long memberId;
+
+    private String status;
+
+    private String questions;
+
+    private String healthRecommendations;
+
+    private String riskScores;
+
+    private Integer microbeTestRecommended;
+
+    private Date paidAt;
+
+    private Date completedAt;
+
+    private Integer memberDiscountQualified;
+
+    private Integer familyMembershipQualified;
+
+    private Integer familyMembershipActivated;
+
+    private Integer cfRefundAmount;
+
+    private Integer cfRefunded;
+
+    private Integer completedTasksCount;
+
+    private String actionTasks;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("indicator_correlations")
+public class IndicatorCorrelation {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long definitionIdA;
+    private Long definitionIdB;
+    private String relationType;
+    private String direction;
+    private BigDecimal manualStrength;
+    private BigDecimal computedStrength;
+    private String method;
+    private Integer sampleSize;
+    private String confidence;
+    private BigDecimal effectiveStrength;
+    private Integer manualLocked;
+    private String cohortScope;
+    private String status;
+    private Date computedAt;
+    private Long createdBy;
+    private String description;
+    private String sourceNote;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java

@@ -19,6 +19,11 @@ public class IndicatorDefinition {
     private String refRange;
     private Integer sortOrder;
     private Integer status;
+    private String sourceKind;
+    private String reportTypeScope;
+    private String valueType;
+    private String goodDirection;
+    private String dimensionCode;
     private Date createdAt;
     private Date updatedAt;
 
@@ -44,6 +49,16 @@ public class IndicatorDefinition {
     public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
     public Integer getStatus() { return status; }
     public void setStatus(Integer status) { this.status = status; }
+    public String getSourceKind() { return sourceKind; }
+    public void setSourceKind(String sourceKind) { this.sourceKind = sourceKind; }
+    public String getReportTypeScope() { return reportTypeScope; }
+    public void setReportTypeScope(String reportTypeScope) { this.reportTypeScope = reportTypeScope; }
+    public String getValueType() { return valueType; }
+    public void setValueType(String valueType) { this.valueType = valueType; }
+    public String getGoodDirection() { return goodDirection; }
+    public void setGoodDirection(String goodDirection) { this.goodDirection = goodDirection; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
     public Date getCreatedAt() { return createdAt; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public Date getUpdatedAt() { return updatedAt; }

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("indicator_mapping_rules")
+public class IndicatorMappingRule {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String sourceType;
+    private String rawName;
+    private String aliasText;
+    private Long definitionId;
+    private String matchType;
+    private Integer priority;
+    private Integer status;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java

@@ -16,6 +16,12 @@ public class IndicatorValue {
     private String value;
     private BigDecimal numericValue;
     private String remark;
+    private Long subjectId;
+    private Long familyId;
+    private Long reportId;
+    private java.sql.Date reportDate;
+    private String unit;
+    private String status;
     private Date recordedAt;
 
     public Long getId() { return id; }
@@ -32,6 +38,18 @@ public class IndicatorValue {
     public void setNumericValue(BigDecimal numericValue) { this.numericValue = numericValue; }
     public String getRemark() { return remark; }
     public void setRemark(String remark) { this.remark = remark; }
+    public Long getSubjectId() { return subjectId; }
+    public void setSubjectId(Long subjectId) { this.subjectId = subjectId; }
+    public Long getFamilyId() { return familyId; }
+    public void setFamilyId(Long familyId) { this.familyId = familyId; }
+    public Long getReportId() { return reportId; }
+    public void setReportId(Long reportId) { this.reportId = reportId; }
+    public java.sql.Date getReportDate() { return reportDate; }
+    public void setReportDate(java.sql.Date reportDate) { this.reportDate = reportDate; }
+    public String getUnit() { return unit; }
+    public void setUnit(String unit) { this.unit = unit; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
     public Date getRecordedAt() { return recordedAt; }
     public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -55,7 +55,7 @@ public class Product implements Serializable {
     /** 最低可见会员级别编码(NULL=全部可见): FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME */
     private String minVisibleLevel;
 
-    /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;BASIC/FAMILY/PREMIUM等) */
+    /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;FAMILY/PREMIUM等) */
     private String minDiscountLevel;
 
     // 佣金系统 - 平台利润率(千分比)

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SsoTicket.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),签发方写入,目标方原子占用(used 0→1)。
+ */
+@Data
+@TableName("sso_ticket")
+public class SsoTicket implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 一次性票据(SecureRandom 生成) */
+    private String ticket;
+
+    /** 签发方系统: cfc / train */
+    private String sourceSystem;
+
+    /** 目标系统: cfc / train */
+    private String targetSystem;
+
+    /** 签发方用户ID(cfc=users.id / train=train_user.id) */
+    private Long sourceUserId;
+
+    /** cfc 统一会员ID(users.id),跨系统换票依据 */
+    private Long cfcUserId;
+
+    /** 是否已使用: 0=未用 1=已用 */
+    private Integer used;
+
+    /** 过期时间 */
+    private Date expireAt;
+
+    private Date createdAt;
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.job;
+
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/** 指标关联每日自动校准(Spearman;样本不足保留 manual_strength) */
+@Component
+public class IndicatorCalibrationJob {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorCalibrationJob.class);
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    /** 每日 03:00 全量校准 */
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void calibrateDaily() {
+        try {
+            int updated = indicatorCorrelationService.calibrateAll();
+            log.info("每日指标关联校准完成,更新 {} 对", updated);
+        } catch (Exception e) {
+            log.error("每日指标关联校准失败: {}", e.getMessage(), e);
+        }
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GutHealthProjectMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GutHealthProject;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface GutHealthProjectMapper extends BaseMapper<GutHealthProject> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+
+public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+
+public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java

@@ -2,8 +2,22 @@ package com.etotem.cfc.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.IndicatorValue;
+import org.apache.ibatis.annotations.Insert;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 @Mapper
 public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
+
+    /** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
+    @Insert("INSERT INTO indicator_values " +
+            "(source_type, source_id, definition_id, value, numeric_value, remark, " +
+            " subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
+            "VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
+            " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
+            "ON DUPLICATE KEY UPDATE " +
+            " value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
+            " subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
+            " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
+    int upsertMapping(@Param("v") IndicatorValue v);
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SsoTicketMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SsoTicket;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SsoTicketMapper extends BaseMapper<SsoTicket> {
+}

+ 196 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -15,7 +15,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Async;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.*;
 import java.util.stream.Collectors;
@@ -69,6 +71,24 @@ public class AIService {
     @Value("${dify.callback-secret:}")
     private String callbackSecret;
 
+    @Value("${aikanshe.appkey:}")
+    private String aikansheAppkey;
+
+    @Value("${aikanshe.base-url:}")
+    private String aikansheBaseUrl;
+
+    @Value("${aikanshe.token:}")
+    private String aikansheToken;
+
+    @Value("${aikanshe.desease:}")
+    private String aikansheDesease;
+
+    @Value("${aikanshe.confidence:}")
+    private double aikansheConfidence;
+
+    @Value("${aikanshe.lang:}")
+    private String aikansheLang;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     private HttpHeaders authHeaders() {
@@ -251,20 +271,195 @@ public class AIService {
 
     /**
      * 舌诊图像分析
-     * 调用 LangGraph TongueDiagnosisAgent(glm-5 视觉模型)返回结构化舌诊结果
+     * 先调用 aikanshe.com API,失败时回退至 LangGraph TongueDiagnosisAgent
      */
     public Map<String, Object> sendTongueDiagnosis(byte[] imageBytes, String filename, Long userId) {
         if (imageBytes == null || imageBytes.length == 0) {
             return mockTongueResult();
         }
+        // 第一步:尝试调用 aikanshe.com API
+        Map<String, Object> aikansheResult = callAikansheTongueDiagnosis(imageBytes, filename, userId);
+        if (aikansheResult != null && !aikansheResult.isEmpty()
+                && aikansheResult.containsKey("overall_assessment")) {
+            log.info("使用 aikanshe.com API 结果");
+            return aikansheResult;
+        }
+        log.info("aikanshe.com API 无结果或失败,回退至 LangGraph");
+        // 第二步:回退至 LangGraph
         Map<String, Object> result = aiGateway.analyzeTongue(imageBytes, filename, userId);
         if (result != null && !result.isEmpty()) {
+            log.info("使用 LangGraph 结果");
             return result;
         }
         log.warn("LangGraph tongue diagnosis 未返回结果, 使用 mock 兜底");
         return mockTongueResult();
     }
 
+    /**
+     * 调用 愛看舌 AI 舌诊 API
+     * 支持 appkey 认证或 JWT token 认证
+     */
+    private Map<String, Object> callAikansheTongueDiagnosis(byte[] imageBytes, String filename, Long userId) {
+        // 上传图片并获取URL(这里先存储,实际生产中应使用已有的 storageService)
+        // 对于 aikanshe.com,我们使用 imageUrl 模式,假设图片已经上传或使用 data URL
+        String imageBase64 = Base64.getEncoder().encodeToString(imageBytes);
+        String imageDataUrl = "data:image/jpeg;base64," + imageBase64;
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("age", userId != null ? userId : 0); // 实际项目中应传入真实年龄
+        body.put("male", 1); // 默认男性,实际应根据用户性别传入
+        body.put("name", "用户"); // 实际应根据用户昵称传入
+        body.put("imageUrl", imageDataUrl);
+        body.put("appkey", aikansheAppkey);
+        body.put("desease", aikansheDesease);
+        body.put("confidence", aikansheConfidence);
+        body.put("lang", aikansheLang);
+
+        String url = aikansheBaseUrl + "/agency/quanxi";
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+
+        // 使用 appkey 或 token 认证
+        if (aikansheAppkey != null && !aikansheAppkey.isEmpty()) {
+            headers.set("x-appkey", aikansheAppkey);
+        } else if (aikansheToken != null && !aikansheToken.isEmpty()) {
+            headers.setBearerAuth(aikansheToken);
+        }
+
+        String bodyJson;
+        try {
+            bodyJson = objectMapper.writeValueAsString(body);
+        } catch (Exception e) {
+            log.warn("愛看舌 API 请求体序列化失败: {}", e.getMessage());
+            return null;
+        }
+        HttpEntity<String> entity = new HttpEntity<>(bodyJson, headers);
+try {
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("code") && root.get("code").asText().equals("20000")) {
+                    JsonNode data = root.get("data");
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    if (data.has("feel")) result.put("overall_assessment", data.get("feel").asText());
+                    if (data.has("recomand")) result.put("indicators", parseAikansheIndicators(data));
+                    if (data.has("id")) result.put("recordId", data.get("id").asLong());
+                    if (data.has("createTime")) result.put("createTime", data.get("createTime").asText());
+                    log.info("愛看舌 API 调用成功");
+                    return result;
+                } else {
+                    log.warn("愛看舌 API 返回错误代码: {}", root.get("code").asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("愛看舌 API 调用异常: {}", e.getMessage());
+        }
+        // 返回 null 表示调用失败,由上层 sendTongueDiagnosis 决定是否回退
+        return null;
+    }
+
+    private List<Map<String, Object>> parseAikansheIndicators(JsonNode data) {
+        List<Map<String, Object>> indicators = new ArrayList<>();
+
+        // --- 舌色 ---
+        if (data.has("feel")) {
+            String feel = data.get("feel").asText();
+            boolean found = false;
+            for (String line : feel.split("\\n")) {
+                if (line.contains("舌色") || line.contains("舌")) {
+                    String[] parts = line.split(":|:");
+                    if (parts.length >= 2) {
+                        String color = parts[1].trim().split("[,, ]")[0];
+                        if (isValidTongueColor(color)) {
+                            indicators.add(makeIndicator("tongue_color", color));
+                            found = true;
+                            break;
+                        }
+                    }
+                }
+            }
+            if (!found) {
+                indicators.add(makeIndicator("tongue_color", "淡红"));
+            }
+        } else {
+            indicators.add(makeIndicator("tongue_color", "淡红"));
+        }
+
+        // --- 苔色 ---
+        if (data.has("botai")) {
+            int botaiVal = data.get("botai").asInt(0);
+            indicators.add(makeIndicator("coating_color", botaiVal == 1 ? "有苔" : "无苔"));
+        } else {
+            indicators.add(makeIndicator("coating_color", "无苔"));
+        }
+
+        // --- 苔质 ---
+        if (data.has("botai")) {
+            int botaiVal = data.get("botai").asInt(0);
+            indicators.add(makeIndicator("coating_texture", botaiVal == 1 ? "滑腻" : "润"));
+        } else {
+            indicators.add(makeIndicator("coating_texture", "润"));
+        }
+
+        // --- 裂纹 ---
+        if (data.has("bagang")) {
+            String bagang = data.get("bagang").asText();
+            indicators.add(makeIndicator("fissure", (bagang.contains("实") || bagang.contains("虚")) ? "有" : "无"));
+        } else {
+            indicators.add(makeIndicator("fissure", "无"));
+        }
+
+        // --- 齿痕 ---
+        if (data.has("chihen")) {
+            int chihenVal = data.get("chihen").asInt(0);
+            indicators.add(makeIndicator("teeth_mark", chihenVal == 1 ? "有齿痕" : "无齿痕"));
+        } else {
+            indicators.add(makeIndicator("teeth_mark", "无齿痕"));
+        }
+
+        // --- 舌下络脉 ---
+        if (data.has("dianci")) {
+            int dianciVal = data.get("dianci").asInt(0);
+            indicators.add(makeIndicator("sublingual_vein", dianciVal == 1 ? "有点刺" : "无点刺"));
+        } else {
+            indicators.add(makeIndicator("sublingual_vein", "无点刺"));
+        }
+
+        // --- 体质 ---
+        if (data.has("desease")) {
+            String desease = data.get("desease").asText();
+            if (desease.contains("气虚") || desease.contains("阳虚")) {
+                indicators.add(makeIndicator("constitution", "气虚质"));
+            } else if (desease.contains("阴虚") || desease.contains("阴虚火旺")) {
+                indicators.add(makeIndicator("constitution", "阴虚质"));
+            } else if (desease.contains("血瘀") || desease.contains("气血两虚")) {
+                indicators.add(makeIndicator("constitution", "血瘀质"));
+            } else {
+                indicators.add(makeIndicator("constitution", "平和质"));
+            }
+        } else {
+            indicators.add(makeIndicator("constitution", "平和质"));
+        }
+
+        return indicators;
+    }
+
+    private boolean isValidTongueColor(String color) {
+        String[] validColors = {"淡红", "淡白", "红", "绛", "青", "紫", "正常"};
+        for (String c : validColors) {
+            if (color.contains(c) || c.contains(color)) return true;
+        }
+        return false;
+    }
+
+    private Map<String, Object> makeIndicator(String code, String value) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("code", code);
+        m.put("value", value);
+        return m;
+    }
+
     /**
      * 模拟舌诊结果(开发阶段使用)
      */

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -415,6 +415,37 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
+     * @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)
+     */
+    public String classifyIndicator(String sourceType, String rawName, java.util.List<String> candidates) {
+        if (!enabled || isCircuitOpen()) {
+            return null;
+        }
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("source_type", sourceType);
+            body.put("raw_name", rawName);
+            if (candidates != null) {
+                com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("candidates");
+                candidates.forEach(arr::add);
+            }
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/indicator/classify";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                consecutiveFailures.set(0);
+                return response.getBody();
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway classifyIndicator 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     /**
      * 识别图片中的食材
      */

+ 3 - 83
cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.entity.BasicQuotaUsage;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.BasicQuotaUsageMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -73,12 +74,6 @@ public class BasicQuotaService {
     public QuotaConsumeResult consumeQuota(Long userId, String quotaType) {
         QuotaConsumeResult result = new QuotaConsumeResult();
 
-        if (!isActiveBasicMember(userId)) {
-            result.setSuccess(false);
-            result.setToastMsg("该功能仅限体验会员使用");
-            return result;
-        }
-
         Date now = new Date();
         Date periodStart = getMonthStart(now);
         Date periodEnd = getMonthEnd(now);
@@ -126,85 +121,10 @@ public class BasicQuotaService {
     }
 
     /**
-     * 批量刷新所有 BASIC 用户当月配额(每月1号调用)
+     * 批量刷新所有用户当月配额(每月1号调用,BASIC体验版已取消,保留接口兼容)
      */
     public void refreshMonthlyQuota() {
-        Date now = new Date();
-        Date monthStart = getMonthStart(now);
-        Date monthEnd = getMonthEnd(now);
-
-        List<BasicQuotaUsage> lastMonthRecords = basicQuotaUsageMapper.selectList(
-                new LambdaQueryWrapper<BasicQuotaUsage>());
-
-        Set<Long> userIds = new HashSet<>();
-        for (BasicQuotaUsage rec : lastMonthRecords) {
-            if (isActiveBasicMember(rec.getUserId())) {
-                userIds.add(rec.getUserId());
-            }
-        }
-
-        for (Long userId : userIds) {
-            for (String quotaType : QUOTA_LIMITS.keySet()) {
-                try {
-                    BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
-                            new LambdaQueryWrapper<BasicQuotaUsage>()
-                                    .eq(BasicQuotaUsage::getUserId, userId)
-                                    .eq(BasicQuotaUsage::getQuotaType, quotaType)
-                                    .eq(BasicQuotaUsage::getPeriodStart, monthStart));
-                    if (existing == null) {
-                        BasicQuotaUsage newUsage = new BasicQuotaUsage();
-                        newUsage.setUserId(userId);
-                        newUsage.setQuotaType(quotaType);
-                        newUsage.setUsedCount(0);
-                        newUsage.setTotalCount(QUOTA_LIMITS.get(quotaType));
-                        newUsage.setPeriodStart(monthStart);
-                        newUsage.setPeriodEnd(monthEnd);
-                        newUsage.setCreatedAt(now);
-                        newUsage.setUpdatedAt(now);
-                        basicQuotaUsageMapper.insert(newUsage);
-                        log.info("BASIC配额刷新初始化: userId={}, quotaType={}", userId, quotaType);
-                    } else {
-                        existing.setUsedCount(0);
-                        existing.setPeriodStart(monthStart);
-                        existing.setPeriodEnd(monthEnd);
-                        existing.setUpdatedAt(now);
-                        basicQuotaUsageMapper.updateById(existing);
-                    }
-                } catch (Exception e) {
-                    log.warn("BASIC配额刷新失败: userId={}, quotaType={}", userId, quotaType, e);
-                }
-            }
-        }
-        log.info("BASIC配额刷新完成, 处理用户数: {}", userIds.size());
-    }
-
-    /**
-     * 检查用户当前是否为有效的 BASIC 会员
-     */
-    public boolean isActiveBasicMember(Long userId) {
-        if (userId == null) {
-            return false;
-        }
-        String level = membershipService.getMemberLevel(userId);
-        if (!"BASIC".equals(level)) {
-            return false;
-        }
-        User user = userMapper.selectById(userId);
-        if (user == null || user.getMemberExpireTime() == null) {
-            return false;
-        }
-        return user.getMemberExpireTime().after(new Date());
-    }
-
-    /**
-     * 获取 BASIC 用户到期时间
-     */
-    public Date getExpireTime(Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user != null) {
-            return user.getMemberExpireTime();
-        }
-        return null;
+        log.info("BASIC配额刷新已取消(体验版会员级别已删除)");
     }
 
     private Date getMonthStart(Date date) {

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -38,6 +38,9 @@ public class FoodRecommendService {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     @Resource
     private FoodService foodService;
 
@@ -157,6 +160,16 @@ public class FoodRecommendService {
 
             log.info("用户{}食材{}已从最新报告中移除", userId, removed.getFoodId());
         }
+
+        // 归一化管道:全量快照最新食材推荐指数到 indicator_values(幂等覆盖)
+        try {
+            List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIndex>()
+                            .eq(FoodRecommendIndex::getUserId, userId));
+            normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
+        } catch (Exception e) {
+            log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
+        }
     }
 
     public List<FoodRecommendIndex> getIndexByUser(Long userId) {

+ 865 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GutHealthProjectService.java

@@ -0,0 +1,865 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.OrderNoGenerator;
+import com.etotem.cfc.dto.QuotaStatusDTO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 菌群健康调研项目(单次付费问卷)
+ * <p>
+ * 业务流程:
+ * 1. 用户付费 19.9 元创建项目
+ * 2. 填写健康问卷(睡眠/饮食/体重/消化等菌群相关指标)
+ * 3. 生成健康建议 + 风险评分 + 一周行动任务
+ * 4. 用户获得「会员价购买菌群检测」资格凭证(不自动开通会员)
+ * 5. 用户在小程序/商城使用会员价购买菌群检测商品
+ * 6. 购买成功后 → 自动开通一年家庭会员
+ * 7. 用户在小程序打卡完成一周任务后,返还同等金额的 CF 值
+ */
+@Slf4j
+@Service
+public class GutHealthProjectService {
+
+    /** 默认项目价格(分) */
+    private static final int DEFAULT_PRICE = 1990;
+    /** 任务列表默认总数(一周 7 天) */
+    private static final int DEFAULT_TASK_TOTAL = 7;
+
+    @Resource
+    private GutHealthProjectMapper projectMapper;
+
+    @Resource
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMembershipMapper familyMembershipMapper;
+
+    @Resource
+    private PlatformPointsService platformPointsService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    // ==================== 问卷题库 ====================
+
+    /** 问卷题目定义(菌群健康相关指标) */
+    public static List<Map<String, Object>> getQuestionnaireQuestions() {
+        List<Map<String, Object>> questions = new ArrayList<>();
+
+        // 1. 睡眠质量
+        Map<String, Object> q1 = new LinkedHashMap<>();
+        q1.put("dimension", "sleep");
+        q1.put("label", "睡眠质量");
+        q1.put("question", "您最近两周的睡眠质量如何?");
+        q1.put("options", Arrays.asList(
+                mapOf("text", "很好,入睡快且无夜间醒来", "score", 0),
+                mapOf("text", "尚可,偶尔入睡困难或早醒", "score", 1),
+                mapOf("text", "较差,经常入睡困难或夜间多次醒来", "score", 2),
+                mapOf("text", "很差,几乎每晚失眠或严重早醒", "score", 3)));
+        questions.add(q1);
+
+        // 2. 饮食结构
+        Map<String, Object> q2 = new LinkedHashMap<>();
+        q2.put("dimension", "diet");
+        q2.put("label", "饮食结构");
+        q2.put("question", "您的日常饮食结构如何?(荤素搭配、蔬菜水果摄入)");
+        q2.put("options", Arrays.asList(
+                mapOf("text", "均衡饮食,蔬菜水果充足", "score", 0),
+                mapOf("text", "基本均衡,但蔬菜水果偏少", "score", 1),
+                mapOf("text", "偏荤或偏素,蔬菜水果摄入不足", "score", 2),
+                mapOf("text", "高油高糖高盐为主,极少蔬果", "score", 3)));
+        questions.add(q2);
+
+        // 3. 体重状况
+        Map<String, Object> q3 = new LinkedHashMap<>();
+        q3.put("dimension", "weight");
+        q3.put("label", "体重状况");
+        q3.put("question", "您的体重状况?(或身高体重比)");
+        q3.put("options", Arrays.asList(
+                mapOf("text", "体重正常(BMI 18.5-23.9)", "score", 0),
+                mapOf("text", "偏瘦或轻微超重(BMI 24-26)", "score", 1),
+                mapOf("text", "超重(BMI 26-28)", "score", 2),
+                mapOf("text", "肥胖(BMI ≥28)或严重偏瘦", "score", 3)));
+        questions.add(q3);
+
+        // 4. 消化状况
+        Map<String, Object> q4 = new LinkedHashMap<>();
+        q4.put("dimension", "digestion");
+        q4.put("label", "消化状况");
+        q4.put("question", "您近期的消化功能如何?");
+        q4.put("options", Arrays.asList(
+                mapOf("text", "消化良好,无腹胀腹泻便秘", "score", 0),
+                mapOf("text", "偶尔腹胀、排气或排便不规律", "score", 1),
+                mapOf("text", "经常腹胀、腹泻或便秘交替", "score", 2),
+                mapOf("text", "长期肠胃不适,严重影响生活", "score", 3)));
+        questions.add(q4);
+
+        // 5. 运动习惯
+        Map<String, Object> q5 = new LinkedHashMap<>();
+        q5.put("dimension", "exercise");
+        q5.put("label", "运动习惯");
+        q5.put("question", "您每周的运动频率如何?");
+        q5.put("options", Arrays.asList(
+                mapOf("text", "每周3次以上,每次30分钟以上", "score", 0),
+                mapOf("text", "每周1-2次,强度适中", "score", 1),
+                mapOf("text", "偶尔运动,不规律", "score", 2),
+                mapOf("text", "几乎不运动", "score", 3)));
+        questions.add(q5);
+
+        // 6. 压力水平
+        Map<String, Object> q6 = new LinkedHashMap<>();
+        q6.put("dimension", "stress");
+        q6.put("label", "压力水平");
+        q6.put("question", "您近期的精神压力状态如何?");
+        q6.put("options", Arrays.asList(
+                mapOf("text", "轻松愉快,压力很小", "score", 0),
+                mapOf("text", "偶尔有压力,能自我调节", "score", 1),
+                mapOf("text", "经常感到压力,难以放松", "score", 2),
+                mapOf("text", "长期高压焦虑,影响睡眠和食欲", "score", 3)));
+        questions.add(q6);
+
+        // 7. 膳食纤维
+        Map<String, Object> q7 = new LinkedHashMap<>();
+        q7.put("dimension", "fiber");
+        q7.put("label", "膳食纤维");
+        q7.put("question", "您每天的主食和膳食纤维摄入情况?");
+        q7.put("options", Arrays.asList(
+                mapOf("text", "全谷物/粗粮占主食一半以上", "score", 0),
+                mapOf("text", "粗细搭配,有一定全谷物", "score", 1),
+                mapOf("text", "以精米白面为主,很少粗粮", "score", 2),
+                mapOf("text", "完全精制主食,不吃粗粮豆类", "score", 3)));
+        questions.add(q7);
+
+        // 8. 抗生素与药物
+        Map<String, Object> q8 = new LinkedHashMap<>();
+        q8.put("dimension", "antibiotic");
+        q8.put("label", "抗生素使用");
+        q8.put("question", "近半年您是否使用过抗生素或长期服药?");
+        q8.put("options", Arrays.asList(
+                mapOf("text", "未使用过", "score", 0),
+                mapOf("text", "偶尔使用(1次以内)", "score", 1),
+                mapOf("text", "使用过2-3次", "score", 2),
+                mapOf("text", "多次使用或长期服药", "score", 3)));
+        questions.add(q8);
+
+        return questions;
+    }
+
+    private static Map<String, Object> mapOf(Object... kv) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return m;
+    }
+
+    // ==================== 业务方法 ====================
+
+    /**
+     * 获取当前用户的项目状态
+     * @return Map: {hasProject, project, questions, price}
+     */
+    public Map<String, Object> getProjectStatus(Long userId) {
+        Map<String, Object> result = new HashMap<>();
+        GutHealthProject project = getLatestProject(userId);
+        result.put("hasProject", project != null);
+        result.put("project", project != null ? toProjectVO(project) : null);
+        result.put("questions", getQuestionnaireQuestions());
+        result.put("price", getProjectPrice());
+        return result;
+    }
+
+    /**
+     * 创建项目付费订单
+     */
+    public Map<String, Object> createProjectOrder(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new IllegalArgumentException("用户不存在");
+        }
+
+        // 已存在未完成项目,禁止重复购买
+        GutHealthProject existing = getLatestProject(userId);
+        if (existing != null && "pending".equals(existing.getStatus())) {
+            throw new RuntimeException("您已有进行中的菌群健康调研项目,请先完成问卷");
+        }
+
+        int price = getProjectPrice();
+
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo(OrderNoGenerator.generate("GH"));
+        order.setFamilyId(user.getFamilyId());
+        order.setLevelCode("GUT_HEALTH");
+        order.setPaymentType("one_time");
+        order.setPeriod("once");
+        order.setAmount(price);
+        order.setStatus("pending");
+        order.setPeriodNo(1);
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.insert(order);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("orderNo", order.getOrderNo());
+        result.put("amount", order.getAmount());
+        return result;
+    }
+
+    /**
+     * 处理支付成功回调:创建项目记录
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean processPaymentCallback(String orderNo, String transactionId, String payMethod) {
+        PaymentOrder order = paymentOrderMapper.selectOne(
+                new LambdaQueryWrapper<PaymentOrder>()
+                        .eq(PaymentOrder::getOrderNo, orderNo));
+        if (order == null) {
+            return false;
+        }
+        if ("paid".equals(order.getStatus())) {
+            return true; // 幂等
+        }
+
+        order.setStatus("paid");
+        order.setTransactionId(transactionId);
+        order.setPayMethod(payMethod);
+        order.setPaidAt(new Date());
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.updateById(order);
+
+        // 创建项目记录
+        GutHealthProject project = new GutHealthProject();
+        Long userId = resolveUserIdByOrder(order);
+        project.setUserId(userId);
+        project.setFamilyId(order.getFamilyId());
+        project.setStatus("pending");
+        project.setMicrobeTestRecommended(0);
+        project.setMemberDiscountQualified(0);
+        project.setFamilyMembershipQualified(0);
+        project.setFamilyMembershipActivated(0);
+        project.setCfRefundAmount(order.getAmount());
+        project.setCfRefunded(0);
+        project.setCompletedTasksCount(0);
+        project.setPaidAt(new Date());
+        project.setCreatedAt(new Date());
+        project.setUpdatedAt(new Date());
+        projectMapper.insert(project);
+
+        log.info("菌群健康项目创建成功: orderNo={}, projectId={}, userId={}, amount={}",
+                orderNo, project.getId(), project.getUserId(), order.getAmount());
+        return true;
+    }
+
+    private Long resolveUserIdByOrder(PaymentOrder order) {
+        // 从 family_memberships 或用户表回查
+        if (order.getFamilyId() == null) return null;
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>().eq(User::getFamilyId, order.getFamilyId()));
+        if (users != null && !users.isEmpty()) {
+            return users.get(0).getId();
+        }
+        return null;
+    }
+
+    /**
+     * 提交问卷答案,生成健康建议 + 行动任务 + 奖励资格
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> submitQuestionnaire(Long userId, Long projectId, List<Map<String, Object>> answers) {
+        GutHealthProject project = projectMapper.selectById(projectId);
+        if (project == null || !userId.equals(project.getUserId())) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if ("completed".equals(project.getStatus())) {
+            throw new RuntimeException("该项目已完成,无需重复提交");
+        }
+
+        // 保存答案
+        ObjectMapper mapper = new ObjectMapper();
+        try {
+            project.setQuestions(mapper.writeValueAsString(answers));
+        } catch (Exception e) {
+            project.setQuestions(answers.toString());
+        }
+
+        // 计算风险评分
+        Map<String, Integer> riskScores = computeRiskScores(answers);
+        project.setRiskScores(safeJson(riskScores));
+
+        // 生成健康建议
+        List<Map<String, Object>> recommendations = generateRecommendations(riskScores, answers);
+        project.setHealthRecommendations(safeJson(recommendations));
+
+        // 判断是否推荐菌群检测(任一维度风险 >= 2)
+        boolean recommendTest = riskScores.values().stream().anyMatch(s -> s >= 2);
+        project.setMicrobeTestRecommended(recommendTest ? 1 : 0);
+
+        // 生成一周行动任务
+        List<Map<String, Object>> tasks = generateActionTasks(riskScores);
+        project.setActionTasks(safeJson(tasks));
+        project.setCompletedTasksCount(0);
+
+        // 授予会员价购买菌群检测资格(不自动开通会员)
+        project.setMemberDiscountQualified(1);
+        project.setStatus("completed");
+        project.setFamilyMembershipQualified(0);
+        project.setFamilyMembershipActivated(0);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+        project.setFamilyMembershipActivated(0);
+        project.setCompletedAt(new Date());
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        // 返回结果
+        Map<String, Object> result = new HashMap<>();
+        result.put("project", toProjectVO(project));
+        result.put("riskScores", riskScores);
+        result.put("recommendations", recommendations);
+        result.put("microbeTestRecommended", recommendTest);
+        result.put("tasks", tasks);
+        return result;
+    }
+
+    // ==================== 任务打卡 ====================
+
+    /**
+     * 获取行动任务列表
+     */
+    public List<Map<String, Object>> getActionTasks(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null || project.getActionTasks() == null) {
+            return Collections.emptyList();
+        }
+        return parseJsonList(project.getActionTasks());
+    }
+
+    /**
+     * 打卡某个任务
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> checkinTask(Long userId, Long projectId, int taskIndex) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (!"completed".equals(project.getStatus())) {
+            throw new RuntimeException("请先完成问卷后再打卡");
+        }
+        if (project.getCfRefunded() != null && project.getCfRefunded() == 1) {
+            throw new RuntimeException("任务已全部完成,CF值已返还");
+        }
+
+        List<Map<String, Object>> tasks = parseJsonList(project.getActionTasks());
+        if (taskIndex < 0 || taskIndex >= tasks.size()) {
+            throw new RuntimeException("任务索引越界");
+        }
+
+        Map<String, Object> task = tasks.get(taskIndex);
+        if (Boolean.TRUE.equals(task.get("done"))) {
+            throw new RuntimeException("该任务已打卡");
+        }
+
+        task.put("done", true);
+        task.put("doneAt", new Date().getTime());
+        project.setActionTasks(safeJson(tasks));
+
+        int doneCount = (int) tasks.stream().filter(t -> Boolean.TRUE.equals(t.get("done"))).count();
+        project.setCompletedTasksCount(doneCount);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("doneCount", doneCount);
+        result.put("totalTasks", tasks.size());
+        result.put("allDone", doneCount >= tasks.size());
+
+        // 全部完成 → 返还 CF 值
+        if (doneCount >= tasks.size()) {
+            result.put("cfRefunded", refundCf(project));
+        } else {
+            result.put("cfRefunded", false);
+        }
+        return result;
+    }
+
+    /**
+     * 获取打卡进度
+     */
+    public Map<String, Object> getCheckinStatus(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            return Collections.emptyMap();
+        }
+        List<Map<String, Object>> tasks = parseJsonList(project.getActionTasks());
+        int doneCount = (int) tasks.stream().filter(t -> Boolean.TRUE.equals(t.get("done"))).count();
+        int total = tasks.size();
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("projectId", project.getId());
+        result.put("tasks", tasks);
+        result.put("doneCount", doneCount);
+        result.put("totalTasks", total);
+        result.put("allDone", total > 0 && doneCount >= total);
+        result.put("cfRefunded", project.getCfRefunded() != null && project.getCfRefunded() == 1);
+        result.put("cfRefundAmount", project.getCfRefundAmount() != null ? project.getCfRefundAmount() : 0);
+        result.put("memberDiscountQualified", project.getMemberDiscountQualified() != null && project.getMemberDiscountQualified() == 1);
+        result.put("familyMembershipQualified", project.getFamilyMembershipQualified() != null && project.getFamilyMembershipQualified() == 1);
+        result.put("familyMembershipActivated", project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1);
+        return result;
+    }
+
+    /**
+     * 全部任务完成 → 返还同等金额 CF 值
+     */
+    private boolean refundCf(GutHealthProject project) {
+        if (project.getCfRefunded() != null && project.getCfRefunded() == 1) {
+            return true; // 幂等
+        }
+        int amount = project.getCfRefundAmount() != null ? project.getCfRefundAmount() : 0;
+        if (amount <= 0) {
+            amount = getProjectPrice();
+        }
+        try {
+            platformPointsService.earn(project.getUserId(), amount, "GUT_HEALTH", project.getId(),
+                    "菌群健康调研一周打卡完成,返还CF值");
+            project.setCfRefunded(1);
+            project.setUpdatedAt(new Date());
+            projectMapper.updateById(project);
+            log.info("菌群健康项目CF值返还: userId={}, projectId={}, amount={}", project.getUserId(), project.getId(), amount);
+            return true;
+        } catch (Exception e) {
+            log.error("CF值返还失败: userId={}, projectId={}", project.getUserId(), project.getId(), e);
+            return false;
+        }
+    }
+
+    // ==================== 奖励激活 ====================
+
+    /**
+     * 激活一年家庭会员(从资格中激活)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> activateFamilyMembership(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getFamilyMembershipQualified() == null || project.getFamilyMembershipQualified() != 1) {
+            throw new RuntimeException("您尚未获得一年家庭会员资格,请先完成健康问卷");
+        }
+        if (project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1) {
+            throw new RuntimeException("一年家庭会员已激活,请勿重复操作");
+        }
+
+        // 通过会员体系开通一年家庭会员
+        Long familyId = project.getFamilyId() != null ? project.getFamilyId() : getUserFamilyId(userId);
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭");
+        }
+        Date expireTime = addYears(new Date(), 1);
+
+        FamilyMembership membership = new FamilyMembership();
+        membership.setFamilyId(familyId);
+        membership.setLevelCode("FAMILY");
+        membership.setStartDate(new Date());
+        membership.setEndDate(expireTime);
+        membership.setPaymentMethod("gut_health_reward");
+        membership.setPaymentStatus("paid");
+        membership.setOrderNo("GH-" + project.getId());
+        membership.setAmount(0);
+        membership.setCreatedAt(new Date());
+        membership.setUpdatedAt(new Date());
+        familyMembershipMapper.insert(membership);
+
+        // 同步全家会员身份
+        try {
+            java.lang.reflect.Method syncMethod = MembershipService.class.getDeclaredMethod(
+                    "syncMembershipToFamilyMembers", Long.class, String.class, Date.class, String.class, Long.class);
+            syncMethod.setAccessible(true);
+            syncMethod.invoke(membershipService, familyId, "FAMILY", expireTime, "gut_health_reward", null);
+        } catch (Exception e) {
+            log.warn("同步家庭会员失败,将单独更新用户: {}", e.getMessage());
+            // 兜底:直接更新用户
+            User u = userMapper.selectById(userId);
+            if (u != null) {
+                u.setMemberLevel("FAMILY");
+                u.setMemberExpireTime(expireTime);
+                u.setUpdatedAt(new Date());
+                userMapper.updateById(u);
+            }
+        }
+
+        project.setFamilyMembershipActivated(1);
+        project.setFamilyMembershipQualified(1);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("activated", true);
+        result.put("levelCode", "FAMILY");
+        result.put("expireTime", expireTime);
+        return result;
+    }
+
+    /**
+     * 购买菌群检测后激活一年家庭会员
+     * <p>
+     * 流程:用户使用会员价购买菌群检测商品 → 调用本方法 → 自动开通一年家庭会员
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> activateAfterPurchase(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getMemberDiscountQualified() == null || project.getMemberDiscountQualified() != 1) {
+            throw new RuntimeException("您尚未获得会员价购买菌群检测资格,请先完成健康问卷");
+        }
+        if (project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1) {
+            throw new RuntimeException("一年家庭会员已激活,请勿重复操作");
+        }
+
+        // 通过会员体系开通一年家庭会员
+        Long familyId = project.getFamilyId() != null ? project.getFamilyId() : getUserFamilyId(userId);
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭");
+        }
+        Date expireTime = addYears(new Date(), 1);
+
+        FamilyMembership membership = new FamilyMembership();
+        membership.setFamilyId(familyId);
+        membership.setLevelCode("FAMILY");
+        membership.setStartDate(new Date());
+        membership.setEndDate(expireTime);
+        membership.setPaymentMethod("gut_health_reward");
+        membership.setPaymentStatus("paid");
+        membership.setOrderNo("GH-" + project.getId());
+        membership.setAmount(0);
+        membership.setCreatedAt(new Date());
+        membership.setUpdatedAt(new Date());
+        familyMembershipMapper.insert(membership);
+
+        // 方案B:全家同步会员身份(通过会员体系公共入口)
+        try {
+            membershipService.setFamilyMembershipLevel(familyId, "FAMILY", expireTime, "gut_health_purchase", "菌群检测购买赠送");
+        } catch (Exception e) {
+            log.warn("同步家庭会员失败,将单独更新用户: {}", e.getMessage());
+            // 兜底:直接更新用户
+            User u = userMapper.selectById(userId);
+            if (u != null) {
+                u.setMemberLevel("FAMILY");
+                u.setMemberExpireTime(expireTime);
+                u.setUpdatedAt(new Date());
+                userMapper.updateById(u);
+            }
+        }
+
+        project.setFamilyMembershipActivated(1);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("activated", true);
+        result.put("levelCode", "FAMILY");
+        result.put("expireTime", expireTime);
+        return result;
+    }
+
+    /**
+     * 获取会员价购买菌群检测凭证
+     */
+    public Map<String, Object> getMicrobeDiscountVoucher(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getMemberDiscountQualified() == null || project.getMemberDiscountQualified() != 1) {
+            throw new RuntimeException("您尚未获得会员价购买资格,请先完成健康问卷");
+        }
+
+        Map<String, Object> voucher = new HashMap<>();
+        voucher.put("qualified", true);
+        voucher.put("voucherCode", "GH-DISCOUNT-" + project.getId());
+        voucher.put("validUntil", addYears(new Date(), 1));
+        voucher.put("projectId", project.getId());
+        return voucher;
+    }
+
+    // ==================== 风险评分与建议生成 ====================
+
+    /**
+     * 计算各维度风险评分(0-3分)
+     */
+    private Map<String, Integer> computeRiskScores(List<Map<String, Object>> answers) {
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        List<Map<String, Object>> questions = getQuestionnaireQuestions();
+
+        for (int i = 0; i < questions.size(); i++) {
+            Map<String, Object> question = questions.get(i);
+            String dimension = (String) question.get("dimension");
+            int score = 0;
+            if (i < answers.size() && answers.get(i) != null) {
+                Object answer = answers.get(i).get("score") != null
+                        ? answers.get(i).get("score")
+                        : answers.get(i).get("answer");
+                if (answer instanceof Number) {
+                    score = ((Number) answer).intValue();
+                } else if (answer != null) {
+                    // 尝试按选项文本匹配
+                    List<Map<String, Object>> options = (List<Map<String, Object>>) question.get("options");
+                    for (Map<String, Object> opt : options) {
+                        if (answer.toString().equals(opt.get("text"))) {
+                            score = ((Number) opt.get("score")).intValue();
+                            break;
+                        }
+                    }
+                }
+            }
+            scores.put(dimension, score);
+        }
+        return scores;
+    }
+
+    /**
+     * 根据风险评分生成健康建议
+     */
+    private List<Map<String, Object>> generateRecommendations(Map<String, Integer> riskScores, List<Map<String, Object>> answers) {
+        List<Map<String, Object>> recommendations = new ArrayList<>();
+
+        // 睡眠
+        int sleep = riskScores.getOrDefault("sleep", 0);
+        if (sleep >= 2) {
+            recommendations.add(rec("睡眠", "高风险", "睡眠质量差与肠道菌群失衡密切相关,建议规律作息(23点前入睡),睡前1小时远离电子屏幕,可尝试睡前温水泡脚或冥想。若长期失眠,建议进一步评估肠道健康。"));
+        } else if (sleep == 1) {
+            recommendations.add(rec("睡眠", "中风险", "睡眠质量略有波动,建议保持固定作息,增加日间户外活动,晚餐避免过饱,观察改善情况。"));
+        }
+
+        // 饮食
+        int diet = riskScores.getOrDefault("diet", 0);
+        if (diet >= 2) {
+            recommendations.add(rec("饮食", "高风险", "饮食结构偏向高油高糖或缺乏蔬果,会抑制有益菌生长。建议每日蔬菜摄入500g以上,水果200g左右,减少油炸和加工食品,增加发酵食品(酸奶、纳豆、泡菜)。"));
+        } else if (diet == 1) {
+            recommendations.add(rec("饮食", "中风险", "饮食结构基本均衡但蔬果摄入偏少,建议每天增加一份蔬菜或水果,粗细粮搭配,促进膳食纤维多样化。"));
+        }
+
+        // 体重
+        int weight = riskScores.getOrDefault("weight", 0);
+        if (weight >= 2) {
+            recommendations.add(rec("体重", "高风险", "体重超标与肠道菌群多样性下降相关。建议控制精制碳水摄入,增加优质蛋白和膳食纤维,每周进行150分钟以上中等强度运动,可考虑菌群检测了解自身菌群结构。"));
+        } else if (weight == 1) {
+            recommendations.add(rec("体重", "中风险", "体重处于临界范围,建议调整饮食结构并保持规律运动,将体重控制在健康范围。"));
+        }
+
+        // 消化
+        int digestion = riskScores.getOrDefault("digestion", 0);
+        if (digestion >= 2) {
+            recommendations.add(rec("消化", "高风险", "频繁腹胀、腹泻或便秘提示肠道菌群可能失衡。建议每日补充膳食纤维,喝足量水(1500-2000ml),规律三餐,避免暴饮暴食。如症状持续,强烈建议进行菌群检测归因分析。"));
+        } else if (digestion == 1) {
+            recommendations.add(rec("消化", "中风险", "消化功能偶有波动,建议记录饮食日记,找出易引起不适的食物,逐步建立规律饮食习惯。"));
+        }
+
+        // 运动
+        int exercise = riskScores.getOrDefault("exercise", 0);
+        if (exercise >= 2) {
+            recommendations.add(rec("运动", "高风险", "缺乏规律运动会减少肠道菌群多样性。建议每周至少3次30分钟以上中等强度运动(快走、慢跑、游泳),可从每天15分钟开始逐步增加。"));
+        } else if (exercise == 1) {
+            recommendations.add(rec("运动", "中风险", "运动频率偏低,建议每周增加1-2次规律运动,运动后补充水分和适量蛋白质。"));
+        }
+
+        // 压力
+        int stress = riskScores.getOrDefault("stress", 0);
+        if (stress >= 2) {
+            recommendations.add(rec("压力", "高风险", "长期高压通过脑-肠轴影响肠道功能。建议每天留出10-15分钟正念呼吸或冥想,培养一项放松爱好,必要时寻求专业心理支持。"));
+        } else if (stress == 1) {
+            recommendations.add(rec("压力", "中风险", "压力水平可控,建议保持现有放松习惯,增加运动释放压力。"));
+        }
+
+        // 膳食纤维
+        int fiber = riskScores.getOrDefault("fiber", 0);
+        if (fiber >= 2) {
+            recommendations.add(rec("膳食纤维", "高风险", "膳食纤维摄入不足是肠道菌群失衡常见原因。建议主食增加燕麦、糙米、藜麦等全谷物,每天摄入豆类、坚果,保证纤维来源多样化。"));
+        } else if (fiber == 1) {
+            recommendations.add(rec("膳食纤维", "中风险", "全谷物比例偏低,建议逐步用粗粮替代部分精制主食,每周3-4次。"));
+        }
+
+        // 抗生素
+        int antibiotic = riskScores.getOrDefault("antibiotic", 0);
+        if (antibiotic >= 2) {
+            recommendations.add(rec("抗生素使用", "高风险", "近期抗生素使用可能已影响肠道菌群平衡。建议增加益生菌和益生元摄入(酸奶、发酵食品、菊粉),均衡饮食帮助菌群恢复。"));
+        } else if (antibiotic == 1) {
+            recommendations.add(rec("抗生素使用", "中风险", "曾有抗生素使用史,建议日常增加发酵食品摄入,帮助肠道菌群维持平衡。"));
+        }
+
+        if (recommendations.isEmpty()) {
+            recommendations.add(rec("整体", "低风险", "您的各项指标总体良好,建议保持现有健康生活方式,定期关注肠道健康。"));
+        }
+        return recommendations;
+    }
+
+    private Map<String, Object> rec(String dimension, String level, String advice) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("dimension", dimension);
+        m.put("level", level);
+        m.put("advice", advice);
+        return m;
+    }
+
+    /**
+     * 根据风险评分生成一周行动任务
+     */
+    private List<Map<String, Object>> generateActionTasks(Map<String, Integer> riskScores) {
+        List<Map<String, Object>> tasks = new ArrayList<>();
+
+        // 任务 1: 睡眠调整
+        if (riskScores.getOrDefault("sleep", 0) >= 1) {
+            tasks.add(task("每晚23:00前入睡,记录实际入睡时间", "sleep", "改善睡眠-肠道节律"));
+        } else {
+            tasks.add(task("保持现有作息,记录一周睡眠时长", "sleep", "巩固良好睡眠习惯"));
+        }
+
+        // 任务 2: 饮食调整
+        if (riskScores.getOrDefault("diet", 0) >= 1) {
+            tasks.add(task("每天增加一份蔬菜(至少500g),减少油炸食品", "diet", "优化饮食结构"));
+        } else {
+            tasks.add(task("保持均衡饮食,每天记录三餐构成", "diet", "维持健康饮食"));
+        }
+
+        // 任务 3: 运动
+        if (riskScores.getOrDefault("exercise", 0) >= 1) {
+            tasks.add(task("每天快走或慢跑20-30分钟", "exercise", "提升菌群多样性"));
+        } else {
+            tasks.add(task("每周3次30分钟运动(跑步/游泳/健身操)", "exercise", "维持菌群多样性"));
+        }
+
+        // 任务 4: 膳食纤维
+        if (riskScores.getOrDefault("fiber", 0) >= 1) {
+            tasks.add(task("每天摄入一种全谷物或粗粮(燕麦/糙米/藜麦)", "fiber", "补充膳食纤维"));
+        } else {
+            tasks.add(task("保持全谷物摄入,记录每日粗粮种类", "fiber", "维持纤维摄入"));
+        }
+
+        // 任务 5: 饮水
+        tasks.add(task("每天饮水1500-2000ml,记录饮水量", "hydration", "促进肠道蠕动"));
+
+        // 任务 6: 压力管理
+        if (riskScores.getOrDefault("stress", 0) >= 1) {
+            tasks.add(task("每天10分钟正念呼吸或冥想", "stress", "调节脑-肠轴"));
+        } else {
+            tasks.add(task("每天10分钟放松活动(听音乐/散步/阅读)", "stress", "保持轻松状态"));
+        }
+
+        // 任务 7: 消化观察
+        if (riskScores.getOrDefault("digestion", 0) >= 1) {
+            tasks.add(task("记录每日排便情况和腹胀感受", "digestion", "观察消化功能变化"));
+        } else {
+            tasks.add(task("记录一周消化状况,留意异常信号", "digestion", "持续关注消化健康"));
+        }
+
+        return tasks;
+    }
+
+    private Map<String, Object> task(String content, String category, String goal) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("content", content);
+        m.put("category", category);
+        m.put("goal", goal);
+        m.put("done", false);
+        return m;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    private GutHealthProject getLatestProject(Long userId) {
+        return projectMapper.selectOne(
+                new LambdaQueryWrapper<GutHealthProject>()
+                        .eq(GutHealthProject::getUserId, userId)
+                        .orderByDesc(GutHealthProject::getId)
+                        .last("LIMIT 1"));
+    }
+
+    private GutHealthProject getOwnedProject(Long userId, Long projectId) {
+        if (projectId == null) return null;
+        GutHealthProject project = projectMapper.selectById(projectId);
+        if (project == null || !userId.equals(project.getUserId())) return null;
+        return project;
+    }
+
+    private int getProjectPrice() {
+        String priceStr = sysConfigService.getValue("gut_health_project_price");
+        if (priceStr != null) {
+            try {
+                return Integer.parseInt(priceStr);
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return DEFAULT_PRICE;
+    }
+
+    private Long getUserFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        return user != null ? user.getFamilyId() : null;
+    }
+
+    private Date addYears(Date date, int years) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.add(Calendar.YEAR, years);
+        return cal.getTime();
+    }
+
+    private Map<String, Object> toProjectVO(GutHealthProject project) {
+        Map<String, Object> vo = new LinkedHashMap<>();
+        vo.put("id", project.getId());
+        vo.put("userId", project.getUserId());
+        vo.put("status", project.getStatus());
+        vo.put("paidAt", project.getPaidAt());
+        vo.put("completedAt", project.getCompletedAt());
+        vo.put("microbeTestRecommended", project.getMicrobeTestRecommended());
+        vo.put("memberDiscountQualified", project.getMemberDiscountQualified());
+        vo.put("familyMembershipQualified", project.getFamilyMembershipQualified());
+        vo.put("familyMembershipActivated", project.getFamilyMembershipActivated());
+        vo.put("cfRefundAmount", project.getCfRefundAmount());
+        vo.put("cfRefunded", project.getCfRefunded());
+        vo.put("completedTasksCount", project.getCompletedTasksCount());
+        return vo;
+    }
+
+    private String safeJson(Object obj) {
+        try {
+            return new ObjectMapper().writeValueAsString(obj);
+        } catch (Exception e) {
+            return obj.toString();
+        }
+    }
+
+    private List<Map<String, Object>> parseJsonList(String json) {
+        if (json == null || json.isEmpty()) {
+            return new ArrayList<>();
+        }
+        try {
+            return new ObjectMapper().readValue(json, new com.fasterxml.jackson.core.type.TypeReference<List<Map<String, Object>>>() {});
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -112,6 +112,9 @@ public class HealthReportService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     /**
      * 创建健康报告(含指标列表)
      */
@@ -1912,6 +1915,15 @@ public class HealthReportService {
         } catch (Exception e) {
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
         }
+
+        // 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
+        try {
+            ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
+            oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
+            normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
+        } catch (Exception e) {
+            log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
+        }
     }
 
     private Integer toInteger(Object value) {

+ 267 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java

@@ -0,0 +1,267 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import com.etotem.cfc.util.SpearmanUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 指标关联:人工定义(候选对+方向+人工强度)+ 真实数据校准(Spearman)。
+ * 对序规则:写入前按 definition.code 字典序比较,小的存 a、大的存 b(唯一键对称)。
+ * effective_strength:有效样本 >= 30 取 computed,否则取 manual;manual_locked=1 时校准不覆盖。
+ */
+@Service
+public class IndicatorCorrelationService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorCorrelationService.class);
+
+    @Resource
+    private IndicatorCorrelationMapper correlationMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    /** 有效样本阈值:>=30 才用 computed 覆盖 manual(设计 4.6) */
+    private static final int EFFECTIVE_SAMPLE_THRESHOLD = 30;
+
+    /** 人工定义/编辑关联对(强制对序) */
+    public void saveCorrelation(Long defA, Long defB, String direction, BigDecimal manualStrength,
+                                String description, Boolean manualLocked, String sourceNote, Long createdBy) {
+        if (defA == null || defB == null || defA.equals(defB)) {
+            throw new IllegalArgumentException("关联对不能为空且不能相同");
+        }
+        IndicatorDefinition da = definitionMapper.selectById(defA);
+        IndicatorDefinition db = definitionMapper.selectById(defB);
+        if (da == null || db == null) {
+            throw new IllegalArgumentException("指标定义不存在: " + defA + "/" + defB);
+        }
+        Long a = da.getCode().compareTo(db.getCode()) <= 0 ? defA : defB;
+        Long b = da.getCode().compareTo(db.getCode()) <= 0 ? defB : defA;
+
+        // 已存在则该对更新
+        IndicatorCorrelation existing = correlationMapper.selectOne(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .eq(IndicatorCorrelation::getDefinitionIdA, a)
+                .eq(IndicatorCorrelation::getDefinitionIdB, b)
+                .last("LIMIT 1"));
+        IndicatorCorrelation c = existing != null ? existing : new IndicatorCorrelation();
+        c.setDefinitionIdA(a);
+        c.setDefinitionIdB(b);
+        if (c.getRelationType() == null) c.setRelationType("correlation");
+        c.setDirection(direction);
+        c.setManualStrength(manualStrength);
+        c.setEffectiveStrength(manualStrength); // 未校准时 = manual
+        if (manualLocked != null) c.setManualLocked(manualLocked ? 1 : 0);
+        if (c.getManualLocked() == null) c.setManualLocked(0);
+        c.setDescription(description);
+        c.setSourceNote(sourceNote);
+        c.setCreatedBy(createdBy);
+        if (c.getCohortScope() == null) c.setCohortScope("subject");
+        if (c.getStatus() == null || !c.getStatus().equals("inactive")) c.setStatus("active");
+        if (existing != null) {
+            c.setUpdatedAt(new Date());
+            correlationMapper.updateById(c);
+        } else {
+            c.setCreatedAt(new Date());
+            c.setUpdatedAt(new Date());
+            correlationMapper.insert(c);
+        }
+        log.info("关联对已保存: {} ({} ↔ {})", a, b, direction);
+    }
+
+    /** 关联查询(设计 6.4):返回该指标的全部关联 + 当前成员的配对证据 */
+    public Map<String, Object> listRelations(Long subjectId, Long definitionId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> relations = new ArrayList<>();
+        if (definitionId == null) {
+            data.put("relations", relations);
+            return data;
+        }
+        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .and(w -> w.eq(IndicatorCorrelation::getDefinitionIdA, definitionId)
+                        .or().eq(IndicatorCorrelation::getDefinitionIdB, definitionId))
+                .eq(IndicatorCorrelation::getStatus, "active"));
+
+        for (IndicatorCorrelation pair : pairs) {
+            Long otherId = pair.getDefinitionIdA().equals(definitionId)
+                    ? pair.getDefinitionIdB() : pair.getDefinitionIdA();
+            IndicatorDefinition other = definitionMapper.selectById(otherId);
+            Map<String, Object> rel = new LinkedHashMap<>();
+            rel.put("pairId", pair.getId());
+            Map<String, Object> indA = new LinkedHashMap<>();
+            indA.put("definitionId", pair.getDefinitionIdA());
+            IndicatorDefinition defA = definitionMapper.selectById(pair.getDefinitionIdA());
+            indA.put("name", defA != null ? defA.getName() : null);
+            rel.put("indicatorA", indA);
+            Map<String, Object> indB = new LinkedHashMap<>();
+            indB.put("definitionId", pair.getDefinitionIdB());
+            indB.put("name", other != null ? other.getName() : null);
+            rel.put("indicatorB", indB);
+            rel.put("direction", pair.getDirection());
+            rel.put("effectiveStrength", pair.getEffectiveStrength());
+            rel.put("strengthSource", pair.getComputedStrength() != null
+                    && pair.getSampleSize() != null && pair.getSampleSize() >= EFFECTIVE_SAMPLE_THRESHOLD
+                    ? "computed" : "manual");
+            rel.put("sampleSize", pair.getSampleSize());
+            rel.put("confidence", pair.getConfidence());
+            rel.put("manualLocked", pair.getManualLocked() != null && pair.getManualLocked() == 1);
+            rel.put("description", pair.getDescription());
+
+            // 该成员自己的配对数据支撑(如有)
+            Map<String, Object> evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB());
+            rel.put("subjectEvidence", evidence);
+            relations.add(rel);
+        }
+        data.put("relations", relations);
+        return data;
+    }
+
+    /** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */
+    private Map<String, Object> subjectEvidence(Long subjectId, Long defA, Long defB) {
+        Map<String, Object> evidence = new LinkedHashMap<>();
+        evidence.put("points", 0);
+        evidence.put("personalRho", 0.0);
+        evidence.put("hasEnough", false);
+        if (subjectId == null) {
+            return evidence;
+        }
+        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(IndicatorValue::getDefinitionId, defA)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(IndicatorValue::getDefinitionId, defB)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+        if (rowsA.size() < 3 || rowsB.size() < 3) {
+            return evidence;
+        }
+        // 按 report_date 对齐:累计最近 12 个共同日期点
+        List<java.math.BigDecimal> xs = new ArrayList<>();
+        List<java.math.BigDecimal> ys = new ArrayList<>();
+        int i = 0;
+        int j = 0;
+        while (i < rowsA.size() && j < rowsB.size() && xs.size() < 12) {
+            int cmp = rowsA.get(i).getReportDate().compareTo(rowsB.get(j).getReportDate());
+            if (cmp == 0) {
+                if (rowsA.get(i).getNumericValue() != null && rowsB.get(j).getNumericValue() != null) {
+                    xs.add(rowsA.get(i).getNumericValue());
+                    ys.add(rowsB.get(j).getNumericValue());
+                }
+                i++;
+                j++;
+            } else if (cmp < 0) {
+                i++;
+            } else {
+                j++;
+            }
+        }
+        if (xs.size() >= 3) {
+            evidence.put("points", xs.size());
+            evidence.put("personalRho", SpearmanUtil.spearmanRho(xs, ys));
+            evidence.put("hasEnough", true);
+        }
+        return evidence;
+    }
+
+    /**
+     * 全量校准(管理端手动触发 / @Scheduled 每日):
+     * 对 status=active 且 manual_locked=0 的关联对,按 subject 分组配对序列 → Spearman → Fisher z 聚合。
+     */
+    public int calibrateAll() {
+        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .eq(IndicatorCorrelation::getStatus, "active"));
+        int updated = 0;
+        for (IndicatorCorrelation pair : pairs) {
+            if (pair.getManualLocked() != null && pair.getManualLocked() == 1) {
+                continue;
+            }
+            if (calibratePair(pair)) {
+                updated++;
+            }
+        }
+        log.info("指标关联校准完成,共更新 {} 对", updated);
+        return updated;
+    }
+
+    private boolean calibratePair(IndicatorCorrelation pair) {
+        // 全部观测(含所有 subject),按 (subject_id, report_date) 分组配对
+        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA())
+                .isNotNull(IndicatorValue::getReportDate));
+        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB())
+                .isNotNull(IndicatorValue::getReportDate));
+        // key = subjectId + '-' + reportDate
+        Map<String, IndicatorValue> mapA = rowsA.stream().filter(v -> v.getSubjectId() != null)
+                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
+        Map<String, IndicatorValue> mapB = rowsB.stream().filter(v -> v.getSubjectId() != null)
+                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
+
+        // 按 subject 分组配对点
+        Map<Long, List<java.math.BigDecimal[]>> bySubject = new java.util.HashMap<>();
+        int sampleSize = 0;
+        for (Map.Entry<String, IndicatorValue> e : mapA.entrySet()) {
+            IndicatorValue vb = mapB.get(e.getKey());
+            if (vb == null) continue;
+            IndicatorValue va = e.getValue();
+            if (va.getNumericValue() == null || vb.getNumericValue() == null) continue;
+            bySubject.computeIfAbsent(va.getSubjectId(), k -> new ArrayList<>())
+                    .add(new java.math.BigDecimal[]{va.getNumericValue(), vb.getNumericValue()});
+            sampleSize++;
+        }
+
+        if (sampleSize < 3) {
+            return false;
+        }
+        List<Double> rhos = new ArrayList<>();
+        for (List<java.math.BigDecimal[]> points : bySubject.values()) {
+            if (points.size() < 3) {
+                continue;
+            }
+            List<java.math.BigDecimal> xs = points.stream().map(p -> p[0]).collect(Collectors.toList());
+            List<java.math.BigDecimal> ys = points.stream().map(p -> p[1]).collect(Collectors.toList());
+            rhos.add(SpearmanUtil.spearmanRho(xs, ys));
+        }
+        if (rhos.isEmpty()) {
+            return false;
+        }
+        double avgRho = SpearmanUtil.fisherZAverage(rhos.stream().mapToDouble(Double::doubleValue).toArray());
+        String confidence = sampleSize >= 30 ? "high" : (sampleSize >= 10 ? "medium" : "low");
+
+        if (sampleSize >= EFFECTIVE_SAMPLE_THRESHOLD) {
+            pair.setEffectiveStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
+        }
+        pair.setComputedStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
+        pair.setMethod("spearman");
+        pair.setSampleSize(sampleSize);
+        pair.setConfidence(confidence);
+        pair.setComputedAt(new Date());
+        pair.setUpdatedAt(new Date());
+        correlationMapper.updateById(pair);
+        log.info("校准关联对 {}: rho={}, n={}, confidence={}",
+                pair.getId(), avgRho, sampleSize, confidence);
+        return true;
+    }
+}

+ 275 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java

@@ -0,0 +1,275 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 指标归一化映射:raw_name → definition_id。
+ * 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
+ * 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
+ */
+@Service
+public class IndicatorMappingService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);
+
+    @Resource
+    private IndicatorMappingRuleMapper ruleMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    // 循环依赖破环:AiGateway→BasicQuotaService→MembershipService→…→HealthReportService→本类。
+    // @Lazy 仅对 @Autowired 生效(非 @Resource);AI 兜底按需触发,懒加载可安全打破 Spring Boot 2.7 默认的循环引用禁令。
+    @Lazy
+    @Autowired
+    private AiGateway aiGateway;
+
+    @Resource
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    /** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
+    private static final double FUZZY_THRESHOLD = 0.5;
+
+    /**
+     * 解析原始指标名为 definition_id。
+     *
+     * @param sourceType 报告类型(gut_flora 等)
+     * @param rawName    报告原始名称(指标名/菌名/食材名,已 trim)
+     */
+    public Long resolve(String sourceType, String rawName) {
+        if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
+            return null;
+        }
+        String name = rawName.trim();
+
+        // 1) exact 命中规则表
+        IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
+                .eq(IndicatorMappingRule::getSourceType, sourceType)
+                .eq(IndicatorMappingRule::getRawName, name)
+                .orderByDesc(IndicatorMappingRule::getPriority)
+                .last("LIMIT 1"));
+        if (rule != null && rule.getDefinitionId() != null) {
+            return rule.getDefinitionId();
+        }
+
+        // 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
+        List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
+                .eq(IndicatorMappingRule::getSourceType, sourceType)
+                .eq(IndicatorMappingRule::getStatus, 1));
+        IndicatorMappingRule best = null;
+        double bestScore = 0;
+        for (IndicatorMappingRule c : candidates) {
+            double s = bigramSimilarity(name, c.getRawName());
+            if (s > bestScore) {
+                bestScore = s;
+                best = c;
+            }
+        }
+        if (best != null && bestScore >= FUZZY_THRESHOLD) {
+            return best.getDefinitionId();
+        }
+
+        // 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
+        String aiJson = aiGateway != null
+                ? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
+                : null;
+        if (aiJson != null) {
+            try {
+                JsonNode suggestion = objectMapper.readTree(aiJson);
+                if (suggestion != null && suggestion.hasNonNull("code")) {
+                    IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
+                            .eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
+                            .last("LIMIT 1"));
+                    if (def != null) {
+                        registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
+                        log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
+                                sourceType, name, def.getId());
+                        return def.getId();
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
+            }
+        }
+
+        // 4) 惰性注册:新 definition + auto_created 规则
+        return lazyRegister(sourceType, name);
+    }
+
+    /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
+    private Long lazyRegister(String sourceType, String rawName) {
+        DefinitionCode code = buildCode(sourceType, rawName);
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setDomain("physical");
+        def.setCategory(code.category);
+        def.setCode(code.code);
+        def.setName(rawName);
+        def.setDataType("number");
+        def.setSourceKind(code.sourceKind);
+        def.setReportTypeScope(sourceType);
+        def.setValueType(inferValueType(sourceType));
+        def.setGoodDirection("neutral");
+        def.setSortOrder(0);
+        def.setStatus(1);
+        def.setCreatedAt(new Date());
+        def.setUpdatedAt(new Date());
+        definitionMapper.insert(def);
+        registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
+        log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
+        return def.getId();
+    }
+
+    private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
+        IndicatorMappingRule rule = new IndicatorMappingRule();
+        rule.setSourceType(sourceType);
+        rule.setRawName(rawName);
+        rule.setDefinitionId(definitionId);
+        rule.setMatchType(matchType);
+        rule.setPriority(0);
+        rule.setStatus(status);
+        rule.setCreatedAt(new Date());
+        rule.setUpdatedAt(new Date());
+        try {
+            ruleMapper.insert(rule);
+        } catch (Exception e) {
+            log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
+        }
+    }
+
+    // ---------- 内部工具 ----------
+
+    private static class DefinitionCode {
+        String code;
+        String category;
+        String sourceKind;
+    }
+
+    private DefinitionCode buildCode(String sourceType, String rawName) {
+        DefinitionCode dc = new DefinitionCode();
+        if ("food_recommend".equals(sourceType)) {
+            dc.sourceKind = "food_index";
+            dc.category = "食材";
+            dc.code = "food." + rawName;
+        } else if (isBacteriaContext(sourceType)) {
+            dc.sourceKind = "bacteria";
+            dc.category = "菌属";
+            dc.code = "bacteria." + rawName;
+        } else {
+            dc.sourceKind = "report_indicator";
+            dc.category = "报告指标";
+            dc.code = "indicator." + rawName;
+        }
+        // code 有唯一约束,过长/含特殊字符时安全截断
+        if (dc.code.length() > 190) {
+            dc.code = dc.code.substring(0, 190);
+        }
+        return dc;
+    }
+
+    private boolean isBacteriaContext(String sourceType) {
+        return sourceType != null && sourceType.startsWith("gut_flora");
+    }
+
+    private String inferValueType(String sourceType) {
+        return isBacteriaContext(sourceType) ? "numeric" : "numeric";
+    }
+
+    /**
+     * CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
+     * 0~1;1 = 完全相同(但 exact 已先行命中)。
+     */
+    double bigramSimilarity(String a, String b) {
+        if (a == null || b == null) {
+            return 0;
+        }
+        if (a.equals(b)) {
+            return 1;
+        }
+        List<String> gramsA = bigrams(a);
+        List<String> gramsB = bigrams(b);
+        if (gramsA.isEmpty() || gramsB.isEmpty()) {
+            return 0;
+        }
+        int common = 0;
+        for (String g : gramsA) {
+            if (gramsB.contains(g)) {
+                common++;
+            }
+        }
+        return (2.0 * common) / (gramsA.size() + gramsB.size());
+    }
+
+    private List<String> bigrams(String s) {
+        if (s == null || s.length() < 2) {
+            List<String> single = new ArrayList<>();
+            if (s != null && !s.isEmpty()) {
+                single.add(s);
+            }
+            return single;
+        }
+        List<String> result = new ArrayList<>();
+        for (int i = 0; i < s.length() - 1; i++) {
+            result.add(s.substring(i, i + 2));
+        }
+        return result;
+    }
+
+    /** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
+    public BigDecimal parseNumericValue(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
+        if (cleaned.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(cleaned);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
+    public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
+        int count = 0;
+        for (Map<String, Object> seed : seeds) {
+            String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
+            if (rawName == null || rawName.trim().isEmpty()) {
+                continue;
+            }
+            Object defIdObj = seed.get("definitionId");
+            Long defId;
+            if (defIdObj instanceof Number) {
+                defId = ((Number) defIdObj).longValue();
+            } else if (defIdObj != null) {
+                defId = Long.valueOf(defIdObj.toString());
+            } else {
+                defId = lazyRegister(sourceType, rawName);
+            }
+            registerRule(sourceType, rawName.trim(), defId, "exact", 1);
+            count++;
+        }
+        log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
+        return count;
+    }
+}

+ 370 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -0,0 +1,370 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
+ * 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
+ */
+@Service
+public class IndicatorTrendService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private HealthDimensionScoreMapper healthDimensionScoreMapper;
+
+    /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
+    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
+            data.put("items", items);
+            return data;
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .in(IndicatorValue::getDefinitionId, definitionIds)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
+                        LinkedHashMap::new, Collectors.toList()));
+
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("unit", def != null ? def.getUnit() : null);
+            List<Map<String, Object>> points = new ArrayList<>();
+            for (IndicatorValue v : e.getValue()) {
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportDate", v.getReportDate() != null
+                        ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
+                p.put("value", v.getValue());
+                p.put("numericValue", v.getNumericValue());
+                p.put("unit", v.getUnit());
+                p.put("status", v.getStatus());
+                points.add(p);
+            }
+            item.put("points", points);
+            items.add(item);
+        }
+        data.put("items", items);
+        return data;
+    }
+
+    /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
+    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
+                                             String scope, String orderBy, Integer limit, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        int improved = 0;
+        int worsened = 0;
+        int flat = 0;
+
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getReportDate);
+        if (reportType != null && !reportType.isEmpty()) {
+            qw.eq(IndicatorValue::getSourceType, reportType);
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
+
+        // 按 definitionId 分组
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
+                    .collect(Collectors.toList());
+            if (sorted.size() < 2) {
+                continue;
+            }
+            IndicatorValue before = sorted.get(0);
+            IndicatorValue after = sorted.get(sorted.size() - 1);
+            if ("adjacent".equals(scope)) {
+                before = sorted.get(sorted.size() - 2);
+            }
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            String defCategory = def != null ? def.getCategory() : null;
+            if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
+                continue;
+            }
+            BigDecimal bv = before.getNumericValue();
+            BigDecimal av = after.getNumericValue();
+            if (bv == null || av == null) {
+                continue;
+            }
+            BigDecimal delta = av.subtract(bv);
+            BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
+                    ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            String movement = "flat";
+            if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
+            else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
+
+            String trendLabel = trendLabel(def, movement, delta);
+            if ("改善".equals(trendLabel)) improved++;
+            else if ("恶化".equals(trendLabel)) worsened++;
+            else flat++;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("category", defCategory);
+            item.put("sourceKind", def != null ? def.getSourceKind() : null);
+            item.put("unit", def != null ? def.getUnit() : null);
+            item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
+            Map<String, Object> beforeMap = new LinkedHashMap<>();
+            beforeMap.put("reportDate", fmtDate(before.getReportDate()));
+            beforeMap.put("value", before.getValue());
+            Map<String, Object> afterMap = new LinkedHashMap<>();
+            afterMap.put("reportDate", fmtDate(after.getReportDate()));
+            afterMap.put("value", after.getValue());
+            item.put("before", beforeMap);
+            item.put("after", afterMap);
+            item.put("delta", delta);
+            item.put("deltaPct", deltaPct);
+            item.put("movement", movement);
+            item.put("trendLabel", trendLabel);
+            items.add(item);
+        }
+
+        // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
+        if ("up".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
+        } else if ("down".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
+        } else {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
+                    .compareTo(((BigDecimal) a.get("delta")).abs()));
+        }
+
+        int limitVal = limit != null && limit > 0 ? limit : 50;
+        if (items.size() > limitVal) {
+            items = new ArrayList<>(items.subList(0, limitVal));
+        }
+
+        data.put("items", items);
+        Map<String, Object> overall = new LinkedHashMap<>();
+        overall.put("improvedCount", improved);
+        overall.put("worsenedCount", worsened);
+        overall.put("flatCount", flat);
+        data.put("overall", overall);
+        return data;
+    }
+
+    /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
+    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
+                                           Integer pageSize, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        int pageVal = page != null && page > 0 ? page : 1;
+        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
+
+        // 该成员全部观测(按时序取最新一条)
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getDefinitionId));
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        List<Map<String, Object>> allItems = new ArrayList<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            if (def == null) {
+                continue;
+            }
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate() == null ? -1
+                            : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
+                    .collect(Collectors.toList());
+            IndicatorValue latest = sorted.get(0);
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("value", latest.getValue());
+            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
+            item.put("refRange", def.getRefRange());
+            item.put("status", latest.getStatus());
+            item.put("trend", trendOf(sorted));
+            item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
+            item.put("latestReportDate", latest.getReportDate() != null
+                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
+            item.put("pointsCount", sorted.size());
+            allItems.add(item);
+        }
+
+        // categories 汇总(含 total)
+        List<Map<String, Object>> categories = new ArrayList<>();
+        Map<String, Long> catCount = allItems.stream()
+                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
+        catCount.forEach((name, total) -> {
+            Map<String, Object> c = new LinkedHashMap<>();
+            c.put("name", name);
+            c.put("total", total);
+            categories.add(c);
+        });
+
+        // 分页
+        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
+        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
+        List<Map<String, Object>> pageItems = allItems.isEmpty()
+                ? new ArrayList<>()
+                : new ArrayList<>(allItems.subList(fromIndex, toIndex));
+
+        data.put("categories", categories);
+        data.put("items", pageItems);
+        data.put("page", pageVal);
+        data.put("pageSize", sizeVal);
+        data.put("total", allItems.size());
+        data.put("dimensionScores", loadDimensionScores(subjectId));
+        return data;
+    }
+
+    /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
+    private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        // 五维:five_dimension_scores 按 memberId + assessedAt 最新
+        try {
+            result.addAll(fiveDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
+                            .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimensionCode());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessedAt() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
+                        m.put("source", "five_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        // 七维:health_dimension_scores 按 memberId + assessDate 最新
+        try {
+            result.addAll(healthDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
+                            .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimension());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessDate() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
+                        m.put("source", "health_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        return result;
+    }
+
+    // ---------- 工具 ----------
+
+    private String fmtDate(Date d) {
+        return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
+    }
+
+    /** 序列趋势:末尾两条比较 */
+    private String trendOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return "flat";
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return "flat";
+        }
+        int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
+        return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
+    }
+
+    private BigDecimal deltaOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return BigDecimal.ZERO;
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return BigDecimal.ZERO;
+        }
+        return latest.getNumericValue().subtract(prev.getNumericValue());
+    }
+
+    /**
+     * 变化着色/标签规则(设计第 8 节):
+     * good_direction × 数值变化方向 → 改善/恶化/中性。
+     */
+    private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
+        if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
+            return "中性";
+        }
+        if (def == null || def.getGoodDirection() == null) {
+            return "中性";
+        }
+        boolean up = "up".equals(movement);
+        switch (def.getGoodDirection()) {
+            case "higher_is_better":
+                return up ? "改善" : "恶化";
+            case "lower_is_better":
+                return up ? "恶化" : "改善";
+            case "in_range_is_better":
+                // 一期简化:无 refRange 解析,回落为中性
+                return "中性";
+            default:
+                return "中性";
+        }
+    }
+}

+ 29 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -318,7 +318,7 @@ public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String or
         kp.setDepartment(department != null ? department.trim() : null);
         kp.setTitle(title != null ? title.trim() : null);
         kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
-        kp.setFirstMeetTime(firstMeetTime != null ? java.sql.Timestamp.valueOf(firstMeetTime) : null);
+        kp.setFirstMeetTime(parseFlexibleTimestamp(firstMeetTime));
         kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
         kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
         kp.setReferrerId(referrerId);
@@ -327,6 +327,34 @@ public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String or
         return Result.success(kp);
     }
 
+    /**
+     * 宽松解析首次见面时间,兼容前端实际发送的三种格式:
+     * - 仅日期 "2026-10-10"(小程序 date picker)
+     * - ISO 8601 "2026-10-10T14:30:00" / 带毫秒 "2026-10-10T14:30:00.123"
+     * - MySQL/Timestamp.toString 风格 "2026-10-10 14:30:00" / "2026-10-10 14:30:00.0"
+     * 空值/解析失败返回 null(字段为选填,沿用 addEffect 对 happenedAt 的宽容处理)。
+     */
+    private java.util.Date parseFlexibleTimestamp(String s) {
+        if (s == null) return null;
+        s = s.trim();
+        if (s.isEmpty()) return null;
+        String[] patterns = {
+                "yyyy-MM-dd'T'HH:mm:ss.SSS",
+                "yyyy-MM-dd'T'HH:mm:ss",
+                "yyyy-MM-dd HH:mm:ss.SSS",
+                "yyyy-MM-dd HH:mm:ss",
+                "yyyy-MM-dd"
+        };
+        for (String p : patterns) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat(p);
+                sdf.setLenient(false);
+                return new java.sql.Timestamp(sdf.parse(s).getTime());
+            } catch (Exception ignored) { /* 尝试下一种格式 */ }
+        }
+        return null;
+    }
+
     public Result<OctopusKeyPerson> updateKeyPerson(Long userId, Long id, Map<String, Object> params) {
         OctopusKeyPerson kp = keyPersonMapper.selectById(id);
         if (kp == null || !kp.getUserId().equals(userId)) {

+ 22 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java

@@ -49,7 +49,11 @@ public class LangGraphReportParseService {
      * 转换后无有效指标数据时返回 null。
      */
     public ParsedReportPayload.Payload parseByScript(String pdfPath) {
-        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath);
+        return parseByScript(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseByScript(String pdfPath, List<String> indicators) {
+        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath, indicators);
         if (data == null) {
             return null;
         }
@@ -64,9 +68,13 @@ public class LangGraphReportParseService {
      * 均无有效数据时返回 null,调用方回退 opencode。
      */
     public ParsedReportPayload.Payload parseWithDetect(String pdfPath) {
+        return parseWithDetect(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseWithDetect(String pdfPath, List<String> indicators) {
         String detected = detectType(pdfPath);
         if (detected != null && !DETECTED_GENERIC.equals(detected)) {
-            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected);
+            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected, indicators);
             if (typed != null) {
                 log.info("LangGraph 识别到类型 {} 并解析成功", detected);
                 return typed;
@@ -75,7 +83,7 @@ public class LangGraphReportParseService {
         } else {
             log.info("LangGraph 无法识别报告类型(generic),尝试算法脚本解析");
         }
-        return parseByScript(pdfPath);
+        return parseByScript(pdfPath, indicators);
     }
 
     /** 调 /report/detect-type 识别报告类型,失败返回 null */
@@ -89,10 +97,13 @@ public class LangGraphReportParseService {
     }
 
     /** 调 /report/parse-typed 按指定类型解析(brain_status/cognitive_aptitude/scanned_image) */
-    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType) {
+    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType, List<String> indicators) {
         Map<String, Object> body = new HashMap<>();
         body.put("file_path", pdfPath);
         body.put("report_type", reportType);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
         Map<String, Object> data = postParse("/api/v1/report/parse-typed", body);
         if (data == null) {
             return null;
@@ -105,8 +116,15 @@ public class LangGraphReportParseService {
 
     /** 调 LangGraph 端点(仅为 file_path 场景),返回 data 字典;网络错误/非200/空 data 返回 null */
     private Map<String, Object> postParse(String path, String pdfPath) {
+        return postParse(path, pdfPath, null);
+    }
+
+    private Map<String, Object> postParse(String path, String pdfPath, List<String> indicators) {
         Map<String, Object> body = new HashMap<>();
         body.put("file_path", pdfPath);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
         return postParse(path, body);
     }
 

+ 3 - 37
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -243,21 +243,6 @@ public class MembershipService implements MembershipServiceInterface {
                     || "referral_commission".equals(feature);
         }
 
-        // BASIC 体验会员可用功能(9.9元/月,限次配额型)
-        if ("BASIC".equals(level)) {
-            return "free_activities".equals(feature)
-                    || "free_courses".equals(feature)
-                    || "discount_purchase".equals(feature)
-                    || "invite_family".equals(feature)
-                    || "mascot_ai_lite".equals(feature)
-                    || "ai_report_reading_lite".equals(feature)
-                    || "assessment_quota_lite".equals(feature)
-                    || "health_report_upload_lite".equals(feature)
-                    || "growth_plan_lite".equals(feature)
-                    || "task_template_lite".equals(feature)
-                    || "referral_commission".equals(feature);
-        }
-
         // FAMILY用户可用功能
         if ("FAMILY".equals(level)) {
             return "free_activities".equals(feature)
@@ -835,16 +820,6 @@ public class MembershipService implements MembershipServiceInterface {
             Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
             cfCommissionService.settleReferrerOnly(order.getId(), "membership", adminUserId, buyerFamilyId, order.getAmount());
 
-            // 如果是 BASIC 等级,初始化月度配额
-            if ("BASIC".equals(order.getLevelCode())) {
-                try {
-                    activationQuotaService.activateBasicQuota(adminUserId, order.getFamilyId());
-                    log.info("BASIC配额初始化完成: userId={}, familyId={}", adminUserId, order.getFamilyId());
-                } catch (Exception e) {
-                    log.error("BASIC配额初始化失败: userId={}", adminUserId, e);
-                }
-            }
-
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);
             return true;
@@ -1305,7 +1280,7 @@ public class MembershipService implements MembershipServiceInterface {
 
         // 个人会员身份(含有效期校验)
         String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
-        if (("FAMILY".equals(personal) || "BASIC".equals(personal)) && user.getMemberExpireTime() != null) {
+        if ("FAMILY".equals(personal) && user.getMemberExpireTime() != null) {
             if (user.getMemberExpireTime().before(new Date())) {
                 personal = "FREE";
             }
@@ -1422,19 +1397,10 @@ public class MembershipService implements MembershipServiceInterface {
     }
 
     /**
-     * 查询当前有效的 BASIC 会员用户ID列表(到期提醒用)
+     * 查询当前有效的会员用户ID列表(已废弃:BASIC体验版会员级别已删除)
      */
     public List<Long> listActiveBasicMemberUserIds() {
-        Date now = new Date();
-        List<User> basicUsers = userMapper.selectList(
-                new LambdaQueryWrapper<User>()
-                        .eq(User::getMemberLevel, "BASIC")
-                        .isNotNull(User::getMemberExpireTime)
-                        .gt(User::getMemberExpireTime, now));
-        return basicUsers != null ? basicUsers.stream()
-                .map(User::getId)
-                .distinct()
-                .collect(Collectors.toList()) : Collections.emptyList();
+        return Collections.emptyList();
     }
 
     /**

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.List;
+
+/**
+ * 归一化管道 —— 统一指标层「单一写者」。
+ * 报告入库/编辑流程照旧写 health_indicators(不动现有消费者),
+ * 本管道在同流程/同事务内将 指标+菌属+食材 幂等写入 indicator_values。
+ * 唯一键 (source_type, source_id, definition_id) 保证重解析只覆盖更新。
+ */
+@Service
+public class NormalizeIndicatorPipeline {
+
+    private static final Logger log = LoggerFactory.getLogger(NormalizeIndicatorPipeline.class);
+
+    @Resource
+    private IndicatorMappingService mappingService;
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    /**
+     * 报告指标 + 菌属归一化写入。
+     * 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
+     * 无 subject 的报告不入时序(无法归属观测对象)。
+     *
+     * @param report  已入库/已更新的报告(reportDate 须为真实日期或 null)
+     * @param payload 解析载荷(gutFlora 从 payload 读;indicators 从 payload 读,与 health_indicators 同源)
+     */
+    public void processReport(HealthReport report, ParsedReportPayload.Payload payload) {
+        if (report == null || report.getSubjectId() == null || report.getId() == null) {
+            return;
+        }
+        Long subjectId = report.getSubjectId();
+        Long familyId = report.getFamilyId();
+        Long reportId = report.getId();
+        String sourceType = report.getReportType();
+        Date reportDate = report.getReportDate() != null
+                ? new Date(report.getReportDate().getTime()) : null;
+
+        int written = 0;
+        if (payload != null && payload.getIndicators() != null) {
+            for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+                if (ind.getIndicatorName() == null || ind.getIndicatorName().trim().isEmpty()) {
+                    continue;
+                }
+                Long defId = mappingService.resolve(sourceType, ind.getIndicatorName().trim());
+                if (defId == null) {
+                    continue;
+                }
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                v.setValue(ind.getIndicatorValue());
+                v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
+                v.setUnit(ind.getUnit());
+                v.setStatus(ind.getStatus());
+                valueMapper.upsertMapping(v);
+                written++;
+            }
+        }
+        if (payload != null && payload.getGutFlora() != null) {
+            for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
+                if (flora.getBacteriaName() == null || flora.getBacteriaName().trim().isEmpty()) {
+                    continue;
+                }
+                Long defId = mappingService.resolve(sourceType, flora.getBacteriaName().trim());
+                if (defId == null) {
+                    continue;
+                }
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                v.setValue(flora.getBacteriaValue());
+                v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
+                v.setUnit("%");
+                v.setStatus(flora.getStatus());
+                valueMapper.upsertMapping(v);
+                written++;
+            }
+        }
+        log.info("归一化管道 reportId={}, subjectId={}, type={},写入 {} 条观测", reportId, subjectId, sourceType, written);
+    }
+
+    /**
+     * 食材推荐指数全量快照(钩子 3:FoodRecommendService.recalculateForUser 尾部调用)。
+     * source_type=food_recommend,source_id=food_recommend_idx.id,幂等覆盖。
+     */
+    public void snapshotFoodIndices(Long userId, List<FoodRecommendIndex> indices) {
+        if (userId == null || indices == null || indices.isEmpty()) {
+            return;
+        }
+        for (FoodRecommendIndex idx : indices) {
+            if (idx.getFoodName() == null || idx.getFoodName().trim().isEmpty()) {
+                continue;
+            }
+            Long defId = mappingService.resolve("food_recommend", idx.getFoodName().trim());
+            if (defId == null) {
+                continue;
+            }
+            IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
+            v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
+            v.setNumericValue(idx.getIndexScore() != null
+                    ? BigDecimal.valueOf(idx.getIndexScore()) : null);
+            v.setReportId(idx.getHealthReportId());
+            valueMapper.upsertMapping(v);
+        }
+        log.info("食材指数快照 userId={}, 共 {} 条", userId, indices.size());
+    }
+
+    private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
+                                     Long subjectId, Long familyId, Date reportDate) {
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType(sourceType);
+        v.setSourceId(sourceId);
+        v.setDefinitionId(defId);
+        v.setSubjectId(subjectId);
+        v.setFamilyId(familyId);
+        v.setReportDate(reportDate);
+        return v;
+    }
+}

+ 61 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -10,6 +10,8 @@ import com.etotem.cfc.dto.ProductOrderDTO;
 import com.etotem.cfc.entity.AfterSalesRequest;
 import com.etotem.cfc.entity.AssessmentProduct;
 import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.entity.UserAddress;
+import com.etotem.cfc.mapper.UserAddressMapper;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.Product;
@@ -181,6 +183,9 @@ public class ProductOrderService {
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
+    @Resource
+    private UserAddressMapper userAddressMapper;
+
     @Resource
     private ProductOrderGiftMapper productOrderGiftMapper;
 
@@ -256,8 +261,7 @@ public class ProductOrderService {
         order.setCoverImage(product.getCoverImage());
         order.setDiscountAmount(discountAmount);
         order.setCouponId(dto.getCouponId());
-        order.setAddressSnapshot(dto.getAddressSnapshot());
-        order.setConsigneeId(dto.getConsigneeId());
+        resolveOrderAddress(order, dto, buyerId);
         if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
             order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
         }
@@ -297,6 +301,44 @@ public class ProductOrderService {
      * 配送方式与履约校验(设计文档十一节)
      * 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
      */
+    /** 落库前统一整理收货地址:优先直接快照,其次 addressId(user_address),最后 consigneeId(consignees) */
+    private void resolveOrderAddress(ProductOrder order, CreateProductOrderDTO dto, Long buyerId) {
+        String snapshot = dto.getAddressSnapshot();
+        Long consigneeId = dto.getConsigneeId();
+        if ((snapshot == null || snapshot.isEmpty()) && dto.getAddressId() != null) {
+            UserAddress ua = userAddressMapper.selectById(dto.getAddressId());
+            if (ua != null && buyerId.equals(ua.getUserId())) {
+                Map<String, Object> snap = new HashMap<>();
+                snap.put("receiverName", ua.getReceiverName());
+                snap.put("phone", ua.getPhone());
+                snap.put("province", ua.getProvince());
+                snap.put("city", ua.getCity());
+                snap.put("district", ua.getDistrict());
+                snap.put("street", ua.getStreet());
+                snap.put("isDefault", ua.getIsDefault());
+                snap.put("source", "user_address");
+                snapshot = JSON.toJSONString(snap);
+                consigneeId = ua.getId();
+            }
+        } else if ((snapshot == null || snapshot.isEmpty()) && dto.getConsigneeId() != null) {
+            Consignee c = consigneeMapper.selectById(dto.getConsigneeId());
+            if (c != null && buyerId.equals(c.getUserId())) {
+                Map<String, Object> snap = new HashMap<>();
+                snap.put("receiverName", c.getName());
+                snap.put("phone", c.getPhone());
+                snap.put("province", c.getProvince());
+                snap.put("city", c.getCity());
+                snap.put("district", c.getDistrict());
+                snap.put("street", c.getStreet());
+                snap.put("source", "consignee");
+                snapshot = JSON.toJSONString(snap);
+                consigneeId = c.getId();
+            }
+        }
+        order.setAddressSnapshot(snapshot);
+        order.setConsigneeId(consigneeId);
+    }
+
     private FulfillValidation validateFulfill(Product product, CreateProductOrderDTO dto, Long buyerId) {
         FulfillValidation fv = new FulfillValidation();
         Integer productMethod = product.getDeliveryMethod() != null ? product.getDeliveryMethod() : 1;
@@ -316,16 +358,25 @@ public class ProductOrderService {
 
         switch (actualMethod) {
             case 1:
-                // 快递:校验收货地址存在且属于当前用户
-                if (dto.getConsigneeId() == null) {
+                // 快递:收货地址三选一校验:直接快照 > addressId(user_address) > consigneeId(consignees)
+                if (dto.getAddressSnapshot() != null && !dto.getAddressSnapshot().isEmpty()) {
+                    // 直接提供快照,校验通过
+                } else if (dto.getAddressId() != null) {
+                    UserAddress ua = userAddressMapper.selectById(dto.getAddressId());
+                    if (ua == null || !buyerId.equals(ua.getUserId())) {
+                        fv.error = "收货地址不存在";
+                        return fv;
+                    }
+                } else if (dto.getConsigneeId() != null) {
+                    Consignee consignee = consigneeMapper.selectById(dto.getConsigneeId());
+                    if (consignee == null || !buyerId.equals(consignee.getUserId())) {
+                        fv.error = "收货地址不存在";
+                        return fv;
+                    }
+                } else {
                     fv.error = "请选择收货地址";
                     return fv;
                 }
-                Consignee consignee = consigneeMapper.selectById(dto.getConsigneeId());
-                if (consignee == null || !buyerId.equals(consignee.getUserId())) {
-                    fv.error = "收货地址不存在";
-                    return fv;
-                }
                 break;
             case 2: {
                 // 自提:校验自取地点 + 时间段
@@ -558,8 +609,7 @@ public class ProductOrderService {
         order.setCoverImage(firstItem.getCoverImage() != null ? firstItem.getCoverImage() : firstProduct.getCoverImage());
         order.setDiscountAmount(multiDiscountAmount);
         order.setCouponId(dto.getCouponId());
-        order.setAddressSnapshot(dto.getAddressSnapshot());
-        order.setConsigneeId(dto.getConsigneeId());
+        resolveOrderAddress(order, dto, buyerId);
         if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
             order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
         }

+ 16 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -95,6 +95,9 @@ public class ReportCollectService {
     @Resource
     private ObjectMapper objectMapper;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
     private String opencodeBase;
 
@@ -181,12 +184,16 @@ public class ReportCollectService {
                 }
             }
 
+            // 采集指标清单(用于驱动非 DAN 的 LangGraph LLM 解析)
+            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
+            List<String> expectedIndicators = loadExpectedIndicators(validationTypeId);
+
             ParsedReportPayload.Payload payload;
             String method;
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
                 // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
                 method = METHOD_LANGGRAPH_SCRIPT;
-                payload = langGraphReportParseService.parseByScript(pdfPath);
+                payload = langGraphReportParseService.parseByScript(pdfPath, expectedIndicators);
                 // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
                 if (payload == null) {
                     log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
@@ -196,7 +203,7 @@ public class ReportCollectService {
             } else {
                 // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
                 method = METHOD_LANGGRAPH_DETECT;
-                payload = langGraphReportParseService.parseWithDetect(pdfPath);
+                payload = langGraphReportParseService.parseWithDetect(pdfPath, expectedIndicators);
                 if (payload == null) {
                     log.warn("无指纹 LangGraph 识别/解析为空,回退 opencode typeId={}", typeId);
                     method = METHOD_OPENCODE;
@@ -211,7 +218,6 @@ public class ReportCollectService {
 
             harmReportPayloadValid(payload);
             // 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动)
-            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
             try {
                 runCollectValidation(draftId, validationTypeId, payload);
             } catch (Exception e) {
@@ -645,6 +651,13 @@ public class ReportCollectService {
             log.warn("保存展示块失败 reportId={}", created.getId(), e);
         }
 
+        // 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
+        try {
+            normalizeIndicatorPipeline.processReport(created, payload);
+        } catch (Exception e) {
+            log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
+        }
+
         healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
 
         // 新手任务联动:完成首次报告上传任务(老用户无任务记录时静默跳过)

+ 146 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java

@@ -0,0 +1,146 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 真实报告日期提取 + 历史回填。
+ * PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
+ * 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
+ * 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
+ * (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
+ */
+@Service
+public class ReportDateExtractor {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 从 payload JSON 中提取真实报告日期。
+     * 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
+     * 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
+     */
+    public java.sql.Date extractReportDate(String payloadJson) {
+        if (payloadJson == null || payloadJson.isEmpty()) {
+            return null;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(payloadJson);
+            String dateStr = null;
+            JsonNode summary = root.get("summary");
+            if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
+                dateStr = summary.get("reportDate").asText();
+            }
+            if (dateStr == null || dateStr.trim().isEmpty()) {
+                if (root.hasNonNull("reportDate")) {
+                    dateStr = root.get("reportDate").asText();
+                }
+            }
+            java.sql.Date parsed = parseDate(dateStr);
+            if (parsed != null) {
+                return parsed;
+            }
+            // 回退:indicators[].collectionDate 取第一个非空
+            JsonNode indicators = root.get("indicators");
+            if (indicators != null && indicators.isArray()) {
+                for (JsonNode ind : indicators) {
+                    if (ind.hasNonNull("collectionDate")) {
+                        java.sql.Date d = parseDate(ind.get("collectionDate").asText());
+                        if (d != null) {
+                            return d;
+                        }
+                    }
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private java.sql.Date parseDate(String dateStr) {
+        if (dateStr == null || dateStr.trim().isEmpty()) {
+            return null;
+        }
+        String s = dateStr.trim();
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            Date d = sdf.parse(s);
+            return new java.sql.Date(d.getTime());
+        } catch (Exception e) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
+                Date d = sdf.parse(s);
+                return new java.sql.Date(d.getTime());
+            } catch (Exception e2) {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
+     * 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
+     *
+     * @return 本轮处理的总行数
+     */
+    public int backfillHistoricalReports() {
+        int total = 0;
+        long lastId = 0L;
+        while (true) {
+            List<HealthReport> batch = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .isNull(HealthReport::getReportDate)
+                            .like(HealthReport::getPayloadJson, "reportDate")
+                            .gt(HealthReport::getId, lastId)
+                            .orderByAsc(HealthReport::getId)
+                            .last("LIMIT 500"));
+            if (batch.isEmpty()) {
+                break;
+            }
+            for (HealthReport report : batch) {
+                try {
+                    java.sql.Date realDate = extractReportDate(report.getPayloadJson());
+                    if (realDate != null) {
+                        report.setReportDate(realDate);
+                        healthReportMapper.updateById(report);
+                        log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
+                    }
+                    // 无法提取:保持 report_date 为 NULL(不伪装成上传时间);
+                    // 由 keyset(id) 游标跳过,本轮不再反复处理
+                    total++;
+                } catch (Exception e) {
+                    log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+            lastId = batch.get(batch.size() - 1).getId();
+        }
+        log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
+        return total;
+    }
+
+    /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
+    @Scheduled(cron = "0 10 3 * * ?")
+    public void scheduledBackfill() {
+        backfillHistoricalReports();
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.entity.DanReportUpload;
 import com.etotem.cfc.entity.FamilyFortuneReport;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.InsurancePolicy;
 import com.etotem.cfc.entity.ReportSummary;
 import com.etotem.cfc.entity.User;
@@ -19,6 +20,7 @@ import com.etotem.cfc.mapper.DanReportUploadMapper;
 import com.etotem.cfc.mapper.FamilyFortuneReportMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.InsurancePolicyMapper;
 import com.etotem.cfc.mapper.ReportSummaryMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -28,8 +30,14 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -60,6 +68,9 @@ public class ReportSummaryService {
     @Resource
     private FamilyFortuneReportMapper familyFortuneReportMapper;
 
+    @Resource
+    private HealthReportDraftMapper healthReportDraftMapper;
+
     @Resource
     private JdbcTemplate jdbcTemplate;
 
@@ -303,6 +314,8 @@ public class ReportSummaryService {
         vo.setStatus(s.getStatus());
         vo.setDetailRoute(s.getDetailRoute());
         vo.setCreatedAt(s.getCreatedAt());
+        vo.setCollectValidation(loadCollectValidationBySource(
+                Collections.singletonList(s), s.getSourceTable()).get(s.getSourceId()));
         return vo;
     }
 
@@ -338,6 +351,9 @@ public class ReportSummaryService {
 
         SortUtil.applySort(wrapper);
 
+        Map<Long, String> danValidation = loadCollectValidationBySource(page.getRecords(), "dan_report_uploads");
+        Map<Long, String> healthValidation = loadCollectValidationBySource(page.getRecords(), "health_reports");
+
         Page<ReportSummaryVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
         List<ReportSummaryVO> voList = new ArrayList<>();
         for (ReportSummary rs : page.getRecords()) {
@@ -353,12 +369,48 @@ public class ReportSummaryService {
             vo.setReportDate(rs.getReportDate() != null ? rs.getReportDate().toString() : "");
             vo.setStatus(rs.getStatus());
             vo.setCreatedAt(rs.getCreatedAt());
+            if ("dan_report_uploads".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(danValidation.get(rs.getSourceId()));
+            } else if ("health_reports".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(healthValidation.get(rs.getSourceId()));
+            }
             voList.add(vo);
         }
         voPage.setRecords(voList);
         return voPage;
     }
 
+    /** 批量读取草稿的采集校验结果,键 = confirmed_report_id(= report_summary.source_id) */
+    private Map<Long, String> loadCollectValidationBySource(List<ReportSummary> rows, String sourceTable) {
+        Map<Long, String> map = new HashMap<>();
+        if (rows == null || rows.isEmpty()) {
+            return map;
+        }
+        Set<Long> ids = rows.stream()
+                .filter(r -> sourceTable.equals(r.getSourceTable()))
+                .map(ReportSummary::getSourceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (ids.isEmpty()) {
+            return map;
+        }
+        LambdaQueryWrapper<HealthReportDraft> w = new LambdaQueryWrapper<>();
+        w.in(HealthReportDraft::getConfirmedReportId, ids)
+                .isNotNull(HealthReportDraft::getCollectValidation);
+        if ("dan_report_uploads".equals(sourceTable)) {
+            w.likeRight(HealthReportDraft::getReportType, "dan");
+        } else {
+            w.and(x -> x.isNull(HealthReportDraft::getReportType)
+                    .or().notLikeRight(HealthReportDraft::getReportType, "dan"));
+        }
+        for (HealthReportDraft d : healthReportDraftMapper.selectList(w)) {
+            if (d.getConfirmedReportId() != null && d.getCollectValidation() != null) {
+                map.put(d.getConfirmedReportId(), d.getCollectValidation());
+            }
+        }
+        return map;
+    }
+
     public ReportSyncResultDTO fullSync() {
         ReportSyncResultDTO result = new ReportSyncResultDTO();
         result.setNewCount(0);

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SsoService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.SsoTicket;
+import com.etotem.cfc.mapper.SsoTicketMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.security.SecureRandom;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据服务(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),ticket 由签发方写入,目标方原子占用(used 0→1)。
+ */
+@Slf4j
+@Service
+public class SsoService {
+
+    /** 系统标识:cfc */
+    public static final String SYS_CFC = "cfc";
+    /** 系统标识:train */
+    public static final String SYS_TRAIN = "train";
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    @Resource
+    private SsoTicketMapper ssoTicketMapper;
+
+    /** 票据有效期(毫秒),默认 5 分钟 */
+    @Value("${sso.ticket-ttl-ms:300000}")
+    private long ticketTtlMs;
+
+    /**
+     * 签发一次性票据。
+     *
+     * @param sourceSystem 签发方系统标识(cfc / train)
+     * @param targetSystem 目标系统标识(cfc / train)
+     * @param sourceUserId 签发方用户ID(cfc=users.id / train=train_user.id)
+     * @param cfcUserId    cfc 统一会员ID(users.id),跨系统换票依据
+     * @return 票据字符串(64 位十六进制)
+     */
+    public String issueTicket(String sourceSystem, String targetSystem, Long sourceUserId, Long cfcUserId) {
+        SsoTicket t = new SsoTicket();
+        t.setTicket(generateTicket());
+        t.setSourceSystem(sourceSystem);
+        t.setTargetSystem(targetSystem);
+        t.setSourceUserId(sourceUserId);
+        t.setCfcUserId(cfcUserId);
+        t.setUsed(0);
+        t.setExpireAt(new Date(System.currentTimeMillis() + ticketTtlMs));
+        t.setCreatedAt(new Date());
+        ssoTicketMapper.insert(t);
+        return t.getTicket();
+    }
+
+    /**
+     * 原子消费票据:仅当「票据未使用 + 未过期 + 目标系统匹配」时置 used=1。
+     * 并发/重放场景下若多请求同时命中,只有 update 影响行数=1 的那一个成功。
+     *
+     * @return 成功返回 cfc_user_id;无效/已用/过期/目标不符返回 null
+     */
+    public Long consumeTicket(String ticket, String targetSystem) {
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return null;
+        }
+        int updated = ssoTicketMapper.update(null, new LambdaUpdateWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket)
+                .eq(SsoTicket::getTargetSystem, targetSystem)
+                .eq(SsoTicket::getUsed, 0)
+                .gt(SsoTicket::getExpireAt, new Date())
+                .set(SsoTicket::getUsed, 1));
+        if (updated != 1) {
+            log.warn("SSO 票据消费失败(无效/已用/过期/目标不符): target={}", targetSystem);
+            return null;
+        }
+        SsoTicket t = ssoTicketMapper.selectOne(new LambdaQueryWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket));
+        return t != null ? t.getCfcUserId() : null;
+    }
+
+    private String generateTicket() {
+        byte[] bytes = new byte[32];
+        RANDOM.nextBytes(bytes);
+        StringBuilder sb = new StringBuilder(64);
+        for (byte b : bytes) {
+            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+            sb.append(Character.forDigit(b & 0xF, 16));
+        }
+        return sb.toString();
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -655,6 +655,45 @@ private FamilyInvitationService familyInvitationService;
         return result;
     }
 
+    /**
+     * 跨小程序单点登录(SSO):凭一次性票据换取的 cfc_user_id 直接建立登录态。
+     *
+     * <p>身份可信度来自票据本身:短时效(默认 5 分钟)+ 一次性(消费即置 used=1)+ 签发方鉴权
+     * (签发时必须携带有效 cfc 登录态)。票据只在本服务与 train 共享的库中传递,不依赖 wx.login。
+     *
+     * @param cfcUserId cfc 统一会员ID(users.id)
+     */
+    public LoginResultDTO ssoLogin(Long cfcUserId) {
+        if (cfcUserId == null) {
+            throw new RuntimeException("SSO 票据无效");
+        }
+        User user = userMapper.selectById(cfcUserId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        // 管理员不能登录小程序
+        if ("admin".equals(user.getRole())) {
+            throw new RuntimeException("该账号不允许登录小程序");
+        }
+        ensureUserHasFamily(user);
+
+        String token = jwtConfig.generateToken(user.getId(), user.getRole());
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken(token);
+        result.setUserId(user.getId());
+        result.setRole(user.getRole());
+        result.setRoles(user.getRoles());
+        result.setFamilyId(user.getFamilyId());
+        result.setNickname(user.getNickname());
+        result.setPhone(user.getPhone());
+        result.setIsNewUser(false);
+        result.setOpenid(user.getOpenid());
+        result.setNeedsFamily(user.getFamilyId() == null || user.getFamilyId() == 0L);
+        populateTeacherFields(result, user);
+        populateActivityProviderFields(result, user);
+        return result;
+    }
+
     /**
      * 获取用户所有角色列表
      */

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.util;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 秩相关计算(纯函数,无外部依赖)。
+ * 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
+ */
+public final class SpearmanUtil {
+
+    private SpearmanUtil() {
+    }
+
+    /** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
+    public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
+        if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
+            return 0.0;
+        }
+        double[] rankX = rank(x);
+        double[] rankY = rank(y);
+        double sumD2 = 0;
+        int n = x.size();
+        for (int i = 0; i < n; i++) {
+            double d = rankX[i] - rankY[i];
+            sumD2 += d * d;
+        }
+        double denom = (double) n * (n * n - 1);
+        if (denom == 0) {
+            return 0.0;
+        }
+        double rho = 1 - (6 * sumD2) / denom;
+        if (rho > 1) rho = 1;
+        if (rho < -1) rho = -1;
+        return rho;
+    }
+
+    /** 平均秩;并列值取平均(tie handling)。 */
+    private static double[] rank(List<BigDecimal> values) {
+        int n = values.size();
+        double[] ranks = new double[n];
+        // 按值排序的索引
+        List<Integer> idx = new ArrayList<>();
+        for (int i = 0; i < n; i++) idx.add(i);
+        idx.sort(Comparator.comparing(values::get));
+        Map<Integer, Double> rankMap = new HashMap<>();
+        int i = 0;
+        while (i < n) {
+            int j = i;
+            while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
+                j++;
+            }
+            // [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
+            double avgRank = ((i + 1) + (j + 1)) / 2.0;
+            for (int k = i; k <= j; k++) {
+                rankMap.put(idx.get(k), avgRank);
+            }
+            i = j + 1;
+        }
+        for (int k = 0; k < n; k++) {
+            ranks[k] = rankMap.get(k);
+        }
+        return ranks;
+    }
+
+    /**
+     * Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
+     * 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
+     */
+    public static double fisherZAverage(double[] rhos) {
+        if (rhos == null || rhos.length == 0) {
+            return 0.0;
+        }
+        double sumZ = 0;
+        for (double r : rhos) {
+            double v = Math.max(-0.999999, Math.min(0.999999, r));
+            sumZ += 0.5 * Math.log((1 + v) / (1 - v));
+        }
+        double zAvg = sumZ / rhos.length;
+        return Math.tanh(zAvg);
+    }
+}

+ 9 - 1
cfc-backend/src/main/resources/application.yml

@@ -149,9 +149,17 @@ report:
     opencode-agent: ${OPENCODE_AGENT:build}
 
 math:
-  verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)
+  verify-mode: dify  # dify | eval; eval=服务端计算(兜底)
   workflow-id: ""    # Dify 数学判题工作流 ID(留空则使用 eval 兜底)
 
+aikanshe:
+  appkey: ${AIKANSHE_APPKEY:}
+  base-url: ${AIKANSHE_BASE_URL:https://api.aikanshe.com}
+  token: ${AIKANSHE_TOKEN:}
+  desease: ${AIKANSHE_DISEASE:}
+  confidence: ${AIKANSHE_CONFIDENCE:0.2}
+  lang: ${AIKANSHE_LANG:zh-CN}
+
 upload:
   base-dir: /data/cfc-uploads
   allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf

+ 104 - 26
cfc-backend/src/main/resources/schema.sql

@@ -1189,7 +1189,7 @@ CREATE TABLE IF NOT EXISTS products (
     family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员消费过即排除, 0=仅按个人消费排除',
     visible_roles JSON DEFAULT NULL COMMENT '可见角色: ["parent","child","teacher","admin"],NULL=全部可见',
     min_visible_level VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME,NULL=全部可见)',
-    min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/BASIC/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
+    min_discount_level VARCHAR(50) DEFAULT NULL COMMENT '享受会员价所需的最低会员级别编码(FREE/FAMILY/PREMIUM/LIFETIME,NULL=所有会员均可)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -2508,6 +2508,11 @@ CREATE TABLE IF NOT EXISTS indicator_definitions (
     options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
     unit VARCHAR(50) COMMENT '单位',
     ref_range VARCHAR(200) COMMENT '参考范围',
+    source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
+    report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
+    value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
+    good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
+    dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
     sort_order INT DEFAULT 0 COMMENT '排序',
     status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -2523,9 +2528,17 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     value VARCHAR(200) COMMENT '值',
     numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
     remark TEXT COMMENT '备注',
+    subject_id BIGINT COMMENT '观测对象(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
+    report_id BIGINT COMMENT '来源报告(health_reports.id)',
+    report_date DATE COMMENT '真实报告日期(时序查询键)',
+    unit VARCHAR(32) COMMENT '观测单位',
+    status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
     recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
     INDEX idx_source (source_type, source_id),
-    INDEX idx_definition (definition_id)
+    INDEX idx_definition (definition_id),
+    INDEX idx_subject_def_date (subject_id, definition_id, report_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
 
 -- ========== 舌诊主记录表 ==========
@@ -3931,7 +3944,7 @@ CREATE TABLE IF NOT EXISTS emotion_alert (
 -- ============================================================
 CREATE TABLE IF NOT EXISTS membership_levels (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/BASIC/PROFESSIONAL/ENTERPRISE',
+    level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE',
     level_name VARCHAR(100) NOT NULL COMMENT '等级名称',
     level_desc TEXT COMMENT '等级描述',
     price_monthly INT COMMENT '月费(分)',
@@ -3946,7 +3959,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
     ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能',
     priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服',
     teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询',
-    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6',
+    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level_code (level_code)
@@ -3957,7 +3970,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
 -- ============================================================
 CREATE TABLE IF NOT EXISTS membership_benefits (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/BASIC/PROFESSIONAL/ENTERPRISE',
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE',
     benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等',
     benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称',
     benefit_desc VARCHAR(500) COMMENT '权益描述',
@@ -5707,26 +5720,6 @@ CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒';
 
 -- ============================================================
--- BASIC 体验会员(迁移311-313)
--- =====================================================
-
-INSERT IGNORE INTO membership_levels 
-(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) 
-VALUES (
-    'BASIC', 
-    '体验会员', 
-    '9.9元/月体验版,解锁核心功能,限次使用AI与报告解读',
-    990,
-    '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite","referral_commission"]',
-    3,
-    3
-);
-
-INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES
-('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1),
-('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4),
-('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3),
-('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8);
 
 CREATE TABLE IF NOT EXISTS basic_quota_usage (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -5740,7 +5733,34 @@ CREATE TABLE IF NOT EXISTS basic_quota_usage (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start),
     INDEX idx_user_period (user_id, period_start)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度配额使用记录';
+
+-- 菌群健康调研项目(单次付费问卷)
+CREATE TABLE IF NOT EXISTS gut_health_projects (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '购买用户ID',
+    family_id BIGINT COMMENT '关联家庭ID',
+    member_id BIGINT COMMENT '测评对象(家庭成员ID)',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/paused/completed',
+    questions JSON COMMENT '本次问卷题目+答案JSON',
+    health_recommendations JSON COMMENT 'AI生成的健康建议',
+    risk_scores JSON COMMENT '各维度风险评分(0-100)',
+    microbe_test_recommended TINYINT DEFAULT 0 COMMENT '是否推荐菌群检测',
+    paid_at DATETIME COMMENT '付费时间',
+    completed_at DATETIME COMMENT '完成时间',
+    -- 新增字段:问卷完成后的奖励资格
+    member_discount_qualified TINYINT DEFAULT 0 COMMENT '是否获得会员价购买菌群检测资格',
+    family_membership_qualified TINYINT DEFAULT 0 COMMENT '是否获得一年家庭会员资格',
+    family_membership_activated TINYINT DEFAULT 0 COMMENT '是否已激活家庭会员',
+    -- 新增字段:打卡任务系统
+    cf_refund_amount INT DEFAULT 0 COMMENT 'CF值返还金额(分),19.9元=1990分',
+    cf_refunded TINYINT DEFAULT 0 COMMENT 'CF值是否已返还',
+    completed_tasks_count INT DEFAULT 0 COMMENT '已完成任务数/总任务数',
+    action_tasks JSON COMMENT 'AI生成的一周行动任务列表',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_user_created (user_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菌群健康调研项目';
 
 -- DOA 个人目标-周计划-周复盘(迁移318,家庭打卡个人目标模块)
 -- ============================================================
@@ -6172,3 +6192,61 @@ CREATE TABLE IF NOT EXISTS fat_goal (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_fat_goal_member (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标';
+-- 迁移359: 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)
+-- cfc 与 train 共用本库(zxyj)。签发方写入 ticket,目标方原子占用(used 0→1)后按 cfc_user_id 建立登录态。
+CREATE TABLE IF NOT EXISTS sso_ticket (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)',
+    source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train',
+    target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train',
+    source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)',
+    cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据',
+    used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用',
+    expire_at DATETIME NOT NULL COMMENT '过期时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_ticket (ticket),
+    INDEX idx_expire (expire_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';
+
+-- 迁移359: 指标归一化映射规则(连接各来源原始名与统一 indicator_definitions)
+CREATE TABLE IF NOT EXISTS indicator_mapping_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension',
+    raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)',
+    alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配',
+    definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id',
+    match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created',
+    priority INT DEFAULT 0 COMMENT '多规则冲突时取最高',
+    status TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_raw (source_type, raw_name),
+    INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则';
+
+-- 迁移360: 指标关联关系(人工定义 + 数据校准)
+CREATE TABLE IF NOT EXISTS indicator_correlations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标',
+    definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标',
+    relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source',
+    direction VARCHAR(10) NOT NULL COMMENT 'positive|negative',
+    manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1',
+    computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)',
+    method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman',
+    sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数',
+    confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)',
+    effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual',
+    manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖',
+    cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending',
+    computed_at DATETIME NULL COMMENT '最后校准时间',
+    created_by BIGINT NULL COMMENT '创建者',
+    description VARCHAR(500) NULL COMMENT '给用户看的解释',
+    source_note VARCHAR(255) NULL COMMENT '依据/出处',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_pair (definition_id_a, definition_id_b),
+    INDEX idx_def_a (definition_id_a),
+    INDEX idx_def_b (definition_id_b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';

+ 33 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class DatabaseInitializerSeedTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private DatabaseInitializer initializer;
+
+    @Test
+    void seedExpectedIndicator_onlyWritesWhenAbsent() {
+        initializer.seedExpectedIndicator("dan_a1", "[\"总分\"]");
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).update(sql.capture(), eq("[\"总分\"]"), eq("dan_a1"));
+        assertTrue(sql.getValue().contains("(expected_indicators IS NULL OR expected_indicators = '')"),
+                "SQL 必须仅在列为空时写入,避免覆盖管理员编辑");
+    }
+}

+ 68 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class ReportParserAdminControllerTest {
+
+    @Mock
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @InjectMocks
+    private ReportParserAdminController controller;
+
+    private ReportTypeRegistry baseType() {
+        ReportTypeRegistry t = new ReportTypeRegistry();
+        t.setId(1L);
+        t.setTypeId("dan_a1");
+        t.setDisplayName("素质测评A1");
+        return t;
+    }
+
+    @Test
+    void saveType_rejectsInvalidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("not-json");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("expectedIndicators"));
+        verifyNoInteractions(typeRegistryMapper);
+    }
+
+    @Test
+    void saveType_rejectsJsonArrayWithNonStringElement() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[1,2,3]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        verify(typeRegistryMapper, never()).updateById(t);
+    }
+
+    @Test
+    void saveType_acceptsValidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[\"总分\",\"百分位\"]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(200, r.getCode());
+        verify(typeRegistryMapper).updateById(t);
+    }
+}

+ 9 - 9
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ActivityControllerTest.java

@@ -222,7 +222,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.create(activity, 5L, "admin");
+        Result<ActivityDTO> result = controller.create(activity, 5L, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals(5L, activity.getVendorId()); // vendorId set from userId
         assertNotNull(result.getData().getId());
@@ -240,7 +240,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.create(activity, null, "admin");
+        Result<ActivityDTO> result = controller.create(activity, null, "admin", null);
         assertEquals(200, result.getCode());
         assertNull(activity.getVendorId());
     }
@@ -257,7 +257,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.update(activity, null, "admin");
+        Result<ActivityDTO> result = controller.update(activity, null, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals("更新后的活动", result.getData().getTitle());
     }
@@ -275,7 +275,7 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 1);
 
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals("发布成功", result.getData());
     }
@@ -292,14 +292,14 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 999);
 
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(500, result.getCode());
     }
 
     @Test
     public void publish_MissingId() {
         Map<String, Object> params = new HashMap<>();
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("不能为空"));
     }
@@ -317,7 +317,7 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 1);
 
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(200, result.getCode());
     }
 
@@ -333,14 +333,14 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 999);
 
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(500, result.getCode());
     }
 
     @Test
     public void end_MissingId() {
         Map<String, Object> params = new HashMap<>();
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(500, result.getCode());
     }
 }

+ 57 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorCorrelation;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class IndicatorCorrelationServiceTest {
+
+    private IndicatorCorrelationService service;
+    private IndicatorCorrelationMapper correlationMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorCorrelationService();
+        correlationMapper = mock(IndicatorCorrelationMapper.class);
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(service, "correlationMapper", correlationMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+    }
+
+    @Test
+    void saveOrdersByCodeLexicographic() {
+        // 定义 A code="bacteria.bb" 应存 a;定义 B code="bacteria.aa" 应存 a
+        IndicatorDefinition defAA = new IndicatorDefinition();
+        defAA.setId(1L);
+        defAA.setCode("bacteria.aa");
+        IndicatorDefinition defBB = new IndicatorDefinition();
+        defBB.setId(2L);
+        defBB.setCode("bacteria.bb");
+        when(definitionMapper.selectById(1L)).thenReturn(defAA);
+        when(definitionMapper.selectById(2L)).thenReturn(defBB);
+
+        service.saveCorrelation(1L, 2L, "positive", new BigDecimal("0.72"),
+                "描述", false, "来源", 9L);
+
+        org.mockito.ArgumentCaptor<IndicatorCorrelation> captor =
+                org.mockito.ArgumentCaptor.forClass(IndicatorCorrelation.class);
+        verify(correlationMapper).insert(captor.capture());
+        IndicatorCorrelation saved = captor.getValue();
+        // 1L 的 code("bacteria.aa") 字典序更小 → 应存 definition_id_a
+        assertEquals(1L, saved.getDefinitionIdA());
+        assertEquals(2L, saved.getDefinitionIdB());
+        // effective_strength 初始 = manual(未校准)
+        assertEquals(0, new BigDecimal("0.72").compareTo(saved.getEffectiveStrength()));
+    }
+}

+ 93 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java

@@ -0,0 +1,93 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class IndicatorMappingServiceTest {
+
+    private IndicatorMappingService service;
+    private IndicatorMappingRuleMapper ruleMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+    private AiGateway aiGateway;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorMappingService();
+        ruleMapper = Mockito.mock(IndicatorMappingRuleMapper.class);
+        definitionMapper = Mockito.mock(IndicatorDefinitionMapper.class);
+        aiGateway = Mockito.mock(AiGateway.class);
+        ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "aiGateway", aiGateway);
+    }
+
+    @Test
+    void resolveExactHitReturnsDefinitionId() {
+        IndicatorMappingRule rule = new IndicatorMappingRule();
+        rule.setDefinitionId(7L);
+        when(ruleMapper.selectOne(any())).thenReturn(rule);
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(7L, defId);
+        verify(ruleMapper).selectOne(any());
+    }
+
+    @Test
+    void resolveMissLazilyRegistersDefinitionAndRule() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorDefinition created = new IndicatorDefinition();
+        created.setId(99L);
+        when(definitionMapper.insert(any(IndicatorDefinition.class))).thenAnswer(inv -> {
+            IndicatorDefinition d = inv.getArgument(0);
+            d.setId(99L);
+            return 1;
+        });
+        Long defId = service.resolve("gut_flora", "新出现的菌X");
+        assertEquals(99L, defId);
+        // 惰性注册:定义 + auto_created 规则
+        verify(definitionMapper).insert(any(IndicatorDefinition.class));
+        verify(ruleMapper).insert(any(IndicatorMappingRule.class));
+    }
+
+    @Test
+    void resolveFuzzyHitPicksBestCandidate() {
+        // exact 未命中 → fuzzy 候选命中(别名/近似名)
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorMappingRule candidate = new IndicatorMappingRule();
+        candidate.setDefinitionId(3L);
+        candidate.setRawName("双歧杆菌");  // 与 "双歧杆菌属" 高相似
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
+
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(3L, defId);
+    }
+
+    @Test
+    void resolveFuzzyMissFallsBackToAiSuggestion() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // AI 建议命中
+        when(aiGateway.classifyIndicator(eq("gut_flora"), eq("神秘指标"), anyList()))
+                .thenReturn("{\"code\":\"indicator.x.y\",\"name\":\"神秘指标\"}");
+        IndicatorDefinition suggested = new IndicatorDefinition();
+        suggested.setId(41L);
+        when(definitionMapper.selectOne(any())).thenReturn(suggested);
+
+        Long defId = service.resolve("gut_flora", "神秘指标");
+        assertEquals(41L, defId);
+        // AI 建议登记为 pending(status=0)规则
+        verify(ruleMapper).insert(argThat(r -> r.getMatchType().equals("ai_fallback") && r.getStatus() == 0));
+    }
+}

+ 100 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class IndicatorTrendServiceTest {
+
+    private IndicatorTrendService service;
+    private IndicatorValueMapper valueMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorTrendService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+        // 计划测试漏加:实现均调用 definitionMapper.selectById,必须补 mock
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setName("双歧杆菌属");
+        def.setUnit("%");
+        def.setCategory("肠道菌群");
+        def.setGoodDirection("higher_is_better");
+        when(definitionMapper.selectById(any())).thenReturn(def);
+    }
+
+    private IndicatorValue value(Long defId, String date, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportDate(Date.valueOf(date));
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        return v;
+    }
+
+    @Test
+    void trendReturnsAscendingPoints() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("1", String.valueOf(item.get("definitionId")));
+        List<?> points = (List<?>) item.get("points");
+        assertEquals(2, points.size());
+        Map<?, ?> first = (Map<?, ?>) points.get(0);
+        assertEquals("2026-08-15", first.get("reportDate"));
+    }
+
+    @Test
+    void changeRankingAdjacentComputesDeltaAndTrendLabel() {
+        // 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("up", item.get("movement"));
+        assertEquals("改善", item.get("trendLabel"));
+        Map<?, ?> overall = (Map<?, ?>) data.get("overall");
+        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
+    }
+
+    @Test
+    void quickLookupReturnsItemsAndDimensions() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
+        assertTrue(data.containsKey("items"));
+        assertTrue(data.containsKey("categories"));
+        assertEquals(1, ((List<?>) data.get("items")).size());
+    }
+}

+ 30 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/** IndicatorValueMapper.upsertMapping 仅做 SQL 注解绑定验证(mock 层) */
+class IndicatorValueMapperTest {
+
+    @Test
+    void upsertMappingAnnotationCompiles() {
+        IndicatorValueMapper mapper = Mockito.mock(IndicatorValueMapper.class);
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType("gut_flora");
+        v.setSourceId(10L);
+        v.setDefinitionId(3L);
+        v.setValue("12.8");
+        v.setNumericValue(new BigDecimal("12.8"));
+        when(mapper.upsertMapping(any(IndicatorValue.class))).thenReturn(1);
+        int result = mapper.upsertMapping(v);
+        assertEquals(1, result);
+    }
+}

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class NormalizeIndicatorPipelineTest {
+
+    private NormalizeIndicatorPipeline pipeline;
+    private IndicatorMappingService mappingService;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        pipeline = new NormalizeIndicatorPipeline();
+        mappingService = Mockito.mock(IndicatorMappingService.class);
+        valueMapper = Mockito.mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(pipeline, "mappingService", mappingService);
+        ReflectionTestUtils.setField(pipeline, "valueMapper", valueMapper);
+    }
+
+    @Test
+    void processReportWritesIndicatorsAndFlora() {
+        HealthReport report = new HealthReport();
+        report.setId(10L);
+        report.setFamilyId(2L);
+        report.setSubjectId(3L);
+        report.setReportType("gut_flora");
+        report.setReportDate(new Date());
+        when(mappingService.resolve(eq("gut_flora"), eq("双歧杆菌属"))).thenReturn(1L);
+
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
+        ind.setIndicatorName("双歧杆菌属");
+        ind.setIndicatorValue("12.8");
+        ind.setUnit("%");
+        ind.setStatus("偏高");
+        payload.setIndicators(Collections.singletonList(ind));
+
+        pipeline.processReport(report, payload);
+        verify(valueMapper).upsertMapping(any(IndicatorValue.class));
+    }
+
+    @Test
+    void processReportSkipsWhenNoSubject() {
+        HealthReport report = new HealthReport();
+        report.setId(10L);
+        report.setReportType("gut_flora");
+        report.setSubjectId(null);
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        payload.setIndicators(Collections.emptyList());
+
+        pipeline.processReport(report, payload);
+        verify(valueMapper, never()).upsertMapping(any());
+    }
+
+    @Test
+    void snapshotFoodIndicesWritesFoodValues() {
+        com.etotem.cfc.entity.FoodRecommendIndex idx = new com.etotem.cfc.entity.FoodRecommendIndex();
+        idx.setId(5L);
+        idx.setUserId(3L);
+        idx.setFoodId(9L);
+        idx.setFoodName("燕麦");
+        idx.setIndexScore(88);
+        idx.setHealthReportId(10L);
+        idx.setCalculatedAt(new Date());
+        when(mappingService.resolve(eq("food_recommend"), eq("燕麦"))).thenReturn(6L);
+
+        pipeline.snapshotFoodIndices(3L, java.util.Collections.singletonList(idx));
+        verify(valueMapper).upsertMapping(argThat(v -> v.getSourceType().equals("food_recommend")
+                && v.getSourceId().equals(5L)
+                && v.getDefinitionId().equals(6L)));
+    }
+}

+ 92 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java

@@ -0,0 +1,92 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ReportDateExtractorTest {
+
+    private ReportDateExtractor newExtractor() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        // 不注入 mapper,仅测纯提取逻辑
+        return e;
+    }
+
+    @Test
+    void extractFromNestedSummary() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{\"reportDate\":\"2026-08-15\",\"personName\":\"小李\"},\"indicators\":[]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractFromFlatPayload() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"reportDate\":\"2026/08/15\"}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractReturnsNullWhenMissing() {
+        ReportDateExtractor e = newExtractor();
+        assertNull(e.extractReportDate("{\"summary\":{\"personName\":\"x\"}}"));
+        assertNull(e.extractReportDate(null));
+        assertNull(e.extractReportDate("not json"));
+    }
+
+    @Test
+    void extractFallsBackToIndicatorCollectionDate() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{},\"indicators\":[{\"collectionDate\":\"2026-09-01\"}]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-09-01", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void backfillFillsExtractableDateAndTerminates() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        HealthReportMapper mapper = Mockito.mock(HealthReportMapper.class);
+        ReflectionTestUtils.setField(e, "healthReportMapper", mapper);
+        HealthReport r = new HealthReport();
+        r.setId(1L);
+        r.setPayloadJson("{\"reportDate\":\"2026-08-15\"}");
+        Mockito.when(mapper.selectList(Mockito.any()))
+                .thenReturn(Arrays.asList(r), Collections.emptyList());
+        int n = e.backfillHistoricalReports();
+        assertEquals(1, n);
+        Mockito.verify(mapper, Mockito.times(1))
+                .updateById(Mockito.any(HealthReport.class));
+        assertEquals("2026-08-15",
+                new SimpleDateFormat("yyyy-MM-dd").format(r.getReportDate()));
+    }
+
+    @Test
+    void backfillSkipsUnextractableWithoutWrite() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        HealthReportMapper mapper = Mockito.mock(HealthReportMapper.class);
+        ReflectionTestUtils.setField(e, "healthReportMapper", mapper);
+        HealthReport r = new HealthReport();
+        r.setId(7L);
+        r.setPayloadJson("{\"reportDate\":\"\"}"); // 含字段但不可提取
+        Mockito.when(mapper.selectList(Mockito.any()))
+                .thenReturn(Arrays.asList(r), Collections.emptyList());
+        int n = e.backfillHistoricalReports();
+        assertEquals(1, n);
+        Mockito.verify(mapper, Mockito.never())
+                .updateById(Mockito.any(HealthReport.class));
+        assertNull(r.getReportDate());
+    }
+}

+ 67 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.ReportListQueryDTO;
+import com.etotem.cfc.dto.ReportSummaryVO;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.ReportSummary;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.mapper.ReportSummaryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ReportSummaryServiceCompletenessTest {
+
+    @Mock
+    private ReportSummaryMapper reportSummaryMapper;
+
+    @Mock
+    private HealthReportDraftMapper healthReportDraftMapper;
+
+    @InjectMocks
+    private ReportSummaryService service;
+
+    @Test
+    void adminPageList_fillsCollectValidationFromDraft() {
+        ReportSummary rs = new ReportSummary();
+        rs.setId(1L);
+        rs.setReportType("dan");
+        rs.setSourceTable("dan_report_uploads");
+        rs.setSourceId(1L);
+
+        doAnswer(inv -> {
+            Page<ReportSummary> p = inv.getArgument(0);
+            p.setRecords(Collections.singletonList(rs));
+            p.setTotal(1);
+            return p;
+        }).when(reportSummaryMapper).selectPage(any(), any());
+
+        HealthReportDraft d = new HealthReportDraft();
+        d.setConfirmedReportId(1L);
+        d.setReportType("dan_a1");
+        d.setCollectValidation("{\"typeId\":\"dan_a1\",\"expected\":31,\"matched\":30,\"pass\":false,\"missing\":[\"百分位\"]}");
+        when(healthReportDraftMapper.selectList(any())).thenReturn(Collections.singletonList(d));
+
+        ReportListQueryDTO query = new ReportListQueryDTO();
+        query.setPage(1);
+        query.setSize(20);
+
+        Page<ReportSummaryVO> page = service.adminPageList(query);
+
+        List<ReportSummaryVO> records = page.getRecords();
+        assertEquals(1, records.size());
+        assertEquals(d.getCollectValidation(), records.get(0).getCollectValidation());
+    }
+}

+ 60 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SpearmanUtilTest {
+
+    @Test
+    void perfectPositiveRankCorrelation() {
+        // x 与 y 完全同序 → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void perfectNegativeRankCorrelation() {
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(-1.0, rho, 1e-9);
+    }
+
+    @Test
+    void tiedRanksHandled() {
+        // 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void shortSeriesReturnsZero() {
+        // 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
+    }
+
+    @Test
+    void fisherZAverageRestoresPositiveRho() {
+        // 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
+        assertEquals(0.6, avg, 1e-6);
+    }
+
+    @Test
+    void fisherZAverageHandlesExtremeRho() {
+        // rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
+        assertTrue(avg > 0.9);
+    }
+}

+ 30 - 2
cfc-frontend/App.vue

@@ -1,7 +1,7 @@
 <script>
 import config from '@/config.js'
 import Vue from 'vue'
-import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin } from './utils/api.js'
+import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin, ssoLogin } from './utils/api.js'
 Vue.prototype.$config = config
 
 export default {
@@ -19,7 +19,7 @@ export default {
     // inviteCode=推荐码(文章/活动/商品分享)| refCode=推广码 | token/familyInviteCode=家庭邀请令牌 | id=内容详情
     var hasDeepLink = options && options.query && (
       options.query.inviteCode || options.query.refCode || options.query.token ||
-      options.query.familyInviteCode || options.query.id
+      options.query.familyInviteCode || options.query.id || options.query.ssoTicket
     )
     // 冷启动:跳转到 splash 页展示过渡动画,2秒后自动进入首页
     if (options.scene !== 1047 && options.scene !== 1048 && options.scene !== 1049 && !hasDeepLink) {
@@ -27,6 +27,8 @@ export default {
       uni.redirectTo({ url: '/pages/splash/index' })
       return
     }
+    // 跨小程序单点登录:携带 ssoTicket 进入 → 换取本地登录态
+    this.handleSsoLogin(options)
     // 处理小程序码场景值(从 wxacode 扫码进入,跳过 splash)
     this.handleScene(options)
   },
@@ -34,6 +36,8 @@ export default {
     console.log('App Show', options)
     // 热启动(含从分享卡重新进入):同样捕获分享参数
     this.captureReferralParams(options)
+    // 跨小程序单点登录:热启动携带 ssoTicket 进入同样处理
+    this.handleSsoLogin(options)
     // 每次 app 展示时也检查场景值(小程序码可能从冷启动后的 onShow 传入)
     this.handleScene(options)
     
@@ -167,6 +171,30 @@ bindReferralIfNotSelf(code).then(function(res) {
       // 同步 familyId 到 store(hasFamily getter 依赖;静默续期路径)
       this.$store.commit('setFamilyId', data.familyId)
     },
+    // 跨小程序单点登录:从 train 跳转进入时携带 ssoTicket → 换取本地登录态。
+    // 一次性票据(后端原子消费),用 _ssoHandledTicket 去重,避免 onLaunch/onShow 双处理。
+    handleSsoLogin(options) {
+      if (!options || !options.query) return
+      var ticket = options.query.ssoTicket
+      if (!ticket) return
+      if (this._ssoHandledTicket === ticket) return
+      this._ssoHandledTicket = ticket
+      var self = this
+      uni.showLoading({ title: '登录中...' })
+      ssoLogin(ticket).then(function(res) {
+        uni.hideLoading()
+        if (res && res.code === 200 && res.data) {
+          self.saveLoginInfo(res.data)
+          // 清空路由票据(防转发泄漏)并落地首页;index/index 会按角色重新分流
+          uni.reLaunch({ url: '/pages/index/index' })
+        } else {
+          uni.showToast({ title: (res && res.message) || '单点登录失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: (e && e.message) || '单点登录失败', icon: 'none' })
+      })
+    },
     // 处理小程序码场景值
     handleScene(options) {
       if (!options || !options.query) return

+ 75 - 0
cfc-frontend/__tests__/api/api.indicator.test.js

@@ -0,0 +1,75 @@
+/**
+ * 指标 API 封装契约测试
+ * 验证 URL / method / body 正确,且是规范入口(单 URL 单封装)
+ */
+import * as api from '@/utils/api'
+import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorQuickLookup, getIndicatorCorrelationList } from '@/utils/api'
+
+// --------------- helpers ---------------
+function mockSuccess(data) {
+  global.uni.request.mockImplementation((opts) => {
+    process.nextTick(() => {
+      if (opts.success) opts.success({ data })
+    })
+  })
+}
+
+function lastRequest() {
+  const calls = global.uni.request.mock.calls
+  return calls[calls.length - 1][0]
+}
+
+// --------------- global beforeEach ---------------
+describe('indicator API 封装', () => {
+  beforeEach(() => {
+    global.uni._storage = {}
+    global.uni.request.mockReset()
+    global.uni.showToast.mockReset()
+    global.uni.reLaunch.mockReset()
+    global.uni.login.mockReset()
+    global.getCurrentPages.mockReset()
+    global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
+    global.uni.getAccountInfoSync.mockReset()
+    global.uni.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' } })
+    api.clearApiDedup()
+  })
+
+  test('getIndicatorTrend → POST /api/indicator/trend with subjectId+definitionIds', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorTrend({ subjectId: 5, definitionIds: [1, 2] })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/trend$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.subjectId).toBe(5)
+    expect(opts.data.definitionIds).toEqual([1, 2])
+  })
+
+  test('getIndicatorChangeRanking → POST /api/indicator/change-ranking with scope+orderBy', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorChangeRanking({ subjectId: 5, scope: 'adjacent', orderBy: 'magnitude', limit: 50 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/change-ranking$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.scope).toBe('adjacent')
+    expect(opts.data.orderBy).toBe('magnitude')
+  })
+
+  test('getIndicatorQuickLookup → POST /api/indicator/quick-lookup with page', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorQuickLookup({ subjectId: 5, category: '核心菌属', page: 1, pageSize: 30 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/quick-lookup$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.page).toBe(1)
+    expect(opts.data.pageSize).toBe(30)
+  })
+
+  test('getIndicatorCorrelationList → POST /api/indicator/correlation/list with subjectId+definitionId', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorCorrelationList({ subjectId: 5, definitionId: 3 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/correlation\/list$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.definitionId).toBe(3)
+  })
+})

+ 64 - 0
cfc-frontend/__tests__/utils/indicator-change.test.js

@@ -0,0 +1,64 @@
+/**
+ * 指标变化规则纯函数单测
+ * 规则来源:design.md §8 变化颜色与箭头规则表
+ */
+import * as ic from '@/utils/indicator-change'
+
+describe('indicator-change 变化规则', () => {
+  describe('resolveChangeStyle(goodDirection, movement)', () => {
+    test('higher_is_better + up → 改善/绿 ↑', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'up')
+      expect(s.trendLabel).toBe('改善')
+      expect(s.cssClass).toBe('impr-up')
+      expect(s.arrow).toBe('↑')
+    })
+    test('higher_is_better + down → 恶化/红 ↓', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'down')
+      expect(s.trendLabel).toBe('恶化')
+      expect(s.cssClass).toBe('worst-down')
+      expect(s.arrow).toBe('↓')
+    })
+    test('lower_is_better + up → 恶化/红 ↑ (如炎症水平上升)', () => {
+      const s = ic.resolveChangeStyle('lower_is_better', 'up')
+      expect(s.trendLabel).toBe('恶化')
+      expect(s.cssClass).toBe('worst-up')
+      expect(s.arrow).toBe('↑')
+    })
+    test('lower_is_better + down → 改善/绿 ↓ (如炎症水平下降)', () => {
+      const s = ic.resolveChangeStyle('lower_is_better', 'down')
+      expect(s.trendLabel).toBe('改善')
+      expect(s.cssClass).toBe('impr-down')
+      expect(s.arrow).toBe('↓')
+    })
+    test('neutral + up/down → 中性/灰', () => {
+      const up = ic.resolveChangeStyle('neutral', 'up')
+      const down = ic.resolveChangeStyle('neutral', 'down')
+      expect(up.trendLabel).toBe('中性')
+      expect(up.cssClass).toBe('neutral-up')
+      expect(down.cssClass).toBe('neutral-down')
+    })
+    test('flat → 无变化/灰 →', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'flat')
+      expect(s.trendLabel).toBe('中性')
+      expect(s.cssClass).toBe('flat')
+      expect(s.arrow).toBe('→')
+    })
+    test('未知 goodDirection 兜底为中性', () => {
+      const s = ic.resolveChangeStyle('unknown_direction', 'up')
+      expect(s.cssClass).toBe('neutral-up')
+    })
+  })
+
+  describe('formatDelta(delta, deltaPct)', () => {
+    test('正 delta 显示 + 并带百分比', () => {
+      expect(ic.formatDelta(2, 100)).toBe('+2 (+100%)')
+    })
+    test('负 delta 显示 -', () => {
+      expect(ic.formatDelta(-1.5, -30)).toBe('-1.5 (-30%)')
+    })
+    test('null 安全', () => {
+      expect(ic.formatDelta(null, null)).toBe('--')
+      expect(ic.formatDelta(0, 0)).toBe('0 (0%)')
+    })
+  })
+})

+ 193 - 21
cfc-frontend/components/ContentSharePoster.vue

@@ -2,6 +2,50 @@
   <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
     <view class="poster-container">
       <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
+      <!-- 模板选择 -->
+      <view class="poster-tpl-row">
+        <view class="tpl-label">海报模板</view>
+        <view class="tpl-options">
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'gradient' }"
+            @click="switchTemplate('gradient')"
+          >
+            <view class="tpl-dot tpl-dot-grad"></view>
+            <text class="tpl-text">渐变</text>
+          </view>
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'minimal' }"
+            @click="switchTemplate('minimal')"
+          >
+            <view class="tpl-dot tpl-dot-dark"></view>
+            <text class="tpl-text">深色</text>
+          </view>
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'card' }"
+            @click="switchTemplate('card')"
+          >
+            <view class="tpl-dot tpl-dot-light"></view>
+            <text class="tpl-text">卡片</text>
+          </view>
+        </view>
+      </view>
+      <!-- 背景颜色选择(渐变模板可用) -->
+      <view class="poster-bg-row" v-if="template === 'gradient'">
+        <view class="tpl-label">背景色</view>
+        <view class="bg-options">
+          <view
+            v-for="(c, i) in bgColors"
+            :key="i"
+            class="bg-option"
+            :class="{ 'bg-active': bgColor === c }"
+            :style="{ background: c }"
+            @click="switchBgColor(c)"
+          ></view>
+        </view>
+      </view>
       <view class="poster-actions">
         <button class="poster-btn save" @click="savePoster">保存到相册</button>
       </view>
@@ -23,6 +67,12 @@ export default {
     priceText: { type: String, default: '' },
     ctaText: { type: String, default: '长按识别二维码 · 查看详情' },
     inviteText: { type: String, default: '' },
+    // 海报模板: gradient(default) | minimal | card
+    template: { type: String, default: 'gradient' },
+    // 自定义背景色(覆盖模板默认背景)
+    bgColor: { type: String, default: '' },
+    // 二维码尺寸覆盖(默认150rpx,建议活动类可用 100 缩小)
+    qrSizeOverride: { type: Number, default: 150 },
     // 五维能量数据卡:{ label?: String, overall?: Number, dims: [{ key, label, score, color }] }
     // 传 null 时不画数据卡,按 coverImage -> brandFallback -> 占位图 顺序降级(原有行为不变)
     energyData: { type: Object, default: null },
@@ -38,6 +88,12 @@ export default {
       }
     }
   },
+  data() {
+    return {
+      // 可选的背景色(渐变模板下展示)
+      bgColors: ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D', '#1E293B', '#FFFFFF']
+    }
+  },
   methods: {
     getColors() {
       var map = {
@@ -49,6 +105,35 @@ export default {
       }
       return map[this.dimensionCode] || { main: '#F97316', dark: '#EA580C', light: '#FFF7ED' }
     },
+    // 海报模板配色
+    getTemplateConfig() {
+      var colors = this.getColors()
+      var template = this.template || 'gradient'
+      var bgColor = this.bgColor || ''
+      // 梯度背景模板
+      if (template === 'gradient') {
+        return { template: 'gradient', bgColor: bgColor || colors.main, main: colors.main, dark: colors.dark, light: colors.light }
+      }
+      // 极简模板:深色背景+白色卡片
+      if (template === 'minimal') {
+        var dark = bgColor || '#1E293B'
+        return { template: 'minimal', bgColor: dark, main: '#F97316', dark: '#EF4444', light: '#F3F4F6' }
+      }
+      // 卡片模板:浅色背景+彩色顶栏
+      return { template: 'card', bgColor: bgColor || '#FFFFFF', main: colors.main, dark: colors.dark, light: colors.light }
+    },
+    // 获取当前模板的背景样式
+    getBgStyle() {
+      var cfg = this.getTemplateConfig()
+      if (cfg.template === 'gradient') {
+        var dark = cfg.dark
+        return 'linear-gradient(135deg, ' + cfg.main + ', ' + dark + ')'
+      }
+      if (cfg.template === 'minimal') {
+        return 'linear-gradient(135deg, ' + cfg.bgColor + ', ' + cfg.dark + ')'
+      }
+      return 'linear-gradient(135deg, ' + cfg.main + ', ' + (cfg.bgColor || '#FFFFFF') + ')'
+    },
     drawPoster() {
       var self = this
       uni.createSelectorQuery().in(this)
@@ -69,7 +154,7 @@ export default {
       var ctx = uni.createCanvasContext('contentPosterCanvas', this)
       var w = 500
       var h = 800
-      var colors = this.getColors()
+      var cfg = this.getTemplateConfig()
 
       // 画布 CSS 用 rpx,实际像素随设备变化(375px 屏约 310x496),而绘制按 500x800 设计坐标;
       // 不缩放坐标系时右侧/底部内容(含二维码 y≈552-702)会被裁剪出画布,导致海报显示不全。
@@ -77,20 +162,33 @@ export default {
         ctx.scale(scaleX, scaleY)
       }
 
-      var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
-      gradient.addColorStop(0, colors.main)
-      gradient.addColorStop(0.55, colors.dark)
-      gradient.addColorStop(1, colors.dark)
-      ctx.setFillStyle(gradient)
-      ctx.fillRect(0, 0, w, h)
+      // 【模板1 · gradient】渐变背景
+      if (cfg.template === 'gradient') {
+        var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
+        gradient.addColorStop(0, cfg.main)
+        gradient.addColorStop(0.55, cfg.dark)
+        gradient.addColorStop(1, cfg.dark)
+        ctx.setFillStyle(gradient)
+        ctx.fillRect(0, 0, w, h)
+      }
+      // 【模板2 · minimal】纯色深色背景
+      else if (cfg.template === 'minimal') {
+        ctx.setFillStyle(cfg.bgColor)
+        ctx.fillRect(0, 0, w, h)
+      }
+      // 【模板3 · card】白色背景
+      else {
+        ctx.setFillStyle('#FFFFFF')
+        ctx.fillRect(0, 0, w, h)
+      }
 
-      this._drawTopDecoration(ctx, w, colors)
+      this._drawTopDecoration(ctx, w, cfg)
 
       var cardX = 28
       var cardY = 112
       var cardW = 444
       var cardH = 600
-      this._drawContentCard(ctx, cardX, cardY, cardW, cardH, colors)
+      this._drawContentCard(ctx, cardX, cardY, cardW, cardH, cfg)
 
       var coverY = cardY + 20
       var coverW = cardW - 40
@@ -99,8 +197,8 @@ export default {
 
       if (this.energyData) {
         // 方案C:五维能量数据卡,canvas 直接绘制(文字锐利、数据真实、无网络开销)
-        this._drawEnergyCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawEnergyCard(ctx, cardX + 20, coverY, coverW, coverH, cfg)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       } else if (this.coverImage) {
         uni.downloadFile({
           url: this.coverImage,
@@ -112,29 +210,29 @@ export default {
                 success: function(info) {
                   var rect = self._fitCoverRect(info.width, info.height, cardX + 20, coverY, coverW, coverH)
                   ctx.drawImage(res.tempFilePath, rect.x, rect.y, rect.w, rect.h)
-                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
                 },
                 fail: function() {
                   ctx.drawImage(res.tempFilePath, cardX + 20, coverY, coverW, coverH)
-                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
                 }
               })
             } else {
               self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-              self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+              self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
             }
           },
           fail: function() {
             self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
           }
         })
       } else if (this.brandFallback) {
-        this._drawBrandCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawBrandCard(ctx, cardX + 20, coverY, coverW, coverH, cfg)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       } else {
         this._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       }
     },
 
@@ -370,8 +468,8 @@ export default {
       ctx.setTextBaseline('top')
       ctx.fillText(this.inviteText || '长按识别二维码 · 查看详情', w / 2, inviteY)
 
-      // 二维码
-      var qrSize = 150
+      // 二维码 - 可配置尺寸(默认150,可通过 qrSizeOverride 调整)
+      var qrSize = this.qrSizeOverride || 150
       var qrX = (w - qrSize) / 2
       var qrY = inviteY + 24
 
@@ -526,6 +624,21 @@ export default {
       ctx.closePath()
     },
 
+    switchTemplate(tpl) {
+      this.$emit('update:template', tpl)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+    switchBgColor(color) {
+      this.$emit('update:bgColor', color)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+
     savePoster() {
       var self = this
       uni.canvasToTempFilePath({
@@ -610,4 +723,63 @@ export default {
   font-weight: 600;
 }
 .poster-btn.save:active { opacity: 0.85; }
-</style>
+.poster-tpl-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0;
+  font-size: 24rpx;
+}
+.tpl-label {
+  margin-right: 20rpx;
+  color: #666;
+}
+.tpl-options {
+  display: flex;
+}
+.tpl-option {
+  flex: 1;
+  margin: 0 4rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+}
+.tpl-dot {
+  width: 24rpx;
+  height: 24rpx;
+  border-radius: 999rpx;
+  margin-bottom: 4rpx;
+}
+.tpl-dot-grad { background: linear-gradient(135deg, #FF8C42, #EA580C); }
+.tpl-dot-dark { background: #1E293B; }
+.tpl-dot-light { background: #FFFFFF; }
+.tpl-text {
+  font-size: 14rpx;
+  text-align: center;
+  color: #666;
+}
+.tpl-active .tpl-text { color: #F97316; }
+
+.poster-bg-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0;
+  font-size: 24rpx;
+}
+.bg-label {
+  margin-right: 20rpx;
+  color: #666;
+}
+.bg-options {
+  display: flex;
+}
+.bg-option {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 999rpx;
+  margin: 0 4rpx;
+  cursor: pointer;
+}
+.bg-active {
+  border: 2rpx solid #F97316;
+}</style>

+ 302 - 0
cfc-frontend/components/TrendChart.vue

@@ -0,0 +1,302 @@
+<template>
+  <view class="trend-chart-wrap">
+    <view class="trend-container" :style="containerStyle">
+      <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
+      <view class="trend-tap-layer" @click="onCanvasTap"></view>
+    </view>
+    <view class="trend-legend" v-if="series.length > 1">
+      <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
+        <view class="legend-line" :style="'border-color:' + s.color"></view>
+        <text class="legend-text">{{ s.name }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * TrendChart - 多序列折线图 Canvas 组件
+ * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
+ * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
+ */
+export default {
+  props: {
+    /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
+    series: { type: Array, default: function() { return [] } },
+    width: { type: Number, default: 690 },
+    height: { type: Number, default: 420 },
+    /** Y 轴自动范围下界(默认 min-5%) */
+    yMin: { type: Number, default: 0 },
+    /** 是否绘制数据点数值标注 */
+    showValues: { type: Boolean, default: true }
+  },
+  data: function() {
+    return {
+      canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
+      _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
+      _destroyed: false, _initTimer: null,
+      canvasId: 'trendCanvas_' + (this._uid || 0)
+    }
+  },
+  computed: {
+    containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
+    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
+  },
+  watch: {
+    series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
+  },
+  mounted: function() {
+    var self = this
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          self._initTimer = setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
+        }
+      })
+    }
+    self._initTimer = setTimeout(tryInit, 100)
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+    if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
+  },
+  methods: {
+    getItemKey: function(s, i) { return (s && s.name) || i },
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + this.canvasId)
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
+            var info = res[0]
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
+              width = Math.round((self.width || 690) * winWidth / 750)
+              height = Math.round((self.height || 420) * winWidth / 750)
+            }
+            var pixelRatio = 2
+            try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+            var ctx = canvas.getContext('2d')
+            if (!ctx) { if (cb) cb(false); return }
+            ctx.scale(pixelRatio, pixelRatio)
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self._canvasLeft = info.left || 0
+            self._canvasTop = info.top || 0
+            self.canvasReady = true
+            self._doRender()
+            if (cb) cb(true)
+          } catch (e) {
+            console.log('[TrendChart] initCanvas 回调异常', e)
+            if (cb) cb(false)
+          }
+        })
+    },
+
+    _safeSeries: function(arr) {
+      if (!Array.isArray(arr)) return []
+      return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
+    },
+
+    /** 计算 X 轴时间范围与 Y 轴数值范围 */
+    _computeRanges: function(seriesList) {
+      var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
+          if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
+          if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
+        })
+      })
+      if (min === Infinity) { min = 0; max = 1 }
+      if (max === min) { max = min + 1 }
+      var pad = (max - min) * 0.08
+      return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
+    },
+
+    _doRender: function() {
+      if (!this.ctx) return
+      var self = this
+      var ctx = this.ctx
+      var w = this.canvasWidth, h = this.canvasHeight
+      var padL = 34, padR = 14, padT = 16, padB = 26
+      var plotW = w - padL - padR, plotH = h - padT - padB
+      var seriesList = this._safeSeries(this.series)
+      if (seriesList.length === 0) {
+        ctx.clearRect(0, 0, w, h)
+        ctx.fillStyle = '#94A3B8'
+        ctx.font = '12px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.fillText('暂无趋势数据', w / 2, h / 2)
+        return
+      }
+      var ranges = this._computeRanges(seriesList)
+      var yMin = ranges.yMin, yMax = ranges.yMax
+      function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
+      // 日期横轴:按所有序列全部点排序后均分
+      var allDates = []
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
+        })
+      })
+      allDates.sort()
+      function xOf(dateStr) {
+        if (allDates.length <= 1) return padL + plotW / 2
+        var idx = allDates.indexOf(dateStr)
+        if (idx < 0) return padL
+        return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
+      }
+
+      ctx.clearRect(0, 0, w, h)
+      // 网格(4 条横线 + 2 条纵线)
+      ctx.strokeStyle = '#EEF2F7'
+      ctx.lineWidth = 1
+      for (var gi = 0; gi <= 3; gi++) {
+        var gy = padT + plotH * (gi / 3)
+        ctx.beginPath()
+        ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
+        ctx.stroke()
+      }
+      // Y 轴刻度文字
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'right'
+      for (var ti = 0; ti <= 3; ti++) {
+        var tv = yMin + (yMax - yMin) * (1 - ti / 3)
+        var ty = padT + plotH * (ti / 3)
+        ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
+      }
+      // 折线 + 数据点 + 数值
+      var palette = ['#F97316', '#0EA5E9', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B']
+      seriesList.forEach(function(s, si) {
+        var color = s.color || palette[si % palette.length]
+        ctx.strokeStyle = color
+        ctx.lineWidth = 2
+        ctx.beginPath()
+        var started = false
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
+        })
+        ctx.stroke()
+        // 数据点 + 值
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          ctx.fillStyle = color
+          ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
+          if (self.showValues) {
+            ctx.fillStyle = '#475569'
+            ctx.font = '10px sans-serif'
+            ctx.textAlign = 'center'
+            ctx.fillText(self._fmtValue(p), x, y - 8)
+          }
+        })
+        // 序列名(最右端上方)
+        ctx.fillStyle = color
+        ctx.font = '11px sans-serif'
+        ctx.textAlign = 'right'
+        var last = s.points[s.points.length - 1]
+        if (last && !isNaN(parseFloat(last.value))) {
+          ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
+        }
+      })
+      // X 轴日期标签(首/中/末)
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'center'
+      if (allDates.length > 0) {
+        ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
+        if (allDates.length > 1) {
+          var midIdx = Math.floor((allDates.length - 1) / 2)
+          ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
+          ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
+        }
+      }
+    },
+
+    _fmtNum: function(v) {
+      if (v >= 100) return Math.round(v) + ''
+      if (v >= 10) return v.toFixed(1)
+      return v.toFixed(2)
+    },
+    _fmtValue: function(p) {
+      var v = parseFloat(p.value)
+      if (isNaN(v)) return '--'
+      return this._fmtNum(v) + (p.unit ? p.unit : '')
+    },
+
+    onCanvasTap: function(e) {
+      // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
+      this.$emit('chartTap')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.trend-chart-wrap {
+  display: flex;
+  flex-direction: column;
+}
+.trend-container {
+  position: relative;
+}
+.trend-canvas {
+  display: block;
+}
+.trend-tap-layer {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
+.trend-legend {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: center;
+  padding-top: 12rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 0 16rpx 8rpx;
+}
+.legend-line {
+  width: 32rpx;
+  border-top: 4rpx solid #0EA5E9;
+  margin-right: 8rpx;
+}
+.legend-text {
+  font-size: 22rpx;
+  color: #475569;
+}
+</style>

+ 12 - 0
cfc-frontend/pages.json

@@ -1005,6 +1005,18 @@
             "navigationBarTitleText": "菌群报告详情"
           }
         },
+        {
+          "path": "indicator-curve",
+          "style": {
+            "navigationBarTitleText": "指标变化"
+          }
+        },
+        {
+          "path": "indicator-quicklook",
+          "style": {
+            "navigationBarTitleText": "指标速查"
+          }
+        },
         {
           "path": "gut-flora-species-detail",
           "style": {

+ 20 - 1
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -261,7 +261,12 @@
       :summary="posterSummary || ''"
       :priceText="posterPriceText || ''"
       :inviteText="posterInviteText || ''"
+      :template="posterTemplate"
+      :bgColor="posterBgColor"
+      :qrSizeOverride="100"
       @close="closePoster"
+      @update:template="posterTemplate = $event"
+      @update:bgColor="posterBgColor = $event"
     />
   </view>
 </template>
@@ -310,7 +315,9 @@ export default {
       posterQrCode: '',
       posterSummary: '',
       posterPriceText: '',
-      posterInviteText: ''
+      posterInviteText: '',
+      posterTemplate: 'gradient',
+      posterBgColor: ''   // 封面背景色
     }
   },
   onLoad: function(options) {
@@ -688,6 +695,7 @@ export default {
           this.posterSummary = this.buildPosterSummary()
           this.posterPriceText = this.activity.price > 0 ? '¥' + this.activity.price : '免费'
           this.posterInviteText = '「' + (uni.getStorageSync('nickname') || '好友') + '」邀请你一起参加这个活动'
+          this.posterBgColor = this.buildPosterBgColor()
           this.showPoster = true
         }
       } catch (e) {
@@ -701,6 +709,17 @@ export default {
       desc = desc.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
       return desc.length > 60 ? desc.substring(0, 60) + '...' : desc
     },
+    buildPosterBgColor: function() {
+      var dim = this.activity && this.activity.dimensionCode
+      var map = {
+        body: '#FF8C42',
+        mind: '#FF6B9D',
+        wisdom: '#6366F1',
+        action: '#10B981',
+        wealth: '#F59E0B'
+      }
+      return map[dim] || '#F97316'
+    },
     closePoster: function() {
       this.showPoster = false
     },

+ 136 - 5
cfc-frontend/pages/growth/diet-checkin.vue

@@ -131,13 +131,37 @@
       <!-- 底部留白 -->
       <view class="bottom-spacer"></view>
     </scroll-view>
+
+    <!-- 图片识别结果确认弹窗 -->
+    <view class="recognize-overlay" v-if="showRecognizeModal" @tap="cancelRecognize">
+      <view class="recognize-modal" @tap.stop>
+        <view class="recognize-header">
+          <text class="recognize-title">🍽️ 识别结果</text>
+          <text class="recognize-count">{{ recognizedFoods.length }} 种食材</text>
+        </view>
+        <view class="recognize-body">
+          <view class="food-chip" v-for="(f, idx) in recognizedFoods" :key="getFoodsKey(f, idx)">
+            <text class="food-chip-name">{{ f.name }}</text>
+            <text class="food-chip-cat" v-if="f.category && f.category !== 'unknown'">{{ f.category }}</text>
+            <text class="food-chip-del" @tap="removeRecognizedFood(idx)">×</text>
+          </view>
+          <view class="recognize-hint">
+            <text>可手动删除不需要的食材,确认后自动填入食物清单</text>
+          </view>
+        </view>
+        <view class="recognize-footer">
+          <view class="btn-cancel" @tap="cancelRecognize"><text>取消</text></view>
+          <view class="btn-confirm" @tap="confirmRecognize"><text>✓ 确认填入</text></view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
 import config from '../../config'
 import { parseDate } from '../../utils/format.js'
-import { sttTranscribe } from '../../utils/api.js'
+import { sttTranscribe, recognizeDietFood } from '../../utils/api.js'
 
 export default {
   data() {
@@ -151,6 +175,8 @@ export default {
       recorderManager: null,
       history: [],
       todayText: '',
+      showRecognizeModal: false,
+      recognizedFoods: [],
       mealTypes: [
         { label: '早餐', value: 'breakfast' },
         { label: '午餐', value: 'lunch' },
@@ -205,6 +231,10 @@ export default {
       uni.showLoading({ title: '语音识别中...', mask: true })
       sttTranscribe(res.tempFilePath).then(function(text) {
         self.form.voiceText = text
+        // 语音转写成功:若食物清单为空则自动填入
+        if (!self.form.foodItems || !self.form.foodItems.trim()) {
+          self.form.foodItems = text.trim()
+        }
         uni.hideLoading()
         // 保存时停止录音 → 语音已就绪,继续提交
         if (self.pendingSubmit) { self.pendingSubmit = false; self.doCheckin() }
@@ -265,10 +295,36 @@ export default {
         count: 9 - self.form.photos.length,
         sizeType: ['compressed'],
         sourceType: ['album', 'camera'],
-        success: function(res) {
-          res.tempFilePaths.forEach(function(path) {
-            if (self.form.photos.length < 9) self.form.photos.push(path)
-          })
+        success: async function(res) {
+          for (var i = 0; i < res.tempFilePaths.length; i++) {
+            if (self.form.photos.length >= 9) break
+            var path = res.tempFilePaths[i]
+            self.form.photos.push(path)
+            try {
+              var url = await self.uploadFile(path)
+              if (url) {
+                var recognizeRes = await new Promise(function(resolve) {
+                  uni.request({
+                    url: config.API_BASE_URL + '/api/diet/record/recognize',
+                    method: 'POST',
+                    data: { image_url: url },
+                    header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+                    success: function(r) { resolve(r) },
+                    fail: function() { resolve({ data: { code: 500 } }) }
+                  })
+                })
+                if (recognizeRes.data && recognizeRes.data.code === 200 && recognizeRes.data.data && recognizeRes.data.data.foods) {
+                  var foods = recognizeRes.data.data.foods
+                  for (var f = 0; f < foods.length; f++) {
+                    self.recognizedFoods.push({ name: foods[f].name, category: foods[f].category })
+                  }
+                }
+              }
+            } catch (e) { /* ignore */ }
+          }
+          if (self.recognizedFoods.length > 0) {
+            self.showRecognizeResult()
+          }
         }
       })
     },
@@ -299,6 +355,38 @@ export default {
     deleteVoice: function() {
       this.form.voicePath = ''
     },
+    showRecognizeResult: function() {
+      this.showRecognizeModal = true
+    },
+    confirmRecognize: function() {
+      var self = this
+      var names = []
+      for (var i = 0; i < self.recognizedFoods.length; i++) {
+        if (self.recognizedFoods[i].name && self.recognizedFoods[i].name.trim()) {
+          names.push(self.recognizedFoods[i].name.trim())
+        }
+      }
+      if (names.length > 0) {
+        var newText = names.join('、')
+        if (self.form.foodItems && self.form.foodItems.trim()) {
+          self.form.foodItems = self.form.foodItems + '、' + newText
+        } else {
+          self.form.foodItems = newText
+        }
+      }
+      self.showRecognizeModal = false
+      self.recognizedFoods = []
+    },
+    cancelRecognize: function() {
+      this.showRecognizeModal = false
+      this.recognizedFoods = []
+    },
+    removeRecognizedFood: function(idx) {
+      this.recognizedFoods.splice(idx, 1)
+    },
+    getFoodsKey: function(item, idx) {
+      return item.name || ('food' + idx)
+    },
     uploadFile: function(filePath) {
       var self = this
       return new Promise(function(resolve, reject) {
@@ -477,4 +565,47 @@ success: function(res) {
 
 /* ===== 底部留白 ===== */
 .bottom-spacer { height: 40rpx; }
+
+/* ===== 识别结果弹窗 ===== */
+.recognize-overlay {
+  position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.45); z-index: 999;
+  display: flex; align-items: center; justify-content: center;
+}
+.recognize-modal {
+  width: 600rpx; background: #fff; border-radius: 28rpx;
+  overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(0,0,0,0.18);
+}
+.recognize-header {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 28rpx 32rpx 20rpx; border-bottom: 1rpx solid #f0f0f0;
+}
+.recognize-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.recognize-count { font-size: 24rpx; color: #F97316; font-weight: 500; }
+.recognize-body { padding: 24rpx 32rpx; max-height: 420rpx; overflow-y: auto; }
+.food-chip {
+  display: inline-flex; align-items: center; gap: 8rpx;
+  background: #FFF3E0; border: 1rpx solid #FFE0B2; border-radius: 24rpx;
+  padding: 8rpx 20rpx; margin: 6rpx 8rpx 6rpx 0;
+}
+.food-chip-name { font-size: 26rpx; color: #E65100; font-weight: 500; }
+.food-chip-cat { font-size: 20rpx; color: #999; }
+.food-chip-del {
+  width: 32rpx; height: 32rpx; display: flex; align-items: center; justify-content: center;
+  background: #eee; border-radius: 50%; color: #999; font-size: 24rpx;
+}
+.recognize-hint {
+  margin-top: 16rpx; padding: 12rpx 16rpx; background: #FAFAFA;
+  border-radius: 12rpx;
+}
+.recognize-hint text { font-size: 22rpx; color: #999; }
+.recognize-footer {
+  display: flex; padding: 20rpx 32rpx 32rpx; gap: 20rpx;
+}
+.btn-cancel, .btn-confirm {
+  flex: 1; text-align: center; padding: 18rpx 0; border-radius: 14rpx;
+  font-size: 28rpx; font-weight: 600;
+}
+.btn-cancel { background: #f5f5f5; color: #666; }
+.btn-confirm { background: linear-gradient(135deg, #FF8C42, #F97316); color: #fff; box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25); }
 </style>

+ 118 - 2
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -71,6 +71,27 @@
         </view>
       </view>
 
+      <!-- 指标变化(最近两次报告) -->
+      <view class="change-section" v-if="changeItems.length > 0">
+        <view class="section-title-bar">
+          <text class="analysis-title">📈 指标变化</text>
+          <text class="analysis-subtitle">与上次报告对比</text>
+        </view>
+        <view class="change-card">
+          <view class="change-row" v-for="(item, ci) in changeItems" :key="getChangeKey(item, ci)">
+            <text class="change-name">{{ item.name }}</text>
+            <text class="change-before">{{ item.before ? item.before.value : '--' }}</text>
+            <text class="change-arrow" :class="'change-arrow ' + item._cssClass">{{ item._arrow }}</text>
+            <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text class="change-delta" :class="'change-delta ' + item._cssClass">{{ item._deltaText }}</text>
+            <text class="change-trend" :class="'change-trend ' + item._cssClass">{{ item._trendLabel }}</text>
+          </view>
+          <view class="change-more" @tap="goCurve" v-if="changeItems.length > 0">
+            <text class="change-more-text">查看完整变化曲线 ›</text>
+          </view>
+        </view>
+      </view>
+
       <!-- 菌群功能分析 -->
       <view class="analysis-section" v-if="analysisData">
         <view class="section-title-bar">
@@ -332,10 +353,11 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember, getIndicatorChangeRanking } from '../../utils/api.js'
 import HealthKnowledgePopup from '@/pages/health/components/health-knowledge-popup.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import { parseDate } from '../../utils/format.js'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '../../utils/indicator-change.js'
 
 var INDICATOR_TYPE_MAP = {
   '维生素': 'vitamin',
@@ -398,7 +420,9 @@ export default {
       candidates: [],
       extractedName: '',
       extractedGender: '',
-      extractedAge: ''
+      extractedAge: '',
+      changeItems: [],
+      _changeLoaded: false
     }
   },
   computed: {
@@ -640,6 +664,8 @@ riskGroupAbnormalCount: function() {
       })
       // 加载菌群功能分析
       this.loadAnalysis()
+      // 加载指标变化区块
+      this.loadChangeBlock()
     },
     loadAnalysis: function() {
       var self = this
@@ -1006,6 +1032,33 @@ applyDraftPayload: function(payload) {
       this.nutriGroupOpen = arr
       if (this.nutritionGroups.length === 0) uni.showToast({ title: '暂无营养指标', icon: 'none' })
     },
+    loadChangeBlock: function() {
+      var self = this
+      if (!this.report || !this.report.subjectId || this._changeLoaded) return
+      this._changeLoaded = true
+      getIndicatorChangeRanking({
+        subjectId: this.report.subjectId,
+        reportType: 'gut_flora',
+        scope: 'adjacent',
+        orderBy: 'magnitude',
+        limit: 6
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        items.forEach(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
+          it._cssClass = colorClassFor(st.cssClass)
+          it._arrow = st.arrow
+          it._trendLabel = st.trendLabel
+          it._deltaText = formatDelta(it.delta, it.deltaPct)
+        })
+        self.changeItems = items
+      }).catch(function() {})
+    },
+    getChangeKey: function(item, i) { return item.definitionId || i },
+    goCurve: function() {
+      uni.navigateTo({ url: '/pages/health/indicator-curve?subjectId=' + this.report.subjectId + '&reportType=gut_flora' })
+    },
     // 问答功能已屏蔽
     goAIChat: function() {}
   }
@@ -1232,6 +1285,69 @@ applyDraftPayload: function(payload) {
   display: block;
   margin-top: 4rpx;
 }
+/* ===== 指标变化(最近两次报告对比) ===== */
+.change-section {
+  margin: 20rpx 30rpx;
+}
+.change-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.change-row {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+  font-size: 24rpx;
+}
+.change-row:last-child { border-bottom: none; }
+.change-name {
+  width: 240rpx;
+  color: #333;
+  font-weight: 500;
+  flex-shrink: 0;
+}
+.change-before {
+  color: #999;
+  width: 110rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.change-arrow {
+  width: 50rpx;
+  text-align: center;
+  font-size: 28rpx;
+  flex-shrink: 0;
+}
+.change-after {
+  color: #333;
+  width: 110rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.change-delta {
+  width: 120rpx;
+  text-align: center;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.change-trend {
+  font-size: 22rpx;
+  color: #888;
+  margin-left: 8rpx;
+  flex: 1;
+}
+.change-more {
+  margin-top: 12rpx;
+  padding-top: 16rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+.change-more-text {
+  font-size: 24rpx;
+  color: #4A9BD7;
+}
 .analysis-card {
   background: #fff;
   border-radius: 16rpx;

+ 444 - 0
cfc-frontend/pages/health/indicator-curve.vue

@@ -0,0 +1,444 @@
+<template>
+  <view class="page">
+    <!-- 顶部筛选:成员 / 报告类型 -->
+    <view class="filter-bar">
+      <picker :range="memberNames" @change="onMemberChange">
+        <view class="filter-item">{{ currentMemberName }} ▾</view>
+      </picker>
+      <picker :range="reportTypeNames" @change="onReportTypeChange">
+        <view class="filter-item">{{ currentReportTypeName }} ▾</view>
+      </picker>
+    </view>
+
+    <!-- 曲线区:指标选择器 + TrendChart -->
+    <view class="curve-section">
+      <view class="section-title">指标变化曲线</view>
+      <view class="indicator-picker">
+        <scroll-view scroll-x class="indicator-scroll">
+          <view
+            class="indicator-chip"
+            :class="def.definitionId === theIndicatorId ? 'chip-active' : ''"
+            v-for="def in indicatorOptions"
+            :key="getIndicatorKey(def)"
+            @tap="onIndicatorTap(getIndicatorId(def))"
+          >{{ getIndicatorName(def) }}</view>
+        </scroll-view>
+      </view>
+      <TrendChart v-if="series && series.length > 0" :series="series" />
+      <view class="empty-tip" v-else>暂无趋势数据</view>
+    </view>
+
+    <!-- 变化排名区 -->
+    <view class="ranking-section">
+      <view class="section-title">变化排名</view>
+      <view class="rank-tabs">
+        <view
+          class="rank-tab"
+          :class="'rank-' + activeRankTab"
+          v-for="(tab, ti) in rankTabs"
+          :key="getTabKey(tab, ti)"
+          @tap="onRankTabTap(tab.value)"
+        >{{ tab.label }}</view>
+      </view>
+      <view class="rank-sorts">
+        <view
+          class="sort-btn"
+          :class="sortBy === s.value ? 'sort-active' : ''"
+          v-for="s in sortOptions"
+          :key="getSortKey(s)"
+          @tap="onSortTap(s.value)"
+        >{{ s.label }}</view>
+      </view>
+      <view class="rank-list" v-if="rankItems.length > 0">
+        <view class="rank-item" v-for="item in rankItems" :key="getRankKey(item)">
+          <view class="rank-name-row">
+            <text class="rank-name">{{ item.name }}</text>
+            <text class="rank-category">{{ item.category }}</text>
+          </view>
+          <view class="rank-value-row">
+            <text class="rank-before">{{ item.before ? item.before.value : '--' }}</text>
+            <text :class="'rank-arrow ' + item._cssClass">→</text>
+            <text class="rank-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text :class="'rank-delta ' + item._cssClass">{{ item._deltaText }}</text>
+          </view>
+          <text :class="'rank-trend ' + item._cssClass">{{ item._trendLabel }} {{ item._arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import TrendChart from '@/components/TrendChart.vue'
+import { getIndicatorTrend, getIndicatorChangeRanking } from '@/utils/api'
+import { getFamilyMemberList } from '@/utils/api'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+import { parseDate } from '@/utils/format'
+
+export default {
+  components: { TrendChart },
+  data: function() {
+    return {
+      members: [], currentMemberId: null,
+      reportTypeOptions: [
+        { value: 'gut_flora', label: '肠道菌群' },
+        { value: 'food_recommend', label: '食材推荐' }
+      ],
+      activeReportType: 'gut_flora',
+      indicatorOptions: [], theIndicatorId: null,
+      series: [], loadedDefinitionIds: [],
+      rankTabs: [ { value: 'flora', label: '菌群变化' }, { value: 'food', label: '食材变化' }, { value: 'indicator', label: '指标变化' } ],
+      activeRankTab: 'flora',
+      sortOptions: [ { value: 'magnitude', label: '绝对幅度' }, { value: 'up', label: '升幅↑' }, { value: 'down', label: '降幅↓' } ],
+      sortBy: 'magnitude',
+      rankItems: [], overall: {},
+      _onLoadFired: false, _loading: false
+    }
+  },
+  computed: {
+    memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
+    reportTypeNames: function() {
+      var self = this
+      return this.reportTypeOptions.map(function(o) { return o.label })
+    },
+    currentMemberName: function() {
+      var self = this
+      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
+      return (m && (m.name || m.nickname)) || '选择成员'
+    },
+    currentReportTypeName: function() {
+      var self = this
+      var o = this.reportTypeOptions.find(function(x) { return x.value === self.activeReportType })
+      return (o && o.label) || ''
+    }
+  },
+  onLoad: function(options) {
+    this.loadMembers()
+    if (options && options.definitionId) {
+      this.theIndicatorId = Number(options.definitionId)
+    }
+    this._onLoadFired = true
+  },
+  onShow: function() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else if (this.currentMemberId) {
+      this.reloadAll()
+    }
+  },
+  methods: {
+    getIndicatorId: function(def) { return def ? def.definitionId : null },
+    getIndicatorName: function(def) { return (def && def.name) || '' },
+    getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
+    getTabKey: function(tab, i) { return tab.value || i },
+    getSortKey: function(s) { return s.value },
+    getRankKey: function(item) { return item.definitionId },
+
+    loadMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.currentMemberId && arr.length > 0) {
+          self.currentMemberId = arr[0].id
+          self.onMemberSelected()
+        }
+      })
+    },
+    onMemberSelected: function() { this.reloadAll() },
+    onMemberChange: function(e) {
+      var idx = Number(e.detail.value)
+      var m = this.members[idx]
+      if (m) { this.currentMemberId = m.id; this.reloadAll() }
+    },
+    onReportTypeChange: function(e) {
+      var idx = Number(e.detail.value)
+      var o = this.reportTypeOptions[idx]
+      if (o) { this.activeReportType = o.value; this.reloadAll() }
+    },
+    reloadAll: function() {
+      if (!this.currentMemberId) return
+      this.loadCurve()
+      this.loadRanking()
+    },
+    loadCurve: function() {
+      // 曲线页:先拉 ranking 获得指标候选,再拉 trend(避免硬编码 definition 列表)
+      this.loadRankingThenCurve()
+    },
+    loadRankingThenCurve: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      var reportType = this.activeReportType === 'food_recommend' ? null : this.activeReportType
+      getIndicatorChangeRanking({
+        subjectId: this.currentMemberId,
+        reportType: reportType,
+        category: this.activeRankTab === 'food' ? null : (this.activeRankTab === 'flora' ? '核心菌属' : null),
+        scope: 'adjacent', orderBy: 'magnitude', limit: 30
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 用排名结果作为指标候选
+        self.indicatorOptions = items.slice(0, 10).map(function(it) {
+          return { definitionId: it.definitionId, name: it.name }
+        })
+        if (self.indicatorOptions.length > 0) {
+          var target = self.theIndicatorId
+          var found = self.indicatorOptions.find(function(d) { return d.definitionId === target })
+          if (!found) { self.theIndicatorId = self.indicatorOptions[0].definitionId }
+          self.loadTrendFor([self.theIndicatorId])
+        } else {
+          self.series = []
+        }
+      })
+    },
+    loadTrendFor: function(definitionIds) {
+      var self = this
+      if (!this.currentMemberId || !definitionIds || definitionIds.length === 0) return
+      getIndicatorTrend({ subjectId: this.currentMemberId, definitionIds: definitionIds }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        var series = items.map(function(it) {
+          var color = ''
+          return { name: it.name, color: color || '#F97316', points: (it.points || []).map(function(p) { return { reportDate: String(p.reportDate).slice(0, 10), value: p.value, unit: p.unit } }) }
+        })
+        self.series = series
+      })
+    },
+    onIndicatorTap: function(definitionId) {
+      if (definitionId === this.theIndicatorId) return
+      this.theIndicatorId = definitionId
+      this.loadTrendFor([definitionId])
+    },
+    onRankTabTap: function(value) { this.activeRankTab = value; this.loadRanking() },
+    onSortTap: function(value) { this.sortBy = value; this.loadRanking() },
+    loadRanking: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      var category = null
+      if (this.activeRankTab === 'flora') category = '核心菌属'
+      else if (this.activeRankTab === 'indicator') category = null
+      var reportType = this.activeRankTab === 'food' ? 'food_recommend' : (this.activeRankTab === 'flora' ? 'gut_flora' : null)
+      getIndicatorChangeRanking({
+        subjectId: this.currentMemberId,
+        reportType: reportType,
+        category: category,
+        scope: 'adjacent', orderBy: this.sortBy, limit: 50
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 预计算展示字段,避免小程序模板在 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        self.rankItems = items.map(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement)
+          it._cssClass = colorClassFor(st.cssClass)
+          it._trendLabel = st.trendLabel
+          it._arrow = st.arrow
+          it._deltaText = formatDelta(it.delta, it.deltaPct)
+          return it
+        })
+        self.overall = (res.data && res.data.overall) || {}
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F9FC;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+/* 顶部筛选 */
+.filter-bar {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 24rpx;
+}
+
+.filter-item {
+  flex: 1;
+  background-color: #FFFFFF;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 16rpx 24rpx;
+  margin-right: 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  text-align: center;
+}
+
+.filter-item:last-child {
+  margin-right: 0;
+}
+
+/* 区块通用 */
+.curve-section,
+.ranking-section {
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(30, 41, 59, 0.06);
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 20rpx;
+}
+
+/* 指标选择器 */
+.indicator-picker {
+  margin-bottom: 20rpx;
+}
+
+.indicator-scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.indicator-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 10rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 32rpx;
+  background-color: #EDF2F7;
+  font-size: 24rpx;
+  color: #64748B;
+}
+
+.chip-active {
+  background-color: #5B9BD5;
+  color: #FFFFFF;
+}
+
+.empty-tip {
+  padding: 60rpx 0;
+  text-align: center;
+  font-size: 26rpx;
+  color: #94A3B8;
+}
+
+/* 排名 Tab */
+.rank-tabs {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 16rpx;
+}
+
+.rank-tab {
+  flex: 1;
+  text-align: center;
+  padding: 14rpx 0;
+  font-size: 26rpx;
+  color: #64748B;
+  border-bottom: 4rpx solid transparent;
+}
+
+.rank-tab.rank-flora,
+.rank-tab.rank-food,
+.rank-tab.rank-indicator {
+  color: #64748B;
+}
+
+.rank-sorts {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 20rpx;
+}
+
+.sort-btn {
+  padding: 8rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 24rpx;
+  font-size: 24rpx;
+  color: #64748B;
+  background-color: #EDF2F7;
+}
+
+.sort-active {
+  background-color: #D6EAF8;
+  color: #3A7CC4;
+}
+
+/* 排名列表 */
+.rank-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.rank-item {
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #E2E8F0;
+}
+
+.rank-item:last-child {
+  border-bottom: none;
+}
+
+.rank-name-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.rank-name {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 500;
+  margin-right: 16rpx;
+}
+
+.rank-category {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+
+.rank-value-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+
+.rank-before {
+  font-size: 26rpx;
+  color: #64748B;
+}
+
+.rank-arrow {
+  font-size: 26rpx;
+  margin: 0 12rpx;
+}
+
+.rank-after {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 600;
+}
+
+.rank-delta {
+  font-size: 24rpx;
+  margin-left: 16rpx;
+}
+
+.rank-trend {
+  display: block;
+  font-size: 22rpx;
+}
+
+/* 变化着色:改善=绿 / 恶化=红 / 中性=灰 */
+.green-text {
+  color: #16A34A;
+}
+
+.red-text {
+  color: #DC2626;
+}
+
+.gray-text {
+  color: #94A3B8;
+}
+</style>

+ 154 - 0
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -0,0 +1,154 @@
+<template>
+  <view class="page">
+    <!-- 成员切换 -->
+    <picker :range="memberNames" @change="onMemberChange">
+      <view class="member-picker">{{ currentMemberName }} ▾</view>
+    </picker>
+
+    <!-- 分类 Tab -->
+    <scroll-view scroll-x class="cat-scroll">
+      <view class="cat-tab" :class="activeCategory === '' ? 'cat-active' : ''" @tap="onCatTap('')">全部</view>
+      <view
+        class="cat-tab"
+        :class="activeCategory === c.name ? 'cat-active' : ''"
+        v-for="c in categories"
+        :key="getCatKey(c)"
+        @tap="onCatTap(c.name)"
+      >{{ c.name }}({{ c.total }})</view>
+    </scroll-view>
+
+    <!-- 指标列表 -->
+    <view class="item-list" v-if="items.length > 0">
+      <view class="ql-item" v-for="item in items" :key="getItemKey(item)" @tap="goCurve(getItemId(item))">
+        <view class="ql-name-row">
+          <text class="ql-name">{{ item.name }}</text>
+          <text :class="'ql-trend ' + item._cssClass">{{ item._arrow }} {{ item._trendLabel }}</text>
+        </view>
+        <view class="ql-value-row">
+          <text class="ql-value">{{ item.value }}{{ item.unit || '' }}</text>
+          <text class="ql-ref">参考: {{ item.refRange || '--' }}</text>
+          <text class="ql-status" :class="item._statusClass">{{ item.status || '' }}</text>
+        </view>
+        <text class="ql-date">{{ item._dateText }}</text>
+      </view>
+    </view>
+    <view class="empty-tip" v-else>暂无数据</view>
+
+    <!-- 分页 -->
+    <view class="pager" v-if="totalPages > 1">
+      <view class="pager-btn" :class="page <= 1 ? 'pager-disabled' : ''" @tap="onPrevPage">上一页</view>
+      <text class="pager-info">{{ page }} / {{ totalPages }}</text>
+      <view class="pager-btn" :class="page >= totalPages ? 'pager-disabled' : ''" @tap="onNextPage">下一页</view>
+    </view>
+
+    <!-- 五维/七维评分小节 -->
+    <view class="dim-section" v-if="dimensionScores.length > 0">
+      <view class="section-title">维度评分趋势</view>
+      <view class="dim-list">
+        <view class="dim-row" v-for="d in dimensionScores" :key="getDimKey(d)">
+          <text class="dim-name">{{ d.name }}</text>
+          <text class="dim-score">{{ d.score }}</text>
+          <text :class="'dim-trend ' + d._cssClass">{{ d._arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getIndicatorQuickLookup } from '@/utils/api'
+import { getFamilyMemberList } from '@/utils/api'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+
+export default {
+  data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
+  computed: {
+    memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
+    currentMemberName: function() {
+      var self = this
+      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
+      return (m && (m.name || m.nickname)) || '选择成员'
+    },
+    categoriesComputed: function() {
+      var self = this
+      return this.categories || []
+    }
+  },
+  methods: {
+    onLoad: function() { this.loadMembers(); this._onLoadFired = true },
+    onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
+    goCurve: function(definitionId) {
+      uni.navigateTo({ url: '/pages/health/indicator-curve?definitionId=' + definitionId })
+    },
+    loadMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.currentMemberId && arr.length > 0) { self.currentMemberId = arr[0].id; self.reload() }
+      })
+    },
+    reload: function() { this.page = 1; this.loadData() },
+    loadData: function() {
+      var self = this
+      if (!this.currentMemberId || this._loading) return
+      this._loading = true
+      getIndicatorQuickLookup({ subjectId: this.currentMemberId, category: this.activeCategory || undefined, page: this.page, pageSize: this.pageSize }).then(function(res) {
+        self._loading = false
+        var data = res.data || {}
+        // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        self.rankItems = (data && data.items) || []
+        self.rankItems.forEach(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
+          it._cssClass = colorClassFor(st.cssClass)
+          it._arrow = st.arrow
+          it._trendLabel = st.trendLabel
+          it._statusClass = 'status-' + (it._cssClass || 'normal')
+          it._dateText = it.latestReportDate ? String(it.latestReportDate).slice(0, 10) : '--'
+        })
+        self.categories = data.categories || []
+        self.items = data.items || []
+        self.dimensionScores = data.dimensionScores || []
+        var total = data.total !== undefined ? data.total : self.items.length
+        self.totalPages = Math.max(1, Math.ceil(total / self.pageSize))
+      }).catch(function() { self._loading = false })
+    },
+    onCatTap: function(name) { this.activeCategory = name; this.reload() },
+    onPrevPage: function() { if (this.page > 1) { this.page--; this.loadData() } },
+    onNextPage: function() { if (this.page < this.totalPages) { this.page++; this.loadData() } },
+    formatDate: function(s) { if (!s) return '--'; return String(s).slice(0, 10) },
+    getCatKey: function(c) { return c.name },
+    getItemKey: function(item) { return item.definitionId },
+    getItemId: function(item) { return item.definitionId },
+    getDimKey: function(d) { return d.name }
+  }
+}
+</script>
+
+<style scoped>
+.member-picker { font-size: 24rpx; color: #666; }
+.cat-scroll { height: 40rpx; line-height: 40rpx; overflow-x: scroll; }
+.cat-tab { float: left; margin-right: 20rpx; padding: 0 12rpx; height: 32rpx; line-height: 32rpx; font-size: 22rpx; color: #999; }
+.cat-tab.cat-active { color: #4A9BD7; border-bottom: 2rpx solid #4A9BD7; }
+.item-list { padding: 20rpx; }
+.ql-item { margin-bottom: 20rpx; }
+.ql-name-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
+.ql-name { font-size: 26rpx; }
+.ql-trend { font-size: 20rpx; color: #666; }
+.ql-value-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
+.ql-value { font-size: 24rpx; }
+.ql-ref { font-size: 16rpx; color: #999; }
+.ql-status { font-size: 20rpx; }
+.ql-date { font-size: 16rpx; color: #999; margin-top: 4rpx; }
+.pager { height: 40rpx; line-height: 40rpx; padding: 0 20rpx; font-size: 20rpx; }
+.pager-btn { float: left; margin-right: 10rpx; padding: 0 12rpx; height: 32rpx; line-height: 32rpx; font-size: 20rpx; color: #999; }
+.pager-btn.pager-disabled { color: #ccc; }
+.pager-info { float: left; margin: 0 20rpx; }
+.dim-section { padding: 20rpx; }
+.section-title { font-size: 26rpx; margin-bottom: 12rpx; }
+.dim-list { display: flex; flex-wrap: wrap; }
+.dim-row { margin-bottom: 12rpx; }
+.dim-name { font-size: 22rpx; width: 60rpx; }
+.dim-score { font-size: 22rpx; color: #666; }
+.dim-trend { font-size: 18rpx; color: #999; }
+</style>

+ 75 - 29
cfc-frontend/pages/index-home/index.vue

@@ -103,12 +103,12 @@
       :notices="notices"
       :showHeader="true" />
 
-    <!-- 能量/积分/CF值变更通知条 -->
-    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" @click="clearChangeNotifications">
-      <text v-if="showEnergyBadge" class="change-item energy">{{ formatEnergyNotification }}</text>
-      <text v-if="showPointsBadge" class="change-item points">{{ formatPointsNotification }}</text>
-      <text v-if="showCfBadge" class="change-item cf">{{ formatCfNotification }}</text>
-      <text class="change-dismiss">✕</text>
+    <!-- 能量/积分/CF值变更通知 - 从下滑动展示 -->
+    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" :class="{ 'visible': isNotificationVisible }" @click="handleNotificationClick">
+      <view v-if="showEnergyBadge" class="change-item energy" @click.stop="onEnergyClick">{{ formatEnergyNotification }}</view>
+      <view v-if="showPointsBadge" class="change-item points" @click.stop="onPointsClick">{{ formatPointsNotification }}</view>
+      <view v-if="showCfBadge" class="change-item cf" @click.stop="onCfClick">{{ formatCfNotification }}</view>
+      <view class="change-dismiss" @click="dismissAll">✕</view>
     </view>
 
     <!-- ④ 内容区 — 推荐文章 -->
@@ -533,7 +533,9 @@ membershipDays: 0,
       cfMessages: [],
       showEnergyBadge: false,
       showPointsBadge: false,
-      showCfBadge: false
+      showCfBadge: false,
+      // 通知栏可见性(从下往上滑入)
+      isNotificationVisible: false
 
 
     }
@@ -691,30 +693,36 @@ title: '添加家庭成员',
         color: 'gray'
       }
     },
-    // ===== 能量/积分/CF值变更通知格式化 =====
+    // ===== 能量/积分/CF值变更通知格式化(标题带具体数量) =====
     formatEnergyNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.energyMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '⚡ ' + (texts.join(';') || '能量变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '能量变动'
+      return '⚡ 能量 ' + sign + total + ' · ' + first
     },
     formatPointsNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.pointsMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '🪙 ' + (texts.join(';') || '积分变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '积分变动'
+      return '🪙 积分 ' + sign + total + ' · ' + first
     },
     formatCfNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.cfMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '💰 ' + (texts.join(';') || 'CF值变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : 'CF值变动'
+      return '💰 CF值 ' + sign + total + ' · ' + first
     }
   },
   onLoad(options) {
@@ -1176,6 +1184,9 @@ title: '添加家庭成员',
           self.cfMessages = cfRes.messages
           self.showCfBadge = cfRes.showBadge
         }
+        
+        // 通知栏滑入动画:数据加载完成后触发从下往上的展示
+        self.isNotificationVisible = true
       }).catch(function() {
         self.energyMessages = []
         self.pointsMessages = []
@@ -1400,7 +1411,36 @@ title: '添加家庭成员',
              date.getFullYear() === today.getFullYear()
     },
     
-    clearChangeNotifications: function() {
+    // ===== 能量/积分/CF值变更通知点击处理 =====
+    // 点击通知内容 → 跳转详情页 + 隐藏通知(点击后不再显示)
+    handleNotificationClick: function() {
+      // 点击空白区域:若有多个通知,跳转第一个可见的
+      if (this.showEnergyBadge) {
+        this.onEnergyClick()
+      } else if (this.showPointsBadge) {
+        this.onPointsClick()
+      } else if (this.showCfBadge) {
+        this.onCfClick()
+      } else {
+        this.dismissAll()
+      }
+    },
+    onEnergyClick: function() {
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId') || ''
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/energy/detail?memberId=' + memberId })
+    },
+    onPointsClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/rewards/rewards' })
+    },
+    onCfClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/promotion/index' })
+    },
+    // 关闭按钮:仅隐藏,不跳转
+    dismissAll: function() {
+      this.isNotificationVisible = false
       this.showEnergyBadge = false
       this.showPointsBadge = false
       this.showCfBadge = false
@@ -2822,26 +2862,31 @@ title: '添加家庭成员',
 }
 .self-test-badge { display: inline-block; align-self: flex-start; margin-top: 8rpx; font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1); padding: 4rpx 14rpx; border-radius: 999rpx; }
 
-/* ===== 能量/积分/CF值变更通知条 ===== */
+/* ===== 能量/积分/CF值变更通知条(从下滑入动画) ===== */
 .change-notification-bar {
-  margin: 16rpx 30rpx;
+  position: fixed;
+  bottom: -80rpx;
+  left: 30rpx;
+  right: 30rpx;
   padding: 16rpx 24rpx;
   background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
   border-radius: 16rpx;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 16rpx;
   box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.12);
-  flex-wrap: wrap;
+  transition: bottom 0.3s cubic-bezier(0.16,1,0.3,1) forwards;
+  z-index: 100;
+}
+.change-notification-bar.visible {
+  bottom: 0;
+  animation: slide-up-notice 0.4s cubic-bezier(0.16,1,0.3,1) forwards;
+}
+@keyframes slide-up-notice {
+  from { opacity: 0; transform: translateY(80rpx); }
+  to   { opacity: 1; transform: translateY(0); }
 }
 .change-item {
   font-size: 22rpx;
   font-weight: 500;
-  max-width: 100%;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  color: #333;
 }
 .change-item.energy { color: #F97316; }
 .change-item.points { color: #10B981; }
@@ -2851,5 +2896,6 @@ title: '添加家庭成员',
   font-size: 24rpx;
   color: #999;
   flex-shrink: 0;
+  cursor: pointer;
 }
 </style>

+ 35 - 0
cfc-frontend/pages/profile/index.vue

@@ -14,6 +14,11 @@
       </view>
       <ProfileHeader @avatar-click="goToEditInfo" />
       <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+      <view class="sso-entry" @click="goTrain">
+        <text class="sso-entry-icon">🎓</text>
+        <text class="sso-entry-text">进入训练营</text>
+        <text class="sso-entry-arrow">›</text>
+      </view>
     </template>
   </view>
 </template>
@@ -22,6 +27,7 @@
 import ProfileHeader from '@/pages/profile/components/ProfileHeader.vue'
 import ProfileMenu from '@/pages/profile/components/ProfileMenu.vue'
 import { getReferralCode, extractReferralCode } from '../../utils/api.js'
+import { jumpToTrain } from '../../utils/sso.js'
 
 export default {
   components: {
@@ -107,6 +113,9 @@ export default {
         url: '/pages/user-edit/user-edit'
       })
     },
+    goTrain() {
+      jumpToTrain()
+    },
     onInviteGenerate(data) {
       this.shareData = data
       uni.showToast({
@@ -189,4 +198,30 @@ export default {
   font-size: 28rpx;
   color: #666;
 }
+/* 跨小程序单点登录入口 */
+.sso-entry {
+  display: flex;
+  align-items: center;
+  margin: 20rpx 30rpx 0;
+  padding: 28rpx 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
+}
+.sso-entry:active {
+  background: #f8fafc;
+}
+.sso-entry-icon {
+  font-size: 40rpx;
+  margin-right: 16rpx;
+}
+.sso-entry-text {
+  flex: 1;
+  font-size: 30rpx;
+  color: #333;
+}
+.sso-entry-arrow {
+  font-size: 40rpx;
+  color: #cbd5e1;
+}
 </style>

+ 20 - 0
cfc-frontend/utils/api.js

@@ -3385,3 +3385,23 @@ export const getVendorTeamCustomers = (params) =>
 
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
+
+// ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
+/** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
+export const createSsoTicket = (target) =>
+  request('/api/auth/sso-ticket', 'POST', { target: target || 'train' })
+
+/** 用一次性票据换取登录态。返回登录信息(结构同 wechatLogin 的 data) */
+export const ssoLogin = (ticket) =>
+  request('/api/auth/sso-login', 'POST', { ticket })
+
+// ============ 指标变化曲线 / 速查 / 关联 ============
+// 规范入口(singular per URL)。后端契约见 design.md §6.1-6.4。
+/** 指标趋势(曲线页):{ subjectId, definitionIds:[] } */
+export const getIndicatorTrend = (params) => request('/api/indicator/trend', 'POST', params || {})
+/** 变化幅度排名:{ subjectId, reportType?, category?, scope:'adjacent'|'first-vs-latest', orderBy:'up'|'down'|'magnitude', limit } */
+export const getIndicatorChangeRanking = (params) => request('/api/indicator/change-ranking', 'POST', params || {})
+/** 指标速查(分类分页):{ subjectId, category?, page, pageSize } */
+export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
+/** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
+export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})

+ 64 - 0
cfc-frontend/utils/indicator-change.js

@@ -0,0 +1,64 @@
+/**
+ * 指标变化展示规则纯函数
+ * 规则来源:design.md §8 变化颜色与箭头规则
+ * 颜色映射:改善=绿 / 恶化=红 / 中性=灰
+ */
+
+/**
+ * 由 good_direction × movement 推导展示样式
+ * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
+ * @param {string} movement up|down|flat
+ * @returns {{trendLabel:string, cssClass:string, arrow:string}}
+ */
+export function resolveChangeStyle(goodDirection, movement) {
+  var gd = goodDirection || 'neutral'
+  var mv = movement || 'flat'
+  if (mv === 'flat') {
+    return { trendLabel: '中性', cssClass: 'flat', arrow: '→' }
+  }
+  // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体)
+  var isImprove = false
+  if (gd === 'higher_is_better') {
+    isImprove = mv === 'up'
+  } else if (gd === 'lower_is_better') {
+    isImprove = mv === 'down'
+  } else {
+    // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示)
+    return {
+      trendLabel: '中性',
+      cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down',
+      arrow: mv === 'up' ? '↑' : '↓'
+    }
+  }
+  if (isImprove) {
+    return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', arrow: mv === 'up' ? '↑' : '↓' }
+  }
+  return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
+}
+
+/**
+ * Δ 展示文本 "±x (±x%)"
+ * @param {number|null} delta
+ * @param {number|null} deltaPct
+ * @returns {string}
+ */
+export function formatDelta(delta, deltaPct) {
+  if (delta === null || delta === undefined || isNaN(delta)) return '--'
+  var sign = delta > 0 ? '+' : ''
+  if (deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
+    return sign + delta
+  }
+  var pctSign = deltaPct > 0 ? '+' : ''
+  return sign + delta + ' (' + pctSign + deltaPct + '%)'
+}
+
+/**
+ * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
+ * @param {string} cssClass resolveChangeStyle 返回的类
+ * @returns {string}
+ */
+export function colorClassFor(cssClass) {
+  if (cssClass.indexOf('impr') === 0) return 'green-text'
+  if (cssClass.indexOf('worst') === 0) return 'red-text'
+  return 'gray-text'
+}

+ 34 - 0
cfc-frontend/utils/sso.js

@@ -0,0 +1,34 @@
+// 跨小程序单点登录(SSO):从 cfc 跳转到训练营(train)小程序。
+// 复用方式:页面按钮 @click 调 jumpToTrain() 即可;后端已用一次性票据免二次微信授权。
+import { createSsoTicket } from './api.js'
+
+// 训练营小程序 AppID
+const TRAIN_APPID = 'wxcf0ed181fc3bce50'
+// 训练营落地页(App.onLaunch 会读取 query.ssoTicket 换票登录)
+const TRAIN_LANDING_PATH = 'pages/index/index'
+
+/** 已登录用户跳到训练营:签发一次性票据 → navigateToMiniProgram */
+export function jumpToTrain() {
+  if (!uni.getStorageSync('token')) {
+    uni.showToast({ title: '请先登录', icon: 'none' })
+    return
+  }
+  uni.showLoading({ title: '跳转中...' })
+  createSsoTicket('train').then(function(res) {
+    uni.hideLoading()
+    var ticket = res && res.data && res.data.ticket
+    if (!ticket) {
+      uni.showToast({ title: '签发失败,请稍后重试', icon: 'none' })
+      return
+    }
+    uni.navigateToMiniProgram({
+      appId: TRAIN_APPID,
+      path: TRAIN_LANDING_PATH + '?ssoTicket=' + encodeURIComponent(ticket),
+      fail: function() {
+        uni.showToast({ title: '跳转失败,请确认已关联训练营小程序', icon: 'none' })
+      }
+    })
+  }).catch(function() {
+    uni.hideLoading()
+  })
+}

+ 21 - 8
cfc-langgraph/app/agents/report_parse_agent.py

@@ -247,6 +247,17 @@ _FLORA_GROUP_TO_KEY: dict[str, str] = {
 _FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
 
 
+def _indicator_constraint(indicators) -> str:
+    """采集指标清单 → LLM 提示词约束块;清单为空时返回空串(保持现状)。"""
+    if not indicators:
+        return ""
+    names = "、".join(str(x) for x in indicators if x)
+    if not names:
+        return ""
+    return (f"\n\n【采集指标约束】请仅提取以下指标项,name 必须与清单一致;"
+            f"报告中缺失的项返回 null,不得臆造:{names}")
+
+
 class ReportParseAgent:
     """报告解析 Agent"""
 
@@ -366,7 +377,7 @@ class ReportParseAgent:
         if foods:
             result['foods'] = foods
 
-    async def parse(self, file_path: str) -> dict:
+    async def parse(self, file_path: str, indicators: Optional[list] = None) -> dict:
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         # 1. 算法解析
         result = parse_report_pdf_with_fallback(file_path)
@@ -381,7 +392,7 @@ class ReportParseAgent:
         # 2. 如果解析不完整,LLM 兜底
         if result.get('_parse_incomplete') or not result.get('disease_risks'):
             logger.info("算法解析不完整,尝试 LLM 兜底")
-            llm_result = await self._parse_with_llm(file_path)
+            llm_result = await self._parse_with_llm(file_path, indicators)
             if llm_result:
                 # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
                 for key in ['disease_risks', 'nutrition', 'amino_acids',
@@ -399,7 +410,8 @@ class ReportParseAgent:
         result.pop('_parse_incomplete', None)
         return result
 
-    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
         try:
             from PyPDF2 import PdfReader
@@ -411,7 +423,7 @@ class ReportParseAgent:
                 ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
 
             template = await get_prompt("report_parse_generic") or REPORT_PARSE_GENERIC_TEMPLATE
-            prompt = template.format(report_text=text[:12000] + ctx_str)
+            prompt = template.format(report_text=text[:12000] + ctx_str) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 import httpx
@@ -437,7 +449,7 @@ class ReportParseAgent:
             logger.error("通用LLM解析失败: %s", e)
             return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
 
-    async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
+    async def _parse_with_llm(self, file_path: str, indicators: Optional[list] = None) -> Optional[dict]:
         """LLM 兜底解析"""
         try:
             from PyPDF2 import PdfReader
@@ -445,7 +457,7 @@ class ReportParseAgent:
             text = '\n'.join(page.extract_text() or '' for page in reader.pages)
 
             template = await get_prompt("report_parse_gut") or REPORT_PARSE_GUT_TEMPLATE
-            prompt = template.format(report_text=text[:8000])
+            prompt = template.format(report_text=text[:8000]) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 # 调 OpenAI 兼容 API
@@ -477,7 +489,8 @@ class ReportParseAgent:
     # ================================================================
 
     async def parse_by_type(self, file_path: str, report_type: str,
-                            extra_context: Optional[dict] = None) -> dict:
+                            extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """按报告类型路由到专用解析器。
 
         :param file_path: PDF 文件路径
@@ -500,7 +513,7 @@ class ReportParseAgent:
             return await self._parse_scanned_image(file_path)
         else:
             logger.info("未识别的报告类型 %s,走通用解析", report_type)
-            return await self.parse_generic(file_path, extra_context)
+            return await self.parse_generic(file_path, extra_context, indicators)
 
     # ---- 格式检测 ----
 

+ 64 - 0
cfc-langgraph/app/api/indicator_classify.py

@@ -0,0 +1,64 @@
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Optional, List
+from app.llm.client import get_llm
+from langchain_core.messages import SystemMessage, HumanMessage
+import logging
+import json
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["indicator_classify"])
+_llm = None
+
+
+def get_llm_instance():
+    global _llm
+    if _llm is None:
+        _llm = get_llm()
+    return _llm
+
+
+class ClassifyRequest(BaseModel):
+    source_type: str
+    raw_name: str
+    candidates: Optional[List[str]] = None
+
+
+class ClassifyResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+SYSTEM_PROMPT = (
+    "你是健康报告指标归一化归类助手。给定一个报告中的原始指标/菌属/食材名称,"
+    "把它归类为统一指标字典中的一个标准定义。只输出 JSON:"
+    '{"code": "indicator.<类别>.<标准名>" | "bacteria.<标准名>" | "food.<标准名>", '
+    '"name": "标准显示名", "category": "分类"}。'
+    "若候选列表非空,优先从候选中选择语义最接近的标准 code。"
+)
+
+
+@router.post("/indicator/classify", response_model=ClassifyResponse)
+async def classify_indicator(req: ClassifyRequest):
+    logger.info("indicator_classify: source=%s raw=%s", req.source_type, req.raw_name)
+    llm = get_llm_instance()
+    prompt = f"来源类型: {req.source_type}\n原始名称: {req.raw_name}\n"
+    if req.candidates:
+        prompt += "候选标准定义: " + ", ".join(req.candidates) + "\n"
+    try:
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=prompt),
+        ]
+        response = await llm.ainvoke(messages)
+        text = response.content.strip()
+        if "```json" in text:
+            text = text.split("```json")[1].split("```")[0].strip()
+        elif "```" in text:
+            text = text.split("```")[1].strip()
+        data = json.loads(text)
+        return ClassifyResponse(data=data)
+    except Exception as e:
+        logger.error("指标归类失败: %s", e, exc_info=True)
+        return ClassifyResponse(code=500, message=f"归类失败: {str(e)}", data={})

+ 4 - 2
cfc-langgraph/app/api/report_parse.py

@@ -22,6 +22,7 @@ class ParseRequest(BaseModel):
     file_path: str
     family_id: Optional[int] = None
     user_id: Optional[int] = None
+    indicators: Optional[list[str]] = None
 
 
 class ParseResponse(BaseModel):
@@ -39,7 +40,7 @@ async def parse_report(req: ParseRequest):
                 req.file_path, req.family_id, req.user_id)
     agent = get_agent()
     try:
-        result = await agent.parse(req.file_path)
+        result = await agent.parse(req.file_path, req.indicators)
         return ParseResponse(data=result)
     except Exception as e:
         logger.error("报告解析失败: %s", e, exc_info=True)
@@ -81,6 +82,7 @@ class TypedParseRequest(BaseModel):
     file_path: str
     report_type: str = "auto"
     extra_context: Optional[dict] = None
+    indicators: Optional[list[str]] = None
 
 
 class TypedParseResponse(BaseModel):
@@ -108,7 +110,7 @@ async def parse_report_typed(req: TypedParseRequest):
                 req.file_path, req.report_type)
     agent = get_agent()
     try:
-        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context)
+        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context, req.indicators)
         return TypedParseResponse(data=result)
     except Exception as e:
         logger.error("类型报告解析失败: %s", e, exc_info=True)

+ 2 - 1
cfc-langgraph/app/main.py

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz, indicator_classify
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -33,6 +33,7 @@ app.include_router(self_check.router)
 app.include_router(knowledge_base.router)
 app.include_router(portrait.router)
 app.include_router(article_quiz.router)
+app.include_router(indicator_classify.router)
 
 
 @app.on_event("startup")

+ 27 - 37
cfc-langgraph/deploy.sh

@@ -1,54 +1,44 @@
 #!/bin/bash
 # LangGraph 部署脚本
-# 用法: ./deploy.sh [生产|测试]
-# 会将本地 cfc-langgraph/ 代码同步到远程服务器并重启服务
+# 用法: ./deploy.sh
 #
-# 远程服务器: ai.etotem.com.cn (111.228.6.214)
-# 部署路径: /opt/sc-data/cfc/cfc-langgraph/
+# 说明:
+#   cfc-langgraph 以 Docker 镜像方式部署 —— Dockerfile 在构建时把 app/ 等代码 COPY 进镜像,
+#   因此代码变更必须「重新 build 镜像 + 重建容器」才能生效;仅 restart 不会加载新代码。
+#   本脚本在服务器上就地执行(服务地址 https://ai.etotem.com.cn,容器端口 9000)。
+#
+# 历史:旧版本 rsync 到 /opt/sc-data/cfc/cfc-langgraph 后执行 docker-compose restart,
+#   该路径已不存在,且 restart 无法更新镜像,故重写为就地 build + up。
 
 set -e
 
-ENV="${1:-prod}"
-REMOTE_USER="root"
-REMOTE_HOST="111.228.6.214"
-REMOTE_PATH="/opt/sc-data/cfc/cfc-langgraph"
-
 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
 
-echo "=== LangGraph Deploy ($ENV) ==="
-echo "本地: $SCRIPT_DIR"
-echo "远程: $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH"
+echo "=== LangGraph Deploy ==="
+echo "目录: $SCRIPT_DIR"
 
-# 同步代码(排除虚拟环境和缓存)
-echo "[1/3] 同步代码..."
-rsync -av --delete \
-  --exclude='.venv' \
-  --exclude='__pycache__' \
-  --exclude='*.pyc' \
-  --exclude='.env' \
-  --exclude='.env.production' \
-  --exclude='data/' \
-  --exclude='node_modules' \
-  --exclude='.git/' \
-  "$SCRIPT_DIR/" \
-  "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
+echo "[1/3] 构建镜像 (langgraph-svc)..."
+docker compose build langgraph-svc
 
-# 重启服务
-echo "[2/3] 重启 LangGraph 服务..."
-ssh "${REMOTE_USER}@${REMOTE_HOST}" "
-  cd ${REMOTE_PATH} && \
-  docker-compose restart langgraph-svc && \
-  sleep 3 && \
-  docker-compose ps langgraph-svc
-"
+echo "[2/3] 重建并启动服务..."
+docker compose up -d langgraph-svc
 
-# 健康检查
 echo "[3/3] 健康检查..."
-sleep 5
-if curl -sf "https://ai.etotem.com.cn/health" > /dev/null; then
+ok=0
+for i in $(seq 1 30); do
+  if curl -sf "https://ai.etotem.com.cn/health" > /dev/null 2>&1; then
+    ok=1
+    break
+  fi
+  echo "  等待服务就绪 ($i/30)..."
+  sleep 5
+done
+
+if [ "$ok" = "1" ]; then
   echo "✅ 部署成功!服务地址: https://ai.etotem.com.cn"
   curl -s "https://ai.etotem.com.cn/api/v1/health" | python3 -m json.tool 2>/dev/null || true
 else
-  echo "❌ 健康检查失败,请查看日志: ssh ${REMOTE_USER}@${REMOTE_HOST} 'docker-compose -f ${REMOTE_PATH}/docker-compose.yml logs langgraph-svc --tail=50'"
+  echo "❌ 健康检查失败,请查看日志: docker compose -f ${SCRIPT_DIR}/docker-compose.yml logs langgraph-svc --tail=50"
   exit 1
 fi

+ 24 - 1
cfc-langgraph/tests/test_report_parse.py

@@ -58,4 +58,27 @@ async def test_report_parse_invalid_json_response():
                 assert resp.status_code == 200
                 data = resp.json()
                 assert data["code"] == 500
-                assert "解析失败" in data["message"]
+                assert "解析失败" in data["message"]
+
+
+def test_indicator_constraint_contains_names():
+    from app.agents.report_parse_agent import _indicator_constraint
+    s = _indicator_constraint(["总分", "菌群健康"])
+    assert "总分" in s and "菌群健康" in s
+    assert _indicator_constraint(None) == ""
+    assert _indicator_constraint([]) == ""
+
+
+@pytest.mark.asyncio
+async def test_report_parse_accepts_indicators_field():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/report/parse", json={
+            "file_path": "/nonexistent.pdf",
+            "family_id": 1,
+            "user_id": 1,
+            "indicators": ["总分", "菌群健康"],
+        })
+        # 字段被接受(非 422 校验错误),仅文件不存在
+        assert resp.status_code == 400
+        assert "文件不存在" in resp.json()["detail"]

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-85ffba6aa1976012eb0d42554419fcfc4014102e
+0a37c78a61417be5aaf0695dd02d9fd68f31d520

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1532",
+  "version": "1.0.1561",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1532",
+      "version": "1.0.1561",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1533",
+  "version": "1.0.1562",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 748 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,754 @@
 
 ---
 
+## v1.0.1562 (2026-10-10)
+
+### Bug 修复
+- 关键人首次见面时间解析兼容 date/ISO 格式,修复 /api/octopus/key-person/add 必现 400(Timestamp.valueOf 仅接受空格分隔格式,前端发送日期或空串即抛异常)
+
+
+## v1.0.1561 (2026-10-10)
+
+### Bug 修复
+- 重写 deploy.sh 为就地镜像构建部署
+
+### 其他
+-   该路径已不存在,且 restart 不会重建镜像(代码是 Dockerfile COPY 进镜像的),
+-   导致脚本完全失效。
+- - 改为在脚本目录就地 docker compose build langgraph-svc + up -d,
+-   并补充 build 后带重试的健康检查。
+- 
+
+
+## v1.0.1559 (2026-10-10)
+
+### Bug 修复
+- 下单支持user_address地址簿,商品订单落库收货地址快照并完善管理端展示
+
+
+## v1.0.1558 (2026-10-10)
+
+### 新功能
+- 指标映射管理页(列表/筛选/批量导入 JSON/CSV + 待确认映射)+ seeds 解析工具
+- 指标关联配置管理页(列表+新增/编辑+单对/一键校准)
+- 指标关联配置/指标映射管理 路由注册 + 健康配置菜单回退项
+- 指标管理 API 封装(关联/映射 5 端点)+ axios-mock 契约测试
+- 菌群报告详情页增加指标变化区块(最近两次对比)
+
+
+## v1.0.1557 (2026-10-09)
+
+### 新功能
+- 指标速查页(分类分页 + 五维/七维小节 + 跳转曲线)
+- remove BASIC trial membership & add gut health questionnaire project
+
+### 其他
+- - Migrate storage users member_level from BASIC → FREE
+- - Remove BASIC benefits from membership_benefits table
+- - Mark basic_quota_usage table deprecated (preserve data, stop using)
+- - Add new gut_health_projects table for single-pay questionnaire project
+- - Add GutHealthProject entity, mapper, service, controller
+- - Add 6 new API endpoints for questionnaire flow:
+-   - /project/status - query project status
+-   - /project/pay - create order
+-   - /project/pay-notify - payment callback
+-   - /project/submit - submit questionnaire
+-   - /project/tasks - get action tasks
+-   - /project/checkin-status - get checkin status
+-   - /project/activate-after-purchase - activate family membership after purchase
+-   - /project/discount-voucher - get member discount voucher
+- - Update schemas, level_order constants, remove BASIC references throughout codebase
+- 
+
+
+## v1.0.1556 (2026-10-09)
+
+### 新功能
+- pages.json 注册 indicator-curve / indicator-quicklook
+- 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)
+- 指标变化展示规则纯函数 resolveChangeStyle/formatDelta
+- TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 报告管理展示采集完整度列与明细
+- 报告类型管理支持编辑采集指标
+- /report/parse 支持 indicators 采集指标约束注入
+- 非 DAN 解析下传 expected_indicators 驱动 LangGraph LLM
+- 报告管理列表/详情回填采集完整度 collect_validation
+- /types/save 校验 expectedIndicators 为 JSON 字符串数组
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 恢复 PromotionManagement 路由缺失的 path/name(构建阻断回归)
+- 采集指标种子改为仅空时写入,保留管理员编辑
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1555 (2026-10-09)
+
+### 新功能
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 接入图片识别食材 + 语音自动填入食物清单
+- 跨小程序单点登录 sso_ticket 换票与双向免登
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- 
+- 
+
+### 重构
+- 管理端列表页表格改自适应高度布局
+
+
+## v1.0.1554 (2026-10-09)
+
+### 新功能
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+
+### Bug 修复
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标补齐 实施计划(方案A,P1+P2)
+
+
+## v1.0.1553 (2026-10-09)
+
+### Bug 修复
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 修复家庭共餐食材持久化编译错误(selectCount 返回 Long、去除重复迁移方法、迁移354→356)
+- add store/user.js, wire App.vue store init, append fatloss API docs
+- 最近记录展示全家报告,报告列表默认选中本人
+- 基础会员(BASIC) 在会员等级映射表中缺失,导致显示为免费用户
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- add all UI pages (weight/diet/exercise/ai-coach/profile/chart/query)
+- add remaining pages (ai-coach, profile/goal, weight-chart, food-query)
+- add api wrappers and format utils
+- init project scaffold
+- add controllers for weight/diet/exercise/goal
+- add service layer
+- add entities and mappers
+- 家庭共餐食材推荐持久化到数据库
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 新增 DietFamilySelectedIngredient 实体和 Mapper
+- - DietIngredientService 改用数据库持久化替代内存 Map
+- - suggestIngredients 优先从 DB 加载已有选择
+- - refreshIngredients 清空并重新持久化推荐列表
+- - add/removeIngredient 直接操作 DB
+- - confirmIngredients 保留已选食材(不再清空)
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标 spec 改为方案A(沿既有 expected_indicators),补齐管理端/展示/驱动解析
+- mark completed tasks in fatloss-miniprogram-plan
+- add fatloss mini-program implementation plan
+- 报告类型采集指标定义与驱动解析设计(方案2 分层,P1+P2 本期)
+
+
+## v1.0.1552 (2026-10-09)
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 财富中心移除推广入口并统一 CF 值显示
+- 我的团队入口合并至推广团队页
+- add psychological assessment module (PHQ-9, RSES-7) - New pages: pages/psych-assessment/index & quiz - pages.json registers psych-assessment routes - Dimensions: phq9_score, phq4_score, reses_score
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 家庭收益成员贡献榜使用真实家庭成员
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getFamilyId() 通过 userMapper.selectById 查询用户真实 familyId
+- - isFamilyAdmin() 检查 user.isFamilyAdmin 字段
+- - 修复成员贡献榜展示非本家庭用户的问题
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 移除 SharePoster 组件及其相关死代码(import/注册/数据字段/方法)
+- - 移除 fenToYuan/cfToYuan 辅助函数,统一替换为 cfVal 直接展示 CF 值
+- - 更新所有金额标签为 CF 单位(累计推荐CF/可支配CF/今日CF 等)
+- - 分配弹窗提示文字同步更新
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- # Conflicts:
+- #	cfc-frontend/pages/promotion/index.vue
+- 
+- - pages.json 移除 pages/profile/team-tree 页面注册
+- - 删除 pages/profile/team-tree.vue(与推广团队功能重复)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1551 (2026-10-09)
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+
+
+## v1.0.1550 (2026-10-09)
+
+### 新功能
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+
+
+## v1.0.1549 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+
+### 其他
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+
+
+## v1.0.1548 (2026-10-09)
+
+### 文档
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+
+
+## v1.0.1547 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1546 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 六个维度页传入dimension-code按域筛选推荐商品
+- DimensionProducts组件新增dimensionCode按维度取推荐
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 购买态订单查询改用paid等真实状态枚举
+- 维度商品推荐修复订单状态匹配与实物库存口径
+- 迁移354补齐product_recommendation_log缺失列并同步schema.sql
+
+
+## v1.0.1545 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+
+
+## v1.0.1544 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 每类报告定义采集指标项并接入采集完整性校验
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 幂等补注册 dan_a1/b2/b3/b4/b5/b6/c1 与 gut_flora_beijing 类型行(parser_path 空,不干扰指纹路由)
+- - ReportCollectService 采集完成后按定义校验(归一化+包含+CJK二元组重叠匹配),结果写入 health_report_drafts.collect_validation(typeId/expected/matched/pass/missing)
+- - schema.sql 同步两处新列
+- 
+
+
+## v1.0.1543 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 修复B4报告解析——自我概念按「维度 分数」提取6维、自驱力按维度顺序映射我的得分,去除错误兜底
+
+
+## v1.0.1542 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- add fallback from product.domain when candidates empty
+
+
+## v1.0.1541 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- DAN报告新增按维度重解析接口,修复历史误解析并刷新结果/展示块/汇总
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1540 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 我的画像页迁移五维画像并支持全屏编辑,后端新增每日自动更新任务
+- 引入企业级设计系统(Ant Design 5 令牌)并应用 flex 撑满布局
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - OrderManage.vue: 表格改用 .adm-table-wrap 弹性撑满(height=100%),移除 JS 计算的 tableHeight
+- 
+
+
+## v1.0.1539 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- dan报告维度映射支持类型编码(a1/b2/b4等),前端dan_*前缀统一跳转DAN报告页
+
+
+## v1.0.1538 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getScoreClass/getStatusText/buildFamilyOverview 等状态判定全部改为 >= 0 边界
+- - onLoad 从报告入口进入时默认 sortType='asc',setFilter('unsuitable') 时同步重置 sortType
+- - 分类 tab key 从英文 (vegetable/fruit/meat/seafood/grain/dairy) 改为中文 (蔬菜/水果/肉禽/水产/谷物/蛋奶),并新增 getCategoryKeywords() 关键词映射覆盖 PDF/ LangGraph 两套数据源分类命名
+- - 修复分类点击无效问题,确保前端过滤能匹配 DB 中存储的中文分类
+- closes related category filter bug, aligns with backend semantics
+- 
+
+### Bug 修复
+- ingredient query page - 戒备阈值 <50→<0, 默认正序, 分类点击生效
+
+
+## v1.0.1537 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 将 SUM(points) 修正为 SUM(amount),与 points_log 表结构一致
+- - 修复任务完成接口 /api/tasks/{id}/complete 在 calculateLatePenalty 路径抛 BadSqlGrammarException
+- 
+
+### Bug 修复
+- 修复 PointsLogMapper.selectSum SQL 占位符转义及字段名错误
+
+
+## v1.0.1536 (2026-10-07)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 注册/绑定推荐人不再发放CF值,新增首次报告上传任务(300 CF)
+
+
+## v1.0.1535 (2026-10-07)
+
+### Bug 修复
+- 健康首页最近记录只显示当前用户自己的报告
+- 商品管理列表撑满视口并消除多级滚动条
+
+### 其他
+- getFamilyReports 返回全家全部报告),改回传入当前视图成员 memberId,
+- 符合"只有两种情况"的语义:
+- - 未切换视图 = 当前登录用户自己的报告(currentChildId/currentMemberId
+-   未写入 storage 时回退 userId,后端 getMemberReports 按成员严格过滤)
+- - 已切换成员 = 被切换成员的报告
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 表格改用 height=100% + flex:1,仅保留表格自身滚动条
+- - 移除 JS 计算 tableHeight 及 window.resize 监听
+- 
+
+### 新功能
+- admin supply-manage auto-set activityProviderStatus for activity_provider
+
+
+## v1.0.1534 (2026-10-07)
+
+### 新功能
+- integrate article recommendation into dimension pages
+- include vendorType/activityProviderStatus in login response for frontend permission check
+- allow activity_provider to create activities + scene referral binding
+- 小程序新增任务提醒配置页(渠道/时机/偏移/开关)
+- 任务打卡提醒升级为可配置提醒模块
+- 每日任务打卡超时 webhook 提醒
+- 新增迁移352移除报告上传权益与配额残留并同步 schema.sql
+- 移除报告上传配额,对所有登录用户免费无限
+- implement product recommendation engine with persona + association relations (P3) add recommendation rules DB + service + UI ab-test tracking
+
+### 其他
+- - 知识库:wiki/raw 原始资料、wiki 页面、结构化知识、构建脚本及 memory 等知识资产
+- - 新增 知识库/.gitignore,排除中间产物(_kb_work/.wiki-creator/__pycache__/*.pyc/.obsidian 缓存)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 避免最新上传/归属到其他成员的报告在首页不显示;切换到具体成员或孩子本人登录时仍按成员过滤。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 pages/reminder-config/index.vue 提醒设置页(渠道多选含未开通态/时机单选/偏移分钟/启用开关)
+- - pages.json 注册 reminder-config 分包
+- - profile 设置 actionSheet 增加"提醒设置"入口
+- 
+- - 新增 TaskReminderConfigService/Controller(/api/task/reminder-config get/save)
+- - 重写 TaskCheckinWebhookReminderTask: 按配置支持的 before_deadline/after_timeout 双窗口, 多渠道分发
+- - 新增 WebhookNotificationChannel(wechat) 接入微信待办 webhook; Email 通道骨架(未启用)
+- - 站内信(in_app)恒兜底并作幂等标记, 兼容旧 type 判重避免重复推送
+- - 无配置用户走默认: wechat+超时60分钟(与线上行为一致)
+- - 新增 7 个单测覆盖窗口/渠道/判重/停用场景
+- 
+- - 新增 TaskCheckinWebhookReminderTask:每分钟扫描家长执行型待打卡任务,提醒时间(deadline)过 1 小时未完成则推送 webhook
+- - 幂等:user_notifications 标记,同任务同日仅推一次;失败下一分钟自动重试
+- 模板任务数据(9 条健康打卡)一次性直接落库,不写入代码。
+- 
+- 表示该order_id在微信订单库中从未创建成功(并非临时查询失败)。
+- 原逻辑将所有errcode!=0一律return null, 导致超时任务永远跳过不关单,
+- 死单永不清理且每单每分钟刷2条WARN(今日已5200+条)。
+- - queryOrder: 单独识别268490002返回status=-1(本地扩展状态) + INFO日志
+- - VirtualPayTimeoutTask三个handler: status=-1归入明确未支付置cancelled分支
+- - 已用真实paid单(ORD1785990942621001DCC2)实测接口正常, 无误杀风险
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- add: care_family 运营资料 + cfContent 微信贴图话题发布 skill
+
+### Bug 修复
+- 健康首页最近记录默认展示全家报告
+- query_order返回268490002数据不存在视为未支付关单
+- 移除会员对比表与升级弹窗中的报告上传配额文案
+- 微信支付 v3 回调通知加入白名单并修正 notify-url
+- 报告列表默认显示当前登录用户而非全部
+- body-detail navigation to unregistered pages; resolve action-detail merge conflict
+
+### 文档
+- add activity share referral design spec
+
+
 ## v1.0.1533 (2026-10-07)
 
 ### Bug 修复

+ 749 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1533
+> 当前版本: v1.0.1562
 
 ## 历史版本
 
@@ -8,6 +8,754 @@
 
 ---
 
+## v1.0.1562 (2026-10-10)
+
+### Bug 修复
+- 关键人首次见面时间解析兼容 date/ISO 格式,修复 /api/octopus/key-person/add 必现 400(Timestamp.valueOf 仅接受空格分隔格式,前端发送日期或空串即抛异常)
+
+
+## v1.0.1561 (2026-10-10)
+
+### Bug 修复
+- 重写 deploy.sh 为就地镜像构建部署
+
+### 其他
+-   该路径已不存在,且 restart 不会重建镜像(代码是 Dockerfile COPY 进镜像的),
+-   导致脚本完全失效。
+- - 改为在脚本目录就地 docker compose build langgraph-svc + up -d,
+-   并补充 build 后带重试的健康检查。
+- 
+
+
+## v1.0.1559 (2026-10-10)
+
+### Bug 修复
+- 下单支持user_address地址簿,商品订单落库收货地址快照并完善管理端展示
+
+
+## v1.0.1558 (2026-10-10)
+
+### 新功能
+- 指标映射管理页(列表/筛选/批量导入 JSON/CSV + 待确认映射)+ seeds 解析工具
+- 指标关联配置管理页(列表+新增/编辑+单对/一键校准)
+- 指标关联配置/指标映射管理 路由注册 + 健康配置菜单回退项
+- 指标管理 API 封装(关联/映射 5 端点)+ axios-mock 契约测试
+- 菌群报告详情页增加指标变化区块(最近两次对比)
+
+
+## v1.0.1557 (2026-10-09)
+
+### 新功能
+- 指标速查页(分类分页 + 五维/七维小节 + 跳转曲线)
+- remove BASIC trial membership & add gut health questionnaire project
+
+### 其他
+- - Migrate storage users member_level from BASIC → FREE
+- - Remove BASIC benefits from membership_benefits table
+- - Mark basic_quota_usage table deprecated (preserve data, stop using)
+- - Add new gut_health_projects table for single-pay questionnaire project
+- - Add GutHealthProject entity, mapper, service, controller
+- - Add 6 new API endpoints for questionnaire flow:
+-   - /project/status - query project status
+-   - /project/pay - create order
+-   - /project/pay-notify - payment callback
+-   - /project/submit - submit questionnaire
+-   - /project/tasks - get action tasks
+-   - /project/checkin-status - get checkin status
+-   - /project/activate-after-purchase - activate family membership after purchase
+-   - /project/discount-voucher - get member discount voucher
+- - Update schemas, level_order constants, remove BASIC references throughout codebase
+- 
+
+
+## v1.0.1556 (2026-10-09)
+
+### 新功能
+- pages.json 注册 indicator-curve / indicator-quicklook
+- 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)
+- 指标变化展示规则纯函数 resolveChangeStyle/formatDelta
+- TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 报告管理展示采集完整度列与明细
+- 报告类型管理支持编辑采集指标
+- /report/parse 支持 indicators 采集指标约束注入
+- 非 DAN 解析下传 expected_indicators 驱动 LangGraph LLM
+- 报告管理列表/详情回填采集完整度 collect_validation
+- /types/save 校验 expectedIndicators 为 JSON 字符串数组
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 恢复 PromotionManagement 路由缺失的 path/name(构建阻断回归)
+- 采集指标种子改为仅空时写入,保留管理员编辑
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1555 (2026-10-09)
+
+### 新功能
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 接入图片识别食材 + 语音自动填入食物清单
+- 跨小程序单点登录 sso_ticket 换票与双向免登
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- 
+- 
+
+### 重构
+- 管理端列表页表格改自适应高度布局
+
+
+## v1.0.1554 (2026-10-09)
+
+### 新功能
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+
+### Bug 修复
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标补齐 实施计划(方案A,P1+P2)
+
+
+## v1.0.1553 (2026-10-09)
+
+### Bug 修复
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 修复家庭共餐食材持久化编译错误(selectCount 返回 Long、去除重复迁移方法、迁移354→356)
+- add store/user.js, wire App.vue store init, append fatloss API docs
+- 最近记录展示全家报告,报告列表默认选中本人
+- 基础会员(BASIC) 在会员等级映射表中缺失,导致显示为免费用户
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- add all UI pages (weight/diet/exercise/ai-coach/profile/chart/query)
+- add remaining pages (ai-coach, profile/goal, weight-chart, food-query)
+- add api wrappers and format utils
+- init project scaffold
+- add controllers for weight/diet/exercise/goal
+- add service layer
+- add entities and mappers
+- 家庭共餐食材推荐持久化到数据库
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 新增 DietFamilySelectedIngredient 实体和 Mapper
+- - DietIngredientService 改用数据库持久化替代内存 Map
+- - suggestIngredients 优先从 DB 加载已有选择
+- - refreshIngredients 清空并重新持久化推荐列表
+- - add/removeIngredient 直接操作 DB
+- - confirmIngredients 保留已选食材(不再清空)
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标 spec 改为方案A(沿既有 expected_indicators),补齐管理端/展示/驱动解析
+- mark completed tasks in fatloss-miniprogram-plan
+- add fatloss mini-program implementation plan
+- 报告类型采集指标定义与驱动解析设计(方案2 分层,P1+P2 本期)
+
+
+## v1.0.1552 (2026-10-09)
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 财富中心移除推广入口并统一 CF 值显示
+- 我的团队入口合并至推广团队页
+- add psychological assessment module (PHQ-9, RSES-7) - New pages: pages/psych-assessment/index & quiz - pages.json registers psych-assessment routes - Dimensions: phq9_score, phq4_score, reses_score
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 家庭收益成员贡献榜使用真实家庭成员
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getFamilyId() 通过 userMapper.selectById 查询用户真实 familyId
+- - isFamilyAdmin() 检查 user.isFamilyAdmin 字段
+- - 修复成员贡献榜展示非本家庭用户的问题
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 移除 SharePoster 组件及其相关死代码(import/注册/数据字段/方法)
+- - 移除 fenToYuan/cfToYuan 辅助函数,统一替换为 cfVal 直接展示 CF 值
+- - 更新所有金额标签为 CF 单位(累计推荐CF/可支配CF/今日CF 等)
+- - 分配弹窗提示文字同步更新
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- # Conflicts:
+- #	cfc-frontend/pages/promotion/index.vue
+- 
+- - pages.json 移除 pages/profile/team-tree 页面注册
+- - 删除 pages/profile/team-tree.vue(与推广团队功能重复)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1551 (2026-10-09)
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+
+
+## v1.0.1550 (2026-10-09)
+
+### 新功能
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+
+
+## v1.0.1549 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+
+### 其他
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+
+
+## v1.0.1548 (2026-10-09)
+
+### 文档
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+
+
+## v1.0.1547 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1546 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 六个维度页传入dimension-code按域筛选推荐商品
+- DimensionProducts组件新增dimensionCode按维度取推荐
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 购买态订单查询改用paid等真实状态枚举
+- 维度商品推荐修复订单状态匹配与实物库存口径
+- 迁移354补齐product_recommendation_log缺失列并同步schema.sql
+
+
+## v1.0.1545 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+
+
+## v1.0.1544 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 每类报告定义采集指标项并接入采集完整性校验
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 幂等补注册 dan_a1/b2/b3/b4/b5/b6/c1 与 gut_flora_beijing 类型行(parser_path 空,不干扰指纹路由)
+- - ReportCollectService 采集完成后按定义校验(归一化+包含+CJK二元组重叠匹配),结果写入 health_report_drafts.collect_validation(typeId/expected/matched/pass/missing)
+- - schema.sql 同步两处新列
+- 
+
+
+## v1.0.1543 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 修复B4报告解析——自我概念按「维度 分数」提取6维、自驱力按维度顺序映射我的得分,去除错误兜底
+
+
+## v1.0.1542 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- add fallback from product.domain when candidates empty
+
+
+## v1.0.1541 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- DAN报告新增按维度重解析接口,修复历史误解析并刷新结果/展示块/汇总
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1540 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 我的画像页迁移五维画像并支持全屏编辑,后端新增每日自动更新任务
+- 引入企业级设计系统(Ant Design 5 令牌)并应用 flex 撑满布局
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - OrderManage.vue: 表格改用 .adm-table-wrap 弹性撑满(height=100%),移除 JS 计算的 tableHeight
+- 
+
+
+## v1.0.1539 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- dan报告维度映射支持类型编码(a1/b2/b4等),前端dan_*前缀统一跳转DAN报告页
+
+
+## v1.0.1538 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getScoreClass/getStatusText/buildFamilyOverview 等状态判定全部改为 >= 0 边界
+- - onLoad 从报告入口进入时默认 sortType='asc',setFilter('unsuitable') 时同步重置 sortType
+- - 分类 tab key 从英文 (vegetable/fruit/meat/seafood/grain/dairy) 改为中文 (蔬菜/水果/肉禽/水产/谷物/蛋奶),并新增 getCategoryKeywords() 关键词映射覆盖 PDF/ LangGraph 两套数据源分类命名
+- - 修复分类点击无效问题,确保前端过滤能匹配 DB 中存储的中文分类
+- closes related category filter bug, aligns with backend semantics
+- 
+
+### Bug 修复
+- ingredient query page - 戒备阈值 <50→<0, 默认正序, 分类点击生效
+
+
+## v1.0.1537 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 将 SUM(points) 修正为 SUM(amount),与 points_log 表结构一致
+- - 修复任务完成接口 /api/tasks/{id}/complete 在 calculateLatePenalty 路径抛 BadSqlGrammarException
+- 
+
+### Bug 修复
+- 修复 PointsLogMapper.selectSum SQL 占位符转义及字段名错误
+
+
+## v1.0.1536 (2026-10-07)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 注册/绑定推荐人不再发放CF值,新增首次报告上传任务(300 CF)
+
+
+## v1.0.1535 (2026-10-07)
+
+### Bug 修复
+- 健康首页最近记录只显示当前用户自己的报告
+- 商品管理列表撑满视口并消除多级滚动条
+
+### 其他
+- getFamilyReports 返回全家全部报告),改回传入当前视图成员 memberId,
+- 符合"只有两种情况"的语义:
+- - 未切换视图 = 当前登录用户自己的报告(currentChildId/currentMemberId
+-   未写入 storage 时回退 userId,后端 getMemberReports 按成员严格过滤)
+- - 已切换成员 = 被切换成员的报告
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 表格改用 height=100% + flex:1,仅保留表格自身滚动条
+- - 移除 JS 计算 tableHeight 及 window.resize 监听
+- 
+
+### 新功能
+- admin supply-manage auto-set activityProviderStatus for activity_provider
+
+
+## v1.0.1534 (2026-10-07)
+
+### 新功能
+- integrate article recommendation into dimension pages
+- include vendorType/activityProviderStatus in login response for frontend permission check
+- allow activity_provider to create activities + scene referral binding
+- 小程序新增任务提醒配置页(渠道/时机/偏移/开关)
+- 任务打卡提醒升级为可配置提醒模块
+- 每日任务打卡超时 webhook 提醒
+- 新增迁移352移除报告上传权益与配额残留并同步 schema.sql
+- 移除报告上传配额,对所有登录用户免费无限
+- implement product recommendation engine with persona + association relations (P3) add recommendation rules DB + service + UI ab-test tracking
+
+### 其他
+- - 知识库:wiki/raw 原始资料、wiki 页面、结构化知识、构建脚本及 memory 等知识资产
+- - 新增 知识库/.gitignore,排除中间产物(_kb_work/.wiki-creator/__pycache__/*.pyc/.obsidian 缓存)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 避免最新上传/归属到其他成员的报告在首页不显示;切换到具体成员或孩子本人登录时仍按成员过滤。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 pages/reminder-config/index.vue 提醒设置页(渠道多选含未开通态/时机单选/偏移分钟/启用开关)
+- - pages.json 注册 reminder-config 分包
+- - profile 设置 actionSheet 增加"提醒设置"入口
+- 
+- - 新增 TaskReminderConfigService/Controller(/api/task/reminder-config get/save)
+- - 重写 TaskCheckinWebhookReminderTask: 按配置支持的 before_deadline/after_timeout 双窗口, 多渠道分发
+- - 新增 WebhookNotificationChannel(wechat) 接入微信待办 webhook; Email 通道骨架(未启用)
+- - 站内信(in_app)恒兜底并作幂等标记, 兼容旧 type 判重避免重复推送
+- - 无配置用户走默认: wechat+超时60分钟(与线上行为一致)
+- - 新增 7 个单测覆盖窗口/渠道/判重/停用场景
+- 
+- - 新增 TaskCheckinWebhookReminderTask:每分钟扫描家长执行型待打卡任务,提醒时间(deadline)过 1 小时未完成则推送 webhook
+- - 幂等:user_notifications 标记,同任务同日仅推一次;失败下一分钟自动重试
+- 模板任务数据(9 条健康打卡)一次性直接落库,不写入代码。
+- 
+- 表示该order_id在微信订单库中从未创建成功(并非临时查询失败)。
+- 原逻辑将所有errcode!=0一律return null, 导致超时任务永远跳过不关单,
+- 死单永不清理且每单每分钟刷2条WARN(今日已5200+条)。
+- - queryOrder: 单独识别268490002返回status=-1(本地扩展状态) + INFO日志
+- - VirtualPayTimeoutTask三个handler: status=-1归入明确未支付置cancelled分支
+- - 已用真实paid单(ORD1785990942621001DCC2)实测接口正常, 无误杀风险
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- add: care_family 运营资料 + cfContent 微信贴图话题发布 skill
+
+### Bug 修复
+- 健康首页最近记录默认展示全家报告
+- query_order返回268490002数据不存在视为未支付关单
+- 移除会员对比表与升级弹窗中的报告上传配额文案
+- 微信支付 v3 回调通知加入白名单并修正 notify-url
+- 报告列表默认显示当前登录用户而非全部
+- body-detail navigation to unregistered pages; resolve action-detail merge conflict
+
+### 文档
+- add activity share referral design spec
+
+
 ## v1.0.1533 (2026-10-07)
 
 ### Bug 修复

+ 79 - 0
cfc-web/src/__tests__/api/indicator.test.js

@@ -0,0 +1,79 @@
+import MockAdapter from 'axios-mock-adapter'
+import axios from '@/utils/request'
+import {
+  listIndicatorDefinitions,
+  saveIndicatorCorrelation,
+  listIndicatorCorrelations,
+  calibrateIndicatorCorrelations,
+  importIndicatorMappings,
+  listIndicatorMappings
+} from '@/api/indicator'
+
+let mock
+
+beforeEach(() => {
+  mock = new MockAdapter(axios)
+})
+
+afterEach(() => {
+  mock.restore()
+})
+
+describe('indicator admin API', () => {
+  test('listIndicatorDefinitions sends POST to /api/admin/indicator/list', async () => {
+    mock.onPost('/api/admin/indicator/list').reply(200, { code: 200, data: [{ id: 1, name: '乳酸菌' }] })
+    const res = await listIndicatorDefinitions({ domain: 'gut', status: 1 })
+    expect(res.code).toBe(200)
+    expect(res.data).toEqual([{ id: 1, name: '乳酸菌' }])
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/list')
+    expect(mock.history.post[0].method).toBe('post')
+  })
+
+  test('saveIndicatorCorrelation sends POST with body', async () => {
+    const body = { definitionIdA: 1, definitionIdB: 2, direction: 'positive', manualStrength: 0.6, description: 'x', manualLocked: false, sourceNote: 'y' }
+    mock.onPost('/api/admin/indicator/correlation/save').reply(200, { code: 200, data: true })
+    const res = await saveIndicatorCorrelation(body)
+    expect(res.code).toBe(200)
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/correlation/save')
+    expect(JSON.parse(mock.history.post[0].data)).toMatchObject(body)
+  })
+
+  test('listIndicatorCorrelations sends POST with page/size', async () => {
+    mock.onPost('/api/admin/indicator/correlation/list').reply(200, { code: 200, data: { records: [{ id: 1 }], total: 1 } })
+    const res = await listIndicatorCorrelations({ page: 1, size: 10 })
+    expect(res.code).toBe(200)
+    expect(res.data.records).toEqual([{ id: 1 }])
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/correlation/list')
+  })
+
+  test('calibrateIndicatorCorrelations sends POST with empty body', async () => {
+    mock.onPost('/api/admin/indicator/correlation/calibrate').reply(200, { code: 200, message: '校准完成,共更新 0 对' })
+    const res = await calibrateIndicatorCorrelations({})
+    expect(res.code).toBe(200)
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/correlation/calibrate')
+  })
+
+  test('calibrateIndicatorCorrelations sends pairId in body', async () => {
+    mock.onPost('/api/admin/indicator/correlation/calibrate').reply(200, { code: 200, message: '校准完成,共更新 1 对' })
+    const res = await calibrateIndicatorCorrelations({ pairId: 1 })
+    expect(res.code).toBe(200)
+    expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ pairId: 1 })
+  })
+
+  test('importIndicatorMappings sends POST with seeds', async () => {
+    const body = { sourceType: 'gut', seeds: [{ rawName: '乳酸菌', definitionId: 3 }] }
+    mock.onPost('/api/admin/indicator/mapping/import').reply(200, { code: 200, message: '导入完成,共 1 条' })
+    const res = await importIndicatorMappings(body)
+    expect(res.code).toBe(200)
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/mapping/import')
+    expect(JSON.parse(mock.history.post[0].data)).toMatchObject(body)
+  })
+
+  test('listIndicatorMappings sends POST with status/sourceType', async () => {
+    mock.onPost('/api/admin/indicator/mapping/list').reply(200, { code: 200, data: { records: [], total: 0 } })
+    const res = await listIndicatorMappings({ page: 1, size: 10, status: 0, sourceType: 'gut' })
+    expect(res.code).toBe(200)
+    expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ page: 1, size: 10, status: 0, sourceType: 'gut' })
+    expect(mock.history.post[0].url).toBe('/api/admin/indicator/mapping/list')
+  })
+})

+ 57 - 0
cfc-web/src/__tests__/utils/indicatorSeeds.test.js

@@ -0,0 +1,57 @@
+import { parseSeedsText } from '@/utils/indicatorSeeds'
+
+describe('parseSeedsText', () => {
+  test('empty/null/undefined → []', () => {
+    expect(parseSeedsText('')).toEqual([])
+    expect(parseSeedsText(null)).toEqual([])
+    expect(parseSeedsText(undefined)).toEqual([])
+  })
+
+  test('JSON array with definitionId', () => {
+    const input = '[{"rawName":"乳酸菌","definitionId":3},{"rawName":"双歧杆菌"}]'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0]).toEqual({ rawName: '乳酸菌', definitionId: 3 })
+    expect(res[1]).toEqual({ rawName: '双歧杆菌', definitionId: null })
+  })
+
+  test('CSV two columns', () => {
+    const input = '乳酸菌,3\n双歧杆菌\n'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0]).toEqual({ rawName: '乳酸菌', definitionId: 3 })
+    expect(res[1]).toEqual({ rawName: '双歧杆菌', definitionId: null })
+  })
+
+  test('skip empty rawName', () => {
+    const input = '乳酸菌,3\n ,\n双歧杆菌'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0].rawName).toBe('乳酸菌')
+    expect(res[1].rawName).toBe('双歧杆菌')
+  })
+
+  test('skip blank lines', () => {
+    const input = '乳酸菌,3\n\n\n双歧杆菌\n'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+  })
+
+  test('invalid JSON falls back to CSV row parsing', () => {
+    const input = 'not json at all\n乳酸菌,3\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: 3 }])
+  })
+
+  test('non-numeric definitionId in CSV → null', () => {
+    const input = '乳酸菌,abc\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: null }])
+  })
+
+  test('Chinese comma delimiter', () => {
+    const input = '乳酸菌,3\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: 3 }])
+  })
+})

+ 25 - 0
cfc-web/src/api/indicator.js

@@ -0,0 +1,25 @@
+import request from '@/utils/request'
+
+export function listIndicatorDefinitions(params) {
+  return request({ url: '/api/admin/indicator/list', method: 'post', data: params })
+}
+
+export function saveIndicatorCorrelation(data) {
+  return request({ url: '/api/admin/indicator/correlation/save', method: 'post', data })
+}
+
+export function listIndicatorCorrelations(params) {
+  return request({ url: '/api/admin/indicator/correlation/list', method: 'post', data: params })
+}
+
+export function calibrateIndicatorCorrelations(params) {
+  return request({ url: '/api/admin/indicator/correlation/calibrate', method: 'post', data: params })
+}
+
+export function importIndicatorMappings(data) {
+  return request({ url: '/api/admin/indicator/mapping/import', method: 'post', data })
+}
+
+export function listIndicatorMappings(params) {
+  return request({ url: '/api/admin/indicator/mapping/list', method: 'post', data: params })
+}

+ 15 - 0
cfc-web/src/router/index.js

@@ -733,6 +733,9 @@ const routes = [
         component: () => import('@/views/admin/RecommendationRules.vue'),
         meta: { title: '商品推荐规则管理', perm: 'config:recommendation' }
       },
+      {
+        path: 'promotion',
+        name: 'PromotionManagement',
         component: () => import('@/views/admin/PromotionManagement'),
         meta: { title: '推广管理', perm: 'marketing:promotion' }
       },
@@ -797,6 +800,18 @@ const routes = [
         component: () => import('@/views/admin/indicators'),
         meta: { title: '指标管理', perm: 'config:health' }
       },
+      {
+        path: 'indicator-correlations',
+        name: 'IndicatorCorrelations',
+        component: () => import('@/views/admin/IndicatorCorrelations.vue'),
+        meta: { title: '指标关联配置', perm: 'config:health' }
+      },
+      {
+        path: 'indicator-mappings',
+        name: 'IndicatorMappings',
+        component: () => import('@/views/admin/IndicatorMappings.vue'),
+        meta: { title: '指标映射管理', perm: 'config:health' }
+      },
       // ========== 虚拟服务商团队管理 ==========
       {
         path: 'virtual-teams',

+ 86 - 0
cfc-web/src/styles/admin-tokens.css

@@ -581,3 +581,89 @@
 .admin-page .text-muted { color: var(--adm-text-45); }
 .admin-page .adm-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; }
 .admin-page > .header { flex-shrink: 0; }
+
+/* ==== 弹性填充链增强(自动填充,兼容任意层级卡片/标签页嵌套) ==== */
+/* 让 admin-page / page-container 内任意层级的卡片成为弹性填充容器(覆盖 Layout.vue 的直接子级选择器) */
+.admin-page .el-card.el-card,
+.page-container .el-card.el-card {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.admin-page .el-card__body.el-card__body,
+.page-container .el-card__body.el-card__body {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+
+/* adm-table-wrap 全局化:表格容器弹性撑满,仅表格自身滚动 */
+.adm-table-wrap {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+.adm-table-wrap > .el-table {
+  flex: 1 1 auto;
+  min-height: 0;
+  height: 100%;
+}
+
+/* 标签页场景:让 el-tabs 内容区成为弹性填充容器 */
+.admin-page .el-tabs,
+.page-container .el-tabs {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.admin-page .el-tabs__content,
+.page-container .el-tabs__content {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+.admin-page .el-tab-pane,
+.page-container .el-tab-pane {
+  flex: 1 1 auto;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+/* 兜底:未包裹的 el-table 若直接为卡片 body 子级也填充 */
+.admin-page .el-card__body > .el-table,
+.page-container .el-card__body > .el-table,
+.admin-page .el-tab-pane > .el-table,
+.page-container .el-tab-pane > .el-table {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+/* 防止页面级横向滚动:限定内部横向溢出由表格自身处理 */
+.admin-page,
+.page-container {
+  overflow-x: hidden;
+}
+
+/* ==== 弹性填充链 :has 全自动传播(兼容任意层级嵌套 + 自定义根 class) ==== */
+/* 仅作用于真正含有填充表格容器的祖先,对话框(portal 在 main-wrapper 之外且不带 wrap)不受影响 */
+/* 1) 页面根容器:含有填充表格的页面根直接撑满 main-wrapper */
+.main-wrapper > *:has(.adm-table-wrap) {
+  flex: 1 1 auto;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+/* 2) 表格容器内任意祖先容器:自动成为弹性列,确保表格撑满且不产生多余滚动条
+      排除横向栅格(.el-row/.el-col),避免误把横向布局翻转为纵向 */
+.main-wrapper *:has(.adm-table-wrap):not(.el-row):not(.el-col) {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  min-height: 0;
+}

+ 45 - 0
cfc-web/src/utils/indicatorSeeds.js

@@ -0,0 +1,45 @@
+// 指标映射种子解析纯函数(无副作用、无状态,不 import request)
+// 支持两种输入:
+//   1) JSON 数组:[{"rawName":"乳酸菌","definitionId":3}, ...]
+//   2) CSV 两列:rawName,definitionId(逗号/中文逗号分隔,空行跳过)
+export function parseSeedsText(text) {
+  if (text === null || text === undefined) return []
+  const trimmed = String(text).trim()
+  if (!trimmed) return []
+
+  // 先尝试 JSON 数组
+  try {
+    const parsed = JSON.parse(trimmed)
+    if (Array.isArray(parsed)) {
+      return parsed
+        .map(item => {
+          const rawName = String(item.rawName || '').trim()
+          if (!rawName) return null
+          return { rawName, definitionId: item.definitionId || null }
+        })
+        .filter(Boolean)
+    }
+  } catch (e) {
+    // 非法 JSON → 走行解析(CSV 回退)
+  }
+
+  // 按行 CSV 解析
+  const lines = trimmed.split(/\r?\n/)
+  const seeds = []
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim()
+    if (!line) continue
+    const cols = line.split(/[,,]/)
+    const rawName = (cols[0] || '').trim()
+    if (!rawName) continue
+    // 单列且含空格的多词行视为无结构文本(如被误判的 JSON 串),跳过
+    if (cols.length === 1 && /\s/.test(rawName)) continue
+    let definitionId = null
+    if (cols.length > 1 && cols[1].trim() !== '') {
+      const num = Number(cols[1].trim())
+      if (!isNaN(num)) definitionId = num
+    }
+    seeds.push({ rawName, definitionId })
+  }
+  return seeds
+}

+ 3 - 1
cfc-web/src/views/Children.vue

@@ -39,7 +39,8 @@
         </el-form>
 
         <!-- 孩子列表 -->
-        <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
+                <div class="adm-table-wrap">
+<el-table :data="tableData" border v-loading="loading" style="width: 100%" height="100%">
         <el-table-column prop="id" label="ID" width="80">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
@@ -102,6 +103,7 @@
           </template>
         </el-table-column>
         </el-table>
+        </div>
 
         <!-- 分页 -->
       <el-pagination

+ 3 - 1
cfc-web/src/views/Dashboard.vue

@@ -142,7 +142,8 @@
                 查看全部 →
               </el-button>
             </div>
-            <el-table :data="recentTasks" size="small" class="custom-table">
+                        <div class="adm-table-wrap">
+<el-table :data="recentTasks" size="small" class="custom-table" height="100%">
               <el-table-column prop="title" label="任务名称" min-width="180"></el-table-column>
               <el-table-column prop="childName" label="孩子" width="100"></el-table-column>
               <el-table-column prop="status" label="状态" width="100">
@@ -162,6 +163,7 @@
                 </template>
               </el-table-column>
             </el-table>
+            </div>
           </el-card>
         </el-col>
 

+ 12 - 5
cfc-web/src/views/Families.vue

@@ -31,12 +31,13 @@
         </el-form>
 
         <!-- 家庭列表 -->
-        <el-table
+                <div class="adm-table-wrap">
+<el-table
           :data="tableData"
           border
           v-loading="loading"
           style="width: 100%"
-          :max-height="tableHeight"
+          height="100%"
         >
         <el-table-column prop="id" label="ID" width="80">
           <template slot="header">
@@ -78,7 +79,10 @@
             </el-dropdown>
           </template>
         </el-table-column>
+        
+        
         </el-table>
+        </div>
 
         <!-- 分页 -->
       <el-pagination
@@ -165,8 +169,9 @@
               </el-button>
             </template>
           </el-table-column>
-        </el-table>
         <el-empty v-else description="暂无成员" :image-size="60"></el-empty>
+      
+      </el-table>
       </div>
     </el-dialog>
 
@@ -242,7 +247,6 @@
         <el-table-column prop="usedAt" label="使用时间" width="160">
           <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
         </el-table-column>
-      </el-table>
       <el-pagination
         style="margin-top: 12px; text-align: right"
         @size-change="onCouponSizeChange"
@@ -281,7 +285,6 @@
         <el-table-column prop="usedAt" label="使用时间" width="160">
           <template slot-scope="scope">{{ formatTime(scope.row.usedAt) }}</template>
         </el-table-column>
-      </el-table>
       <el-pagination
         style="margin-top: 12px; text-align: right"
         @size-change="onUsageSizeChange"
@@ -293,6 +296,10 @@
         layout="total, sizes, prev, pager, next"
       ></el-pagination>
     </el-dialog>
+  
+  </el-table>
+  
+  </el-table>
   </div>
 </template>
 

+ 8 - 4
cfc-web/src/views/GuideFamilyTask.vue

@@ -70,7 +70,8 @@
       </div>
 
       <!-- 孩子列表 -->
-      <el-table :max-height="tableHeight" v-if="familyOverview && !searchForm.memberId" :data="familyOverview.children" border>
+            <div class="adm-table-wrap">
+<el-table height="100%" v-if="familyOverview && !searchForm.memberId" :data="familyOverview.children" border>
         <el-table-column prop="childName" label="孩子姓名" width="120"></el-table-column>
         <el-table-column prop="totalPoints" label="总积分" width="100"></el-table-column>
         <el-table-column prop="streakDays" label="连续打卡" width="100">
@@ -89,9 +90,11 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
 
       <!-- 任务列表 -->
-      <el-table v-if="searchForm.memberId" :data="tasks" border v-loading="taskLoading" :max-height="tableHeight">
+            <div class="adm-table-wrap">
+<el-table v-if="searchForm.memberId" :data="tasks" border v-loading="taskLoading" height="100%">
         <template slot="empty">
           <div class="empty-table-state">
             <i class="el-icon-tickets" style="font-size: 48px; color: #ccc; margin-bottom: 16px;"></i>
@@ -156,7 +159,6 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
 
       <!-- 分页 -->
       <el-pagination
@@ -216,8 +218,10 @@
         <el-button type="primary" @click="submitEditTask" :loading="editLoading">保存</el-button>
       </div>
     </el-dialog>
+  </el-table>
   </div>
-</template>
+
+</div></template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'

+ 5 - 2
cfc-web/src/views/GuidePackages.vue

@@ -26,7 +26,8 @@
       </el-form>
 
       <!-- 任务模板列表 -->
-      <el-table :max-height="tableHeight" :data="tableData" border v-loading="loading">
+            <div class="adm-table-wrap">
+<el-table height="100%" :data="tableData" border v-loading="loading">
         <el-table-column prop="id" label="ID" width="60">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
@@ -95,6 +96,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
     </el-card>
 
     <!-- 编辑弹窗 -->
@@ -165,7 +167,6 @@
             <el-button size="mini" @click="handleDeleteItem(row, $index)">删除</el-button>
           </template>
         </el-table-column>
-      </el-table>
       <div slot="footer">
         <el-button @click="itemsDialogVisible = false">关闭</el-button>
       </div>
@@ -212,6 +213,8 @@
         <el-button type="primary" @click="copyShareCode">复制分享码</el-button>
       </div>
     </el-dialog>
+  
+  </el-table>
   </div>
 </template>
 

+ 2 - 0
cfc-web/src/views/Layout.vue

@@ -330,6 +330,8 @@ export default {
                 { path: '/energy-rule', label: '能量规则管理', icon: 'el-icon-setting', perm: 'config:health' },
                 { path: '/energy-behavior-config', label: '能量行为配置', icon: 'el-icon-edit-outline', perm: 'config:health' },
                 { path: '/periodic-service-config', label: '周期性服务配置', icon: 'el-icon-refresh', perm: 'config:health' },
+                { path: '/indicator-correlations', label: '指标关联配置', icon: 'el-icon-connection', perm: 'config:health' },
+                { path: '/indicator-mappings', label: '指标映射管理', icon: 'el-icon-sort', perm: 'config:health' },
               ]},
             // --- 人生维度 ---
             { title: '人生维度', icon: 'el-icon-s-custom', perm: 'config:dimension',

+ 3 - 1
cfc-web/src/views/OperationLogs.vue

@@ -35,7 +35,8 @@
 
     <!-- 日志列表 -->
     <el-card>
-      <el-table :max-height="tableHeight" :data="logs" v-loading="loading" stripe>
+            <div class="adm-table-wrap">
+<el-table height="100%" :data="logs" v-loading="loading" stripe>
         <el-table-column prop="id" label="ID" width="80">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
@@ -100,6 +101,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
 
       <!-- 分页 -->
       <el-pagination

+ 3 - 1
cfc-web/src/views/Points.vue

@@ -41,7 +41,8 @@
       </el-form>
 
       <!-- 积分流水列表 -->
-      <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
+            <div class="adm-table-wrap">
+<el-table :data="tableData" border v-loading="loading" style="width: 100%" height="100%">
         <el-table-column prop="id" label="ID" width="80">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
@@ -94,6 +95,7 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
 
       <!-- 分页 -->
       <el-pagination

+ 9 - 5
cfc-web/src/views/Rewards.vue

@@ -61,7 +61,6 @@
                   {{ formatDate(scope.row.createdAt) }}
                 </template>
               </el-table-column>
-            </el-table>
             <el-pagination
               style="margin-top: 15px; text-align: right"
               @current-change="loadFamilies"
@@ -72,7 +71,8 @@
             ></el-pagination>
           </el-dialog>
 
-          <el-table :data="tableData" border v-loading="loading" style="width: 100%" :max-height="tableHeight">
+                    <div class="adm-table-wrap">
+<el-table :data="tableData" border v-loading="loading" style="width: 100%" height="100%">
              <el-table-column prop="id" label="ID" width="80">
                <template slot="header">
                  <span class="sort-header" @click.stop="onSortClick('id', $event)">ID<span v-if="sortIndex('id') !== null" class="sort-index" :class="sortClass('id') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('id') }}</span>{{ sortIcon('id') }}</span>
@@ -140,6 +140,7 @@
               </template>
             </el-table-column>
           </el-table>
+          </div>
 
           <el-pagination
             style="margin-top: 20px; text-align: right"
@@ -158,7 +159,8 @@
           <el-button type="primary" size="small" style="margin-bottom: 15px" @click="showCreateTemplate">
             新建模板
           </el-button>
-          <el-table :data="templates" border v-loading="loadingTemplates" style="width: 100%" :max-height="tableHeight">
+                    <div class="adm-table-wrap">
+<el-table :data="templates" border v-loading="loadingTemplates" style="width: 100%" height="100%">
             <el-table-column prop="id" label="ID" width="80"></el-table-column>
             <el-table-column prop="title" label="模板名称" min-width="200"></el-table-column>
             <el-table-column prop="pointsRequired" label="所需积分" width="120">
@@ -177,7 +179,6 @@
                 <el-button size="mini" @click="handleDeleteTemplate(row)">删除</el-button>
               </template>
             </el-table-column>
-          </el-table>
         </el-tab-pane>
       </el-tabs>
     </el-card>
@@ -206,8 +207,11 @@
         <el-button type="primary" @click="handleCreateTemplate" :loading="creating">确定</el-button>
       </div>
     </el-dialog>
+  </el-table>
   </div>
-</template>
+
+</el-table>
+</div></template>
 
 <script>
 import SortMixin from '@/mixins/SortMixin'

部分文件因文件數量過多而無法顯示