Преглед изворни кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
#	cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
#	cfc-backend/src/main/resources/schema.sql
Sisyphus пре 1 недеља
родитељ
комит
48b0c93dcf
100 измењених фајлова са 4807 додато и 1625 уклоњено
  1. 143 19
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 0 1
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/config/RestTemplateConfig.java
  4. 0 2
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  5. 11 25
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  6. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  7. 131 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParseAdminController.java
  8. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java
  9. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java
  10. 12 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecordController.java
  11. 40 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java
  12. 266 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java
  13. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java
  14. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ComboSelectionDTO.java
  15. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ComboTierDTO.java
  16. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CustomizationStateDTO.java
  17. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java
  18. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProcurementTaskDTO.java
  19. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  20. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDiseaseRisk.java
  21. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java
  22. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java
  23. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProcurementTask.java
  24. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  25. 42 1
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportDraftMapper.java
  26. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProcurementTaskMapper.java
  27. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  28. 106 2
      cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java
  29. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java
  30. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java
  31. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java
  32. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  33. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/service/KnowledgeBaseV3Service.java
  34. 105 16
      cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java
  35. 13 1
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  36. 57 2
      cfc-backend/src/main/java/com/etotem/cfc/service/NutritionProductService.java
  37. 301 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProcurementTaskService.java
  38. 122 6
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  39. 66 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  40. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java
  41. 85 7
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  42. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserAddressService.java
  43. 104 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java
  44. 28 9
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java
  45. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/task/ReportCollectRetryTask.java
  46. 4 7
      cfc-backend/src/main/resources/application.yml
  47. 598 0
      cfc-backend/src/main/resources/knowledge-base/报告指标知识库.json
  48. 53 1
      cfc-backend/src/main/resources/schema.sql
  49. 59 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java
  50. 100 0
      cfc-backend/src/test/java/com/etotem/cfc/service/RealNameBackfillServiceTest.java
  51. 55 0
      cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java
  52. 1 1
      cfc-desktop-pet/.gitignore
  53. 43 23
      cfc-desktop-pet/README.md
  54. 0 24
      cfc-desktop-pet/XafPet.Desktop.sln
  55. 59 2
      cfc-desktop-pet/src-tauri/src/api/mod.rs
  56. 36 5
      cfc-desktop-pet/src-tauri/src/api/models.rs
  57. 3 0
      cfc-desktop-pet/src-tauri/src/app_state.rs
  58. 77 32
      cfc-desktop-pet/src-tauri/src/commands.rs
  59. 64 0
      cfc-desktop-pet/src-tauri/src/config.rs
  60. 414 0
      cfc-desktop-pet/src-tauri/src/locomotion.rs
  61. 7 0
      cfc-desktop-pet/src-tauri/src/main.rs
  62. 1 1
      cfc-desktop-pet/src-tauri/tauri.conf.json
  63. 0 6
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml
  64. 0 16
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs
  65. 0 46
      cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css
  66. 0 51
      cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js
  67. 0 105
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js
  68. 0 30
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html
  69. 0 27
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json
  70. 0 27
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json
  71. 0 16
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml
  72. 0 207
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs
  73. 0 78
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs
  74. 0 89
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs
  75. 0 73
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs
  76. 0 27
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs
  77. 0 62
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs
  78. 0 11
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml
  79. 0 77
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs
  80. 0 20
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml
  81. 0 44
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs
  82. 0 23
      cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs
  83. 0 43
      cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs
  84. 0 28
      cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj
  85. 106 0
      cfc-desktop-pet/src/animation-gen.js
  86. 53 0
      cfc-desktop-pet/src/animations.css
  87. 28 3
      cfc-desktop-pet/src/bridge.js
  88. 19 3
      cfc-desktop-pet/src/index.html
  89. 40 2
      cfc-desktop-pet/src/interactions.js
  90. 170 13
      cfc-desktop-pet/src/pet-engine.js
  91. 43 0
      cfc-desktop-pet/src/pet-pose.js
  92. 26 10
      cfc-desktop-pet/src/settings.html
  93. 0 96
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs
  94. 0 68
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs
  95. 0 8
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/README.md
  96. 0 107
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs
  97. 0 17
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj
  98. 201 0
      cfc-frontend/components/DimensionVisitorCard.vue
  99. 3 1
      cfc-frontend/components/LoginGuideCard.vue
  100. 302 0
      cfc-frontend/components/TrendChart.vue

+ 143 - 19
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -21,7 +21,6 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 @Component
@@ -95,18 +94,37 @@ public class DatabaseInitializer implements CommandLineRunner {
             // Remove SQL single-line comments
             content = content.replaceAll("(?m)^\\s*--.*$", "");
 
-            // Extract all CREATE TABLE IF NOT EXISTS statements
-            Pattern pattern = Pattern.compile(
-                    "CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+.*?;",
-                    Pattern.CASE_INSENSITIVE | Pattern.DOTALL
-            );
-            Matcher matcher = pattern.matcher(content);
-
+            // 提取所有 CREATE TABLE IF NOT EXISTS 语句。
+            // 不能用非贪婪 ".*?;" 匹配:COMMENT 字符串字面量中可能含分号(如
+            // task_reminder_config.offset_minutes 的注释 "截止前N分钟; after_timeout..."),
+            // 会被提前截断导致语句不完整而建表失败。此处按字符扫描,跳过引号内部的分号。
             List<String> createTableSQLs = new ArrayList<>();
-            while (matcher.find()) {
-                String sql = matcher.group().trim();
-                if (!sql.isEmpty()) {
-                    createTableSQLs.add(sql);
+            Pattern createTableHead = Pattern.compile(
+                    "^\\s*CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\b", Pattern.CASE_INSENSITIVE);
+            StringBuilder stmt = new StringBuilder();
+            boolean inSingleQuote = false;
+            boolean inBacktick = false;
+            for (int i = 0; i < content.length(); i++) {
+                char c = content.charAt(i);
+                if (c == '\'' && !inBacktick) {
+                    if (inSingleQuote && i + 1 < content.length() && content.charAt(i + 1) == '\'') {
+                        stmt.append("''");
+                        i++;
+                        continue;
+                    }
+                    inSingleQuote = !inSingleQuote;
+                    stmt.append(c);
+                } else if (c == '`' && !inSingleQuote) {
+                    inBacktick = !inBacktick;
+                    stmt.append(c);
+                } else if (c == ';' && !inSingleQuote && !inBacktick) {
+                    String sql = stmt.toString().trim();
+                    if (!sql.isEmpty() && createTableHead.matcher(sql).find()) {
+                        createTableSQLs.add(sql + ";");
+                    }
+                    stmt.setLength(0);
+                } else {
+                    stmt.append(c);
                 }
             }
 
@@ -3014,6 +3032,15 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createIndicatorCorrelationsTable();
         ensureIndicatorCollectionDate();
         seedDimensionDefinitions();
+
+        // 商品组合推荐与采购任务迁移(363)
+        createProcurementTaskTable();
+
+        // 迁移364: health_report_drafts 添加解析进度/重试字段(known-type 解析失败退避重试,不再静默回退 opencode)
+        addDraftParseProgressColumns();
+
+        // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
+        ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
     }
 
     private void migrateHealthDimensionsTable() {
@@ -3279,6 +3306,12 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // blood_type_config.description
         modifyColumnToText("blood_type_config", "description", "性格描述");
 
+        // 迁移20240709-02: 北京菌群报告采集增强(P0 疾病关联菌 / P1 菌群状态)
+        // health_disease_risk.related_bacteria: 疾病关联菌(异常菌)明细 JSON
+        ensureColumn("health_disease_risk", "related_bacteria", "TEXT NULL COMMENT '关联菌明细JSON:[{bacteria,correlation,value,status}]'");
+        // health_reports.gut_status_level: 肠道菌群状态(0度~4度)
+        ensureColumn("health_reports", "gut_status_level", "VARCHAR(20) NULL COMMENT '肠道菌群状态(0度~4度)'");
+
         // 迁移248: 任务系统升级(行为调度中心)——tasks 新增字段
         ensureColumn("tasks", "prerequisite_task_id", "BIGINT NULL COMMENT '前置任务ID:完成后才解锁本任务'");
         ensureColumn("tasks", "action_type", "VARCHAR(32) NULL COMMENT '联动动作: minigame/focus/survey/article/activity/cart/order'");
@@ -6299,6 +6332,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
         // 迁移134: products表添加sort_order列(商品按排序字段排序展示)
         ensureColumn("products", "sort_order", "INT DEFAULT 0 COMMENT '排序(值越小越靠前)'");
         ensureColumn("products", "recommendation_tags", "VARCHAR(500) DEFAULT NULL COMMENT '推荐标签'");
+        ensureColumn("products", "is_recommended", "TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是'");
         ensureColumn("products", "repurchase_interval_days", "INT DEFAULT 30 COMMENT '复购间隔天数'");
 
         // 迁移134: 创建 category_ppoint 表(品类P点比例配置)
@@ -9206,7 +9240,8 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                         "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " +
                         "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " +
                         "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " +
-                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app')");
+                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app'), " +
+                        "('procurement_completed', '采购任务已完成', '您的【{comboTier}】套餐采购任务已完成,共{productCount}件商品,下单时间:{createTime},产品即将送达,请及时查收。', 'in_app,wechat')");
                 log.info("notification_templates 种子数据已插入");
             } else {
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
@@ -10989,6 +11024,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         backfillTemplateInstanceFamilyMemberId();
         createReportTransferRequestsTable();
         addProductMinDiscountLevel();
+        restoreBasicMembershipLevel();
         backfillMembershipLevelOrder();
         removeReportUploadQuota();
 
@@ -11818,15 +11854,53 @@ 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 '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);
+                    "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");
+            log.info("迁移351: 已回填 membership_levels.level_order({} 行):FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6", updated);
+        } catch (Exception e) {
+            log.warn("迁移351: 回填 membership_levels.level_order 失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移360: 恢复 9.9 元/月 BASIC 体验会员等级与权益(幂等)。
+     * 反向还原提交 f86dd164b 删除的内容;level_order 由 backfillMembershipLevelOrder 统一回填。
+     */
+    private void restoreBasicMembershipLevel() {
+        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\",\"referral_commission\"]', " +
+                            "3, 3)");
+            log.info("迁移360: 已恢复 BASIC 体验会员等级");
+        } catch (Exception e) {
+            log.warn("迁移360: 恢复 BASIC 会员等级失败: {}", e.getMessage());
+        }
+        // 兼容历史数据:早期 BASIC features 缺少 referral_commission,补齐
+        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\"]");
+        } catch (Exception e) {
+            log.warn("迁移360: 补齐 BASIC features 失败: {}", e.getMessage());
+        }
+        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("迁移360: 已恢复 BASIC 会员权益");
         } catch (Exception e) {
-            log.warn("迁移351: 顺延 membership_levels.level_order 失败: {}", e.getMessage());
+            log.warn("迁移360: 恢复 BASIC 会员权益失败: {}", e.getMessage());
         }
     }
 
-    // 迁移352: 健康报告上传改为对所有登录用户免费无限
     private void removeReportUploadQuota() {
         try {
             int deletedBenefits = jdbcTemplate.update(
@@ -12319,7 +12393,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    // 迁移359: products 表添加 vendor_visible 列(供应商仅可见功能)
     private void addProductVendorVisibleColumn() {
         ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
     }
@@ -12442,4 +12515,55 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         log.info("迁移364: disease_intake_limits种子数据初始化完成");
     }
+
+    // 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+    private void createProcurementTaskTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS procurement_tasks (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识', " +
+                    "user_id BIGINT NOT NULL COMMENT '所属用户ID', " +
+                    "family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID', " +
+                    "combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low', " +
+                    "original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)', " +
+                    "selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)', " +
+                    "custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)', " +
+                    "custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)', " +
+                    "status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED', " +
+                    "create_time DATETIME NOT NULL COMMENT '创建时间', " +
+                    "complete_time DATETIME DEFAULT NULL COMMENT '完成时间', " +
+                    "message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒', " +
+                    "execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID', " +
+                    "update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_task_id (task_id), " +
+                    "INDEX idx_user_id (user_id), " +
+                    "INDEX idx_family_member_id (family_member_id), " +
+                    "INDEX idx_status (status), " +
+                    "INDEX idx_create_time (create_time)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)'");
+            log.info("已创建procurement_tasks表");
+        } catch (Exception e) {
+            log.warn("创建procurement_tasks表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移364: health_report_drafts 添加解析进度/重试字段(INFORMATION_SCHEMA 检查后再 ALTER,幂等)
+    private void addDraftParseProgressColumns() {
+        if (!columnExists("health_report_drafts", "parse_attempt")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数'");
+            log.info("已添加 parse_attempt 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "next_retry_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次自动重试时间(指数退避,上限5分钟)'");
+            log.info("已添加 next_retry_at 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "parse_started_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间'");
+            log.info("已添加 parse_started_at 列到 health_report_drafts 表");
+        }
+        if (!columnExists("health_report_drafts", "parse_finished_at")) {
+            jdbcTemplate.execute("ALTER TABLE health_report_drafts ADD COLUMN parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)'");
+            log.info("已添加 parse_finished_at 列到 health_report_drafts 表");
+        }
+    }
 }

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

@@ -43,7 +43,6 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/direct-register",
         "/api/auth/register-with-invite",
         "/api/auth/wechat-web/qr",
-        "/api/auth/wechat-web/callback",
         "/api/auth/wechat-web/poll",
         "/api/internal/",
         "/api/product/list",

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/RestTemplateConfig.java

@@ -17,4 +17,13 @@ public class RestTemplateConfig {
                 .setReadTimeout(Duration.ofSeconds(180))
                 .build();
     }
+
+    /** LangGraph 专用 RestTemplate:读超时放宽到 300s,避免大报告解析超时被误判失败 */
+    @Bean
+    public RestTemplate langGraphRestTemplate(RestTemplateBuilder builder) {
+        return builder
+                .setConnectTimeout(Duration.ofSeconds(30))
+                .setReadTimeout(Duration.ofSeconds(300))
+                .build();
+    }
 }

+ 0 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -103,7 +103,6 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
                 "/api/auth/wechat-web/qr",
-                "/api/auth/wechat-web/callback",
                 "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
@@ -149,7 +148,6 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
                 "/api/auth/wechat-web/qr",
-                "/api/auth/wechat-web/callback",
                 "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",

+ 11 - 25
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -899,10 +899,9 @@ public class HealthReportController {
         String originalFilename = draft.getOriginalFilename();
 
         try {
-            // LangGraph 优先:远程 OSS URL 直接传 URL(LangGraph 自行下载),本地路径传共享卷路径
+            // LangGraph 需要本地路径:经 getFilePath 下载到共享卷(LangGraph 容器可读)
             try {
-                String lgPath = (fileUrl != null && fileUrl.startsWith("http"))
-                        ? fileUrl : storageService.getFilePath(fileUrl);
+                String lgPath = storageService.getFilePath(fileUrl);
                 Map<String, Object> lgRequest = new HashMap<>();
                 lgRequest.put("file_path", lgPath);
                 lgRequest.put("user_id", userId);
@@ -1081,12 +1080,11 @@ public class HealthReportController {
 
             // 保存文件(通过 StorageService 支持本地磁盘或京东云 OSS)
             String fileUrl = storageService.storeFile(file, "health-reports");
-            boolean isRemote = fileUrl != null && fileUrl.startsWith("http");
 
-            // 尝试 LangGraph 远程解析(远程 OSS URL 直接传 URL,LangGraph 自行下载)
+            // 尝试 LangGraph 解析(LangGraph 只认本地路径,必须经 getFilePath 下载到共享卷)
             try {
                 Map<String, Object> lgRequest = new HashMap<>();
-                lgRequest.put("file_path", isRemote ? fileUrl : storageService.getFilePath(fileUrl));
+                lgRequest.put("file_path", storageService.getFilePath(fileUrl));
                 lgRequest.put("user_id", userId);
                 if (familyId != null) {
                     lgRequest.put("family_id", familyId);
@@ -2321,6 +2319,10 @@ public class HealthReportController {
 
         List<Food> foods = new ArrayList<>();
         for (ParsedReportPayload.FoodItem pf : list) {
+            if (pf == null || pf.getName() == null || pf.getName().trim().isEmpty()) {
+                log.warn("跳过无效食材条目(元素为空或缺少名称)");
+                continue;
+            }
             Food food = new Food();
             food.setName(pf.getName());
             food.setCategory(pf.getCategory());
@@ -2439,7 +2441,8 @@ public class HealthReportController {
         }
 
         try {
-            String absolutePath = saveUploadFileForProxy(file, userId);
+            String fileUrl = storageService.storeFile(file, "health-reports");
+            String absolutePath = storageService.getFilePath(fileUrl);
 
             // 调用 LangGraph 解析
             Map<String, Object> result = callLangGraphParse(absolutePath, userId, familyId, subjectId);
@@ -2448,6 +2451,7 @@ public class HealthReportController {
                 return Result.error("报告解析失败");
             }
 
+            result.put("fileUrl", fileUrl);
             // 保存草稿
             Long draftId = (Long) result.get("draftId");
             if (draftId != null && note != null) {
@@ -2519,24 +2523,6 @@ public class HealthReportController {
         }
     }
 
-    /**
-     * 代上传场景:保存文件(用代上传人目录)
-     */
-    private String saveUploadFileForProxy(MultipartFile file, Long proxyUserId) throws IOException {
-        String uploadDir = "uploads" + File.separator + "health-reports-proxy" + File.separator + proxyUserId;
-        File dir = new File(uploadDir);
-        if (!dir.exists()) {
-            dir.mkdirs();
-        }
-        String originalFilename = file.getOriginalFilename();
-        if (originalFilename == null) originalFilename = "report.pdf";
-        String extension = originalFilename.substring(originalFilename.lastIndexOf('.'));
-        String savedFilename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + extension;
-        Path filePath = Paths.get(uploadDir, savedFilename);
-        Files.copy(file.getInputStream(), filePath);
-        return uploadDir.replace("\\", "/") + "/" + savedFilename;
-    }
-
     /**
      * 调用 LangGraph 解析报告
      */

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -114,6 +114,9 @@ public class AdminArticleController {
         if (body.get("difficultyLevel") != null && !body.get("difficultyLevel").toString().trim().isEmpty()) {
             article.setDifficultyLevel(Integer.valueOf(body.get("difficultyLevel").toString()));
         }
+        if (body.get("sortOrder") != null && !body.get("sortOrder").toString().trim().isEmpty()) {
+            article.setSortOrder(Integer.valueOf(body.get("sortOrder").toString()));
+        }
         // relatedDimensions may come as array from frontend
         Object rd = body.get("relatedDimensions");
         if (rd instanceof List) {
@@ -195,6 +198,9 @@ public class AdminArticleController {
         if (body.get("difficultyLevel") != null && !body.get("difficultyLevel").toString().trim().isEmpty()) {
             article.setDifficultyLevel(Integer.valueOf(body.get("difficultyLevel").toString()));
         }
+        if (body.get("sortOrder") != null && !body.get("sortOrder").toString().trim().isEmpty()) {
+            article.setSortOrder(Integer.valueOf(body.get("sortOrder").toString()));
+        }
         Object rd = body.get("relatedDimensions");
         if (rd instanceof List) {
             article.setRelatedDimensions(String.join(",", (List<String>) rd));

+ 131 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParseAdminController.java

@@ -0,0 +1,131 @@
+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.HealthReportDraft;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.PostMapping;
+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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 报告解析进度管理(管理员):草稿解析进度查询、手动重试、食材回填。
+ * 鉴权由 JwtInterceptor 对 /api/** 统一拦截。
+ */
+@RestController
+@RequestMapping("/api/admin/report-parse")
+public class ReportParseAdminController {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportParseAdminController.class);
+
+    @Resource
+    private HealthReportDraftMapper draftMapper;
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private ReportCollectService reportCollectService;
+
+    /** 草稿解析进度分页查询 */
+    @PostMapping("/list")
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
+        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
+        String parseStatus = (String) params.get("parseStatus");
+        String reportType = (String) params.get("reportType");
+        String keyword = (String) params.get("keyword");
+
+        Page<HealthReportDraft> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<HealthReportDraft> wrapper = new LambdaQueryWrapper<>();
+        if (parseStatus != null && !parseStatus.isEmpty()) {
+            wrapper.eq(HealthReportDraft::getParseStatus, parseStatus);
+        }
+        if (reportType != null && !reportType.isEmpty()) {
+            wrapper.eq(HealthReportDraft::getReportType, reportType);
+        }
+        if (keyword != null && !keyword.isEmpty()) {
+            wrapper.and(w -> w.like(HealthReportDraft::getOriginalFilename, keyword)
+                    .or().like(HealthReportDraft::getUserId, keyword));
+        }
+        wrapper.orderByDesc(HealthReportDraft::getUpdatedAt);
+
+        Page<HealthReportDraft> result = draftMapper.selectPage(pageParam, wrapper);
+        List<Map<String, Object>> records = new ArrayList<>();
+        for (HealthReportDraft d : result.getRecords()) {
+            records.add(toRecordMap(d));
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("total", result.getTotal());
+        data.put("records", records);
+        return Result.success(data);
+    }
+
+    /** 手动重试:重置解析进度并重新提交异步采集 */
+    @PostMapping("/retry")
+    public Result<Map<String, Object>> retry(@RequestBody Map<String, Object> params) {
+        Long draftId = params.get("draftId") != null ? ((Number) params.get("draftId")).longValue() : null;
+        if (draftId == null) {
+            return Result.error("draftId 不能为空");
+        }
+        healthReportDraftService.resetForManualRetry(draftId);
+        reportCollectService.submitAsyncCollect(draftId);
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("draftId", draftId);
+        data.put("parseStatus", "collecting");
+        return Result.success(data);
+    }
+
+    /** 食材回填:重新解析报告并覆盖写入 foods(失败保留旧载荷) */
+    @PostMapping("/backfill-food")
+    public Result<Map<String, Object>> backfillFood(@RequestBody Map<String, Object> params) {
+        Long reportId = params.get("reportId") != null ? ((Number) params.get("reportId")).longValue() : null;
+        if (reportId == null) {
+            return Result.error("reportId 不能为空");
+        }
+        try {
+            int count = reportCollectService.reparseReportFoods(reportId);
+            Map<String, Object> data = new LinkedHashMap<>();
+            data.put("reportId", reportId);
+            data.put("foodCount", count);
+            return Result.success(data);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("食材回填失败 reportId={}: {}", reportId, e.getMessage());
+            return Result.error(e.getMessage());
+        }
+    }
+
+    private Map<String, Object> toRecordMap(HealthReportDraft d) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("draftId", d.getId());
+        m.put("userId", d.getUserId());
+        m.put("reportType", d.getReportType());
+        m.put("originalFilename", d.getOriginalFilename());
+        m.put("fileHash", d.getFileHash());
+        m.put("parseStatus", d.getParseStatus());
+        m.put("parseMethod", d.getParseMethod());
+        m.put("parseError", d.getParseError());
+        m.put("parseAttempt", d.getParseAttempt());
+        m.put("nextRetryAt", d.getNextRetryAt());
+        m.put("parseStartedAt", d.getParseStartedAt());
+        m.put("parseFinishedAt", d.getParseFinishedAt());
+        m.put("collectValidation", d.getCollectValidation());
+        m.put("draftStatus", d.getStatus());
+        m.put("confirmedReportId", d.getConfirmedReportId());
+        m.put("createdAt", d.getCreatedAt());
+        m.put("updatedAt", d.getUpdatedAt());
+        return m;
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReviewCenterController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.ActivityRegistration;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ButlerProfile;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.PendingRefund;
 import com.etotem.cfc.entity.SupplyHierarchyChangeRequest;
 import com.etotem.cfc.entity.TaskTemplatePackage;
 import com.etotem.cfc.entity.User;
@@ -20,6 +21,7 @@ import com.etotem.cfc.mapper.SupplyHierarchyChangeRequestMapper;
 import com.etotem.cfc.mapper.TaskTemplatePackageMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.WithdrawalRequestMapper;
+import com.etotem.cfc.mapper.PendingRefundMapper;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -59,6 +61,9 @@ public class ReviewCenterController {
     @Resource
     private SupplyHierarchyChangeRequestMapper supplyHierarchyChangeRequestMapper;
 
+    @Resource
+    private PendingRefundMapper pendingRefundMapper;
+
     @PostMapping("/summary")
     public Result<Map<String, Integer>> summary() {
         Map<String, Integer> counts = new HashMap<>();
@@ -90,6 +95,9 @@ public class ReviewCenterController {
         counts.put("withdrawPending", withdrawalRequestMapper.selectCount(
                 new QueryWrapper<WithdrawalRequest>().eq("status", "pending")).intValue());
 
+        counts.put("refundPending", pendingRefundMapper.selectCount(
+                new QueryWrapper<PendingRefund>().eq("status", "pending")).intValue());
+
         counts.put("hierarchyChangePending", supplyHierarchyChangeRequestMapper.selectCount(
                 new QueryWrapper<SupplyHierarchyChangeRequest>().eq("status", "pending")).intValue());
 

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.controller.auth;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.WechatWebLoginService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+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.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 微信「小程序码」扫码登录(桌面端 SSO)。
+ *
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (从 scene 取 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认 →
+ * 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>全部为 POST:/qr、/poll 公开(桌面端尚未持有令牌),/scan 需小程序 JWT
+ * (扫码者即小程序已登录用户,据此完成与小程序账号的 SSO)。
+ */
+@Tag(name = "微信小程序扫码登录", description = "桌面端小程序码扫码登录,与小程序账号 SSO")
+@RestController
+@Slf4j
+@RequestMapping("/api/auth/wechat-web")
+public class WechatWebAuthController {
+
+    @Resource
+    private WechatWebLoginService wechatWebLoginService;
+
+    @Resource
+    private UserService userService;
+
+    @Operation(summary = "获取扫码登录小程序码")
+    @PostMapping("/qr")
+    public Result<Map<String, Object>> qr() {
+        String state = UUID.randomUUID().toString().replace("-", "");
+        wechatWebLoginService.putState(state, "P");
+        Map<String, Object> data = new HashMap<>();
+        data.put("state", state);
+        try {
+            String base64 = wechatWebLoginService.generateQrImage(state);
+            data.put("qrImage", base64 == null || base64.isEmpty() ? "" : "data:image/png;base64," + base64);
+        } catch (RuntimeException e) {
+            log.error("生成登录小程序码失败: state={}", state, e);
+            wechatWebLoginService.removeState(state);
+            return Result.error("生成二维码失败:" + e.getMessage());
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "小程序扫码确认登录(小程序端调用,携带小程序 JWT)")
+    @PostMapping("/scan")
+    public Result<Map<String, Object>> scan(@RequestBody Map<String, String> body,
+                                            @RequestAttribute("userId") Long userId) {
+        String state = body != null ? body.get("state") : null;
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            return Result.error("二维码已过期,请重新获取");
+        }
+        if (!"P".equals(stored.toString())) {
+            return Result.error("二维码已被使用或已过期");
+        }
+        if (userId == null) {
+            return Result.error("请先登录小程序");
+        }
+        wechatWebLoginService.putState(state, "U" + userId);
+        log.info("小程序扫码确认登录: state={}, userId={}", state, userId);
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "ok");
+        return Result.success(data);
+    }
+
+    @Operation(summary = "轮询扫码登录结果")
+    @PostMapping("/poll")
+    public Result<Map<String, Object>> poll(@RequestBody Map<String, String> body) {
+        String state = body != null ? body.get("state") : null;
+        Map<String, Object> data = new HashMap<>();
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            data.put("status", "expired");
+            return Result.success(data);
+        }
+        String value = stored.toString();
+        if ("P".equals(value)) {
+            data.put("status", "pending");
+            return Result.success(data);
+        }
+        if ("N".equals(value)) {
+            wechatWebLoginService.removeState(state);
+            data.put("status", "notfound");
+            return Result.success(data);
+        }
+        if (value.startsWith("U")) {
+            wechatWebLoginService.removeState(state);
+            Long userId = Long.valueOf(value.substring(1));
+            try {
+                LoginResultDTO login = userService.ssoLogin(userId);
+                data.put("status", "success");
+                data.put("token", login.getToken());
+                data.put("userId", login.getUserId());
+                data.put("role", login.getRole());
+                data.put("nickname", login.getNickname());
+            } catch (RuntimeException e) {
+                log.warn("小程序扫码换取 JWT 失败: state={}, error={}", state, e.getMessage());
+                data.put("status", "error");
+                data.put("message", e.getMessage() != null ? e.getMessage() : "登录失败");
+            }
+            return Result.success(data);
+        }
+        data.put("status", "error");
+        data.put("message", "未知状态");
+        return Result.success(data);
+    }
+}

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecordController.java

@@ -1,6 +1,8 @@
 package com.etotem.cfc.controller.diet;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.QuotaConsumeResult;
+import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.DietRecordService;
 import org.springframework.web.bind.annotation.*;
 
@@ -14,8 +16,17 @@ public class DietRecordController {
     @Resource
     private DietRecordService dietRecordService;
 
+    @Resource
+    private BasicQuotaService basicQuotaService;
+
     @PostMapping("/recognize")
-    public Result<com.etotem.cfc.dto.RecognizeFoodResult> recognizeFood(@RequestBody Map<String, String> request) {
+    public Result<com.etotem.cfc.dto.RecognizeFoodResult> recognizeFood(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, String> request) {
+        QuotaConsumeResult quota = basicQuotaService.consumeFoodRecognizeQuota(userId);
+        if (!quota.isSuccess()) {
+            return Result.error(403, quota.getToastMsg());
+        }
         return Result.success(dietRecordService.recognizeFood(request.get("image_url")));
     }
 

+ 40 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.dto.SelfCheckResultVO;
 import com.etotem.cfc.dto.SubmitSelfCheckDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.FiveDimensionSelfCheckService;
 import com.etotem.cfc.service.UnlockGateService;
 import com.etotem.cfc.util.ParamUtils;
@@ -18,6 +19,7 @@ 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.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -44,6 +46,9 @@ public class FiveDimensionSelfCheckController {
     @Resource
     private com.etotem.cfc.service.HealthPlanService healthPlanService;
 
+    @Resource
+    private AiGateway aiGateway;
+
     @Operation(summary = "获取自检问卷题目(首次固定 15 题;再次合并题库随机抽)")
     @PostMapping("/questions")
     public Result<List<SelfCheckQuestionVO>> getQuestions(
@@ -68,7 +73,7 @@ public class FiveDimensionSelfCheckController {
         }
         try {
             SelfCheckResultVO vo = selfCheckService.submitSelfCheck(dto, userId);
-            // 触发通关解锁刷新:自检通过 → 解锁个人沙盘
+
             User user = userMapper.selectById(userId);
             if (user != null) {
                 unlockGateService.refreshPersonalGates(userId, user.getFamilyId());
@@ -165,4 +170,38 @@ public class FiveDimensionSelfCheckController {
             return Result.error("趋势分析失败");
         }
     }
+
+    @Operation(summary = "AI生成五维自检题目(智能出题)")
+    @PostMapping("/ai-generate-questions")
+    public Result<List<SelfCheckQuestionVO>> aiGenerateQuestions(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody(required = false) Map<String, Object> body) {
+        if (userId == null) return Result.error("请先登录");
+        try {
+            Map<String, Object> inputs = new HashMap<>();
+            inputs.put("user_id", userId);
+            if (body != null) {
+                if (body.get("count") != null) inputs.put("count", body.get("count"));
+                if (body.get("difficulty") != null) inputs.put("difficulty", body.get("difficulty"));
+                if (body.get("dimensions") != null) inputs.put("dimensions", body.get("dimensions"));
+                if (body.get("exclude_ids") != null) inputs.put("exclude_ids", body.get("exclude_ids"));
+            }
+            List<Map<String, Object>> rawQuestions = aiGateway.generateSelfCheckQuestions(inputs);
+            List<SelfCheckQuestionVO> questions = new ArrayList<>();
+            if (rawQuestions != null) {
+                for (Map<String, Object> q : rawQuestions) {
+                    SelfCheckQuestionVO vo = new SelfCheckQuestionVO();
+                    if (q.get("id") != null) vo.setId(Integer.parseInt(q.get("id").toString()));
+                    if (q.get("dimension") != null) vo.setDimension(q.get("dimension").toString());
+                    if (q.get("sub_dim") != null) vo.setSubDim(q.get("sub_dim").toString());
+                    if (q.get("text") != null) vo.setText(q.get("text").toString());
+                    // options 暂不映射,请根据实际返回格式补充
+                    questions.add(vo);
+                }
+            }
+            return Result.success(questions);
+        } catch (Exception e) {
+            return Result.error("AI生成题目失败: " + e.getMessage());
+        }
+    }
 }

+ 266 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProcurementTaskController.java

@@ -0,0 +1,266 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProcurementTaskService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 商品组合推荐与采购任务控制器
+ * 提供三种方案(高/中/低端)+ 用户定制 + 采购任务全生命周期管理
+ * 路由前缀:/api/product/
+ */
+@RestController
+@RequestMapping("/api/product")
+public class ProcurementTaskController {
+
+    private static final Logger log = LoggerFactory.getLogger(ProcurementTaskController.class);
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProcurementTaskService procurementTaskService;
+
+    /**
+     * 获取三种商品组合方案(高/中/低端),从数据库按维度动态生成
+     */
+    @PostMapping("/combo-tiers")
+    public Result<List<ComboTierDTO>> getComboTiers(@RequestAttribute(value = "userId", required = false) Long userId) {
+        List<ComboTierDTO> tiers = new ArrayList<>();
+
+        // === 高端:五维全覆盖,价格区间较高 ===
+        Map<String, List<Product>> highProducts = productService.listByDimensions(
+                Arrays.asList("body", "mind", "wisdom", "action", "wealth"), 2);
+        List<Long> highSkuIds = buildSkuIdsFromProducts(highProducts);
+        tiers.add(buildTier("high", "尊享全家幸福包",
+                "涵盖身、智、富、行、心五大维度的完整幸福提案,精选优质产品,助您全家安享福贵双全",
+                highProducts, highSkuIds, 100000, 300000));
+
+        // === 中端:身+智+行,中等价位 ===
+        Map<String, List<Product>> mediumProducts = productService.listByDimensions(
+                Arrays.asList("body", "wisdom", "action"), 2);
+        List<Long> mediumSkuIds = buildSkuIdsFromProducts(mediumProducts);
+        tiers.add(buildTier("medium", "平衡成长包",
+                "精选2-3个核心维度的关键产品,性价比高,适合大多数家庭的起步配置",
+                mediumProducts, mediumSkuIds, 10000, 80000));
+
+        // === 低端:单一维度,入门价位 ===
+        List<Product> lowProducts = productService.listByDimension("body", null, null, 3);
+        List<Long> lowSkuIds = lowProducts.stream().map(p -> p.getId()).collect(java.util.stream.Collectors.toList());
+        tiers.add(buildTier("low", "基础启动包",
+                "单一维度必需品,入门首选,让您以最低成本体验核心关怀",
+                Collections.singletonMap("body", lowProducts), lowSkuIds, 0, 50000));
+
+        return Result.success(tiers);
+    }
+
+    /**
+     * 用户选择并确认套餐
+     */
+    @PostMapping("/select-combo")
+    public Result<ProcurementTaskDTO> selectCombo(@RequestBody ComboSelectionDTO selection,
+                                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                                   @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (selection.getSelectedTier() == null || !Arrays.asList("high", "medium", "low").contains(selection.getSelectedTier())) {
+            return Result.error("无效的套餐等级");
+        }
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        Long memberId = selection.getFamilyMemberId() != null ? selection.getFamilyMemberId() : familyMemberId;
+        if (memberId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
+
+        ProcurementTaskDTO task = procurementTaskService.createTask(
+                userId, memberId, selection.getSelectedTier(),
+                selection.getSelectedSkuIds(), selection.getAddedSkuIds(), selection.getRemovedSkuIds());
+        return Result.success(task);
+    }
+
+    /**
+     * 获取当前定制状态
+     */
+    @PostMapping("/customization-state")
+    public Result<CustomizationStateDTO> getCustomizationState(
+            @RequestAttribute(value = "userId", required = false) Long userId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
+        if (userId == null) return Result.error("请先登录");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setUserId(userId);
+        state.setFamilyMemberId(familyMemberId);
+        state.setCurrentTier("low");
+        state.setBaseSkuIds(new ArrayList<>());
+        state.setAddedSkuIds(new ArrayList<>());
+        state.setRemovedSkuIds(new ArrayList<>());
+        state.setFinalSkuIds(new ArrayList<>());
+        state.setIsModified(false);
+        state.setLogEntries(new ArrayList<>());
+        return Result.success(state);
+    }
+
+    /**
+     * 添加产品到当前选择
+     */
+    @PostMapping("/add-product")
+    public Result<CustomizationStateDTO> addProduct(@RequestBody Map<String, Object> req,
+                                                     @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        String skuName = req.get("skuName") != null ? req.get("skuName").toString() : "";
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setAddedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        CustomizationStateDTO.CustomizationLogEntry entry = new CustomizationStateDTO.CustomizationLogEntry();
+        entry.setAction("add");
+        entry.setSkuId(skuId);
+        entry.setSkuName(skuName);
+        entry.setTimestamp(new Date());
+        state.setLogEntries(Collections.singletonList(entry));
+        return Result.success(state);
+    }
+
+    /**
+     * 从当前选择中移除产品
+     */
+    @PostMapping("/remove-product")
+    public Result<CustomizationStateDTO> removeProduct(@RequestBody Map<String, Object> req) {
+        Long skuId = req.get("skuId") instanceof Number ? ((Number) req.get("skuId")).longValue() : null;
+        if (skuId == null) {
+            try {
+                Object raw = req.get("skuId");
+                if (raw != null) skuId = Long.parseLong(raw.toString());
+            } catch (Exception ignored) {}
+        }
+        if (skuId == null) return Result.error("缺少有效的 SKU ID");
+        CustomizationStateDTO state = new CustomizationStateDTO();
+        state.setRemovedSkuIds(new ArrayList<>(Collections.singletonList(skuId)));
+        state.setIsModified(true);
+        return Result.success(state);
+    }
+
+    /**
+     * 启动采购流程(SELECTED → PROCURING → EXECUTING)
+     */
+    @PostMapping("/task/procure/{taskId}")
+    public Result<ProcurementTaskDTO> startProcurement(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.startProcurement(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 完成执行(EXECUTING → COMPLETED + 发送消息提醒)
+     */
+    @PostMapping("/task/execute/{taskId}")
+    public Result<ProcurementTaskDTO> completeExecution(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.completeExecution(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 取消任务
+     */
+    @PostMapping("/task/cancel/{taskId}")
+    public Result<ProcurementTaskDTO> cancelTask(@PathVariable String taskId) {
+        try {
+            return Result.success(procurementTaskService.cancelTask(taskId));
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取任务详情
+     */
+    @PostMapping("/task-detail")
+    public Result<ProcurementTaskDTO> taskDetail(@RequestBody Map<String, Object> params) {
+        String taskId = params.get("taskId") != null ? params.get("taskId").toString() : null;
+        if (taskId == null) return Result.error("缺少任务ID");
+        ProcurementTaskDTO task = procurementTaskService.getTaskById(taskId);
+        return task != null ? Result.success(task) : Result.error("任务不存在");
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    @PostMapping("/task-list")
+    public Result<List<ProcurementTaskDTO>> taskList(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long queryUserId = params.get("userId") != null ?
+                ((Number) params.get("userId")).longValue() : userId;
+        return Result.success(procurementTaskService.getUserTasks(queryUserId));
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private ComboTierDTO buildTier(String tier, String title, String description,
+                                    Map<String, List<Product>> productMap,
+                                    List<Long> skuIds, int minPrice, int maxPrice) {
+        ComboTierDTO c = new ComboTierDTO();
+        c.setTier(tier);
+        c.setTitle(title);
+        c.setDescription(description);
+        c.setPrice(calculateComboPrice(productMap, skuIds));
+        c.setDimensions(new ArrayList<>(productMap.keySet()));
+        c.setBaseSkuIds(skuIds);
+        c.setCustomizable(true);
+        // 可添加/移除的品类名称
+        c.setAddableCategories(Arrays.asList("nutrition", "assessment", "workshop", "counseling", "membership"));
+        c.setRemovableCategories(new ArrayList<>(productMap.keySet()));
+        return c;
+    }
+
+    /**
+     * 从产品映射中提取实际 SKU ID 列表(取每个产品的第一个上架 SKU ID 或 productId 作为 base)
+     */
+    private List<Long> buildSkuIdsFromProducts(Map<String, List<Product>> productMap) {
+        List<Long> ids = new ArrayList<>();
+        for (List<Product> products : productMap.values()) {
+            if (products == null) continue;
+            for (Product p : products) {
+                if (p != null && p.getId() != null) {
+                    ids.add(p.getId());
+                }
+            }
+        }
+        return ids;
+    }
+
+    private int calculateComboPrice(Map<String, List<Product>> productMap, List<Long> skuIds) {
+        int total = 0;
+        if (productMap != null) {
+            for (List<Product> products : productMap.values()) {
+                if (products == null) continue;
+                for (Product p : products) {
+                    if (p.getPrice() != null) total += p.getPrice();
+                }
+            }
+        }
+        return Math.max(total, 1000); // 最低 10 元(DB 无数据兜底)
+    }
+}

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java

@@ -27,6 +27,7 @@ public class ProductRecommendationController {
     @Operation(summary = "获取维度页推荐商品")
     @PostMapping("/dimension-products")
     public Result<List<Map<String, Object>>> getDimensionProducts(@RequestBody Map<String, Object> params,
+                                                                  @RequestAttribute(value = "userId") Long userId,
                                                                   @RequestAttribute(value = "familyId", required = false) Long familyId) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
@@ -43,7 +44,7 @@ public class ProductRecommendationController {
         int limit = limitObj != null ? toInt(limitObj) : 6;
 
         List<Map<String, Object>> result = productRecommendationService.getDimensionRecommendations(
-                dimensionCode, familyId, memberId, excludeIds, limit);
+                dimensionCode, familyId, memberId, excludeIds, limit, userId);
 
         return Result.success(result);
     }

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboSelectionDTO.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class ComboSelectionDTO {
+    /** 用户选择的套餐等级 */
+    private String selectedTier; // high|medium|low
+    /** 是否进行了自定义 (add/remove) */
+    private Boolean customized;
+    /** 原始套餐等级 (customization时使用) */
+    private String originalTier;
+    /** 最终选中的商品 ID 列表 */
+    private List<Long> selectedSkuIds;
+    /** 用户新增的商品 ID 列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品 ID 列表 */
+    private List<Long> removedSkuIds;
+    /** 当前登录用户ID */
+    private Long userId;
+    /** 当前家庭成员ID */
+    private Long familyMemberId;
+    /** 维度权重调整 (可选) */
+    private Map<String, Integer> dimensionWeights; // body,mind,wisdom,action,wealth
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ComboTierDTO.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class ComboTierDTO {
+    /** 套餐等级: high|medium|low */
+    private String tier;
+    /** 套餐标题 */
+    private String title;
+    /** 套餐描述 */
+    private String description;
+    /** 套餐价格(分) */
+    private Integer price;
+    /** 覆盖的维度列表 */
+    private List<String> dimensions;
+    /** 基础商品 ID 列表(对应 products.id) */
+    private List<Long> baseSkuIds;
+    /** 是否可定制 */
+    private Boolean customizable;
+    /** 可添加的产品类目名称 */
+    private List<String> addableCategories;
+    /** 可移除的产品维度码 */
+    private List<String> removableCategories;
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CustomizationStateDTO.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class CustomizationStateDTO {
+    /** 当前选中的套餐等级 */
+    private String currentTier; // high|medium|low
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 基础套餐的商品ID列表(在定制前的状态) */
+    private List<Long> baseSkuIds;
+    /** 用户新增的商品ID列表 */
+    private List<Long> addedSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> removedSkuIds;
+    /** 最终确定的商品ID列表(基础+新增-移除) */
+    private List<Long> finalSkuIds;
+    /** 可供添加的产品分类及对应的SKU选项 */
+    private Map<String, List<Long>> availableAdditions; // categoryId -> [productId, ...]
+    /** 当前套餐覆盖的维度 */
+    private List<String> currentDimensions;
+    /** 定制是否已修改过 (与原始套餐比较) */
+    private Boolean isModified;
+    /** 定制操作日志(简化版) */
+    private List<CustomizationLogEntry> logEntries;
+
+    @Data
+    public static class CustomizationLogEntry {
+        private String action; // "add" 或 "remove"
+        private Long skuId;
+        private String skuName;
+        private Date timestamp;
+    }
+}

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

@@ -21,6 +21,8 @@ public class ParsedReportPayload {
         private Integer coreGenusScore;
         private String gutAge;
         private String gutType;
+        /** 肠道菌群状态,如"0度"(0度~4度) */
+        private String gutStatusLevel;
     private String shannonIndex;
         private String reportNumber;
         private String reportDate;
@@ -51,6 +53,8 @@ public class ParsedReportPayload {
         public void setGutAge(String gutAge) { this.gutAge = gutAge; }
         public String getGutType() { return gutType; }
         public void setGutType(String gutType) { this.gutType = gutType; }
+        public String getGutStatusLevel() { return gutStatusLevel; }
+        public void setGutStatusLevel(String gutStatusLevel) { this.gutStatusLevel = gutStatusLevel; }
     public String getShannonIndex() { return shannonIndex; }
     public void setShannonIndex(String shannonIndex) { this.shannonIndex = shannonIndex; }
         public String getReportNumber() { return reportNumber; }
@@ -164,6 +168,8 @@ public class ParsedReportPayload {
         private String diseaseName;
         private String riskValue;
         private String riskLevel;
+        /** 关联菌(异常菌)列表 — 北京菌群报告"疾病肠菌评估(异常项)" */
+        private List<RelatedBacterium> relatedBacteria;
 
         public String getDiseaseName() { return diseaseName; }
         public void setDiseaseName(String diseaseName) { this.diseaseName = diseaseName; }
@@ -171,6 +177,25 @@ public class ParsedReportPayload {
         public void setRiskValue(String riskValue) { this.riskValue = riskValue; }
         public String getRiskLevel() { return riskLevel; }
         public void setRiskLevel(String riskLevel) { this.riskLevel = riskLevel; }
+        public List<RelatedBacterium> getRelatedBacteria() { return relatedBacteria; }
+        public void setRelatedBacteria(List<RelatedBacterium> relatedBacteria) { this.relatedBacteria = relatedBacteria; }
+    }
+
+    /** 疾病关联菌明细 */
+    public static class RelatedBacterium {
+        private String bacteria;      // 菌名
+        private String correlation;   // 相关性(超标/缺乏)
+        private String value;         // 检测值
+        private String status;        // 状态
+
+        public String getBacteria() { return bacteria; }
+        public void setBacteria(String bacteria) { this.bacteria = bacteria; }
+        public String getCorrelation() { return correlation; }
+        public void setCorrelation(String correlation) { this.correlation = correlation; }
+        public String getValue() { return value; }
+        public void setValue(String value) { this.value = value; }
+        public String getStatus() { return status; }
+        public void setStatus(String status) { this.status = status; }
     }
 
     public static class Payload {

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProcurementTaskDTO.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class ProcurementTaskDTO {
+    /** 任务唯一标识 */
+    private String taskId;
+    /** 所属用户ID */
+    private Long userId;
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+    /** 套餐原始等级 (用于自定义追踪) */
+    private String originalComboTier;
+    /** 最终选中的商品ID列表(对应 products.id) */
+    private List<Long> selectedProductSkuIds;
+    /** 用户自新增的商品ID列表 */
+    private List<Long> customAdditionSkuIds;
+    /** 用户移除的商品ID列表 */
+    private List<Long> customRemovalSkuIds;
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+    /** 创建时间 */
+    private Date createTime;
+    /** 完成时间 */
+    private Date completeTime;
+    /** 是否已发送消息提醒 */
+    private Boolean messageReminderSent;
+    /** 关联的执行任务ID (status=EXECUTING时设置) */
+    private String executionTaskId;
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -36,6 +36,7 @@ public class ProductDTO {
     private String minVisibleLevel;        // 最低可见会员级别编码,NULL=全部可见
     private String minDiscountLevel;       // 享受会员价所需的最低会员级别,NULL=所有会员均可
     private Boolean vendorVisible;         // 是否仅供应商体系内用户可见(列表接口生效)
+    private Integer isRecommended;         // 是否推荐商品: 0=否 1=是(推荐时同步至营养产品)
     private List<String> imageList;
     private Long energyDimensionId;
     private Integer effectivePpoint;     // 有效P点(基点,如1000=10%)
@@ -127,6 +128,7 @@ public class ProductDTO {
         d.minVisibleLevel = p.getMinVisibleLevel();
         d.minDiscountLevel = p.getMinDiscountLevel();
         d.vendorVisible = p.getVendorVisible();
+        d.isRecommended = p.getIsRecommended();
         d.createdAt = p.getCreatedAt();
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDiseaseRisk.java

@@ -30,5 +30,9 @@ public class HealthDiseaseRisk implements Serializable {
     /** 风险等级: 高风险/需注意/低风险 */
     private String riskLevel;
 
+    /** 关联菌(异常菌)明细,JSON 数组字符串:
+     *  [{"bacteria":"","correlation":"超标/缺乏","value":"","status":""}] */
+    private String relatedBacteria;
+
     private Date createdAt;
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -62,6 +62,9 @@ public class HealthReport implements Serializable {
     /** 肠型 (如 "拟杆菌型") */
     private String gutType;
 
+    /** 肠道菌群状态,如"0度"(0度~4度,北京菌群报告) */
+    private String gutStatusLevel;
+
     /** 菌群平衡分 (0-100) */
     private Integer gutBalanceScore;
 

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportDraft.java

@@ -77,6 +77,18 @@ public class HealthReportDraft implements Serializable {
     /** 采集指标校验结果(JSON: typeId/expected/matched/pass/missing),由 ReportCollectService 采集后写入 */
     private String collectValidation;
 
+    /** 已知类型解析重试次数(指数退避,上限5分钟) */
+    private Integer parseAttempt;
+
+    /** 下次自动重试时间(known-type 解析失败后设置,成功/失败完成时清空) */
+    private Date nextRetryAt;
+
+    /** 最近一次解析开始时间 */
+    private Date parseStartedAt;
+
+    /** 最近一次解析结束时间(成功或失败) */
+    private Date parseFinishedAt;
+
     private Date createdAt;
     private Date updatedAt;
 }

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProcurementTask.java

@@ -0,0 +1,63 @@
+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("procurement_tasks")
+public class ProcurementTask implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 任务唯一标识 */
+    private String taskId;
+
+    /** 所属用户ID */
+    private Long userId;
+
+    /** 所属家庭成员ID */
+    private Long familyMemberId;
+
+    /** 选择的套餐等级: high|medium|low */
+    private String comboTier;
+
+    /** 原始套餐等级 (用于自定义追踪) */
+    private String originalComboTier;
+
+    /** 最终选中的产品SKU列表 (JSON数组) */
+    private String selectedProductSkuIds;
+
+    /** 用户自新增的产品SKU列表 (JSON数组) */
+    private String customAdditionSkuIds;
+
+    /** 用户移除的产品SKU列表 (JSON数组) */
+    private String customRemovalSkuIds;
+
+    /** 任务状态 */
+    private String status; // SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED
+
+    /** 创建时间 */
+    private Date createTime;
+
+    /** 完成时间 */
+    private Date completeTime;
+
+    /** 是否已发送消息提醒 */
+    private Integer messageReminderSent;
+
+    /** 关联的执行任务ID */
+    private String executionTaskId;
+
+    /** 更新时间 */
+    private Date updateTime;
+}

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

@@ -72,6 +72,9 @@ public class Product implements Serializable {
     /** 是否仅供应商体系内用户可见(在列表接口生效,详情页不受影响,直接分享可 bypass) */
     private Boolean vendorVisible;
 
+    /** 是否推荐商品: 0=否 1=是(推荐时同步至营养产品表) */
+    private Integer isRecommended;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 

+ 42 - 1
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportDraftMapper.java

@@ -3,7 +3,48 @@ package com.etotem.cfc.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.HealthReportDraft;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.Date;
+import java.util.List;
 
 @Mapper
 public interface HealthReportDraftMapper extends BaseMapper<HealthReportDraft> {
-}
+
+    /**
+     * 登记一次 known-type 解析失败重试:解析次数+1,写入错误原因,
+     * 并按指数退避(30s * 2^(attempt-1),上限300s)设置下次重试时间。
+     * 注意 SET 顺序:先按旧 parse_attempt 计算 next_retry_at,再自增。
+     *
+     * @return 受影响行数
+     */
+    @Update("UPDATE health_report_drafts " +
+            "SET next_retry_at = DATE_ADD(NOW(), INTERVAL LEAST(300, FLOOR(30 * POW(2, LEAST(COALESCE(parse_attempt, 0), 8)))) SECOND), " +
+            "parse_attempt = COALESCE(parse_attempt, 0) + 1, " +
+            "parse_error = #{error}, " +
+            "parse_status = 'collecting', " +
+            "updated_at = NOW() " +
+            "WHERE id = #{draftId}")
+    int recordRetry(@Param("draftId") Long draftId, @Param("error") String error);
+
+    /**
+     * 原子抢占一次重试:仅当 next_retry_at 为空或已到期时清空(归零待重试状态),返回影响行数。
+     * 抢到(rows==1)后由调度任务重新提交异步采集。
+     */
+    @Update("UPDATE health_report_drafts SET next_retry_at = NULL " +
+            "WHERE id = #{draftId} AND (next_retry_at IS NULL OR next_retry_at <= #{now})")
+    int claimRetry(@Param("draftId") Long draftId, @Param("now") Date now);
+
+    /**
+     * 查询待重试草稿:状态 pending + 采集中 + (已到 next_retry_at,或从未有过 next_retry_at 且
+     * 最近一次解析开始超过10分钟——用于兜底卡死/丢失的采集任务),按 updated_at 升序取前 N 条。
+     */
+    @Select("SELECT * FROM health_report_drafts " +
+            "WHERE status = 'pending' AND parse_status = 'collecting' " +
+            "AND ((next_retry_at IS NOT NULL AND next_retry_at <= #{now}) " +
+            "OR (next_retry_at IS NULL AND (parse_started_at IS NULL OR parse_started_at < DATE_SUB(#{now}, INTERVAL 10 MINUTE)))) " +
+            "ORDER BY updated_at ASC LIMIT #{limit}")
+    List<HealthReportDraft> listPendingRetry(@Param("now") Date now, @Param("limit") int limit);
+}

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

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

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

@@ -364,6 +364,36 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 生成五维自检题目(调用 LangGraph question_generation_graph)
+     * @param inputs 含 user_id(int)、count、difficulty、dimensions、exclude_ids
+     * @return 题目列表(每项含 id/dimension/text/options/sub_dim);失败返回 null(调用方决定 fallback)
+     */
+    public List<Map<String, Object>> generateSelfCheckQuestions(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.valueToTree(inputs);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/question-generate";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode questions = root.get("questions");
+                if (questions != null && questions.isArray() && questions.size() > 0) {
+                    List<Map<String, Object>> list = objectMapper.convertValue(questions, List.class);
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway generateSelfCheckQuestions 成功: {} 题", list.size());
+                    return list;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateSelfCheckQuestions 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     private List<Map<String, Object>> parseTasks(JsonNode tasksNode) {
         List<Map<String, Object>> tasks = new ArrayList<>();
         if (tasksNode != null && tasksNode.isArray()) {

+ 106 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java

@@ -33,6 +33,7 @@ public class BasicQuotaService {
         QUOTA_LIMITS.put("AI_CHAT", 3);
         QUOTA_LIMITS.put("REPORT_READING", 1);
         QUOTA_LIMITS.put("ASSESSMENT_QUOTA", 1);
+        QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
     }
 
     /**
@@ -121,10 +122,113 @@ public class BasicQuotaService {
     }
 
     /**
-     * 批量刷新所有用户当月配额(每月1号调用,BASIC体验版已取消,保留接口兼容)
+     * 批量刷新所有有效 BASIC 用户当月配额(每月1号调用)
      */
     public void refreshMonthlyQuota() {
-        log.info("BASIC配额刷新已取消(体验版会员级别已删除)");
+        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());
+    }
+
+    /**
+     * 获取会员到期时间
+     */
+    public Date getExpireTime(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user != null) {
+            return user.getMemberExpireTime();
+        }
+        return null;
+    }
+
+    /**
+     * 图片解析(食材识别)配额校验:
+     * - FAMILY 及以上(level_order &gt; BASIC):不限次放行;
+     * - BASIC:走 FOOD_RECOGNIZE 月度配额;
+     * - FREE:拒绝并提示升级。
+     */
+    public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
+        QuotaConsumeResult result = new QuotaConsumeResult();
+        if (userId == null || userId == 0L) {
+            result.setSuccess(true);
+            return result;
+        }
+        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
+        int basicOrder = membershipService.getLevelOrder("BASIC");
+        if (userOrder < basicOrder) {
+            result.setSuccess(false);
+            result.setRemaining(0);
+            result.setToastMsg("图片解析为体验会员及以上专属权益,升级后即可使用");
+            result.setActionUrl("/pages/membership/upgrade");
+            return result;
+        }
+        if (userOrder > basicOrder) {
+            result.setSuccess(true);
+            return result;
+        }
+        return consumeQuota(userId, "FOOD_RECOGNIZE");
     }
 
     private Date getMonthStart(Date date) {

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java

@@ -21,6 +21,9 @@ public class ConsigneeService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private RealNameBackfillService realNameBackfillService;
+
     public Result<List<Consignee>> list(Long userId) {
         List<Consignee> list = consigneeMapper.selectList(
                 new LambdaQueryWrapper<Consignee>()
@@ -59,6 +62,8 @@ public class ConsigneeService {
             consigneeMapper.updateById(consignee);
             // 若联系人与当前用户姓名/手机号一致,同步地区信息到用户
             syncUserRegion(existing.getUserId(), consignee);
+            // 回填写在 syncUserRegion 之后:保持既有同步逻辑看到回填前的 realName,行为不变
+            realNameBackfillService.backfill(existing.getUserId(), consignee.getName());
             return Result.success(consignee.getId());
         } else {
             // create
@@ -71,6 +76,7 @@ public class ConsigneeService {
             consigneeMapper.insert(consignee);
             // 若联系人与当前用户姓名/手机号一致,同步地区信息到用户
             syncUserRegion(consignee.getUserId(), consignee);
+            realNameBackfillService.backfill(consignee.getUserId(), consignee.getName());
             return Result.success(consignee.getId());
         }
     }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java

@@ -172,6 +172,8 @@ public class FoodService {
     }
 
     public void saveFoodsForReport(Long reportId, List<Food> foods) {
+        // 幂等入库:先删除该报告已有的食材推荐,再写入新数据(重新解析/回填时避免重复行)
+        foodMapper.delete(new LambdaQueryWrapper<Food>().eq(Food::getReportId, reportId));
         if (foods == null || foods.isEmpty()) return;
         int sortOrder = 0;
         for (Food food : foods) {

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

@@ -226,6 +226,7 @@ public class HealthReportDraftService {
         uw.eq(HealthReportDraft::getId, draftId)
           .set(HealthReportDraft::getParseStatus, "failed")
           .set(HealthReportDraft::getParseError, safe)
+          .set(HealthReportDraft::getParseFinishedAt, new Date())
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
@@ -240,6 +241,7 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getParseError, null)
           .set(HealthReportDraft::getStatus, "confirmed")
           .set(HealthReportDraft::getConfirmedReportId, reportId)
+          .set(HealthReportDraft::getParseFinishedAt, new Date())
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
@@ -254,4 +256,54 @@ public class HealthReportDraftService {
           .set(HealthReportDraft::getUpdatedAt, new Date());
         draftMapper.update(null, uw);
     }
+
+    /**
+     * 解析开始前声明占用:置 parse_status=collecting、parse_started_at=now、清空 next_retry_at 与上次错误。
+     */
+    public void markParseStarted(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseStatus, "collecting")
+          .set(HealthReportDraft::getParseStartedAt, new Date())
+          .set(HealthReportDraft::getNextRetryAt, null)
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
+
+    /**
+     * 登记一次 known-type 解析失败重试(不置 failed,保持 collecting,交由定时驱动器退避重试)。
+     */
+    public void recordRetry(Long draftId, String error) {
+        String safe = error != null && error.length() > 2000 ? error.substring(0, 2000) : error;
+        draftMapper.recordRetry(draftId, safe);
+    }
+
+    /**
+     * 查询待重试草稿(状态 pending + 采集中 + 重试时间已到或采集可能卡死),按 updated_at 升序取前 limit 条。
+     */
+    public java.util.List<HealthReportDraft> listPendingRetry(Date now, int limit) {
+        return draftMapper.listPendingRetry(now, limit);
+    }
+
+    /**
+     * 原子抢占一次重试(仅 next_retry_at 为空或已到期时成功),抢到后调用方重新提交异步采集。
+     */
+    public boolean claimRetry(Long draftId, Date now) {
+        return draftMapper.claimRetry(draftId, now) == 1;
+    }
+
+    /**
+     * 人工重试重置:清零重试次数与错误,回到 collecting 待采集状态。
+     */
+    public void resetForManualRetry(Long draftId) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getParseAttempt, 0)
+          .set(HealthReportDraft::getNextRetryAt, null)
+          .set(HealthReportDraft::getParseError, null)
+          .set(HealthReportDraft::getParseStatus, "collecting")
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
 }

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

@@ -1201,6 +1201,15 @@ public class HealthReportService {
         return healthReportMapper.selectById(reportId);
     }
 
+    /**
+     * 仅更新报告的 payload_json 列(用于食材重新解析回填,只覆盖 foods 键,不影响其他字段)。
+     * 使用 JdbcTemplate 直改单列,避免 MyBatis-Plus NOT_NULL 策略下其他 null 字段被误覆盖。
+     */
+    public void updatePayloadJson(Long reportId, String payloadJson) {
+        jdbcTemplate.update("UPDATE health_reports SET payload_json = ?, updated_at = NOW() WHERE id = ?",
+                payloadJson, reportId);
+    }
+
     public void bindReportToMember(Long reportId, Long subjectId) {
         // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
         String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/KnowledgeBaseV3Service.java

@@ -92,7 +92,13 @@ public class KnowledgeBaseV3Service {
         List<String> indicatorSections = Arrays.asList(
                 "疾病风险评估", "主要营养评估", "氨基酸评估", "维生素评估",
                 "微量元素评估", "抗生素风险评估", "肠道屏障及代谢物",
-                "短链脂肪酸", "神经递质及激素", "健康总分评估"
+                "短链脂肪酸", "神经递质及激素", "健康总分评估",
+                // 北京菌群报告新增章节(与 报告指标知识库.json 对齐)
+                "主要消化道致病菌", "肠道微生物健康指数", "肠道菌群表型评估",
+                "肠道免疫评估", "肠道黏膜屏障评估", "糖类代谢评估", "脂类代谢评估",
+                "嘌呤代谢评估", "天然色素代谢评估", "三肽代谢评估", "胆汁酸代谢评估",
+                "抗自由基与抗氧化评估", "毒性物质清除能力评估", "趣味肠菌评估",
+                "胃肠道感染病原体评估"
         );
         for (String section : indicatorSections) {
             Object secRaw = kbRoot.get(section);

+ 105 - 16
cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java

@@ -36,7 +36,8 @@ public class LangGraphReportParseService {
     public static final String DETECTED_GENERIC = "generic";
 
     @Resource
-    private RestTemplate restTemplate;
+    @org.springframework.beans.factory.annotation.Qualifier("langGraphRestTemplate")
+    private RestTemplate langGraphRestTemplate;
 
     @Resource
     private ObjectMapper objectMapper;
@@ -44,21 +45,69 @@ public class LangGraphReportParseService {
     @Value("${langgraph.base-url:http://localhost:9000}")
     private String langgraphBaseUrl;
 
+    /**
+     * 解析结果 + 失败原因(known-type 分支用 parse_error 落库,避免失败原因被吞掉)。
+     */
+    public static class ParseOutcome {
+        private final ParsedReportPayload.Payload payload;
+        private final String error;
+
+        private ParseOutcome(ParsedReportPayload.Payload payload, String error) {
+            this.payload = payload;
+            this.error = error;
+        }
+
+        public static ParseOutcome success(ParsedReportPayload.Payload payload) {
+            return new ParseOutcome(payload, null);
+        }
+
+        public static ParseOutcome failure(String error) {
+            return new ParseOutcome(null, error);
+        }
+
+        public ParsedReportPayload.Payload getPayload() { return payload; }
+        public String getError() { return error; }
+    }
+
+    /** HTTP 调用结果:data 或 error(二选一) */
+    private static class HttpResult {
+        final Map<String, Object> data;
+        final String error;
+
+        HttpResult(Map<String, Object> data, String error) {
+            this.data = data;
+            this.error = error;
+        }
+    }
+
     /**
      * 指纹成功路径:调 LangGraph 脚本(/report/parse,算法 + LLM 兜底)。
      * 转换后无有效指标数据时返回 null。
      */
     public ParsedReportPayload.Payload parseByScript(String pdfPath) {
-        return parseByScript(pdfPath, null);
+        ParseOutcome outcome = parseByScriptWithReason(pdfPath, null);
+        return outcome != null ? outcome.getPayload() : 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;
+        ParseOutcome outcome = parseByScriptWithReason(pdfPath, indicators);
+        return outcome != null ? outcome.getPayload() : null;
+    }
+
+    /**
+     * 带失败原因版本的脚本解析:失败时返回 null payload 且 error 含具体原因
+     * (如 "Read timed out"、http=500、空 data 等),供调用方登记重试与落库 parse_error。
+     */
+    public ParseOutcome parseByScriptWithReason(String pdfPath, List<String> indicators) {
+        HttpResult r = postParseWithReason("/api/v1/report/parse", pdfPath, indicators);
+        if (r == null || r.data == null) {
+            return ParseOutcome.failure(r != null && r.error != null ? r.error : "解析结果为空或无效");
         }
-        ParsedReportPayload.Payload payload = buildPayloadFromLgData(data);
-        return isValid(payload) ? payload : null;
+        ParsedReportPayload.Payload payload = buildPayloadFromLgData(r.data);
+        if (!isValid(payload)) {
+            return ParseOutcome.failure("LangGraph 解析结果无有效指标数据");
+        }
+        return ParseOutcome.success(payload);
     }
 
     /**
@@ -128,29 +177,50 @@ public class LangGraphReportParseService {
         return postParse(path, body);
     }
 
-    @SuppressWarnings("unchecked")
     private Map<String, Object> postParse(String path, Map<String, Object> body) {
+        HttpResult r = postParseWithReason(path, body);
+        return r != null ? r.data : null;
+    }
+
+    /** 带失败原因版本的 LangGraph 调用(known-type 分支用于把失败原因写入 parse_error) */
+    private HttpResult postParseWithReason(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 postParseWithReason(path, body);
+    }
+
+    @SuppressWarnings("unchecked")
+    private HttpResult postParseWithReason(String path, Map<String, Object> body) {
         try {
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
-            ResponseEntity<Map> resp = restTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
+            ResponseEntity<Map> resp = langGraphRestTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
             Map<String, Object> respBody = resp.getBody();
-            if (respBody == null || !Integer.valueOf(200).equals(respBody.get("code"))) {
+            if (respBody == null) {
+                log.warn("LangGraph 端点 {} 返回空响应", path);
+                return new HttpResult(null, path + " 返回空响应");
+            }
+            if (!Integer.valueOf(200).equals(respBody.get("code"))) {
+                String detail = "http=" + String.valueOf(respBody.get("code"))
+                        + " message=" + String.valueOf(respBody.get("message"));
                 log.warn("LangGraph 端点 {} 返回异常: code={}, message={}",
-                        path, respBody != null ? respBody.get("code") : null,
-                        respBody != null ? respBody.get("message") : null);
-                return null;
+                        path, respBody.get("code"), respBody.get("message"));
+                return new HttpResult(null, path + " 返回异常: " + detail);
             }
             Object data = respBody.get("data");
             if (!(data instanceof Map) || ((Map<String, Object>) data).isEmpty()) {
                 log.warn("LangGraph 端点 {} 返回空 data", path);
-                return null;
+                return new HttpResult(null, path + " 返回空 data(无有效解析结果)");
             }
-            return (Map<String, Object>) data;
+            return new HttpResult((Map<String, Object>) data, null);
         } catch (Exception e) {
             log.warn("LangGraph 端点 {} 调用失败: {}", path, e.getMessage());
-            return null;
+            String reason = "LangGraph 调用异常(" + e.getClass().getSimpleName() + "): " + e.getMessage();
+            return new HttpResult(null, reason);
         }
     }
 
@@ -185,6 +255,7 @@ public class LangGraphReportParseService {
             summary.setCoreGenusScore(toInteger(overview.get("coreGenusScore")));
             summary.setGutAge(objectToString(overview.get("gutAge")));
             summary.setGutType(objectToString(overview.get("gutType")));
+            summary.setGutStatusLevel(objectToString(overview.get("gutStatusLevel")));
             summary.setShannonIndex(objectToString(overview.get("shannonIndex")));
             summary.setReportNumber(objectToString(overview.get("report_number")));
             summary.setReportDate(objectToString(overview.get("report_date")));
@@ -200,6 +271,24 @@ public class LangGraphReportParseService {
                 risk.setDiseaseName(objectToString(dr.get("name")));
                 risk.setRiskValue(objectToString(dr.get("value")));
                 risk.setRiskLevel(objectToString(dr.get("status")));
+                Object rb = dr.get("related_bacteria");
+                if (rb instanceof List && !((List<?>) rb).isEmpty()) {
+                    List<ParsedReportPayload.RelatedBacterium> bacteria = new ArrayList<>();
+                    for (Object o : (List<?>) rb) {
+                        if (o instanceof Map) {
+                            Map<String, Object> bm = (Map<String, Object>) o;
+                            ParsedReportPayload.RelatedBacterium b = new ParsedReportPayload.RelatedBacterium();
+                            b.setBacteria(objectToString(bm.get("bacteria")));
+                            b.setCorrelation(objectToString(bm.get("correlation")));
+                            b.setValue(objectToString(bm.get("value")));
+                            b.setStatus(objectToString(bm.get("status")));
+                            bacteria.add(b);
+                        }
+                    }
+                    if (!bacteria.isEmpty()) {
+                        risk.setRelatedBacteria(bacteria);
+                    }
+                }
                 risks.add(risk);
             }
             payload.setDiseaseRisks(risks);

+ 13 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -243,7 +243,19 @@ public class MembershipService implements MembershipServiceInterface {
                     || "referral_commission".equals(feature);
         }
 
-        // FAMILY用户可用功能
+        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);
+        }
         if ("FAMILY".equals(level)) {
             return "free_activities".equals(feature)
                     || "free_courses".equals(feature)

+ 57 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/NutritionProductService.java

@@ -39,7 +39,7 @@ public class NutritionProductService {
     private NutritionProductReviewMapper reviewMapper;
 
     @Resource
-    private ProductService productService;
+    private ProductMapper productMapper;
 
     /** 分类列表(树结构) */
     public List<NutritionCategory> listCategories() {
@@ -128,7 +128,7 @@ public class NutritionProductService {
         result.put("reviews", reviewMap);
         // 如果有关联的本平台商品,返回其信息
         if (product.getLocalProductId() != null) {
-            Product localProduct = productService.getById(product.getLocalProductId());
+            Product localProduct = productMapper.selectById(product.getLocalProductId());
             if (localProduct != null) {
                 Map<String, Object> localInfo = new HashMap<>();
                 localInfo.put("id", localProduct.getId());
@@ -187,6 +187,61 @@ public class NutritionProductService {
         );
     }
 
+    /**
+     * 将推荐商品同步至营养产品表。
+     * isRecommended=1 时创建或更新 nutrition_product 记录;
+     * isRecommended=0 时将已有记录置为下架(不删除)。
+     */
+    public void syncFromProduct(Product product) {
+        if (product == null || product.getId() == null) return;
+        boolean recommended = product.getIsRecommended() != null && product.getIsRecommended() == 1;
+        NutritionProduct existing = nutritionProductMapper.selectOne(
+            new LambdaQueryWrapper<NutritionProduct>()
+                .eq(NutritionProduct::getLocalProductId, product.getId())
+                .last("LIMIT 1")
+        );
+        if (recommended) {
+            if (existing == null) {
+                existing = new NutritionProduct();
+                existing.setLocalProductId(product.getId());
+                existing.setName(product.getName());
+                existing.setImageUrl(product.getCoverImage());
+                existing.setProductImages(product.getImages());
+                existing.setDescription(product.getDescription());
+                existing.setPrice(convertFenToYuan(product.getPrice()));
+                if (product.getMemberPrice() != null && product.getMemberPrice() > 0) {
+                    existing.setOriginalPrice(convertFenToIntYuan(product.getMemberPrice()));
+                }
+                existing.setStatus(1);
+                nutritionProductMapper.insert(existing);
+            } else {
+                existing.setName(product.getName());
+                existing.setImageUrl(product.getCoverImage());
+                existing.setProductImages(product.getImages());
+                existing.setDescription(product.getDescription());
+                existing.setPrice(convertFenToYuan(product.getPrice()));
+                if (product.getMemberPrice() != null && product.getMemberPrice() > 0) {
+                    existing.setOriginalPrice(convertFenToIntYuan(product.getMemberPrice()));
+                }
+                existing.setStatus(1);
+                nutritionProductMapper.updateById(existing);
+            }
+        } else if (existing != null) {
+            existing.setStatus(0);
+            nutritionProductMapper.updateById(existing);
+        }
+    }
+
+    private BigDecimal convertFenToYuan(Integer fen) {
+        if (fen == null) return BigDecimal.ZERO;
+        return BigDecimal.valueOf(fen).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+    }
+
+    private Integer convertFenToIntYuan(Integer fen) {
+        if (fen == null) return 0;
+        return fen / 100;
+    }
+
     // ---- 评分私有方法 ----
 
     private double calcIngredientScore(NutritionProduct product, NutritionRecommendDTO.RecommendQuery query) {

+ 301 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProcurementTaskService.java

@@ -0,0 +1,301 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ProcurementTaskDTO;
+import com.etotem.cfc.entity.ProcurementTask;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProcurementTaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.ProductService;
+import com.etotem.cfc.service.ProductSkuService;
+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;
+
+/**
+ * 采购任务服务
+ * 管理商品组合推荐、用户定制、采购任务全生命周期
+ */
+@Slf4j
+@Service
+public class ProcurementTaskService {
+
+    @Resource
+    private ProcurementTaskMapper procurementTaskMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ProductService productService;
+
+    @Resource
+    private ProductSkuService productSkuService;
+
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    /**
+     * 创建采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO createTask(Long userId, Long familyMemberId, String selectedTier,
+                                         List<Long> baseSkuIds, List<Long> addedSkuIds,
+                                         List<Long> removedSkuIds) {
+        List<Long> resolvedBase = resolveSkuIds(baseSkuIds);
+        List<Long> resolvedAdded = resolveSkuIds(addedSkuIds);
+        List<Long> resolvedRemoved = resolveSkuIds(removedSkuIds);
+        List<Long> finalSkuIds = mergeSkus(resolvedBase, resolvedAdded, resolvedRemoved);
+
+        ProcurementTaskDTO task = new ProcurementTaskDTO();
+        task.setTaskId(UUID.randomUUID().toString());
+        task.setUserId(userId);
+        task.setFamilyMemberId(familyMemberId);
+        task.setComboTier(selectedTier);
+        task.setOriginalComboTier(selectedTier);
+        task.setSelectedProductSkuIds(finalSkuIds);
+        task.setCustomAdditionSkuIds(resolvedAdded);
+        task.setCustomRemovalSkuIds(resolvedRemoved);
+        task.setStatus("SELECTED");
+        task.setCreateTime(new Date());
+        task.setMessageReminderSent(false);
+
+        saveTask(task);
+        log.info("创建采购任务: taskId={}, userId={}, tier={}, skus={}",
+                task.getTaskId(), userId, selectedTier, finalSkuIds);
+        return task;
+    }
+
+    /**
+     * 启动采购流程 - SELECTED → PROCURING → PROCURED → EXECUTING
+     */
+    @Transactional
+    public ProcurementTaskDTO startProcurement(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 状态的任务可进行采购流程");
+        }
+
+        task.setStatus("PROCURING");
+        updateTask(task);
+
+        // 模拟采购执行(实际应调用供应商API/库存服务)
+        boolean success = executeProcurement(task);
+
+        if (success) {
+            task.setStatus("PROCURED");
+            task.setExecutionTaskId(UUID.randomUUID().toString());
+            task.setStatus("EXECUTING");
+            log.info("采购成功,进入执行阶段: taskId={}, executionTaskId={}", taskId, task.getExecutionTaskId());
+        } else {
+            task.setStatus("CANCELLED");
+            log.warn("采购失败: taskId={}", taskId);
+        }
+
+        updateTask(task);
+        return task;
+    }
+
+    /**
+     * 完成任务执行 - EXECUTING → COMPLETED,发送消息提醒
+     */
+    @Transactional
+    public ProcurementTaskDTO completeExecution(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"EXECUTING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 EXECUTING 状态的任务可标记为完成");
+        }
+
+        task.setStatus("COMPLETED");
+        task.setCompleteTime(new Date());
+
+        if (task.getMessageReminderSent() == null || !task.getMessageReminderSent()) {
+            sendCompletionReminder(task);
+            task.setMessageReminderSent(true);
+        }
+
+        updateTask(task);
+        log.info("执行任务完成: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 取消采购任务
+     */
+    @Transactional
+    public ProcurementTaskDTO cancelTask(String taskId) {
+        ProcurementTaskDTO task = getTaskById(taskId);
+        if (task == null) {
+            throw new RuntimeException("任务不存在");
+        }
+        if (!"SELECTED".equals(task.getStatus()) && !"PROCURING".equals(task.getStatus())) {
+            throw new RuntimeException("仅 SELECTED 或 PROCURING 状态的任务可取消");
+        }
+
+        task.setStatus("CANCELLED");
+        task.setCompleteTime(new Date());
+        updateTask(task);
+        log.info("取消采购任务: taskId={}, userId={}", taskId, task.getUserId());
+        return task;
+    }
+
+    /**
+     * 获取任务详情
+     */
+    public ProcurementTaskDTO getTaskById(String taskId) {
+        return convertToDTO(procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, taskId)));
+    }
+
+    /**
+     * 获取用户的采购任务列表
+     */
+    public List<ProcurementTaskDTO> getUserTasks(Long userId) {
+        List<ProcurementTask> tasks = procurementTaskMapper.selectList(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getUserId, userId)
+                        .orderByDesc(ProcurementTask::getCreateTime));
+        return tasks.stream().map(this::convertToDTO).collect(Collectors.toList());
+    }
+
+    /** ---------------------------------------------------------
+     *  内部辅助方法
+     *  --------------------------------------------------------- */
+
+    private List<Long> resolveSkuIds(List<Long> skuIds) {
+        if (skuIds == null || skuIds.isEmpty()) return new ArrayList<>();
+        // 过滤无效 ID(<=0)
+        return skuIds.stream()
+                .filter(id -> id != null && id > 0)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    private List<Long> mergeSkus(List<Long> base, List<Long> added, List<Long> removed) {
+        Set<Long> result = new LinkedHashSet<>(base);
+        result.addAll(added);
+        result.removeAll(removed);
+        return new ArrayList<>(result);
+    }
+
+    private boolean executeProcurement(ProcurementTaskDTO task) {
+        // 实际应:检查库存、调用供应商API、扣减库存、生成订单等
+        // 简化实现:假设采购成功
+        log.info("执行采购: taskId={}, skus={}", task.getTaskId(), task.getSelectedProductSkuIds());
+        return true;
+    }
+
+    private void sendCompletionReminder(ProcurementTaskDTO task) {
+        try {
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("comboTier", formatTierLabel(task.getComboTier()));
+            vars.put("productCount", task.getSelectedProductSkuIds() != null ? task.getSelectedProductSkuIds().size() : 0);
+            vars.put("createTime", task.getCreateTime() != null ?
+                    new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(task.getCreateTime()) : "");
+            notificationDispatchService.sendByTemplate(
+                    "procurement_completed",
+                    task.getUserId(),
+                    vars,
+                    "procurement_task",
+                    null,
+                    "采购任务完成",
+                    "您的采购任务已完成,产品即将送达。请及时查收!"
+            );
+            log.info("发送采购完成提醒: taskId={}, userId={}", task.getTaskId(), task.getUserId());
+        } catch (Exception e) {
+            log.warn("发送采购完成提醒失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
+        }
+    }
+
+    private String formatTierLabel(String tier) {
+        if (tier == null) return "未知";
+        switch (tier) {
+            case "high": return "尊享";
+            case "medium": return "平衡";
+            case "low": return "入门";
+            default: return tier;
+        }
+    }
+
+    private ProcurementTaskDTO convertToDTO(ProcurementTask task) {
+        if (task == null) return null;
+        ProcurementTaskDTO dto = new ProcurementTaskDTO();
+        dto.setTaskId(task.getTaskId());
+        dto.setUserId(task.getUserId());
+        dto.setFamilyMemberId(task.getFamilyMemberId());
+        dto.setComboTier(task.getComboTier());
+        dto.setOriginalComboTier(task.getOriginalComboTier());
+        dto.setSelectedProductSkuIds(fromJson(task.getSelectedProductSkuIds()));
+        dto.setCustomAdditionSkuIds(fromJson(task.getCustomAdditionSkuIds()));
+        dto.setCustomRemovalSkuIds(fromJson(task.getCustomRemovalSkuIds()));
+        dto.setStatus(task.getStatus());
+        dto.setCreateTime(task.getCreateTime());
+        dto.setCompleteTime(task.getCompleteTime());
+        dto.setMessageReminderSent(task.getMessageReminderSent() != null && task.getMessageReminderSent() == 1);
+        dto.setExecutionTaskId(task.getExecutionTaskId());
+        return dto;
+    }
+
+    private void saveTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = new ProcurementTask();
+        task.setTaskId(dto.getTaskId());
+        task.setUserId(dto.getUserId());
+        task.setFamilyMemberId(dto.getFamilyMemberId());
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCreateTime(dto.getCreateTime());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.insert(task);
+    }
+
+    private void updateTask(ProcurementTaskDTO dto) {
+        ProcurementTask task = procurementTaskMapper.selectOne(
+                new LambdaQueryWrapper<ProcurementTask>()
+                        .eq(ProcurementTask::getTaskId, dto.getTaskId()));
+        if (task == null) return;
+        task.setComboTier(dto.getComboTier());
+        task.setOriginalComboTier(dto.getOriginalComboTier());
+        task.setSelectedProductSkuIds(toJson(dto.getSelectedProductSkuIds()));
+        task.setCustomAdditionSkuIds(toJson(dto.getCustomAdditionSkuIds()));
+        task.setCustomRemovalSkuIds(toJson(dto.getCustomRemovalSkuIds()));
+        task.setStatus(dto.getStatus());
+        task.setCompleteTime(dto.getCompleteTime());
+        task.setMessageReminderSent(dto.getMessageReminderSent() != null && dto.getMessageReminderSent() ? 1 : 0);
+        task.setExecutionTaskId(dto.getExecutionTaskId());
+        task.setUpdateTime(new Date());
+        procurementTaskMapper.updateById(task);
+    }
+
+    private String toJson(List<Long> list) {
+        if (list == null) return null;
+        return com.alibaba.fastjson.JSON.toJSONString(list);
+    }
+
+    private List<Long> fromJson(String json) {
+        if (json == null || json.isEmpty()) return new ArrayList<>();
+        try {
+            return com.alibaba.fastjson.JSON.parseArray(json, Long.class);
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

+ 122 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -46,6 +46,18 @@ public class ProductRecommendationService {
     @Resource
     private HealthFilterService healthFilterService;
 
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+
+    @Resource
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private SupplySystemService supplySystemService;
+
     /**
      * 维度页推荐
      *
@@ -54,11 +66,12 @@ public class ProductRecommendationService {
      * @param memberId         成员ID(可选)
      * @param excludeProductIds 排除的商品ID列表
      * @param limit            返回数量
+     * @param userId           当前登录用户ID(用于供应商优先级计算,可为null)
      * @return 推荐商品列表
      */
     public List<Map<String, Object>> getDimensionRecommendations(
             String dimensionCode, Long familyId, Long memberId,
-            List<Long> excludeProductIds, int limit) {
+            List<Long> excludeProductIds, int limit, Long userId) {
 
         // 0. 加载成员健康画像(硬拦截基础)
         HealthFilterService.HealthProfile healthProfile = healthFilterService.loadProfile(memberId, null, null);
@@ -194,7 +207,7 @@ public class ProductRecommendationService {
         }
 
         // 5. 最终排序并返回
-        return sortAndReturn(candidates, limit);
+        return sortAndReturn(candidates, limit, userId);
     }
 
     /**
@@ -420,12 +433,115 @@ public class ProductRecommendationService {
     }
 
     /**
-     * 根据 matchScore 和 strength 进行最终排序
+     * 计算供应商优先级加分
+     * <p>
+     * 规则1:若直接引荐人是某产品供应商(supply_system.admin),优先推荐其体系内产品
+     * 规则2:沿引荐链向上追溯,找到任一层级是供应商的引荐人,优先推荐其产品
+     * 规则3:同一供应商体系引荐的用户越多,权重越高(按体系成员数加权)
+     * 补充规则:用户所属多个供应商体系时,其关联产品均优先推荐
+     */
+    private Map<Long, Double> getSupplierPriorityScores(Long userId) {
+        if (userId == null) return Collections.emptyMap();
+
+        // 1. 收集引荐链上的所有祖先(直接推荐人、间接推荐人)
+        List<ReferralTree> ancestorNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getChildId, userId)
+                        .orderByAsc(ReferralTree::getLevel));
+        if (ancestorNodes.isEmpty()) return Collections.emptyMap();
+
+        // 祖先 userId -> 层级(level 越小=关系越近,权重越高)
+        Map<Long, Integer> ancestorLevels = new HashMap<>();
+        for (ReferralTree node : ancestorNodes) {
+            ancestorLevels.putIfAbsent(node.getParentId(), node.getLevel());
+        }
+
+        // 2. 找出引荐链中的供应商(adminId 对应 supply_system)
+        //    supplySystemId -> 引荐层级(取最小即最近层级,权重最高)
+        Map<Long, Integer> supplierSystemToNearestLevel = new HashMap<>();
+        for (Map.Entry<Long, Integer> entry : ancestorLevels.entrySet()) {
+            Long ancestorId = entry.getKey();
+            Integer level = entry.getValue();
+            SupplySystem system = supplySystemService.getByAdminId(ancestorId);
+            if (system != null) {
+                supplierSystemToNearestLevel.merge(system.getId(), level,
+                        (existing, newVal) -> Math.min(existing, newVal));
+            }
+        }
+
+        // 3. 也检查当前用户本身是否属于某些供应商体系(补充规则)
+        List<SupplySystemMember> userSystems = supplySystemMemberMapper.selectList(
+                new LambdaQueryWrapper<SupplySystemMember>()
+                        .eq(SupplySystemMember::getUserId, userId)
+                        .eq(SupplySystemMember::getStatus, "active"));
+        for (SupplySystemMember mem : userSystems) {
+            supplierSystemToNearestLevel.merge(mem.getSystemId(), 0,
+                    (existing, newVal) -> Math.min(existing, newVal));
+        }
+
+        if (supplierSystemToNearestLevel.isEmpty()) return Collections.emptyMap();
+
+        // 4. 计算每个供应商体系的成员数作为权重(规则3)
+        //    supplySystemId -> 加分系数 = memberCount * 0.1 / levelPenalty
+        //    levelPenalty: 直接推荐人(level=1) 系数为1.0,每远一层衰减
+        Map<Long, Double> systemBoosts = new HashMap<>();
+        for (Map.Entry<Long, Integer> sysEntry : supplierSystemToNearestLevel.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Integer level = sysEntry.getValue();
+            // 成员数
+            Long memberCount = supplySystemMemberMapper.selectCount(
+                    new LambdaQueryWrapper<SupplySystemMember>()
+                            .eq(SupplySystemMember::getSystemId, systemId)
+                            .eq(SupplySystemMember::getStatus, "active"));
+            int count = memberCount == null ? 0 : memberCount.intValue();
+            // 层级衰减:level=1(直接推荐人)不打折,level越大折扣越大
+            double levelFactor = Math.max(0.1, 1.0 - (level - 1) * 0.15);
+            // 加分系数 = 成员数 * 0.1 * 层级因子,基础分50,最大加成5分
+            double boost = Math.min(5.0, count * 0.1 * levelFactor);
+            systemBoosts.put(systemId, boost);
+        }
+
+        // 5. 按 distributionSystemId 聚合加分
+        //    同一个产品的 distributionSystemId 对应多个体系时,取最高加分
+        Map<Long, Double> productIdBoost = new HashMap<>();
+        for (Map.Entry<Long, Double> sysEntry : systemBoosts.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Double boost = sysEntry.getValue();
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>()
+                            .eq(Product::getDistributionSystemId, systemId)
+                            .eq(Product::getStatus, "on_shelf"));
+            for (Product p : products) {
+                productIdBoost.merge(p.getId(), boost, Math::max);
+            }
+        }
+
+        return productIdBoost;
+    }
+
+    /**
+     * 根据 matchScore 和供应商优先级进行最终排序
      */
-    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit) {
+    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId) {
         if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
 
-        // 按 matchScore 降序排序
+        // 计算供应商优先级加分(规则1~3)
+        Map<Long, Double> supplierPriorityScores = getSupplierPriorityScores(userId);
+
+        // 将供应商优先级加入 matchScore
+        if (!supplierPriorityScores.isEmpty()) {
+            for (Map<String, Object> item : candidates) {
+                Long productId = (Long) item.get("productId");
+                Double priorityBoost = productId != null ? supplierPriorityScores.get(productId) : null;
+                Double currentScore = (Double) item.get("matchScore");
+                if (priorityBoost != null && priorityBoost > 0) {
+                    double boostedScore = (currentScore != null ? currentScore : 0) + priorityBoost;
+                    item.put("matchScore", boostedScore);
+                }
+                item.put("supplierPriorityBoost", priorityBoost != null ? priorityBoost : 0);
+            }
+        }
+
         candidates.sort((a, b) -> {
             Double scoreA = (Double) a.get("matchScore");
             Double scoreB = (Double) b.get("matchScore");
@@ -660,7 +776,7 @@ public class ProductRecommendationService {
         for (String dim : uniqueDims) {
             if (result.size() >= limit) break;
             // 获取该维度的关联推荐(含画像缺口、共购、复购)
-            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
+            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size(), null);
             for (Map<String, Object> p : prods) {
                 Long pid = (Long) p.get("id");
                 if (pid == null || seenProductIds.contains(pid)) continue;

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -28,6 +28,7 @@ import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -58,6 +59,9 @@ public class ProductService {
     @Resource
     private GrowthArchiveProductService growthArchiveProductService;
 
+    @Resource
+    private NutritionProductService nutritionProductService;
+
     @Resource
     private ProductDeliveryConfigService productDeliveryConfigService;
 
@@ -71,6 +75,65 @@ public class ProductService {
         return productMapper.selectById(id);
     }
 
+    /**
+     * 按维度查询上架商品(用于商品组合方案生成)
+     * 支持价格区间筛选,返回前 limit 个上架商品
+     *
+     * @param dimensionCode 维度码 (body/mind/wisdom/action/wealth)
+     * @param minPrice      最低价(分,可为 null)
+     * @param maxPrice      最高价(分,可为 null)
+     * @param limit         最大返回数量
+     * @return 上架且维度匹配的商品列表
+     */
+    public List<Product> listByDimension(String dimensionCode, Integer minPrice, Integer maxPrice, int limit) {
+        if (dimensionCode == null || dimensionCode.isEmpty() || !DimensionCodes.isValid(dimensionCode)) {
+            return new ArrayList<>();
+        }
+        String dim = dimensionCode.trim();
+        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
+                .eq(Product::getStatus, "on_shelf")
+                .and(w -> w
+                        .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dim)
+                        .or().eq(Product::getDomain, dim));
+        if (minPrice != null) {
+            wrapper.ge(Product::getPrice, minPrice);
+        }
+        if (maxPrice != null) {
+            wrapper.le(Product::getPrice, maxPrice);
+        }
+        wrapper.orderByAsc(Product::getSortOrder).orderByAsc(Product::getId).last("LIMIT " + Math.max(1, limit));
+        return productMapper.selectList(wrapper);
+    }
+
+    /**
+     * 按维度批量查询上架商品(五维方案生成用)
+     * 对每个维度码调用 listByDimension 并返回 维度码→商品列表 的映射
+     *
+     * @param dimensionCodes 维度码列表
+     * @param limitPerDim    每维度最大返回数量
+     * @return 维度码→上架商品列表
+     */
+    public Map<String, List<Product>> listByDimensions(List<String> dimensionCodes, int limitPerDim) {
+        Map<String, List<Product>> result = new LinkedHashMap<>();
+        if (dimensionCodes == null) return result;
+        for (String dim : dimensionCodes) {
+            if (dim != null && !dim.isEmpty()) {
+                result.put(dim, listByDimension(dim, null, null, limitPerDim));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 按商品名称精确查找(用于占位 SKU 名解析)
+     */
+    public Product findByName(String name) {
+        if (name == null || name.trim().isEmpty()) return null;
+        return productMapper.selectOne(new LambdaQueryWrapper<Product>()
+                .eq(Product::getName, name.trim())
+                .last("LIMIT 1"));
+    }
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId, String role) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
@@ -231,6 +294,7 @@ wrapper.in(Product::getId, productIds)
         if (!deliveryConfig.isEmpty()) {
             dto.setDeliveryConfig(deliveryConfig);
         }
+        nutritionProductService.syncFromProduct(product);
         return Result.success(dto);
     }
 
@@ -269,6 +333,7 @@ wrapper.in(Product::getId, productIds)
         product.setStatus("pending");
         product.setUpdatedAt(new Date());
         productMapper.updateById(product);
+        nutritionProductService.syncFromProduct(product);
         return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
     }
 
@@ -502,6 +567,7 @@ wrapper.in(Product::getId, productIds)
         // 避免管理端 el-select 清空提交 '' 导致商品对所有用户不可见
         boolean clearMinVisibleLevel = normalizeMinVisibleLevel(product);
         productMapper.updateById(product);
+        nutritionProductService.syncFromProduct(product);
         // updateById 默认忽略 null 字段,清空时需显式将 min_visible_level 置 NULL
         if (clearMinVisibleLevel) {
             productMapper.update(null, new LambdaUpdateWrapper<Product>()

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RealNameBackfillService.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+/**
+ * 真实姓名回填:用户第一次输入地址/收货人时,若 users.real_name 尚未填写,
+ * 用联系人姓名回填(规格:docs/superpowers/specs/2026-10-10-address-contact-backfill-realname-design.md)。
+ * 幂等:回填一次后 realName 非空,后续保存不再触发。
+ * 风格参照 ConsigneeService.syncUserRegion():失败静默,不阻塞主流程。
+ */
+@Service
+public class RealNameBackfillService {
+
+    private static final Logger log = LoggerFactory.getLogger(RealNameBackfillService.class);
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 若 userId 对应用户的 realName 为空白,则用 contactName(去首尾空白后)回填。
+     * contactName 空白、userId 为空、用户不存在时直接跳过。
+     */
+    public void backfill(Long userId, String contactName) {
+        try {
+            if (userId == null) return;
+            if (contactName == null || contactName.trim().isEmpty()) return;
+            User user = userMapper.selectById(userId);
+            if (user == null) return;
+            String realName = user.getRealName();
+            if (realName != null && !realName.trim().isEmpty()) return;
+            user.setRealName(contactName.trim());
+            userMapper.updateById(user);
+        } catch (Exception e) {
+            log.warn("[RealNameBackfill] 回填失败 userId={} : {}", userId, e.getMessage());
+        }
+    }
+}

+ 85 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.*;
@@ -152,7 +153,7 @@ public class ReportCollectService {
             return;
         }
         try {
-            healthReportDraftService.markCollecting(draftId, null);
+            healthReportDraftService.markParseStarted(draftId);
             String pdfPath = resolvePdfPath(draft);
             if (pdfPath == null) {
                 healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
@@ -192,13 +193,18 @@ public class ReportCollectService {
             String method;
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
                 // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
+                // 已知类型解析失败不再回退 opencode,改为登记退避重试(parse_error 记录失败原因),
+                // 由 ReportCollectRetryTask 定时驱动重试,避免静默降级导致 foods 等数据缺失。
                 method = METHOD_LANGGRAPH_SCRIPT;
-                payload = langGraphReportParseService.parseByScript(pdfPath, expectedIndicators);
-                // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
+                LangGraphReportParseService.ParseOutcome outcome =
+                        langGraphReportParseService.parseByScriptWithReason(pdfPath, expectedIndicators);
+                payload = outcome != null ? outcome.getPayload() : null;
                 if (payload == null) {
-                    log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
-                    method = METHOD_OPENCODE;
-                    payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+                    String reason = outcome != null && outcome.getError() != null
+                            ? outcome.getError() : "解析结果为空或无效";
+                    log.warn("已知类型 LangGraph 脚本解析失败,登记退避重试 typeId={}, reason={}", typeId, reason);
+                    healthReportDraftService.recordRetry(draftId, reason);
+                    return;
                 }
             } else {
                 // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
@@ -226,6 +232,15 @@ public class ReportCollectService {
             healthReportDraftService.updateParseMethod(draftId, method);
             healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
 
+            // 幂等守卫:解析期间若草稿已被确认入库或已完成(并发/外部触发),跳过本次入库
+            HealthReportDraft fresh = healthReportDraftService.getDraftById(draftId);
+            if (fresh != null && (fresh.getConfirmedReportId() != null
+                    || STATUS_COMPLETED.equals(fresh.getParseStatus()))) {
+                log.warn("草稿解析期间已被处理,跳过重复入库 draftId={}, confirmedReportId={}, parseStatus={}",
+                        draftId, fresh.getConfirmedReportId(), fresh.getParseStatus());
+                return;
+            }
+
             insertReport(draft, payload);
 
             log.info("异步采集完成 draftId={}, method={}", draftId, method);
@@ -416,7 +431,7 @@ public class ReportCollectService {
 
         JsonNode sr = opencodeSection(pdfText, fileName,
                 "只提取 summary 和 diseaseRisks 两部分,其余字段不返回",
-                "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"chronicDiseaseScore\":0,\"nutritionScore\":0,\"balanceScore\":0,\"diversityScore\":0,\"beneficialScore\":0,\"harmfulScore\":0,\"coreGenusScore\":0,\"gutAge\":\"\",\"gutType\":\"\",\"shannonIndex\":\"\",\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\",\"age\":0,\"birthday\":\"\"},\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}]}");
+                "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"chronicDiseaseScore\":0,\"nutritionScore\":0,\"balanceScore\":0,\"diversityScore\":0,\"beneficialScore\":0,\"harmfulScore\":0,\"coreGenusScore\":0,\"gutAge\":\"\",\"gutType\":\"\",\"gutStatusLevel\":\"\",\"shannonIndex\":\"\",\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\",\"age\":0,\"birthday\":\"\"},\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\",\"relatedBacteria\":[{\"bacteria\":\"\",\"correlation\":\"\",\"value\":\"\",\"status\":\"\"}]}]}");
         if (sr != null) {
             if (sr.has("summary") && !sr.get("summary").isNull()) {
                 payload.setSummary(objectMapper.convertValue(sr.get("summary"), ParsedReportPayload.Summary.class));
@@ -686,6 +701,7 @@ public class ReportCollectService {
             report.setCoreGenusScore(s.getCoreGenusScore());
             report.setGutAge(s.getGutAge());
             report.setGutType(s.getGutType());
+            report.setGutStatusLevel(s.getGutStatusLevel());
             report.setShannonIndex(s.getShannonIndex());
             report.setReportNumber(s.getReportNumber());
             report.setPersonName(s.getPersonName());
@@ -801,6 +817,13 @@ public class ReportCollectService {
             dr.setDiseaseName(pr.getDiseaseName());
             dr.setRiskValue(pr.getRiskValue());
             dr.setRiskLevel(pr.getRiskLevel());
+            if (pr.getRelatedBacteria() != null && !pr.getRelatedBacteria().isEmpty()) {
+                try {
+                    dr.setRelatedBacteria(objectMapper.writeValueAsString(pr.getRelatedBacteria()));
+                } catch (Exception e) {
+                    log.warn("序列化相关菌失败: {}", e.getMessage());
+                }
+            }
             risks.add(dr);
         }
         return risks;
@@ -811,6 +834,10 @@ public class ReportCollectService {
         if (list == null || list.isEmpty()) return new ArrayList<>();
         List<Food> foods = new ArrayList<>();
         for (ParsedReportPayload.FoodItem pf : list) {
+            if (pf == null || pf.getName() == null || pf.getName().trim().isEmpty()) {
+                log.warn("跳过无效食材条目(元素为空或缺少名称)");
+                continue;
+            }
             Food food = new Food();
             food.setName(pf.getName());
             food.setCategory(pf.getCategory());
@@ -828,6 +855,57 @@ public class ReportCollectService {
         return foods;
     }
 
+    /**
+     * 重新解析指定健康报告并回填食材推荐。
+     * 先删除旧 foods 再插入(幂等),解析成功后仅覆盖 payload_json 的 foods 键(其他键不变),
+     * 解析失败则保留旧载荷并抛 IllegalStateException。
+     *
+     * @return 本次落库的食材条数
+     */
+    public int reparseReportFoods(Long reportId) {
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            throw new IllegalArgumentException("报告不存在: " + reportId);
+        }
+        String pdfPath = storageService.getFilePath(report.getFileUrl());
+        File f = new File(pdfPath);
+        if (!f.exists()) {
+            throw new IllegalStateException("报告文件不存在: " + pdfPath);
+        }
+        LangGraphReportParseService.ParseOutcome outcome =
+                langGraphReportParseService.parseByScriptWithReason(pdfPath, null);
+        ParsedReportPayload.Payload payload = outcome != null ? outcome.getPayload() : null;
+        if (payload == null) {
+            String reason = outcome != null && outcome.getError() != null ? outcome.getError() : "解析结果为空";
+            throw new IllegalStateException("重新解析失败: " + reason);
+        }
+        List<Food> foods = buildFoodsFromPayload(payload);
+        foodService.saveFoodsForReport(reportId, foods);
+
+        // 仅合并 foods 键到原 payload_json,不覆盖其他键;失败时保留旧载荷
+        try {
+            JsonNode root = objectMapper.readTree(report.getPayloadJson());
+            if (root != null && root.isObject()) {
+                ((ObjectNode) root).set("foods", objectMapper.valueToTree(payload.getFoods()));
+                healthReportService.updatePayloadJson(reportId, objectMapper.writeValueAsString(root));
+            }
+        } catch (Exception e) {
+            log.warn("合并 foods 到 payload_json 失败 reportId={}: {}", reportId, e.getMessage());
+        }
+
+        // 刷新食材推荐指数(优先以报告主体成员为准,其次上传用户)
+        try {
+            Long recalcUserId = report.getSubjectId() != null ? report.getSubjectId() : report.getUserId();
+            if (recalcUserId != null) {
+                foodRecommendService.recalculateForUser(recalcUserId);
+            }
+        } catch (Exception e) {
+            log.warn("刷新食材推荐指数失败 reportId={}: {}", reportId, e.getMessage());
+        }
+
+        return foods.size();
+    }
+
     // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
 
     private String opencodeCreateSession() {

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserAddressService.java

@@ -17,6 +17,9 @@ public class UserAddressService {
     @Resource
     private UserAddressMapper userAddressMapper;
 
+    @Resource
+    private RealNameBackfillService realNameBackfillService;
+
     public List<UserAddress> list(Long userId) {
         LambdaQueryWrapper<UserAddress> wrapper = new LambdaQueryWrapper<UserAddress>()
             .eq(UserAddress::getUserId, userId)
@@ -46,6 +49,7 @@ public class UserAddressService {
             clearDefault(userId);
         }
         userAddressMapper.insert(address);
+        realNameBackfillService.backfill(userId, address.getReceiverName());
     }
 
     @Transactional
@@ -60,6 +64,7 @@ public class UserAddressService {
             clearDefault(userId);
         }
         userAddressMapper.updateById(address);
+        realNameBackfillService.backfill(userId, address.getReceiverName());
     }
 
     @Transactional

+ 104 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java

@@ -0,0 +1,104 @@
+package com.etotem.cfc.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 桌面端「小程序码」扫码登录。
+ *
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (落地页从 scene 取出 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认
+ * → 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>采用小程序 appid/secret + {@code getwxacodeunlimit},无需「网站应用」凭据,
+ * 也不产生任何 GET 回调;扫码者身份即小程序已登录用户,天然与小程序账号 SSO。
+ */
+@Service
+public class WechatWebLoginService {
+
+    private static final Logger log = LoggerFactory.getLogger(WechatWebLoginService.class);
+
+    private static final String STATE_KEY_PREFIX = "wechat:web:login:";
+    /** state 有效期(秒),与二维码展示时长一致 */
+    private static final long STATE_TTL_SECONDS = 300;
+
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;
+
+    /** 扫码落地的小程序页面路径(须已在 pages.json 注册) */
+    @Value("${wechat.wxacode-login-page:pages/login/scan-login}")
+    private String scanPage;
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Resource
+    private WechatService wechatService;
+
+    /** Redis 不可用时的内存兜底:state -> [value, 写入时间(ms)] */
+    private final Map<String, String[]> memory = new ConcurrentHashMap<>();
+
+    /**
+     * 生成扫码登录用的小程序码(PNG 的 base64 编码,不含 data URI 前缀)。
+     * scene 直接放入 state(32 位十六进制,符合微信「最大 32 个可见字符」限制)。
+     * 测试模式返回空串(无真实小程序码,桌面端展示提示即可)。
+     */
+    public String generateQrImage(String state) {
+        if (testMode) {
+            log.info("测试模式:跳过小程序码生成(state={})", state);
+            return "";
+        }
+        return wechatService.generateWxacode(state, scanPage);
+    }
+
+    /** 写入扫码状态:P=待扫码 / U{userId}=已确认 / N=未绑定 */
+    public void putState(String state, String value) {
+        try {
+            redisTemplate.opsForValue().set(STATE_KEY_PREFIX + state, value,
+                    Duration.ofSeconds(STATE_TTL_SECONDS));
+        } catch (Exception e) {
+            log.warn("写入 state 到 Redis 失败,改用内存兜底: {}", e.getMessage());
+            memory.put(state, new String[]{value, String.valueOf(System.currentTimeMillis())});
+        }
+    }
+
+    /** 读取扫码状态;不存在或已过期返回 null */
+    public Object getState(String state) {
+        try {
+            Object v = redisTemplate.opsForValue().get(STATE_KEY_PREFIX + state);
+            if (v != null) {
+                return v;
+            }
+        } catch (Exception e) {
+            log.warn("读取 state 从 Redis 失败,改用内存兜底: {}", e.getMessage());
+        }
+        String[] entry = memory.get(state);
+        if (entry == null) {
+            return null;
+        }
+        long ts = Long.parseLong(entry[1]);
+        if (System.currentTimeMillis() - ts > STATE_TTL_SECONDS * 1000) {
+            memory.remove(state);
+            return null;
+        }
+        return entry[0];
+    }
+
+    /** 清除扫码状态(登录成功后调用,防止重放) */
+    public void removeState(String state) {
+        try {
+            redisTemplate.delete(STATE_KEY_PREFIX + state);
+        } catch (Exception e) {
+            log.warn("删除 state 从 Redis 失败: {}", e.getMessage());
+        }
+        memory.remove(state);
+    }
+}

+ 28 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -25,9 +25,11 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
@@ -61,6 +63,13 @@ public class JdCloudStorageService implements StorageService {
     @Value("${jdcloud.region:cn-north-1}")
     private String region;
 
+    /**
+     * 本地共享目录。京东云对象在供本地进程(如 cfc-langgraph 容器)按路径读取时,
+     * 会被下载到该目录下的缓存子目录,确保 LangGraph 能读到文件。
+     */
+    @Value("${upload.base-dir:/data/cfc-uploads}")
+    private String uploadBaseDir;
+
     @Resource
     private FileRecordMapper fileRecordMapper;
 
@@ -153,25 +162,35 @@ public class JdCloudStorageService implements StorageService {
 
     @Override
     public String getFilePath(String filename) {
-        // 京东云文件不在本地磁盘,下载到临时目录供本地解析使用
+        // 京东云文件不在本地磁盘:下载到共享卷 upload.base-dir 下的缓存目录,
+        // 供本地进程(含 cfc-langgraph 容器)按本地路径读取;缓存目录需可被其它用户遍历/读取。
         try {
             String key = extractKey(filename);
-            S3Object object = s3Client.getObject(bucket, key);
-
-            Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"), "cfc-jdcloud");
-            Files.createDirectories(tempDir);
-            String localName = key.substring(key.lastIndexOf('/') + 1);
-            Path tempFile = tempDir.resolve(UUID.randomUUID().toString() + "-" + localName);
+            Path cacheDir = Paths.get(uploadBaseDir, ".jdcloud-cache").toAbsolutePath().normalize();
+            Files.createDirectories(cacheDir);
+            cacheDir.toFile().setReadable(true, false);
+            cacheDir.toFile().setExecutable(true, false);
+
+            String localName = sha256(key.getBytes(StandardCharsets.UTF_8))
+                    + "-" + key.substring(key.lastIndexOf('/') + 1);
+            Path cached = cacheDir.resolve(localName);
+            if (Files.exists(cached) && Files.size(cached) > 0) {
+                return cached.toString();
+            }
 
+            S3Object object = s3Client.getObject(bucket, key);
+            Path tmp = cacheDir.resolve(localName + ".part");
             try (InputStream in = object.getObjectContent();
-                 FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
+                 FileOutputStream out = new FileOutputStream(tmp.toFile())) {
                 byte[] buffer = new byte[8192];
                 int len;
                 while ((len = in.read(buffer)) != -1) {
                     out.write(buffer, 0, len);
                 }
             }
-            return tempFile.toString();
+            Files.move(tmp, cached, StandardCopyOption.REPLACE_EXISTING);
+            cached.toFile().setReadable(true, false);
+            return cached.toString();
         } catch (IOException e) {
             throw new RuntimeException("下载京东云文件失败: " + filename, e);
         }

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/ReportCollectRetryTask.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.service.HealthReportDraftService;
+import com.etotem.cfc.service.ReportCollectService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 已知类型报告解析失败重试驱动器:
+ * 定时捞取"采集中但已到退避重试时间(或可能卡死)"的草稿,抢占后重新提交异步采集。
+ */
+@Slf4j
+@Component
+public class ReportCollectRetryTask {
+
+    @Resource
+    private HealthReportDraftService healthReportDraftService;
+
+    @Resource
+    private ReportCollectService reportCollectService;
+
+    @Scheduled(fixedDelayString = "${report.collect.retry-interval-ms:60000}",
+            initialDelayString = "${report.collect.retry-initial-delay-ms:60000}")
+    public void redrivePendingRetries() {
+        try {
+            Date now = new Date();
+            List<HealthReportDraft> drafts = healthReportDraftService.listPendingRetry(now, 20);
+            if (drafts == null || drafts.isEmpty()) {
+                return;
+            }
+            log.info("定时重试驱动器: 发现 {} 条待重试草稿", drafts.size());
+            for (HealthReportDraft draft : drafts) {
+                try {
+                    if (healthReportDraftService.claimRetry(draft.getId(), now)) {
+                        reportCollectService.submitAsyncCollect(draft.getId());
+                        log.info("已抢占并重新提交异步采集 draftId={}", draft.getId());
+                    }
+                } catch (Exception e) {
+                    log.warn("重试草稿提交失败 draftId={}: {}", draft.getId(), e.getMessage());
+                }
+            }
+        } catch (Exception e) {
+            log.error("定时重试驱动器执行异常", e);
+        }
+    }
+}

+ 4 - 7
cfc-backend/src/main/resources/application.yml

@@ -83,7 +83,7 @@ zkxz:
 
 wechat:
   appid: wx5ba8038ef16fb245
-  secret: ${WECHAT_SECRET:dev-only-wechat-secret}
+  secret: ${WECHAT_SECRET:3dae525c6f5e010cc034f59abbd1b1cc}
   mch-id: 1703255039
   mch-key: ${WECHAT_MCH_KEY:dev-only-mch-key}
   api-v3-key: ${WECHAT_API_V3_KEY:dev-only-api-v3-key}
@@ -103,12 +103,9 @@ wechat:
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}
   # 小程序码环境:dev=development / test=trial / prod=release
   env-version: ${WECHAT_ENV_VERSION:release}
-  # 网站应用(微信开放平台“网站应用”)——桌面端扫码登录 SSO。
-  # 前置:该网站应用必须与小程序(appid=wx5ba8038ef16fb245)绑定同一开放平台账号,否则 unionid 不一致导致匹配失败。
-  web:
-    appid: ${WECHAT_WEB_APPID:}
-    secret: ${WECHAT_WEB_SECRET:}
-    redirect-uri: ${WECHAT_WEB_REDIRECT_URI:https://cfc.cf-club.com/api/auth/wechat-web/callback}
+  # 桌面端「小程序码」扫码登录:扫码落地的小程序页面路径(须已在 cfc-frontend/pages.json 注册)。
+  # 复用上面的 wechat.appid/secret 调 getwxacodeunlimit,无需「网站应用」凭据,也不产生 GET 回调。
+  wxacode-login-page: ${WECHAT_WXACODE_LOGIN_PAGE:pages/login/scan-login}
 
 dify:
   base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}

+ 598 - 0
cfc-backend/src/main/resources/knowledge-base/报告指标知识库.json

@@ -4373,5 +4373,603 @@
         "调整方向": ""
       }
     ]
+  },
+  "肠道微生物健康指数": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "GMHI(肠道微生物健康指数)",
+        "说明": "GMHI评分五级分级",
+        "标准范围": {
+          "最小值": 0,
+          "最大值": 100
+        },
+        "状态分级": [
+          {
+            "状态": "不健康",
+            "最小值": 0,
+            "最大值": 20
+          },
+          {
+            "状态": "不健康倾向",
+            "最小值": 20,
+            "最大值": 40
+          },
+          {
+            "状态": "亚健康",
+            "最小值": 40,
+            "最大值": 80
+          },
+          {
+            "状态": "健康倾向",
+            "最小值": 80,
+            "最大值": 90
+          },
+          {
+            "状态": "健康",
+            "最小值": 90,
+            "最大值": 100
+          }
+        ],
+        "偏高影响": "",
+        "偏低影响": "偏低提示肠道菌群健康水平有待改善",
+        "调整建议": "增加膳食纤维多样性,必要时进行粪菌移植或益生菌干预"
+      }
+    ]
+  },
+  "肠道菌群表型评估": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "革兰氏阳性菌",
+        "说明": "厚壁菌门为主的G+菌群比例",
+        "偏高影响": "异常",
+        "偏低影响": "异常",
+        "调整建议": "均衡饮食结构"
+      },
+      {
+        "名称": "革兰氏阴性菌",
+        "说明": "拟杆菌门为主的G-菌群比例",
+        "偏高影响": "异常",
+        "偏低影响": "异常",
+        "调整建议": "均衡饮食结构"
+      },
+      {
+        "名称": "生物膜合成",
+        "说明": "细菌生物膜形成能力",
+        "偏高影响": "异常",
+        "偏低影响": "",
+        "调整建议": "益生菌调节"
+      },
+      {
+        "名称": "好氧菌",
+        "说明": "需氧菌群比例",
+        "偏高影响": "异常",
+        "偏低影响": "",
+        "调整建议": "维持肠道厌氧环境"
+      },
+      {
+        "名称": "厌氧菌",
+        "说明": "厌氧菌群比例",
+        "偏高影响": "",
+        "偏低影响": "异常",
+        "调整建议": "增加膳食纤维"
+      },
+      {
+        "名称": "兼性厌氧菌",
+        "说明": "兼性厌氧菌群比例",
+        "偏高影响": "异常",
+        "偏低影响": "",
+        "调整建议": "益生菌调节"
+      },
+      {
+        "名称": "氧化胁迫耐受",
+        "说明": "菌群对氧化压力的耐受能力",
+        "偏高影响": "异常",
+        "偏低影响": "",
+        "调整建议": "增加抗氧化食物"
+      },
+      {
+        "名称": "致病潜力",
+        "说明": "菌群致病潜力综合评估",
+        "偏高影响": "异常提示潜在致病菌比例偏高",
+        "偏低影响": "",
+        "调整建议": "增加有益菌,减少高脂饮食"
+      }
+    ]
+  },
+  "肠道免疫评估": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "肠道抗炎能力",
+        "说明": "肠道抗炎能力综合评分",
+        "标准范围": {
+          "最小值": 0,
+          "最大值": 100
+        },
+        "状态分级": [
+          {
+            "状态": "弱",
+            "最小值": 0,
+            "最大值": 20
+          },
+          {
+            "状态": "较弱",
+            "最小值": 20,
+            "最大值": 40
+          },
+          {
+            "状态": "中等",
+            "最小值": 40,
+            "最大值": 60
+          },
+          {
+            "状态": "较强",
+            "最小值": 60,
+            "最大值": 80
+          },
+          {
+            "状态": "强",
+            "最小值": 80,
+            "最大值": 100
+          }
+        ],
+        "偏高影响": "",
+        "偏低影响": "抗炎能力偏弱提示肠道炎症风险较高",
+        "调整建议": "增加抗炎饮食(Omega-3、多酚、膳食纤维),减少促炎食物(高脂、高糖)"
+      },
+      {
+        "名称": "肠道免疫力",
+        "说明": "肠道免疫功能综合评分",
+        "标准范围": {
+          "最小值": 0,
+          "最大值": 100
+        },
+        "状态分级": [
+          {
+            "状态": "弱",
+            "最小值": 0,
+            "最大值": 20
+          },
+          {
+            "状态": "较弱",
+            "最小值": 20,
+            "最大值": 40
+          },
+          {
+            "状态": "中等",
+            "最小值": 40,
+            "最大值": 60
+          },
+          {
+            "状态": "较强",
+            "最小值": 60,
+            "最大值": 80
+          },
+          {
+            "状态": "强",
+            "最小值": 80,
+            "最大值": 100
+          }
+        ],
+        "偏高影响": "",
+        "偏低影响": "免疫力偏弱提示肠道防御能力不足",
+        "调整建议": "补充益生菌、锌、维生素D,均衡营养"
+      },
+      {
+        "名称": "肠道膳食纤维需求",
+        "说明": "肠道对膳食纤维的需求评估",
+        "标准范围": {
+          "最小值": 0,
+          "最大值": 100
+        },
+        "状态分级": [
+          {
+            "状态": "需求偏低",
+            "最小值": 0,
+            "最大值": 60
+          },
+          {
+            "状态": "需求适中",
+            "最小值": 60,
+            "最大值": 80
+          },
+          {
+            "状态": "需求较高",
+            "最小值": 80,
+            "最大值": 100
+          }
+        ],
+        "偏高影响": "",
+        "偏低影响": "需求较高提示当前纤维摄入不足",
+        "调整建议": "增加全谷物、蔬菜、豆类摄入"
+      }
+    ]
+  },
+  "肠道黏膜屏障评估": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "BE比值",
+        "说明": "双歧杆菌/肠杆菌科比值,评估肠道黏膜屏障功能",
+        "偏高影响": "",
+        "偏低影响": "BE<1提示肠屏障功能受损",
+        "调整建议": "补充双歧杆菌和膳食纤维,减少高脂饮食"
+      },
+      {
+        "名称": "肠道菌群多样性(Shannon指数)",
+        "说明": "Shannon指数评估菌群种类多样性",
+        "偏高影响": "",
+        "偏低影响": "偏低提示菌群种类减少,菌群脆弱",
+        "调整建议": "增加膳食纤维多样性,减少抗生素使用"
+      }
+    ]
+  },
+  "糖类代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "葡萄糖(糖酵解途径)",
+        "说明": "糖酵解途径代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示碳水化合物代谢能力不足",
+        "调整建议": "调整碳水摄入结构"
+      },
+      {
+        "名称": "葡萄糖(磷酸戊糖途径)",
+        "说明": "磷酸戊糖途径代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示核苷酸合成相关代谢减弱",
+        "调整建议": "增加B族维生素摄入"
+      },
+      {
+        "名称": "果糖和甘露糖",
+        "说明": "果糖和甘露糖代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示果糖代谢能力不足",
+        "调整建议": "减少高果糖摄入"
+      },
+      {
+        "名称": "半乳糖",
+        "说明": "半乳糖代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示乳糖/半乳糖代谢减弱",
+        "调整建议": "调节乳制品摄入"
+      },
+      {
+        "名称": "淀粉和蔗糖",
+        "说明": "淀粉和蔗糖代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示复杂碳水分解能力减弱",
+        "调整建议": "增加全谷物,减少精制糖"
+      },
+      {
+        "名称": "氨基糖和核苷酸糖",
+        "说明": "氨基糖代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响肠黏膜修复",
+        "调整建议": "增加膳食纤维"
+      },
+      {
+        "名称": "丙酮酸",
+        "说明": "丙酮酸代谢水平",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响能量代谢",
+        "调整建议": "增加B族维生素"
+      }
+    ]
+  },
+  "脂类代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "甘油酯",
+        "说明": "甘油酯代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响脂肪消化吸收",
+        "调整建议": "适量摄入优质脂肪"
+      },
+      {
+        "名称": "甘油磷脂",
+        "说明": "细胞膜磷脂代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响细胞膜功能",
+        "调整建议": "增加卵磷脂(蛋黄、大豆)"
+      },
+      {
+        "名称": "鞘脂类",
+        "说明": "神经鞘脂代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响神经功能",
+        "调整建议": "增加神经酸食物摄入"
+      },
+      {
+        "名称": "花生四烯酸",
+        "说明": "Omega-6脂肪酸前体",
+        "偏高影响": "偏高可能促进炎症",
+        "偏低影响": "偏低可能影响信号传导",
+        "调整建议": "平衡Omega-3/Omega-6比例"
+      },
+      {
+        "名称": "二十二碳六烯酸(DHA)",
+        "说明": "DHA代谢能力,大脑和视网膜关键脂肪酸",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响脑功能和视力健康",
+        "调整建议": "增加深海鱼油、藻油摄入"
+      },
+      {
+        "名称": "Omega-3脂肪酸",
+        "说明": "抗炎脂肪酸代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗炎脂肪酸代谢不足",
+        "调整建议": "增加亚麻籽、奇亚籽、深海鱼摄入"
+      },
+      {
+        "名称": "Omega-6脂肪酸",
+        "说明": "促炎/抗炎平衡相关脂肪酸",
+        "偏高影响": "偏高可能促进炎症倾向",
+        "偏低影响": "",
+        "调整建议": "平衡Omega-3/Omega-6比例"
+      }
+    ]
+  },
+  "嘌呤代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "嘌呤代谢",
+        "说明": "嘌呤代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低可能影响尿酸代谢",
+        "调整建议": "控制高嘌呤食物(动物内脏、海鲜等)"
+      }
+    ]
+  },
+  "天然色素代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "类胡萝卜素",
+        "说明": "抗氧化色素代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗氧化代谢减弱",
+        "调整建议": "增加胡萝卜、番茄、南瓜等摄入"
+      },
+      {
+        "名称": "花青素",
+        "说明": "花青素代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗氧化代谢减弱",
+        "调整建议": "增加蓝莓、紫薯、桑葚等摄入"
+      },
+      {
+        "名称": "类黄酮",
+        "说明": "类黄酮代谢能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗氧化代谢减弱",
+        "调整建议": "增加绿茶、洋葱、苹果等摄入"
+      },
+      {
+        "名称": "异黄酮素",
+        "说明": "异黄酮代谢能力(植物雌激素)",
+        "偏高影响": "",
+        "偏低影响": "偏低提示植物雌激素代谢减弱",
+        "调整建议": "增加豆制品摄入"
+      }
+    ]
+  },
+  "三肽代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "谷胱甘肽",
+        "说明": "主要内源性抗氧化三肽",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗氧化能力减弱",
+        "调整建议": "增加含硫氨基酸食物(大蒜、洋葱、西兰花)"
+      }
+    ]
+  },
+  "胆汁酸代谢评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "胆汁酸代谢",
+        "说明": "胆汁酸代谢水平",
+        "偏高影响": "偏高可能影响脂肪消化",
+        "偏低影响": "偏低提示脂肪乳化能力减弱",
+        "调整建议": "适量摄入优质脂肪,必要时补充消化酶"
+      }
+    ]
+  },
+  "抗自由基与抗氧化评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "硫辛酸",
+        "说明": "线粒体关键抗氧化辅酶",
+        "偏高影响": "",
+        "偏低影响": "偏低提示抗氧化能力和线粒体功能减弱",
+        "调整建议": "增加西兰花、菠菜、动物内脏等摄入"
+      },
+      {
+        "名称": "辅酶Q",
+        "说明": "线粒体电子传递链关键辅酶",
+        "偏高影响": "",
+        "偏低影响": "偏低提示细胞能量代谢和抗氧化能力减弱",
+        "调整建议": "增加动物内脏、坚果、菠菜等摄入"
+      }
+    ]
+  },
+  "毒性物质清除能力评估": {
+    "标准范围": {
+      "最小值": 0,
+      "最大值": 100
+    },
+    "状态分级": [
+      {
+        "状态": "清除能力差",
+        "最小值": 0,
+        "最大值": 10
+      },
+      {
+        "状态": "清除能力稍弱",
+        "最小值": 10,
+        "最大值": 30
+      },
+      {
+        "状态": "清除能力偏弱",
+        "最小值": 30,
+        "最大值": 50
+      },
+      {
+        "状态": "清除能力中等",
+        "最小值": 50,
+        "最大值": 70
+      },
+      {
+        "状态": "清除能力正常",
+        "最小值": 70,
+        "最大值": 100
+      }
+    ],
+    "指标": [
+      {
+        "名称": "苯甲酸酯",
+        "说明": "防腐剂类毒性物质的肠道清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该物质清除能力不足",
+        "调整建议": "减少加工食品摄入"
+      },
+      {
+        "名称": "二恶英",
+        "说明": "持久性有机污染物清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该类毒素清除能力不足",
+        "调整建议": "减少高脂动物性食品,增加膳食纤维"
+      },
+      {
+        "名称": "双酚",
+        "说明": "塑料添加剂类环境激素清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该类毒素清除能力不足",
+        "调整建议": "减少塑料容器使用"
+      },
+      {
+        "名称": "多环芳烃",
+        "说明": "烧烤/烟熏食品中多环芳烃清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该类毒素清除能力不足",
+        "调整建议": "减少烧烤、烟熏食品"
+      },
+      {
+        "名称": "甲苯",
+        "说明": "有机溶剂类毒素清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该类毒素清除能力不足",
+        "调整建议": "减少环境污染暴露"
+      },
+      {
+        "名称": "氯烷烃和氯烯烃",
+        "说明": "有机氯类环境毒素的清除能力",
+        "偏高影响": "",
+        "偏低影响": "偏低提示该类毒素清除能力不足",
+        "调整建议": "选择有机食品"
+      }
+    ]
+  },
+  "趣味肠菌评估": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "认知能力评分",
+        "说明": "基于肠道菌群预测的认知功能评分",
+        "偏高影响": "",
+        "偏低影响": "偏低提示认知功能有提升空间",
+        "调整建议": "增加Omega-3(特别是DHA)、B族维生素、多酚类食物摄入"
+      },
+      {
+        "名称": "人格特征分析",
+        "说明": "基于肠道菌群的大五人格特征倾向",
+        "偏高影响": "",
+        "偏低影响": "",
+        "调整建议": "仅供参考,不作为诊断依据"
+      }
+    ]
+  },
+  "胃肠道感染病原体评估": {
+    "标准范围": null,
+    "状态分级": null,
+    "指标": [
+      {
+        "名称": "幽门螺杆菌",
+        "说明": "胃炎、胃溃疡和胃癌相关病原菌",
+        "偏高影响": "超标提示感染风险",
+        "偏低影响": "",
+        "调整建议": "必要时进行抗菌治疗"
+      },
+      {
+        "名称": "艰难梭菌",
+        "说明": "抗生素相关性腹泻主要病原菌",
+        "偏高影响": "超标提示感染风险",
+        "偏低影响": "",
+        "调整建议": "避免滥用抗生素"
+      },
+      {
+        "名称": "肺炎链球菌",
+        "说明": "呼吸道和肠道感染相关病原菌",
+        "偏高影响": "超标提示感染风险",
+        "偏低影响": "",
+        "调整建议": "增强免疫力"
+      },
+      {
+        "名称": "金黄色葡萄球菌",
+        "说明": "常见条件致病菌",
+        "偏高影响": "超标提示感染风险",
+        "偏低影响": "",
+        "调整建议": "注意饮食卫生"
+      },
+      {
+        "名称": "沙门氏菌属",
+        "说明": "食物中毒常见病原菌",
+        "偏高影响": "超标提示感染风险",
+        "偏低影响": "",
+        "调整建议": "注意食品卫生,避免生食"
+      }
+    ]
   }
 }

+ 53 - 1
cfc-backend/src/main/resources/schema.sql

@@ -1193,6 +1193,7 @@ CREATE TABLE IF NOT EXISTS products (
     contraindicated_diseases VARCHAR(255) DEFAULT NULL COMMENT '禁忌基础疾病,逗号分隔(如"慢性肾病,肝病")',
     allergen_tags VARCHAR(255) DEFAULT NULL COMMENT '过敏原标签,逗号分隔(如"花生,小麦")',
     drug_interaction_flags TINYINT DEFAULT 0 COMMENT '是否命中药物-食物相互作用(1=是,需对照drug_interactions表)',
+    is_recommended TINYINT(1) DEFAULT 0 COMMENT '是否推荐商品 0否 1是',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
@@ -1892,6 +1893,10 @@ CREATE TABLE IF NOT EXISTS health_report_drafts (
     confirmed_report_id BIGINT COMMENT 'confirm阶段入库后的健康报告ID',
     remark VARCHAR(500) DEFAULT NULL COMMENT '备注(如代上传时的说明文字)',
     collect_validation TEXT DEFAULT NULL COMMENT '采集指标校验结果(JSON: typeId/expected/matched/pass/missing)',
+    parse_attempt INT DEFAULT 0 COMMENT '已知类型解析重试次数(指数退避,上限5分钟)',
+    next_retry_at DATETIME NULL COMMENT '下次自动重试时间(known-type 解析失败后设置)',
+    parse_started_at DATETIME NULL COMMENT '最近一次解析开始时间',
+    parse_finished_at DATETIME NULL COMMENT '最近一次解析结束时间(成功/失败)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),
@@ -4730,6 +4735,7 @@ CREATE TABLE IF NOT EXISTS diet_preferences (
     health_goals JSON COMMENT '健康目标(减脂/增肌/控糖/肠道调理/护肝)',
     goal_source VARCHAR(20) DEFAULT NULL COMMENT 'manual/system_recommend',
     goal_confirmed TINYINT(1) DEFAULT 0 COMMENT '用户已确认',
+    no_preference TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)',
     filled_at TIMESTAMP NULL COMMENT '首次填写时间',
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_member (family_member_id),
@@ -5724,12 +5730,34 @@ CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (
     INDEX idx_pir_resource (resource_item_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒';
 
+-- ============================================================
+-- BASIC 体验会员(迁移360 恢复,对应提交 f86dd164b 的反向还原)
+-- ============================================================
+
+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,
     user_id BIGINT NOT NULL COMMENT '家长用户ID(非family_id)',
-    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/REPORT_READING/ASSESSMENT_QUOTA',
+    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/REPORT_READING/ASSESSMENT_QUOTA/FOOD_RECOGNIZE',
     used_count INT DEFAULT 0 COMMENT '本月已用次数',
     total_count INT NOT NULL COMMENT '月度总配额',
     period_start DATE NOT NULL COMMENT '周期开始日期(每月1号)',
@@ -6282,3 +6310,27 @@ CREATE TABLE IF NOT EXISTS disease_intake_limits (
     stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)',
     INDEX idx_disease_nutrient (disease_type, nutrient)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表';
+
+-- 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+CREATE TABLE IF NOT EXISTS procurement_tasks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
+    user_id BIGINT NOT NULL COMMENT '所属用户ID',
+    family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID',
+    combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low',
+    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)',
+    selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)',
+    custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)',
+    custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)',
+    status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
+    create_time DATETIME NOT NULL COMMENT '创建时间',
+    complete_time DATETIME DEFAULT NULL COMMENT '完成时间',
+    message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒',
+    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID',
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_task_id (task_id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_family_member_id (family_member_id),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';

+ 59 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ConsigneeServiceBackfillTest.java

@@ -0,0 +1,59 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.mapper.ConsigneeMapper;
+import com.etotem.cfc.mapper.UserMapper;
+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.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ConsigneeServiceBackfillTest {
+
+    @Mock
+    private ConsigneeMapper consigneeMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private RealNameBackfillService realNameBackfillService;
+
+    @InjectMocks
+    private ConsigneeService consigneeService;
+
+    @Test
+    void save_新增分支保存成功后以联系人姓名回填真实姓名() {
+        Consignee consignee = new Consignee();
+        consignee.setUserId(1L);
+        consignee.setName("张三");
+        consignee.setIsDefault(0);
+
+        consigneeService.save(consignee);
+
+        verify(consigneeMapper).insert(consignee);
+        verify(realNameBackfillService).backfill(1L, "张三");
+    }
+
+    @Test
+    void save_编辑分支保存成功后以联系人姓名回填真实姓名() {
+        Consignee existing = new Consignee();
+        existing.setId(10L);
+        existing.setUserId(1L);
+        when(consigneeMapper.selectById(10L)).thenReturn(existing);
+
+        Consignee consignee = new Consignee();
+        consignee.setId(10L);
+        consignee.setName("李四");
+        consignee.setIsDefault(0);
+
+        consigneeService.save(consignee);
+
+        verify(consigneeMapper).updateById(consignee);
+        verify(realNameBackfillService).backfill(1L, "李四");
+    }
+}

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

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+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.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class RealNameBackfillServiceTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @InjectMocks
+    private RealNameBackfillService realNameBackfillService;
+
+    @Test
+    void backfill_真实姓名为null时回填联系人姓名() {
+        User user = new User();
+        user.setRealName(null);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "张三");
+
+        assertEquals("张三", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_真实姓名为空白时回填联系人姓名() {
+        User user = new User();
+        user.setRealName("   ");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "李四");
+
+        assertEquals("李四", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_联系人姓名去除首尾空白后回填() {
+        User user = new User();
+        user.setRealName(null);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "  王五  ");
+
+        assertEquals("王五", user.getRealName());
+        verify(userMapper).updateById(user);
+    }
+
+    @Test
+    void backfill_真实姓名已有值时不覆盖() {
+        User user = new User();
+        user.setRealName("原有姓名");
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        realNameBackfillService.backfill(1L, "张三");
+
+        assertEquals("原有姓名", user.getRealName());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    @Test
+    void backfill_联系人姓名为null时跳过() {
+        realNameBackfillService.backfill(1L, null);
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_联系人姓名为空白时跳过() {
+        realNameBackfillService.backfill(1L, "   ");
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_userId为null时跳过() {
+        realNameBackfillService.backfill(null, "张三");
+
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void backfill_用户不存在时跳过() {
+        when(userMapper.selectById(99L)).thenReturn(null);
+
+        realNameBackfillService.backfill(99L, "张三");
+
+        verify(userMapper, never()).updateById(any());
+    }
+}

+ 55 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/UserAddressServiceBackfillTest.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.UserAddress;
+import com.etotem.cfc.mapper.UserAddressMapper;
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class UserAddressServiceBackfillTest {
+
+    @Mock
+    private UserAddressMapper userAddressMapper;
+
+    @Mock
+    private RealNameBackfillService realNameBackfillService;
+
+    @InjectMocks
+    private UserAddressService userAddressService;
+
+    @Test
+    void create_保存成功后以收货人姓名回填真实姓名() {
+        UserAddress address = new UserAddress();
+        address.setReceiverName("张三");
+        address.setIsDefault(0);
+
+        userAddressService.create(address, 1L);
+
+        verify(userAddressMapper).insert(address);
+        verify(realNameBackfillService).backfill(1L, "张三");
+    }
+
+    @Test
+    void update_保存成功后以收货人姓名回填真实姓名() {
+        UserAddress existing = new UserAddress();
+        existing.setId(10L);
+        existing.setUserId(1L);
+        when(userAddressMapper.selectOne(any())).thenReturn(existing);
+
+        UserAddress address = new UserAddress();
+        address.setId(10L);
+        address.setReceiverName("李四");
+        address.setIsDefault(0);
+
+        userAddressService.update(address, 1L);
+
+        verify(userAddressMapper).updateById(address);
+        verify(realNameBackfillService).backfill(1L, "李四");
+    }
+}

+ 1 - 1
cfc-desktop-pet/.gitignore

@@ -1,3 +1,3 @@
-# .NET / WPF 构建产物(旧版 XafPet.Desktop / tests 目录)
+# .NET 构建产物(历史遗留,保留以防本地残留)
 bin/
 obj/

+ 43 - 23
cfc-desktop-pet/README.md

@@ -1,42 +1,62 @@
-# 浠艾福桌面宠物 (XafPet.Desktop)
+# 浠艾福桌面宠物 (XafPet)
 
-Windows 桌面宠物应用,实时反映 CFC 健康陪跑状态。
+Windows / macOS 桌面宠物应用(Tauri v2 + Rust),实时反映 CFC 健康陪跑状态。
 
 ## 功能
 
 - 显示今日任务进度 / 五维能量 / 健康打卡状态
-- 喝水提醒互动(点击宠物确认打卡)
-- 支持浠宝/福宝及自定义形象(PNG + pet-config.json)
-- 支持用户自定义状态(custom_state_1 ~ custom_state_10)
-- 托盘常驻,最小化到托盘,双击唤出
+- 喝水提醒互动(点击宠物确认打卡);点击任务提醒可直接完成任务
+- 支持浠宝 / 福宝及自定义形象(PNG + `pet-config.json`,支持动作帧序列)
+- 微信扫码登录(网站应用 `snsapi_login`,与小程序账号 unionid SSO)
+- 托盘常驻,右键菜单(设置 / 鼠标穿透 / 退出)
 
-## 使用
+## 技术栈
 
-1. 发布:`dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64`
-2. 运行 `XafPet.Desktop.exe`
-3. 首次启动 → 右键托盘图标 → 设置 → 填入后端地址 + JWT Token
-4. 宠物出现在桌面右下角,可拖拽移动
+- 前端:静态 HTML/JS(`src/`),经 Tauri `frontendDist` 打包(`src-tauri/tauri.conf.json`)
+- 后端逻辑:Rust + Tauri v2(`src-tauri/`)
 
-## 自定义宠物
+## 构建 / 运行
 
-1. 在 `%APPDATA%\XafPet\pets\<名称>\` 放置 `standing.png` + `pet-config.json`
-2. 重启应用,设置中选择形象
+```bash
+cd cfc-desktop-pet/src-tauri
+cargo tauri build          # 打包发行版
+cargo tauri dev            # 本地调试
+```
 
-## 开发
+## 测试
 
 ```bash
-# 构建
-dotnet build XafPet.Desktop.sln
+cd cfc-desktop-pet/src-tauri
+cargo test
+```
+
+## 自定义宠物
 
-# 测试(需 Windows 机器,.NET 6 SDK)
-dotnet test tests/XafPet.Desktop.Tests -v
+内置形象位于 `src/DefaultPets/{xibao,fubao}/`(`standing.png` + `pet-config.json`)。
+也可在「设置」页上传自定义形象。
+
+`pet-config.json` 支持 provider 无关的动作帧序列(`animations`):
+
+```json
+{
+  "actions": { "happy": "cheer" },
+  "animations": {
+    "cheer": { "frames": ["cheer_01.png", "cheer_02.png"], "fps": 15, "loop": false }
+  }
+}
 ```
 
+`frames[]` 为逐帧图片(相对 `image_base_url` 或绝对 URL);`loop:false` 时播完回到基础形象;未配置 `animations` 时回退为 CSS 动画。
+
 ## 环境要求
 
-- Windows 10 (1803+) / Windows 11
-- .NET 6 Runtime(发布时 Self-Contained,自带 WebView2 Runtime)
-- CFC 后端(localhost:9082)可选,不连接后端时宠物保持 idle 状态
+- Rust stable + Tauri CLI v2(`cargo install tauri-cli --version "^2"`)
+- Windows:WebView2 Runtime(Win 11 自带)+ Visual Studio Build Tools(MSVC)
+- CFC 后端(默认 `https://cfc.cf-club.com`)
+
+## CI
+
+`.github/workflows/release.yml`:推送 `desktop-pet-v*` tag 触发,构建 Windows + macOS 产物。
 
 ---
-> **注意:** 本项目仅在 Windows 上构建/运行。本代码仓库的其他模块(cfc-backend/cfc-web/cfc-frontend)不受影响。
+> **注意:** 本项目为旧版 WPF(`XafPet.Desktop`)的 Tauri 重写。WPF 实现已移除。

+ 0 - 24
cfc-desktop-pet/XafPet.Desktop.sln

@@ -1,24 +0,0 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XafPet.Desktop", "src\XafPet.Desktop\XafPet.Desktop.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XafPet.Desktop.Tests", "tests\XafPet.Desktop.Tests\XafPet.Desktop.Tests.csproj", "{D4E5F6A7-B8C9-0123-DEFG-234567890123}"
-EndProject
-Global
-	GlobalSection(SolutionConfigurationPlatforms) = preSolution
-		Debug|Any CPU = Debug|Any CPU
-		Release|Any CPU = Release|Any CPU
-	EndGlobalSection
-	GlobalSection(ProjectConfigurationPlatforms) = postSolution
-		{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{D4E5F6A7-B8C9-0123-DEFG-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{D4E5F6A7-B8C9-0123-DEFG-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
-	EndGlobalSection
-EndGlobal

+ 59 - 2
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -23,6 +23,31 @@ impl std::fmt::Display for ApiError {
     }
 }
 
+/// 规范化 base_url:非本机主机的 `http://` 一律升级为 `https://`。
+///
+/// 生产 `http://cfc.cf-club.com` 会被 nginx 以 301 重定向到 https,而 reqwest 在
+/// 301/302/303 上会把 POST 降级为 GET —— 后端对 GET 一律返回 405
+/// ("请求方法不支持,本平台接口统一使用POST")。直接升级协议头即可让 POST 原样直达,
+/// 从根上消除该 405;本机 / 内网地址保留 http,便于本地联调。
+pub fn normalize_base_url(raw: &str) -> String {
+    let t = raw.trim().trim_end_matches('/');
+    if let Some(rest) = t.strip_prefix("http://") {
+        let host_port = rest.split('/').next().unwrap_or("");
+        let host = host_port.split(':').next().unwrap_or("");
+        let is_local = host == "localhost"
+            || host == "127.0.0.1"
+            || host == "::1"
+            || host == "[::1]"
+            || host.starts_with("192.168.")
+            || host.starts_with("10.")
+            || host.starts_with("172.");
+        if !is_local {
+            return format!("https://{rest}");
+        }
+    }
+    t.to_string()
+}
+
 pub struct ApiClient {
     http: reqwest::Client,
     base_url: RwLock<String>,
@@ -33,7 +58,7 @@ impl ApiClient {
     pub fn new(base_url: String, token: String) -> Self {
         Self {
             http: reqwest::Client::new(),
-            base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
+            base_url: RwLock::new(normalize_base_url(&base_url)),
             token: RwLock::new(token),
         }
     }
@@ -43,7 +68,7 @@ impl ApiClient {
     }
 
     pub fn set_base_url(&self, base_url: String) {
-        *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
+        *self.base_url.write().unwrap() = normalize_base_url(&base_url);
     }
 
     pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
@@ -185,4 +210,36 @@ mod tests {
         let client = ApiClient::new("http://x".into(), "t".into());
         assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
     }
+
+    #[test]
+    fn normalize_upgrades_public_http_to_https() {
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com/"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("  http://cfc.cf-club.com  "),
+            "https://cfc.cf-club.com"
+        );
+        // 已是 https 保持不变
+        assert_eq!(
+            normalize_base_url("https://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+    }
+
+    #[test]
+    fn normalize_keeps_local_and_private_http() {
+        assert_eq!(normalize_base_url("http://localhost:9082"), "http://localhost:9082");
+        assert_eq!(normalize_base_url("http://127.0.0.1:9082"), "http://127.0.0.1:9082");
+        assert_eq!(
+            normalize_base_url("http://192.168.16.251:9082"),
+            "http://192.168.16.251:9082"
+        );
+        assert_eq!(normalize_base_url("http://10.0.0.5"), "http://10.0.0.5");
+    }
 }

+ 36 - 5
cfc-desktop-pet/src-tauri/src/api/models.rs

@@ -110,12 +110,18 @@ pub struct MediaRecord {
     pub url: Option<String>,
 }
 
-/// POST /api/auth/wechat-web/qr → {state, url}
+/// POST /api/auth/wechat-web/qr → {state, qrImage}
+/// qrImage 为小程序码的 data:image/png;base64,... 数据 URI(测试模式为空串)
 #[derive(Debug, Clone, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct WechatQrResp {
     pub state: String,
-    pub url: String,
+    #[serde(default)]
+    pub qr_image: Option<String>,
+    /// 旧版后端(微信开放平台 qrconnect 网页扫码)返回的字段;新版小程序码契约下为 None。
+    /// 保留以便识别「服务端未升级」并给出明确提示,而不是静默展示空码。
+    #[serde(default)]
+    pub url: Option<String>,
 }
 
 /// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?}
@@ -128,7 +134,6 @@ pub struct WechatPollResp {
     pub user_id: Option<i64>,
     pub role: Option<String>,
     pub nickname: Option<String>,
-    #[allow(dead_code)]
     pub message: Option<String>,
 }
 
@@ -209,12 +214,38 @@ mod tests {
 
     #[test]
     fn parses_wechat_qr_response() {
-        let json = r#"{"code":200,"message":"success","data":{"state":"abc123","url":"https://open.weixin.qq.com/connect/qrconnect?appid=x"}}"#;
+        let json = r#"{"code":200,"message":"success","data":{"state":"abc123","qrImage":"data:image/png;base64,AAAA"}}"#;
         let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
         assert!(r.is_success());
         let q = r.data.unwrap();
         assert_eq!(q.state, "abc123");
-        assert!(q.url.contains("qrconnect"));
+        assert!(q
+            .qr_image
+            .as_deref()
+            .unwrap_or("")
+            .starts_with("data:image/png;base64,"));
+    }
+
+    #[test]
+    fn parses_wechat_qr_response_empty_image() {
+        // 测试模式后端返回 qrImage="":字段存在但为空
+        let json = r#"{"code":200,"data":{"state":"abc123","qrImage":""}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "abc123");
+        assert_eq!(q.qr_image.as_deref(), Some(""));
+        assert!(q.url.is_none());
+    }
+
+    #[test]
+    fn parses_legacy_wechat_qr_response_with_url() {
+        // 旧版后端(qrconnect)只返回 {state,url},无 qrImage
+        let json = r#"{"code":200,"message":"success","data":{"state":"s1","url":"https://open.weixin.qq.com/connect/qrconnect?appid=&state=s1"}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "s1");
+        assert!(q.qr_image.is_none());
+        assert!(q.url.as_deref().unwrap_or("").contains("qrconnect"));
     }
 
     #[test]

+ 3 - 0
cfc-desktop-pet/src-tauri/src/app_state.rs

@@ -1,4 +1,5 @@
 use crate::api::ApiClient;
+use std::sync::atomic::AtomicBool;
 use std::sync::{Arc, Mutex};
 
 pub struct AppState {
@@ -6,5 +7,7 @@ pub struct AppState {
     pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
     /// 当前提醒中、可一键完成的待办任务 id(点击宠物即完成)
     pub pending_task: Mutex<Option<i64>>,
+    /// 是否处于「站手跟随光标」模式(前端胸部悬停满 5s 后置真;抖动甩飞/退出后置假)
+    pub follow_cursor: AtomicBool,
     pub api: Arc<ApiClient>,
 }

+ 77 - 32
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -3,6 +3,7 @@ use crate::poller;
 use crate::settings::SettingsStore;
 use crate::state::{PetState, PetStatePayload};
 use crate::token::TokenStore;
+use std::sync::atomic::Ordering;
 use tauri::{AppHandle, Emitter, Manager, State};
 
 #[tauri::command]
@@ -39,6 +40,20 @@ pub async fn drag_end() -> Result<(), String> {
     Ok(())
 }
 
+/// 站手跟随:前端「胸部悬停满 5s」后调用。locomotion 循环会接管窗口移动并回发 `pet://perch`。
+#[tauri::command]
+pub fn start_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(true, Ordering::Relaxed);
+    Ok(())
+}
+
+/// 退出站手跟随(前端主动中止;抖动甩飞是循环内自动退出)。
+#[tauri::command]
+pub fn stop_perch(state: State<AppState>) -> Result<(), String> {
+    state.follow_cursor.store(false, Ordering::Relaxed);
+    Ok(())
+}
+
 #[tauri::command]
 pub fn open_settings(app: AppHandle) -> Result<(), String> {
     if let Some(win) = app.get_webview_window("settings") {
@@ -87,6 +102,12 @@ pub async fn upload_mascot(
         return Err("图片过大(上限 10MB)".into());
     }
 
+    // 生成 data URL(前端 canvas 生成动作帧用,规避远程图片跨域污染)。
+    let data_url = {
+        let m = if mime.is_empty() { "image/png".to_string() } else { mime.clone() };
+        format!("data:{};base64,{}", m, base64::engine::general_purpose::STANDARD.encode(&bytes))
+    };
+
     let state = app.state::<AppState>();
     let record = state
         .api
@@ -99,7 +120,10 @@ pub async fn upload_mascot(
     cfg.custom_mascot_url = Some(url.clone());
     settings.save(&cfg)?;
 
-    let _ = app.emit("pet://custom-image", serde_json::json!({ "url": url }));
+    let _ = app.emit(
+        "pet://custom-image",
+        serde_json::json!({ "url": url, "dataUrl": data_url }),
+    );
     Ok(url)
 }
 
@@ -112,8 +136,31 @@ pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Re
     Ok(())
 }
 
-/// 微信扫码登录:取二维码链接 → 用系统浏览器打开 → 后台轮询换取 JWT → 写入 keyring。
-/// 结果通过 `pet://wechat-login` 事件回传
+/// 拉取远程图片并转成 data URL(供前端 canvas 生成动作帧,规避跨域污染)。
+#[tauri::command]
+pub async fn data_url_from_url(url: String) -> Result<String, String> {
+    if url.starts_with("data:") {
+        return Ok(url);
+    }
+    let resp = reqwest::get(&url).await.map_err(|e| format!("请求失败: {e}"))?;
+    if !resp.status().is_success() {
+        return Err(format!("请求失败: HTTP {}", resp.status()));
+    }
+    let mime = resp
+        .headers()
+        .get(reqwest::header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("image/png")
+        .to_string();
+    let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
+    use base64::Engine;
+    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
+    Ok(format!("data:{};base64,{}", mime, b64))
+}
+
+/// 微信小程序码扫码登录:取小程序码图片 → 前端展示 → 后台轮询换取 JWT → 写入 keyring。
+/// 二维码图片通过 `pet://wechat-qr` 事件回传({state, qrImage});
+/// 登录结果通过 `pet://wechat-login` 事件回传
 /// (status: pending / success / notfound / expired / error / timeout)。
 #[tauri::command]
 pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, String> {
@@ -125,7 +172,26 @@ pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, Str
             .await
             .map_err(|e| format!("获取二维码失败: {e}"))?
     };
-    open_url(&qr.url)?;
+
+    // 兼容旧版后端:只返回 {state,url}(微信开放平台 qrconnect 网页扫码)而无小程序码。
+    // 此时新版登录流程无法继续,明确报错,避免静默展示空码让用户困惑。
+    let qr_image = qr.qr_image.clone().unwrap_or_default();
+    if qr_image.is_empty() && qr.url.as_deref().map(|u| !u.is_empty()).unwrap_or(false) {
+        let _ = app.emit(
+            "pet://wechat-login",
+            serde_json::json!({
+                "status": "error",
+                "message": "服务端仍是旧版登录接口(微信开放平台 qrconnect),请部署最新后端(小程序码 /scan)后重试"
+            }),
+        );
+        return Err("服务端登录接口版本过旧:缺少小程序码,需部署最新后端".into());
+    }
+
+    // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
+    let _ = app.emit(
+        "pet://wechat-qr",
+        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr_image }),
+    );
 
     let api = app.state::<AppState>().api.clone();
     let state_token = qr.state.clone();
@@ -158,7 +224,12 @@ pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, Str
                         }
                         let _ = handle.emit(
                             "pet://wechat-login",
-                            serde_json::json!({"status": "success", "nickname": resp.nickname}),
+                            serde_json::json!({
+                                "status": "success",
+                                "nickname": resp.nickname,
+                                "userId": resp.user_id,
+                                "role": resp.role
+                            }),
                         );
                         return;
                     }
@@ -176,33 +247,7 @@ pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, Str
         let _ = handle.emit("pet://wechat-login", serde_json::json!({"status": "timeout"}));
     });
 
-    Ok(serde_json::json!({ "status": "pending", "url": qr.url }))
-}
-
-/// 用系统默认浏览器打开 URL(Windows: cmd start / macOS: open / 其它: xdg-open)。
-fn open_url(url: &str) -> Result<(), String> {
-    #[cfg(target_os = "windows")]
-    {
-        std::process::Command::new("cmd")
-            .args(["/C", "start", "", url])
-            .spawn()
-            .map_err(|e| e.to_string())?;
-    }
-    #[cfg(target_os = "macos")]
-    {
-        std::process::Command::new("open")
-            .arg(url)
-            .spawn()
-            .map_err(|e| e.to_string())?;
-    }
-    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
-    {
-        std::process::Command::new("xdg-open")
-            .arg(url)
-            .spawn()
-            .map_err(|e| e.to_string())?;
-    }
-    Ok(())
+    Ok(serde_json::json!({ "status": "pending", "qrImage": qr.qr_image }))
 }
 
 #[tauri::command]

+ 64 - 0
cfc-desktop-pet/src-tauri/src/config.rs

@@ -27,6 +27,10 @@ pub struct PetConfig {
     pub default_image: String,
     #[serde(default)]
     pub actions: HashMap<String, String>,
+    /// 动作名 → 帧序列剪辑(真运动序列帧)。provider 无关:
+    /// frames[] 为逐帧图片(相对 image_base_url 或绝对 URL);sprite 为图集(可选)。
+    #[serde(default)]
+    pub animations: HashMap<String, ActionClip>,
     #[serde(default)]
     pub sounds: HashMap<String, String>,
     #[serde(default)]
@@ -54,6 +58,28 @@ impl Default for PetParameters {
     }
 }
 
+/// 单个动作的帧序列剪辑(provider 无关的动作动画基础)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ActionClip {
+    /// 逐帧图片列表(8-16 帧)。相对路径以 image_base_url 为前缀。
+    #[serde(default)]
+    pub frames: Vec<String>,
+    /// 图集图片(可选,优先级低于 frames)。
+    #[serde(default)]
+    pub sprite: Option<String>,
+    #[serde(default = "default_frame_width")]
+    pub frame_width: u32,
+    #[serde(default = "default_frame_height")]
+    pub frame_height: u32,
+    #[serde(default)]
+    pub count: u32,
+    #[serde(default = "default_fps")]
+    pub fps: f64,
+    #[serde(default = "default_true")]
+    pub r#loop: bool,
+}
+
 fn default_version() -> String { "1.0".into() }
 fn default_species() -> String { "other".into() }
 fn default_gender() -> String { "neutral".into() }
@@ -61,6 +87,10 @@ fn default_image() -> String { "standing.png".into() }
 fn default_move_distance() -> i32 { 15 }
 fn default_animation_speed() -> f64 { 1.0 }
 fn default_interaction_timeout() -> i32 { 300 }
+fn default_frame_width() -> u32 { 128 }
+fn default_frame_height() -> u32 { 128 }
+fn default_fps() -> f64 { 12.0 }
+fn default_true() -> bool { true }
 
 impl PetConfig {
     /// 解析 JSON;actions 为空时补默认映射(idle/happy/busy/sleeping)
@@ -82,6 +112,12 @@ impl PetConfig {
     pub fn get_action_for_state(&self, state_name: &str) -> String {
         self.actions.get(state_name).cloned().unwrap_or_else(|| state_name.to_string())
     }
+
+    /// 解析某状态对应的帧序列剪辑(provider 无关)。无则 None(前端回退 CSS 动画)。
+    pub fn get_clip_for_state(&self, state_name: &str) -> Option<&ActionClip> {
+        let action = self.get_action_for_state(state_name);
+        self.animations.get(&action)
+    }
 }
 
 #[cfg(test)]
@@ -120,4 +156,32 @@ mod tests {
     fn parse_error_on_bad_json() {
         assert!(PetConfig::parse("not json").is_err());
     }
+
+    #[test]
+    fn parses_action_frames_and_resolves_by_state() {
+        let json = r#"{"type":"custom","actions":{"happy":"cheer"},"animations":{"cheer":{"frames":["a_01.png","a_02.png","a_03.png"],"fps":15,"loop":false}}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        let clip = cfg.get_clip_for_state("happy").expect("clip");
+        assert_eq!(clip.frames.len(), 3);
+        assert_eq!(clip.fps, 15.0);
+        assert!(!clip.r#loop);
+        assert_eq!(clip.frame_width, 128);
+        assert_eq!(clip.frame_height, 128);
+        assert_eq!(clip.count, 0);
+    }
+
+    #[test]
+    fn no_clip_when_animations_absent() {
+        let json = r#"{"type":"custom","actions":{"idle":"idle"}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert!(cfg.get_clip_for_state("idle").is_none());
+        assert!(cfg.animations.is_empty());
+    }
+
+    #[test]
+    fn action_clip_loop_defaults_true() {
+        let json = r#"{"type":"custom","actions":{"idle":"idle"},"animations":{"idle":{"frames":["x.png"]}}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert!(cfg.get_clip_for_state("idle").unwrap().r#loop);
+    }
 }

+ 414 - 0
cfc-desktop-pet/src-tauri/src/locomotion.rs

@@ -0,0 +1,414 @@
+//! 桌面宠物「精细化动作」运动控制:轮询全局光标 + 移动窗口,实现走 / 跳着走 / 飞 / 站手跟随。
+//!
+//! 设计:宠物窗口仅 200×200,跨屏走/飞必须**移动窗口本身**;窗口位置只能由 Rust 修改,
+//! 故本模块是「移动者」,前端 `pet-pose.js` 只负责渲染局部姿态(走路摆动/跳跃/扑腾)。
+//!
+//! 免全局鼠标钩子:用 `WebviewWindow::cursor_position()` 轮询全局光标(跨平台、无 macOS 辅助功能权限)。
+
+use crate::app_state::AppState;
+use serde::Serialize;
+use std::sync::atomic::Ordering;
+use std::time::{Duration, Instant};
+use tauri::{AppHandle, Emitter, Manager, PhysicalPosition};
+
+/// 循环间隔(ms)
+const TICK_MS: u64 = 60;
+/// 宠物窗口物理尺寸(与 tauri.conf.json 保持一致)
+const WIN_W: i32 = 200;
+const WIN_H: i32 = 200;
+/// 宠物双脚在窗口内的锚点(窗口左上角为原点)。站手时双脚落在光标处。
+const FEET_ANCHOR_X: i32 = 100;
+const FEET_ANCHOR_Y: i32 = 200;
+
+// ── 宠物可视包围盒(窗口内),用于「鼠标悬停在宠物身上 → 暂停活动」判定 ──
+// 与 index.html 的 #pet(120×140,底部居中)布局保持一致:left=(200-120)/2=40,top=200-140=60
+const PET_BOX_OFFSET_X: i32 = 40;
+const PET_BOX_OFFSET_Y: i32 = 60;
+const PET_BOX_W: i32 = 120;
+const PET_BOX_H: i32 = 140;
+
+// ── 抖动检测参数 ──
+const SHAKE_WINDOW_MS: u128 = 600;
+const SHAKE_MIN_REVERSALS: usize = 4;
+const SHAKE_MIN_TRAVEL: f64 = 80.0;
+/// 甩飞后抖动冷却(tick 数,60ms/tick → 约 1.8s),避免落地即再触发
+const SHAKE_COOLDOWN_TICKS: u32 = 30;
+const MAX_SAMPLES: usize = 40;
+
+// ── 步态参数(每 tick 位移,60ms/tick → 约 27px/s 走 / 43px/s 跳)──
+const WALK_STEP: f64 = 1.6;
+const HOP_STEP: f64 = 2.6;
+const FLY_TICKS: u32 = 10; // ≈600ms
+const IDLE_MIN_TICKS: u32 = 45; // ≈2.7s 起才会有自主动作
+const IDLE_JITTER_TICKS: u64 = 70;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+    Idle,
+    Walk,
+    Hop,
+    Fly,
+    Perch,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct Sample {
+    pub x: f64,
+    pub y: f64,
+    pub t: Instant,
+}
+
+/// 抖动检测:在时间窗口内统计水平方向反转次数与总位移。
+/// 反转 ≥4 次且总位移 ≥80px → 视为「抖动鼠标」。
+pub fn detect_shake(samples: &[Sample], now: Instant) -> bool {
+    let recent: Vec<&Sample> = samples
+        .iter()
+        .filter(|s| now.duration_since(s.t).as_millis() <= SHAKE_WINDOW_MS)
+        .collect();
+    if recent.len() < 4 {
+        return false;
+    }
+    let mut reversals = 0usize;
+    let mut travel = 0.0f64;
+    let mut prev_dir = 0i32;
+    for w in recent.windows(2) {
+        let dx = w[1].x - w[0].x;
+        if dx.abs() < 1.0 {
+            continue;
+        }
+        travel += dx.abs();
+        let dir = if dx > 0.0 { 1 } else { -1 };
+        if prev_dir != 0 && dir != prev_dir {
+            reversals += 1;
+        }
+        prev_dir = dir;
+    }
+    reversals >= SHAKE_MIN_REVERSALS && travel >= SHAKE_MIN_TRAVEL
+}
+
+/// 把窗口左上角夹进工作区,保证整窗可见。
+pub fn clamp_window_pos(x: i32, y: i32, wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32) -> (i32, i32) {
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    (x.clamp(wa_x, max_x), y.clamp(wa_y, max_y))
+}
+
+/// 光标是否落在窗口内的宠物包围盒上(用于「悬停在宠物身上 → 暂停活动」)。
+pub fn cursor_over_pet(cursor: (f64, f64), win_pos: (i32, i32)) -> bool {
+    let left = (win_pos.0 + PET_BOX_OFFSET_X) as f64;
+    let top = (win_pos.1 + PET_BOX_OFFSET_Y) as f64;
+    let right = left + PET_BOX_W as f64;
+    let bottom = top + PET_BOX_H as f64;
+    cursor.0 >= left && cursor.0 <= right && cursor.1 >= top && cursor.1 <= bottom
+}
+
+/// 工作区内的确定性随机落点(LCG 单步,便于单测)。
+pub fn pick_random_target(wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32, seed: u64) -> (i32, i32) {
+    let s = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    let rx = ((s >> 33) as u32) as f64 / (u32::MAX as f64);
+    let ry = ((s >> 11) as u32) as f64 / (u32::MAX as f64);
+    let max_x = wa_x + (wa_w - WIN_W).max(0);
+    let max_y = wa_y + (wa_h - WIN_H).max(0);
+    let x = wa_x + (rx * (max_x - wa_x).max(0) as f64).round() as i32;
+    let y = wa_y + (ry * (max_y - wa_y).max(0) as f64).round() as i32;
+    clamp_window_pos(x, y, wa_x, wa_y, wa_w, wa_h)
+}
+
+fn next_rand(seed: &mut u64) -> u64 {
+    *seed = seed
+        .wrapping_mul(6364136223846793005)
+        .wrapping_add(1442695040888963407);
+    *seed
+}
+
+fn work_area(win: &tauri::WebviewWindow) -> (i32, i32, i32, i32) {
+    if let Ok(Some(m)) = win.current_monitor() {
+        let wa = m.work_area();
+        (wa.position.x, wa.position.y, wa.size.width as i32, wa.size.height as i32)
+    } else {
+        (0, 0, 1920, 1080)
+    }
+}
+
+fn set_pos(win: &tauri::WebviewWindow, x: i32, y: i32) {
+    let _ = win.set_position(PhysicalPosition::new(x, y));
+}
+
+fn emit_gait(
+    app: &AppHandle,
+    last: &mut Option<(&'static str, &'static str)>,
+    kind: &'static str,
+    direction: &'static str,
+) {
+    if *last == Some((kind, direction)) {
+        return;
+    }
+    *last = Some((kind, direction));
+    let _ = app.emit(
+        "pet://gait",
+        GaitPayload { kind, direction },
+    );
+}
+
+#[derive(Serialize, Clone)]
+struct GaitPayload {
+    kind: &'static str,
+    direction: &'static str,
+}
+
+/// 启动运动控制循环(在 `app.manage(AppState)` 之后调用)。
+pub fn spawn(app: AppHandle) {
+    tauri::async_runtime::spawn(async move {
+        let mut last_pos: (i32, i32) = app
+            .get_webview_window("main")
+            .and_then(|w| w.outer_position().ok())
+            .map(|p| (p.x, p.y))
+            .unwrap_or((0, 0));
+        let mut mode = Mode::Idle;
+        let mut target: (i32, i32) = last_pos;
+        let mut fly_from: (i32, i32) = last_pos;
+        let mut fly_left: u32 = 0;
+        let mut idle_ticks: u32 = 0;
+        let mut shake_cooldown: u32 = 0;
+        let mut samples: Vec<Sample> = Vec::new();
+        let mut last_gait: Option<(&'static str, &'static str)> = Some(("idle", "none"));
+        let mut seed: u64 = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_nanos() as u64)
+            .unwrap_or(0x1234_5678_9abc_def0);
+
+        loop {
+            tokio::time::sleep(Duration::from_millis(TICK_MS)).await;
+
+            let Some(win) = app.get_webview_window("main") else {
+                continue;
+            };
+            let now = Instant::now();
+            let (wa_x, wa_y, wa_w, wa_h) = work_area(&win);
+            let follow = app
+                .try_state::<AppState>()
+                .map(|s| s.follow_cursor.load(Ordering::Relaxed))
+                .unwrap_or(false);
+            if shake_cooldown > 0 {
+                shake_cooldown -= 1;
+            }
+
+            // 采样全局光标(用于抖动检测 / 站手跟随 / 悬停暂停)
+            let mut cursor: Option<(f64, f64)> = None;
+            if let Ok(cur) = win.cursor_position() {
+                cursor = Some((cur.x, cur.y));
+                samples.push(Sample { x: cur.x, y: cur.y, t: now });
+                if samples.len() > MAX_SAMPLES {
+                    samples.remove(0);
+                }
+            }
+
+            if follow {
+                if mode != Mode::Perch {
+                    mode = Mode::Perch;
+                    samples.clear(); // 丢弃进入前的光标历史,避免接近动作被误判为抖动
+                    emit_gait(&app, &mut last_gait, "perch", "none");
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": true }));
+                }
+                if shake_cooldown == 0 && detect_shake(&samples, now) {
+                    // 抖动 → 甩飞到工作区随机点
+                    if let Some(s) = app.try_state::<AppState>() {
+                        s.follow_cursor.store(false, Ordering::Relaxed);
+                    }
+                    let (tx, ty) = pick_random_target(wa_x, wa_y, wa_w, wa_h, next_rand(&mut seed));
+                    fly_from = last_pos;
+                    target = (tx, ty);
+                    fly_left = FLY_TICKS;
+                    mode = Mode::Fly;
+                    shake_cooldown = SHAKE_COOLDOWN_TICKS;
+                    samples.clear();
+                    let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                    emit_gait(&app, &mut last_gait, "fly", "none");
+                } else if let Some(s) = samples.last() {
+                    // 站手跟随:窗口 = 光标 - 双脚锚点
+                    let (cx, cy) = clamp_window_pos(
+                        s.x as i32 - FEET_ANCHOR_X,
+                        s.y as i32 - FEET_ANCHOR_Y,
+                        wa_x,
+                        wa_y,
+                        wa_w,
+                        wa_h,
+                    );
+                    set_pos(&win, cx, cy);
+                    last_pos = (cx, cy);
+                }
+                continue;
+            } else if mode == Mode::Perch {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                let _ = app.emit("pet://perch", serde_json::json!({ "active": false }));
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
+            // 鼠标悬停在宠物身上时暂停自主动作(走/跳/飞 → 立即待机)
+            let hovered = cursor.map(|c| cursor_over_pet(c, last_pos)).unwrap_or(false);
+            if hovered && matches!(mode, Mode::Walk | Mode::Hop | Mode::Fly) {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
+            match mode {
+                Mode::Fly => {
+                    if fly_left > 0 {
+                        fly_left -= 1;
+                        let t = 1.0 - (fly_left as f64 / FLY_TICKS as f64);
+                        let x = fly_from.0 as f64 + (target.0 - fly_from.0) as f64 * t;
+                        let y = fly_from.1 as f64 + (target.1 - fly_from.1) as f64 * t;
+                        let (cx, cy) = clamp_window_pos(x as i32, y as i32, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    } else {
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    }
+                }
+                Mode::Walk | Mode::Hop => {
+                    let step = if mode == Mode::Walk { WALK_STEP } else { HOP_STEP };
+                    let dx = target.0 as f64 - last_pos.0 as f64;
+                    if dx.abs() <= step {
+                        let (cx, cy) = clamp_window_pos(target.0, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                        mode = Mode::Idle;
+                        idle_ticks = 0;
+                        emit_gait(&app, &mut last_gait, "idle", "none");
+                    } else {
+                        let nx = last_pos.0 as f64 + step * dx.signum();
+                        let (cx, cy) = clamp_window_pos(nx as i32, last_pos.1, wa_x, wa_y, wa_w, wa_h);
+                        set_pos(&win, cx, cy);
+                        last_pos = (cx, cy);
+                    }
+                }
+                _ => {
+                    if hovered {
+                        // 悬停期间保持静止:不累积空闲计时,不触发新的走/跳
+                        idle_ticks = 0;
+                    } else {
+                        idle_ticks += 1;
+                        let threshold =
+                            IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
+                        if idle_ticks >= threshold {
+                            idle_ticks = 0;
+                            let hop = next_rand(&mut seed) % 2 == 0;
+                            let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
+                            let dist = 60 + (next_rand(&mut seed) % 120) as i32;
+                            let (tx, ty) = clamp_window_pos(
+                                last_pos.0 + dir * dist,
+                                last_pos.1,
+                                wa_x,
+                                wa_y,
+                                wa_w,
+                                wa_h,
+                            );
+                            target = (tx, ty);
+                            mode = if hop { Mode::Hop } else { Mode::Walk };
+                            emit_gait(
+                                &app,
+                                &mut last_gait,
+                                if hop { "hop" } else { "walk" },
+                                if dir > 0 { "right" } else { "left" },
+                            );
+                        }
+                    }
+                }
+            }
+        }
+    });
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn sample(x: f64, t: Instant) -> Sample {
+        Sample { x, y: 0.0, t }
+    }
+
+    #[test]
+    fn shake_detected_on_rapid_reversals() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 0.0, 40.0, 0.0, 40.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_on_monotonic_movement() {
+        let now = Instant::now();
+        let base = now - Duration::from_millis(500);
+        let xs = [0.0, 40.0, 80.0, 120.0, 160.0, 200.0];
+        let samples: Vec<Sample> = xs
+            .iter()
+            .enumerate()
+            .map(|(i, x)| sample(*x, base + Duration::from_millis(i as u64 * 80)))
+            .collect();
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_too_few_samples() {
+        let now = Instant::now();
+        let samples = vec![sample(0.0, now), sample(5.0, now)];
+        assert!(!detect_shake(&samples, now));
+    }
+
+    #[test]
+    fn no_shake_when_reversals_outside_window() {
+        let now = Instant::now();
+        let old = now - Duration::from_millis(2000);
+        let old_samples: Vec<Sample> = (0..8)
+            .map(|i| sample(if i % 2 == 0 { 0.0 } else { 40.0 }, old))
+            .collect();
+        assert!(!detect_shake(&old_samples, now));
+    }
+
+    #[test]
+    fn clamp_keeps_window_inside_work_area() {
+        assert_eq!(clamp_window_pos(-50, -50, 0, 0, 1920, 1080), (0, 0));
+        assert_eq!(clamp_window_pos(5000, 5000, 0, 0, 1920, 1080), (1720, 880));
+        assert_eq!(clamp_window_pos(100, 100, 0, 0, 1920, 1080), (100, 100));
+    }
+
+    #[test]
+    fn clamp_handles_offset_work_area() {
+        // 副屏起点 (1920, 0)
+        assert_eq!(clamp_window_pos(1900, -10, 1920, 0, 1280, 1024), (1920, 0));
+        assert_eq!(clamp_window_pos(9999, 9999, 1920, 0, 1280, 1024), (3000, 824));
+    }
+
+    #[test]
+    fn random_target_stays_within_bounds() {
+        for seed in [0u64, 1, 42, 123_456, u64::MAX] {
+            let (x, y) = pick_random_target(0, 0, 1920, 1080, seed);
+            assert!((0..=1720).contains(&x), "x={x}");
+            assert!((0..=880).contains(&y), "y={y}");
+        }
+    }
+
+    #[test]
+    fn cursor_over_pet_detects_hit_and_miss() {
+        // 窗口左上角 (100,100):宠物盒 = x∈[140,260], y∈[160,300]
+        assert!(cursor_over_pet((200.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((10.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((200.0, 10.0), (100, 100)));
+        // 边界包含
+        assert!(cursor_over_pet((140.0, 160.0), (100, 100)));
+        assert!(cursor_over_pet((260.0, 300.0), (100, 100)));
+        // 窗口移动后包围盒随之平移
+        assert!(cursor_over_pet((500.0, 600.0), (400, 450)));
+    }
+}

+ 7 - 0
cfc-desktop-pet/src-tauri/src/main.rs

@@ -4,6 +4,7 @@ mod api;
 mod app_state;
 mod commands;
 mod config;
+mod locomotion;
 mod poller;
 mod settings;
 mod state;
@@ -36,8 +37,11 @@ fn main() {
                 passthrough: std::sync::Mutex::new(false),
                 poll_handle: std::sync::Mutex::new(Some(handle)),
                 pending_task: std::sync::Mutex::new(None),
+                follow_cursor: std::sync::atomic::AtomicBool::new(false),
                 api,
             });
+            // 精细化动作运动控制:轮询全局光标 + 移动窗口(走/跳/飞/站手跟随)
+            locomotion::spawn(app.handle().clone());
             tray::setup_tray(app)?;
             Ok(())
         })
@@ -45,12 +49,15 @@ fn main() {
             commands::on_interact,
             commands::drag_start,
             commands::drag_end,
+            commands::start_perch,
+            commands::stop_perch,
             commands::get_settings,
             commands::save_settings,
             commands::open_settings,
             commands::quit_app,
             commands::upload_mascot,
             commands::clear_custom_mascot,
+            commands::data_url_from_url,
             commands::start_wechat_login,
         ])
         .run(tauri::generate_context!())

+ 1 - 1
cfc-desktop-pet/src-tauri/tauri.conf.json

@@ -27,7 +27,7 @@
         "title": "设置",
         "url": "settings.html",
         "width": 360,
-        "height": 470,
+        "height": 640,
         "resizable": false,
         "visible": false
       }

+ 0 - 6
cfc-desktop-pet/src/XafPet.Desktop/App.xaml

@@ -1,6 +0,0 @@
-<Application x:Class="XafPet.Desktop.App"
-             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-             StartupUri="MainWindow.xaml">
-    <Application.Resources></Application.Resources>
-</Application>

+ 0 - 16
cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs

@@ -1,16 +0,0 @@
-using System.Windows;
-
-namespace XafPet.Desktop;
-
-public partial class App : Application
-{
-    protected override void OnStartup(StartupEventArgs e)
-    {
-        base.OnStartup(e);
-        if (!SingleInstanceHelper.TryEnterFirstInstance())
-        {
-            Current.Shutdown();
-            return;
-        }
-    }
-}

+ 0 - 46
cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css

@@ -1,46 +0,0 @@
-/* ===== 浠艾福桌面宠物动画定义 ===== */
-@keyframes wings_idle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-3px) } }
-@keyframes wings_spread { 0% { transform: scale(1) } 30% { transform: scale(1.15, 0.95) } 70% { transform: scale(0.95, 1.1) } 100% { transform: scale(1) } }
-@keyframes head_tilt { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-8deg) } 75% { transform: rotate(6deg) } }
-@keyframes hand_gesture { 0%, 100% { transform: translateY(0) } 20% { transform: translateY(-8px) } 40% { transform: translateY(0) } 60% { transform: translateY(-5px) } 80% { transform: translateY(0) } }
-@keyframes breath_loop { 0%, 100% { transform: scale(1, 1) } 50% { transform: scale(1.04, 0.96) } }
-@keyframes celebrate { 0%, 100% { transform: translateY(0) rotate(0) } 25% { transform: translateY(-16px) rotate(-4deg) } 50% { transform: translateY(0) rotate(0) } 75% { transform: translateY(-16px) rotate(4deg) } }
-@keyframes sad_animation { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(2px) } }
-@keyframes tail_wrap { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(3deg) } }
-@keyframes belly_show { 0%, 100% { transform: rotate(0deg) scale(1) } 50% { transform: rotate(-4deg) scale(1.05) } }
-@keyframes paw_press { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(3px) } }
-@keyframes tail_flick { 0%, 100% { transform: rotate(0deg) } 30% { transform: rotate(10deg) } 60% { transform: rotate(-10deg) } }
-@keyframes blink_eye { 0%, 90%, 100% { transform: scaleY(1) } 95% { transform: scaleY(0.1) } }
-@keyframes look_around { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-12deg) } 75% { transform: rotate(12deg) } }
-@keyframes wave { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-15deg) } 50% { transform: rotate(10deg) } 75% { transform: rotate(-15deg) } }
-@keyframes stretch { 0%, 100% { transform: scaleY(1) } 50% { transform: scaleY(1.15) } }
-@keyframes clap { 0%, 100% { transform: scale(1) } 40% { transform: scale(1.1, 0.9) } 70% { transform: scale(0.95, 1.05) } }
-@keyframes bow { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(10deg) } }
-@keyframes whistle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-4px) } }
-@keyframes ear_scrape { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(6deg) } }
-@keyframes pet_touch { 0% { transform: scale(1) } 50% { transform: scale(1.08) } 100% { transform: scale(1) } }
-@keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } }
-@keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
-
-.anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
-.anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }
-.anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; }
-.anim-hand_gesture { animation: hand_gesture 1.8s ease-in-out infinite; }
-.anim-breath_loop { animation: breath_loop 2.4s ease-in-out infinite; }
-.anim-celebrate { animation: celebrate 0.8s ease-in-out 3; }
-.anim-sad_animation { animation: sad_animation 3s ease-in-out infinite; }
-.anim-tail_wrap { animation: tail_wrap 2s ease-in-out infinite; }
-.anim-belly_show { animation: belly_show 1.4s ease-in-out 2; }
-.anim-paw_press { animation: paw_press 1s ease-in-out 2; }
-.anim-tail_flick { animation: tail_flick 1.5s ease-in-out 2; }
-.anim-blink_eye { animation: blink_eye 3.5s ease-in-out infinite; }
-.anim-look_around { animation: look_around 2s ease-in-out 1; }
-.anim-wave { animation: wave 1.2s ease-in-out 2; }
-.anim-stretch { animation: stretch 1.8s ease-in-out 1; }
-.anim-clap { animation: clap 0.6s ease-in-out 3; }
-.anim-bow { animation: bow 1.2s ease-in-out 2; }
-.anim-whistle { animation: whistle 0.9s ease-in-out 3; }
-.anim-ear_scrape { animation: ear_scrape 1s ease-in-out 2; }
-.anim-pet_touch { animation: pet_touch 0.6s ease-in-out 1; }
-.anim-puzzle_active { animation: puzzle_active 1s ease-in-out infinite; }
-.anim-worried_walk { animation: worried_walk 1.2s ease-in-out infinite; }

+ 0 - 51
cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js

@@ -1,51 +0,0 @@
-(function () {
-  'use strict';
-
-  const petEl = document.getElementById('pet');
-
-  let dragStartX = 0, dragStartY = 0;
-  let pressTimer = null;
-  let isDragging = false;
-
-  petEl.addEventListener('mousedown', function (e) {
-    dragStartX = e.clientX;
-    dragStartY = e.clientY;
-    isDragging = false;
-    pressTimer = setTimeout(() => {
-      notifyInteract('pet', {});
-    }, 1500);
-  });
-
-  petEl.addEventListener('mousemove', function (e) {
-    const dx = e.clientX - dragStartX;
-    const dy = e.clientY - dragStartY;
-    if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
-      isDragging = true;
-      clearTimeout(pressTimer);
-      notifyDragStart();
-    }
-  });
-
-  petEl.addEventListener('mouseup', function (e) {
-    clearTimeout(pressTimer);
-    if (!isDragging) {
-      notifyInteract('click', {});
-    } else {
-      notifyDragEnd();
-    }
-  });
-
-  function notifyInteract(action, data) {
-    try {
-      chrome.webview.hostObjects.sync.cfcHost.onInteract(action, JSON.stringify(data));
-    } catch (err) { console.log('[interaction]', action, data); }
-  }
-
-  function notifyDragStart() {
-    try { chrome.webview.hostObjects.sync.cfcHost.onDragStart(); } catch (e) { /* noop */ }
-  }
-
-  function notifyDragEnd() {
-    try { chrome.webview.hostObjects.sync.cfcHost.onDragEnd(); } catch (e) { /* noop */ }
-  }
-})();

+ 0 - 105
cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js

@@ -1,105 +0,0 @@
-(function () {
-  'use strict';
-
-  const petImg = document.getElementById('pet-img');
-  const petEl = document.getElementById('pet');
-  const bubble = document.getElementById('bubble');
-
-  let currentState = 'idle';
-  let currentAnimation = null;
-  let config = null;
-  let bubbleTimer = null;
-
-  const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
-    encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
-      '<text y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
-
-  const BUBBLE_TEXTS = {
-    happy: '完成了!继续保持~',
-    busy: '有任务在等你呢~',
-    worried: '任务要过期啦,快去看看!',
-    excited: '打卡成功,太棒了!',
-    remind: '该喝水啦~',
-    tired: '有点累了呢…',
-    sad: '要照顾好自己哦…',
-    waitingForInteraction: '来和我互动一下吧!',
-    miniGame: '我们一起玩吧!',
-    sleeping: 'zZ…',
-    custom: '自定义状态'
-  };
-
-  window.cfcPetAPI = {
-    notifyState: function (stateJson) {
-      const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
-      setState(payload.state, payload.reason || null, payload.data || null);
-    },
-    setCustomState: function (stateName, dataJson) {
-      const data = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson;
-      setState(stateName, 'custom', data);
-    },
-    loadConfig: function (configJson) {
-      config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
-      applyImage(config);
-    },
-    showBubble: function (text, durationMs) {
-      bubble.textContent = text;
-      bubble.style.display = 'block';
-      clearTimeout(bubbleTimer);
-      if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
-    },
-    hideBubble: hideBubble,
-    getState: function () { return currentState; }
-  };
-
-  function hideBubble() { bubble.style.display = 'none'; }
-
-  function applyImage(cfg) {
-    if (!cfg || !cfg.default_image) return;
-    const src = cfg.image_base_url + cfg.default_image;
-    petImg.src = src;
-    petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
-  }
-
-  function setState(stateName, reason, data) {
-    currentState = stateName;
-    if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
-
-    let animClass = null;
-    if (config && config.actions && config.actions[stateName]) {
-      animClass = 'anim-' + config.actions[stateName];
-    } else {
-      animClass = 'anim-' + stateName;
-    }
-
-    const styleEl = document.getElementById('pet-animations');
-    const sheet = styleEl ? styleEl.sheet : null;
-    const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
-    if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
-
-    if (reason === 'drink_water') {
-      bubble.textContent = '该喝水啦~';
-      bubble.style.display = 'block';
-      clearTimeout(bubbleTimer);
-      bubbleTimer = setTimeout(hideBubble, 8000);
-    } else if (reason === 'welcome') {
-      bubble.textContent = '我来了!';
-      bubble.style.display = 'block';
-      clearTimeout(bubbleTimer);
-      bubbleTimer = setTimeout(hideBubble, 3000);
-    } else if (BUBBLE_TEXTS[stateName]) {
-      bubble.textContent = BUBBLE_TEXTS[stateName];
-      bubble.style.display = 'block';
-      clearTimeout(bubbleTimer);
-      bubbleTimer = setTimeout(hideBubble, 5000);
-    }
-  }
-
-  petImg.src = DEFAULT_IMAGE;
-
-  setInterval(function () {
-    if (document.getElementById('pet-img')) {
-      petImg.classList.add('anim-blink_eye');
-      setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200);
-    }
-  }, 3500);
-})();

+ 0 - 30
cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html

@@ -1,30 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="UTF-8" />
-<title>Xaf Pet</title>
-<link rel="stylesheet" href="animations.css" />
-<style>
-  html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
-  #pet-stage { position: fixed; inset: 0; display: flex; align-items: flex-end; justify-content: center; pointer-events: none; }
-  #pet { width: 120px; height: 140px; pointer-events: auto; cursor: grab; user-select: none; }
-  #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
-  #bubble { position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
-            background: #fff; border: 2px solid #4A9BD7; border-radius: 12px; padding: 6px 12px;
-            font: 13px/1.4 'PingFang SC', sans-serif; color: #333; white-space: nowrap;
-            display: none; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
-  #bubble::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
-                   border: 6px solid transparent; border-top-color: #4A9BD7; }
-</style>
-</head>
-<body>
-<div id="pet-stage">
-  <div id="pet">
-    <img id="pet-img" src="" alt="pet" draggable="false" />
-    <div id="bubble"></div>
-  </div>
-</div>
-<script src="pet-engine.js"></script>
-<script src="interactions.js"></script>
-</body>
-</html>

+ 0 - 27
cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json

@@ -1,27 +0,0 @@
-{
-  "version": "1.0",
-  "type": "animal",
-  "species": "british_shorthair",
-  "gender": "male",
-  "image_base_url": "./DefaultPets/fubao/",
-  "default_image": "standing.png",
-  "actions": {
-    "idle": "tail_wrap",
-    "happy": "belly_show",
-    "busy": "paw_press",
-    "waitingForInteraction": "tail_flick",
-    "sleeping": "breath_loop",
-    "celebrate": "celebrate",
-    "sad": "sad_animation"
-  },
-  "sounds": {
-    "click": "tap.mp3",
-    "complete": "bell.mp3",
-    "remind": "water_please.mp3"
-  },
-  "parameters": {
-    "move_distance": 12,
-    "animation_speed": 1.0,
-    "interaction_timeout": 300
-  }
-}

+ 0 - 27
cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json

@@ -1,27 +0,0 @@
-{
-  "version": "1.0",
-  "type": "animal",
-  "species": "cockatiel",
-  "gender": "female",
-  "image_base_url": "./DefaultPets/xibao/",
-  "default_image": "standing.png",
-  "actions": {
-    "idle": "wings_idle",
-    "happy": "wings_spread",
-    "busy": "head_tilt",
-    "waitingForInteraction": "hand_gesture",
-    "sleeping": "breath_loop",
-    "celebrate": "celebrate",
-    "sad": "sad_animation"
-  },
-  "sounds": {
-    "click": "pip.mp3",
-    "complete": "cheer.mp3",
-    "remind": "water_please.mp3"
-  },
-  "parameters": {
-    "move_distance": 15,
-    "animation_speed": 1.0,
-    "interaction_timeout": 300
-  }
-}

+ 0 - 16
cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml

@@ -1,16 +0,0 @@
-<Window x:Class="XafPet.Desktop.MainWindow"
-        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-        xmlns:pet="clr-namespace:XafPet.Desktop.PetEngine"
-        Title="浠艾福桌面宠物"
-        Width="200" Height="200"
-        WindowStyle="None"
-        AllowsTransparency="True"
-        Background="Transparent"
-        Topmost="True"
-        ShowInTaskbar="False"
-        ResizeMode="NoResize">
-    <Grid x:Name="RootGrid" Background="Transparent">
-        <pet:WebViewHost x:Name="PetHost" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
-    </Grid>
-</Window>

+ 0 - 207
cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs

@@ -1,207 +0,0 @@
-using System;
-using System.IO;
-using System.Text.Json;
-using System.Timers;
-using System.Windows;
-using System.Windows.Forms;
-using XafPet.Desktop.PetEngine;
-
-namespace XafPet.Desktop;
-
-public partial class MainWindow : Window
-{
-    private NotifyIcon? _trayIcon;
-    private ApiClient? _api;
-    private Timer? _taskTimer;
-    private Timer? _energyTimer;
-    private Timer? _waterTimer;
-    private string _baseUrl = "http://localhost:9082";
-
-    public MainWindow()
-    {
-        InitializeComponent();
-        RootGrid.MouseLeftButtonDown += (s, e) =>
-        {
-            if (e.ButtonState == MouseButtonState.Pressed) DragMove();
-        };
-        PetHost.InteractionReceived += OnInteractionReceived;
-        Loaded += OnLoaded;
-        Closed += OnClosed;
-    }
-
-    private void OnLoaded(object sender, RoutedEventArgs e)
-    {
-        var token = TokenStore.Load();
-        _api = new ApiClient(new HttpClient(), _baseUrl, token ?? "dev-token");
-        StartPolling();
-        SetupTray();
-        _ = SyncMascotAsync();
-    }
-
-    private void StartPolling()
-    {
-        _taskTimer = new Timer(30_000) { AutoReset = true };
-        _taskTimer.Elapsed += async (_, _) => await PollTasksAsync();
-        _taskTimer.Start();
-
-        _energyTimer = new Timer(60_000) { AutoReset = true };
-        _energyTimer.Elapsed += async (_, _) => await PollHealthAsync();
-        _energyTimer.Start();
-
-        StartWaterReminder();
-    }
-
-    private async Task PollTasksAsync()
-    {
-        if (_api == null) return;
-        try
-        {
-            var tasks = await _api.GetTodayTasksAsync();
-            var state = StateManager.MapState(tasks ?? new List<CfcTask>(), null, null, false);
-            PushState(state, "tasks");
-        }
-        catch (ApiException ex) { PushBubble($"服务暂时不可用({ex.ApiCode})"); }
-        catch (Exception) { PushBubble("连接后端失败,请检查服务"); }
-    }
-
-    private async Task PollHealthAsync()
-    {
-        if (_api == null) return;
-        try
-        {
-            var energy = await _api.GetEnergyOverviewAsync();
-            var checkin = await _api.GetDailyCheckinAsync();
-            var state = StateManager.MapState(null, energy, checkin, false);
-            PushState(state, "health");
-        }
-        catch (Exception) { /* 静默 */ }
-    }
-
-    private void PushState(PetState state, string reason)
-    {
-        var payload = new PetStatePayload(StateManager.StateToCamelCase(state), reason, null);
-        PetHost.NotifyState(JsonSerializer.Serialize(payload));
-    }
-
-    private void PushBubble(string text)
-    {
-        PetHost.ShowBubble(text, 5000);
-    }
-
-    private void SetupTray()
-    {
-        var menu = new ContextMenuStrip();
-        menu.Items.Add("设置", null, (_, _) => ShowSettings());
-        menu.Items.Add("显示/隐藏", null, (_, _) => ToggleVisible());
-        menu.Items.Add("退出", null, (_, _) => { _trayIcon?.Dispose(); Application.Current.Shutdown(); });
-
-        _trayIcon = new NotifyIcon
-        {
-            Text = "浠艾福桌面宠物",
-            Icon = SystemIcons.Application,
-            ContextMenuStrip = menu,
-            Visible = true
-        };
-        _trayIcon.DoubleClick += (_, _) => ToggleVisible();
-    }
-
-    private void ToggleVisible()
-    {
-        Visibility = Visibility == Visibility.Visible ? Visibility.Hidden : Visibility.Visible;
-    }
-
-    private void ShowSettings()
-    {
-        var win = new SettingsWindow(TokenStore.Load(), _baseUrl) { Owner = this };
-        if (win.ShowDialog() == true)
-        {
-            _baseUrl = win.BaseUrl;
-            if (!string.IsNullOrEmpty(win.Token)) TokenStore.Save(win.Token);
-            _api = new ApiClient(new HttpClient(), _baseUrl, TokenStore.Load() ?? "dev-token");
-            StartPolling();
-        }
-    }
-
-    private async void OnClosed(object? sender, EventArgs e)
-    {
-        _taskTimer?.Stop(); _energyTimer?.Stop(); _waterTimer?.Stop();
-        _taskTimer?.Dispose(); _energyTimer?.Dispose(); _waterTimer?.Dispose();
-        _trayIcon?.Dispose();
-    }
-
-    // ── 喝水提醒 ─────────────────────────────────────────────
-    private void StartWaterReminder()
-    {
-        _waterTimer = new Timer(60 * 60 * 1000) { AutoReset = true };
-        _waterTimer.Elapsed += async (_, _) => await CheckWaterAsync();
-        _waterTimer.Start();
-        _ = CheckWaterAsync();
-    }
-
-    private async Task CheckWaterAsync()
-    {
-        if (_api == null) return;
-        var hour = DateTime.Now.Hour;
-        if (hour < 10 || hour > 20) return;
-        try
-        {
-            var checkin = await _api.GetDailyCheckinAsync();
-            if (checkin != null && !checkin.Items.Contains("water"))
-            {
-                PushState(PetState.WaitingForInteraction, "drink_water");
-                PetHost.ShowBubble("该喝水啦~点我确认完成!", 15000);
-            }
-        }
-        catch (Exception) { }
-    }
-
-    private void OnInteractionReceived(string action, string? dataJson)
-    {
-        switch (action)
-        {
-            case "click":
-                _ = HandleClickAsync();
-                break;
-            case "pet":
-                PetHost.ShowBubble("嘻嘻~", 2000);
-                break;
-        }
-    }
-
-    private async Task HandleClickAsync()
-    {
-        if (_api == null) return;
-        try
-        {
-            var result = await _api.CompleteDrinkWaterAsync();
-            if (result?.IsSuccess == true)
-            {
-                PushState(PetState.Happy, "drink_complete");
-                PetHost.ShowBubble("喝水打卡成功!太棒了~", 4000);
-            }
-        }
-        catch (Exception)
-        {
-            PushBubble("打卡失败,请稍后再试");
-        }
-    }
-
-    // ── 吉祥物同步 ───────────────────────────────────────────
-    private async Task SyncMascotAsync()
-    {
-        if (_api == null) return;
-        try
-        {
-            var mascot = await _api.GetMascotAsync();
-            var petName = mascot?.MascotId ?? "xibao";
-            var cfgPath = Path.Combine(AppContext.BaseDirectory, "DefaultPets", petName, "pet-config.json");
-            if (File.Exists(cfgPath))
-            {
-                var json = File.ReadAllText(cfgPath);
-                var parsed = PetConfig.Parse(json);
-                PetHost.LoadPetConfig(JsonSerializer.Serialize(parsed));
-            }
-        }
-        catch (Exception) { /* 默认浠宝 */ }
-    }
-}

+ 0 - 78
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs

@@ -1,78 +0,0 @@
-using System.Net.Http;
-using System.Text;
-using System.Text.Json;
-
-namespace XafPet.Desktop.PetEngine;
-
-/// <summary>CFC 后端 API 客户端。所有接口统一 POST + JWT Bearer。</summary>
-public sealed class ApiClient
-{
-    private readonly HttpClient _http;
-    private readonly string _baseUrl;
-    private string _token;
-
-    private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
-
-    public ApiClient(HttpClient http, string baseUrl, string token)
-    {
-        _http = http;
-        _baseUrl = baseUrl.TrimEnd('/');
-        _token = token;
-    }
-
-    public void UpdateToken(string token) => _token = token;
-
-    private async Task<T?> PostAsync<T>(string path, object? body = null)
-    {
-        using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}{path}");
-        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
-        if (body != null)
-        {
-            req.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts),
-                Encoding.UTF8, "application/json");
-        }
-        var resp = await _http.SendAsync(req);
-        var json = await resp.Content.ReadAsStringAsync();
-        var result = JsonSerializer.Deserialize<ApiResult<T>>(json, JsonOpts)
-            ?? throw new ApiException(-1, "响应解析失败");
-        if (!result.IsSuccess)
-            throw new ApiException(result.Code, result.Message ?? "未知错误");
-        return result.Data;
-    }
-
-    /// <summary>POST /api/tasks/today — 今日任务</summary>
-    public Task<List<CfcTask>?> GetTodayTasksAsync()
-        => PostAsync<List<CfcTask>>("/api/tasks/today", new Dictionary<string, object>());
-
-    /// <summary>POST /api/energy/overview — 五维能量概览</summary>
-    public Task<EnergyOverview?> GetEnergyOverviewAsync()
-        => PostAsync<EnergyOverview>("/api/energy/overview");
-
-    /// <summary>POST /api/daily/checkin — 今日打卡状态</summary>
-    public Task<DailyCheckin?> GetDailyCheckinAsync()
-        => PostAsync<DailyCheckin>("/api/daily/checkin");
-
-    /// <summary>POST /api/daily/checkin — 完成喝水打卡(body: {"type":"water"})</summary>
-    public async Task<ApiResult<object>?> CompleteDrinkWaterAsync()
-    {
-        using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/daily/checkin");
-        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
-        req.Content = new StringContent("""{"type":"water"}""", Encoding.UTF8, "application/json");
-        var resp = await _http.SendAsync(req);
-        var json = await resp.Content.ReadAsStringAsync();
-        var result = JsonSerializer.Deserialize<ApiResult<object>>(json, JsonOpts)
-            ?? throw new ApiException(-1, "响应解析失败");
-        if (!result.IsSuccess)
-            throw new ApiException(result.Code, result.Message ?? "未知错误");
-        return result;
-    }
-
-    /// <summary>POST /api/user/mascot/get — 获取用户已选吉祥物</summary>
-    public Task<MascotInfo?> GetMascotAsync()
-        => PostAsync<MascotInfo>("/api/user/mascot/get");
-
-    /// <summary>POST /api/user/mascot/set — 设置吉祥物</summary>
-    public Task<object?> SetMascotAsync(string mascotId)
-        => PostAsync<object>("/api/user/mascot/set", new { mascotId });
-
-}

+ 0 - 89
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs

@@ -1,89 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace XafPet.Desktop.PetEngine;
-
-/// <summary>CFC 统一响应包装 Result<T></summary>
-public sealed class ApiResult<T>
-{
-    [JsonPropertyName("code")]
-    public int Code { get; set; }
-
-    [JsonPropertyName("message")]
-    public string? Message { get; set; }
-
-    [JsonPropertyName("data")]
-    public T? Data { get; set; }
-
-    public bool IsSuccess => Code == 200;
-}
-
-public sealed class CfcTask
-{
-    [JsonPropertyName("id")]
-    public long Id { get; set; }
-
-    [JsonPropertyName("title")]
-    public string? Title { get; set; }
-
-    [JsonPropertyName("status")]
-    public string? Status { get; set; }   // PENDING / IN_PROGRESS / COMPLETED / REJECTED
-
-    [JsonPropertyName("dueDate")]
-    public string? DueDate { get; set; }
-}
-
-public sealed class DimensionValue
-{
-    [JsonPropertyName("dimension")]
-    public string? Dimension { get; set; }
-
-    [JsonPropertyName("value")]
-    public int Value { get; set; }
-
-    [JsonPropertyName("color")]
-    public string? Color { get; set; }
-}
-
-public sealed class EnergyOverview
-{
-    [JsonPropertyName("dimensions")]
-    public List<DimensionValue> Dimensions { get; set; } = new();
-}
-
-public sealed class DailyCheckin
-{
-    [JsonPropertyName("checked")]
-    public bool Checked { get; set; }
-
-    [JsonPropertyName("items")]
-    public List<string> Items { get; set; } = new();
-}
-
-/// <summary>API 返回非 200 code 时抛出</summary>
-public sealed class ApiException : Exception
-{
-    public int ApiCode { get; }
-
-    public ApiException(int apiCode, string message) : base(message)
-    {
-        ApiCode = apiCode;
-    }
-}
-
-public sealed class MascotInfo
-{
-    [JsonPropertyName("mascotId")]
-    public string? MascotId { get; set; }
-
-    [JsonPropertyName("name")]
-    public string? Name { get; set; }
-
-public sealed class MascotInfo
-{
-    [JsonPropertyName("mascotId")]
-    public string? MascotId { get; set; }
-
-    [JsonPropertyName("name")]
-    public string? Name { get; set; }
-}
-}

+ 0 - 73
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs

@@ -1,73 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace XafPet.Desktop.PetEngine;
-
-public enum PetType { Animal, Human, Custom }
-
-/// <summary>宠物形象配置(pet-config.json)</summary>
-public sealed class PetConfig
-{
-    [JsonPropertyName("version")]
-    public string Version { get; set; } = "1.0";
-
-    [JsonPropertyName("type")]
-    public PetType Type { get; set; } = PetType.Custom;
-
-    [JsonPropertyName("species")]
-    public string Species { get; set; } = "other";
-
-    [JsonPropertyName("gender")]
-    public string Gender { get; set; } = "neutral";
-
-    [JsonPropertyName("image_base_url")]
-    public string ImageBaseUrl { get; set; } = "";
-
-    [JsonPropertyName("default_image")]
-    public string DefaultImage { get; set; } = "standing.png";
-
-    [JsonPropertyName("actions")]
-    public Dictionary<string, string> Actions { get; set; } = new();
-
-    [JsonPropertyName("sounds")]
-    public Dictionary<string, string> Sounds { get; set; } = new();
-
-    [JsonPropertyName("parameters")]
-    public PetParameters Parameters { get; set; } = new();
-
-    [JsonIgnore]
-    public int CustomStateCount => Actions.Keys.Count(k => k.StartsWith("custom_state_"));
-
-    /// <summary>解析 JSON 字符串;缺省 actions 时补默认映射(key=value)</summary>
-    public static PetConfig Parse(string json)
-    {
-        var config = JsonSerializer.Deserialize<PetConfig>(json)
-            ?? throw new JsonException("pet-config.json 反序列化结果为 null");
-        if (config.Actions.Count == 0)
-        {
-            config.Actions = new Dictionary<string, string>
-            {
-                ["idle"] = "idle",
-                ["happy"] = "happy",
-                ["busy"] = "busy",
-                ["sleeping"] = "sleeping"
-            };
-        }
-        return config;
-    }
-
-    public string GetActionForState(string stateName)
-        => Actions.TryGetValue(stateName, out var v) ? v : stateName;
-}
-
-public sealed class PetParameters
-{
-    [JsonPropertyName("move_distance")]
-    public int MoveDistance { get; set; } = 15;
-
-    [JsonPropertyName("animation_speed")]
-    public double AnimationSpeed { get; set; } = 1.0;
-
-    [JsonPropertyName("interaction_timeout")]
-    public int InteractionTimeout { get; set; } = 300; // 秒
-}

+ 0 - 27
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs

@@ -1,27 +0,0 @@
-namespace XafPet.Desktop.PetEngine;
-
-/// <summary>宠物核心状态枚举(C# 侧定义,JS 侧同名接收)</summary>
-public enum PetState
-{
-    Idle,                  // 默认
-    Happy,                 // 任务全完成
-    Busy,                  // 有任务进行中
-    Worried,               // 任务超期未完成
-    Excited,               // 健康打卡完成
-    Remind,                // 提醒用户(喝水/任务)
-    Energetic,             // 身维度能量高
-    Tired,                 // 身维度能量低
-    Curious,               // 智维度能量高
-    Sad,                   // 全维度低
-    Sleeping,              // 用户无操作 >15min
-    Watching,              // 用户活跃,注视光标
-    WaitingForInteraction, // 等待用户互动(打卡确认)
-    MiniGame               // 小游戏模式
-}
-
-/// <summary>推送 WebView 的状态负载</summary>
-public sealed record PetStatePayload(
-    string State,           // 枚举名 camelCase:如 "waitingForInteraction"
-    string? Reason,         // 可选:如 "drink_water"、"task_complete"
-    object? Data            // 可选:任务列表/能量值等
-);

+ 0 - 62
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs

@@ -1,62 +0,0 @@
-using System.Text.RegularExpressions;
-
-namespace XafPet.Desktop.PetEngine;
-
-/// <summary>把 CFC API 数据转换为宠物状态(纯逻辑,无 UI 依赖)</summary>
-public static class StateManager
-{
-    /// <summary>核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle</summary>
-    public static PetState MapState(
-        List<CfcTask>? tasks,
-        EnergyOverview? energy,
-        DailyCheckin? checkin,
-        bool userInactive)
-    {
-        // 1. 用户无操作 → 睡眠(最高优先级,除非有未完成任务需要提醒)
-        var hasPendingTasks = tasks?.Any(t => t.Status is "PENDING" or "IN_PROGRESS") ?? false;
-        if (userInactive && !hasPendingTasks) return PetState.Sleeping;
-
-        // 2. 健康打卡完成 → 兴奋(次高)
-        if (checkin is { Checked: true }) return PetState.Excited;
-
-        // 3. 任务状态
-        if (tasks is { Count: > 0 })
-        {
-            var allDone = tasks.All(t => t.Status == "COMPLETED");
-            if (allDone) return PetState.Happy;
-
-            // 超期未完成(dueDate 早于今天且非完成态)→ 担忧
-            var today = DateOnly.FromDateTime(DateTime.Today);
-            var overdue = tasks.Any(t =>
-                t.Status is "PENDING" or "IN_PROGRESS" &&
-                DateOnly.TryParse(t.DueDate, out var due) && due < today);
-            if (overdue) return PetState.Worried;
-
-            return PetState.Busy;
-        }
-
-        // 4. 五维能量
-        if (energy is { Dimensions.Count: > 0 })
-        {
-            var dims = energy.Dimensions;
-            var allLow = dims.All(d => d.Value < 30);
-            if (allLow) return PetState.Sad;
-
-            var body = dims.FirstOrDefault(d => d.Dimension == "身");
-            if (body != null && body.Value < 30) return PetState.Tired;
-            if (body != null && body.Value >= 85) return PetState.Energetic;
-
-            var mind = dims.FirstOrDefault(d => d.Dimension == "智");
-            if (mind != null && mind.Value >= 85) return PetState.Curious;
-        }
-
-        return PetState.Idle;
-    }
-
-    /// <summary>枚举 → camelCase(JS 侧状态名)。WaitingForInteraction → waitingForInteraction</summary>
-    public static string StateToCamelCase(PetState state)
-    {
-        var name = state.ToString();
-        return Regex.Replace(name, "(^[A-Z])", m => m.Value.ToLowerInvariant());
-    }
-}

+ 0 - 11
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml

@@ -1,11 +0,0 @@
-<UserControl x:Class="XafPet.Desktop.PetEngine.WebViewHost"
-             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-             xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
-             Background="Transparent">
-    <Grid>
-        <wv2:WebView2 x:Name="PetWebView"
-                       DefaultBackgroundColor="Transparent"
-                       HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
-    </Grid>
-</UserControl>

+ 0 - 77
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs

@@ -1,77 +0,0 @@
-using System;
-using System.IO;
-using System.Threading.Tasks;
-using System.Windows.Controls;
-using Microsoft.Web.WebView2.Core;
-
-namespace XafPet.Desktop.PetEngine;
-
-public partial class WebViewHost : UserControl
-{
-    public event Action<string, string?>? InteractionReceived;
-
-    public WebViewHost()
-    {
-        InitializeComponent();
-        Loaded += async (_, _) => await InitializeWebViewAsync();
-    }
-
-    private async Task InitializeWebViewAsync()
-    {
-        var env = await CoreWebView2Environment.CreateAsync(
-            userDataFolder: Path.Combine(
-                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
-                "XafPet", "WebView2"));
-        await PetWebView.EnsureCoreWebView2Async(env);
-
-        PetWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
-        PetWebView.CoreWebView2.Settings.AreDevToolsEnabled = true;
-        PetWebView.CoreWebView2.Settings.IsStatusBarEnabled = false;
-
-        PetWebView.CoreWebView2.AddHostObjectToScript("cfcHost", new CfcHostBridge(this));
-
-        var htmlPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Assets", "pet.html"));
-        PetWebView.CoreWebView2.Navigate($"file:///{htmlPath.Replace('\\', '/')}");
-    }
-
-    /// <summary>C# → JS:推送宠物状态</summary>
-    public void NotifyState(string stateJson)
-    {
-        PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.notifyState({stateJson})");
-    }
-
-    /// <summary>C# → JS:推送自定义状态</summary>
-    public void SetCustomState(string stateName, string dataJson)
-    {
-        PetWebView.ExecuteScriptAsync(
-            $"window.cfcPetAPI?.setCustomState('{stateName}', {dataJson})");
-    }
-
-    /// <summary>C# → JS:显示气泡</summary>
-    public void ShowBubble(string text, int durationMs = 5000)
-    {
-        var escaped = text.Replace("\\", "\\\\").Replace("'", "\\'");
-        PetWebView.ExecuteScriptAsync(
-            $"window.cfcPetAPI?.showBubble('{escaped}', {durationMs})");
-    }
-
-    /// <summary>C# → JS:加载配置</summary>
-    public void LoadPetConfig(string configJson)
-    {
-        PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.loadConfig({configJson})");
-    }
-
-    internal void RaiseInteraction(string action, string? dataJson)
-        => InteractionReceived?.Invoke(action, dataJson);
-}
-
-/// <summary>暴露给 JS 的 C# 宿主对象(window.chrome.webview.hostObjects.sync.cfcHost)</summary>
-internal sealed class CfcHostBridge
-{
-    private readonly WebViewHost _host;
-    public CfcHostBridge(WebViewHost host) => _host = host;
-    public void onInteract(string action, string dataJson)
-        => _host.RaiseInteraction(action, dataJson);
-    public void onDragStart() { }
-    public void onDragEnd() { }
-}

+ 0 - 20
cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml

@@ -1,20 +0,0 @@
-<Window x:Class="XafPet.Desktop.SettingsWindow"
-        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-        Title="浠艾福桌面宠物 - 设置"
-        Width="360" Height="340"
-        WindowStartupLocation="CenterScreen">
-    <StackPanel Margin="20">
-        <TextBlock Text="后端地址" FontWeight="SemiBold" Margin="0,0,0,6"/>
-        <TextBox x:Name="BaseUrlBox" Text="http://localhost:9082" Padding="6"/>
-
-        <TextBlock Text="JWT Token" FontWeight="SemiBold" Margin="0,16,0,6"/>
-        <PasswordBox x:Name="TokenBox" Padding="6"/>
-
-        <TextBlock Text="宠物形象" FontWeight="SemiBold" Margin="0,16,0,6"/>
-        <ComboBox x:Name="PetCombo" Padding="6" MinWidth="120"/>
-
-        <Button Content="保存" Click="OnSaveClick" Margin="0,24,0,0" Padding="10,6"
-                HorizontalAlignment="Right" Width="100"/>
-    </StackPanel>
-</Window>

+ 0 - 44
cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs

@@ -1,44 +0,0 @@
-using System.IO;
-using System.Text.Json;
-using System.Windows;
-using XafPet.Desktop.PetEngine;
-
-namespace XafPet.Desktop;
-
-public partial class SettingsWindow : Window
-{
-    public SettingsWindow(string? currentToken, string currentBaseUrl)
-    {
-        InitializeComponent();
-        BaseUrlBox.Text = currentBaseUrl;
-        if (!string.IsNullOrEmpty(currentToken)) TokenBox.Password = currentToken;
-        LoadPets();
-    }
-
-    private void LoadPets()
-    {
-        var dirs = new List<string>();
-        var builtin = Path.Combine(AppContext.BaseDirectory, "DefaultPets");
-        if (Directory.Exists(builtin)) dirs.AddRange(Directory.GetDirectories(builtin));
-        var userDir = Path.Combine(
-            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
-            "XafPet", "pets");
-        if (Directory.Exists(userDir)) dirs.AddRange(Directory.GetDirectories(userDir));
-
-        foreach (var d in dirs)
-        {
-            if (File.Exists(Path.Combine(d, "pet-config.json")))
-                PetCombo.Items.Add(Path.GetFileName(d));
-        }
-        if (PetCombo.Items.Count > 0) PetCombo.SelectedIndex = 0;
-    }
-
-    public string BaseUrl => BaseUrlBox.Text.Trim();
-    public string? Token => TokenBox.Password.Length > 0 ? TokenBox.Password : null;
-
-    private void OnSaveClick(object sender, RoutedEventArgs e)
-    {
-        DialogResult = true;
-        Close();
-    }
-}

+ 0 - 23
cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs

@@ -1,23 +0,0 @@
-using System.IO;
-using System.Runtime.InteropServices;
-
-namespace XafPet.Desktop;
-
-/// <summary>简单的单实例检测(按进程名匹配)</summary>
-public static class SingleInstanceHelper
-{
-    [DllImport("user32.dll")]
-    private static extern bool SetForegroundWindow(IntPtr hWnd);
-
-    public static bool TryEnterFirstInstance()
-    {
-        var name = System.Diagnostics.Process.GetCurrentProcess().ProcessName;
-        var instances = System.Diagnostics.Process.GetProcessesByName(name);
-        if (instances.Length > 1)
-        {
-            SetForegroundWindow(instances[0].MainWindowHandle);
-            return false;
-        }
-        return true;
-    }
-}

+ 0 - 43
cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs

@@ -1,43 +0,0 @@
-using System.IO;
-using System.Security.Cryptography;
-using System.Text;
-
-namespace XafPet.Desktop;
-
-public static class TokenStore
-{
-    private static string _path = Path.Combine(
-        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
-        "XafPet", "token.bin");
-
-    public static void SetPath(string path) => _path = path;
-
-    public static void Save(string token)
-    {
-        Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
-        var encrypted = ProtectedData.Protect(
-            Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser);
-        File.WriteAllBytes(_path, encrypted);
-    }
-
-    public static string? Load()
-    {
-        if (!File.Exists(_path)) return null;
-        try
-        {
-            var encrypted = File.ReadAllBytes(_path);
-            var decrypted = ProtectedData.Unprotect(
-                encrypted, null, DataProtectionScope.CurrentUser);
-            return Encoding.UTF8.GetString(decrypted);
-        }
-        catch (CryptographicException)
-        {
-            return null;
-        }
-    }
-
-    public static void Clear()
-    {
-        if (File.Exists(_path)) File.Delete(_path);
-    }
-}

+ 0 - 28
cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj

@@ -1,28 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-  <PropertyGroup>
-    <OutputType>WinExe</OutputType>
-    <TargetFramework>net6.0-windows</TargetFramework>
-    <Nullable>enable</Nullable>
-    <UseWPF>true</UseWPF>
-    <ImplicitUsings>enable</ImplicitUsings>
-    <AssemblyName>XafPet.Desktop</AssemblyName>
-    <RootNamespace>XafPet.Desktop</RootNamespace>
-  </PropertyGroup>
-  <ItemGroup>
-    <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2210.55" />
-  </ItemGroup>
-  <ItemGroup>
-    <FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
-  </ItemGroup>
-  <ItemGroup>
-    <None Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
-    <None Include="DefaultPets\**" CopyToOutputDirectory="PreserveNewest" />
-  </ItemGroup>
-  <!-- 发布配置(单文件 exe,Self-Contained) -->
-  <PropertyGroup>
-    <PublishSingleFile>true</PublishSingleFile>
-    <SelfContained>true</SelfContained>
-    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
-    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
-  </PropertyGroup>
-</Project>

+ 106 - 0
cfc-desktop-pet/src/animation-gen.js

@@ -0,0 +1,106 @@
+// 程序化动作帧生成器(provider 无关,本地 canvas 变换,无需 API Key)。
+// 依据基础形象为每个动作生成 8-16 帧序列(PNG data URL),供 pet-engine.js 的 playClip 播放。
+// 数据源必须是 data: URL 或同源资源;若为跨域远程图,canvas 会被污染导致 toDataURL 抛错,
+// 此时 Promise reject,前端静默回退 CSS 动画。
+(function () {
+  'use strict';
+
+  var MAX_DIM = 256;
+
+  // 每个状态 → 一段随时间 t∈[0,1) 的仿射变换。
+  // dx/dy: 相对宽/高的位移比例;rot: 弧度;sx/sy: 缩放。
+  var PRESETS = {
+    idle:                  { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s, sx: 1 - 0.010 * s, sy: 1 + 0.020 * s }; } },
+    happy:                 { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.030 * Math.sin(2 * Math.PI * t) }; } },
+    excited:               { frames: 14, fps: 16, loop: true,  tf: function (t) { return { dy: -0.080 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(2 * Math.PI * t) }; } },
+    celebrate:             { frames: 16, fps: 16, loop: true,  tf: function (t) { return { dy: -0.100 * Math.abs(Math.sin(Math.PI * t)), rot: 0.080 * Math.sin(2 * Math.PI * t) }; } },
+    busy:                  { frames: 8,  fps: 16, loop: true,  tf: function (t) { return { dx: 0.012 * Math.sin(4 * Math.PI * t), rot: 0.020 * Math.sin(4 * Math.PI * t) }; } },
+    miniGame:              { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(4 * Math.PI * t) }; } },
+    waitingForInteraction: { frames: 14, fps: 10, loop: true,  tf: function (t) { return { rot: 0.050 * Math.sin(2 * Math.PI * t), dx: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    remind:                { frames: 10, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.060 * Math.sin(4 * Math.PI * t) }; } },
+    sleeping:              { frames: 16, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.040, sy: 1 + 0.025 * Math.sin(2 * Math.PI * t), dy: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    worried:               { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    sad:                   { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    tired:                 { frames: 12, fps: 5,  loop: true,  tf: function (t) { return { dy: 0.020, rot: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    energetic:             { frames: 14, fps: 18, loop: true,  tf: function (t) { return { dy: -0.070 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.040 * Math.sin(2 * Math.PI * t) }; } },
+    curious:               { frames: 12, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.070 * Math.sin(2 * Math.PI * t), dy: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    watching:              { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.030 * Math.sin(2 * Math.PI * t), dy: -0.010 * Math.abs(Math.sin(2 * Math.PI * t)) }; } },
+    custom:                { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s }; } }
+  };
+
+  var KNOWN_STATES = ['idle', 'happy', 'busy', 'worried', 'excited', 'sad', 'tired',
+    'remind', 'energetic', 'curious', 'watching', 'waitingForInteraction', 'miniGame',
+    'sleeping', 'celebrate', 'custom'];
+
+  function presetFor(state) { return PRESETS[state] || PRESETS.idle; }
+
+  function loadImage(src) {
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      img.onload = function () { resolve(img); };
+      img.onerror = function () { reject(new Error('image load failed')); };
+      img.src = src;
+    });
+  }
+
+  function makeClip(img, preset) {
+    var iw = img.naturalWidth || img.width || 120;
+    var ih = img.naturalHeight || img.height || 140;
+    var scale = Math.min(1, MAX_DIM / Math.max(iw, ih));
+    var cw = Math.max(1, Math.round(iw * scale));
+    var ch = Math.max(1, Math.round(ih * scale));
+    var canvas = document.createElement('canvas');
+    canvas.width = cw;
+    canvas.height = ch;
+    var ctx = canvas.getContext('2d');
+    var frames = [];
+    for (var i = 0; i < preset.frames; i++) {
+      var tr = preset.tf(i / preset.frames) || {};
+      ctx.clearRect(0, 0, cw, ch);
+      ctx.save();
+      ctx.translate(cw / 2 + (tr.dx || 0) * cw, ch / 2 + (tr.dy || 0) * ch);
+      if (tr.rot) ctx.rotate(tr.rot);
+      ctx.scale(tr.sx || 1, tr.sy || 1);
+      ctx.drawImage(img, -cw / 2, -ch / 2, cw, ch);
+      ctx.restore();
+      frames.push(canvas.toDataURL('image/png'));
+    }
+    return {
+      frames: frames,
+      sprite: null,
+      frame_width: cw,
+      frame_height: ch,
+      count: frames.length,
+      fps: preset.fps,
+      loop: preset.loop
+    };
+  }
+
+  // 依据 config.actions(状态→动作名)为每个动作生成剪辑,按动作名为键。
+  // 返回 Promise<{ [actionName]: ActionClip }>;canvas 被污染时 reject。
+  function generateForConfig(config, src) {
+    var actions = (config && config.actions) || {};
+    var map = {}; // actionName -> state
+    KNOWN_STATES.forEach(function (s) {
+      var a = actions[s] || s;
+      if (!(a in map)) map[a] = s;
+    });
+    Object.keys(actions).forEach(function (s) {
+      var a = actions[s];
+      if (a && !(a in map)) map[a] = s;
+    });
+
+    return loadImage(src).then(function (img) {
+      var cache = {}; // state -> clip
+      var out = {};
+      Object.keys(map).forEach(function (actionName) {
+        var state = map[actionName];
+        var clip = cache[state] || (cache[state] = makeClip(img, presetFor(state)));
+        out[actionName] = clip;
+      });
+      return out;
+    });
+  }
+
+  window.cfcPetGen = { generateForConfig: generateForConfig, presets: PRESETS };
+})();

+ 53 - 0
cfc-desktop-pet/src/animations.css

@@ -22,6 +22,59 @@
 @keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } }
 @keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
 
+/* ===== 精细化动作:姿态层(#pet-body)=====
+   由 locomotion.rs 的 pet://gait 事件驱动;与 #pet 上的情绪动画(.anim-*)分层叠加。
+   朝向:--face 由 .pose-face-left 切换(1=右,-1=左),关键帧统一 scaleX(var(--face,1))。 */
+
+/* 走:上下颠簸 + 左右重心摆 + 前后微倾,模拟迈步 */
+@keyframes walk_bob {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(-3.5deg) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-1px) rotate(0deg) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(3.5deg) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+}
+
+/* 跳着走:下蹲预备 → 起跳拉伸 → 滞空 → 落地挤压缓冲 */
+@keyframes hop_squash {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+  18%  { transform: scaleX(var(--face,1)) translateY(2px)   scale(1.09, 0.9) }
+  45%  { transform: scaleX(var(--face,1)) translateY(-15px) scale(0.9, 1.15) }
+  62%  { transform: scaleX(var(--face,1)) translateY(-11px) scale(0.95, 1.05) }
+  82%  { transform: scaleX(var(--face,1)) translateY(0)     scale(1.07, 0.91) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+}
+
+/* 飞:高频振翅(垂直挤压振荡)+ 抬升 + 侧倾 */
+@keyframes fly_flutter {
+  0%   { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-11px) rotate(5deg)  scale(0.93, 1.09) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-4px)  rotate(-6deg) scale(1.07, 0.93) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-13px) rotate(7deg)  scale(0.91, 1.11) }
+  100% { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+}
+
+/* 站手:随手指轻晃 + 微起伏 */
+@keyframes perch_lean {
+  0%   { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+  50%  { transform: scaleX(var(--face,1)) rotate(2.5deg)  translateY(-2px) }
+  100% { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+}
+
+/* 待机:极轻微呼吸起伏(无位移,避免与情绪动画冲突) */
+@keyframes idle_breathe {
+  0%   { transform: scaleX(var(--face,1)) scale(1, 1) }
+  50%  { transform: scaleX(var(--face,1)) scale(1.02, 0.985) }
+  100% { transform: scaleX(var(--face,1)) scale(1, 1) }
+}
+
+.pose-walk { animation: walk_bob 0.75s ease-in-out infinite; }
+.pose-hop { animation: hop_squash 0.62s ease-in-out infinite; }
+.pose-fly { animation: fly_flutter 0.3s ease-in-out infinite; }
+.pose-perch { animation: perch_lean 1.8s ease-in-out infinite; }
+.pose-idle { animation: idle_breathe 3.2s ease-in-out infinite; }
+.pose-face-left { --face: -1; }
+
 .anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
 .anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }
 .anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; }

+ 28 - 3
cfc-desktop-pet/src/bridge.js

@@ -14,15 +14,34 @@
   listen('pet://config', function (e) {
     if (window.cfcPetAPI) window.cfcPetAPI.loadConfig(e.payload);
   });
+  function applyCustomImage(url, dataUrl) {
+    if (!window.cfcPetAPI) return;
+    if (!dataUrl && url && /^https?:/i.test(url)) {
+      // 远程图片:先取 data URL(规避 canvas 跨域污染)再生成动作;失败则仍显示原图
+      try {
+        invoke('data_url_from_url', { url: url }).then(function (d) {
+          window.cfcPetAPI.setCustomImage(url, d);
+        }).catch(function () {
+          window.cfcPetAPI.setCustomImage(url, null);
+        });
+      } catch (_) {
+        window.cfcPetAPI.setCustomImage(url, null);
+      }
+    } else {
+      window.cfcPetAPI.setCustomImage(url, dataUrl);
+    }
+  }
+
   listen('pet://custom-image', function (e) {
-    if (window.cfcPetAPI) window.cfcPetAPI.setCustomImage(e.payload && e.payload.url);
+    applyCustomImage(e.payload && e.payload.url, e.payload && e.payload.dataUrl);
   });
 
   // 启动时应用已保存的自定义形象
   try {
     invoke('get_settings').then(function (s) {
-      if (s && s.customMascotUrl && window.cfcPetAPI) {
-        window.cfcPetAPI.setCustomImage(s.customMascotUrl);
+      window.__petHasToken = !!(s && s.hasToken);
+      if (s && s.customMascotUrl) {
+        applyCustomImage(s.customMascotUrl, null);
       }
     }).catch(function () {});
   } catch (_) { /* noop */ }
@@ -41,6 +60,12 @@
     },
     onDragEnd: function () {
       try { invoke('drag_end'); } catch (e) { /* noop */ }
+    },
+    startPerch: function () {
+      try { invoke('start_perch'); } catch (e) { /* noop */ }
+    },
+    stopPerch: function () {
+      try { invoke('stop_perch'); } catch (e) { /* noop */ }
     }
   };
 })();

+ 19 - 3
cfc-desktop-pet/src/index.html

@@ -16,11 +16,22 @@
     background: transparent !important;
   }
   #pet {
-    width: 120px; height: 140px; pointer-events: auto;
-    cursor: grab; user-select: none;
+    position: relative; width: 120px; height: 140px; pointer-events: auto;
+    cursor: pointer; user-select: none;
     -webkit-app-region: no-drag;
   }
+  #pet-body {
+    position: relative; width: 100%; height: 100%;
+    transform-origin: 50% 100%;
+    cursor: pointer; --face: 1;
+  }
   #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
+  /* 胸部热区:悬停 5s → 站手跟随(纯 hover,无需按键) */
+  #chest {
+    position: absolute; left: 50%; top: 16%; width: 55%; height: 42%;
+    transform: translateX(-50%);
+    pointer-events: auto; cursor: pointer; background: transparent;
+  }
   #bubble {
     position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
     background: rgba(255,255,255,0.95); border: 2px solid #4A9BD7;
@@ -51,7 +62,10 @@
 <body>
 <div id="pet-stage">
   <div id="pet">
-    <img id="pet-img" src="" alt="pet" draggable="false" />
+    <div id="pet-body">
+      <img id="pet-img" src="" alt="pet" draggable="false" />
+    </div>
+    <div id="chest"></div>
     <div id="bubble"></div>
   </div>
 </div>
@@ -62,7 +76,9 @@
   <div class="divider"></div>
   <div class="item" data-action="quit">退出</div>
 </div>
+<script src="animation-gen.js"></script>
 <script src="pet-engine.js"></script>
+<script src="pet-pose.js"></script>
 <script src="bridge.js"></script>
 <script src="interactions.js"></script>
 </body>

+ 40 - 2
cfc-desktop-pet/src/interactions.js

@@ -2,12 +2,15 @@
   'use strict';
 
   const petEl = document.getElementById('pet');
+  const chest = document.getElementById('chest');
   const ctxMenu = document.getElementById('context-menu');
   const invoke = window.__TAURI__?.core?.invoke;
 
   let dragStartX = 0, dragStartY = 0;
   let pressTimer = null;
   let isDragging = false;
+  const CHEST_HOLD_MS = 5000;
+  let chestTimer = null;
 
   // ── 右键菜单 ──────────────────────────────
   petEl.addEventListener('contextmenu', function (e) {
@@ -37,11 +40,13 @@ ctxMenu.addEventListener('click', function (e) {
 
   // ── 点击 / 长按 / 拖拽 ───────────────────
   petEl.addEventListener('mousedown', function (e) {
+    if (window.__petPerched) return; // 站手跟随中:窗口由 Rust 驱动,禁用原生拖拽
     dragStartX = e.clientX;
     dragStartY = e.clientY;
     isDragging = false;
     pressTimer = setTimeout(() => {
-      notifyInteract('pet', {});
+      localReact('pet');
+      if (backendEnabled()) notifyInteract('pet', {});
     }, 1500);
   });
 
@@ -58,12 +63,45 @@ ctxMenu.addEventListener('click', function (e) {
   petEl.addEventListener('mouseup', function (e) {
     clearTimeout(pressTimer);
     if (!isDragging) {
-      notifyInteract('click', {});
+      localReact('click');
+      // 未登录 / 离线时不打后端,避免只剩「打卡失败」噪音,仅保留本地互动反馈。
+      if (backendEnabled()) notifyInteract('click', {});
     } else {
       notifyDragEnd();
     }
   });
 
+  // ── 胸部悬停 5s → 站手跟随 ────────────────
+  if (chest) {
+    chest.addEventListener('mouseenter', function () {
+      if (window.__petPerched) return;
+      clearChestTimer();
+      chestTimer = setTimeout(function () {
+        chestTimer = null;
+        if (window.__petPerched) return;
+        window.__petPerched = true; // 乐观置位;Rust 会回发 pet://perch 确认
+        try { window.cfcHost.startPerch(); } catch (_) { /* noop */ }
+        try { window.cfcPetAPI.showBubble('飞到你手上~', 2000); } catch (_) { /* noop */ }
+      }, CHEST_HOLD_MS);
+    });
+    // 悬停未满 5s 就移开 → 取消;站手后窗口跟随光标会使指针移出胸部,此处仅为清理(无害)
+    chest.addEventListener('mouseleave', clearChestTimer);
+  }
+
+  function clearChestTimer() {
+    if (chestTimer) { clearTimeout(chestTimer); chestTimer = null; }
+  }
+
+  // 本地即时反馈:不依赖后端也能看到宠物对点击的响应。
+  function localReact(kind) {
+    try { if (window.cfcPetAPI && window.cfcPetAPI.react) window.cfcPetAPI.react(kind); } catch (_) { /* noop */ }
+  }
+
+  // 是否已连后端(有令牌)。默认允许,bridge 拉到设置后按 hasToken 修正。
+  function backendEnabled() {
+    return window.__petHasToken !== false;
+  }
+
   function notifyInteract(action, data) {
     try {
       window.cfcHost.onInteract(action, JSON.stringify(data));

+ 170 - 13
cfc-desktop-pet/src/pet-engine.js

@@ -10,12 +10,43 @@
   let config = null;
   let lastConfig = null;
   let customImage = null;
+  let customDataUrl = null;
+  let runtimeAnimations = {};
+  let animSig = null;
+  let animPending = false;
   let bubbleTimer = null;
+  let frameTimer = null;
+  let frameIdx = 0;
 
   const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
     encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
       '<text x="60" y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
 
+  // 离线兜底配置:未连后端(未登录 / 服务不可达)时也要有「状态→动作」映射,
+  // 否则 setState 会退化成 .anim-<state> 而动画样式并不存在 → 宠物静止。
+  const FALLBACK_CONFIG = {
+    image_base_url: 'DefaultPets/xibao/',
+    default_image: 'standing.png',
+    actions: {
+      idle: 'wings_idle',
+      happy: 'wings_spread',
+      busy: 'head_tilt',
+      waitingForInteraction: 'hand_gesture',
+      sleeping: 'breath_loop',
+      celebrate: 'celebrate',
+      sad: 'sad_animation'
+    }
+  };
+
+  // 服务端状态超过该时长未到达即视为离线,进入「自主活动」模式。
+  const SERVER_TRUST_MS = 90000;
+  const LIFE_TICK_MS = 2800;
+  const LIFE_CYCLE = ['idle', 'idle', 'busy', 'idle', 'happy', 'idle',
+    'waitingForInteraction', 'idle', 'sleeping', 'idle', 'celebrate'];
+  const LIFE_BUBBLES = ['在想什么呢~', '陪我玩一会儿吧!', '今天也要加油哦~', '嗯…休息一下~', '你好呀!'];
+  let lastServerStateAt = 0;
+  let lifeIdx = 0;
+
   const BUBBLE_TEXTS = {
     happy: '完成了!继续保持~',
     busy: '有任务在等你呢~',
@@ -33,6 +64,8 @@
   window.cfcPetAPI = {
     notifyState: function (stateJson) {
       const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
+      lastServerStateAt = Date.now();
+      lifeIdx = 0;
       setState(payload.state, payload.reason || null, payload.data || null);
     },
     setCustomState: function (stateName, dataJson) {
@@ -43,15 +76,19 @@
       config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
       applyImage(config);
     },
-    setCustomImage: function (url) {
+    setCustomImage: function (url, dataUrl) {
       customImage = url || null;
+      customDataUrl = dataUrl || null;
+      stopClip();
       if (customImage) {
         petImg.src = customImage;
+        petImg.onload = function () { ensureAnimations(); };
         petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
       } else if (lastConfig) {
         applyImage(lastConfig);
       } else {
         petImg.src = DEFAULT_IMAGE;
+        ensureAnimations();
       }
     },
     showBubble: function (text, durationMs) {
@@ -61,6 +98,7 @@
       if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
     },
     hideBubble: hideBubble,
+    react: react,
     getState: function () { return currentState; }
   };
 
@@ -68,29 +106,39 @@
 
   function applyImage(cfg) {
     if (!cfg) return;
+    stopClip();
     lastConfig = cfg;
     const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
-    if (!src) return;
+    if (!src) { petImg.src = DEFAULT_IMAGE; ensureAnimations(); return; }
     petImg.src = src;
-    petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
+    petImg.onload = function () { ensureAnimations(); };
+    petImg.onerror = function () {
+      petImg.src = DEFAULT_IMAGE;
+      petImg.onload = function () { ensureAnimations(); };
+    };
   }
 
   function setState(stateName, reason, data) {
     currentState = stateName;
     if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
 
-    let animClass = null;
-    if (config && config.actions && config.actions[stateName]) {
-      animClass = 'anim-' + config.actions[stateName];
+    const actionName = (config && config.actions && config.actions[stateName]) || stateName;
+    const clipCfg = (config && config.animations && config.animations[actionName])
+      || runtimeAnimations[actionName]
+      || null;
+
+    if (clipCfg && clipCfg.frames && clipCfg.frames.length) {
+      playClip(clipCfg);
     } else {
-      animClass = 'anim-' + stateName;
+      stopClip();
+      applyImage(lastConfig);
+      const animClass = 'anim-' + actionName;
+      const styleEl = document.getElementById('pet-animations');
+      const sheet = styleEl ? styleEl.sheet : null;
+      const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
+      if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
     }
 
-    const styleEl = document.getElementById('pet-animations');
-    const sheet = styleEl ? styleEl.sheet : null;
-    const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
-    if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
-
     if (reason === 'drink_water') {
       bubble.textContent = '该喝水啦~';
       bubble.style.display = 'block';
@@ -101,7 +149,7 @@
       bubble.style.display = 'block';
       clearTimeout(bubbleTimer);
       bubbleTimer = setTimeout(hideBubble, 3000);
-    } else if (BUBBLE_TEXTS[stateName]) {
+    } else if (reason !== 'life' && BUBBLE_TEXTS[stateName]) {
       bubble.textContent = BUBBLE_TEXTS[stateName];
       bubble.style.display = 'block';
       clearTimeout(bubbleTimer);
@@ -109,7 +157,116 @@
     }
   }
 
+  function baseSrc() {
+    if (customImage) return customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  // 生成动作帧所用的基础形象:优先用 data URL(避免 canvas 跨域污染)。
+  function generationSrc() {
+    if (customImage) return customDataUrl || customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  // 本地程序化生成动作帧(provider 无关、无需 API Key)。
+  // 成功后重新应用当前状态,使生成的动作立即生效。
+  // 以「基础形象 + 动作映射」为签名,同一形象下不重复生成。
+  function ensureAnimations() {
+    if (!window.cfcPetGen) return;
+    const src = generationSrc();
+    if (!src) return;
+    const cfg = lastConfig || { actions: {} };
+    const sig = src + '|' + JSON.stringify(cfg.actions || {});
+    if (animSig === sig && Object.keys(runtimeAnimations).length) return;
+    if (animPending) return;
+    animPending = true;
+    window.cfcPetGen.generateForConfig(cfg, src).then(function (clips) {
+      runtimeAnimations = clips || {};
+      animSig = sig;
+      animPending = false;
+      setState(currentState, null, null);
+    }).catch(function () {
+      animPending = false;
+      // 配置形象缺失(如默认图不存在)时,退回内置形象再生成一次,保证离线也有帧动画。
+      if (src !== DEFAULT_IMAGE) {
+        animPending = true;
+        window.cfcPetGen.generateForConfig(cfg, DEFAULT_IMAGE).then(function (clips) {
+          runtimeAnimations = clips || {};
+          animSig = sig + '|fallback';
+          animPending = false;
+          setState(currentState, null, null);
+        }).catch(function () { animPending = false; });
+      }
+    });
+  }
+
+  function stopClip() {
+    if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
+  }
+
+  // 播放帧序列剪辑(provider 无关)。loop:false 播完回到基础形象。
+  function playClip(clip) {
+    stopClip();
+    const base = (lastConfig && lastConfig.image_base_url) || '';
+    const frames = clip.frames.map(function (f) {
+      return /^(https?:|data:|\/)/.test(f) ? f : base + f;
+    });
+    const fps = clip.fps > 0 ? clip.fps : 12;
+    frameIdx = 0;
+    petImg.onerror = null;
+    petImg.src = frames[0];
+    frameTimer = setInterval(function () {
+      frameIdx++;
+      if (frameIdx >= frames.length) {
+        if (clip.loop === false) {
+          stopClip();
+          petImg.src = baseSrc();
+          return;
+        }
+        frameIdx = 0;
+      }
+      petImg.src = frames[frameIdx];
+    }, 1000 / fps);
+  }
+
+  // 本地即时互动反馈(不依赖后端,也不改变当前状态):点击/抚摸时立即给出动作。
+  function react(kind) {
+    const cls = kind === 'pet' ? 'anim-clap' : 'anim-pet_touch';
+    const ms = kind === 'pet' ? 1900 : 700;
+    petEl.classList.remove(cls);
+    void petEl.offsetWidth; // 强制回流,确保动画可重播
+    petEl.classList.add(cls);
+    setTimeout(function () { petEl.classList.remove(cls); }, ms);
+    if (kind === 'pet') {
+      bubble.textContent = '好痒~嘻嘻!';
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 2000);
+    }
+  }
+
+  // 自主活动循环:服务端状态长时间未到达(离线 / 未登录)时,按节奏切换轻量动作,
+  // 让宠物始终保持「活着」的互动感;服务端状态恢复后自动让位。
+  setInterval(function () {
+    if (Date.now() - lastServerStateAt <= SERVER_TRUST_MS) { lifeIdx = 0; return; }
+    lifeIdx = (lifeIdx + 1) % LIFE_CYCLE.length;
+    const st = LIFE_CYCLE[lifeIdx];
+    setState(st, 'life', null);
+    if (st !== 'idle' && Math.random() < 0.5) {
+      bubble.textContent = LIFE_BUBBLES[Math.floor(Math.random() * LIFE_BUBBLES.length)];
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 3000);
+    }
+  }, LIFE_TICK_MS);
+
+  // 启动即以兜底配置呈现,确保离线也具备「状态→动作」映射;服务端配置到达后覆盖。
+  config = FALLBACK_CONFIG;
+  lastConfig = FALLBACK_CONFIG;
   petImg.src = DEFAULT_IMAGE;
+  petImg.onload = function () { ensureAnimations(); };
 
   setInterval(function () {
     if (document.getElementById('pet-img')) {

+ 43 - 0
cfc-desktop-pet/src/pet-pose.js

@@ -0,0 +1,43 @@
+// 姿态渲染层:监听 locomotion.rs 的 pet://gait / pet://perch,把动作映射到 #pet-body 上的 .pose-* 类。
+// 与 #pet 上的情绪动画(pet-engine.js 管理 .anim-*)分层叠加,互不覆盖。
+(function () {
+  'use strict';
+
+  var petBody = document.getElementById('pet-body');
+  if (!petBody) return;
+
+  var POSES = { idle: 'pose-idle', walk: 'pose-walk', hop: 'pose-hop', fly: 'pose-fly', perch: 'pose-perch' };
+  var currentPose = null;
+  var lastDir = 'right';
+
+  function apply(kind, direction) {
+    if (direction === 'left' || direction === 'right') lastDir = direction;
+    var cls = POSES[kind] || 'pose-idle'; // 未知 kind / idle → 呼吸待机
+    if (currentPose !== cls) {
+      if (currentPose) petBody.classList.remove(currentPose);
+      currentPose = cls;
+      void petBody.offsetWidth; // 强制回流,确保动画可重播
+      petBody.classList.add(cls);
+    }
+    // 朝向:left → 水平镜像(fly/perch 的 direction 为 none,沿用上次朝向)
+    petBody.classList.toggle('pose-face-left', lastDir === 'left');
+  }
+
+  // 初始即进入呼吸待机(Rust 侧初始 gait 不会 emit,故此处兜底)
+  apply('idle');
+
+  try {
+    var listen = window.__TAURI__.event.listen;
+    listen('pet://gait', function (e) {
+      var p = (e && e.payload) || {};
+      apply(p.kind, p.direction);
+    });
+    listen('pet://perch', function (e) {
+      window.__petPerched = !!(e.payload && e.payload.active);
+      // 站手瞬间 gait 事件已带 perch;此处兜底确保姿态出现
+      if (window.__petPerched) apply('perch');
+    });
+  } catch (err) {
+    console.log('[pet-pose] tauri events unavailable', err);
+  }
+})();

+ 26 - 10
cfc-desktop-pet/src/settings.html

@@ -17,13 +17,12 @@
                     border: 1px dashed #ccc; border-radius: 8px; display: none; margin-bottom: 8px; }
   hr { border: none; border-top: 1px solid #eee; margin: 14px 0; }
   #status { margin-top: 10px; font-size: 12px; color: #999; }
+  #wechat-qr { display: none; width: 180px; height: 180px; margin: 8px auto 0; }
 </style>
 </head>
 <body>
   <label for="base-url">后端地址</label>
   <input id="base-url" placeholder="https://cfc.cf-club.com" />
-  <label for="token">令牌(留空则不修改)</label>
-  <input id="token" type="password" placeholder="JWT Token" />
   <button id="save">保存</button>
 
   <hr />
@@ -39,7 +38,8 @@
 
   <label>微信登录</label>
   <button id="wechat-login">微信扫码登录</button>
-  <div class="hint">用微信扫码,自动与小程序账号同步登录(无需手动填令牌)</div>
+  <div class="hint">点击后出现小程序码,用微信扫码并在小程序内确认,即可自动与小程序账号同步登录(无需手动填令牌)</div>
+  <img id="wechat-qr" alt="小程序码" />
 
   <div id="status"></div>
   <script>
@@ -47,7 +47,6 @@
       'use strict';
       var invoke = window.__TAURI__.core.invoke;
       var baseUrlEl = document.getElementById('base-url');
-      var tokenEl = document.getElementById('token');
       var statusEl = document.getElementById('status');
       var fileEl = document.getElementById('mascot-file');
       var previewEl = document.getElementById('mascot-preview');
@@ -62,13 +61,12 @@
           previewEl.src = s.customMascotUrl;
           previewEl.style.display = 'block';
         }
-        setStatus(s.hasToken ? '已保存令牌' : '未设置令牌');
+        setStatus('');
       }).catch(function () {});
 
       document.getElementById('save').addEventListener('click', function () {
         var baseUrl = baseUrlEl.value.trim();
-        var token = tokenEl.value.trim();
-        invoke('save_settings', { baseUrl: baseUrl, token: token ? token : null })
+        invoke('save_settings', { baseUrl: baseUrl })
           .then(function () {
             setStatus('已保存,轮询已重启');
             setTimeout(function () { setStatus(''); }, 2500);
@@ -130,27 +128,45 @@
       });
 
       document.getElementById('wechat-login').addEventListener('click', function () {
-        setStatus('正在打开微信登录…');
+        setStatus('正在获取小程序码…');
         invoke('start_wechat_login').then(function () {
-          setStatus('请用微信扫码,完成后自动登录');
+          setStatus('请用微信扫码并在小程序内确认');
         }).catch(function (e) { setStatus('发起登录失败:' + e); });
       });
 
+      // Rust 获取到小程序码后由 emit 推送
+      try {
+        window.__TAURI__.event.listen('pet://wechat-qr', function (ev) {
+          var p = (ev && ev.payload) || {};
+          var img = document.getElementById('wechat-qr');
+          if (p.qrImage) {
+            img.src = p.qrImage;
+            img.style.display = 'block';
+            setStatus('请用微信扫码并在小程序内确认');
+          } else {
+            img.style.display = 'none';
+            setStatus('(测试模式)已生成登录状态:请在已登录的小程序内访问扫码登录接口确认');
+          }
+        });
+      } catch (e) { /* event API 不可用时忽略 */ }
+
       // 后台轮询任务完成后由 Rust 侧 emit
       try {
         window.__TAURI__.event.listen('pet://wechat-login', function (ev) {
           var p = (ev && ev.payload) || {};
           if (p.status === 'success') {
             setStatus('登录成功' + (p.nickname ? ':' + p.nickname : ''));
-            tokenEl.value = '';
+            document.getElementById('wechat-qr').style.display = 'none';
           } else if (p.status === 'pending') {
             setStatus('等待扫码…');
           } else if (p.status === 'timeout') {
             setStatus('登录超时,请重试');
+            document.getElementById('wechat-qr').style.display = 'none';
           } else if (p.status === 'notfound') {
             setStatus('该微信未绑定小程序账号');
           } else if (p.status === 'expired') {
             setStatus('二维码已过期,请重试');
+            document.getElementById('wechat-qr').style.display = 'none';
           } else {
             setStatus('登录失败:' + (p.message || p.status));
           }

+ 0 - 96
cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs

@@ -1,96 +0,0 @@
-using System.Net;
-using System.Text.Json;
-using Moq;
-using Moq.Protected;
-using XafPet.Desktop.PetEngine;
-using Xunit;
-
-namespace XafPet.Desktop.Tests;
-
-public class ApiClientTests
-{
-    private static ApiClient CreateClientWithResponse(HttpStatusCode code, string json, string authToken = "test-token")
-    {
-        var handler = new Mock<HttpMessageHandler>();
-        handler.Protected()
-            .Setup<Task<HttpResponseMessage>>("SendAsync",
-                ItExpr.IsAny<HttpRequestMessage>(),
-                ItExpr.IsAny<CancellationToken>())
-            .ReturnsAsync(new HttpResponseMessage(code)
-            {
-                Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
-            });
-        var http = new HttpClient(handler.Object);
-        return new ApiClient(http, "http://localhost:9082", authToken);
-    }
-
-    [Fact]
-    public async Task GetTodayTasks_ReturnsTaskList()
-    {
-        var json = """
-        { "code": 200, "message": "ok", "data": [
-            { "id": 1, "title": "喝水", "status": "PENDING", "dueDate": "2026-10-10" }
-        ] }
-        """;
-        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
-
-        var tasks = await client.GetTodayTasksAsync();
-
-        Assert.Single(tasks);
-        Assert.Equal("喝水", tasks[0].Title);
-        Assert.Equal("PENDING", tasks[0].Status);
-    }
-
-    [Fact]
-    public async Task GetEnergyOverview_ReturnsDimensions()
-    {
-        var json = """
-        { "code": 200, "message": "ok", "data": {
-            "dimensions": [
-              { "dimension": "身", "value": 80, "color": "#FF8C42" },
-              { "dimension": "智", "value": 45, "color": "#6366F1" }
-            ]
-        } }
-        """;
-        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
-
-        var overview = await client.GetEnergyOverviewAsync();
-
-        Assert.Equal(2, overview.Dimensions.Count);
-        Assert.Equal(80, overview.Dimensions[0].Value);
-    }
-
-    [Fact]
-    public async Task GetDailyCheckin_ReturnsStatus()
-    {
-        var json = """
-        { "code": 200, "message": "ok", "data": { "checked": true, "items": ["water", "exercise"] } }
-        """;
-        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
-
-        var status = await client.GetDailyCheckinAsync();
-
-        Assert.True(status.Checked);
-        Assert.Contains("water", status.Items);
-    }
-
-    [Fact]
-    public async Task CompleteDrinkWater_PostsCorrectEndpoint()
-    {
-        var json = """{ "code": 200, "message": "打卡成功", "data": null }""";
-        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
-
-        var result = await client.CompleteDrinkWaterAsync();
-
-        Assert.Equal("打卡成功", result.Message);
-    }
-
-    [Fact]
-    public async Task NonSuccessCode_ThrowsApiException()
-    {
-        var json = """{ "code": 500, "message": "服务器错误", "data": null }""";
-        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
-
-        await Assert.ThrowsAsync<ApiException>(() => client.GetTodayTasksAsync());
-    }
-}

+ 0 - 68
cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs

@@ -1,68 +0,0 @@
-using System.Text.Json;
-using XafPet.Desktop.PetEngine;
-using Xunit;
-
-namespace XafPet.Desktop.Tests;
-
-public class PetConfigTests
-{
-    private const string ValidJson = """
-    {
-      "version": "1.0",
-      "type": "animal",
-      "species": "cockatiel",
-      "gender": "female",
-      "image_base_url": "./DefaultPets/xibao/",
-      "default_image": "standing.png",
-      "actions": {
-        "idle": "wings_idle",
-        "happy": "wings_spread",
-        "custom_state_1": "user_animation_1"
-      },
-      "parameters": {
-        "move_distance": 15,
-        "animation_speed": 1.0,
-        "interaction_timeout": 300
-      }
-    }
-    """;
-
-    [Fact]
-    public void Parse_ValidJson_LoadsAllFields()
-    {
-        var config = PetConfig.Parse(ValidJson);
-
-        Assert.Equal("1.0", config.Version);
-        Assert.Equal(PetType.Animal, config.Type);
-        Assert.Equal("cockatiel", config.Species);
-        Assert.Equal("female", config.Gender);
-        Assert.Equal("./DefaultPets/xibao/", config.ImageBaseUrl);
-        Assert.Equal("standing.png", config.DefaultImage);
-        Assert.Equal("wings_idle", config.Actions["idle"]);
-        Assert.Equal(15, config.Parameters.MoveDistance);
-    }
-
-    [Fact]
-    public void Parse_MissingActionsKey_UsesDefaults()
-    {
-        var json = """{ "version": "1.0", "type": "human", "image_base_url": "./pets/", "default_image": "a.png" }""";
-        var config = PetConfig.Parse(json);
-
-        Assert.Equal("idle", config.Actions["idle"]);
-    }
-
-    [Fact]
-    public void Parse_InvalidJson_ThrowsJsonException()
-    {
-        Assert.Throws<JsonException>(() => PetConfig.Parse("{ not valid json"));
-    }
-
-    [Fact]
-    public void Parse_CustomStates_CountedCorrectly()
-    {
-        var config = PetConfig.Parse(ValidJson);
-
-        Assert.Equal(1, config.CustomStateCount);
-        Assert.Equal("user_animation_1", config.GetActionForState("custom_state_1"));
-    }
-}

+ 0 - 8
cfc-desktop-pet/tests/XafPet.Desktop.Tests/README.md

@@ -1,8 +0,0 @@
-```json
-{
-  "version": "1.0",
-  "type": "project",
-  "name": "XafPet.Desktop",
-  "description": "Desktop pet application scaffold for XafPet (Care Family)"
-}
-```

+ 0 - 107
cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs

@@ -1,107 +0,0 @@
-using XafPet.Desktop.PetEngine;
-using Xunit;
-
-namespace XafPet.Desktop.Tests;
-
-public class StateManagerTests
-{
-    [Fact]
-    public void AllTasksCompleted_ReturnsHappy()
-    {
-        var tasks = new List<CfcTask>
-        {
-            new() { Id = 1, Title = "喝水", Status = "COMPLETED" },
-            new() { Id = 2, Title = "运动", Status = "COMPLETED" }
-        };
-        var state = StateManager.MapState(tasks, null, null, false);
-        Assert.Equal(PetState.Happy, state);
-    }
-
-    [Fact]
-    public void SomeTasksPending_ReturnsBusy()
-    {
-        var tasks = new List<CfcTask>
-        {
-            new() { Id = 1, Title = "喝水", Status = "COMPLETED" },
-            new() { Id = 2, Title = "运动", Status = "PENDING" }
-        };
-        var state = StateManager.MapState(tasks, null, null, false);
-        Assert.Equal(PetState.Busy, state);
-    }
-
-    [Fact]
-    public void NoTasks_ReturnsIdle()
-    {
-        var state = StateManager.MapState(new List<CfcTask>(), null, null, false);
-        Assert.Equal(PetState.Idle, state);
-    }
-
-    [Fact]
-    public void CheckinDone_OverridesToExcited()
-    {
-        var state = StateManager.MapState(new List<CfcTask>(), null,
-            new DailyCheckin { Checked = true, Items = new List<string> { "water" } }, false);
-        Assert.Equal(PetState.Excited, state);
-    }
-
-    [Fact]
-    public void LowBodyEnergy_ReturnsTired()
-    {
-        var energy = new EnergyOverview
-        {
-            Dimensions = new List<DimensionValue>
-            {
-                new() { Dimension = "身", Value = 20, Color = "#FF8C42" },
-                new() { Dimension = "智", Value = 80, Color = "#6366F1" }
-            }
-        };
-        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
-        Assert.Equal(PetState.Tired, state);
-    }
-
-    [Fact]
-    public void AllDimensionsLow_ReturnsSad()
-    {
-        var energy = new EnergyOverview
-        {
-            Dimensions = new List<DimensionValue>
-            {
-                new() { Dimension = "身", Value = 15, Color = "#FF8C42" },
-                new() { Dimension = "智", Value = 20, Color = "#6366F1" },
-                new() { Dimension = "富", Value = 25, Color = "#F59E0B" }
-            }
-        };
-        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
-        Assert.Equal(PetState.Sad, state);
-    }
-
-    [Fact]
-    public void HighBodyEnergy_ReturnsEnergetic()
-    {
-        var energy = new EnergyOverview
-        {
-            Dimensions = new List<DimensionValue>
-            {
-                new() { Dimension = "身", Value = 90, Color = "#FF8C42" }
-            }
-        };
-        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
-        Assert.Equal(PetState.Energetic, state);
-    }
-
-    [Fact]
-    public void UserInactive_ReturnsSleeping()
-    {
-        var state = StateManager.MapState(new List<CfcTask>(), null, null, true);
-        Assert.Equal(PetState.Sleeping, state);
-    }
-
-    [Fact]
-    public void StateToCamelCase_ConvertsEnumName()
-    {
-        Assert.Equal("waitingForInteraction",
-            StateManager.StateToCamelCase(PetState.WaitingForInteraction));
-        Assert.Equal("miniGame", StateManager.StateToCamelCase(PetState.MiniGame));
-        Assert.Equal("idle", StateManager.StateToCamelCase(PetState.Idle));
-    }
-}

+ 0 - 17
cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj

@@ -1,17 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-  <PropertyGroup>
-    <TargetFramework>net6.0-windows</TargetFramework>
-    <Nullable>enable</Nullable>
-    <ImplicitUsings>enable</ImplicitUsings>
-    <IsPackable>false</IsPackable>
-  </PropertyGroup>
-  <ItemGroup>
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
-    <PackageReference Include="xunit" Version="2.5.3" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
-    <PackageReference Include="Moq" Version="4.20.69" />
-  </ItemGroup>
-  <ItemGroup>
-    <ProjectReference Include="..\..\src\XafPet.Desktop\XafPet.Desktop.csproj" />
-  </ItemGroup>
-</Project>

+ 201 - 0
cfc-frontend/components/DimensionVisitorCard.vue

@@ -0,0 +1,201 @@
+<template>
+  <view class="dvc">
+    <!-- 价值预览卡:维度浅底 + 维度色 CTA -->
+    <view class="dvc-card" :style="{ background: softColor }">
+      <view class="dvc-head">
+        <text class="dvc-title">{{ cardTitle }}</text>
+        <text class="dvc-sub">登录后即可解锁你的专属内容</text>
+      </view>
+
+      <view class="dvc-benefits">
+        <view class="dvc-benefit" v-for="(b, i) in benefits" :key="i">
+          <text class="dvc-dot" :style="{ background: mainColor }"></text>
+          <text class="dvc-benefit-text">{{ b }}</text>
+        </view>
+      </view>
+
+      <view class="dvc-actions">
+        <button class="dvc-btn-primary" :style="{ background: mainColor }" @click="onLogin">{{ ctaText }}</button>
+        <button
+          v-if="secondaryText"
+          class="dvc-btn-secondary"
+          :style="{ color: mainColor, borderColor: mainColor }"
+          @click="onSecondary">{{ secondaryText }}</button>
+      </view>
+    </view>
+
+    <!-- 维度专属预览占位(登录后展示真实数据) -->
+    <view class="dvc-preview" v-if="previewText">
+      <text class="dvc-preview-icon">{{ previewIcon }}</text>
+      <text class="dvc-preview-text">{{ previewText }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * 维度游客价值卡 —— 五维主页面登录前统一复用
+ *
+ * 维度色严格对齐 uni.scss 五维令牌(v4.0):
+ *   身 body   #FF8C42   心 mind   #FF6B9D
+ *   智 wisdom #6366F1   行 action #10B981   富 wealth #F59E0B
+ * 此处用显式 hex(与令牌值一致)而非 var(--x),确保 mp-weixin 运行时稳定渲染。
+ *
+ * 登录行为复用全局 mixin 的 openLoginSheet()(main.js 已全局注册)。
+ */
+var DIM_TOKENS = {
+  body:   { main: '#FF8C42', soft: 'rgba(255, 140, 66, 0.15)',  icon: '🌿' },
+  mind:   { main: '#FF6B9D', soft: 'rgba(255, 107, 157, 0.15)', icon: '🔥' },
+  wisdom: { main: '#6366F1', soft: 'rgba(99, 102, 241, 0.15)',  icon: '⚡' },
+  action: { main: '#10B981', soft: 'rgba(16, 185, 129, 0.15)',  icon: '🤝' },
+  wealth: { main: '#F59E0B', soft: 'rgba(245, 158, 11, 0.15)',  icon: '💧' }
+}
+
+export default {
+  props: {
+    /** 维度编码:body | mind | wisdom | action | wealth */
+    dimCode: { type: String, default: 'body' },
+    /** 卡片标题 */
+    cardTitle: { type: String, default: '登录后查看专属内容' },
+    /** 价值收益列表(2–3 条) */
+    benefits: {
+      type: Array,
+      default: function() { return [] }
+    },
+    /** 主登录按钮文案 */
+    ctaText: { type: String, default: '立即登录' },
+    /** 次级动作文案(可选) */
+    secondaryText: { type: String, default: '' },
+    /** 次级动作跳转地址(可选);留空则 emit('secondary') */
+    secondaryUrl: { type: String, default: '' },
+    /** 维度专属预览占位文案(可选);留空则不渲染预览块 */
+    previewText: { type: String, default: '' }
+  },
+  computed: {
+    token: function() {
+      return DIM_TOKENS[this.dimCode] || DIM_TOKENS.body
+    },
+    mainColor: function() { return this.token.main },
+    softColor: function() { return this.token.soft },
+    previewIcon: function() { return this.token.icon }
+  },
+  methods: {
+    onLogin: function() {
+      // 全局 mixin 提供 openLoginSheet;极端情况下回退整页登录
+      if (typeof this.openLoginSheet === 'function') {
+        this.openLoginSheet()
+      } else {
+        uni.navigateTo({ url: '/pages/login/login' })
+      }
+    },
+    onSecondary: function() {
+      if (this.secondaryUrl) {
+        uni.navigateTo({ url: this.secondaryUrl })
+      } else {
+        this.$emit('secondary')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.dvc {
+  margin: 12rpx 30rpx 0;
+}
+
+.dvc-card {
+  border-radius: 24rpx;
+  padding: 32rpx 28rpx;
+}
+
+.dvc-head {
+  margin-bottom: 22rpx;
+}
+.dvc-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+  display: block;
+  line-height: 1.4;
+}
+.dvc-sub {
+  font-size: 22rpx;
+  color: #999999;
+  margin-top: 6rpx;
+  display: block;
+}
+
+.dvc-benefits {
+  margin-bottom: 26rpx;
+}
+.dvc-benefit {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.dvc-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+.dvc-benefit-text {
+  font-size: 26rpx;
+  color: #333333;
+  line-height: 1.5;
+}
+
+.dvc-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+}
+.dvc-btn-primary {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  color: #ffffff;
+  font-size: 28rpx;
+  font-weight: 600;
+  border: none;
+  padding: 0;
+}
+.dvc-btn-primary:active { opacity: 0.85; }
+.dvc-btn-secondary {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  background: #ffffff;
+  font-size: 28rpx;
+  font-weight: 500;
+  border: 2rpx solid;
+  padding: 0;
+}
+.dvc-btn-secondary:active { opacity: 0.7; }
+
+.dvc-preview {
+  margin-top: 24rpx;
+  background: #ffffff;
+  border: 1rpx dashed #e5e7eb;
+  border-radius: 20rpx;
+  padding: 40rpx 28rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.dvc-preview-icon {
+  font-size: 44rpx;
+  margin-bottom: 14rpx;
+}
+.dvc-preview-text {
+  font-size: 24rpx;
+  color: #999999;
+  text-align: center;
+  line-height: 1.6;
+}
+</style>

+ 3 - 1
cfc-frontend/components/LoginGuideCard.vue

@@ -26,7 +26,9 @@ export default {
   },
   methods: {
     goLogin: function() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      // 走全局登录浮层(mixin 会沿 $parent 链找到宿主页面的 <login-sheet>);
+      // 宿主页面未挂浮层时 mixin 自动回退整页登录
+      this.openLoginSheet()
     }
   }
 }

+ 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>

Неке датотеке нису приказане због велике количине промена