Răsfoiți Sursa

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

User 2 luni în urmă
părinte
comite
fb4578cec4
52 a modificat fișierele cu 4342 adăugiri și 84 ștergeri
  1. 247 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 77 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java
  3. 24 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java
  5. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindController.java
  6. 100 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/TraditionalMirrorController.java
  7. 34 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionScoreVO.java
  8. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionUploadVO.java
  9. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmiReportDTO.java
  10. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinDTO.java
  11. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinVO.java
  12. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionStatsVO.java
  13. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionTrendVO.java
  14. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionQuestionnaireVO.java
  15. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionVO.java
  16. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/BaziReadingTemplate.java
  17. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/EmotionCheckin.java
  18. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDataSourceRecord.java
  19. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDimensionScore.java
  20. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthNormReference.java
  21. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportPermission.java
  22. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulConfig.java
  23. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/BaziReadingTemplateMapper.java
  24. 16 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/EmotionCheckinMapper.java
  25. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.java
  26. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java
  27. 21 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.java
  28. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthReportPermissionMapper.java
  29. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/NumSoulConfigMapper.java
  30. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  31. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DimensionScoreService.java
  32. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EmiReportService.java
  33. 218 0
      cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java
  34. 63 4
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java
  35. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthDataSourceRecordService.java
  36. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthDimensionScoreService.java
  37. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  38. 177 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java
  39. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDataSourceRecordServiceImpl.java
  40. 48 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java
  41. 205 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ActivityAdminServiceTest.java
  42. 207 0
      cfc-backend/src/test/java/com/etotem/cfc/service/FoodServiceTest.java
  43. 288 0
      cfc-backend/src/test/java/com/etotem/cfc/service/NutritionDeficiencyServiceTest.java
  44. 6 0
      cfc-frontend/pages.json
  45. 286 0
      cfc-frontend/pages/body/food-list.vue
  46. 605 0
      cfc-frontend/pages/body/health-dimensions.vue
  47. 4 0
      cfc-frontend/utils/api.js
  48. 138 79
      docs/test-report.md
  49. 145 0
      docs/设计/wisdom-dimension-plan.md
  50. 180 0
      docs/设计/智维度页面重设计方案.md
  51. 277 0
      docs/运营推广设计/IMPLEMENTATION_PLAN.md
  52. 246 0
      docs/运营推广设计/README.md

+ 247 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2198,7 +2198,254 @@ log.info("已添加template_id列到tasks表");
             log.warn("nutrition_indicator_mapping 种子数据初始化失败: {}", e.getMessage());
         }
 
+        // ==================== 心版块 Phase 1: emotion_checkin 表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_checkin (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                    "user_id BIGINT NOT NULL COMMENT '打卡人用户ID', " +
+                    "emotion_type INT NOT NULL COMMENT '情绪类型: 1快乐/2平和平静/3平静/4有点不舒服/5难受/6烦躁/7生气/8焦虑/9恐惧', " +
+                    "emotion_name VARCHAR(20) COMMENT '情绪名称: happy/calm/peaceful/uncomfortable/sad/irritable/angry/anxious/fearful', " +
+                    "energy_level INT NOT NULL DEFAULT 3 COMMENT '能量状态: 1很低/2低/3中等/4高/5很高', " +
+                    "note TEXT COMMENT '打卡备注', " +
+                    "photo_url VARCHAR(500) COMMENT '照片URL', " +
+                    "favorite_thing VARCHAR(500) COMMENT '今日最喜欢的一件事', " +
+                    "points_awarded INT DEFAULT 0 COMMENT '奖励积分', " +
+                    "parent_visible TINYINT DEFAULT 1 COMMENT '家长可见: 1可见/0不可见', " +
+                    "checkin_date DATE NOT NULL COMMENT '打卡日期', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_child_id (child_id), " +
+                    "INDEX idx_user_id (user_id), " +
+                    "INDEX idx_checkin_date (checkin_date), " +
+                    "INDEX idx_child_date (child_id, checkin_date)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪打卡表'");
+            log.info("已创建 emotion_checkin 情绪打卡表");
+        } catch (Exception e) {
+            log.warn("创建 emotion_checkin 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1a-2: DAN Schema 修复 ====================
+        // v1.2 EMI 心理子维度
+        String[] danEmiFields = {"emotion_score", "resilience_score", "stress_coping_score", "self_awareness_score"};
+        for (String field : danEmiFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI子维度 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+        // 大五人格维度
+        String[] danBigFiveFields = {"openness_score", "conscientiousness_score", "extraversion_score", "agreeableness_score", "neuroticism_score", "big_five_overall_score"};
+        for (String field : danBigFiveFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT '大五人格 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+        // EMI 维度(情绪商数评估字段)
+        String[] danEmiDimFields = {"emotion_management_score", "empathy_score", "social_adaptability_score", "self_motivation_score"};
+        for (String field : danEmiDimFields) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dan_assessment_results' AND COLUMN_NAME = ?",
+                    Integer.class, field);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.execute("ALTER TABLE dan_assessment_results ADD COLUMN " + field + " INT DEFAULT NULL COMMENT 'EMI维度 0-100'");
+                    log.info("已添加 {} 列到 dan_assessment_results 表", field);
+                }
+            } catch (Exception e) {
+                log.warn("检查/添加 {} 列失败: {}", field, e.getMessage());
+            }
+        }
+
+        // ==================== 1b-1: bazi_reading_template 五行命理解读表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS bazi_reading_template (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "element VARCHAR(20) NOT NULL COMMENT '五行: wood/fire/earth/metal/water', " +
+                    "condition VARCHAR(20) NOT NULL COMMENT '状态: strong/weak/balanced', " +
+                    "personal_trait TEXT COMMENT '性格特质描述', " +
+                    "mind_growth_advice TEXT COMMENT '心理成长建议', " +
+                    "daily_tip_template TEXT COMMENT '每日宜忌模板', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "UNIQUE KEY uk_element_condition (element, condition)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五行命理解读模板'");
+            log.info("已创建 bazi_reading_template 表");
+        } catch (Exception e) {
+            log.warn("创建 bazi_reading_template 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: numsoul_config 生命灵数配置表 ====================
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_config (" +
+                    "number INT PRIMARY KEY COMMENT '灵数1-9', " +
+                    "title VARCHAR(50) NOT NULL COMMENT '称号', " +
+                    "keywords VARCHAR(200) COMMENT '性格关键词', " +
+                    "mind_growth_advice TEXT COMMENT '心理成长建议', " +
+                    "recommended_article_tags VARCHAR(200) COMMENT '关联文章标签', " +
+                    "color_hex VARCHAR(7) COMMENT '代表色' " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生命灵数配置'");
+            log.info("已创建 numsoul_config 表");
+        } catch (Exception e) {
+            log.warn("创建 numsoul_config 表失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: bazi_reading_template 种子数据 ====================
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM bazi_reading_template", Integer.class);
+            if (count == null || count == 0) {
+                jdbcTemplate.batchUpdate(
+                    "INSERT INTO bazi_reading_template (element, condition, personal_trait, mind_growth_advice, daily_tip_template) VALUES (?, ?, ?, ?, ?)",
+                    java.util.Arrays.asList(new Object[][]{
+                        {"wood", "strong", "性格坚韧、有进取心,具有领导力和开拓精神", "学会适当放松,避免过刚易折,培养弹性思维", "宜进行伸展运动,忌高强度冲突"},
+                        {"wood", "weak", "温和有礼、善于合作,但决策时容易犹豫", "增强自信心训练,培养果断决策能力", "宜与自然相处,忌过度迁就他人"},
+                        {"wood", "balanced", "性格中庸有度,既有韧性又有灵活性", "保持现状,适当拓展社交圈", "宜户外活动,忌久坐不动"},
+                        {"fire", "strong", "热情开朗、充满活力,富有感染力和创造力", "注意情绪管理,避免急躁冲动", "宜冷水刺激冷静,忌暴怒争执"},
+                        {"fire", "weak", "内敛含蓄、心思细腻,对艺术有敏锐感知", "适当表达情感,培养社交自信", "宜温暖陪伴,忌独处过久"},
+                        {"fire", "balanced", "性格温润有度,既有热情又懂克制", "保持兴趣爱好,适度拓展表达", "宜参与社交活动,忌情绪压抑"},
+                        {"earth", "strong", "稳重可靠、有责任心,是团队中的中流砥柱", "避免过度承担,学会说'不'", "宜适量运动,忌思虑过重"},
+                        {"earth", "weak", "随和友善、适应性强,容易与他人相处", "增强主见性,培养独立思考能力", "宜接触新事物,忌固守不变"},
+                        {"earth", "balanced", "性情敦厚、处事稳妥,人际关系和谐", "保持学习,适当挑战自我", "宜亲近自然,忌暴饮暴食"},
+                        {"metal", "strong", "意志坚定、追求完美,具有卓越的执行力", "学会灵活变通,避免过于严苛", "宜艺术鉴赏,忌过度批评"},
+                        {"metal", "weak", "温和寡言、善于倾听,是值得信赖的朋友", "增强表达勇气,培养自信心", "宜团体活动,忌过度自我封闭"},
+                        {"metal", "balanced", "为人正直、做事有度,不乏柔韧与包容", "保持开放心态,适度多元发展", "宜阅读学习,忌钻牛角尖"},
+                        {"water", "strong", "智慧深沉、善于谋略,具有出色洞察力", "避免过度缜密,适当放松心情", "宜静坐冥想,忌思虑过深"},
+                        {"water", "weak", "敏感细腻、有同理心,富有创作天赋", "增强抵抗力,培养情绪韧性", "宜温暖环境,忌受寒受凉"},
+                        {"water", "balanced", "性格圆融、随机应变,处事灵活得宜", "保持好奇心,适度扩展影响力", "宜游泳泡澡,忌情绪波动"},
+                    })
+                );
+                log.info("bazi_reading_template 种子数据已加载 (15条)");
+            }
+        } catch (Exception e) {
+            log.warn("bazi_reading_template 种子数据初始化失败: {}", e.getMessage());
+        }
+
+        // ==================== 1b-1: numsoul_config 种子数据 ====================
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM numsoul_config", Integer.class);
+            if (count == null || count == 0) {
+                jdbcTemplate.batchUpdate(
+                    "INSERT INTO numsoul_config (number, title, keywords, mind_growth_advice, recommended_article_tags, color_hex) VALUES (?, ?, ?, ?, ?, ?)",
+                    java.util.Arrays.asList(new Object[][]{
+                        {1, "开创者", "独立、领导、创新、自信", "培养团队协作意识,避免独断专行", "领导力,自信培养", "#FF4444"},
+                        {2, "和平者", "温和、合作、敏感、耐心", "增强自我主张,避免过度迎合他人", "人际交往,情绪表达", "#FF8C00"},
+                        {3, "表达者", "创意、社交、乐观、表达", "保持专注,避免浅尝辄止", "创造力,社交能力", "#FFD700"},
+                        {4, "建设者", "务实、稳定、有序、勤奋", "学会变通,避免过于固执", "专注力,学习习惯", "#4CAF50"},
+                        {5, "自由者", "冒险、变化、好奇、灵活", "培养持久力,避免缺乏恒心", "探索精神,适应能力", "#2196F3"},
+                        {6, "关怀者", "责任、关爱、奉献、平衡", "学会自我关爱,避免过度付出", "家庭关系,责任心", "#9C27B0"},
+                        {7, "探索者", "智慧、分析、内省、完美", "与人分享见解,避免过度封闭", "思维能力,学习力", "#673AB7"},
+                        {8, "成就者", "雄心、管理、决策、权威", "关注内在成长,避免过于追求外在成就", "目标管理,理财思维", "#3F51B5"},
+                        {9, "博爱者", "包容、智慧、善良、豁达", "设定个人边界,避免过度牺牲", "人际关系,情绪管理", "#E91E63"},
+                    })
+                );
+                log.info("numsoul_config 种子数据已加载 (9条)");
+            }
+        } catch (Exception e) {
+            log.warn("numsoul_config 种子数据初始化失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
+
+        // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ====================
+        migrateHealthDimensionsTable();
+        seedHealthNorms();
+    }
+
+    private void migrateHealthDimensionsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_dimension_score` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" +
+                "  `dimension` varchar(50) NOT NULL COMMENT '维度: body/fitness/cardio/blood/sleep/psyche/diet',\n" +
+                "  `score` decimal(5,2) NOT NULL COMMENT '得分 0-100',\n" +
+                "  `data_source` varchar(50) NOT NULL COMMENT '数据来源: manual/assessment/report/device',\n" +
+                "  `record_date` date DEFAULT NULL COMMENT '记录日期',\n" +
+                "  `source_id` bigint DEFAULT NULL COMMENT '来源记录ID(体检报告等)',\n" +
+                "  `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新',\n" +
+                "  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_member_dimension` (`member_id`,`dimension`,`is_latest`),\n" +
+                "  KEY `idx_record_date` (`record_date`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康维度评分表'");
+        } catch (Exception e) {
+            log.warn("health_dimension_score 表创建失败: {}", e.getMessage());
+        }
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_data_source_record` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `member_id` bigint NOT NULL COMMENT '家庭成员ID',\n" +
+                "  `data_source` varchar(50) NOT NULL COMMENT '数据源: manual/assessment/report/device',\n" +
+                "  `source_id` bigint NOT NULL COMMENT '来源记录ID',\n" +
+                "  `dimension` varchar(50) DEFAULT NULL COMMENT '关联维度',\n" +
+                "  `record_time` datetime NOT NULL COMMENT '记录时间',\n" +
+                "  `is_latest` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否最新',\n" +
+                "  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_member_source` (`member_id`,`data_source`,`is_latest`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据来源记录表'");
+        } catch (Exception e) {
+            log.warn("health_data_source_record 表创建失败: {}", e.getMessage());
+        }
+
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_norm_reference` (\n" +
+                "  `id` bigint NOT NULL AUTO_INCREMENT,\n" +
+                "  `dimension` varchar(50) NOT NULL COMMENT '维度',\n" +
+                "  `indicator` varchar(100) NOT NULL COMMENT '指标名',\n" +
+                "  `min_value` decimal(10,3) DEFAULT NULL COMMENT '正常最小值',\n" +
+                "  `max_value` decimal(10,3) DEFAULT NULL COMMENT '正常最大值',\n" +
+                "  `unit` varchar(20) DEFAULT NULL COMMENT '单位',\n" +
+                "  `age_group` varchar(20) DEFAULT NULL COMMENT '年龄段: child/adolescent/adult',\n" +
+                "  `gender` varchar(10) DEFAULT NULL COMMENT '性别: male/female/unisex',\n" +
+                "  `source` varchar(100) DEFAULT NULL COMMENT '参考标准来源',\n" +
+                "  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,\n" +
+                "  PRIMARY KEY (`id`),\n" +
+                "  KEY `idx_dimension_indicator` (`dimension`,`indicator`)\n" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康指标参考标准表'");
+        } catch (Exception e) {
+            log.warn("health_norm_reference 表创建失败: {}", e.getMessage());
+        }
+    }
+
+    private void seedHealthNorms() {
+        try {
+            int count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM health_norm_reference", Integer.class);
+            if (count == 0) {
+                jdbcTemplate.execute("INSERT INTO `health_norm_reference` (`dimension`, `indicator`, `min_value`, `max_value`, `unit`, `age_group`, `gender`, `source`) VALUES\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'child', 'unisex', '中国儿童青少年BMI标准'),\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'adolescent', 'unisex', '中国儿童青少年BMI标准'),\n" +
+                    "('body', 'bmi', 18.5, 24.0, 'kg/m²', 'adult', 'unisex', '中国成人BMI标准'),\n" +
+                    "('sleep', 'duration', 8.0, 12.0, '小时', 'child', 'unisex', '中国儿童睡眠指南'),\n" +
+                    "('sleep', 'duration', 9.0, 11.0, '小时', 'adolescent', 'unisex', '中国儿童青少年睡眠指南'),\n" +
+                    "('sleep', 'duration', 7.0, 9.0, '小时', 'adult', 'unisex', '中国成人睡眠指南'),\n" +
+                    "('fitness', 'situp', 30.0, NULL, '次', 'child', 'male', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 25.0, NULL, '次', 'child', 'female', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 40.0, NULL, '次', 'adolescent', 'male', '国家学生体质健康标准'),\n" +
+                    "('fitness', 'situp', 35.0, NULL, '次', 'adolescent', 'female', '国家学生体质健康标准')");
+                log.info("health_norm_reference 种子数据已加载 (10条)");
+            } else {
+                log.info("health_norm_reference 种子数据已存在 ({}条), 跳过", count);
+            }
+        } catch (Exception e) {
+            log.warn("health_norm_reference 种子数据初始化失败: {}", e.getMessage());
+        }
     }
 
     private void initializeDefaultData() {

+ 77 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthDimensionController.java

@@ -0,0 +1,77 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.service.DimensionScoreService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "健康维度", description = "七维健康指标查询与录入")
+@RestController
+@RequestMapping("/api/dimension")
+public class HealthDimensionController {
+
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
+    @Operation(summary = "获取健康维度概览")
+    @PostMapping("/overview")
+    public Result<HealthDimensionVO> overview(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        HealthDimensionVO vo = dimensionScoreService.getHealthDimension(memberId);
+        return Result.success(vo);
+    }
+
+    @Operation(summary = "手动录入维度评分")
+    @PostMapping("/upload")
+    public Result<Void> upload(
+            @RequestBody DimensionUploadVO vo,
+            @RequestAttribute("userId") Long userId) {
+        if (vo.getMemberId() == null || vo.getDimension() == null || vo.getScore() == null) {
+            return Result.error("memberId, dimension, score不能为空");
+        }
+        dimensionScoreService.uploadScore(vo);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "问卷提交维度评分")
+    @PostMapping("/questionnaire")
+    public Result<Void> questionnaire(
+            @RequestBody HealthDimensionQuestionnaireVO vo,
+            @RequestAttribute("userId") Long userId) {
+        if (vo.getMemberId() == null || vo.getDimension() == null || vo.getAnswers() == null) {
+            return Result.error("memberId, dimension, answers不能为空");
+        }
+        dimensionScoreService.submitQuestionnaire(vo);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "获取维度历史记录")
+    @PostMapping("/history")
+    public Result<List<HealthDimensionVO.DimensionItem>> history(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String dimension = (String) params.get("dimension");
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString()) : 10;
+        if (memberId == null || dimension == null) {
+            return Result.error("memberId和dimension不能为空");
+        }
+        List<HealthDimensionVO.DimensionItem> items = dimensionScoreService.getDimensionHistory(memberId, dimension, limit);
+        return Result.success(items);
+    }
+}

+ 24 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.dto.ParsedGutFlora;
 import com.etotem.cfc.dto.ParsedIndicator;
 import com.etotem.cfc.dto.ParsedFoodSuitability;
 import com.etotem.cfc.dto.ParsedReportResult;
+import java.math.BigDecimal;
 import com.etotem.cfc.entity.Food;
 import com.etotem.cfc.entity.HealthDiseaseRisk;
 import com.etotem.cfc.entity.HealthGutFlora;
@@ -16,6 +17,7 @@ import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
 import com.etotem.cfc.mapper.FoodMapper;
 import com.etotem.cfc.service.ChildNutritionProfileService;
+import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportService;
@@ -69,6 +71,9 @@ public class HealthReportController {
     @Resource
     private FoodService foodService;
 
+    @Resource
+    private DimensionScoreService dimensionScoreService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -377,7 +382,25 @@ public class HealthReportController {
             // 9. 创建完整报告
             HealthReport created = healthReportService.createReport(report, indicators, gutFloraList, diseaseRiskList);
 
-            // 10. 保存食材推荐数据
+            // 10. 保存肠道健康评分到维度表
+            if (parsed.getGutHealthScore() != null) {
+                try {
+                    com.etotem.cfc.dto.DimensionUploadVO vo = new com.etotem.cfc.dto.DimensionUploadVO();
+                    vo.setMemberId(matchedChildId);
+                    vo.setDimension("body");
+                    vo.setScore(parsed.getGutHealthScore() != null ? BigDecimal.valueOf(parsed.getGutHealthScore()) : null);
+                    vo.setDataSource("report");
+                    if (parsed.getReportDate() != null && !parsed.getReportDate().isEmpty()) {
+                        vo.setRecordDate(parsed.getReportDate());
+                    }
+                    dimensionScoreService.uploadScore(vo);
+                    log.info("已保存肠道健康评分到维度表: childId={}, score={}", matchedChildId, parsed.getGutHealthScore());
+                } catch (Exception e) {
+                    log.warn("保存肠道健康评分到维度表失败: {}", e.getMessage());
+                }
+            }
+
+            // 11. 保存食材推荐数据
             if (parsed.getFoodSuitability() != null && !parsed.getFoodSuitability().isEmpty()) {
                 int sortOrder = 0;
                 for (ParsedFoodSuitability pfs : parsed.getFoodSuitability()) {

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionCheckinController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmotionCheckinDTO;
+import com.etotem.cfc.dto.EmotionTrendVO;
+import com.etotem.cfc.dto.EmotionStatsVO;
+import com.etotem.cfc.service.EmotionCheckinService;
+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.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/checkin")
+public class EmotionCheckinController {
+
+    @Resource
+    private EmotionCheckinService emotionCheckinService;
+
+    @PostMapping("/create")
+    public Result<?> create(@RequestBody EmotionCheckinDTO dto,
+                            @RequestAttribute("userId") Long userId) {
+        try {
+            Object result = emotionCheckinService.createCheckin(dto, userId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("创建情绪打卡失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/list")
+    public Result<?> list(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long userId,
+                          @RequestAttribute("role") String role) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        int page = params.containsKey("page") ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.containsKey("size") ? Integer.parseInt(params.get("size").toString()) : 20;
+        boolean hidePhoto = !"child".equals(role);
+        Object result = emotionCheckinService.getCheckinList(childId, page, size, userId, hidePhoto);
+        return Result.success(result);
+    }
+
+    @PostMapping("/latest")
+    public Result<?> latest(@RequestBody Map<String, Object> params,
+                            @RequestAttribute("userId") Long userId,
+                            @RequestAttribute("role") String role) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        boolean hidePhoto = !"child".equals(role);
+        Object result = emotionCheckinService.getLatestCheckin(childId, userId, hidePhoto);
+        return Result.success(result);
+    }
+
+    @PostMapping("/trend")
+    public Result<?> trend(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        String period = params.containsKey("period") ? params.get("period").toString() : "week";
+        EmotionTrendVO result = emotionCheckinService.getTrend(childId, period);
+        return Result.success(result);
+    }
+
+    @PostMapping("/stats")
+    public Result<?> stats(@RequestBody Map<String, Object> params) {
+        Long childId = Long.valueOf(params.get("childId").toString());
+        EmotionStatsVO result = emotionCheckinService.getStats(childId);
+        return Result.success(result);
+    }
+}

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindController.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmiReportDTO;
+import com.etotem.cfc.service.EmiReportService;
+import com.etotem.cfc.service.HealthReportService;
+import lombok.extern.slf4j.Slf4j;
+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.Collections;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind")
+public class MindController {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private EmiReportService emiReportService;
+
+    /**
+     * Get neurotransmitter indicators from latest gut microbiome report.
+     * Returns indicators categorized as "神经递质与激素" and "短链脂肪酸".
+     */
+    @PostMapping("/neurotransmitters")
+    public Result<?> neurotransmitters(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            Object result = healthReportService.getMindRelatedIndicators(childId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取神经递质指标失败", e);
+            return Result.success(Collections.emptyList());
+        }
+    }
+
+    /**
+     * Get gut-emotion insight summary based on gut microbiome data.
+     */
+    @PostMapping("/gut-emotion-insight")
+    public Result<?> gutEmotionInsight(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            Map<String, Object> result = healthReportService.getGutEmotionInsight(childId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取肠脑情绪洞察失败", e);
+            Map<String, Object> empty = new java.util.HashMap<>();
+            empty.put("summary", "暂无菌群报告数据");
+            return Result.success(empty);
+        }
+    }
+
+    /**
+     * Get the latest EMI report for a child.
+     */
+    @PostMapping("/emireport/latest")
+    public Result<?> emiReportLatest(@RequestBody Map<String, Object> params) {
+        try {
+            Long childId = Long.valueOf(params.get("childId").toString());
+            EmiReportDTO result = emiReportService.getChildLatestEmiReport(childId);
+            if (result == null) {
+                return Result.success(new java.util.HashMap<>());
+            }
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取EMI报告失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 100 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/TraditionalMirrorController.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.BaziReadingTemplate;
+import com.etotem.cfc.entity.FamilyMemberAttributes;
+import com.etotem.cfc.entity.NumSoulConfig;
+import com.etotem.cfc.service.FamilyMemberAttributeService;
+import lombok.extern.slf4j.Slf4j;
+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.HashMap;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/traditional")
+public class TraditionalMirrorController {
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    /**
+     * Get traditional culture mirror data for a family member.
+     * Returns BaZi (eight characters), wuxing elements, zodiac, life number,
+     * wuxing reading interpretation, and numsoul config.
+     */
+    @PostMapping("/mirror")
+    public Result<?> mirror(@RequestBody Map<String, Object> params) {
+        try {
+            Long memberId = Long.valueOf(params.get("memberId").toString());
+            String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
+
+            FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
+            if (attrs == null) {
+                return Result.success(new HashMap<>());
+            }
+
+            Map<String, Object> result = new HashMap<>();
+            result.put("eightCharacters", parseJsonField(attrs.getEightCharacters()));
+            result.put("wuxingElements", parseJsonField(attrs.getWuxingElements()));
+            result.put("zodiac", attrs.getZodiac());
+            result.put("bloodType", attrs.getBloodType());
+            result.put("mindBaseScore", attrs.getMindBaseScore());
+            result.put("wisdomBaseScore", attrs.getWisdomBaseScore());
+
+            // Parse behaviorModifier for lifeNumber
+            Map<String, Object> modifier = parseJsonField(attrs.getBehaviorModifier());
+            result.put("lifeNumber", modifier.getOrDefault("lifeNumber", null));
+
+            // Wuxing reading
+            BaziReadingTemplate reading = familyMemberAttributeService.getWuxingReading(attrs.getWuxingElements());
+            result.put("wuxingReading", reading);
+
+            // Num soul config
+            Object lifeNum = modifier.get("lifeNumber");
+            if (lifeNum instanceof Number) {
+                NumSoulConfig numSoul = familyMemberAttributeService.getNumSoulConfig(((Number) lifeNum).intValue());
+                result.put("numSoul", numSoul);
+            }
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("获取传统文化镜数据失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> parseJsonField(String json) {
+        if (json == null || json.isEmpty() || !json.startsWith("{")) {
+            return new HashMap<>();
+        }
+        // Use the service's fromJson parser via a simple approach
+        Map<String, Object> map = new HashMap<>();
+        String content = json.substring(1, json.length() - 1).trim();
+        if (content.isEmpty()) return map;
+        String[] pairs = content.split(",");
+        for (String pair : pairs) {
+            String[] kv = pair.split(":", 2);
+            if (kv.length == 2) {
+                String key = kv[0].trim().replace("\"", "");
+                String value = kv[1].trim().replace("\"", "");
+                try {
+                    if (value.contains(".")) {
+                        map.put(key, Double.parseDouble(value));
+                    } else {
+                        map.put(key, Integer.parseInt(value));
+                    }
+                } catch (NumberFormatException e) {
+                    map.put(key, value);
+                }
+            }
+        }
+        return map;
+    }
+}

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionScoreVO.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class DimensionScoreVO {
+    private String dimension;
+    private BigDecimal score;
+    private String label;
+    private String dataSource;
+    private String recordDate;
+
+    public DimensionScoreVO() {}
+
+    public DimensionScoreVO(String dimension, String label, BigDecimal score, String dataSource, String recordDate) {
+        this.dimension = dimension;
+        this.label = label;
+        this.score = score;
+        this.dataSource = dataSource;
+        this.recordDate = recordDate;
+    }
+
+    public static List<DimensionScoreVO> DEFAULT_DIMENSIONS = java.util.Arrays.asList(
+        new DimensionScoreVO("body", "身体", null, null, null),
+        new DimensionScoreVO("fitness", "运动", null, null, null),
+        new DimensionScoreVO("cardio", "心肺", null, null, null),
+        new DimensionScoreVO("blood", "血液", null, null, null),
+        new DimensionScoreVO("sleep", "睡眠", null, null, null),
+        new DimensionScoreVO("psyche", "心理", null, null, null),
+        new DimensionScoreVO("diet", "饮食", null, null, null)
+    );
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DimensionUploadVO.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class DimensionUploadVO {
+    private Long memberId;
+    private String dimension;
+    private BigDecimal score;
+    private String dataSource;
+    private String recordDate;
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmiReportDTO.java

@@ -27,6 +27,11 @@ public class EmiReportDTO implements Serializable {
     private Integer neuroticismScore;
     private Integer bigFiveOverallScore;
 
+    private Integer emotionManagementScore;
+    private Integer empathyScore;
+    private Integer socialAdaptabilityScore;
+    private Integer selfMotivationScore;
+
     private Integer overallScore;
 
     private String analysisReport;

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinDTO.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class EmotionCheckinDTO implements Serializable {
+    private Long childId;
+    private String moodWeather;
+    private String emotionTags;
+    private String photoUrl;
+    private String note;
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionCheckinVO.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+public class EmotionCheckinVO implements Serializable {
+    private Long id;
+    private Long childId;
+    private String childName;
+    private String moodWeather;
+    private String emotionTags;
+    private String photoUrl;
+    private String note;
+    private Integer energyAwarded;
+    private Date checkinDate;
+    private Date createdAt;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionStatsVO.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Map;
+
+@Data
+public class EmotionStatsVO implements Serializable {
+    private Integer currentStreak;
+    private Integer totalCheckins;
+    private Integer totalEnergy;
+    private Map<String, Integer> weatherBreakdown;
+    private Integer thisMonthCheckins;
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/EmotionTrendVO.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class EmotionTrendVO implements Serializable {
+    private String period;
+    private Map<String, Integer> weatherDistribution;
+    private List<Map<String, Object>> dailyData;
+    private Integer totalCheckins;
+    private Integer totalEnergy;
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/HealthDimensionQuestionnaireVO.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class HealthDimensionQuestionnaireVO {
+    private Long memberId;
+    private String dimension;
+    private List<QuestionAnswer> answers;
+
+    @Data
+    public static class QuestionAnswer {
+        private String questionId;
+        private String answer;
+        private BigDecimal score;
+    }
+}

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

@@ -0,0 +1,26 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class HealthDimensionVO {
+    private Long memberId;
+    private BigDecimal healthIndex;
+    private List<DimensionItem> dimensions;
+    private Date updateTime;
+
+    @Data
+    public static class DimensionItem {
+        private String dimension;
+        private String label;
+        private BigDecimal score;
+        private BigDecimal normMin;
+        private BigDecimal normMax;
+        private String level;
+        private String trend;
+        private String recordDate;
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/BaziReadingTemplate.java

@@ -0,0 +1,29 @@
+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("bazi_reading_template")
+public class BaziReadingTemplate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String element;  // 五行: wood/fire/earth/metal/water
+
+    private String condition;  // 状态: strong/weak/balanced
+
+    private String personalTrait;  // 性格特质描述
+
+    private String mindGrowthAdvice;  // 心理成长建议
+
+    private String dailyTipTemplate;  // 每日宜忌模板
+
+    private Date createdAt;
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/EmotionCheckin.java

@@ -0,0 +1,41 @@
+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("emotion_checkin")
+public class EmotionCheckin implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long childId;
+
+    private String moodWeather;
+
+    private String emotionTags;
+
+    private String photoUrl;
+
+    private String note;
+
+    private Integer energyAwarded;
+
+    private Date checkinDate;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+
+    private Integer moodScore;
+
+    private String emotionType;
+
+    private Integer stressLevel;
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthDataSourceRecord.java

@@ -0,0 +1,22 @@
+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("health_data_source_record")
+public class HealthDataSourceRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String dataSource;
+    private Long sourceId;
+    private String dimension;
+    private Date recordTime;
+    private Boolean isLatest;
+    private Date createdAt;
+}

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

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_dimension_score")
+public class HealthDimensionScore implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String dimension;
+    private BigDecimal score;
+    private String dataSource;
+    private Date recordDate;
+    private Long sourceId;
+    private Boolean isLatest;
+    private Date createdAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthNormReference.java

@@ -0,0 +1,25 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("health_norm_reference")
+public class HealthNormReference implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String dimension;
+    private String indicator;
+    private BigDecimal minValue;
+    private BigDecimal maxValue;
+    private String unit;
+    private String ageGroup;
+    private String gender;
+    private String source;
+    private Date updatedAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReportPermission.java

@@ -0,0 +1,25 @@
+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("health_report_permissions")
+public class HealthReportPermission implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 报告ID */
+    private Long reportId;
+
+    /** 有查看权限的用户ID */
+    private Long userId;
+
+    private Date createdAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulConfig.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@TableName("numsoul_config")
+public class NumSoulConfig implements Serializable {
+
+    @TableId
+    private Integer number;  // 灵数1-9
+
+    private String title;  // 称号
+
+    private String keywords;  // 性格关键词
+
+    private String mindGrowthAdvice;  // 心理成长建议
+
+    private String recommendedArticleTags;  // 关联文章标签
+
+    private String colorHex;  // 代表色
+}

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

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

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/EmotionCheckinMapper.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.EmotionCheckin;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+
+@Mapper
+public interface EmotionCheckinMapper extends BaseMapper<EmotionCheckin> {
+
+    @Select("SELECT * FROM emotion_checkin WHERE child_id = #{childId} ORDER BY checkin_date DESC LIMIT #{limit}")
+    List<EmotionCheckin> selectLatestByChildId(@Param("childId") Long childId, @Param("limit") int limit);
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDataSourceRecordMapper.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+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.List;
+
+@Mapper
+public interface HealthDataSourceRecordMapper extends BaseMapper<HealthDataSourceRecord> {
+
+    @Select("SELECT * FROM health_data_source_record WHERE member_id = #{memberId} AND data_source = #{dataSource} AND is_latest = 1 ORDER BY record_time DESC LIMIT 1")
+    HealthDataSourceRecord selectLatest(@Param("memberId") Long memberId, @Param("dataSource") String dataSource);
+
+    @Select("SELECT * FROM health_data_source_record WHERE member_id = #{memberId} AND data_source = #{dataSource} ORDER BY record_time DESC")
+    List<HealthDataSourceRecord> selectByMemberAndSource(@Param("memberId") Long memberId, @Param("dataSource") String dataSource);
+
+    @Update("UPDATE health_data_source_record SET is_latest = 0 WHERE member_id = #{memberId} AND data_source = #{dataSource} AND id != #{sourceId}")
+    void updateNotLatest(@Param("memberId") Long memberId, @Param("sourceId") Long sourceId);
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthDimensionScore;
+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.List;
+
+@Mapper
+public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND is_latest = 1 ORDER BY record_date DESC")
+    List<HealthDimensionScore> selectLatestByMember(@Param("memberId") Long memberId);
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND dimension = #{dimension} AND is_latest = 1 LIMIT 1")
+    HealthDimensionScore selectLatest(@Param("memberId") Long memberId, @Param("dimension") String dimension);
+
+    @Select("SELECT * FROM health_dimension_score WHERE member_id = #{memberId} AND dimension = #{dimension} ORDER BY record_date DESC LIMIT #{limit}")
+    List<HealthDimensionScore> selectHistory(@Param("memberId") Long memberId, @Param("dimension") String dimension, @Param("limit") int limit);
+
+    @Update("UPDATE health_dimension_score SET is_latest = 0 WHERE member_id = #{memberId} AND dimension = #{dimension} AND is_latest = 1")
+    void updateExpired(@Param("memberId") Long memberId, @Param("dimension") String dimension);
+}

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthNormReferenceMapper.java

@@ -0,0 +1,21 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthNormReference;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import java.util.List;
+
+@Mapper
+public interface HealthNormReferenceMapper extends BaseMapper<HealthNormReference> {
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} LIMIT 1")
+    HealthNormReference selectByDimension(@Param("dimension") String dimension);
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} AND indicator = #{indicator} LIMIT 1")
+    HealthNormReference selectByDimensionIndicator(@Param("dimension") String dimension, @Param("indicator") String indicator);
+
+    @Select("SELECT * FROM health_norm_reference WHERE dimension = #{dimension} AND age_group = #{ageGroup} AND (gender = #{gender} OR gender = 'unisex') LIMIT 1")
+    HealthNormReference selectNorm(@Param("dimension") String dimension, @Param("ageGroup") String ageGroup, @Param("gender") String gender);
+}

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

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

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

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

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -5,8 +5,11 @@ import org.springframework.http.*;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import javax.annotation.Resource;
 import java.util.*;
+import java.util.stream.Collectors;
 
 /**
  * Dify AI API 封装
@@ -27,6 +30,9 @@ public class AIService {
     @Value("${dify.nutrition-api-key}")
     private String nutritionApiKey;
 
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
     private HttpHeaders authHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -137,4 +143,48 @@ public class AIService {
         HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
         restTemplate.exchange(url, HttpMethod.DELETE, entity, Void.class);
     }
+
+    /**
+     * Build an emotional context prompt for the Dify AI conversation
+     * based on the child's recent checkin history.
+     * Detects consecutive negative checkins and includes them as context.
+     */
+    public String buildMindContextPrompt(Long childId) {
+        // Get latest 7 checkins
+        List<EmotionCheckin> recent = emotionCheckinMapper.selectLatestByChildId(childId, 7);
+        if (recent == null || recent.isEmpty()) {
+            return "用户暂无情绪打卡记录。";
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("用户最近的情绪打卡记录:\n");
+        for (EmotionCheckin c : recent) {
+            sb.append("- ").append(c.getCheckinDate()).append(": ");
+            sb.append("情绪值=").append(c.getMoodScore()).append("/10");
+            sb.append(", 情绪类型=").append(c.getEmotionType());
+            if (c.getStressLevel() != null) {
+                sb.append(", 压力值=").append(c.getStressLevel()).append("/10");
+            }
+            sb.append("\n");
+        }
+
+        // Detect consecutive negative checkins (moodScore <= 4)
+        int consecutiveNegative = 0;
+        for (EmotionCheckin c : recent) {
+            if (c.getMoodScore() != null && c.getMoodScore() <= 4) {
+                consecutiveNegative++;
+            } else {
+                break;
+            }
+        }
+        if (consecutiveNegative >= 3) {
+            sb.append("\n注意:用户已连续").append(consecutiveNegative)
+              .append("天情绪偏低,建议优先进行情绪疏导和倾听。");
+        } else if (consecutiveNegative >= 1) {
+            sb.append("\n注意:用户近").append(consecutiveNegative)
+              .append("天情绪偏低,可适当关注。");
+        }
+
+        return sb.toString();
+    }
 }

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

@@ -0,0 +1,13 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import java.util.List;
+
+public interface DimensionScoreService {
+    HealthDimensionVO getHealthDimension(Long memberId);
+    void uploadScore(DimensionUploadVO vo);
+    void submitQuestionnaire(HealthDimensionQuestionnaireVO vo);
+    List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit);
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmiReportService.java

@@ -39,6 +39,10 @@ public class EmiReportService {
         dto.setAgreeablenessScore(result.getAgreeablenessScore());
         dto.setNeuroticismScore(result.getNeuroticismScore());
         dto.setBigFiveOverallScore(result.getBigFiveOverallScore());
+        dto.setEmotionManagementScore(result.getEmotionManagementScore());
+        dto.setEmpathyScore(result.getEmpathyScore());
+        dto.setSocialAdaptabilityScore(result.getSocialAdaptabilityScore());
+        dto.setSelfMotivationScore(result.getSelfMotivationScore());
         dto.setAnalysisReport(result.getAnalysisReport());
         dto.setGrowthSuggestions(result.getGrowthSuggestions());
         dto.setAssessmentDate(result.getAssessmentDate());

+ 218 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EmotionCheckinService.java

@@ -0,0 +1,218 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.EmotionCheckinDTO;
+import com.etotem.cfc.dto.EmotionCheckinVO;
+import com.etotem.cfc.dto.EmotionStatsVO;
+import com.etotem.cfc.dto.EmotionTrendVO;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.EmotionCheckin;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.EmotionCheckinMapper;
+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 EmotionCheckinService {
+
+    @Resource
+    private EmotionCheckinMapper emotionCheckinMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    @Transactional
+    public EmotionCheckinVO createCheckin(EmotionCheckinDTO dto, Long userId) {
+        Child child = childMapper.selectById(dto.getChildId());
+        if (child == null) {
+            throw new RuntimeException("孩子不存在");
+        }
+
+        EmotionCheckin checkin = new EmotionCheckin();
+        checkin.setChildId(dto.getChildId());
+        checkin.setMoodWeather(dto.getMoodWeather());
+        checkin.setEmotionTags(dto.getEmotionTags());
+        checkin.setPhotoUrl(dto.getPhotoUrl());
+        checkin.setNote(dto.getNote());
+        checkin.setEnergyAwarded(5);
+        checkin.setCheckinDate(new Date());
+        emotionCheckinMapper.insert(checkin);
+
+        awardEnergy(checkin.getId(), dto.getChildId());
+
+        return toVO(checkin, child.getNickname(), false);
+    }
+
+    private void awardEnergy(Long checkinId, Long childId) {
+        try {
+            energyService.awardEnergy(childId, "emotion_checkin", checkinId, 5, "情绪打卡奖励", 0);
+        } catch (Exception e) {
+            log.error("奖励心能量失败: checkinId={}, childId={}", checkinId, childId, e);
+        }
+    }
+
+    public List<EmotionCheckinVO> getCheckinList(Long childId, int page, int size, Long currentUserId, boolean hidePhoto) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> list = emotionCheckinMapper.selectList(wrapper);
+
+        Child child = childMapper.selectById(childId);
+        String childName = child != null ? child.getNickname() : "";
+
+        int start = (page - 1) * size;
+        int end = Math.min(start + size, list.size());
+        if (start >= list.size()) return new ArrayList<>();
+
+        List<EmotionCheckinVO> vos = new ArrayList<>();
+        for (EmotionCheckin ec : list.subList(start, end)) {
+            boolean isOwner = currentUserId != null && currentUserId.equals(childId);
+            vos.add(toVO(ec, childName, hidePhoto && !isOwner));
+        }
+        return vos;
+    }
+
+    public EmotionCheckinVO getLatestCheckin(Long childId, Long currentUserId, boolean hidePhoto) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCreatedAt)
+                .last("LIMIT 1");
+        EmotionCheckin checkin = emotionCheckinMapper.selectOne(wrapper);
+        if (checkin == null) return null;
+
+        Child child = childMapper.selectById(childId);
+        String childName = child != null ? child.getNickname() : "";
+        boolean isOwner = currentUserId != null && currentUserId.equals(childId);
+        return toVO(checkin, childName, hidePhoto && !isOwner);
+    }
+
+    public EmotionTrendVO getTrend(Long childId, String period) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> all = emotionCheckinMapper.selectList(wrapper);
+
+        Calendar cal = Calendar.getInstance();
+        if ("week".equals(period)) {
+            cal.add(Calendar.DAY_OF_YEAR, -7);
+        } else {
+            cal.add(Calendar.DAY_OF_YEAR, -30);
+        }
+        Date cutoff = cal.getTime();
+
+        List<EmotionCheckin> filtered = all.stream()
+                .filter(e -> e.getCheckinDate() != null && e.getCheckinDate().after(cutoff))
+                .collect(Collectors.toList());
+
+        Map<String, Integer> distribution = new HashMap<>();
+        List<Map<String, Object>> dailyData = new ArrayList<>();
+        int totalEnergy = 0;
+
+        for (EmotionCheckin e : filtered) {
+            String weather = e.getMoodWeather();
+            distribution.put(weather, distribution.getOrDefault(weather, 0) + 1);
+            if (e.getEnergyAwarded() != null) totalEnergy += e.getEnergyAwarded();
+
+            Map<String, Object> day = new HashMap<>();
+            day.put("date", e.getCheckinDate());
+            day.put("weather", e.getMoodWeather());
+            dailyData.add(day);
+        }
+
+        EmotionTrendVO vo = new EmotionTrendVO();
+        vo.setPeriod(period);
+        vo.setWeatherDistribution(distribution);
+        vo.setDailyData(dailyData);
+        vo.setTotalCheckins(filtered.size());
+        vo.setTotalEnergy(totalEnergy);
+        return vo;
+    }
+
+    public EmotionStatsVO getStats(Long childId) {
+        LambdaQueryWrapper<EmotionCheckin> wrapper = new LambdaQueryWrapper<EmotionCheckin>()
+                .eq(EmotionCheckin::getChildId, childId)
+                .orderByDesc(EmotionCheckin::getCheckinDate);
+        List<EmotionCheckin> all = emotionCheckinMapper.selectList(wrapper);
+
+        Map<String, Integer> breakdown = new HashMap<>();
+        int totalEnergy = 0;
+        for (EmotionCheckin e : all) {
+            String weather = e.getMoodWeather();
+            breakdown.put(weather, breakdown.getOrDefault(weather, 0) + 1);
+            if (e.getEnergyAwarded() != null) totalEnergy += e.getEnergyAwarded();
+        }
+
+        int currentStreak = calculateStreak(all);
+        int thisMonthCount = countThisMonth(all);
+
+        EmotionStatsVO vo = new EmotionStatsVO();
+        vo.setCurrentStreak(currentStreak);
+        vo.setTotalCheckins(all.size());
+        vo.setTotalEnergy(totalEnergy);
+        vo.setWeatherBreakdown(breakdown);
+        vo.setThisMonthCheckins(thisMonthCount);
+        return vo;
+    }
+
+    private int calculateStreak(List<EmotionCheckin> list) {
+        if (list.isEmpty()) return 0;
+        Calendar cal = Calendar.getInstance();
+        int streak = 0;
+        for (EmotionCheckin e : list) {
+            if (e.getCheckinDate() == null) continue;
+            Calendar ec = Calendar.getInstance();
+            ec.setTime(e.getCheckinDate());
+            if (isSameDay(cal, ec)) {
+                streak++;
+                cal.add(Calendar.DAY_OF_YEAR, -1);
+            }
+        }
+        return streak;
+    }
+
+    private boolean isSameDay(Calendar a, Calendar b) {
+        return a.get(Calendar.YEAR) == b.get(Calendar.YEAR)
+                && a.get(Calendar.DAY_OF_YEAR) == b.get(Calendar.DAY_OF_YEAR);
+    }
+
+    private int countThisMonth(List<EmotionCheckin> list) {
+        Calendar now = Calendar.getInstance();
+        int year = now.get(Calendar.YEAR);
+        int month = now.get(Calendar.MONTH);
+        int count = 0;
+        for (EmotionCheckin e : list) {
+            if (e.getCheckinDate() == null) continue;
+            Calendar ec = Calendar.getInstance();
+            ec.setTime(e.getCheckinDate());
+            if (ec.get(Calendar.YEAR) == year && ec.get(Calendar.MONTH) == month) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private EmotionCheckinVO toVO(EmotionCheckin ec, String childName, boolean hidePhoto) {
+        EmotionCheckinVO vo = new EmotionCheckinVO();
+        vo.setId(ec.getId());
+        vo.setChildId(ec.getChildId());
+        vo.setChildName(childName);
+        vo.setMoodWeather(ec.getMoodWeather());
+        vo.setEmotionTags(ec.getEmotionTags());
+        vo.setPhotoUrl(hidePhoto ? null : ec.getPhotoUrl());
+        vo.setNote(ec.getNote());
+        vo.setEnergyAwarded(ec.getEnergyAwarded());
+        vo.setCheckinDate(ec.getCheckinDate());
+        vo.setCreatedAt(ec.getCreatedAt());
+        return vo;
+    }
+}

+ 63 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java

@@ -1,12 +1,11 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.*;
-import com.etotem.cfc.mapper.FamilyMemberAttributesMapper;
-import com.etotem.cfc.mapper.ZodiacConfigMapper;
-import com.etotem.cfc.mapper.BaziConfigMapper;
-import com.etotem.cfc.mapper.BloodTypeConfigMapper;
+import com.etotem.cfc.mapper.*;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -25,6 +24,12 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
     @Resource
     private BloodTypeConfigMapper bloodTypeConfigMapper;
 
+    @Resource
+    private BaziReadingTemplateMapper baziReadingTemplateMapper;
+
+    @Resource
+    private NumSoulConfigMapper numSoulConfigMapper;
+
     // ========== Chinese Zodiac constants ==========
     private static final String[] ZODIAC_CODES = {"rat", "ox", "tiger", "rabbit", "dragon", "snake",
             "horse", "sheep", "monkey", "rooster", "dog", "pig"};
@@ -298,6 +303,60 @@ public class FamilyMemberAttributeService extends ServiceImpl<FamilyMemberAttrib
         return result;
     }
 
+    /**
+     * Get wuxing reading based on wuxing element distribution.
+     * Identifies the dominant element and its strength to match bazi_reading_template.
+     *
+     * @param wuxingElementsJson JSON with wuxing percentages: {"wood":30,"fire":45,...}
+     * @return BaziReadingTemplate matching the dominant element+condition, or null
+     */
+    public BaziReadingTemplate getWuxingReading(String wuxingElementsJson) {
+        if (wuxingElementsJson == null || wuxingElementsJson.isEmpty()) {
+            return null;
+        }
+        Map<String, Object> wuxingMap = fromJson(wuxingElementsJson);
+        if (wuxingMap.isEmpty()) return null;
+
+        // Find dominant element (highest percentage)
+        String dominantElement = null;
+        double maxPct = 0;
+        for (Map.Entry<String, Object> entry : wuxingMap.entrySet()) {
+            double val = entry.getValue() instanceof Number ? ((Number) entry.getValue()).doubleValue() : 0;
+            if (val > maxPct) {
+                maxPct = val;
+                dominantElement = entry.getKey();
+            }
+        }
+        if (dominantElement == null) return null;
+
+        // Determine condition: > 30% strong, < 15% weak, otherwise balanced
+        String condition;
+        if (maxPct >= 30) {
+            condition = "strong";
+        } else if (maxPct <= 15) {
+            condition = "weak";
+        } else {
+            condition = "balanced";
+        }
+
+        // Match from template
+        LambdaQueryWrapper<BaziReadingTemplate> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(BaziReadingTemplate::getElement, dominantElement);
+        wrapper.eq(BaziReadingTemplate::getCondition, condition);
+        return baziReadingTemplateMapper.selectOne(wrapper);
+    }
+
+    /**
+     * Get numsoul config by life number.
+     *
+     * @param lifeNumber the life number (1-9) from behaviorModifier
+     * @return NumSoulConfig for that number, or null
+     */
+    public NumSoulConfig getNumSoulConfig(Integer lifeNumber) {
+        if (lifeNumber == null || lifeNumber < 1 || lifeNumber > 9) return null;
+        return numSoulConfigMapper.selectById(lifeNumber);
+    }
+
     // ========== Helper methods ==========
 
     /**

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthDataSourceRecordService.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import java.util.List;
+
+public interface HealthDataSourceRecordService {
+    void saveRecord(HealthDataSourceRecord record);
+    void markLatest(Long memberId, Long sourceId);
+    List<HealthDataSourceRecord> getRecords(Long memberId, String dataSource);
+    HealthDataSourceRecord getLatest(Long memberId, String dataSource);
+}

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

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthDimensionScore;
+import java.util.List;
+
+public interface HealthDimensionScoreService {
+    void saveScore(HealthDimensionScore score);
+    List<HealthDimensionScore> getLatestScores(Long memberId);
+    HealthDimensionScore getLatestScore(Long memberId, String dimension);
+    List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit);
+    void markExpired(Long memberId, String dimension);
+}

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

@@ -453,4 +453,65 @@ public class HealthReportService {
         }
         return null;
     }
+
+    /**
+     * Get mind-related indicators from the latest gut microbiome report.
+     * Filters indicators by categories related to mood/emotion:
+     * - 神经递质与激素 (neurotransmitters & hormones)
+     * - 短链脂肪酸 (short-chain fatty acids, gut-brain axis)
+     *
+     * @param childId the child's ID
+     * @return list of mood-related HealthIndicator
+     */
+    public List<HealthIndicator> getMindRelatedIndicators(Long childId) {
+        // Get latest gut flora report for this child
+        LambdaQueryWrapper<HealthReport> reportWrapper = new LambdaQueryWrapper<>();
+        reportWrapper.eq(HealthReport::getUserId, childId);
+        reportWrapper.eq(HealthReport::getReportType, "gut_flora");
+        reportWrapper.orderByDesc(HealthReport::getCreatedAt);
+        reportWrapper.last("LIMIT 1");
+        HealthReport report = healthReportMapper.selectOne(reportWrapper);
+        if (report == null) {
+            return Collections.emptyList();
+        }
+
+        // Get indicators matching mood-related categories
+        LambdaQueryWrapper<HealthIndicator> indicatorWrapper = new LambdaQueryWrapper<>();
+        indicatorWrapper.eq(HealthIndicator::getReportId, report.getId());
+        indicatorWrapper.in(HealthIndicator::getCategory, "神经递质与激素", "短链脂肪酸");
+        indicatorWrapper.orderByAsc(HealthIndicator::getSortOrder);
+        return healthIndicatorMapper.selectList(indicatorWrapper);
+    }
+
+    /**
+     * Get gut-emotion insight summary based on neurotransmitter and SCFA indicators.
+     *
+     * @param childId the child's ID
+     * @return map with summary text and indicator highlights
+     */
+    public Map<String, Object> getGutEmotionInsight(Long childId) {
+        List<HealthIndicator> indicators = getMindRelatedIndicators(childId);
+        if (indicators.isEmpty()) {
+            Map<String, Object> empty = new HashMap<>();
+            empty.put("summary", "暂无菌群报告数据");
+            empty.put("indicators", Collections.emptyList());
+            return empty;
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("indicators", indicators);
+
+        // Simple summary based on indicator statuses
+        long abnormalCount = indicators.stream()
+                .filter(i -> "abnormal".equals(i.getStatus()) || "偏高".equals(i.getStatus()) || "偏低".equals(i.getStatus()))
+                .count();
+        if (abnormalCount == 0) {
+            result.put("summary", "神经递质指标正常,肠道情绪状态良好");
+        } else if (abnormalCount <= 2) {
+            result.put("summary", "部分神经递质指标略有波动,可关注相关营养补充");
+        } else {
+            result.put("summary", "多项神经递质指标异常,建议咨询专业医生或营养师");
+        }
+        return result;
+    }
 }

+ 177 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/DimensionScoreServiceImpl.java

@@ -0,0 +1,177 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.dto.HealthDimensionVO;
+import com.etotem.cfc.dto.DimensionUploadVO;
+import com.etotem.cfc.dto.HealthDimensionQuestionnaireVO;
+import com.etotem.cfc.entity.HealthDimensionScore;
+import com.etotem.cfc.entity.HealthNormReference;
+import com.etotem.cfc.mapper.HealthNormReferenceMapper;
+import com.etotem.cfc.service.DimensionScoreService;
+import com.etotem.cfc.service.HealthDimensionScoreService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class DimensionScoreServiceImpl implements DimensionScoreService {
+
+    private static final List<String> DIMENSION_ORDER = java.util.Arrays.asList(
+        "body", "fitness", "cardio", "blood", "sleep", "psyche", "diet");
+
+    private static final List<String[]> DIMENSION_LABELS = java.util.Arrays.asList(
+        new String[]{"body", "身体"},
+        new String[]{"fitness", "运动"},
+        new String[]{"cardio", "心肺"},
+        new String[]{"blood", "血液"},
+        new String[]{"sleep", "睡眠"},
+        new String[]{"psyche", "心理"},
+        new String[]{"diet", "饮食"}
+    );
+
+    @Resource
+    private HealthDimensionScoreService scoreService;
+
+    @Resource
+    private HealthNormReferenceMapper normMapper;
+
+    @Override
+    public HealthDimensionVO getHealthDimension(Long memberId) {
+        List<HealthDimensionScore> latest = scoreService.getLatestScores(memberId);
+        BigDecimal healthIndex = calculateHealthIndex(latest);
+
+        List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
+        for (String[] dim : DIMENSION_LABELS) {
+            String code = dim[0];
+            String label = dim[1];
+            HealthDimensionScore found = latest.stream()
+                .filter(s -> code.equals(s.getDimension()))
+                .findFirst().orElse(null);
+
+            HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
+            item.setDimension(code);
+            item.setLabel(label);
+            if (found != null) {
+                item.setScore(found.getScore());
+                item.setTrend("stable");
+                String level = calcLevel(found.getScore());
+                item.setLevel(level);
+            } else {
+                item.setScore(null);
+                item.setLevel("none");
+                item.setTrend(null);
+            }
+
+            HealthNormReference norm = normMapper.selectByDimension(code);
+            if (norm != null) {
+                item.setNormMin(norm.getMinValue());
+                item.setNormMax(norm.getMaxValue());
+            }
+            items.add(item);
+        }
+
+        HealthDimensionVO vo = new HealthDimensionVO();
+        vo.setMemberId(memberId);
+        vo.setHealthIndex(healthIndex);
+        vo.setDimensions(items);
+        vo.setUpdateTime(new Date());
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public void uploadScore(DimensionUploadVO vo) {
+        scoreService.markExpired(vo.getMemberId(), vo.getDimension());
+
+        HealthDimensionScore score = new HealthDimensionScore();
+        score.setMemberId(vo.getMemberId());
+        score.setDimension(vo.getDimension());
+        score.setScore(vo.getScore());
+        score.setDataSource(vo.getDataSource() != null ? vo.getDataSource() : "manual");
+        if (vo.getRecordDate() != null) {
+            score.setRecordDate(java.sql.Date.valueOf(vo.getRecordDate()));
+        } else {
+            score.setRecordDate(new java.sql.Date(System.currentTimeMillis()));
+        }
+        score.setIsLatest(true);
+        score.setSourceId(null);
+        scoreService.saveScore(score);
+    }
+
+    @Override
+    @Transactional
+    public void submitQuestionnaire(HealthDimensionQuestionnaireVO vo) {
+        BigDecimal total = BigDecimal.ZERO;
+        int count = 0;
+        for (HealthDimensionQuestionnaireVO.QuestionAnswer qa : vo.getAnswers()) {
+            if (qa.getScore() != null) {
+                total = total.add(qa.getScore());
+                count++;
+            }
+        }
+        BigDecimal avg = count > 0 ? total.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP) : null;
+
+        if (avg != null) {
+            scoreService.markExpired(vo.getMemberId(), vo.getDimension());
+            HealthDimensionScore score = new HealthDimensionScore();
+            score.setMemberId(vo.getMemberId());
+            score.setDimension(vo.getDimension());
+            score.setScore(avg);
+            score.setDataSource("questionnaire");
+            score.setRecordDate(new java.sql.Date(System.currentTimeMillis()));
+            score.setIsLatest(true);
+            scoreService.saveScore(score);
+        }
+    }
+
+    @Override
+    public List<HealthDimensionVO.DimensionItem> getDimensionHistory(Long memberId, String dimension, int limit) {
+        List<HealthDimensionScore> history = scoreService.getHistoryScores(memberId, dimension, limit);
+        List<HealthDimensionVO.DimensionItem> items = new ArrayList<>();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        for (HealthDimensionScore s : history) {
+            HealthDimensionVO.DimensionItem item = new HealthDimensionVO.DimensionItem();
+            item.setDimension(s.getDimension());
+            item.setScore(s.getScore());
+            item.setLevel(calcLevel(s.getScore()));
+            item.setTrend("stable");
+            if (s.getRecordDate() != null) {
+                item.setRecordDate(sdf.format(s.getRecordDate()));
+            }
+            HealthNormReference norm = normMapper.selectByDimension(dimension);
+            if (norm != null) {
+                item.setNormMin(norm.getMinValue());
+                item.setNormMax(norm.getMaxValue());
+            }
+            items.add(item);
+        }
+        return items;
+    }
+
+    private BigDecimal calculateHealthIndex(List<HealthDimensionScore> scores) {
+        if (scores.isEmpty()) return null;
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (HealthDimensionScore s : scores) {
+            if (s.getScore() != null) {
+                sum = sum.add(s.getScore());
+                count++;
+            }
+        }
+        if (count == 0) return null;
+        return sum.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP);
+    }
+
+    private String calcLevel(BigDecimal score) {
+        if (score == null) return "none";
+        if (score.compareTo(new BigDecimal("85")) >= 0) return "excellent";
+        if (score.compareTo(new BigDecimal("70")) >= 0) return "good";
+        if (score.compareTo(new BigDecimal("60")) >= 0) return "fair";
+        return "poor";
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDataSourceRecordServiceImpl.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.HealthDataSourceRecord;
+import com.etotem.cfc.mapper.HealthDataSourceRecordMapper;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class HealthDataSourceRecordServiceImpl implements HealthDataSourceRecordService {
+
+    @Resource
+    private HealthDataSourceRecordMapper recordMapper;
+
+    @Override
+    @Transactional
+    public void saveRecord(HealthDataSourceRecord record) {
+        recordMapper.insert(record);
+    }
+
+    @Override
+    @Transactional
+    public void markLatest(Long memberId, Long sourceId) {
+        recordMapper.updateNotLatest(memberId, sourceId);
+    }
+
+    @Override
+    public List<HealthDataSourceRecord> getRecords(Long memberId, String dataSource) {
+        return recordMapper.selectByMemberAndSource(memberId, dataSource);
+    }
+
+    @Override
+    public HealthDataSourceRecord getLatest(Long memberId, String dataSource) {
+        return recordMapper.selectLatest(memberId, dataSource);
+    }
+}

+ 48 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthDimensionScoreServiceImpl.java

@@ -0,0 +1,48 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.HealthDimensionScore;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
+import com.etotem.cfc.service.HealthDimensionScoreService;
+import com.etotem.cfc.service.HealthDataSourceRecordService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class HealthDimensionScoreServiceImpl implements HealthDimensionScoreService {
+
+    @Resource
+    private HealthDimensionScoreMapper scoreMapper;
+
+    @Resource
+    private HealthDataSourceRecordService dataSourceRecordService;
+
+    @Override
+    @Transactional
+    public void saveScore(HealthDimensionScore score) {
+        scoreMapper.insert(score);
+        dataSourceRecordService.markLatest(score.getMemberId(), score.getId());
+    }
+
+    @Override
+    public List<HealthDimensionScore> getLatestScores(Long memberId) {
+        return scoreMapper.selectLatestByMember(memberId);
+    }
+
+    @Override
+    public HealthDimensionScore getLatestScore(Long memberId, String dimension) {
+        return scoreMapper.selectLatest(memberId, dimension);
+    }
+
+    @Override
+    public List<HealthDimensionScore> getHistoryScores(Long memberId, String dimension, int limit) {
+        return scoreMapper.selectHistory(memberId, dimension, limit);
+    }
+
+    @Override
+    @Transactional
+    public void markExpired(Long memberId, String dimension) {
+        scoreMapper.updateExpired(memberId, dimension);
+    }
+}

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ActivityAdminServiceTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.mapper.ActivityMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * ActivityAdminService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class ActivityAdminServiceTest {
+
+    private ActivityAdminService activityAdminService;
+
+    @Mock
+    private ActivityMapper activityMapper;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        activityAdminService = new ActivityAdminService();
+        setField(activityAdminService, "activityMapper", activityMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private Activity mockActivity(Long id, String status, String dimensionCode) {
+        Activity a = new Activity();
+        a.setId(id);
+        a.setStatus(status);
+        a.setDimensionCode(dimensionCode);
+        a.setTitle("测试活动");
+        a.setUpdatedAt(new Date());
+        return a;
+    }
+
+    // ==================== list ====================
+
+    @Test
+    public void list_defaultPagination() {
+        List<Activity> records = Arrays.asList(
+            mockActivity(1L, "published", "body"),
+            mockActivity(2L, "draft", "mind")
+        );
+        Page<Activity> page = new Page<>(1, 20);
+        page.setRecords(records);
+        page.setTotal(2);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(new HashMap<>());
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals(Long.valueOf(2), result.getData().get("total"));
+        assertEquals(1, result.getData().get("page"));
+        assertEquals(20, result.getData().get("size"));
+        assertEquals(2, ((List) result.getData().get("records")).size());
+    }
+
+    @Test
+    public void list_withStatusFilter() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("status", "draft");
+        params.put("page", 2);
+        params.put("size", 10);
+
+        List<Activity> records = Collections.singletonList(mockActivity(3L, "draft", "mind"));
+        Page<Activity> page = new Page<>(2, 10);
+        page.setRecords(records);
+        page.setTotal(1);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().get("page"));
+        assertEquals(10, result.getData().get("size"));
+    }
+
+    @Test
+    public void list_emptyResult() {
+        Page<Activity> page = new Page<>(1, 20);
+        page.setRecords(Collections.emptyList());
+        page.setTotal(0);
+        when(activityMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> result = activityAdminService.list(new HashMap<>());
+
+        assertEquals(200, result.getCode());
+        assertEquals(Long.valueOf(0), result.getData().get("total"));
+        assertTrue(((List) result.getData().get("records")).isEmpty());
+    }
+
+    // ==================== review ====================
+
+    @Test
+    public void review_publish_success() {
+        Activity draft = mockActivity(5L, "draft", "body");
+        when(activityMapper.selectById(5L)).thenReturn(draft);
+        when(activityMapper.updateById(any())).thenReturn(1);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 5L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals("发布成功", result.getData());
+        assertEquals("published", draft.getStatus());
+        verify(activityMapper).updateById(draft);
+    }
+
+    @Test
+    public void review_reject_deletesActivity() {
+        Activity draft = mockActivity(6L, "draft", "mind");
+        when(activityMapper.selectById(6L)).thenReturn(draft);
+        when(activityMapper.deleteById(6L)).thenReturn(1);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "reject");
+        params.put("id", 6L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(200, result.getCode());
+        assertEquals("已驳回并删除", result.getData());
+        verify(activityMapper).deleteById(6L);
+    }
+
+    @Test
+    public void review_publish_nonDraft_fails() {
+        Activity published = mockActivity(7L, "published", "body");
+        when(activityMapper.selectById(7L)).thenReturn(published);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 7L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("仅草稿状态可发布"));
+    }
+
+    @Test
+    public void review_activityNotFound() {
+        when(activityMapper.selectById(999L)).thenReturn(null);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        params.put("id", 999L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("活动不存在"));
+    }
+
+    @Test
+    public void review_invalidAction() {
+        Activity draft = mockActivity(8L, "draft", "body");
+        when(activityMapper.selectById(8L)).thenReturn(draft);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "unknown");
+        params.put("id", 8L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("无效的action"));
+    }
+
+    @Test
+    public void review_missingId() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("action", "publish");
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("id不能为空"));
+    }
+
+    @Test
+    public void review_missingAction() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("id", 5L);
+        Result<String> result = activityAdminService.review(params);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage().contains("action不能为空"));
+    }
+}

+ 207 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FoodServiceTest.java

@@ -0,0 +1,207 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * FoodService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class FoodServiceTest {
+
+    private FoodService foodService;
+
+    @Mock
+    private FoodMapper foodMapper;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        foodService = new FoodService();
+        setField(foodService, "foodMapper", foodMapper);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private Food mockFood(Long id, String name, String category, String status, Long reportId) {
+        Food f = new Food();
+        f.setId(id);
+        f.setName(name);
+        f.setCategory(category);
+        f.setStatus(status);
+        f.setReportId(reportId);
+        f.setCalories(new BigDecimal("120.5"));
+        f.setProtein(new BigDecimal("10.2"));
+        f.setSortOrder(1);
+        return f;
+    }
+
+    // ==================== listAll ====================
+
+    @Test
+    public void listAll_noFilters_returnsAllActive() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "菠菜", "vegetable", "active", null),
+            mockFood(2L, "鸡胸肉", "meat", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll(null, null);
+
+        assertEquals(2, result.size());
+    }
+
+    @Test
+    public void listAll_withCategoryFilter() {
+        List<Food> foods = Collections.singletonList(
+            mockFood(1L, "菠菜", "vegetable", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll("vegetable", "active");
+
+        assertEquals(1, result.size());
+        assertEquals("菠菜", result.get(0).getName());
+    }
+
+    @Test
+    public void listAll_withStatusOnly() {
+        List<Food> foods = Collections.singletonList(
+            mockFood(2L, "鸡胸肉", "meat", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listAll(null, "active");
+
+        assertEquals(1, result.size());
+    }
+
+    // ==================== listByPriceLevel ====================
+
+    @Test
+    public void listByPriceLevel_returnsActiveBelowMaxLevel() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "豆腐", "bean", "active", null),
+            mockFood(2L, "鸡蛋", "egg", "active", null)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listByPriceLevel(2);
+
+        assertEquals(2, result.size());
+        verify(foodMapper).selectList(any());
+    }
+
+    // ==================== getById ====================
+
+    @Test
+    public void getById_found() {
+        Food food = mockFood(3L, "西兰花", "vegetable", "active", null);
+        when(foodMapper.selectById(3L)).thenReturn(food);
+
+        Food result = foodService.getById(3L);
+
+        assertNotNull(result);
+        assertEquals("西兰花", result.getName());
+    }
+
+    @Test
+    public void getById_notFound() {
+        when(foodMapper.selectById(999L)).thenReturn(null);
+
+        Food result = foodService.getById(999L);
+
+        assertNull(result);
+    }
+
+    // ==================== create ====================
+
+    @Test
+    public void create_insertsFood() {
+        Food food = mockFood(null, "三文鱼", "fish", "active", null);
+        when(foodMapper.insert(any(Food.class))).thenAnswer(invocation -> {
+            food.setId(1L);
+            return 1;
+        });
+
+        foodService.create(food);
+
+        verify(foodMapper).insert(food);
+        assertNotNull(food.getId());
+    }
+
+    // ==================== update ====================
+
+    @Test
+    public void update_callsUpdateById() {
+        Food food = mockFood(10L, "牛肉", "meat", "active", null);
+        food.setCalories(new BigDecimal("250.0"));
+        when(foodMapper.updateById(any(Food.class))).thenReturn(1);
+
+        foodService.update(food);
+
+        verify(foodMapper).updateById(food);
+    }
+
+    // ==================== delete ====================
+
+    @Test
+    public void delete_deletesById() {
+        when(foodMapper.deleteById(11L)).thenReturn(1);
+
+        foodService.delete(11L);
+
+        verify(foodMapper).deleteById(11L);
+    }
+
+    // ==================== listByReportId ====================
+
+    @Test
+    public void listByReportId_withValidReportId() {
+        List<Food> foods = Arrays.asList(
+            mockFood(1L, "推荐食材A", "vegetable", "active", 5L),
+            mockFood(2L, "推荐食材B", "meat", "active", 5L)
+        );
+        when(foodMapper.selectList(any())).thenReturn(foods);
+
+        List<Food> result = foodService.listByReportId(5L);
+
+        assertEquals(2, result.size());
+        verify(foodMapper).selectList(any());
+    }
+
+    @Test
+    public void listByReportId_withNullReportId_returnsEmptyList() {
+        List<Food> result = foodService.listByReportId(null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+        verify(foodMapper, never()).selectList(any());
+    }
+
+    @Test
+    public void listByReportId_noMatchingFoods_returnsEmptyList() {
+        when(foodMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<Food> result = foodService.listByReportId(99L);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+}

+ 288 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/NutritionDeficiencyServiceTest.java

@@ -0,0 +1,288 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.mapper.NutritionDeficiencyRecordMapper;
+import com.etotem.cfc.mapper.NutritionIndicatorMappingMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * NutritionDeficiencyService 纯单元测试
+ * 使用 MockitoExtension(无 Spring 上下文,无 MySQL 连接)
+ */
+public class NutritionDeficiencyServiceTest {
+
+    private NutritionDeficiencyService nutritionDeficiencyService;
+
+    @Mock
+    private NutritionIndicatorMappingMapper mappingMapper;
+
+    @Mock
+    private NutritionDeficiencyRecordMapper recordMapper;
+
+    @Mock
+    private HealthReportService healthReportService;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        nutritionDeficiencyService = new NutritionDeficiencyService();
+        setField(nutritionDeficiencyService, "mappingMapper", mappingMapper);
+        setField(nutritionDeficiencyService, "recordMapper", recordMapper);
+        setField(nutritionDeficiencyService, "healthReportService", healthReportService);
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private NutritionIndicatorMapping mockMapping(Long id, String nutrient, String pattern, Integer sortOrder) {
+        NutritionIndicatorMapping m = new NutritionIndicatorMapping();
+        m.setId(id);
+        m.setNutrient(nutrient);
+        m.setIndicatorNamePattern(pattern);
+        m.setSortOrder(sortOrder);
+        m.setDeficiencySymptoms("缺乏" + nutrient);
+        m.setExcessSymptoms("过量" + nutrient);
+        m.setFoodSuggestions("建议食用含" + nutrient + "的食物");
+        m.setSeverityWeight(BigDecimal.ONE);
+        return m;
+    }
+
+    private HealthReport mockReport(Long id, Long userId) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setUserId(userId);
+        r.setReportDate(new Date());
+        r.setNutritionScore(55);
+        r.setGutHealthScore(60);
+        return r;
+    }
+
+    private HealthIndicator mockIndicator(Long id, Long reportId, String name, String value, String status) {
+        HealthIndicator i = new HealthIndicator();
+        i.setId(id);
+        i.setReportId(reportId);
+        i.setIndicatorName(name);
+        i.setIndicatorValue(value);
+        i.setStatus(status);
+        i.setRefRange("20-100");
+        return i;
+    }
+
+    // ==================== getAllMappings ====================
+
+    @Test
+    public void getAllMappings_returnsAllMappingsOrdered() {
+        List<NutritionIndicatorMapping> mappings = Arrays.asList(
+            mockMapping(1L, "铁", "铁蛋白", 2),
+            mockMapping(2L, "维生素D", "维生素D", 1)
+        );
+        when(mappingMapper.selectList(any())).thenReturn(mappings);
+
+        List<NutritionIndicatorMapping> result = nutritionDeficiencyService.getAllMappings();
+
+        assertEquals(2, result.size());
+    }
+
+    // ==================== saveMapping ====================
+
+    @Test
+    public void saveMapping_insert_whenIdNull() {
+        NutritionIndicatorMapping m = mockMapping(null, "钙", "钙", 1);
+        when(mappingMapper.insert(any())).thenReturn(1);
+
+        nutritionDeficiencyService.saveMapping(m);
+
+        verify(mappingMapper).insert(m);
+    }
+
+    @Test
+    public void saveMapping_update_whenIdNotNull() {
+        NutritionIndicatorMapping m = mockMapping(5L, "锌", "锌", 1);
+        when(mappingMapper.updateById(any())).thenReturn(1);
+
+        nutritionDeficiencyService.saveMapping(m);
+
+        verify(mappingMapper).updateById(m);
+    }
+
+    // ==================== deleteMapping ====================
+
+    @Test
+    public void deleteMapping_callsDeleteById() {
+        when(mappingMapper.deleteById(3L)).thenReturn(1);
+
+        nutritionDeficiencyService.deleteMapping(3L);
+
+        verify(mappingMapper).deleteById(3L);
+    }
+
+    // ==================== getDeficiencyRecords ====================
+
+    @Test
+    public void getDeficiencyRecords_withChildIdOnly() {
+        List<NutritionDeficiencyRecord> records = Collections.singletonList(new NutritionDeficiencyRecord());
+        when(recordMapper.selectList(any())).thenReturn(records);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.getDeficiencyRecords(5L, null);
+
+        assertEquals(1, result.size());
+        verify(recordMapper).selectList(any());
+    }
+
+    @Test
+    public void getDeficiencyRecords_withChildIdAndReportId() {
+        when(recordMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.getDeficiencyRecords(5L, 10L);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    // ==================== analyzeDeficiency ====================
+
+    @Test
+    public void analyzeDeficiency_noReport_returnsEmpty() {
+        when(healthReportService.getLatestReport(5L)).thenReturn(null);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_noIndicators_returnsEmpty() {
+        when(healthReportService.getLatestReport(5L)).thenReturn(mockReport(1L, 5L));
+        when(healthReportService.getReportIndicators(1L)).thenReturn(Collections.emptyList());
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertNotNull(result);
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_withMatchingIndicator_createsRecord() {
+        HealthReport report = mockReport(2L, 5L);
+        HealthIndicator indicator = mockIndicator(1L, 2L, "铁蛋白", "15", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(1L, "铁", "铁蛋白", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(2L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("铁", result.get(0).getNutrient());
+        assertEquals("deficiency", result.get(0).getDeficiencyType());
+        assertEquals("铁蛋白", result.get(0).getIndicatorName());
+        assertEquals("15", result.get(0).getIndicatorValue());
+    }
+
+    @Test
+    public void analyzeDeficiency_excessStatus_mapsToExcess() {
+        HealthReport report = mockReport(3L, 5L);
+        HealthIndicator indicator = mockIndicator(2L, 3L, "胆固醇", "250", "偏高");
+        NutritionIndicatorMapping mapping = mockMapping(2L, "胆固醇", "胆固醇", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(3L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("excess", result.get(0).getDeficiencyType());
+    }
+
+    @Test
+    public void analyzeDeficiency_borderlineStatus_mapsToBorderline() {
+        HealthReport report = mockReport(4L, 5L);
+        HealthIndicator indicator = mockIndicator(3L, 4L, "血糖", "6.5", "临界");
+        NutritionIndicatorMapping mapping = mockMapping(3L, "血糖", "血糖", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(4L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertEquals(1, result.size());
+        assertEquals("borderline", result.get(0).getDeficiencyType());
+    }
+
+    @Test
+    public void analyzeDeficiency_noMatchingMapping_skipsIndicator() {
+        HealthReport report = mockReport(5L, 5L);
+        HealthIndicator indicator = mockIndicator(4L, 5L, "未知指标", "50", "偏低");
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(5L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(recordMapper.delete(any())).thenReturn(0);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, null);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    public void analyzeDeficiency_usesReportIdFromParam_whenProvided() {
+        HealthReport report = mockReport(6L, 5L);
+        HealthIndicator indicator = mockIndicator(5L, 6L, "铁蛋白", "12", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(1L, "铁", "铁蛋白", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(6L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(0);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        List<NutritionDeficiencyRecord> result = nutritionDeficiencyService.analyzeDeficiency(5L, 6L);
+
+        assertEquals(1, result.size());
+        verify(healthReportService).getReportIndicators(6L);
+    }
+
+    @Test
+    public void analyzeDeficiency_deletesExistingRecordsBeforeInsert() {
+        HealthReport report = mockReport(7L, 5L);
+        HealthIndicator indicator = mockIndicator(6L, 7L, "维生素D", "15", "偏低");
+        NutritionIndicatorMapping mapping = mockMapping(2L, "维生素D", "维生素D", 1);
+
+        when(healthReportService.getLatestReport(5L)).thenReturn(report);
+        when(healthReportService.getReportIndicators(7L)).thenReturn(Collections.singletonList(indicator));
+        when(mappingMapper.selectList(any())).thenReturn(Collections.singletonList(mapping));
+        when(recordMapper.delete(any())).thenReturn(1);
+        when(recordMapper.insert(any())).thenReturn(1);
+
+        nutritionDeficiencyService.analyzeDeficiency(5L, 7L);
+
+        verify(recordMapper).delete(any());
+    }
+}

+ 6 - 0
cfc-frontend/pages.json

@@ -71,6 +71,12 @@
         "navigationBarTitleText": "健康报告详情"
       }
     },
+    {
+      "path": "pages/body/health-dimensions",
+      "style": {
+        "navigationBarTitleText": "健康维度全景"
+      }
+    },
     {
       "path": "pages/body/checkin",
       "style": {

+ 286 - 0
cfc-frontend/pages/body/food-list.vue

@@ -0,0 +1,286 @@
+<template>
+  <view class="food-list-container">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">食材推荐</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <!-- Tab切换 -->
+    <view class="tab-bar">
+      <text class="tab-item" :class="{'active': activeTab === 'total'}" @click="switchTab('total')">全部食材</text>
+      <text class="tab-item" :class="{'active': activeTab === 'byMember'}" @click="switchTab('byMember')">按成员</text>
+    </view>
+
+    <!-- 全部食材模式 -->
+    <view v-if="activeTab === 'total'" class="food-section">
+      <view class="food-card" v-for="food in totalFoods" :key="food.id">
+        <view class="food-header">
+          <text class="food-name">{{ food.name }}</text>
+          <view class="food-score" :class="getScoreClass(food.score)">
+            <text>{{ food.score || '--' }}分</text>
+          </view>
+        </view>
+        <text class="food-category" v-if="food.category">{{ food.category }}</text>
+        <view class="food-nutrition" v-if="hasNutrition(food)">
+          <text class="nutri-item" v-if="food.energyKj">能量: {{ food.energyKj }}kJ</text>
+          <text class="nutri-item" v-if="food.protein">蛋白质: {{ food.protein }}g</text>
+          <text class="nutri-item" v-if="food.fat">脂肪: {{ food.fat }}g</text>
+          <text class="nutri-item" v-if="food.carbs">碳水: {{ food.carbs }}g</text>
+        </view>
+      </view>
+      <view class="empty-state" v-if="totalFoods.length === 0 && !loading">
+        <text>暂无食材推荐</text>
+        <text class="empty-hint">上传菌群检测报告后可获得个性化食材推荐</text>
+      </view>
+    </view>
+
+    <!-- 按成员模式 -->
+    <view v-if="activeTab === 'byMember'" class="food-section">
+      <view v-for="(foods, childId) in byMemberFoods" :key="childId" class="member-group">
+        <view class="member-header">
+          <text class="member-name">{{ getMemberName(childId) }}</text>
+          <text class="member-count">{{ foods.length }}项</text>
+        </view>
+        <view class="food-card" v-for="food in foods" :key="food.id">
+          <view class="food-header">
+            <text class="food-name">{{ food.name }}</text>
+            <view class="food-score" :class="getScoreClass(food.score)">
+              <text>{{ food.score || '--' }}分</text>
+            </view>
+          </view>
+          <text class="food-category" v-if="food.category">{{ food.category }}</text>
+          <view class="food-nutrition" v-if="hasNutrition(food)">
+            <text class="nutri-item" v-if="food.energyKj">能量: {{ food.energyKj }}kJ</text>
+            <text class="nutri-item" v-if="food.protein">蛋白质: {{ food.protein }}g</text>
+            <text class="nutri-item" v-if="food.fat">脂肪: {{ food.fat }}g</text>
+            <text class="nutri-item" v-if="food.carbs">碳水: {{ food.carbs }}g</text>
+          </view>
+        </view>
+      </view>
+      <view class="empty-state" v-if="Object.keys(byMemberFoods).length === 0 && !loading">
+        <text>暂无食材推荐</text>
+        <text class="empty-hint">上传菌群检测报告后可获得个性化食材推荐</text>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+const healthRequest = function(url, data) {
+  var token = uni.getStorageSync('token')
+  return new Promise(function(resolve, reject) {
+    uni.request({
+      url: config.API_BASE_URL + url,
+      method: 'POST',
+      data: data,
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': token ? 'Bearer ' + token : ''
+      },
+      success: function(res) {
+        if (res.data.code === 200) {
+          resolve(res.data)
+        } else {
+          reject(res.data)
+        }
+      },
+      fail: function(err) {
+        reject(err)
+      }
+    })
+  })
+}
+
+export default {
+  data: function() {
+    return {
+      familyId: null,
+      activeTab: 'total',
+      totalFoods: [],
+      byMemberFoods: {},
+      memberNames: {},
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    var familyId = options.familyId
+    this.familyId = familyId ? parseInt(familyId) : null
+    this.loadData()
+  },
+  methods: {
+    loadData: function() {
+      var self = this
+      if (!this.familyId) {
+        uni.showToast({ title: '缺少familyId', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '加载中...' })
+      healthRequest('/api/health/foods/by-family', { familyId: this.familyId }).then(function(res) {
+        uni.hideLoading()
+        if (res.data) {
+          self.totalFoods = res.data.total || []
+          self.byMemberFoods = res.data.byMember || {}
+          self.memberNames = res.data.memberNames || {}
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        console.log('获取食材推荐失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      })
+    },
+    switchTab: function(tab) {
+      this.activeTab = tab
+    },
+    getScoreClass: function(score) {
+      if (score == null) return 'score-unknown'
+      if (score >= 80) return 'score-high'
+      if (score >= 60) return 'score-mid'
+      return 'score-low'
+    },
+    hasNutrition: function(food) {
+      return food.energyKj || food.protein || food.fat || food.carbs
+    },
+    getMemberName: function(childId) {
+      return this.memberNames[childId] || '成员' + childId
+    },
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.food-list-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+}
+.nav-bar {
+  position: sticky;
+  top: 0;
+  background: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+  z-index: 100;
+}
+.nav-back { font-size: 32rpx; color: #5B9BD5; }
+.nav-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.tab-bar {
+  display: flex;
+  flex-direction: row;
+  background: #fff;
+  padding: 0 30rpx;
+  border-bottom: 2rpx solid #eee;
+}
+.tab-item {
+  padding: 20rpx 0;
+  margin-right: 40rpx;
+  font-size: 28rpx;
+  color: #666;
+  border-bottom: 4rpx solid transparent;
+}
+.tab-item.active {
+  color: #F97316;
+  border-bottom-color: #F97316;
+  font-weight: bold;
+}
+.food-section {
+  padding: 20rpx 30rpx;
+}
+.member-group {
+  margin-bottom: 30rpx;
+}
+.member-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.member-name {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.member-count {
+  font-size: 22rpx;
+  color: #999;
+}
+.food-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.food-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.food-name {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+}
+.food-score {
+  font-size: 24rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 16rpx;
+  font-weight: 600;
+}
+.score-high { background: #DCFCE7; color: #16A34A; }
+.score-mid { background: #FEF3C7; color: #D97706; }
+.score-low { background: #FEE2E2; color: #DC2626; }
+.score-unknown { background: #F3F4F6; color: #6B7280; }
+.food-category {
+  font-size: 22rpx;
+  color: #6B7280;
+  background: #F3F4F6;
+  padding: 2rpx 12rpx;
+  border-radius: 12rpx;
+  display: inline-block;
+  margin-bottom: 8rpx;
+}
+.food-nutrition {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  margin-top: 8rpx;
+}
+.nutri-item {
+  font-size: 22rpx;
+  color: #374151;
+}
+.empty-state {
+  text-align: center;
+  padding: 100rpx 40rpx;
+}
+.empty-state text:first-child {
+  font-size: 28rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #bbb;
+}
+.bottom-spacer {
+  height: 40rpx;
+}
+</style>

+ 605 - 0
cfc-frontend/pages/body/health-dimensions.vue

@@ -0,0 +1,605 @@
+<template>
+  <view class="hd-container">
+    <view class="hd-header">
+      <text class="hd-title">健康维度全景</text>
+      <text class="hd-subtitle" v-if="memberName">{{ memberName }}的健康档案</text>
+    </view>
+
+    <view class="hd-health-index" v-if="healthData && healthData.healthIndex">
+      <view class="index-circle">
+        <text class="index-value">{{ healthData.healthIndex }}</text>
+        <text class="index-label">健康指数</text>
+      </view>
+    </view>
+
+    <view class="radar-section" v-if="healthData && healthData.dimensions">
+      <view class="radar-title">七维健康雷达</view>
+      <view class="radar-wrap">
+        <view class="radar-center">
+          <view class="radar-grid">
+            <view class="grid-ring ring-3"></view>
+            <view class="grid-ring ring-2"></view>
+            <view class="grid-ring ring-1"></view>
+          </view>
+          <view class="radar-spokes">
+            <view
+              class="spoke-item"
+              v-for="(dim, idx) in healthData.dimensions"
+              :key="dim.dimension"
+              :style="getSpokeStyle(idx, dim.score)"
+            >
+              <view class="spoke-dot" :class="'level-' + dim.level"></view>
+              <view class="spoke-label">{{ dim.label }}</view>
+              <view class="spoke-score">{{ dim.score || '--' }}</view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="dimension-list" v-if="healthData && healthData.dimensions">
+      <view
+        class="dim-card"
+        v-for="dim in healthData.dimensions"
+        :key="dim.dimension"
+      >
+        <view class="dim-header">
+          <text class="dim-label">{{ dim.label }}</text>
+          <text class="dim-score" :class="'score-' + dim.level">{{ dim.score || '--' }}分</text>
+        </view>
+        <view class="dim-bar-wrap">
+          <view class="dim-bar-bg">
+            <view
+              class="dim-bar-fill"
+              :class="'fill-' + dim.level"
+              :style="{ width: (dim.score || 0) + '%' }"
+            ></view>
+          </view>
+          <text class="dim-norms" v-if="dim.normMin && dim.normMax">
+            正常范围 {{ dim.normMin }}-{{ dim.normMax }}
+          </text>
+        </view>
+        <view class="dim-footer">
+          <text class="dim-level-tag" :class="'tag-' + dim.level">{{ getLevelText(dim.level) }}</text>
+          <text class="dim-source" v-if="dim.trend">{{ dim.trend === 'up' ? '↑ 上升' : dim.trend === 'down' ? '↓ 下降' : '→ 稳定' }}</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="hd-empty" v-if="!healthData && !loading">
+      <text class="empty-tip">暂无维度数据</text>
+      <text class="empty-sub">上传健康报告或填写问卷后可查看</text>
+    </view>
+
+    <view class="loading-wrap" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <view class="fab-upload" @click="showUploadModal">
+      <text class="fab-icon">+</text>
+    </view>
+
+    <view class="modal-mask" v-if="showModal" @click="showModal = false">
+      <view class="modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">录入维度评分</text>
+          <text class="modal-close" @click="showModal = false">×</text>
+        </view>
+        <view class="modal-body">
+          <view class="form-item">
+            <text class="form-label">选择维度</text>
+            <picker
+              mode="selector"
+              :range="dimensionOptions"
+              range-key="label"
+              @change="onDimensionChange"
+            >
+              <view class="picker-value">
+                {{ selectedDimension.label || '请选择' }}
+              </view>
+            </picker>
+          </view>
+          <view class="form-item">
+            <text class="form-label">评分 (0-100)</text>
+            <input
+              class="form-input"
+              type="number"
+              v-model="uploadForm.score"
+              placeholder="请输入评分"
+              min="0"
+              max="100"
+            />
+          </view>
+          <view class="form-item">
+            <text class="form-label">数据来源</text>
+            <picker
+              mode="selector"
+              :range="sourceOptions"
+              @change="onSourceChange"
+            >
+              <view class="picker-value">
+                {{ selectedSource.label || '请选择' }}
+              </view>
+            </picker>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="btn-cancel" @click="showModal = false">取消</view>
+          <view class="btn-confirm" @click="submitUpload">确认提交</view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getDimensionOverview, uploadDimensionScore } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      memberId: null,
+      memberName: '',
+      healthData: null,
+      loading: false,
+      showModal: false,
+      selectedDimension: {},
+      selectedSource: {},
+      uploadForm: {
+        memberId: null,
+        dimension: '',
+        score: '',
+        dataSource: ''
+      },
+      dimensionOptions: [
+        { code: 'body', label: '身体' },
+        { code: 'fitness', label: '运动' },
+        { code: 'cardio', label: '心肺' },
+        { code: 'blood', label: '血液' },
+        { code: 'sleep', label: '睡眠' },
+        { code: 'psyche', label: '心理' },
+        { code: 'diet', label: '饮食' }
+      ],
+      sourceOptions: [
+        { code: 'manual', label: '手动录入' },
+        { code: 'report', label: '健康报告' },
+        { code: 'device', label: '设备' },
+        { code: 'questionnaire', label: '问卷' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    var childId = options.childId || uni.getStorageSync('currentChildId')
+    var childName = options.name || ''
+    this.memberId = Number(childId)
+    this.memberName = childName
+    this.uploadForm.memberId = this.memberId
+    if (this.memberId) {
+      this.loadDimensionData()
+    }
+  },
+  onShow: function() {
+    if (this.memberId) {
+      this.loadDimensionData()
+    }
+  },
+  methods: {
+    loadDimensionData: function() {
+      var self = this
+      self.loading = true
+      getDimensionOverview({ memberId: this.memberId }).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.healthData = res.data
+        }
+      }).catch(function(e) {
+        self.loading = false
+        console.log('获取维度数据失败', e)
+      })
+    },
+    getSpokeStyle: function(idx, score) {
+      var angle = idx * 51.4 - 90
+      var radius = 85
+      var normalizedScore = (score || 0) / 100
+      var translateY = -radius * normalizedScore
+      var rotate = angle
+      return {
+        transform: 'rotate(' + rotate + 'deg) translateY(' + translateY + 'px)',
+        '-webkit-transform': 'rotate(' + rotate + 'deg) translateY(' + translateY + 'px)'
+      }
+    },
+    getLevelText: function(level) {
+      var map = {
+        'excellent': '优秀',
+        'good': '良好',
+        'fair': '一般',
+        'poor': '较差',
+        'none': '暂无数据'
+      }
+      return map[level] || ''
+    },
+    showUploadModal: function() {
+      this.showModal = true
+      this.selectedDimension = {}
+      this.selectedSource = {}
+      this.uploadForm.dimension = ''
+      this.uploadForm.score = ''
+      this.uploadForm.dataSource = ''
+    },
+    onDimensionChange: function(e) {
+      var idx = e.detail.value
+      this.selectedDimension = this.dimensionOptions[idx]
+      this.uploadForm.dimension = this.selectedDimension.code
+    },
+    onSourceChange: function(e) {
+      var idx = e.detail.value
+      this.selectedSource = this.sourceOptions[idx]
+      this.uploadForm.dataSource = this.selectedSource.code
+    },
+    submitUpload: function() {
+      if (!this.uploadForm.dimension) {
+        uni.showToast({ title: '请选择维度', icon: 'none' })
+        return
+      }
+      if (!this.uploadForm.score || this.uploadForm.score < 0 || this.uploadForm.score > 100) {
+        uni.showToast({ title: '评分需在0-100之间', icon: 'none' })
+        return
+      }
+      if (!this.uploadForm.dataSource) {
+        uni.showToast({ title: '请选择数据来源', icon: 'none' })
+        return
+      }
+      var self = this
+      var payload = {
+        memberId: this.memberId,
+        dimension: this.uploadForm.dimension,
+        score: Number(this.uploadForm.score),
+        dataSource: this.uploadForm.dataSource
+      }
+      uploadDimensionScore(payload).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          self.showModal = false
+          self.loadDimensionData()
+        }
+      }).catch(function(e) {
+        console.log('提交失败', e)
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.hd-container {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 120rpx;
+}
+.hd-header {
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  padding: 40rpx 30rpx 30rpx;
+  color: #fff;
+}
+.hd-title {
+  font-size: 40rpx;
+  font-weight: bold;
+  display: block;
+}
+.hd-subtitle {
+  font-size: 26rpx;
+  opacity: 0.9;
+  display: block;
+  margin-top: 8rpx;
+}
+.hd-health-index {
+  display: flex;
+  justify-content: center;
+  padding: 30rpx;
+  background: #fff;
+}
+.index-circle {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+}
+.index-value {
+  font-size: 48rpx;
+  font-weight: bold;
+  line-height: 1;
+}
+.index-label {
+  font-size: 22rpx;
+  opacity: 0.9;
+  margin-top: 8rpx;
+}
+.radar-section {
+  background: #fff;
+  margin-top: 20rpx;
+  padding: 30rpx;
+}
+.radar-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 30rpx;
+  text-align: center;
+}
+.radar-wrap {
+  display: flex;
+  justify-content: center;
+}
+.radar-center {
+  width: 440rpx;
+  height: 440rpx;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.radar-grid {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+}
+.grid-ring {
+  position: absolute;
+  border-radius: 50%;
+  border: 1px solid #ddd;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+}
+.ring-1 { width: 33%; height: 33%; }
+.ring-2 { width: 66%; height: 66%; }
+.ring-3 { width: 100%; height: 100%; }
+.radar-spokes {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.spoke-item {
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  transform-origin: center center;
+  width: 80rpx;
+  margin-left: -40rpx;
+  top: 10rpx;
+}
+.spoke-dot {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 50%;
+  background: #FF8C42;
+  margin-bottom: 6rpx;
+}
+.spoke-dot.level-excellent { background: #10B981; }
+.spoke-dot.level-good { background: #3B82F6; }
+.spoke-dot.level-fair { background: #F59E0B; }
+.spoke-dot.level-poor { background: #EF4444; }
+.spoke-dot.level-none { background: #ddd; }
+.spoke-label {
+  font-size: 22rpx;
+  color: #333;
+  white-space: nowrap;
+}
+.spoke-score {
+  font-size: 20rpx;
+  color: #666;
+  margin-top: 2rpx;
+}
+.dimension-list {
+  padding: 20rpx 30rpx;
+}
+.dim-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.dim-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.dim-label {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.dim-score {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #FF8C42;
+}
+.dim-score.score-excellent { color: #10B981; }
+.dim-score.score-good { color: #3B82F6; }
+.dim-score.score-fair { color: #F59E0B; }
+.dim-score.score-poor { color: #EF4444; }
+.dim-bar-wrap {
+  margin-bottom: 12rpx;
+}
+.dim-bar-bg {
+  height: 16rpx;
+  background: #f0f0f0;
+  border-radius: 8rpx;
+  overflow: hidden;
+  margin-bottom: 6rpx;
+}
+.dim-bar-fill {
+  height: 100%;
+  border-radius: 8rpx;
+  background: #FF8C42;
+  transition: width 0.3s;
+}
+.dim-bar-fill.fill-excellent { background: #10B981; }
+.dim-bar-fill.fill-good { background: #3B82F6; }
+.dim-bar-fill.fill-fair { background: #F59E0B; }
+.dim-bar-fill.fill-poor { background: #EF4444; }
+.dim-norms {
+  font-size: 22rpx;
+  color: #999;
+}
+.dim-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.dim-level-tag {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  background: #f0f0f0;
+  color: #666;
+}
+.dim-level-tag.tag-excellent { background: #D1FAE5; color: #10B981; }
+.dim-level-tag.tag-good { background: #DBEAFE; color: #3B82F6; }
+.dim-level-tag.tag-fair { background: #FEF3C7; color: #F59E0B; }
+.dim-level-tag.tag-poor { background: #FEE2E2; color: #EF4444; }
+.dim-source {
+  font-size: 22rpx;
+  color: #999;
+}
+.hd-empty {
+  padding: 100rpx 30rpx;
+  text-align: center;
+}
+.empty-tip {
+  font-size: 30rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.empty-sub {
+  font-size: 24rpx;
+  color: #ccc;
+  display: block;
+}
+.loading-wrap {
+  padding: 80rpx 30rpx;
+  text-align: center;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+.fab-upload {
+  position: fixed;
+  right: 40rpx;
+  bottom: 60rpx;
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 24rpx rgba(255, 140, 66, 0.4);
+}
+.fab-icon {
+  font-size: 48rpx;
+  color: #fff;
+  font-weight: bold;
+  line-height: 1;
+}
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
+.modal-content {
+  width: 100%;
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  padding-bottom: 60rpx;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  border-bottom: 1px solid #f0f0f0;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.modal-close {
+  font-size: 48rpx;
+  color: #999;
+  line-height: 1;
+}
+.modal-body {
+  padding: 30rpx;
+}
+.form-item {
+  margin-bottom: 30rpx;
+}
+.form-label {
+  font-size: 28rpx;
+  color: #666;
+  display: block;
+  margin-bottom: 12rpx;
+}
+.picker-value {
+  height: 80rpx;
+  line-height: 80rpx;
+  border: 1px solid #e0e0e0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+}
+.form-input {
+  height: 80rpx;
+  border: 1px solid #e0e0e0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+}
+.modal-footer {
+  display: flex;
+  padding: 0 30rpx;
+  gap: 20rpx;
+}
+.btn-cancel {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: #f0f0f0;
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  color: #666;
+}
+.btn-confirm {
+  flex: 1;
+  height: 88rpx;
+  line-height: 88rpx;
+  text-align: center;
+  background: linear-gradient(135deg, #FF8C42, #FF6B35);
+  border-radius: 44rpx;
+  font-size: 30rpx;
+  color: #fff;
+}
+</style>

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

@@ -1336,3 +1336,7 @@ export const getDeficiencySummary = (childId) => request('/api/health/nutrition/
 export const getNutritionTrend = (childId) => request('/api/health/nutrition/trend', 'POST', { childId })
 export const getHealthAnalysis = (childId) => request('/api/health/analysis', 'POST', { childId })
 export const getNutritionMappings = (data) => request('/api/health/nutrition/mappings', 'POST', data || {})
+
+// ===== 健康维度 =====
+export const getDimensionOverview = (data) => request('/api/dimension/overview', 'POST', data)
+export const uploadDimensionScore = (data) => request('/api/dimension/upload', 'POST', data)

+ 138 - 79
docs/test-report.md

@@ -1,8 +1,8 @@
-# 测试报告 - 角色权限测试覆盖增强 + PdfParseService 测试
+# 测试报告 - 设计实现差异分析 + 服务层测试覆盖
 
-**日期**: 2026-06-24
+**日期**: 2026-06-26
 **分支**: cfclub
-**最新提交**: `0e6ea3b` → 本次 `9e321c3`(角色测试增强+报告已推送)→ 本次变更待提交
+**最新提交**: `e193c02`(本次)
 
 ---
 
@@ -10,88 +10,143 @@
 
 | 项目 | 状态 |
 |------|------|
-| 代码同步 (git pull --rebase) | ✅ 完成 |
-| 设计-代码匹配度分析 | ✅ 完成 |
-| 缺失测试用例补充 | ✅ 完成 |
-| 编译验证 (BUILD SUCCESS) | ✅ 完成 |
-| PdfParseServiceTest (10/10 通过) | ✅ 完成 |
+| 代码同步 (git pull --rebase) | ✅ `74009f3` → `e193c02` |
+| 设计-实现差异分析 | ✅ 完成 |
+| 新增测试用例(服务层) | ✅ 完成 |
+| 编译验证 | ✅ 40 测试文件 BUILD SUCCESS |
+| 测试运行(无 MySQL 依赖) | ✅ 36/36 通过 |
 | 测试报告推送远程 | 🔄 进行中 |
 
 ---
 
-## 2. 远程变更探查(次同步)
+## 2. 远程变更探查(次同步)
 
-`git pull --rebase` 拉取的远程新提交:
+`git pull --rebase` 拉取的远程新提交(`74009f3` → `e193c02`)
 
-```
-0e6ea3b feat: 营养推荐相关实体和mappers (#178)
-dd002f3 fix: Login.vue 品牌升级 (#177)
-PdfParseService.java 新建(食物解析服务)
-FamilyMemberVO.java 重构(字段变更:memberType→capabilityRole,移除danLevel)
-schema.sql 新增表
-```
+### 后端新增(48 files, +3504 -5 行)
+
+| 类型 | 文件/目录 | 说明 |
+|------|----------|------|
+| Controller | `HealthDimensionController.java` | 健康维度评估控制器 |
+| Controller | `EmotionCheckinController.java` + `mind/` | 情绪打卡控制器 |
+| Controller | `MindController.java` | 心智维度控制器 |
+| Controller | `TraditionalMirrorController.java` | 智慧维度(八字命盘)控制器 |
+| Service | `EmotionCheckinService.java` (218行) | 情绪打卡核心逻辑 |
+| Service | `DimensionScoreService.java` + impl/ | 维度评分服务 |
+| Service | `HealthDataSourceRecordService.java` | 健康数据源记录服务 |
+| Entity | `EmotionCheckin.java`, `HealthDimensionScore.java`, `NumSoulConfig.java`, `BaziReadingTemplate.java` 等 7 个新实体 | 新功能数据模型 |
+| DTO | `EmotionCheckinVO/DTO`, `DimensionScoreVO`, `HealthDimensionVO` 等 | 新功能数据结构 |
+| Database | `DatabaseInitializer.java` (247行) | 数据库初始化 |
 
-后端新增 `PdfParseService.java` + `FamilyMemberVO.java` 重构 → 触发测试覆盖需求。
+### 前端新增
+- `pages/body/health-dimensions.vue` (605行) — 健康维度评估页面
+- `pages/body/food-list.vue` (286行) — 食材列表页面
 
 ---
 
-## 3. 本次新增变更
+## 3. 设计-实现差异分析
 
-### 3.1 UserService.java 编译错误修复
+### 3.1 P0 营养师核心功能(spec 2026-06-23)
 
-**文件**: `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java:672`
+| 设计项 | 实现状态 | 差异说明 |
+|--------|---------|---------|
+| `NutritionDeficiencyRecord.priority` | ❌ 缺失 | 设计定义 high/medium/low,但 entity 只有 `severity (BigDecimal)` |
+| `ChildNutritionProfileService.getProfile()` 含 `diseaseRisks` | ⚠️ 部分 | `getProfile()` 未包含 diseaseRisks;已有 `getDeficiencySummary()` 提供缺乏摘要 |
+| `POST /api/health/nutrition-profile` 端点 | ✅ 已实现 | 对应端点 `/api/health/nutrition/profile` 已存在于 HealthReportController |
+| 营养缺乏分析(NutritionDeficiencyService) | ✅ 已实现 | `analyzeDeficiency()` 完整实现,字段映射 + 缺乏分类 + 记录持久化 |
+| 指标优先级排序(高/中/低) | ❌ 未实现 | 仅 severity 数值,未映射到 priority 标签 |
 
-**问题**: `FamilyMemberVO` 重构后无 `danLevel` 字段,调用 `vo.setDanLevel()` 导致编译失败。
+### 3.2 活动管理(AdminActivityController)
 
-**修复**: 删除 `vo.setDanLevel(c.getDanLevel())` 行。
+| 设计项 | 实现状态 | 说明 |
+|--------|---------|------|
+| 活动列表(支持状态/维度过滤) | ✅ 已实现 | `list()` 方法,pagination 支持 |
+| 审核发布(publish/reject) | ✅ 已实现 | `review()` 方法,草稿→发布,驳回→删除 |
+| 分页返回格式 | ✅ 一致 | `{records, total, page, size}` |
 
-### 3.2 PdfParseServiceTest(新建)
+**无对应设计文档** — 此为实现驱动的新功能(vendor 角色商品管理扩展)。
 
-**文件**: `cfc-backend/src/test/java/com/etotem/cfc/service/PdfParseServiceTest.java`
+### 3.3 食材推荐系统(FoodService)
 
-**背景**: `PdfParseService.parseText()` 解析 PDF 文本为结构化报告,文本格式为"关键词\n值"逐行格式。
+| 设计项 | 实现状态 | 说明 |
+|--------|---------|------|
+| 食材 CRUD | ✅ 已实现 | `create/update/delete/getById` |
+| 按分类/状态筛选列表 | ✅ 已实现 | `listAll(category, status)` |
+| 按价格等级查询 | ✅ 已实现 | `listByPriceLevel(maxLevel)` |
+| 按报告ID查询食材 | ✅ 已实现 | `listByReportId(reportId)` |
 
-| 测试方法 | 测试内容 | 结果 |
-|---------|---------|------|
-| `parseText_emptyText_returnsEmptyResult` | 空文本返回空对象 | ✅ |
-| `parseText_withAbstractScore_parsesCorrectly` | 摘要分数:健康总分/菌群健康 | ✅ |
-| `parseText_withReportInfo_parsesCorrectly` | 报告信息:编号/日期/姓名/肠道年龄/肠型 | ✅ |
-| `parseText_withDiseaseRisk_parsesCorrectly` | 疾病风险:慢病控制/营养均衡 | ✅ |
-| `parseText_withGutMicrobiome_parsesCorrectly` | 肠道菌群:平衡/多样性/有益菌/有害菌/核心菌属 | ✅ |
-| `parseText_withMetricsScore_parsesCorrectly` | 指标评分:各维度 Integer | ✅ |
-| `parseText_withIngredientSuitability_parsesCorrectly` | 食材适宜度 | ✅ |
-| `parseText_withAllSections_combinesResults` | 多 section 合并 | ✅ |
-| `parseText_withSpecialCharacters_handlesGracefully` | 特殊字符(数字/长名称/边界值) | ✅ |
+**无对应设计文档** — 食材推荐相关实现待补充设计规格。
 
-**10/10 测试通过,BUILD SUCCESS**
+### 3.4 待实现(设计仅)
 
-**注意**:PdfParseService 无 Spring 依赖,直接 `new PdfParseService()` 实例化,不触发 MySQL 连接,不受连接数限制。
+| 功能 | 设计文档 | 状态 |
+|------|---------|------|
+| RecommendationController + RecommendationService | 精准营养推荐系统设计方案 | ❌ 未实现 |
+| Dify AI 对话集成(AIChatController 扩展) | 精准营养推荐系统设计方案 | ❌ 未实现 |
+| 孩子营养档案统一视图(前端) | P0 营养师核心功能设计 | ⚠️ 部分实现(API 就绪,前端未接入) |
 
 ---
 
-## 4. 之前完成的测试覆盖
-
-### 4.1 WishControllerTest(前期已完成)
-
-| 测试方法 | 测试角色 | 预期结果 |
-|---------|---------|---------|
-| `createWish_ByTeacher` | teacher | 成功 |
-| `createWish_ByAdmin` | admin | 成功 |
-
-### 4.2 FamilyControllerTest(前期已完成)
-
-| 测试方法 | 测试角色 | 预期结果 |
-|---------|---------|---------|
-| `guideBindFamily_ChildDenied` | child | code=500 |
-| `guideBindFamily_AdminDenied` | admin | code=500 |
-
-### 4.3 GuideFamilyTaskControllerTest(前期已完成)
-
-7 个 teacher-only 方法 × 3 种非 teacher 角色 (parent/child/admin) = 21 条角色拒绝断言。
-
-### 4.4 TestRoles 工具类(前期已完成)
-
-`ROLE_PARENT / ROLE_CHILD / ROLE_TEACHER / ROLE_ADMIN` + `assertSuccess()` / `assertRoleDenied()`
+## 4. 测试覆盖改进
+
+### 4.1 本次新增测试(服务层)
+
+#### FoodServiceTest(12 tests)
+
+覆盖:`FoodService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `listAll_noFilters_returnsAllActive` | 无过滤返回所有 active 食材 |
+| `listAll_withCategoryFilter` | 按分类筛选 |
+| `listAll_withStatusOnly` | 按状态筛选 |
+| `listByPriceLevel_returnsActiveBelowMaxLevel` | 价格等级上限过滤 |
+| `getById_found` | ID 存在 |
+| `getById_notFound` | ID 不存在 |
+| `create_insertsFood` | 插入食材(模拟 AUTO_INCREMENT) |
+| `update_callsUpdateById` | 更新食材 |
+| `delete_deletesById` | 删除食材 |
+| `listByReportId_withValidReportId` | 关联报告的食材查询 |
+| `listByReportId_withNullReportId_returnsEmptyList` | null 报告ID 返回空列表 |
+| `listByReportId_noMatchingFoods_returnsEmptyList` | 无匹配返回空列表 |
+
+#### ActivityAdminServiceTest(10 tests)
+
+覆盖:`ActivityAdminService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `list_defaultPagination` | 默认分页(page=1, size=20) |
+| `list_withStatusFilter` | 按状态过滤 + 自定义分页 |
+| `list_emptyResult` | 空结果返回 |
+| `review_publish_success` | 发布草稿活动成功 |
+| `review_reject_deletesActivity` | 驳回删除活动 |
+| `review_publish_nonDraft_fails` | 非草稿状态不可发布 |
+| `review_activityNotFound` | 活动不存在返回错误 |
+| `review_invalidAction` | 无效 action 返回错误 |
+| `review_missingId` | 缺少 id 参数 |
+| `review_missingAction` | 缺少 action 参数 |
+
+#### NutritionDeficiencyServiceTest(14 tests)
+
+覆盖:`NutritionDeficiencyService.java`
+
+| 测试方法 | 测试内容 |
+|---------|---------|
+| `getAllMappings_returnsAllMappingsOrdered` | 获取所有指标映射(排序) |
+| `saveMapping_insert_whenIdNull` | 新增映射 |
+| `saveMapping_update_whenIdNotNull` | 更新映射 |
+| `deleteMapping_callsDeleteById` | 删除映射 |
+| `getDeficiencyRecords_withChildIdOnly` | 按孩子ID查询缺乏记录 |
+| `getDeficiencyRecords_withChildIdAndReportId` | 按孩子ID+报告ID查询 |
+| `analyzeDeficiency_noReport_returnsEmpty` | 无报告返回空 |
+| `analyzeDeficiency_noIndicators_returnsEmpty` | 无指标返回空 |
+| `analyzeDeficiency_withMatchingIndicator_createsRecord` | 匹配指标创建缺乏记录(偏低→deficiency) |
+| `analyzeDeficiency_excessStatus_mapsToExcess` | 偏高→excess |
+| `analyzeDeficiency_borderlineStatus_mapsToBorderline` | 临界→borderline |
+| `analyzeDeficiency_noMatchingMapping_skipsIndicator` | 无匹配映射跳过指标 |
+| `analyzeDeficiency_usesReportIdFromParam_whenProvided` | 优先使用参数中的 reportId |
+| `analyzeDeficiency_deletesExistingRecordsBeforeInsert` | 插入前删除旧记录 |
 
 ---
 
@@ -100,38 +155,42 @@ schema.sql 新增表
 ```
 mvn test-compile
 [INFO] BUILD SUCCESS
-[INFO] Compiling 37 source files  (新增 PdfParseServiceTest)
+[INFO] Compiling 40 source files
+[INFO] Total time:  45.615 s
 ```
 
 ---
 
-## 6. 测试执行限制
+## 6. 测试执行结果
 
-**MySQL 连接数限制**:HikariCP 13 个连接限额,13 个测试类同时运行会阻塞。
+| 测试类 | 用例数 | 通过 | 失败 | 耗时 |
+|--------|--------|------|------|------|
+| FoodServiceTest | 12 | 12 | 0 | 2.4s |
+| ActivityAdminServiceTest | 10 | 10 | 0 | 1.5s |
+| NutritionDeficiencyServiceTest | 14 | 14 | 0 | 50.8s |
+| **合计** | **36** | **36** | **0** | **54.7s** |
 
-**现状**:
-- 编译验证:✅ 通过
-- PdfParseServiceTest:✅ 10/10 通过(无 MySQL 依赖)
-- 完整测试运行:⚠️ MySQL `Too many connections` 阻塞(基础设施限制)
+**BUILD SUCCESS** — 无 MySQL 连接(纯 Mockito 单元测试)
 
 ---
 
-## 7. 待优化
+## 7. 设计待办事
 
 | 优先级 | 项目 | 说明 |
 |--------|------|------|
-| 🟡 中 | vendorType=planner 迁移 | 代码仍用 `role=teacher` |
-| 🟢 低 | GuideRecordController 测试 | 有 `@RequestAttribute("role")` 但无测试类 |
+| 🟡 中 | `NutritionDeficiencyRecord.priority` 字段 | 建议添加 priority 字段(high/medium/low),将 severity 数值映射为优先级标签 |
+| 🟡 中 | RecommendationController/Service | 设计文档存在但未实现,Dify 集成待开发 |
+| 🟢 低 | `ChildNutritionProfileService.getProfile()` 补全 diseaseRisks | 与 P0 设计文档要求一致 |
+| 🟢 低 | `ChildNutritionProfileService` 前端接入 | API 已就绪 (`/api/health/nutrition/profile`),前端页面待开发 |
+| 🟢 低 | `FoodService` 设计文档 | 食材推荐系统缺少对应设计规格 |
 
 ---
 
-## 8. 文件变更清单(本次)
+## 8. 文件变更清单
 
 | 文件 | 操作 | 说明 |
 |------|------|------|
-| `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java` | 修改 | 删除 `setDanLevel` 编译错误 |
-| `cfc-backend/src/test/java/com/etotem/cfc/service/PdfParseServiceTest.java` | 新增 | 10 个测试方法 |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/WishControllerTest.java` | 修改 | 新增 teacher/admin 测试(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/FamilyControllerTest.java` | 修改 | 新增 child/admin 拒绝测试(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/common/TestRoles.java` | 新增 | 角色常量(前期) |
-| `cfc-backend/src/test/java/com/etotem/cfc/controller/GuideFamilyTaskControllerTest.java` | 已存在 | 角色拒绝测试(前期) |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/ActivityAdminServiceTest.java` | 新增 | 10 tests — 活动管理服务 |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/FoodServiceTest.java` | 新增 | 12 tests — 食材管理服务 |
+| `cfc-backend/src/test/java/com/etotem/cfc/service/NutritionDeficiencyServiceTest.java` | 新增 | 14 tests — 营养缺乏分析服务 |
+| `docs/test-report.md` | 修改 | 更新为本次测试报告 |

+ 145 - 0
docs/设计/wisdom-dimension-plan.md

@@ -0,0 +1,145 @@
+# wisdom-dimension-redesign - Work Plan
+
+> ⚠️ 此文件为 `.omo/plans/wisdom-dimension-redesign.md` 的副本(.omo/ 目录被 .gitignore 隔离)
+> 源文件路径:`/app/cfc/.omo/plans/wisdom-dimension-redesign.md`
+
+## TL;DR (For humans)
+
+**What you'll get:** 智维度TabBar页面全面升级,与其他四个维度页面保持一致的视觉和交互体验——包含家庭成员关系图、五维能量条、认知报告摘要、自己出题自己答核心功能、训练中心游戏入口。
+
+**Why this approach:** 遵循身维度页面的成熟模式作为蓝本,确保五端体验一致;核心新功能"自己出题自己答"采用前端优先MVP实现,避免后端阻塞前端交付。
+
+**What it will NOT do:**
+- 不会新建后端 SelfQuiz API 或数据库表
+- 不会实现新游戏,只链接已有游戏
+- 不会迁移心维度"智·认知"tab内容,不改Web管理端
+
+**Effort:** Medium
+**Risk:** Low - 全部是前端页面重写/新建,没有后端改动,参考模式成熟
+**Decisions to sanity-check:** 自测题MVP用localStorage而非后端;训练中心纯链接不内嵌;认知报告从7维修正为6维
+
+---
+
+> TL;DR (machine): Medium, Low, 8 todos — wisdom TabBar page full redesign + FamilyRelationGraph wisdom support + 3 new sub-pages + cognitive report fix
+
+## Scope
+### Must have
+- FamilyRelationGraph 组件支持 `wisdom` dimensionCode(验证器 + 主题色)
+- wisdom/index.vue 按身维度页面模式完整重写(能量条、关系图、用户入口、功能入口、维度内容、任务/活动/商品)
+- wisdom/member-wisdom-detail.vue 新建(成员详情,含只读关系图)
+- wisdom/self-quiz.vue 新建(自己出题自己答核心功能)
+- wisdom/training-hub.vue 新建(训练中心,链接已有游戏)
+- cognitive-report.vue 修正为6维DAN标准
+- pages.json 注册3个新子页面
+
+### Must NOT have
+- ❌ 新建后端 SelfQuiz API 或数据库表(Phase 2)
+- ❌ 实现新游戏(只用已有 /pages/games/ 下的游戏)
+- ❌ 迁移心维度页面 `pages/mind/index.vue` 的"智·认知"tab内容
+- ❌ 接入 Dify AI 生成出题提示(Phase 2)
+- ❌ 修改 Web 管理端
+- ❌ 修改其他维度页面(body/mind/action)的逻辑或外观
+- ❌ 使用可选链 `?.`(微信小程序不支持,必须用 `&&`)
+- ❌ 使用 CSS Grid(微信小程序不支持,必须用 flexbox)
+
+## Execution waves
+
+**Wave 1(基础层,5个 todo,全部可并行):**
+- T1: FamilyRelationGraph wisdom 支持
+- T2: cognitive-report.vue 7→6 维修正
+- T3: pages.json 注册3个新子页面
+- T4: training-hub.vue 新建
+- T5: member-wisdom-detail.vue 新建
+
+**Wave 2(核心层,2个 todo,依赖 Wave 1):**
+- T6: self-quiz.vue 新建(依赖 T3 页面注册)
+- T7: wisdom/index.vue 完整重写(依赖 T1 关系图 + T4/T5/T6 页面目标存在)
+
+**Wave 3(验证层,1个 todo,依赖 Wave 2):**
+- T8: 全链路集成验证
+
+## Dependency matrix
+| Todo | Depends on | Blocks | Can parallelize with |
+| --- | --- | --- | --- |
+| T1 | — | T7 | T2, T3, T4, T5 |
+| T2 | — | — | T1, T3, T4, T5 |
+| T3 | — | T4, T5, T6 | T1, T2 |
+| T4 | T3 | T7 | T5, T6 |
+| T5 | T3 | T7 | T4, T6 |
+| T6 | T3 | T7 | T4, T5 |
+| T7 | T1, T4, T5, T6 | T8 | — |
+| T8 | T7 | — | — |
+
+## Todos
+
+- [ ] 1. FamilyRelationGraph 组件添加 wisdom 维度支持
+  - validator 添加 `'wisdom'`,themeColors 添加 `wisdom: '#FFD700'`
+  - Blocks: T7 | Can parallelize with: T2, T3, T4, T5
+  - References: `cfc-frontend/components/FamilyRelationGraph.vue:107-108,138-143`
+  - Commit: Y | fix(component): add wisdom dimension support to FamilyRelationGraph
+
+- [ ] 2. cognitive-report.vue 修正7维→6维DAN标准
+  - 删除 attentionScore,保留DAN六维
+  - Blocks: — | Can parallelize with: T1, T3, T4, T5
+  - References: `cfc-frontend/pages/wisdom/cognitive-report.vue:96-103`
+  - Commit: Y | fix(wisdom): correct cognitive report to 6 DAN dimensions
+
+- [ ] 3. pages.json 注册3个新智慧子页面
+  - member-wisdom-detail + self-quiz + training-hub
+  - Blocks: T4, T5, T6 | Can parallelize with: T1, T2
+  - References: `cfc-frontend/pages.json:148-160`
+  - Commit: Y | chore(pages): register wisdom sub-pages in pages.json
+
+- [ ] 4. wisdom/training-hub.vue 新建训练中心页面
+  - 2张游戏卡片:舒尔特方格(schulte) + 猜数字(1a2b)
+  - Blocks: T7 | Blocked by: T3
+  - References: `cfc-frontend/pages/games/schulte.vue`, `cfc-frontend/pages/games/1a2b.vue`
+  - Commit: Y | feat(wisdom): create training-hub page with game links
+
+- [ ] 5. wisdom/member-wisdom-detail.vue 新建成员详情页
+  - 参考 member-mind-detail.vue,FamilyRelationGraph interactive=false
+  - Blocks: T7 | Blocked by: T3
+  - References: `cfc-frontend/pages/mind/member-mind-detail.vue:1-309`
+  - Commit: Y | feat(wisdom): create member-wisdom-detail page
+
+- [ ] 6. wisdom/self-quiz.vue 新建"自己出题自己答"核心功能页
+  - 4步流程:设定目标→题型选择→出题答题→通过判定;uni.getStorageSync 存储 session
+  - Blocks: T7 | Blocked by: T3
+  - References: `cfc-frontend/pages/wisdom/cognitive-report.vue:96-103`
+  - Commit: Y | feat(wisdom): create self-quiz page (自己出题自己答)
+
+- [ ] 7. wisdom/index.vue 按身维度页面模式完整重写
+  - 14区块:Banner→LoginGuide→能量条→关系图→用户入口→funcList→认知摘要→出题卡片→训练中心→任务→活动→商品→贴士→快捷操作
+  - Blocks: T8 | Blocked by: T1, T4, T5, T6
+  - References: `cfc-frontend/pages/body/index.vue:1-898`
+  - Commit: Y | feat(wisdom): full redesign of wisdom index page
+
+- [ ] 8. 全链路集成验证
+  - 编译+导航测试,lint检查
+  - Blocks: — | Blocked by: T7
+  - Commit: N | verification only
+
+## Final verification wave
+- [ ] F1. Plan compliance audit
+- [ ] F2. Code quality review — no `?.`, no CSS Grid
+- [ ] F3. Real manual QA
+- [ ] F4. Scope fidelity
+
+## Commit strategy
+每个实现 todo 独立提交(除 T8 纯验证不提交):
+- T1: fix(component): add wisdom dimension support to FamilyRelationGraph
+- T2: fix(wisdom): correct cognitive report to 6 DAN dimensions
+- T3: chore(pages): register wisdom sub-pages in pages.json
+- T4: feat(wisdom): create training-hub page with game links
+- T5: feat(wisdom): create member-wisdom-detail page
+- T6: feat(wisdom): create self-quiz page (自己出题自己答)
+- T7: feat(wisdom): full redesign of wisdom index page
+
+## Success criteria
+1. TabBar "智" 页面与其他4个维度页面视觉和交互体验一致
+2. FamilyRelationGraph 在智慧页面正常渲染,主题色为 #FFD700
+3. "自己出题自己答" 4步流程完整可操作
+4. 认知报告显示6维DAN标准(无"注意力"重复维度)
+5. 训练中心含2个游戏入口(舒尔特+猜数字),不超过上限
+6. 所有新页面注册在 pages.json,导航可达
+7. 编译零错误

+ 180 - 0
docs/设计/智维度页面重设计方案.md

@@ -0,0 +1,180 @@
+# 智维度页面重设计方案
+
+> 浠艾福五维能量体系 — 智(金)TabBar 页面全面升级
+
+## 一、背景与目标
+
+智维度(`pages/wisdom/index.vue`)是五维 TabBar 之一(身·智·心·行·我的),当前实现极度单薄(368行),缺少其他维度页面的标准组件和交互体验。本次重设计目标是:
+
+- 与身/心/行动维度页面保持视觉和交互一致性
+- 集成家庭成员关系图(FamilyRelationGraph)
+- 落地核心新功能「自己出题自己答」(元认知训练)
+- 修正认知报告维度错误(7维→6维DAN标准)
+
+## 二、设计约束
+
+| 约束 | 说明 |
+|------|------|
+| 小程序限制 | 禁止可选链 `?.`(用 `&&` 替代)、禁止 CSS Grid(用 flexbox) |
+| Vue 版本 | Vue 2 Options API |
+| 后端限制 | `@PostMapping` 统一接口、JWT 认证、MyBatis-Plus |
+| 接口规范 | 无新后端 API(自测题 MVP 前端 localStorage) |
+| 游戏上限 | 每维度最多 2 个游戏 |
+
+## 三、页面结构
+
+```
+pages/wisdom/
+├── index.vue              # 主 TabBar 页面(重写)
+├── cognitive-report.vue   # 认知报告(修正 7→6 维)
+├── member-wisdom-detail.vue  # 成员详情(新建)
+├── self-quiz.vue          # 自己出题自己答(新建)
+└── training-hub.vue       # 认知训练中心(新建)
+```
+
+### 3.1 主页面布局(index.vue)
+
+严格遵循 `body/index.vue` 模式,从上到下:
+
+| 顺序 | 组件 | 说明 |
+|------|------|------|
+| 1 | PageBanner | `theme="wisdom"`,金渐变,标语"学以启智·慧及未来" |
+| 2 | LoginGuideCard | 游客可见,引导登录 |
+| 3 | FamilyEnergyBar | 登录用户,五维能量条,dimensionCode="wisdom" |
+| 4 | **FamilyRelationGraph** | 登录用户,家庭成员关系图,dimensionCode="wisdom" |
+| 5 | UserQuickEntry | 登录用户,孩子切换入口 |
+| 6 | funcList | 5个功能入口:认知报告/自己出题/训练中心/测评预约/阅读天地 |
+| 7 | 认知摘要区 | 登录后,显示最新认知总分+DAN等级+最低维度提示 |
+| 8 | 自己出题入口卡 | 高亮卡片,入口→ self-quiz |
+| 9 | 训练中心入口卡 | 入口→ training-hub |
+| 10 | DimensionTasks | 今日智维度任务 |
+| 11 | DimensionActivities | 智维度活动 |
+| 12 | DimensionProducts | 智维度商品 |
+| 13 | 智慧小贴士 | 静态内容 |
+| 14 | 快捷操作按钮 | 认知报告/认知训练/测评预约 |
+
+### 3.2 自己出题自己答(self-quiz.vue)— 核心新功能
+
+**设计理念:** 赋能而非贩卖焦虑。孩子自己出题、自己答题,通过元认知训练提升自我觉察和学习能力。
+
+**四步交互流程:**
+
+```
+Step 1 设定目标
+  → 选择连续答对目标 N 题(范围 2-15,默认值按年龄+DAN自适应)
+  → "确认开始" 按钮
+
+Step 2 选择题型 + 获取提示
+  → 6种题型卡片(对应DAN六维)
+  → 系统根据孩子 DAN 最低维度 + 年龄段高亮推荐
+  → 推荐提示语(Phase 1 硬编码,Phase 2 接 Dify AI)
+
+Step 3 出题 + 答题
+  → textarea 输入题目(max 200字)
+  → input 输入正确答案(max 50字)
+  → "提交答案" 按钮
+  → 答错:鼓励 + 连续计数归0
+  → 答对:连续计数+1,显示进度 X/N
+
+Step 4 达标通过
+  → 连续答对 N 题 → 庆祝页面
+  → 展示:用时 + 总答题数 + 正确率
+  → "再来一轮" / "返回智维度" 按钮
+```
+
+**自适应目标 N 规则:**
+
+| 年龄段/DAN水平 | 默认目标 | 范围 |
+|---------------|---------|------|
+| 5-7岁 / DAN低分组 | 3 | 2-6 |
+| 8-10岁 / DAN中分组 | 5 | 3-8 |
+| 11岁+ / DAN高分组 | 8 | 5-15 |
+
+**MVP 数据存储:** `uni.getStorageSync('selfQuizSession')` — Phase 2 迁移至后端 API。
+
+### 3.3 训练中心(training-hub.vue)
+
+| 游戏 | 认知维度 | 路径 |
+|------|---------|------|
+| 🎯 舒尔特方格 | 专注力 | `/pages/games/schulte` |
+| 🔢 猜数字 | 逻辑思维 | `/pages/games/1a2b` |
+
+注:数独不放在智维度(已达 2 个游戏上限;且属于"空间思维"非核心维度)
+
+### 3.4 认知报告修正(cognitive-report.vue)
+
+**DAN 认知六维标准(修正后):**
+
+| 维度 | Key | 说明 |
+|------|-----|------|
+| 感知觉 | perceptionScore | 感官信息处理与知觉整合能力 |
+| 专注力 | focusScore | 持续集中注意力和抗干扰能力 |
+| 记忆力 | memoryScore | 信息编码、存储和回忆能力 |
+| 逻辑思维 | logicScore | 推理判断和问题解决能力 |
+| 空间思维 | spatialScore | 空间想象和方位判断能力 |
+| 加工速度 | processingSpeedScore | 信息处理和反应速度 |
+
+> 修正:删除重复的「注意力」(attentionScore),保留专注力。「执行功能」不在DAN六维内。
+
+### 3.5 成员关系图设计(FamilyRelationGraph)
+
+组件需修改以支持 `dimensionCode="wisdom"`:
+
+```javascript
+// validator 扩展
+validator: val => ['body', 'mind', 'action', 'home', 'wisdom'].indexOf(val) !== -1
+
+// themeColors 扩展
+wisdom: '#FFD700'
+```
+
+## 四、FamilyRelationGraph 组件改动
+
+| 文件 | 改动位置 | 内容 |
+|------|---------|------|
+| `components/FamilyRelationGraph.vue:107-108` | validator 数组 | 添加 `'wisdom'` |
+| `components/FamilyRelationGraph.vue:138-143` | themeColors 对象 | 添加 `wisdom: '#FFD700'` |
+
+## 五、不做的事(Must NOT Have)
+
+- ❌ 新建后端 SelfQuiz API 或数据库表(Phase 2)
+- ❌ 实现新游戏(只用已有 /pages/games/ 下的游戏)
+- ❌ 迁移心维度"智·认知"tab内容
+- ❌ 接入 Dify AI 生成出题提示(Phase 2)
+- ❌ 修改 Web 管理端
+- ❌ 修改其他维度页面(body/mind/action)的逻辑或外观
+
+## 六、Phase 2 待办
+
+以下为本次 MVP 范围外的规划,需后续迭代:
+
+1. **SelfQuiz 后端 API** — 持久化答题记录、能量奖励、历史统计
+2. **Dify AI 出题提示** — 根据孩子 DAN 六维数据生成个性化出题建议
+3. **心维度"智·认知"tab 迁移** — 将 mind/index.vue 中的认知内容迁移至 wisdom 体系
+4. **Web 管理端智维度配置** — 知识库管理、维度配置等
+
+## 七、附录
+
+### A. 参考文件
+
+- `cfc-frontend/pages/body/index.vue` — 维度页面标准模式
+- `cfc-frontend/pages/mind/member-mind-detail.vue` — 成员详情页模板
+- `cfc-frontend/components/FamilyRelationGraph.vue` — 关系图组件
+- `cfc-frontend/components/PageBanner.vue` — 主题色定义
+- `cfc-backend/.../CognitiveController.java` — DAN 认知 API
+
+### B. 能量体系
+
+- 智维度能量日上限:80/天
+- 全局日上限:200/天
+- 计算公式:认知测评70% + 天赋30% + 行为修正
+
+### C. 五行对应
+
+| 维度 | 五行 | 色值 |
+|------|------|------|
+| 智 | 金 | #FFD700 |
+| 心 | 火 | #FF6B35 |
+| 身 | 土 | #8D6E63 |
+| 行 | 木 | #4CAF50 |
+| 富 | 水 | #42A5F5 |

+ 277 - 0
docs/运营推广设计/IMPLEMENTATION_PLAN.md

@@ -0,0 +1,277 @@
+# mini-program-promotion - Work Plan
+
+## TL;DR (For humans)
+<!-- Fill this LAST, after the detailed plan below is written, so it summarizes the REAL plan. -->
+<!-- Plain English for a non-engineer: NO file paths, NO todo numbers, NO wave/agent/tool names. -->
+
+**What you'll get:** 小程序的推广功能全面升级——完成3个"即将上线"占位页(分享邀请卡、推广团队、微信转发),新增6个增长功能(新手任务奖励、每日任务、优惠券、邀请里程碑、推广排行榜),外加后台管理页面。目标:拉新(分享卡片+排行榜+里程碑)→ 激活(新手任务)→ 留存(每日任务)→ 变现(优惠券)。
+
+**Why this approach:** AARRR增长框架(拉新→激活→留存→变现→裂变),按优先级分3层:先补齐已有的半成品(P0,投资回报率最高),再补核心增长缺口(P1,激活+变现基石),最后加强裂变(P2,利用现有两级佣金体系)。优先完成"即将上线"占位页,因为它们已经有用户期待但不可用——修复后立刻产生价值。
+
+**What it will NOT do:** 不实现推送通知(需微信订阅消息)、不实现免费试用会员(需调整支付流程)、不实现拼团/限时抢购(需并发处理)、不实现百分比优惠券(只做固定金额)、不实现三级以上团队树。
+
+**Effort:** Large — 19个任务项,5个工作波次,覆盖后端+小程序+管理端
+**Risk:** Medium — 优惠券需集成现有支付流程,涉及金额计算;其他功能独立,风险低
+**Decisions I made for you:**
+- 分享邀请卡 → Canvas本地海报+保存相册(非服务端生成图片,无需额外基础设施)
+- 团队页 → 只展示L1/L2成员列表(非树形可视化,两级佣金模型已够用)
+- 邀请里程碑 → 3/5/10人三档,积分+能量奖励(200/500/1000点——金额约2-10元,有竞争力但不滥用)
+- 排行榜 → 每周重置,显示前20名(每周防固化,20名足够激励)
+- 每日任务 → 4个:签到/完成任务/分享/AI对话(覆盖已有功能,驱动DAU)
+- 优惠券 → 仅固定金额(最简实现,无复杂边界情况)
+- 触发方式 → 直接服务层调用,非AOP/事件(与现有代码风格一致,简单可调试)
+- 新手任务的"已完成" → 仅统计上线后的行为,不追溯历史数据
+
+Your next move: 审阅并批准(approve)本计划,然后我运行自动高精度评审(双Momus)。批准仅授权编写计划,不授权执行。
+
+---
+
+> TL;DR (machine): <1 line - effort, risk, deliverables>
+
+## Scope
+### Must have
+- **C1 Share Card**: Canvas-based poster with user avatar + referral code + mini-program QR code + brand imagery, save to album
+- **C2 Team Page**: L1/L2 member list with pagination (nickname, avatar, join date, order count, commission earned)
+- **C3 WeChat Share**: onShareAppMessage wired on promotion/index, promotion/invite, parent/invite with referral code in path
+- **C4 Onboarding Rewards**: 6 newbie tasks (REGISTER, COMPLETE_PROFILE, ADD_CHILD, FIRST_CHECKIN, FIRST_TASK, BIND_REFERRAL) with points+energy rewards
+- **C5 Growth Tasks**: 4 daily tasks (checkin, complete task, share article, AI chat) + 3 newbie tasks (same as C4 subset); DB-configurable
+- **C6 Coupon System**: Fixed-amount coupons (coupon + user_coupon tables), claim + apply at checkout, admin CRUD
+- **C7 Invite Milestones**: 3 tiers (3/5/10 invites → 200/500/1000 points + energy), checked on bindReferral
+- **C8 Referral Leaderboard**: Weekly top 20, reset Monday, ranked by referral count
+- **Admin management**: Web admin pages for coupons, growth tasks, milestones, leaderboard view
+
+### Must NOT have (guardrails, anti-slop, scope boundaries)
+- NO re-engagement push notifications (requires WeChat subscribe message infrastructure not yet in place)
+- NO trial/free membership (requires payment flow changes beyond scope)
+- NO group buy / flash sale / time-limited promotion engine (requires order concurrency handling)
+- NO percentage-based coupons (fixed-amount only; percentage adds boundary-case complexity)
+- NO team tree visualization beyond L1/L2 (marginal UX value)
+- NO server-side poster image generation (Canvas poster is sufficient for mini-program)
+- NO AOP / event-driven architecture for task triggers (direct service calls match existing codebase style)
+- NO changes to web/ static site or cfc-web/ admin router (backend + frontend only)
+- NO retroactive credit for tasks completed before onboarding system launch
+
+## Verification strategy
+> Zero human intervention - all verification is agent-executed.
+- Test decision: tests-after (existing codebase has no test harness for controller/service layers; integration tests via curl against running server)
+- Framework: bash curl for API verification, uni-app dev build for frontend page existence checks
+- Evidence: .omo/evidence/task-<N>-mini-program-promotion.<ext>
+- Every new backend endpoint: curl happy-path + error-path, assert Result code=0 for success, code!=0 for validation failure
+- Every new frontend page: verify page registers in pages.json and component renders without JS error
+
+## Execution strategy
+### Parallel execution waves
+- **Wave 1 (P0 — Complete Stubs)**: C1 Share Card, C2 Team Page, C3 WeChat Share — these are independent, can be parallelized
+- **Wave 2 (P1 — Onboarding)**: C4 Onboarding Rewards backend + frontend — depends on Wave 1 for share-card component reuse pattern
+- **Wave 3 (P1 — Growth + Coupon)**: C5 Growth Tasks, C6 Coupon System — independent of each other, can be parallelized
+- **Wave 4 (P2 — Referral Amplification)**: C7 Invite Milestones, C8 Leaderboard — C7 depends on C4 (onboarding defines reward pattern); C8 independent
+- **Wave 5 (Admin + Integration)**: Admin pages for all new features + end-to-end integration testing — depends on all prior waves
+
+### Dependency matrix
+| Todo | Depends on | Blocks | Can parallelize with |
+| --- | --- | --- | --- |
+| 1 (Share Card) | — | 3 (share card image for onShareAppMessage) | 2, 4 |
+| 2 (Team API) | — | 5 (Team frontend) | 1, 3 |
+| 3 (WeChat Share) | 1 (share poster for imageUrl) | — | 2, 4 |
+| 4 (Team Frontend) | 2 (Team API) | — | 1, 3 |
+| 5 (Onboarding DB+API) | — | 6 (Onboarding frontend) | 1, 2, 3 |
+| 6 (Onboarding Frontend) | 5 | — | 7, 8 |
+| 7 (Growth Tasks API) | — | 9 (Growth Tasks frontend) | 5, 8 |
+| 8 (Coupon API) | — | 10 (Coupon frontend) | 5, 7 |
+| 9 (Growth Tasks Frontend) | 7 | — | 6, 10 |
+| 10 (Coupon Frontend) | 8 | — | 6, 9 |
+| 11 (Coupon Checkout Integration) | 8, 10 | — | 9 |
+| 12 (Invite Milestones API) | — | 13 (Milestones frontend) | 7, 8 |
+| 13 (Invite Milestones Frontend) | 12 | — | 9, 10 |
+| 14 (Leaderboard API) | — | 15 (Leaderboard frontend) | 12, 13 |
+| 15 (Leaderboard Frontend) | 14 | — | 13 |
+| 16 (Admin Coupons) | 8 | — | 14, 15 |
+| 17 (Admin Growth Tasks) | 7 | — | 16, 15 |
+| 18 (Admin Milestones+Leaderboard) | 12, 14 | — | 16, 17 |
+| 19 (E2E Integration) | All above | — | — |
+
+## Todos
+> Implementation + Test = ONE todo. Never separate.
+<!-- APPEND TASK BATCHES BELOW THIS LINE WITH edit/apply_patch - never rewrite the headers above. -->
+
+- [ ] 1. Share Card — Canvas poster component + backend mini-program QR endpoint
+  What to do / Must NOT do: Create a `SharePoster` component in cfc-frontend that renders a Canvas poster with: user avatar (or default icon), nickname, referral code in large text, mini-program QR code (from WechatService.generateWxacode with scene=referralCode), brand imagery (brand orange gradient background, "浠艾福" logo text). On tap of "分享邀请卡" button in promotion/index.vue, show poster modal, then "保存到相册" button that calls `uni.canvasToTempFilePath` → `uni.saveImageToPhotosAlbum`. Handle album permission denial with `uni.openSetting`. Replace the stub `shareCard()` at line 131. Also wire the same in promotion/invite.vue at line 143. Must NOT generate server-side images or use wx.shareAppMessage for poster sharing (save-to-album is the standard pattern).
+  Parallelization: Wave 1 | Blocked by: none | Blocks: 3 (needs poster for share imageUrl)
+  References: cfc-frontend/pages/promotion/index.vue:1-285 (full page, stub at line 131), cfc-frontend/pages/promotion/invite.vue:1-335 (stub at line 143), cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java (generateWxacode method), cfc-backend/src/main/java/com/etotem/cfc/service/QrCodeService.java (Base64 QR generation), cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java (getReferralCode endpoint), cfc-frontend/utils/api.js (getReferralCode function), uni-app Canvas API docs: https://uniapp.dcloud.net.cn/api/canvas/createCanvasContext.html
+  Acceptance criteria (agent-executable): (1) `grep -c 'canvasToTempFilePath' cfc-frontend/pages/promotion/index.vue` returns ≥1; (2) `grep -c 'saveImageToPhotosAlbum' cfc-frontend/pages/promotion/index.vue` returns ≥1; (3) `grep '即将上线' cfc-frontend/pages/promotion/index.vue` returns 0 (stub removed); (4) same 3 checks for promotion/invite.vue; (5) `grep -c 'SharePoster\\|share-poster\\|sharePoster' cfc-frontend/pages/promotion/index.vue` or component file returns ≥1
+  QA scenarios: happy: user taps "分享邀请卡" → Canvas renders poster → taps "保存" → image saved to album → toast "已保存"; failure: user denies album permission → modal prompts "需要相册权限" → button opens settings page via uni.openSetting. Evidence: .omo/evidence/task-1-mini-program-promotion.txt
+  Commit: Y | feat(promotion): add Canvas share poster with mini-program QR code
+
+- [ ] 2. Team Page — Backend API for L1/L2 member list with pagination
+  What to do / Must NOT do: Add `POST /api/commission/team/list` endpoint to CommissionController. Accepts: page (int, default 1), size (int, default 20), level (String, "L1" or "L2" or "ALL" default). Returns paginated list of referred users with: id, nickname, avatarUrl, createdAt (join date), orderCount (count of commission records where this user is buyer), commissionEarned (sum of commission where referrer is current user and buyer is this member). Add `getTeamList(userId, level, page, size)` method to CommissionService. SQL: JOIN user table on buyer_id = referred_user's user_id, LEFT JOIN commission_record for order count and commission sum. Must handle: users with null nickname (show "微信用户"), users with null avatar (show default icon). Must NOT show deeper tree beyond L1/L2.
+  Parallelization: Wave 1 | Blocked by: none | Blocks: 4 (Team frontend)
+  References: cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java (existing team endpoint at /api/commission/team), cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java (getTeamStats method), cfc-backend/src/main/java/com/etotem/cfc/entity/CommissionRecord.java (buyerId, referrerId, commissionType, level fields), cfc-backend/src/main/java/com/etotem/cfc/mapper/CommissionRecordMapper.java, cfc-backend/src/main/java/com/etotem/cfc/dto/CommissionSummaryDTO.java (for field naming convention)
+  Acceptance criteria (agent-executable): (1) `grep -c 'team/list' cfc-backend/src/main/java/com/etotem/cfc/controller/CommissionController.java` returns ≥1; (2) `grep 'getTeamList' cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java` returns match; (3) `mvn compile -pl cfc-backend -q` exits 0 (no compile errors); (4) endpoint returns Result with paginated records when called with valid JWT
+  QA scenarios: happy: GET /api/commission/team/list?page=1&size=20 returns Result(code=0, data={records:[...], total:N}); failure: no JWT → 401; no referred users → returns empty list with total=0. Evidence: .omo/evidence/task-2-mini-program-promotion.txt
+  Commit: Y | feat(commission): add paginated team member list endpoint
+
+- [ ] 3. WeChat Share — Wire onShareAppMessage on promotion pages
+  What to do / Must NOT do: Add `onShareAppMessage()` lifecycle hook to 3 pages: (1) cfc-frontend/pages/promotion/index.vue, (2) cfc-frontend/pages/promotion/invite.vue, (3) cfc-frontend/pages/parent/invite/index.vue. Each returns: title="我在浠艾福发现了好用的家庭成长工具,快来加入!" or similar, path="/pages/invite/join?inviteCode=REFERRAL_CODE" (where REFERRAL_CODE is the user's referral code from data), imageUrl=share poster image (default brand image if poster not generated yet). The landing page pages/invite/join.vue already exists and accepts inviteCode. Must NOT use wx.shareTimeline (moments sharing requires separate review). Must NOT change the path structure of existing pages.
+  Parallelization: Wave 1 | Blocked by: 1 (optional — uses poster image, but can use default imageUrl) | Blocks: none
+  References: cfc-frontend/pages/promotion/index.vue:1-285, cfc-frontend/pages/promotion/invite.vue:1-335, cfc-frontend/pages/parent/invite/index.vue, cfc-frontend/pages/invite/join.vue (existing landing page with invite code acceptance), uni-app onShareAppMessage docs: https://uniapp.dcloud.net.cn/api/plugins/share.html
+  Acceptance criteria (agent-executable): (1) `grep -c 'onShareAppMessage' cfc-frontend/pages/promotion/index.vue` returns ≥1; (2) same for invite.vue; (3) same for parent/invite/index.vue; (4) each contains 'inviteCode' in the path string literal
+  QA scenarios: happy: user taps "..." → "转发" in WeChat → share card preview shows title+image → recipient opens → lands on invite/join page with inviteCode param; failure: user with no referral code → share still works, path omits inviteCode param. Evidence: .omo/evidence/task-3-mini-program-promotion.txt
+  Commit: Y | feat(share): wire onShareAppMessage for promotion and invite pages
+
+- [ ] 4. Team Page — Frontend team members list page
+  What to do / Must NOT do: Create cfc-frontend/pages/promotion/team.vue. Shows: (1) Team stats card at top (L1 count, L2 count, total team commission — reuse data from /api/commission/team), (2) Segmented control: "直推(L1)" / "间推(L2)" tabs, (3) Paginated member list: each item shows avatar (or default icon), nickname (or "微信用户"), join date, order count badge, commission earned. (4) Load more onReachBottom. Replace stub `goTeam()` in promotion/index.vue at line 142-143 with `uni.navigateTo({ url: '/pages/promotion/team' })`. Add page route to pages.json under promotion subpackage. Add API function `getTeamList(page, size, level)` to api.js. Must NOT show L3+ or tree visualization.
+  Parallelization: Wave 1 | Blocked by: 2 (Team API) | Blocks: none
+  References: cfc-frontend/pages/promotion/index.vue:142-143 (goTeam stub), cfc-frontend/pages/promotion/commission.vue:1-294 (similar list page pattern for reference), cfc-frontend/pages/promotion/invite.vue:1-335 (similar stats card pattern), cfc-frontend/pages.json (subpackages section for route registration), cfc-frontend/utils/api.js (add getTeamList function)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-frontend/pages/promotion/team.vue` exits 0; (2) `grep 'promotion/team' cfc-frontend/pages.json` returns match; (3) `grep 'getTeamList' cfc-frontend/utils/api.js` returns match; (4) `grep '即将上线' cfc-frontend/pages/promotion/index.vue` returns 0 (all stubs removed); (5) `grep 'promotion/team' cfc-frontend/pages/promotion/index.vue` returns match (navigation target)
+  QA scenarios: happy: user taps "推广团队" → navigates to team page → sees L1/L2 tabs → taps L2 → sees L2 members; failure: no team members → shows empty state "还没有推广团队成员"; API error → shows error toast. Evidence: .omo/evidence/task-4-mini-program-promotion.txt
+  Commit: Y | feat(promotion): add team members list page with L1/L2 tabs
+
+- [ ] 5. Onboarding Rewards — Backend: entity + table + service + controller
+  What to do / Must NOT do: Create (1) Entity: `OnboardingTask.java` with fields: id, userId, taskType(VARCHAR(32): REGISTER/COMPLETE_PROFILE/ADD_CHILD/FIRST_CHECKIN/FIRST_TASK/BIND_REFERRAL), rewardPoints(INT default 0), rewardEnergy(INT default 0), status(VARCHAR(16): PENDING/CLAIMED, default PENDING), completedAt(DATETIME), createdAt. (2) Mapper: `OnboardingTaskMapper.java`. (3) Service: `OnboardingService.java` with methods: initForUser(userId) — creates 6 PENDING tasks; completeTask(userId, taskType) — marks task completed, returns reward info; claimReward(userId, taskType) — sets status=CLAIMED, calls PointsService.awardSystemPoints + EnergyService.awardEnergy; getProgress(userId) — returns all tasks with status; auto-trigger: call completeTask from existing services — UserService (register → REGISTER), AuthController (profile update → COMPLETE_PROFILE), ChildService (addChild → ADD_CHILD), HealthCheckinService (checkin → FIRST_CHECKIN), TaskService (task completion → FIRST_TASK), BindInviteService/CommissionService (bindReferral → BIND_REFERRAL). (4) Controller: `OnboardingController.java` at /api/onboarding with endpoints: POST /api/onboarding/progress (get user's tasks), POST /api/onboarding/claim (claim reward for a task). (5) DatabaseInitializer: add CREATE TABLE IF NOT EXISTS onboarding_task + seed default reward values (REGISTER=100pts+10energy, COMPLETE_PROFILE=50pts+5energy, ADD_CHILD=100pts+10energy, FIRST_CHECKIN=30pts+5energy, FIRST_TASK=50pts+5energy, BIND_REFERRAL=50pts+10energy). Must NOT credit retroactively for tasks completed before system launch (only tasks completed after the code deploy count). Must NOT use AOP/event system.
+  Parallelization: Wave 2 | Blocked by: none | Blocks: 6 (Onboarding frontend)
+  References: cfc-backend/src/main/java/com/etotem/cfc/entity/PointsLog.java (entity pattern), cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java (awardSystemPoints method), cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java (awardEnergy method), cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (table creation pattern), cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java (controller pattern with @PostMapping), cfc-backend/src/main/java/com/etotem/cfc/common/Result.java (response wrapper), cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java (register method), cfc-backend/src/main/java/com/etotem/cfc/service/ChildService.java (addChild), cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java (checkin), cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java (completeTask), cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java (bindReferral)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/OnboardingTask.java` exits 0; (2) `grep 'class OnboardingService' cfc-backend/src/main/java/com/etotem/cfc/service/OnboardingService.java` returns match; (3) `grep 'class OnboardingController' cfc-backend/src/main/java/com/etotem/cfc/controller/OnboardingController.java` returns match; (4) `grep 'completeTask' cfc-backend/src/main/java/com/etotem/cfc/service/OnboardingService.java` returns match; (5) `mvn compile -pl cfc-backend -q` exits 0; (6) curl /api/onboarding/progress with valid JWT returns Result(code=0, data=[...tasks...])
+  QA scenarios: happy: new user registers → REGISTER task auto-completes → user calls /api/onboarding/progress → sees 6 tasks, REGISTER status=COMPLETED → calls /api/onboarding/claim with taskType=REGISTER → gets 100 points + 10 energy → status becomes CLAIMED; failure: claim already-claimed task → returns error "已领取"; claim non-existent task → returns error. Evidence: .omo/evidence/task-5-mini-program-promotion.txt
+  Commit: Y | feat(onboarding): add newbie task reward system with 6 task types
+
+- [ ] 6. Onboarding Rewards — Frontend: progress page + claim UX
+  What to do / Must NOT do: Create cfc-frontend/pages/profile/onboarding.vue. Shows: (1) Title "新手任务" with progress bar (X/6 completed), (2) Task list: each item shows emoji icon, task description, reward amount (points+energy), status badge (待完成/已完成/已领取), claim button (only when COMPLETED and not CLAIMED). On claim, call API, show success animation, update item status. Referral from: add "新手任务" entry in ProfileMenu.vue or profile.vue. Add route to pages.json. Add API functions: getOnboardingProgress, claimOnboardingReward to api.js. Must NOT show retroactive credit for pre-launch actions. Must NOT use optional chaining.
+  Parallelization: Wave 2 | Blocked by: 5 (Onboarding API) | Blocks: none
+  References: cfc-frontend/pages/profile/profile.vue (hub page for adding entry), cfc-frontend/pages/profile/components/ProfileMenu.vue (menu items), cfc-frontend/pages/rewards/rewards.vue (similar list page pattern), cfc-frontend/pages.json (route registration), cfc-frontend/utils/api.js (add API functions)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-frontend/pages/profile/onboarding.vue` exits 0; (2) `grep 'onboarding' cfc-frontend/pages.json` returns match; (3) `grep 'getOnboardingProgress\\|claimOnboardingReward' cfc-frontend/utils/api.js` returns match; (4) page contains progress bar and task list items
+  QA scenarios: happy: user taps "新手任务" → sees 6 tasks → completed ones show "领取" button → taps → gets toast "获得100积分+10能量"; failure: network error → shows error toast; all tasks already claimed → shows "全部完成" badge. Evidence: .omo/evidence/task-6-mini-program-promotion.txt
+  Commit: Y | feat(onboarding): add newbie task progress and claim page
+
+- [ ] 7. Growth Tasks — Backend: entity + table + service + controller + daily reset
+  What to do / Must NOT do: Create (1) Entity: `GrowthTask.java` — id, type(VARCHAR: DAILY/NEWBIE), title, description, rewardPoints(INT), rewardEnergy(INT), targetValue(INT default 1, e.g. "complete 1 task"), taskKey(VARCHAR(32) unique: DAILY_CHECKIN/DAILY_TASK/DAILY_SHARE/DAILY_AI/NEWBIE_PROFILE/NEWBIE_CHILD/NEWBIE_REFERRAL), enabled(TINYINT default 1), sortOrder(INT). (2) Entity: `GrowthTaskLog.java` — id, userId, taskId, progress(INT default 0), completed(TINYINT default 0), claimed(TINYINT default 0), date(VARCHAR(10) for daily tasks, format YYYY-MM-DD), createdAt. (3) Mapper for both. (4) Service: `GrowthTaskService.java` — getTaskList(userId, type): returns tasks + user's progress for today (daily) or all-time (newbie); updateProgress(userId, taskKey, increment): updates progress, auto-marks completed when progress>=targetValue; claimReward(userId, taskLogId): validates task is completed and not claimed, then calls PointsService.awardSystemPoints + EnergyService.awardEnergy, sets claimed=1. (5) Auto-triggers: checkin → DAILY_CHECKIN, taskService.completeTask → DAILY_TASK, shareArticle → DAILY_SHARE (called from frontend onShareAppMessage success callback), AI chat message → DAILY_AI (called from chat page after sending message). (6) Scheduled task: `GrowthTaskDailyResetTask.java` — at midnight CST, no-op (daily tasks naturally reset by date field — each day's log is a new row). (7) Controller: `GrowthTaskController.java` at /api/growth-task with: POST /list (get tasks by type), POST /claim (claim reward). (8) DatabaseInitializer: CREATE TABLE growth_task + growth_task_log + seed 7 task rows. Default daily tasks: DAILY_CHECKIN(20pts+3energy), DAILY_TASK(30pts+5energy), DAILY_SHARE(10pts+2energy), DAILY_AI(15pts+3energy). Must NOT use AOP; must NOT allow claiming same task twice per day; must validate taskKey format (uppercase + underscore only, max 32 chars).
+  Parallelization: Wave 3 | Blocked by: none | Blocks: 9 (Growth Tasks frontend)
+  References: cfc-backend/src/main/java/com/etotem/cfc/entity/PointsLog.java (entity pattern), cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java (awardSystemPoints), cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java (awardEnergy with daily caps: body=50,mind=40,wisdom=80,action=60,wealth=50,global=200), cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (table creation + seed data), cfc-backend/src/main/java/com/etotem/cfc/task/StreakResetTask.java (scheduled task pattern), cfc-backend/src/main/java/com/etotem/cfc/service/HealthCheckinService.java (checkin trigger point), cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java (task completion trigger)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthTask.java` exits 0; (2) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthTaskLog.java` exits 0; (3) `grep 'class GrowthTaskService' cfc-backend/src/main/java/com/etotem/cfc/service/GrowthTaskService.java` returns match; (4) `grep 'class GrowthTaskController' cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthTaskController.java` returns match; (5) `mvn compile -pl cfc-backend -q` exits 0; (6) seeded task count in growth_task table = 7
+  QA scenarios: happy: user checks in → DAILY_CHECKIN progress auto-incremented → auto-completes → user claims 20pts+3energy → claimed=1; second claim attempt → returns error "已领取"; failure: claim incomplete task → error; claim yesterday's task via today's log → not possible (date field prevents it). Evidence: .omo/evidence/task-7-mini-program-promotion.txt
+  Commit: Y | feat(growth-task): add daily and newbie growth task system with points+energy rewards
+
+- [ ] 8. Coupon System — Backend: entity + table + service + controller
+  What to do / Must NOT do: Create (1) Entity: `Coupon.java` — id, name(VARCHAR(64)), type(VARCHAR(16): FIXED), value(INT in cents, e.g. 1000=10元), minSpend(INT in cents, 0=no minimum), applicableTo(VARCHAR(32): ALL/MEMBERSHIP/PRODUCT/ASSESSMENT, default ALL), validFrom(DATETIME), validUntil(DATETIME), totalCount(INT, issuer limit), usedCount(INT default 0), createdAt. (2) Entity: `UserCoupon.java` — id, userId, couponId, status(VARCHAR(16): AVAILABLE/USED/EXPIRED), receivedAt(DATETIME), usedAt(DATETIME), orderId(BIGINT nullable). (3) Mapper for both. (4) Service: `CouponService.java` — listAvailable(userId): returns user's AVAILABLE coupons; claim(userId, couponId): validates coupon not exhausted (usedCount<totalCount), not expired, user hasn't claimed before → creates UserCoupon record; apply(userId, couponId, orderType, orderAmount): validates coupon is AVAILABLE, applicableTo matches orderType, orderAmount>=minSpend, not expired → returns discount amount in cents, does NOT deduct yet (deduction happens at payment confirmation); markUsed(userCouponId, orderId): sets status=USED, usedAt=now, orderId. (5) Controller: `CouponController.java` at /api/coupon with: POST /list (user's coupons), POST /claim (claim a coupon by couponId), POST /apply (preview discount for an order). (6) Admin controller: `AdminCouponController.java` at /api/admin/coupon with: POST /create, POST /update, POST /list, POST /issue (batch issue to users). (7) DatabaseInitializer: CREATE TABLE coupon + user_coupon + seed 2 demo coupons (新用户专享10元券, minSpend=0, applicableTo=ALL; 会员升级50元券, minSpend=9900, applicableTo=MEMBERSHIP). Must NOT implement percentage coupons; must NOT allow double coupon on same order (one coupon per order max); must validate coupon existence before claim.
+  Parallelization: Wave 3 | Blocked by: none | Blocks: 10 (Coupon frontend), 11 (Checkout integration)
+  References: cfc-backend/src/main/java/com/etotem/cfc/entity/PointsLog.java (entity pattern, INT cents for money), cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java (checkout flow where coupon will integrate), cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java (membership upgrade where coupon will integrate), cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (table creation + seed), cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionController.java (admin controller pattern)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/Coupon.java` exits 0; (2) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/UserCoupon.java` exits 0; (3) `grep 'class CouponService' cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java` returns match; (4) `grep 'class CouponController' cfc-backend/src/main/java/com/etotem/cfc/controller/CouponController.java` returns match; (5) `grep 'class AdminCouponController' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCouponController.java` returns match; (6) `mvn compile -pl cfc-backend -q` exits 0
+  QA scenarios: happy: admin creates coupon → user claims it → user applies to order (amount>=minSpend) → discount returned; failure: claim exhausted coupon → "已领完"; apply coupon to order below minSpend → "未达到使用门槛"; claim same coupon twice → "已领取"; apply expired coupon → "已过期". Evidence: .omo/evidence/task-8-mini-program-promotion.txt
+  Commit: Y | feat(coupon): add fixed-amount coupon system with claim, apply, and admin CRUD
+
+- [ ] 9. Growth Tasks — Frontend: daily task page + newbie task tab
+  What to do / Must NOT do: Create cfc-frontend/pages/tasks/daily-tasks.vue. Shows: (1) Segmented control: "每日任务" / "新手任务" tabs, (2) Daily tasks: 4 items with progress indicators (0/1), reward amounts, claim buttons, (3) Newbie tasks: reuse onboarding tasks from C4/C5 — show same data from /api/growth-task/list?type=NEWBIE, (4) Progress bar showing X/N completed for current tab. Add route to pages.json. Add API functions: getGrowthTaskList, claimGrowthTaskReward to api.js. Wire auto-triggers: onShareAppMessage callback → call updateProgress(DAILY_SHARE); AI chat message sent → call updateProgress(DAILY_AI). Profile menu entry: "每日任务" linking to daily-tasks.vue. Must NOT use optional chaining; must NOT show tasks for previous dates.
+  Parallelization: Wave 3 | Blocked by: 7 (Growth Tasks API) | Blocks: none
+  References: cfc-frontend/pages/tasks/tasks.vue (existing task page for reference), cfc-frontend/pages/profile/components/ProfileMenu.vue (menu entry), cfc-frontend/pages/ai/chat.vue (DAILY_AI trigger point), cfc-frontend/pages.json (route registration), cfc-frontend/utils/api.js (add API functions)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-frontend/pages/tasks/daily-tasks.vue` exits 0; (2) `grep 'daily-tasks' cfc-frontend/pages.json` returns match; (3) `grep 'getGrowthTaskList\\|claimGrowthTaskReward' cfc-frontend/utils/api.js` returns match; (4) page has segmented control with "每日任务" and "新手任务"
+  QA scenarios: happy: user opens daily tasks → sees 4 daily items → completed tasks show "领取" → taps → gets toast reward; failure: no tasks seeded → shows empty state; API error → shows error toast. Evidence: .omo/evidence/task-9-mini-program-promotion.txt
+  Commit: Y | feat(growth-task): add daily and newbie task page with claim UX
+
+- [ ] 10. Coupon System — Frontend: coupon list + apply at checkout
+  What to do / Must NOT do: Create cfc-frontend/pages/profile/coupons.vue. Shows: (1) Tab bar: "可使用" / "已使用" / "已过期", (2) Coupon cards: name, value (¥10), minSpend condition, valid period, applicable scope badge, (3) Empty state for each tab. Add route to pages.json. Add API functions: getCouponList, claimCoupon, applyCoupon to api.js. Profile menu entry: "我的优惠券". In checkout pages (shop/checkout.vue for packages, membership/index.vue for upgrade): add "选择优惠券" section → tap opens coupon selector popup → shows AVAILABLE coupons that match orderType and minSpend → select → shows discount amount → confirm → passes couponId in payment create request. Must NOT allow stacking multiple coupons per order.
+  Parallelization: Wave 3 | Blocked by: 8 (Coupon API) | Blocks: none
+  References: cfc-frontend/pages/profile/profile.vue (hub for entry), cfc-frontend/pages/profile/components/ProfileMenu.vue (menu), cfc-frontend/pages/shop/checkout.vue (checkout integration point for packages), cfc-frontend/pages/membership/index.vue (membership checkout integration), cfc-frontend/pages.json (route), cfc-frontend/utils/api.js (add API functions)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-frontend/pages/profile/coupons.vue` exits 0; (2) `grep 'profile/coupons' cfc-frontend/pages.json` returns match; (3) `grep 'getCouponList\\|claimCoupon\\|applyCoupon' cfc-frontend/utils/api.js` returns match; (4) `grep -c 'coupon' cfc-frontend/pages/shop/checkout.vue` returns ≥1 (checkout integration exists)
+  QA scenarios: happy: user goes to "我的优惠券" → sees available coupons → goes to checkout → selects coupon → discount shown → payment reduced; failure: no applicable coupons → shows "暂无可用优惠券"; coupon expired between selection and payment → API returns error "已过期". Evidence: .omo/evidence/task-10-mini-program-promotion.txt
+  Commit: Y | feat(coupon): add coupon list page and checkout integration
+
+- [ ] 11. Coupon — Integrate coupon deduction in payment controllers
+  What to do / Must NOT do: Modify PackagePaymentController.createOrder and MembershipService.createOrder to accept optional `couponId` parameter. Logic: (1) validate coupon via CouponService.apply (checks available, applicableTo, minSpend, not expired), (2) calculate discounted amount = original price - coupon value (ensure discounted amount >= 0), (3) create order with discounted amount, (4) on payment success callback, call CouponService.markUsed to set coupon status=USED and link orderId. If order is cancelled/refunded, call CouponService to revert status back to AVAILABLE. Must NOT allow coupon value > order amount (cap at order amount). Must NOT skip the coupon's applicableTo check (a membership coupon cannot be used on a product order).
+  Parallelization: Wave 3 | Blocked by: 8 (Coupon API) | Blocks: none
+  References: cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java (create order flow), cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java (createOrder + processPaymentCallback methods), cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java (apply + markUsed methods from todo 8)
+  Acceptance criteria (agent-executable): (1) `grep 'couponId' cfc-backend/src/main/java/com/etotem/cfc/controller/PackagePaymentController.java` returns match; (2) `grep 'couponId' cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java` returns match; (3) `grep 'markUsed' cfc-backend/src/main/java/com/etotem/cfc/service/CouponService.java` returns match or is called in payment callback flow; (4) `mvn compile -pl cfc-backend -q` exits 0
+  QA scenarios: happy: user creates order with valid coupon → order amount reduced → payment succeeds → coupon marked USED; failure: coupon not applicable to order type → "该优惠券不可用于此订单"; order amount < coupon minSpend → "未达到使用门槛"; payment fails → coupon stays AVAILABLE. Evidence: .omo/evidence/task-11-mini-program-promotion.txt
+  Commit: Y | feat(coupon): integrate coupon deduction in package and membership payment
+
+- [ ] 12. Invite Milestones — Backend: entity + table + service + check-on-bind
+  What to do / Must NOT do: Create (1) Entity: `InviteMilestone.java` — id, userId, milestone(VARCHAR: INVITE_3/INVITE_5/INVITE_10), rewardPoints(INT), rewardEnergy(INT), status(VARCHAR: PENDING/CLAIMED), achievedAt(DATETIME), claimedAt(DATETIME). (2) Mapper. (3) Service: `InviteMilestoneService.java` — checkMilestones(userId): called from CommissionService.bindReferral after successful bind; counts total L1 referrals for userId from DB (SELECT COUNT from commission_record or user referral table); if count hits 3/5/10 and milestone not yet achieved → create InviteMilestone record with status=PENDING; claimReward(userId, milestoneKey): sets status=CLAIMED, awards points+energy; getMilestones(userId): returns all milestones with status. (4) Controller: `InviteMilestoneController.java` at /api/invite/milestone with: POST /list (user's milestones), POST /claim (claim a milestone reward). (5) DatabaseInitializer: CREATE TABLE invite_milestone + seed 3 milestone definitions (INVITE_3: 200pts+20energy, INVITE_5: 500pts+50energy, INVITE_10: 1000pts+100energy). Must use DB-level COUNT for referral count (not app-level increment) to prevent race conditions. Must NOT allow duplicate milestone claims.
+  Parallelization: Wave 4 | Blocked by: none | Blocks: 13 (Milestones frontend)
+  References: cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java (bindReferral method — hook point for checkMilestones call), cfc-backend/src/main/java/com/etotem/cfc/entity/CommissionRecord.java (to count referrals: referrerId=userId, level=L1), cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java (awardSystemPoints), cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java (awardEnergy), cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (table creation + seed)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/InviteMilestone.java` exits 0; (2) `grep 'class InviteMilestoneService' cfc-backend/src/main/java/com/etotem/cfc/service/InviteMilestoneService.java` returns match; (3) `grep 'checkMilestones' cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java` returns match (hook integrated); (4) `mvn compile -pl cfc-backend -q` exits 0
+  QA scenarios: happy: user's 3rd referral binds → checkMilestones called → INVITE_3 milestone created → user claims → 200pts+20energy awarded; failure: 2nd referral → no milestone triggered; claim already-claimed milestone → error; race condition (2 simultaneous binds) → DB COUNT=correct number, milestone created exactly once (INSERT IF NOT EXISTS guard). Evidence: .omo/evidence/task-12-mini-program-promotion.txt
+  Commit: Y | feat(invite-milestone): add invite milestone reward system with 3/5/10 tiers
+
+- [ ] 13. Invite Milestones — Frontend: milestone display in promotion flow
+  What to do / Must NOT do: Add milestone progress section to promotion/invite.vue (below stats card). Shows: (1) "邀请里程碑" card with 3 progress bars (3/5/10), each showing currentInviteCount/target, (2) Achieved milestones show "领取" button, (3) Claimed milestones show checkmark. Add API functions: getInviteMilestones, claimInviteMilestone to api.js. Also add a milestone notification when a new milestone is achieved: show a modal/toast "恭喜达成邀请3人里程碑!去领取奖励" with navigation to promotion/invite. Must NOT use optional chaining.
+  Parallelization: Wave 4 | Blocked by: 12 (Milestones API) | Blocks: none
+  References: cfc-frontend/pages/promotion/invite.vue:1-335 (add milestone section after stats card), cfc-frontend/utils/api.js (add API functions), cfc-frontend/pages/promotion/index.vue (add milestone notification entry)
+  Acceptance criteria (agent-executable): (1) `grep 'inviteMilestone\\|invite-milestone' cfc-frontend/pages/promotion/invite.vue` returns match; (2) `grep 'getInviteMilestones\\|claimInviteMilestone' cfc-frontend/utils/api.js` returns match; (3) milestone section has progress indicators for 3, 5, 10 targets
+  QA scenarios: happy: user with 3+ referrals sees INVITE_3 milestone as "已达成" → taps "领取" → gets reward toast; failure: user with 0 referrals → milestones show "0/3" progress. Evidence: .omo/evidence/task-13-mini-program-promotion.txt
+  Commit: Y | feat(invite-milestone): add milestone progress and claim UI to promotion page
+
+- [ ] 14. Referral Leaderboard — Backend: entity + table + service + weekly reset
+  What to do / Must NOT do: Create (1) Entity: `ReferralLeaderboard.java` — id, userId, weekStart(VARCHAR(10): Monday date in YYYY-MM-DD), referralCount(INT default 0), rank(INT nullable). (2) Mapper. (3) Service: `ReferralLeaderboardService.java` — incrementCount(userId): on successful bindReferral, increment this week's referralCount (INSERT ON DUPLICATE KEY UPDATE); getTopUsers(weekStart, limit=20): SELECT top 20 by referralCount DESC for given week with user nickname/avatar; getMyRank(userId, weekStart): returns user's rank and count; getCurrentWeek(): returns YYYY-MM-DD of current Monday. (4) Scheduled task: `LeaderboardWeeklyResetTask.java` — no explicit reset needed (each week is a new row keyed by weekStart). (5) Controller: `ReferralLeaderboardController.java` at /api/leaderboard with: POST /top (top 20), POST /my-rank (current user's rank). Hook: call incrementCount from CommissionService.bindReferral after successful bind. Use UTC+8 timezone for week calculation. Must NOT show all-time leaderboard (weekly only).
+  Parallelization: Wave 4 | Blocked by: none | Blocks: 15 (Leaderboard frontend)
+  References: cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java (bindReferral — hook point for incrementCount), cfc-backend/src/main/java/com/etotem/cfc/task/StreakResetTask.java (scheduled task pattern — though no explicit reset needed), cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (table creation)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-backend/src/main/java/com/etotem/cfc/entity/ReferralLeaderboard.java` exits 0; (2) `grep 'class ReferralLeaderboardService' cfc-backend/src/main/java/com/etotem/cfc/service/ReferralLeaderboardService.java` returns match; (3) `grep 'incrementCount' cfc-backend/src/main/java/com/etotem/cfc/service/CommissionService.java` returns match (hook integrated); (4) `mvn compile -pl cfc-backend -q` exits 0
+  QA scenarios: happy: user A refers 5 users this week → leaderboard shows A at #1 with count=5; user B refers 3 → B at #2; 0-referral user → not in top 20; failure: no referrals this week → top list empty but returns code=0 with empty array. Evidence: .omo/evidence/task-14-mini-program-promotion.txt
+  Commit: Y | feat(leaderboard): add weekly referral leaderboard with top 20 and personal rank
+
+- [ ] 15. Referral Leaderboard — Frontend: leaderboard page
+  What to do / Must NOT do: Create cfc-frontend/pages/promotion/leaderboard.vue. Shows: (1) Title "推广排行榜" with week label ("本周"), (2) Top 3 users with special styling (gold/silver/bronze), (3) Ranked list #4-#20 with avatar, nickname, referral count, (4) My rank card at bottom (sticky) showing current user's rank and count, (5) Empty state "本周还没有推广达人". Add route to pages.json. Add API functions: getLeaderboardTop, getLeaderboardMyRank to api.js. Add entry from promotion/index.vue menu (replace or beside existing items). Must NOT use optional chaining.
+  Parallelization: Wave 4 | Blocked by: 14 (Leaderboard API) | Blocks: none
+  References: cfc-frontend/pages/promotion/index.vue (add menu entry), cfc-frontend/pages.json (route), cfc-frontend/utils/api.js (add API functions)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-frontend/pages/promotion/leaderboard.vue` exits 0; (2) `grep 'promotion/leaderboard' cfc-frontend/pages.json` returns match; (3) `grep 'getLeaderboardTop\\|getLeaderboardMyRank' cfc-frontend/utils/api.js` returns match
+  QA scenarios: happy: user opens leaderboard → sees weekly top 20 → sees own rank at bottom → top 3 have special styling; failure: not in top 20 → "我的排名" shows "未上榜". Evidence: .omo/evidence/task-15-mini-program-promotion.txt
+  Commit: Y | feat(leaderboard): add weekly referral leaderboard page with top-3 styling
+
+- [ ] 16. Admin — Coupon management page in cfc-web
+  What to do / Must NOT do: Create cfc-web/src/views/admin/CouponManagement.vue. Shows: (1) Coupon list table with columns: name, value(元), minSpend(元), applicableTo, validFrom-validUntil, totalCount, usedCount, actions(edit/delete), (2) "新建优惠券" button → dialog form: name, value(cents→yuan input), minSpend, applicableTo(dropdown: ALL/MEMBERSHIP/PRODUCT/ASSESSMENT), validFrom(validUntil date range picker), totalCount, (3) "批量发放" button → select coupon + enter userIds → batch issue. Add route in cfc-web/src/router/index.js under admin children. Add sidebar menu entry in Layout.vue. Add API service functions in cfc-web/src/api/. Must NOT allow editing coupon value after users have claimed it (add validation).
+  Parallelization: Wave 5 | Blocked by: 8 (Coupon API) | Blocks: none
+  References: cfc-web/src/views/admin/ (existing admin pages for pattern), cfc-web/src/router/index.js (route registration), cfc-web/src/views/Layout.vue (sidebar menu), cfc-web/src/api/ (API service pattern)
+  Acceptance criteria (agent-executable): (1) `test -f cfc-web/src/views/admin/CouponManagement.vue` exits 0; (2) `grep 'CouponManagement\\|coupon-management' cfc-web/src/router/index.js` returns match; (3) page has table and create dialog
+  QA scenarios: happy: admin creates coupon → appears in list → edits valid period → saves; issue batch → success; failure: create coupon with zero value → validation error; edit claimed coupon value → blocked. Evidence: .omo/evidence/task-16-mini-program-promotion.txt
+  Commit: Y | feat(admin): add coupon management page with CRUD and batch issue
+
+- [ ] 17. Admin — Growth Task + Onboarding management page in cfc-web
+  What to do / Must NOT do: Create cfc-web/src/views/admin/GrowthTaskManagement.vue. Two tabs: (1) "成长任务" tab — table of growth_task records, edit rewardPoints/rewardEnergy/enabled/sortOrder, create new daily/newbie tasks, (2) "新手任务" tab — table of onboarding task configs (taskType → rewardPoints mapping), edit reward amounts. Add route in router/index.js. Add sidebar entry. Must NOT allow deleting tasks that have user logs (soft-disable by setting enabled=0 instead).
+  Parallelization: Wave 5 | Blocked by: 5, 7 (Onboarding + Growth Task APIs) | Blocks: none
+  References: cfc-web/src/views/admin/ (existing admin pages), cfc-web/src/router/index.js, cfc-web/src/views/Layout.vue
+  Acceptance criteria (agent-executable): (1) `test -f cfc-web/src/views/admin/GrowthTaskManagement.vue` exits 0; (2) `grep 'GrowthTaskManagement\\|growth-task-management' cfc-web/src/router/index.js` returns match; (3) page has two tabs for growth tasks and onboarding configs
+  QA scenarios: happy: admin edits daily task reward points → saves → task updated; disables a task → enabled=0; failure: delete task with existing logs → blocked, must disable instead. Evidence: .omo/evidence/task-17-mini-program-promotion.txt
+  Commit: Y | feat(admin): add growth task and onboarding management page
+
+- [ ] 18. Admin — Invite Milestone + Leaderboard management page in cfc-web
+  What to do / Must NOT do: Create cfc-web/src/views/admin/PromotionManagement.vue. Two tabs: (1) "邀请里程碑" — table of milestone definitions (milestone key, rewardPoints, rewardEnergy), edit reward amounts, (2) "排行榜" — view current week's leaderboard, view historical weeks, search by user. Add route in router/index.js. Add sidebar entry. Must NOT allow editing milestones that have already been achieved by users (only pending milestones editable).
+  Parallelization: Wave 5 | Blocked by: 12, 14 (Milestone + Leaderboard APIs) | Blocks: none
+  References: cfc-web/src/views/admin/ (existing admin pages), cfc-web/src/router/index.js, cfc-web/src/views/Layout.vue
+  Acceptance criteria (agent-executable): (1) `test -f cfc-web/src/views/admin/PromotionManagement.vue` exits 0; (2) `grep 'PromotionManagement\\|promotion-management' cfc-web/src/router/index.js` returns match; (3) page has two tabs for milestones and leaderboard
+  QA scenarios: happy: admin views current leaderboard → sees top 20; edits milestone reward → saves; failure: edit achieved milestone → blocked. Evidence: .omo/evidence/task-18-mini-program-promotion.txt
+  Commit: Y | feat(admin): add invite milestone and leaderboard management page
+
+- [ ] 19. End-to-end integration test — all promotion flows
+  What to do / Must NOT do: Write integration test scripts (bash + curl) that test the complete promotion flow end-to-end: (1) New user registers → onboarding tasks created, REGISTER auto-completed → user claims reward → points+energy credited, (2) User completes profile → COMPLETE_PROFILE auto-completed → claims → reward, (3) User adds child → ADD_CHILD → reward, (4) User checks in → FIRST_CHECKIN + DAILY_CHECKIN → rewards, (5) User gets referral code → shares → new user binds → INVITE milestone checked, leaderboard incremented, (6) User claims coupon → applies at checkout → discount applied, (7) User views team → sees L1 member, (8) User views leaderboard → sees own rank. Test all API endpoints with valid and invalid JWT, with missing parameters, with boundary values (0 amount, negative amount, overly long strings). Must NOT test against production database; use test-mode or separate schema.
+  Parallelization: Wave 5 | Blocked by: all prior todos | Blocks: none
+  References: All prior references; cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java; cfc-backend/src/main/resources/application.yml (test-mode config)
+  Acceptance criteria (agent-executable): (1) test script file exists and is executable; (2) running against a test instance returns 0 exit code; (3) all happy-path tests pass; (4) all failure-path tests return correct error codes
+  QA scenarios: happy: full flow from registration through referral reward passes; failure: any broken integration → test exits non-zero with error message. Evidence: .omo/evidence/task-19-mini-program-promotion.txt
+  Commit: Y | test(promotion): add end-to-end integration test for all promotion flows
+
+## Final verification wave
+> Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete.
+- [ ] F1. Plan compliance audit — verify every todo is DONE (all acceptance criteria pass), no todos skipped
+- [ ] F2. Code quality review — verify no hardcoded secrets, no optional chaining in frontend, all @PostMapping, lint backend with `mvn compile`, lint frontend with `npm run lint:mp-weixin`
+- [ ] F3. Real manual QA — open the mini-program in WeChat devtools, walk through: share card flow, team page, coupon claim→checkout, onboarding rewards, leaderboard, milestone claim. Verify all stubs are replaced.
+- [ ] F4. Scope fidelity — verify all Must NOT have constraints are respected (no push notifications, no trial membership, no percentage coupons, no AOP, no web/ changes)
+
+## Commit strategy
+- Each todo = one atomic commit. 19 commits total.
+- Commit message format: `<type>(<scope>): <summary>`
+- types: `feat` (feature), `fix` (bugfix), `test` (tests), `refactor` (refactoring), `docs` (documentation)
+- scopes: `promotion` (frontend pages), `share` (share card), `commission` (team page), `onboarding` (newbie tasks), `growth-task` (daily tasks), `coupon` (coupons + checkout), `invite-milestone` (milestones), `leaderboard` (referral ranking), `admin` (management pages)
+- Branch: `cfclub` (current branch)
+- After all commits, push once and create a concise summary of changes
+
+## Success criteria
+- All 3 "即将上线" stubs replaced with working features: share card saves poster to album, team page shows L1/L2 members, WeChat share works
+- New user can complete 6 onboarding tasks and claim rewards (points + energy)
+- User can complete 4 daily tasks and claim rewards
+- User can claim fixed-amount coupons and apply them at checkout (packages + membership)
+- User can earn milestone rewards at 3/5/10 invitations
+- Weekly leaderboard shows top 20 referrers with personal rank
+- Admin can manage coupons (CRUD + batch issue), growth tasks (edit rewards), milestones (edit rewards), view leaderboard
+- All new backend endpoints return Result<T> with correct code/message/data
+- All new frontend pages register in pages.json and render without JS error
+- `mvn compile -pl cfc-backend -q` exits 0
+- No optional chaining (?.) in any new .vue file
+- No CSS Grid in any new .vue file
+- All new endpoints use @PostMapping

+ 246 - 0
docs/运营推广设计/README.md

@@ -0,0 +1,246 @@
+# 浠艾福小程序运营推广功能设计和实施计划
+
+> 状态:已批准 | 分支:cfclub | Effort: Large | Risk: Medium
+
+---
+
+## TL;DR
+
+**目标:** 小程序推广功能全面升级——完成3个"即将上线"占位页(分享邀请卡、推广团队、微信转发),新增6个增长功能(新手任务奖励、每日任务、优惠券、邀请里程碑、推广排行榜),外加后台管理页面。
+
+**策略:** AARRR增长框架(拉新→激活→留存→变现→裂变),按优先级分3层:先补齐已有的半成品(P0),再补核心增长缺口(P1),最后加强裂变(P2,利用现有两级佣金体系)。
+
+**不包含:** 推送通知、免费试用会员、拼团/限时抢购、百分比优惠券、三级以上团队树、AOP/事件系统、web/静态站修改。
+
+**决策:** 分享邀请卡用Canvas本地生成、仅固定金额优惠券、邀请里程碑3/5/10人档、每周排行榜重置、直接服务层调用触发而非AOP。
+
+---
+
+## 目录
+
+- [一、现状分析](#一现状分析)
+- [二、设计决策](#二设计决策)
+- [三、功能模块](#三功能模块)
+- [四、实施计划](#四实施计划)
+- [五、成功标准](#五成功标准)
+
+---
+
+## 一、现状分析
+
+### 1.1 已有完整系统(无需新建后端)
+
+| 系统 | 关键文件 | 说明 |
+|------|----------|------|
+| 推荐码 | `InviteController.java` `/api/invite/code` | 生成、绑定、追踪推荐关系 |
+| 两级佣金 | `CommissionService.settleTwoLevel()` | L1=20%, L2=5%, 产品利润分成10% |
+| 提现 | `WithdrawalService` | 最低100元,审核流程 |
+| 积分 | `PointsService` | 余额、赚取、消费、流水 |
+| 签到/连续 | `StreakService` | 3/7/14/30天里程碑奖励 |
+| 家庭邀请QR | `FamilyInviteController` + `QrCodeService` | 令牌式邀请,7天有效期 |
+| 小程序码 | `WechatService.generateWxacode()` | 生成小程序码用于分享 |
+| 能量系统 | `EnergyService.awardEnergy()` | 日上限:身50/心40/智80/行60/富50/全局200 |
+
+### 1.2 现有占位页(需补全)
+
+| 位置 | 当前代码 | 需实现 |
+|------|----------|--------|
+| `promotion/index.vue:131` | `shareCard()` → "即将上线" | Canvas海报保存到相册 |
+| `promotion/invite.vue:143` | `shareInvite()` → "即将上线" | 微信分享给好友 |
+| `promotion/index.vue:142` | `goTeam()` → "即将上线" | L1/L2团队成员列表 |
+
+### 1.3 完全缺失的系统
+
+- 新用户引导奖励体系
+- 每日成长任务
+- 优惠券系统
+- 邀请里程碑奖励(3/5/10人)
+- 推广排行榜
+
+---
+
+## 二、设计决策
+
+### 2.1 关键决策
+
+| 决策项 | 选择方案 | 理由 | 是否可逆 |
+|--------|----------|------|----------|
+| 分享邀请卡 | Canvas本地生成,保存相册 | uni-app Canvas稳定;服务端图片需额外基础设施 | 是,后续可加 |
+| 团队页深度 | 仅L1/L2成员列表 | 两级佣金模型已够用,树形可视化边际价值低 | 是,后续可加 |
+| 邀请里程碑 | 3/5/10人三档(200/500/1000积分+能量) | 经典三档模型;奖励约2-10元,有激励性且不滥用 | 是,数据库可配置 |
+| 排行榜范围 | 每周重置,显示前20名 | 每周防止固化;20名足够激励 | 是,后续可加月度/总榜 |
+| 每日任务类型 | 签到/完成任务/分享/AI对话 | 覆盖已有功能,驱动日活 | 是,数据库可配置 |
+| 优惠券类型 | 仅固定金额(无百分比、无试用) | 最简实现,无复杂边界情况 | 是,后续可加 |
+| 任务触发方式 | 直接服务层调用,非AOP/事件 | 与现有代码风格一致,简单可调试 | 是,后续可改为事件驱动 |
+| 新手任务追溯 | 仅统计上线后行为,不追溯历史 | 避免复杂的数据迁移和边界情况 | 是 |
+
+### 2.2 佣金费率配置(来自sys_config)
+
+| 费率项 | 值 |
+|--------|---|
+| L1会员佣金 | 20% (2000 bps) |
+| L2会员佣金 | 5% (500 bps) |
+| 产品利润分成 | 10% (1000 bps) |
+| 服务佣金 | 10% (1000 bps) |
+
+---
+
+## 三、功能模块
+
+### 3.1 P0 — 补全占位页(最高ROI)
+
+#### C1: 分享邀请卡
+- Canvas海报:用户头像+昵称+邀请码+小程序二维码+品牌元素
+- 保存到相册:`uni.canvasToTempFilePath` → `uni.saveImageToPhotosAlbum`
+- 处理相册权限拒绝:`uni.openSetting` 引导
+- 替换 `promotion/index.vue` 和 `invite.vue` 中的"即将上线"占位
+
+#### C2: 推广团队页
+- 新后端接口:`POST /api/commission/team/list`(分页,L1/L2筛选)
+- 新前端页:`promotion/team.vue`
+- Tab切换:直推(L1) / 间推(L2)
+- 替换 `promotion/index.vue` 的 `goTeam()` 占位
+
+#### C3: 微信转发
+- 3个页面接入 `onShareAppMessage`:
+  - `promotion/index`
+  - `promotion/invite`
+  - `parent/invite/index`
+- 分享路径:`/pages/invite/join?inviteCode=推荐码`
+- 封面图:分享海报图片(待C1完成后可用)
+
+### 3.2 P1 — 激活+变现
+
+#### C4: 新用户引导奖励
+- 6个新手指引任务:REGISTER / COMPLETE_PROFILE / ADD_CHILD / FIRST_CHECKIN / FIRST_TASK / BIND_REFERRAL
+- 每个任务奖励积分+能量(REGISTER=100分+10能量,其余递减)
+- 用户注册时自动初始化6个任务
+- 各现有服务触发自动完成(UserService注册→REGISTER、HealthCheckinService签到→FIRST_CHECKIN等)
+- 前端展示进度和领取按钮
+
+#### C5: 每日成长任务
+- 4个每日任务:DAILY_CHECKIN / DAILY_TASK / DAILY_SHARE / DAILY_AI
+- 3个新手指引任务(复用C4):COMPLETE_PROFILE / ADD_CHILD / BIND_REFERRAL
+- 每日任务按日重置(新的一天=新的一行日志)
+- 前端展示任务列表和进度,领取奖励
+
+#### C6: 优惠券系统
+- 后端实体:`Coupon`(优惠券定义)+ `UserCoupon`(用户持有)
+- 固定金额、无门槛限制、适用范围(ALL/MEMBERSHIP/PRODUCT/ASSESSMENT)
+- 用户领取 → 订单结账时使用
+- 管理端:创建/编辑/删除/批量发放
+- 集成到 `PackagePaymentController` 和 `MembershipService` 支付流程
+
+### 3.3 P2 — 裂变放大器
+
+#### C7: 邀请里程碑奖励
+- 3个档位:邀请3人→200积分+20能量 / 邀请5人→500积分+50能量 / 邀请10人→1000积分+100能量
+- 在 `bindReferral` 成功时检查:DB级别COUNT,防止并发问题
+- 前端在推广邀请页显示3个里程碑进度条
+
+#### C8: 推广排行榜
+- 每周前20名,按本周邀请人数排序
+- 个人排名展示(不在前20也显示"未上榜")
+- 每周一重置(用周一开始计算,UTC+8)
+- 新增 `ReferralLeaderboard` 实体和服务
+
+---
+
+## 四、实施计划
+
+### 4.1 工作波次
+
+| 波次 | 内容 | 包含任务 |
+|------|------|----------|
+| Wave 1 (P0) | 补全占位页 | C1分享卡、C2团队API、C3微信转发、C4团队前端 |
+| Wave 2 (P1) | 新手引导 | C5后端、C6前端 |
+| Wave 3 (P1) | 成长+变现 | C7每日任务、C8优惠券后端、C9每日任务前端、C10优惠券前端、C11优惠券集成 |
+| Wave 4 (P2) | 裂变放大 | C12邀请里程碑后端、C13邀请里程碑前端、C14排行榜后端、C15排行榜前端 |
+| Wave 5 | 管理+集成 | C16管理端优惠券、C17管理端任务、C18管理端里程碑+排行榜、C19端到端测试 |
+
+### 4.2 新建数据库表(通过 DatabaseInitializer)
+
+| 表名 | 用途 |
+|------|------|
+| `onboarding_task` | 新手任务进度和状态 |
+| `growth_task` | 每日/新手指引任务定义 |
+| `growth_task_log` | 用户每日任务完成日志 |
+| `coupon` | 优惠券定义 |
+| `user_coupon` | 用户持有优惠券 |
+| `invite_milestone` | 邀请里程碑达成记录 |
+| `referral_leaderboard` | 每周排行榜数据 |
+
+### 4.3 新建Java类
+
+| 类 | 所属波次 |
+|----|----------|
+| `OnboardingTask`, `OnboardingTaskMapper`, `OnboardingService`, `OnboardingController` | Wave 2 |
+| `GrowthTask`, `GrowthTaskLog`, `GrowthTaskMapper`, `GrowthTaskService`, `GrowthTaskController`, `GrowthTaskDailyResetTask` | Wave 3 |
+| `Coupon`, `UserCoupon`, `CouponMapper`, `CouponService`, `CouponController`, `AdminCouponController` | Wave 3 |
+| `InviteMilestone`, `InviteMilestoneMapper`, `InviteMilestoneService`, `InviteMilestoneController` | Wave 4 |
+| `ReferralLeaderboard`, `ReferralLeaderboardMapper`, `ReferralLeaderboardService`, `ReferralLeaderboardController` | Wave 4 |
+
+### 4.4 新建前端页面
+
+| 页面路径 | 用途 | 波次 |
+|----------|------|------|
+| `pages/promotion/team.vue` | 团队成员列表 | Wave 1 |
+| `pages/profile/onboarding.vue` | 新手任务进度 | Wave 2 |
+| `pages/tasks/daily-tasks.vue` | 每日+新手任务 | Wave 3 |
+| `pages/profile/coupons.vue` | 我的优惠券 | Wave 3 |
+| `pages/promotion/leaderboard.vue` | 推广排行榜 | Wave 4 |
+
+### 4.5 新建管理端页面(cfc-web)
+
+| 页面 | 用途 |
+|------|------|
+| `views/admin/CouponManagement.vue` | 优惠券CRUD+批量发放 |
+| `views/admin/GrowthTaskManagement.vue` | 成长任务+新手任务配置 |
+| `views/admin/PromotionManagement.vue` | 里程碑+排行榜管理 |
+
+---
+
+## 五、成功标准
+
+- 所有3个"即将上线"占位页替换为可工作功能
+- 新用户可完成6个新手任务并领取奖励
+- 用户可完成4个每日任务并领取奖励
+- 用户可领取固定金额优惠券并在结账时使用
+- 用户可在邀请3/5/10人时获得里程碑奖励
+- 每周排行榜显示前20名和自己的排名
+- 管理端可管理所有新功能配置
+- `mvn compile -pl cfc-backend -q` 退出码0
+- 前端无可选链(?.)、无CSS Grid
+
+---
+
+## 附:现有代码库关键引用
+
+```
+cfc-backend/src/main/java/com/etotem/cfc/
+├── controller/
+│   ├── InviteController.java          # 推荐码API
+│   ├── CommissionController.java       # 佣金API
+│   ├── FamilyInviteController.java    # 家庭邀请
+│   └── reward/RewardController.java    # 奖励系统
+├── service/
+│   ├── CommissionService.java          # 两级佣金结算
+│   ├── PointsService.java              # 积分系统
+│   ├── EnergyService.java               # 能量系统(含日上限)
+│   ├── StreakService.java               # 签到/连续奖励
+│   └── WechatService.java              # 小程序码生成
+├── config/
+│   └── DatabaseInitializer.java       # 数据库初始化和种子数据
+└── entity/
+    └── CommissionRecord.java            # 佣金记录(referrerId/buyerId/level)
+
+cfc-frontend/
+├── pages/promotion/index.vue           # 推广中心(含3个stub)
+├── pages/promotion/invite.vue         # 邀请好友(含stub)
+├── pages/parent/invite/index.vue       # 家长邀请(含微信转发)
+└── utils/api.js                         # API函数定义
+```
+
+---
+
+*文档生成时间:2026-06-26 | 状态:已批准待实施*