Prechádzať zdrojové kódy

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

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
#	cfc-web/.last_build_commit
#	cfc-web/package.json
#	cfc-web/public/CHANGELOG-v1.0.md
#	cfc-web/public/CHANGELOG.md
Xiaogang Liao 14 hodín pred
rodič
commit
2b9c0d10f6
55 zmenil súbory, kde vykonal 5387 pridanie a 142 odobranie
  1. 68 1
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java
  3. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIBehaviorController.java
  4. 224 27
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java
  5. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RecommendationRuleAdminController.java
  6. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
  7. 12 7
      cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java
  8. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java
  9. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/RecommendationRule.java
  10. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/RecommendationRuleMapper.java
  11. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  12. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  13. 3 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  14. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java
  15. 104 0
      cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationRuleService.java
  16. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java
  17. 45 0
      cfc-backend/src/main/resources/schema.sql
  18. 48 3
      cfc-frontend/components/OctopusDiagram.vue
  19. 7 1
      cfc-frontend/pages.json
  20. 148 13
      cfc-frontend/pages/action-detail/index.vue
  21. 12 3
      cfc-frontend/pages/action-detail/octopus-add-effect.vue
  22. 224 0
      cfc-frontend/pages/action-detail/octopus-effects-list.vue
  23. 12 10
      cfc-frontend/pages/article-center/article-detail.vue
  24. 22 28
      cfc-frontend/pages/health/gut-flora-detail.vue
  25. 9 3
      cfc-frontend/pages/health/health-plan-summary.vue
  26. 3 3
      cfc-frontend/pages/index-home/index.vue
  27. 70 2
      cfc-frontend/pages/profile/portrait-edit.vue
  28. 14 6
      cfc-frontend/pages/tasks/create-task.vue
  29. 6 0
      cfc-frontend/utils/api.js
  30. 24 0
      cfc-langgraph/app/api/article_quiz.py
  31. 56 0
      cfc-langgraph/app/api/portrait.py
  32. 46 22
      cfc-langgraph/app/checkpointer.py
  33. 58 0
      cfc-langgraph/app/graphs/article_quiz_graph.py
  34. 141 0
      cfc-langgraph/app/graphs/portrait_generation_graph.py
  35. 8 1
      cfc-langgraph/app/main.py
  36. 13 0
      cfc-langgraph/app/models/article_quiz.py
  37. 13 0
      cfc-langgraph/app/prompt_service.py
  38. 1 0
      cfc-langgraph/requirements.txt
  39. 2 0
      cfc-langgraph/requirements_frozen.txt
  40. 1 1
      cfc-web/.last_build_commit
  41. 2 2
      cfc-web/package-lock.json
  42. 1 1
      cfc-web/package.json
  43. 109 1
      cfc-web/public/CHANGELOG-v1.0.md
  44. 110 2
      cfc-web/public/CHANGELOG.md
  45. 4 2
      docs/superpowers/PROJECT-OVERVIEW.md
  46. 1015 0
      docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md
  47. 162 0
      docs/superpowers/plans/2026-09-17-product-recommendation-rule-engine-plan.md
  48. 973 0
      docs/superpowers/plans/2026-09-19-article-reading-quiz.md
  49. 321 0
      docs/superpowers/specs/2026-09-17-product-recommendation-rule-engine-design.md
  50. 180 0
      docs/superpowers/specs/2026-09-19-article-reading-quiz-design.md
  51. 142 0
      运营文案/个人号A种草内容-第一批.md
  52. 257 0
      运营文案/便秘腹泻账号运营方案.md
  53. 142 0
      运营文案/便秘腹泻赛道竞品分析.md
  54. 157 0
      运营文案/小红书运营方略/知识图谱总结.md
  55. 85 0
      运营文案/竞品搜索_raw.txt

+ 68 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11105,6 +11105,32 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                 "VARCHAR(50) DEFAULT NULL COMMENT '最低可见会员级别编码(FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME, NULL=全部可见)'");
         log.info("迁移321: 已完成商品/分类按会员级别可见性改造");
 
+        // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "article_id BIGINT DEFAULT NULL, " +
+                    "user_id BIGINT DEFAULT NULL, " +
+                    "member_id BIGINT DEFAULT NULL, " +
+                    "questions TEXT, " +
+                    "answers TEXT, " +
+                    "score INT DEFAULT 0, " +
+                    "total_questions INT DEFAULT 0, " +
+                    "cf_earned INT DEFAULT 0, " +
+                    "energy_earned INT DEFAULT 0, " +
+                    "created_at DATETIME, " +
+                    "updated_at DATETIME, " +
+                    "INDEX idx_member (member_id), " +
+                    "INDEX idx_article (article_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'");
+            log.info("已创建article_quiz_records表");
+        } catch (Exception e) {
+            log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移300: profile_snapshot 增加 quiz_summary 列(答题认知雷达汇总)
+        ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'");
+
         // 迁移323-325: 原误置于 seedGutFloraFingerprints 早返回(return)之后,指纹数据存在时永不执行;
         // 现移至 runSystemMigrations 无条件执行(octopus_effect_record.happened_at / article_reading_records 回填与补列)
         // 迁移323: octopus_effect_record 添加 happened_at 列(成效发生时间)
@@ -11209,6 +11235,47 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移312: 写入 gut_flora 指纹规则失败: {}", e.getMessage());
         }
 
-        // 迁移323-325(octopus happened_at / article 回填与补列)已移至 runSystemMigrations() 无条件执行
+        // 迁移316: 创建 recommendation_rules 表(统一推荐规则配置)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_rules (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "rule_type TINYINT NOT NULL COMMENT '1-5', " +
+                    "rule_name VARCHAR(100) COMMENT '运营名称', " +
+                    "trigger_product_id BIGINT COMMENT '触发商品ID', " +
+                    "trigger_product_ids VARCHAR(1000) COMMENT '触发商品ID JSON 数组', " +
+                    "target_product_id BIGINT COMMENT '目标商品ID', " +
+                    "priority INT DEFAULT 10 COMMENT '优先级,越大越优', " +
+                    "enabled TINYINT DEFAULT 1 COMMENT '启用 1/0', " +
+                    "time_window_days INT COMMENT '回溯窗口(天)', " +
+                    "delay_days INT COMMENT '复购延迟天数', " +
+                    "scope VARCHAR(20) COMMENT 'family/individual', " +
+                    "scene_codes VARCHAR(500) COMMENT '生效场景 JSON 数组', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐规则配置'");
+            log.info("迁移316: 已创建recommendation_rules表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
+        // 迁移317: recommendation_rules 表索引
+        try {
+            jdbcTemplate.execute("CREATE INDEX idx_rules_enabled ON recommendation_rules(enabled)");
+            log.info("迁移317: 已创建idx_rules_enabled索引");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
+        try {
+            jdbcTemplate.execute("CREATE INDEX idx_rules_scene ON recommendation_rules(scene_codes)");
+            log.info("迁移317: 已创建idx_rules_scene索引");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
+        try {
+            jdbcTemplate.execute("CREATE INDEX idx_rules_priority ON recommendation_rules(priority)");
+            log.info("迁移317: 已创建idx_rules_priority索引");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
     }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/InviteController.java

@@ -64,6 +64,9 @@ public class InviteController {
 
     @PostMapping("/qrcode")
     public Result<Map<String, Object>> getQrCode(@RequestAttribute("userId") Long userId) {
+        if (userId == null || userId <= 0) {
+            return Result.error("请先登录");
+        }
         try {
             String referralCode = commissionService.getOrCreateReferralCode(userId);
             String qrBase64 = wechatService.generateWxacode("ref=" + referralCode, "pages/invite/join");

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIBehaviorController.java

@@ -0,0 +1,26 @@
+package com.etotem.cfc.controller.ai;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.FamilyContextService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/ai/behavior")
+public class AIBehaviorController {
+    @Resource
+    private FamilyContextService familyContextService;
+
+    @PostMapping("/summary")
+    public Result<Map<String, Object>> getBehaviorSummary(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("member_id") != null
+                ? Long.valueOf(params.get("member_id").toString()) : null;
+        Map<String, Object> context = familyContextService.buildContext(userId);
+        if (memberId != null) context.put("memberId", memberId);
+        return Result.success(context);
+    }
+}

+ 224 - 27
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java

@@ -3,16 +3,28 @@ package com.etotem.cfc.controller.content;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.ArticleComment;
 import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleQuizRecord;
+import com.etotem.cfc.entity.ProfileSnapshot;
 import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
+import com.etotem.cfc.mapper.ProfileSnapshotMapper;
 import com.etotem.cfc.service.ArticleCommentService;
-import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.PointsService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.util.*;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "文章评论", description = "文章评论与AI答题")
@@ -20,14 +32,22 @@ import com.etotem.cfc.util.ParamUtils;
 @RequestMapping("/api/articles")
 public class ArticleCommentController {
 
+    private static final Logger log = LoggerFactory.getLogger(ArticleCommentController.class);
+
     @Resource
     private ArticleCommentService articleCommentService;
     @Resource
     private ArticleMapper articleMapper;
     @Resource
-    private AIService aiService;
-    @Resource
     private EnergyService energyService;
+    @Resource
+    private AiGateway aiGateway;
+    @Resource
+    private PointsService pointsService;
+    @Resource
+    private ArticleQuizRecordMapper articleQuizRecordMapper;
+    @Resource
+    private ProfileSnapshotMapper profileSnapshotMapper;
 
     @PostMapping("/comments/list")
     public Result<List<Map<String, Object>>> listComments(@RequestBody Map<String, Object> params) {
@@ -83,39 +103,216 @@ public class ArticleCommentController {
     }
 
     @PostMapping("/quiz/generate")
-    public Result<List<Map<String, Object>>> generateQuiz(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> generateQuiz(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("userId") Long userId,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long articleId = ParamUtils.getLong(params.get("articleId"));
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         Article article = articleMapper.selectById(articleId);
         if (article == null) return Result.error("文章不存在");
-        String c = article.getContent();
-        if (c != null && c.length() > 2000) c = c.substring(0, 2000);
+
+        String content = article.getContent();
+        if (content != null && content.length() > 2000) content = content.substring(0, 2000);
+
+        // 组装用户画像摘要(从 profile_snapshot 读五维评分)
+        String profileSummary = "";
+        if (memberId != null) {
+            ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
+            if (snapshot != null && snapshot.getDimensionScores() != null) {
+                profileSummary = snapshot.getDimensionScores();
+            }
+        }
+
+        // 优先走 AiGateway + LangGraph,失败降级硬编码
+        List<Map<String, Object>> questions = null;
         try {
-            Map<String, Object> inputs = new HashMap<>();
-            inputs.put("article_title", article.getTitle());
-            inputs.put("article_content", c);
-            Map<String, Object> dr = aiService.sendWorkflow("article-quiz", inputs);
-            List<Map<String, Object>> qs = (List<Map<String, Object>>) dr.getOrDefault("questions", new ArrayList<>());
-            if (!qs.isEmpty()) return Result.success(qs);
-        } catch (Exception e) {}
-        List<Map<String, Object>> fb = new ArrayList<>();
-        fb.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知","B. 健康生活","C. 亲子关系","D. 财富管理"), "A"));
-        fb.add(q("文章提到了哪些维度?", Arrays.asList("A. 身","B. 心","C. 智","D. 以上都有"), "D"));
-        fb.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识","B. 获得了启发","C. 需要再想想","D. 很有共鸣"), "A"));
-        return Result.success(fb);
+            questions = aiGateway.generateArticleQuiz(article.getTitle(), content, profileSummary);
+        } catch (Exception e) {
+            log.warn("AI 出题异常: {}", e.getMessage());
+        }
+
+        if (questions == null || questions.isEmpty()) {
+            questions = new ArrayList<>();
+            questions.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知", "B. 健康生活", "C. 亲子关系", "D. 财富管理"), "A", "wisdom"));
+            questions.add(q("文章提到了哪些维度?", Arrays.asList("A. 身", "B. 心", "C. 智", "D. 以上都有"), "D", "wisdom"));
+            questions.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识", "B. 获得了启发", "C. 需要再想想", "D. 很有共鸣"), "A", "wisdom"));
+        }
+
+        // 落库题目快照,返回 recordId(判分以库内答案为准,防篡改)
+        ArticleQuizRecord record = new ArticleQuizRecord();
+        record.setArticleId(articleId);
+        record.setUserId(userId);
+        record.setMemberId(memberId);
+        record.setQuestions(JSON.toJSONString(questions));
+        record.setAnswers(null);
+        record.setScore(0);
+        record.setTotalQuestions(questions.size());
+        record.setCfEarned(0);
+        record.setEnergyEarned(0);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.insert(record);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("recordId", record.getId());
+        result.put("questions", questions);
+        return Result.success(result);
     }
-    private Map<String, Object> q(String q, List<String> opts, String a) {
-        Map<String, Object> m = new HashMap<>(); m.put("question", q); m.put("options", opts); m.put("answer", a); return m;
+
+    private Map<String, Object> q(String q, List<String> opts, String a, String dimension) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("question", q);
+        m.put("options", opts);
+        m.put("answer", a);
+        m.put("dimension", dimension);
+        return m;
     }
 
     @PostMapping("/quiz/submit")
-    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId,
+    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
+                                                  @RequestAttribute("userId") Long userId,
                                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
-        int correct = params.get("correctCount") != null ? Integer.parseInt(params.get("correctCount").toString()) : 0;
-        int earned = correct * 5;
+        Long recordId = ParamUtils.getLong(params.get("recordId"));
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
-        if (memberId != null && earned > 0) try { energyService.awardEnergy(memberId, "article_quiz", 0L, earned, "答题奖励", null); } catch (Exception e) {}
-        Map<String, Object> r = new HashMap<>();
-        r.put("correctCount", correct); r.put("totalQuestions", 3); r.put("earnedEnergy", earned);
-        return Result.success(r);
+
+        ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
+        if (record == null) return Result.error("答题记录不存在");
+        if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
+            return Result.error("已作答,不可重复提交");
+        }
+
+        int score = 0;
+        int totalQuestions = 0;
+        List<String> answerList = new ArrayList<>();
+        Map<String, int[]> dimStats = new HashMap<>(); // dimension -> [correct, total]
+        try {
+            JSONArray qArr = JSON.parseArray(record.getQuestions());
+            JSONArray aArr = parseAnswers(params.get("answers"));
+            totalQuestions = qArr != null ? qArr.size() : 0;
+            if (aArr != null) {
+                for (int i = 0; i < aArr.size(); i++) {
+                    answerList.add(aArr.getString(i));
+                }
+            }
+            for (int i = 0; i < totalQuestions; i++) {
+                JSONObject q = qArr.getJSONObject(i);
+                String correct = q.getString("answer");
+                String dim = q.getString("dimension");
+                if (dim == null || dim.isEmpty()) dim = "wisdom";
+                String selected = i < answerList.size() ? answerList.get(i) : "";
+                boolean isCorrect = correct != null && selected != null && correct.equalsIgnoreCase(selected.trim());
+                int[] stat = dimStats.computeIfAbsent(dim, k -> new int[2]);
+                stat[1]++;
+                if (isCorrect) { stat[0]++; score++; }
+            }
+        } catch (Exception e) {
+            log.warn("解析答题结果失败: {}", e.getMessage());
+        }
+
+        int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
+        int energyEarned = score * 5;
+
+        record.setAnswers(JSON.toJSONString(answerList));
+        record.setScore(score);
+        record.setTotalQuestions(totalQuestions);
+        record.setCfEarned(cfEarned);
+        record.setEnergyEarned(energyEarned);
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.updateById(record);
+
+        if (memberId != null) {
+            if (cfEarned > 0) {
+                try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
+                catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
+            }
+            if (energyEarned > 0) {
+                try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
+                catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
+            }
+        }
+
+        updateQuizSummary(memberId, score, totalQuestions, dimStats);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("correctCount", score);
+        result.put("totalQuestions", totalQuestions);
+        result.put("cfEarned", cfEarned);
+        result.put("energyEarned", energyEarned);
+        return Result.success(result);
+    }
+
+    /**
+     * 兼容前端两种传参:answers 为 JSON 数组字符串("[\"A\",\"B\"]")或直接为数组(["A","B"])。
+     * 后端 @RequestBody 反序列化时数组会变成 List,直接 String.valueOf(list) 得到 "[A, B]" 无法被 fastjson 解析,
+     * 故必须区分处理。
+     */
+    private JSONArray parseAnswers(Object answers) {
+        if (answers == null) return null;
+        if (answers instanceof String) {
+            String s = ((String) answers).trim();
+            return s.isEmpty() ? null : JSON.parseArray(s);
+        }
+        if (answers instanceof List) {
+            return JSON.parseArray(JSON.toJSONString(answers));
+        }
+        if (answers instanceof JSONArray) {
+            return (JSONArray) answers;
+        }
+        return null;
+    }
+
+    private void updateQuizSummary(Long memberId, int score, int totalQuestions, Map<String, int[]> dimStats) {
+        if (memberId == null) return;
+        try {
+            ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
+
+            JSONObject summary = new JSONObject();
+            if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
+                summary = JSON.parseObject(snapshot.getQuizSummary());
+            }
+
+            int prevCorrect = summary.getIntValue("correct_count");
+            int prevTotal = summary.getIntValue("total_count");
+            int newCorrect = prevCorrect + score;
+            int newTotal = prevTotal + totalQuestions;
+            summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
+            summary.put("correct_count", newCorrect);
+            summary.put("total_count", newTotal);
+            summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
+            summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+
+            JSONObject byDim = summary.getJSONObject("by_dimension");
+            if (byDim == null) byDim = new JSONObject();
+            for (Map.Entry<String, int[]> entry : dimStats.entrySet()) {
+                String dim = entry.getKey();
+                int correct = entry.getValue()[0];
+                int total = entry.getValue()[1];
+                JSONObject dimStat = byDim.getJSONObject(dim);
+                if (dimStat == null) dimStat = new JSONObject();
+                int dimCorrect = dimStat.getIntValue("correct_count") + correct;
+                int dimTotal = dimStat.getIntValue("total_count") + total;
+                dimStat.put("correct_count", dimCorrect);
+                dimStat.put("total_count", dimTotal);
+                dimStat.put("correct_rate", dimTotal > 0 ? (double) dimCorrect / dimTotal : 0.0);
+                byDim.put(dim, dimStat);
+            }
+            summary.put("by_dimension", byDim);
+
+            if (snapshot == null) {
+                snapshot = new ProfileSnapshot();
+                snapshot.setMemberId(memberId);
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.insert(snapshot);
+            } else {
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.updateById(snapshot);
+            }
+        } catch (Exception e) {
+            log.warn("更新认知雷达失败: {}", e.getMessage());
+        }
     }
 }

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RecommendationRuleAdminController.java

@@ -0,0 +1,78 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.RecommendationRule;
+import com.etotem.cfc.service.RecommendationRuleService;
+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;
+import java.util.stream.Collectors;
+
+@Tag(name = "商品推荐规则管理", description = "商品推荐规则配置管理接口")
+@RestController
+@RequestMapping("/api/admin/recommendation-rules")
+public class RecommendationRuleAdminController {
+
+    @Resource
+    private RecommendationRuleService recommendationRuleService;
+
+    @Operation(summary = "获取推荐规则列表")
+    @PostMapping("/list")
+    public Result<List<RecommendationRule>> list(@RequestBody Map<String, Object> params) {
+        String sceneCode = (String) params.get("sceneCode");
+        Integer ruleType = (Integer) params.get("ruleType");
+        List<RecommendationRule> list = recommendationRuleService.loadRulesByScene(sceneCode);
+        if (ruleType != null) {
+            list = list.stream()
+                    .filter(r -> r.getRuleType().equals(ruleType))
+                    .collect(Collectors.toList());
+        }
+        return Result.success(list);
+    }
+
+    @Operation(summary = "创建推荐规则")
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody RecommendationRule rule) {
+        boolean saved = recommendationRuleService.save(rule);
+        return Result.success(saved);
+    }
+
+    @Operation(summary = "更新推荐规则")
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody RecommendationRule rule) {
+        boolean updated = recommendationRuleService.updateById(rule);
+        return Result.success(updated);
+    }
+
+    @Operation(summary = "删除/停用推荐规则")
+    @PostMapping("/delete/{id}")
+    public Result<Boolean> delete(@PathVariable Long id) {
+        RecommendationRule rule = recommendationRuleService.getById(id);
+        if (rule != null) {
+            rule.setEnabled(0);
+            recommendationRuleService.updateById(rule);
+        }
+        return Result.success(true);
+    }
+
+    @Operation(summary = "批量启用/停用")
+    @PostMapping("/batch-enable")
+    public Result<Boolean> batchEnable(@RequestBody Map<String, Object> params) {
+        List<Long> ids = (List<Long>) params.get("ids");
+        Integer enabled = (Integer) params.get("enabled");
+        if (ids != null && !ids.isEmpty()) {
+            for (Long id : ids) {
+                RecommendationRule rule = recommendationRuleService.getById(id);
+                if (rule != null) {
+                    rule.setEnabled(enabled);
+                    recommendationRuleService.updateById(rule);
+                }
+            }
+        }
+        return Result.success(true);
+    }
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java

@@ -37,4 +37,29 @@ public class PortraitController {
         portraitService.updatePortraitPrompt(userId, portraitPrompt);
         return Result.success("保存成功");
     }
+
+    @PostMapping("/generate")
+    public Result<Map<String, Object>> generatePortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String portrait = portraitService.generatePortrait(userId, memberId);
+        // getPortrait 提供渲染快照(renderedSnapshot),portrait 覆盖为本次生成结果
+        Map<String, Object> result = portraitService.getPortrait(userId, null, memberId);
+        result.put("portraitPrompt", portrait);
+        return Result.success(result);
+    }
+
+    @PostMapping("/regenerate")
+    public Result<Map<String, Object>> regeneratePortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        String portrait = portraitService.regeneratePortrait(userId, memberId);
+        Map<String, Object> result = portraitService.getPortrait(userId, null, memberId);
+        result.put("portraitPrompt", portrait);
+        return Result.success(result);
+    }
 }

+ 12 - 7
cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java

@@ -11,31 +11,36 @@ public class ArticleQuizRecord {
     private Long id;
     private Long articleId;
     private Long userId;
-    private Long childId;
+    private Long memberId;
     private String questions;
     private String answers;
     private Integer score;
-    private Integer pointsEarned;
+    private Integer totalQuestions;
+    private Integer cfEarned;
+    private Integer energyEarned;
     private Date createdAt;
     private Date updatedAt;
 
-    // getters and setters for ALL fields
     public Long getId() { return id; }
     public void setId(Long id) { this.id = id; }
     public Long getArticleId() { return articleId; }
     public void setArticleId(Long articleId) { this.articleId = articleId; }
     public Long getUserId() { return userId; }
     public void setUserId(Long userId) { this.userId = userId; }
-    public Long getChildId() { return childId; }
-    public void setChildId(Long childId) { this.childId = childId; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
     public String getQuestions() { return questions; }
     public void setQuestions(String questions) { this.questions = questions; }
     public String getAnswers() { return answers; }
     public void setAnswers(String answers) { this.answers = answers; }
     public Integer getScore() { return score; }
     public void setScore(Integer score) { this.score = score; }
-    public Integer getPointsEarned() { return pointsEarned; }
-    public void setPointsEarned(Integer pointsEarned) { this.pointsEarned = pointsEarned; }
+    public Integer getTotalQuestions() { return totalQuestions; }
+    public void setTotalQuestions(Integer totalQuestions) { this.totalQuestions = totalQuestions; }
+    public Integer getCfEarned() { return cfEarned; }
+    public void setCfEarned(Integer cfEarned) { this.cfEarned = cfEarned; }
+    public Integer getEnergyEarned() { return energyEarned; }
+    public void setEnergyEarned(Integer energyEarned) { this.energyEarned = energyEarned; }
     public Date getCreatedAt() { return createdAt; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public Date getUpdatedAt() { return updatedAt; }

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

@@ -21,6 +21,7 @@ public class ProfileSnapshot implements Serializable {
     private String actionMetrics;
     private String wealthMetrics;
     private String problemDomains;
+    private String quizSummary;
     private Date computedAt;
     private Date updatedAt;
 }

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

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("recommendation_rules")
+public class RecommendationRule implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 规则类型:1-已购不推,2-定时复购,3-购买A推B,4-购买A不推B,5-系列重点推 */
+    private Integer ruleType;
+
+    /** 规则名称(运营可读) */
+    private String ruleName;
+
+    /** 触发商品ID(规则1/2/3/4 单商品) */
+    private Long triggerProductId;
+
+    /** 触发商品ID数组 JSON(规则5 系列,如 "[1,2,3]") */
+    private String triggerProductIds;
+
+    /** 目标商品ID(被推荐/排除/重点推) */
+    private Long targetProductId;
+
+    /** 优先级,越大越优 */
+    private Integer priority;
+
+    /** 启用 1/0 */
+    private Integer enabled;
+
+    /** 购买回溯窗口(天) */
+    private Integer timeWindowDays;
+
+    /** 复购延迟天数(规则2) */
+    private Integer delayDays;
+
+    /** 范围:family/individual */
+    private String scope;
+
+    /** 生效场景 JSON 数组 */
+    private String sceneCodes;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

@@ -0,0 +1,22 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.RecommendationRule;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+
+@Mapper
+public interface RecommendationRuleMapper extends BaseMapper<RecommendationRule> {
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND FIND_IN_SET(?, scene_codes) LIMIT 1")
+    RecommendationRule selectByScene(String sceneCode);
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND rule_type = ? ORDER BY priority DESC LIMIT 1")
+    RecommendationRule selectByRuleType(Integer ruleType);
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND rule_type = ? AND trigger_product_id = ?")
+    RecommendationRule selectByTriggerProductId(Integer ruleType, Long triggerProductId);
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND rule_type = ? AND FIND_IN_SET(?, trigger_product_ids)")
+    RecommendationRule selectByTriggerProductIds(Integer ruleType, String triggerProductIdsJson);
+}

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

@@ -63,6 +63,9 @@ public class AIService {
     @Resource
     private AiUserFactMapper aiUserFactMapper;
 
+    @Resource
+    private FamilyContextService familyContextService;
+
     @Value("${dify.callback-secret:}")
     private String callbackSecret;
 
@@ -583,4 +586,18 @@ public class AIService {
             log.warn("保存关键事实失败: {}", e.getMessage());
         }
     }
+
+    /**
+     * 生成用户画像(通过AiGateway调用LangGraph)
+     */
+    public String generateUserPortrait(Long userId, Long memberId, String previousPortrait) {
+        try {
+            Map<String, Object> context = familyContextService.buildContext(userId);
+            if (memberId != null) context.put("memberId", memberId);
+            return aiGateway.generatePortrait(userId, memberId, previousPortrait, context);
+        } catch (Exception e) {
+            log.error("AI 生成画像失败: userId={}, memberId={}", userId, memberId, e);
+            return null;
+        }
+    }
 }

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

@@ -385,6 +385,36 @@ public class AiGateway {
         return headers;
     }
 
+    /**
+     * 生成用户画像文本(调用 LangGraph /api/portrait/generate)
+     * @return 画像文本;失败返回 null(调用方决定 fallback)
+     */
+    public String generatePortrait(Long userId, Long memberId, String previousPortrait, Map<String, Object> context) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("user_id", userId);
+            if (memberId != null) body.put("member_id", memberId);
+            if (previousPortrait != null) body.put("previous_portrait", previousPortrait);
+            if (context != null) {
+                context.forEach((k, v) -> body.put(k, v != null ? v.toString() : ""));
+            }
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/portrait/generate";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                consecutiveFailures.set(0);
+                return root.has("portrait_text") ? root.get("portrait_text").asText() : null;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generatePortrait 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     /**
      * 识别图片中的食材
      */
@@ -660,4 +690,38 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 生成文章阅读答题(调用 LangGraph article_quiz_graph)
+     * @return 题目列表(每项含 question/options/answer/dimension/explanation);失败返回 null
+     */
+    public List<Map<String, Object>> generateArticleQuiz(String articleTitle, String articleContent, String profileSummary) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("article_title", articleTitle != null ? articleTitle : "");
+            body.put("article_content", articleContent != null ? articleContent : "");
+            body.put("profile_summary", profileSummary != null ? profileSummary : "");
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/article/quiz/generate";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode questions = root.get("questions");
+                if (questions != null && questions.isArray() && questions.size() > 0) {
+                    List<Map<String, Object>> list = objectMapper.convertValue(questions, List.class);
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway generateArticleQuiz 成功: {} 题", list.size());
+                    return list;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateArticleQuiz 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
 }

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

@@ -794,11 +794,11 @@ public class ArticleService {
         ArticleQuizRecord record = new ArticleQuizRecord();
         record.setArticleId(articleId);
         record.setUserId(userId);
-        record.setChildId(memberId);
+        record.setMemberId(memberId);
         record.setQuestions(questionsJson);
         record.setAnswers(null);
         record.setScore(0);
-        record.setPointsEarned(0);
+        record.setCfEarned(0);
         record.setCreatedAt(new Date());
         record.setUpdatedAt(new Date());
         articleQuizRecordMapper.insert(record);
@@ -865,7 +865,7 @@ public class ArticleService {
         // 4. 更新记录
         record.setAnswers(answersJson);
         record.setScore(score);
-        record.setPointsEarned(pointsEarned);
+        record.setCfEarned(pointsEarned);
         record.setUpdatedAt(new Date());
         articleQuizRecordMapper.updateById(record);
 

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

@@ -9,4 +9,9 @@ public interface PortraitService {
     void updatePortraitPrompt(Long userId, String portraitPrompt);
     /** 读取用户画像(含渲染快照,用于前端展示) */
     Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+
+    /** AI 生成用户画像(分析行为+五维指标,保存到用户表) */
+    String generatePortrait(Long userId, Long memberId);
+    /** AI 重画用户画像(参考旧画像+变化因素,保存到用户表) */
+    String regeneratePortrait(Long userId, Long memberId);
 }

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

@@ -0,0 +1,104 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.RecommendationRule;
+import com.etotem.cfc.mapper.RecommendationRuleMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class RecommendationRuleService extends ServiceImpl<RecommendationRuleMapper, RecommendationRule> {
+
+    @Resource
+    private RecommendationRuleMapper recommendationRuleMapper;
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    /**
+     * 根据场景码加载启用的规则,按 priority 降序
+     */
+    public List<RecommendationRule> loadRulesByScene(String sceneCode) {
+        List<RecommendationRule> allEnabled = this.list(new LambdaQueryWrapper<RecommendationRule>()
+                .eq(RecommendationRule::getEnabled, 1));
+        return allEnabled.stream()
+                .filter(rule -> rule.getSceneCodes() != null && rule.getSceneCodes().contains(sceneCode))
+                .sorted(Comparator.comparing(RecommendationRule::getPriority).reversed())
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 查询已购商品ID(回溯窗口内)
+     */
+    public Set<Long> getPurchasedProductIds(Long familyId, Long memberId, int daysAgo) {
+        Set<Long> ids = new HashSet<>();
+        if (familyId == null) return ids;
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
+        Date cutoff = cal.getTime();
+
+        // 查 family 成员订单
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getFamilyId, familyId)
+                        .eq(ProductOrder::getStatus, "已支付")
+                        .gt(ProductOrder::getPaidAt, cutoff)
+        );
+        for (ProductOrder order : orders) {
+            if (order.getProductId() != null) {
+                ids.add(order.getProductId());
+            }
+        }
+        // 若未查到家庭订单,且 memberId 不为空,查个人订单
+        if (ids.isEmpty() && memberId != null) {
+            List<ProductOrder> orders2 = productOrderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .eq(ProductOrder::getBuyerId, memberId)
+                            .eq(ProductOrder::getStatus, "已支付")
+                            .gt(ProductOrder::getPaidAt, cutoff)
+            );
+            for (ProductOrder order : orders2) {
+                if (order.getProductId() != null) {
+                    ids.add(order.getProductId());
+                }
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * 查询当前用户已购商品(用于 individual scope)
+     */
+    public Set<Long> getMyPurchasedProductIds(Long userId, int daysAgo) {
+        Set<Long> ids = new HashSet<>();
+        if (userId == null) return ids;
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
+        Date cutoff = cal.getTime();
+
+        List<ProductOrder> orders = productOrderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getBuyerId, userId)
+                        .eq(ProductOrder::getStatus, "已支付")
+                        .gt(ProductOrder::getPaidAt, cutoff)
+        );
+        for (ProductOrder order : orders) {
+            if (order.getProductId() != null) {
+                ids.add(order.getProductId());
+            }
+        }
+        return ids;
+    }
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java

@@ -2,8 +2,11 @@ package com.etotem.cfc.service.impl;
 
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AIService;
 import com.etotem.cfc.service.PortraitService;
 import com.etotem.cfc.service.ProfileReadService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
@@ -13,10 +16,14 @@ import java.util.Map;
 @Service
 public class PortraitServiceImpl implements PortraitService {
 
+    private static final Logger log = LoggerFactory.getLogger(PortraitServiceImpl.class);
+
     @Resource
     private UserMapper userMapper;
     @Resource
     private ProfileReadService profileReadService;
+    @Resource
+    private AIService aiService;
 
     @Override
     public String buildPortraitPrompt(Long userId, Long memberId) {
@@ -41,6 +48,36 @@ public class PortraitServiceImpl implements PortraitService {
         userMapper.updateById(user);
     }
 
+    @Override
+    public String generatePortrait(Long userId, Long memberId) {
+        try {
+            String result = aiService.generateUserPortrait(userId, memberId, null);
+            if (result != null && !result.trim().isEmpty()) {
+                updatePortraitPrompt(userId, result);
+                return result;
+            }
+        } catch (Exception e) {
+            log.warn("AI 生成画像失败,回退到快照: userId={}, memberId={}", userId, memberId, e);
+        }
+        return renderSnapshot(memberId);
+    }
+
+    @Override
+    public String regeneratePortrait(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        String currentPortrait = user != null ? user.getPortraitPrompt() : null;
+        try {
+            String result = aiService.generateUserPortrait(userId, memberId, currentPortrait);
+            if (result != null && !result.trim().isEmpty()) {
+                updatePortraitPrompt(userId, result);
+                return result;
+            }
+        } catch (Exception e) {
+            log.warn("AI 重画画像失败,使用旧画像: userId={}, memberId={}", userId, memberId, e);
+        }
+        return currentPortrait;
+    }
+
     @Override
     public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
         Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;

+ 45 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1,6 +1,32 @@
 -- 浠艾福数据库初始化脚本
 -- 自动创建表
 
+-- 推荐规则表(统一推荐引擎规则配置)
+CREATE TABLE IF NOT EXISTS recommendation_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    rule_type TINYINT NOT NULL COMMENT '1-5',
+    rule_name VARCHAR(100) COMMENT '运营名称',
+    trigger_product_id BIGINT COMMENT '触发商品ID',
+    trigger_product_ids VARCHAR(1000) COMMENT '触发商品ID JSON 数组',
+    target_product_id BIGINT COMMENT '目标商品ID',
+    priority INT DEFAULT 10 COMMENT '优先级,越大越优',
+    enabled TINYINT DEFAULT 1 COMMENT '启用 1/0',
+    time_window_days INT COMMENT '回溯窗口(天)',
+    delay_days INT COMMENT '复购延迟天数',
+    scope VARCHAR(20) COMMENT 'family/individual',
+    scene_codes VARCHAR(500) COMMENT '生效场景 JSON 数组',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐规则配置';
+
+-- 推荐规则表索引
+CREATE INDEX idx_rules_enabled ON recommendation_rules(enabled);
+CREATE INDEX idx_rules_scene ON recommendation_rules(scene_codes);
+CREATE INDEX idx_rules_priority ON recommendation_rules(priority);
+
+-- 浠艾福数据库初始化脚本
+-- 自动创建表
+
 -- 家庭表
 CREATE TABLE IF NOT EXISTS families (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -4084,6 +4110,24 @@ CREATE TABLE IF NOT EXISTS article_reading_records (
   INDEX idx_article_child_date (article_id, child_id, read_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章阅读记录表';
 
+-- 文章答题记录表
+CREATE TABLE IF NOT EXISTS article_quiz_records (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  article_id BIGINT DEFAULT NULL COMMENT '文章ID',
+  user_id BIGINT DEFAULT NULL COMMENT '用户ID',
+  member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)',
+  questions TEXT COMMENT '题目快照JSON(含正确答案与维度)',
+  answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL',
+  score INT DEFAULT 0 COMMENT '答对题数',
+  total_questions INT DEFAULT 0 COMMENT '题目总数',
+  cf_earned INT DEFAULT 0 COMMENT '发放的CF值',
+  energy_earned INT DEFAULT 0 COMMENT '发放的能量值',
+  created_at DATETIME,
+  updated_at DATETIME,
+  INDEX idx_member (member_id),
+  INDEX idx_article (article_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表';
+
 -- ============================================================
 -- 统一报告汇总表(report_summary)
 -- ============================================================
@@ -5411,6 +5455,7 @@ CREATE TABLE IF NOT EXISTS profile_snapshot (
     action_metrics JSON DEFAULT NULL COMMENT '近7天行为指标',
     wealth_metrics JSON DEFAULT NULL COMMENT '近30天财商指标',
     problem_domains VARCHAR(500) DEFAULT NULL COMMENT '关注的问题域标签',
+    quiz_summary JSON DEFAULT NULL COMMENT '答题认知雷达汇总',
     computed_at DATETIME DEFAULT NULL COMMENT '最后一次计算时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_member_id (member_id)

+ 48 - 3
cfc-frontend/components/OctopusDiagram.vue

@@ -41,9 +41,9 @@
   <!-- 空状态 -->
   <view class="octopus-empty" v-else-if="effects && effects.list && effects.list.length === 0">
     <text class="empty-icon">🧭</text>
-    <text class="empty-text">暂无成效记录</text>
-      <text class="empty-hint">记录你的成交成效,画出章鱼图</text>
-    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 记录成效</text>
+    <text class="empty-text">暂无感恩记录</text>
+      <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
+    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 感恩记录</text>
   </view>
   <!-- 加载状态 -->
   <view class="octopus-loading" v-else>
@@ -205,6 +205,7 @@ export default {
       var w = this.canvasWidth
       var h = this.canvasHeight
       ctx.clearRect(0, 0, w, h)
+      this._drawOctopusBackground(ctx)
       this._drawCenterNode(ctx)
       this._drawEdges(ctx)
       for (var i = 0; i < this.effectNodes.length; i++) {
@@ -215,6 +216,50 @@ export default {
       }
       this._drawLabels(ctx)
     },
+    /** 绘制章鱼背景:头部 + 8 条波浪触手(低透明度,衬托节点) */
+    _drawOctopusBackground: function(ctx) {
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
+      var headR = minDim * 0.12
+      // 触手:8 条波浪曲线向外延伸
+      for (var i = 0; i < 8; i++) {
+        var angle = (i / 8) * Math.PI * 2 - Math.PI / 2
+        var len = minDim * 0.36
+        var sx = cx + Math.cos(angle) * headR
+        var sy = cy + Math.sin(angle) * headR
+        var ex = cx + Math.cos(angle) * (headR + len)
+        var ey = cy + Math.sin(angle) * (headR + len)
+        // 控制点:垂直偏移制造波浪
+        var perp = angle + Math.PI / 2
+        var mid = headR + len * 0.5
+        var wave = Math.sin(i * 2.2) * len * 0.16
+        var mx = cx + Math.cos(angle) * mid + Math.cos(perp) * wave
+        var my = cy + Math.sin(angle) * mid + Math.sin(perp) * wave
+        ctx.beginPath()
+        ctx.moveTo(sx, sy)
+        ctx.quadraticCurveTo(mx, my, ex, ey)
+        ctx.strokeStyle = 'rgba(16, 185, 129, 0.12)'
+        ctx.lineWidth = 6
+        ctx.lineCap = 'round'
+        ctx.stroke()
+      }
+      // 头部(半透明圆)
+      ctx.beginPath()
+      ctx.arc(cx, cy, headR, 0, Math.PI * 2)
+      ctx.fillStyle = 'rgba(16, 185, 129, 0.08)'
+      ctx.fill()
+      // 眼睛
+      var eyeOff = headR * 0.35
+      var eyeR = Math.max(3, headR * 0.09)
+      ctx.beginPath()
+      ctx.arc(cx - eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
+      ctx.fillStyle = 'rgba(16, 185, 129, 0.22)'
+      ctx.fill()
+      ctx.beginPath()
+      ctx.arc(cx + eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
+      ctx.fill()
+    },
     _drawCenterNode: function(ctx) {
       var cx = this.canvasWidth / 2
       var cy = this.canvasHeight / 2

+ 7 - 1
cfc-frontend/pages.json

@@ -1268,7 +1268,13 @@
         {
           "path": "octopus-add-effect",
           "style": {
-            "navigationBarTitleText": "记录成效"
+            "navigationBarTitleText": "感恩记录"
+          }
+        },
+        {
+          "path": "octopus-effects-list",
+          "style": {
+            "navigationBarTitleText": "感恩记录"
           }
         },
         {

+ 148 - 13
cfc-frontend/pages/action-detail/index.vue

@@ -53,7 +53,10 @@
     <!-- 登录后:家庭关系(行维度核心) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <text class="section-title">家庭关系</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('家庭关系', '了解每位家人的关系质量,改善沟通方式。点击家人节点,查看关系详情。')">?</text>
+          <text class="section-title">家庭关系</text>
+        </view>
         <text class="section-sub">了解每位家人的关系质量,改善沟通方式</text>
         <text class="section-sub">点击家人节点,查看关系详情</text>
       </view>
@@ -69,11 +72,14 @@
     <!-- 登录后:章鱼图(关键人拓展) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">🐙 章鱼图</text>
-          <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('章鱼图', '从成效出发,挖掘关键人,画出拓展路径。记录感恩时刻,建立有温度的人脉网络。')">?</text>
+          <view>
+            <text class="section-title">🐙 章鱼图</text>
+            <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
+          </view>
         </view>
-        <text class="section-link" @tap="goOctopusAddEffect">+ 记录成效</text>
+        <text class="section-link section-link-octopus" @tap="goOctopusEffectsList">+ 感恩记录</text>
       </view>
       <OctopusDiagram
         ref="octopusDiagram"
@@ -90,11 +96,14 @@
     <!-- 登录后:珍珠图(我有什么) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">🫧 珍珠图</text>
-          <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('珍珠图', '把可调用的资源串成珍珠项链——盘点我有什么。登记人脉、资源、信息缺口,画出一条清晰的成长珍珠链。')">?</text>
+          <view>
+            <text class="section-title">🫧 珍珠图</text>
+            <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
+          </view>
         </view>
-        <text class="section-link" @tap="goPearlAddResource">+ 登记</text>
+        <text class="section-link section-link-pearl" @tap="goPearlAddResource">+ 登记</text>
       </view>
       <PearlDiagram
         ref="pearlDiagram"
@@ -109,10 +118,14 @@
     <!-- 登录后:能力图(谁会什么) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">💡 能力图</text>
-          <text class="section-sub">身边藏着什么技能——谁有能力帮到你</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('能力图', '身边藏着什么技能——谁有能力帮到你。记录技能类帮助后,这里会画出大家的能力图谱。')">?</text>
+          <view>
+            <text class="section-title">💡 能力图</text>
+            <text class="section-sub">身边藏着什么技能——谁有能力帮到你</text>
+          </view>
         </view>
+        <text class="section-link section-link-ability" @tap="goInteractionLog">+ 记录帮助</text>
       </view>
       <AbilityDiagram
         ref="abilityDiagram"
@@ -177,6 +190,17 @@
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <FloatingAvatar />
+
+    <!-- 帮助说明弹窗 -->
+    <view class="help-mask" v-if="helpPopup.visible" @tap="closeHelp">
+      <view class="help-dialog" @tap.stop>
+        <view class="help-dialog-header">
+          <text class="help-dialog-title">{{ helpPopup.title }}</text>
+          <text class="help-dialog-close" @tap="closeHelp">✕</text>
+        </view>
+        <text class="help-dialog-content">{{ helpPopup.content }}</text>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -226,6 +250,8 @@ export default {
       octopusExpansions: [],
       octopusKeyPersons: {},
       overdueMap: null,
+      // 帮助弹窗
+      helpPopup: { visible: false, title: '', content: '' },
       funcList: [
         { icon: '\u{1F4AC}', label: '记录互动', needLogin: true, page: '/pages/action-detail/interaction-log' },
         { icon: '\u{1F4DD}', label: '关系问卷', needLogin: true, page: '/pages/action-detail/relationship-questionnaire' },
@@ -559,6 +585,10 @@ export default {
     goPearlAddResource: function() {
       uni.navigateTo({ url: '/pages/action-detail/pearl-add-resource' })
     },
+    /** 能力图记录帮助入口(跳转互动记录,记录技能类帮助) */
+    goInteractionLog: function() {
+      uni.navigateTo({ url: '/pages/action-detail/interaction-log' })
+    },
     /** 删除珍珠图资源 */
     onDeletePearlResource: function(item) {
       if (!item || !item.id) return
@@ -613,6 +643,10 @@ export default {
     goOctopusAddEffect: function() {
       uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' })
     },
+    /** 跳转感恩记录维护列表 */
+    goOctopusEffectsList: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-effects-list' })
+    },
     /** 点击成效节点 */
     onEffectClick: function(effect) {
       var self = this
@@ -631,6 +665,13 @@ export default {
     onReanalyze: function() {
       this.loadOctopusData()
     },
+    /** 显示帮助弹窗 */
+    showHelp: function(title, content) {
+      this.helpPopup = { visible: true, title: title, content: content }
+    },
+    closeHelp: function() {
+      this.helpPopup.visible = false
+    },
     onTaskClick: function(task) {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
@@ -717,6 +758,30 @@ export default {
   justify-content: space-between;
   margin-bottom: 16rpx;
 }
+.section-title-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8rpx;
+}
+.help-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  border: 2rpx solid #CBD5E1;
+  color: #94A3B8;
+  font-size: 22rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: all 0.2s;
+}
+.help-icon:active {
+  border-color: #F97316;
+  color: #F97316;
+  background: #FFF7ED;
+}
 .section-title {
   font-size: 32rpx;
   font-weight: bold;
@@ -726,8 +791,78 @@ export default {
   font-size: 22rpx;
   color: #999;
 }
+.section-link {
+  display: inline-flex;
+  align-items: center;
+  padding: 10rpx 28rpx;
+  border-radius: 40rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  white-space: nowrap;
+  transition: opacity 0.2s;
+}
+.section-link:active {
+  opacity: 0.7;
+}
+.section-link-octopus {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25);
+}
+.section-link-pearl {
+  background: linear-gradient(135deg, #0EA5E9, #38BDF8);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(14,165,233,0.25);
+}
+.section-link-ability {
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(16,185,129,0.25);
+}
 
-/* ===== 功能入口 ===== */
+/* 帮助说明弹窗 */
+.help-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.help-dialog {
+  background: #fff;
+  border-radius: 20rpx;
+  width: 90%;
+  max-width: 500rpx;
+  max-height: 80vh;
+  overflow: hidden;
+}
+.help-dialog-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  border-bottom: 1rpx solid #E5E7EB;
+}
+.help-dialog-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.help-dialog-close {
+  font-size: 32rpx;
+  color: #94A3B8;
+  cursor: pointer;
+}
+.help-dialog-content {
+  padding: 24rpx;
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+}
 .func-section {
   margin: 20rpx 30rpx 0;
 }

+ 12 - 3
cfc-frontend/pages/action-detail/octopus-add-effect.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="container">
     <view class="page-header">
-      <text class="page-title">记录成效</text>
+      <text class="page-title">感恩记录</text>
     </view>
 
     <!-- 成交金额 -->
@@ -57,7 +57,7 @@
 
     <!-- 提交按钮 -->
     <view class="submit-bar">
-      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存成效</button>
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存感恩记录</button>
     </view>
   </view>
 </template>
@@ -80,7 +80,8 @@ export default {
       happenedTime: h + ':' + min,
       keyPersons: [],
       selectedMap: {},   // kpId -> true
-      roleMap: {}        // kpId -> role (1引荐/2决策/3其他)
+      roleMap: {},        // kpId -> role (1引荐/2决策/3其他)
+      _onLoadFired: false
     }
   },
   computed: {
@@ -90,6 +91,14 @@ export default {
   },
   onLoad() {
     this.loadKeyPersons()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadKeyPersons()
+    }
   },
   methods: {
     async loadKeyPersons() {

+ 224 - 0
cfc-frontend/pages/action-detail/octopus-effects-list.vue

@@ -0,0 +1,224 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">感恩记录</text>
+      <text class="page-sub">管理你的感恩时刻与成效记录</text>
+    </view>
+
+    <!-- 记录列表 -->
+    <view class="record-list" v-if="records && records.length > 0">
+      <view class="record-item" v-for="r in records" :key="r.id">
+        <view class="record-main">
+          <view class="record-amount">{{ formatAmount(r.effectAmount) }} 元</view>
+          <view class="record-tags">
+            <text class="tag" v-for="t in (r.effectTypeList || [])" :key="t">{{ t }}</text>
+          </view>
+        </view>
+        <view class="record-date">{{ formatTime(r.createdAt) }}</view>
+        <view class="record-desc" v-if="r.effectDesc">{{ r.effectDesc }}</view>
+        <view class="record-actions">
+          <text class="action-btn-del" @tap="onDelete(r.id)">删除</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else>
+      <text class="empty-icon">🙏</text>
+      <text class="empty-text">暂无感恩记录</text>
+      <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
+    </view>
+
+    <!-- 添加按钮 -->
+    <view class="fab" @tap="goAddEffect">
+      <text class="fab-icon">+</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getOctopusEffects, deleteOctopusEffect } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      records: [],
+      _onLoadFired: false
+    }
+  },
+  onLoad() {
+    this.loadRecords()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadRecords()
+    }
+  },
+  methods: {
+    async loadRecords() {
+      try {
+        var res = await getOctopusEffects()
+        if (res.code === 200 && res.data && res.data.list) {
+          this.records = res.data.list
+        } else {
+          this.records = []
+        }
+      } catch (e) { /* 静默 */ }
+    },
+    formatAmount: function(val) {
+      if (val == null) return '0'
+      return String(val)
+    },
+    formatTime: function(ts) {
+      if (!ts) return ''
+      return String(ts).substring(0, 10)
+    },
+    onDelete: function(id) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定删除这条感恩记录吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          deleteOctopusEffect({ effectId: id }).then(function(r) {
+            if (r.code === 200) {
+              uni.showToast({ title: '已删除', icon: 'success' })
+              self.loadRecords()
+            } else {
+              uni.showToast({ title: r.message || '删除失败', icon: 'none' })
+            }
+          }).catch(function(e) {
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    goAddEffect: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 24rpx;
+  padding-bottom: 120rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.page-sub {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.record-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+.record-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+  position: relative;
+}
+.record-main {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.record-amount {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.record-tags {
+  display: flex;
+  gap: 8rpx;
+}
+.tag {
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  background: #FFF7ED;
+  color: #F97316;
+  border-radius: 8rpx;
+}
+.record-date {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 12rpx;
+}
+.record-desc {
+  font-size: 26rpx;
+  color: #666;
+  margin-top: 8rpx;
+}
+.record-actions {
+  position: absolute;
+  top: 24rpx;
+  right: 24rpx;
+}
+.action-btn-del {
+  font-size: 24rpx;
+  color: #CBD5E1;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.action-btn-del:active {
+  color: #EF4444;
+  background: #FEF2F2;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 20rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 8rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #999;
+}
+.fab {
+  position: fixed;
+  bottom: 80rpx;
+  right: 40rpx;
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  box-shadow: 0 4rpx 20rpx rgba(249,115,22,0.35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.fab-icon {
+  font-size: 48rpx;
+  color: #fff;
+  font-weight: 300;
+}
+</style>

+ 12 - 10
cfc-frontend/pages/article-center/article-detail.vue

@@ -77,7 +77,7 @@
       <view class="result-dialog">
         <text class="result-icon">{{ resultIcon }}</text>
         <text class="result-text">答对 {{ quizResult.correctCount }}/{{ quizResult.totalQuestions }} 题</text>
-        <text class="result-energy">获得 {{ quizResult.earnedEnergy }} 能量 ⚡</text>
+        <text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
         <button class="result-btn" @click="closeResult">知道了</button>
       </view>
     </view>
@@ -104,7 +104,7 @@ export default {
       posterDimension: '', posterSummary: '', posterInviteText: '',
       contentTagStyle: { p: 'font-size:32rpx;color:#444;line-height:1.8;', div: 'font-size:32rpx;color:#444;line-height:1.8;', section: 'font-size:32rpx;color:#444;line-height:1.8;', li: 'font-size:32rpx;color:#444;line-height:1.8;', blockquote: 'font-size:32rpx;color:#444;line-height:1.8;' },
       // 答题
-      showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {},
+      showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {}, quizRecordId: null,
       mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
       mascotIcon: '🐶', mascotName: '浠宝'
     }
@@ -267,24 +267,26 @@ export default {
           setTimeout(function() { uni.navigateTo({ url: '/pages/membership/upgrade' }) }, 1500)
           return
         }
-        var res = await generateQuiz({ articleId: this.articleId })
-        if (res.code === 200 && res.data && res.data.length > 0) {
-          this.quizQuestions = res.data
+        var memberId = uni.getStorageSync('currentChildId')
+        var res = await generateQuiz({ articleId: this.articleId, memberId: parseInt(memberId || 0) })
+        if (res.code === 200 && res.data && res.data.questions && res.data.questions.length > 0) {
+          this.quizRecordId = res.data.recordId
+          this.quizQuestions = res.data.questions
           this.quizAnswers = []
           this.showQuiz = true
         }
       } catch (e) {}
     },
     async submitQuiz() {
-      var correct = 0
+      var memberId = uni.getStorageSync('currentChildId')
+      var answers = []
       for (var i = 0; i < this.quizQuestions.length; i++) {
-        if (this.quizAnswers[i] === this.quizQuestions[i].answer) correct++
+        answers.push(this.quizAnswers[i] || '')
       }
-      var memberId = uni.getStorageSync('currentChildId')
       try {
-        var res = await submitQuiz({ correctCount: correct, memberId: parseInt(memberId || 0) })
+        var res = await submitQuiz({ recordId: this.quizRecordId, memberId: parseInt(memberId || 0), answers: answers })
         if (res.code === 200) {
-          this.quizResult = res.data || { correctCount: correct, totalQuestions: 3, earnedEnergy: correct * 5 }
+          this.quizResult = res.data || {}
           this.showQuiz = false
           this.showResult = true
         }

+ 22 - 28
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -219,7 +219,7 @@
       </view>
 
 
-      <view class="feedback-section" @tap="openFeedback">
+      <view class="feedback-section" @tap="openQuickAnalyze">
         <text class="feedback-icon">💬</text>
         <text class="feedback-text">对这个报告有疑问或建议?告诉我们</text>
         <text class="feedback-arrow">›</text>
@@ -228,18 +228,15 @@
       <view class="bottom-spacer"></view>
     </scroll-view>
 
-    <!-- 报告快速分析入口(仅查看模式,reportId 或 draftId 存在) -->
-    <view class="quick-analyze-fab" v-if="!isEditing && (reportId || draftId)" @tap="openQuickAnalyze">
-      <text class="quick-analyze-fab-icon">🎯</text>
-      <text class="quick-analyze-fab-text">快速分析</text>
-    </view>
-
-    <!-- 快速分析浮层 -->
+    <!-- 快速分析浮层(由「有疑问或建议」入口唤起) -->
     <view class="quick-analyze-overlay" v-if="quickAnalyze.show" @tap="closeQuickAnalyze"></view>
     <view class="quick-analyze-panel" v-if="quickAnalyze.show">
       <view class="quick-analyze-header">
         <text class="quick-analyze-title">报告快速分析</text>
-        <text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
+        <view class="quick-analyze-header-right">
+          <text class="quick-analyze-feedback" @tap="openFeedbackFromAnalyze">提交反馈</text>
+          <text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
+        </view>
       </view>
       <view class="quick-analyze-hint" v-if="quickAnalyze.usedToday">免费版今日次数已用完,升级会员解锁更多分析</view>
       <scroll-view class="quick-analyze-messages" scroll-y>
@@ -983,12 +980,13 @@ applyDraftPayload: function(payload) {
       this.knowledgeVisible = false
       this.knowledgeData = {}
     },
-    openFeedback: function() {
-      this.feedbackVisible = true
-    },
     closeFeedback: function() {
       this.feedbackVisible = false
     },
+    openFeedbackFromAnalyze: function() {
+      this.quickAnalyze.show = false
+      this.feedbackVisible = true
+    },
     toggleEdit: function() {
       this.isEditing = true
     },
@@ -1469,22 +1467,6 @@ applyDraftPayload: function(payload) {
   line-height: 1.5;
 }
 /* ===== 报告快速分析 ===== */
-.quick-analyze-fab {
-  position: fixed;
-  right: 30rpx;
-  bottom: 60rpx;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  background: linear-gradient(135deg, #5B9BD5, #3B82F6);
-  border-radius: 44rpx;
-  padding: 18rpx 30rpx;
-  box-shadow: 0 4rpx 16rpx rgba(59, 130, 246, 0.4);
-  z-index: 90;
-}
-.quick-analyze-fab-icon { font-size: 32rpx; margin-right: 10rpx; }
-.quick-analyze-fab-text { font-size: 28rpx; color: #fff; font-weight: 500; }
-.quick-analyze-fab:active { opacity: 0.85; }
 .quick-analyze-overlay {
   position: fixed;
   top: 0; left: 0; right: 0; bottom: 0;
@@ -1511,6 +1493,18 @@ applyDraftPayload: function(payload) {
   margin-bottom: 16rpx;
 }
 .quick-analyze-title { font-size: 30rpx; font-weight: bold; color: #333; }
+.quick-analyze-header-right {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+}
+.quick-analyze-feedback {
+  font-size: 24rpx;
+  color: #5B9BD5;
+  padding: 8rpx;
+}
+.quick-analyze-feedback:active { opacity: 0.7; }
 .quick-analyze-close { font-size: 32rpx; color: #999; padding: 8rpx; }
 .quick-analyze-hint {
   font-size: 24rpx;

+ 9 - 3
cfc-frontend/pages/health/health-plan-summary.vue

@@ -445,15 +445,21 @@ selectedMembers: function() {
           var mid = self.pickerMembers[i].memberId || self.pickerMembers[i].id
           if (String(mid) === String(id)) { found = self.pickerMembers[i]; break }
         }
-        if (found) out.push(found)
+        // 规整为标准结构(兼容 pickerMembers 只用 id 或 nickname 的情况)
+        if (found) {
+          out.push({
+            memberId: String(found.memberId || found.id),
+            name: found.name || found.nickname || '成员'
+          })
+        }
       })
       return out
     },
     currentMemberName: function() {
       var members = this.selectedMembers
       for (var i = 0; i < members.length; i++) {
-        if (String(members[i].memberId || members[i].id) === String(this.activeFormMemberId)) {
-          return members[i].name || members[i].nickname || '成员'
+        if (String(members[i].memberId) === String(this.activeFormMemberId)) {
+          return members[i].name
         }
       }
       return '成员'

+ 3 - 3
cfc-frontend/pages/index-home/index.vue

@@ -656,12 +656,12 @@ membershipDays: 0,
           color: 'purple'
         }
       }
-      // 6) 默认引导 — 已完成所有任务,指向增值内容
+      // 6) 默认引导 — 已完成所有任务,指向任务中心
       return {
         type: 'default',
         icon: '✨',
         title: '今日任务已清空',
-        sub: '去看看今日推荐阅读,给成长加点料',
+        sub: '去任务中心看看,保持成长节奏',
         btn: '逛一逛',
         color: 'gray'
       }
@@ -1149,7 +1149,7 @@ membershipDays: 0,
           this.navTo('/pages/profile/profile')
           break
         case 'default':
-          this.goMoreArticles()
+          this.navTo('/pages/tasks/tasks')
           break
       }
     },

+ 70 - 2
cfc-frontend/pages/profile/portrait-edit.vue

@@ -3,6 +3,17 @@
     <view class="section">
       <view class="section-title">我的画像描述</view>
       <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
+      
+      <!-- 操作按钮行 -->
+      <view class="action-row">
+        <button class="action-btn" :loading="generating" @click="onGenerate" v-if="!portraitPrompt">
+          🪄 AI 生成画像
+        </button>
+        <button class="action-btn secondary" :loading="regenerating" @click="onRegenerate" v-if="portraitPrompt">
+          🔄 重画画像
+        </button>
+      </view>
+      
       <textarea
         class="portrait-textarea"
         :value="portraitPrompt"
@@ -23,13 +34,15 @@
 </template>
 
 <script>
-import { getPortrait, editPortrait } from '../../utils/api.js'
+import { getPortrait, editPortrait, generatePortrait, regeneratePortrait } from '../../utils/api.js'
 export default {
   data() {
     return {
       portraitPrompt: '',
       renderedSnapshot: '',
-      saving: false
+      saving: false,
+      generating: false,
+      regenerating: false
     }
   },
   onLoad() {
@@ -50,6 +63,45 @@ export default {
     onInput(e) {
       this.portraitPrompt = e.detail.value
     },
+    async onGenerate() {
+      this.generating = true
+      try {
+        const memberId = this.$store.state.currentMemberId
+        const res = await generatePortrait({ memberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+          uni.showToast({ title: '生成成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        this.generating = false
+      }
+    },
+    async onRegenerate() {
+      this.regenerating = true
+      try {
+        const memberId = this.$store.state.currentMemberId
+        const res = await regeneratePortrait({ 
+          memberId,
+          previousPortrait: this.portraitPrompt  // 传入当前画像作为参考
+        })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+          uni.showToast({ title: '重画成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '重画失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '重画失败', icon: 'none' })
+      } finally {
+        this.regenerating = false
+      }
+    },
     async onSave() {
       this.saving = true
       try {
@@ -93,6 +145,22 @@ export default {
   color: #999;
   margin-bottom: 16rpx;
 }
+.action-row {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 16rpx;
+}
+.action-btn {
+  flex: 1;
+  height: 72rpx;
+  border-radius: 36rpx;
+  font-size: 26rpx;
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  color: #fff;
+}
+.action-btn.secondary {
+  background: linear-gradient(135deg, #F97316, #F59E0B);
+}
 .portrait-textarea {
   width: 100%;
   height: 300rpx;

+ 14 - 6
cfc-frontend/pages/tasks/create-task.vue

@@ -153,8 +153,8 @@ actionTypes: [
 { value: 'cart', label: '购物(加购)' },
 { value: 'order', label: '购物(支付/收货)' }
 ],
-dateTimeRange: [[], [], [], []],
-dateTimeIndex: [0, 0, 0, 0],
+dateTimeRange: [[], [], [], [], []],
+dateTimeIndex: [0, 0, 0, 0, 0],
 deadlineText: '',
 submitting: false
 }
@@ -251,6 +251,8 @@ minutes.push(i.toString().padStart(2, '0') + '分')
 }
 
 this.dateTimeRange = [years, months, days, hours, minutes]
+// 默认选中当前日期时间
+this.dateTimeIndex = [0, month - 1, day - 1, hour, Math.floor(minute / 5)]
 },
 onChildrenChange(e) {
 this.selectedChildIds = e.detail.value
@@ -291,17 +293,23 @@ this.deadlineText = `${year}年${month}月${day}日 ${hour.toString().padStart(2
 this.form.deadline = new Date(year, month - 1, day, hour, minute)
 },
 onColumnChange(e) {
-// 处理月份变化时更新天数
-if (e.detail.column === 1) {
-const month = e.detail.value + 1
+const column = e.detail.column
+this.$set(this.dateTimeIndex, column, e.detail.value)
+// 年、月变化时更新当月天数(含闰年处理)
+if (column === 0 || column === 1) {
 const now = new Date()
 const year = now.getFullYear() + this.dateTimeIndex[0]
+const month = this.dateTimeIndex[1] + 1
 const daysInMonth = new Date(year, month, 0).getDate()
 const days = []
 for (let i = 1; i <= daysInMonth; i++) {
 days.push(i + '日')
 }
 this.$set(this.dateTimeRange, 2, days)
+// 若选中的日超出新月天数,回退到最后一天
+if (this.dateTimeIndex[2] >= daysInMonth) {
+this.$set(this.dateTimeIndex, 2, daysInMonth - 1)
+}
 }
 },
 onRepeatChange(e) {
@@ -399,7 +407,7 @@ this.form.requireInput = e.detail.value
 .form-item { margin-bottom: 30rpx; }
 .label { display: block; font-size: 28rpx; color: #333; margin-bottom: 10rpx; }
 .input, .textarea { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; width: 100%; box-sizing: border-box; }
-.input { height: 80rpx; }
+.input { height: 96rpx; }
 .textarea { height: 150rpx; }
 .picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
 .form-hint { display: block; font-size: 22rpx; color: #999; margin-top: 8rpx; }

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

@@ -3060,3 +3060,9 @@ export function getPortrait(params = {}) {
 export function editPortrait(data) {
   return request('/api/user/portrait/edit', 'POST', data)
 }
+export function generatePortrait(params = {}) {
+  return request('/api/user/portrait/generate', 'POST', params)
+}
+export function regeneratePortrait(params = {}) {
+  return request('/api/user/portrait/regenerate', 'POST', params)
+}

+ 24 - 0
cfc-langgraph/app/api/article_quiz.py

@@ -0,0 +1,24 @@
+from fastapi import APIRouter
+from app.models.article_quiz import ArticleQuizRequest, ArticleQuizResponse
+from app.graphs.article_quiz_graph import ArticleQuizAgent
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["article_quiz"])
+
+_agent = None
+
+
+def get_agent() -> ArticleQuizAgent:
+    global _agent
+    if _agent is None:
+        _agent = ArticleQuizAgent()
+    return _agent
+
+
+@router.post("/article/quiz/generate", response_model=ArticleQuizResponse)
+async def generate_article_quiz(req: ArticleQuizRequest):
+    agent = get_agent()
+    result = await agent.run(req.article_title, req.article_content, req.profile_summary)
+    return ArticleQuizResponse(questions=result.get("questions", []),
+                               fallback_used=result.get("fallback_used", True))

+ 56 - 0
cfc-langgraph/app/api/portrait.py

@@ -0,0 +1,56 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from app.graphs.portrait_generation_graph import get_portrait_graph
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/portrait", tags=["portrait"])
+
+
+class PortraitGenerateRequest(BaseModel):
+    user_id: int
+    member_id: int | None = None
+    previous_portrait: str | None = None
+    profile_snapshot: dict | None = None
+    behavior_summary: dict | None = None
+
+
+@router.post("/generate")
+async def generate_portrait(req: PortraitGenerateRequest):
+    """AI 生成用户画像(首次生成)"""
+    try:
+        graph = get_portrait_graph()
+        state = {
+            "user_id": req.user_id,
+            "member_id": req.member_id,
+            "previous_portrait": None,
+            "profile_snapshot": req.profile_snapshot,
+            "behavior_summary": req.behavior_summary,
+            "portrait_text": None,
+        }
+        result = await graph.ainvoke(state)
+        return {"portrait_text": result["portrait_text"]}
+    except Exception as e:
+        logger.exception("画像生成失败")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/regenerate")
+async def regenerate_portrait(req: PortraitGenerateRequest):
+    """AI 重画用户画像(参考旧画像)"""
+    try:
+        graph = get_portrait_graph()
+        state = {
+            "user_id": req.user_id,
+            "member_id": req.member_id,
+            "previous_portrait": req.previous_portrait,
+            "profile_snapshot": req.profile_snapshot,
+            "behavior_summary": req.behavior_summary,
+            "portrait_text": None,
+        }
+        result = await graph.ainvoke(state)
+        return {"portrait_text": result["portrait_text"]}
+    except Exception as e:
+        logger.exception("画像重画失败")
+        raise HTTPException(status_code=500, detail=str(e))

+ 46 - 22
cfc-langgraph/app/checkpointer.py

@@ -2,22 +2,25 @@
 共享的 LangGraph checkpointer 工厂。
 
 原实现使用 MemorySaver(纯内存),服务重启即丢失所有对话状态。
-这里改用 SQLite 持久化(SqliteSaver),对话状态落盘,重启不丢。
+这里改用 SQLite 持久化(AsyncSqliteSaver),支持异步调用,对话状态落盘,重启不丢。
 
 说明:LangGraph 官方 RedisSaver 依赖 RedisSearch 模块(FT.* 命令),
 当前 Redis 未安装该模块,故采用 SQLite 持久化作为务实替代。
+
+重要:AsyncSqliteSaver 内部绑定 asyncio.Lock + 当前事件循环(get_running_loop),
+必须在 FastAPI 的 running loop 内初始化(app startup 时调用 init_checkpointer),
+不能在临时 loop 中创建,否则后续 ainvoke 会报 "attached to a different loop"。
 """
 import logging
 import os
-import sqlite3
-import threading
 
-from langgraph.checkpoint.sqlite import SqliteSaver
+import aiosqlite
+from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
 
 logger = logging.getLogger(__name__)
 
 # SQLite 数据库文件路径:
-# - Docker 容器内 /data 为持久化卷(与 Chroma 同级)
+# - Docker 容器内 /data 为持久化卷(与 Chroma 同级),但需检查写权限
 # - 本地开发 fallback 到项目 data/ 目录
 # - 可用环境变量 CHECKPOINT_DB_PATH 覆盖
 _DEFAULT_DB_PATH = os.path.join(
@@ -26,25 +29,46 @@ _DEFAULT_DB_PATH = os.path.join(
     "checkpoints.db",
 )
 
-# 线程本地存储:每个线程维护独立的 SqliteSaver 实例
-# check_same_thread=False 允许跨线程共享 sqlite3 连接
-# SqliteSaver 内部有自己的 threading.Lock() 保证序列化写入
-_local = threading.local()
+# 全局单例(由 init_checkpointer 在 startup 时填充)
+_saver: AsyncSqliteSaver | None = None
+
+
+def _resolve_db_path() -> str:
+    """解析数据库路径:优先环境变量,其次有写权限的目录。"""
+    # 1. 环境变量覆盖
+    env_path = os.environ.get("CHECKPOINT_DB_PATH")
+    if env_path:
+        return env_path
+
+    # 2. 尝试 /data(容器卷),需检查写权限
+    if os.path.isdir("/data") and os.access("/data", os.W_OK):
+        return "/data/checkpoints.db"
 
+    # 3. fallback 到项目 data 目录
+    return _DEFAULT_DB_PATH
 
-def get_checkpointer() -> SqliteSaver:
-    """获取当前线程的 SqliteSaver 实例(线程安全)。"""
-    db_path = os.environ.get(
-        "CHECKPOINT_DB_PATH",
-        "/data/checkpoints.db" if os.path.isdir("/data") else _DEFAULT_DB_PATH,
-    )
+
+async def init_checkpointer() -> AsyncSqliteSaver:
+    """在运行中的事件循环内初始化 AsyncSqliteSaver(app startup 时调用)。"""
+    global _saver
+    if _saver is not None:
+        return _saver
+
+    db_path = _resolve_db_path()
     os.makedirs(os.path.dirname(db_path), exist_ok=True)
 
-    if not hasattr(_local, "saver"):
-        conn = sqlite3.connect(db_path, check_same_thread=False)
-        saver = SqliteSaver(conn)
-        saver.setup()
-        _local.saver = saver
-        logger.info("LangGraph checkpointer 已初始化: %s", db_path)
+    conn = await aiosqlite.connect(db_path)
+    saver = AsyncSqliteSaver(conn)
+    await saver.setup()
+    _saver = saver
+    logger.info("LangGraph AsyncSqliteSaver 已初始化: %s", db_path)
+    return _saver
+
 
-    return _local.saver
+def get_checkpointer() -> AsyncSqliteSaver:
+    """获取全局 AsyncSqliteSaver(须已通过 init_checkpointer 在 startup 初始化)。"""
+    if _saver is None:
+        raise RuntimeError(
+            "checkpointer 未初始化,请确保 app startup 已调用 init_checkpointer()"
+        )
+    return _saver

+ 58 - 0
cfc-langgraph/app/graphs/article_quiz_graph.py

@@ -0,0 +1,58 @@
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.config import settings
+from app.monitoring import monitor_agent
+from app.prompt_service import get_prompt
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
+
+要求:
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
+2. 题目考察文章核心内容的理解,适合青少年读者
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
+
+输出格式:
+[
+  {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
+]
+"""
+
+
+class ArticleQuizAgent:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=settings.llm_temperature,
+        )
+
+    @monitor_agent("article_quiz")
+    async def run(self, article_title: str, article_content: str, profile_summary: str = "") -> dict:
+        """根据文章内容生成 3 道选择题,失败返回空 questions"""
+        try:
+            human = f"文章标题:{article_title}\n文章内容:{article_content}"
+            if profile_summary:
+                human += f"\n用户画像摘要:{profile_summary}"
+            messages = [
+                SystemMessage(content=await get_prompt("article_quiz") or SYSTEM_PROMPT),
+                HumanMessage(content=human),
+            ]
+            response = await self.llm.ainvoke(messages)
+            content = response.content.strip()
+            if "```json" in content:
+                content = content.split("```json")[1].split("```")[0].strip()
+            elif "```" in content:
+                content = content.split("```")[1].split("```")[0].strip()
+            questions = json.loads(content)
+            if not isinstance(questions, list):
+                return {"questions": [], "fallback_used": True}
+            return {"questions": questions, "fallback_used": False}
+        except Exception as e:
+            logger.warning("文章出题生成失败: %s", e)
+            return {"questions": [], "fallback_used": True}

+ 141 - 0
cfc-langgraph/app/graphs/portrait_generation_graph.py

@@ -0,0 +1,141 @@
+"""用户画像生成/重画 LangGraph
+
+输入(由 Java 传入):
+- user_id: 用户ID
+- member_id: 成员ID(可空)
+- previous_portrait: 旧画像文本(重画时传入,首次生成为 None)
+- profile_snapshot: 五维画像快照 dict(含 member/dimension_scores/body_metrics/mind_metrics/problem_domains)
+- behavior_summary: 行为摘要 dict
+
+输出:
+- portrait_text: 生成的画像文本(中文,自然语言描述)
+"""
+from typing import TypedDict
+from langgraph.graph import StateGraph, START, END
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PortraitGenerationState(TypedDict):
+    user_id: int
+    member_id: int | None
+    previous_portrait: str | None
+    profile_snapshot: dict | None
+    behavior_summary: dict | None
+    portrait_text: str | None
+
+
+SYSTEM_PROMPT = """你是「浠艾福」家庭成长平台的 AI 画像分析师。
+
+任务:根据用户的行为数据、五维能量指标、健康画像,生成一段自然语言的「用户画像」文本。
+
+画像用途:作为后续所有 AI 对话(健康教练、管家、营养师、通用聊天)的 SystemMessage 注入,帮助 AI 更懂用户,给出更个性化的建议。
+
+生成要求:
+1. 语言自然、温暖、具体,像一位了解家庭的成长顾问写的观察笔记
+2. 必须包含:核心特质、行为模式、关注点、潜在需求
+3. 长度:200-500 字,不要过长
+4. 不要列举原始数据(如"身 7.2 智 6.5"),要转化为洞察(如"身体维度相对突出,但智力维度有提升空间")
+5. 如有 previous_portrait,重画时要体现"变化"与"延续":哪些特质稳定,哪些有新变化,原因可能是什么
+
+输出格式:纯文本,无 JSON,无标记。"""
+
+
+def create_portrait_generation_graph():
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.7,
+    )
+
+    builder = StateGraph(PortraitGenerationState)
+
+    async def generate_portrait(state: PortraitGenerationState) -> dict:
+        previous = state.get("previous_portrait")
+        profile = state.get("profile_snapshot") or {}
+        behavior = state.get("behavior_summary") or {}
+
+        context_parts = []
+
+        if profile:
+            member = profile.get("member") or {}
+            name = member.get("name", "用户")
+            age = member.get("age")
+            gender = member.get("gender")
+            context_parts.append(f"用户:{name},{age}岁,{gender}")
+
+            dims = profile.get("dimension_scores") or {}
+            if dims:
+                context_parts.append(
+                    f"五维评分:身 {dims.get('body','?')} 智 {dims.get('wisdom','?')} "
+                    f"心 {dims.get('mind','?')} 行 {dims.get('action','?')} 富 {dims.get('wealth','?')}"
+                )
+
+            body = profile.get("body_metrics") or {}
+            if body.get("sleep_dur_avg"):
+                context_parts.append(f"平均睡眠:{body['sleep_dur_avg']}h/天")
+            if body.get("exercise_count_week"):
+                context_parts.append(f"周运动:{body['exercise_count_week']}次")
+
+            mind = profile.get("mind_metrics") or {}
+            if mind.get("stress_avg"):
+                context_parts.append(f"平均压力:{mind['stress_avg']}/10")
+
+            problems = profile.get("problem_domains") or []
+            if problems:
+                context_parts.append(f"关注问题:{', '.join(problems[:5])}")
+
+        if behavior:
+            context_parts.append("行为摘要:" + str(behavior))
+
+        context_text = "\n".join(context_parts) or "暂无额外数据"
+
+        if previous:
+            human_content = f"""【重画任务】请基于以下最新数据,重新生成用户画像。
+
+【原画像(参考)】:
+{previous}
+
+【最新数据】:
+{context_text}
+
+要求:保留原画像中仍有效的核心特质,更新已变化的部分,体现"变化与延续"。"""
+        else:
+            human_content = f"""【生成任务】请基于以下数据,生成用户画像。
+
+【数据】:
+{context_text}"""
+
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=human_content),
+        ]
+
+        response = await llm.ainvoke(messages)
+        portrait_text = response.content.strip()
+
+        logger.info("生成画像完成: user_id=%d, member_id=%s, len=%d",
+                    state["user_id"], state.get("member_id"), len(portrait_text))
+
+        return {"portrait_text": portrait_text}
+
+    builder.add_node("generate_portrait", generate_portrait)
+    builder.add_edge(START, "generate_portrait")
+    builder.add_edge("generate_portrait", END)
+
+    return builder.compile()
+
+
+_portrait_graph = None
+
+
+def get_portrait_graph():
+    global _portrait_graph
+    if _portrait_graph is None:
+        _portrait_graph = create_portrait_generation_graph()
+    return _portrait_graph

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

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -29,7 +29,10 @@ app.include_router(audio.router)
 app.include_router(logs.router)
 app.include_router(innate_portrait.router)
 app.include_router(self_check.router)
+app.include_router(self_check.router)
 app.include_router(knowledge_base.router)
+app.include_router(portrait.router)
+app.include_router(article_quiz.router)
 
 
 @app.on_event("startup")
@@ -55,6 +58,10 @@ async def startup():
     retriever = RagRetriever()
     await retriever.initialize()
 
+    # 初始化 LangGraph checkpointer(必须在运行中的事件循环内创建 AsyncSqliteSaver)
+    from app.checkpointer import init_checkpointer
+    await init_checkpointer()
+
     async def schedule_kb_sync():
         # 首次同步延迟 120s,让 healthcheck 先通过,避免 worker 刚启动就被阻塞
         await asyncio.sleep(120)

+ 13 - 0
cfc-langgraph/app/models/article_quiz.py

@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+from typing import List, Dict, Any
+
+
+class ArticleQuizRequest(BaseModel):
+    article_title: str
+    article_content: str = ""
+    profile_summary: str = ""
+
+
+class ArticleQuizResponse(BaseModel):
+    questions: List[Dict[str, Any]] = []
+    fallback_used: bool = True

+ 13 - 0
cfc-langgraph/app/prompt_service.py

@@ -80,6 +80,19 @@ DEFAULT_PROMPTS: dict[str, str] = {
         }
     ]
 }
+""",
+    "article_quiz": """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
+
+要求:
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
+2. 题目考察文章核心内容的理解,适合青少年读者
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
+
+输出格式:
+[
+  {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
+]
 """,
 }
 

+ 1 - 0
cfc-langgraph/requirements.txt

@@ -2,6 +2,7 @@ fastapi>=0.110,<1.0
 uvicorn[standard]>=0.29,<1.0
 langgraph>=0.2,<1.0
 langgraph-checkpoint-sqlite==3.0.3
+aiosqlite>=0.19,<1.0
 langchain-openai>=0.2,<1.0
 langchain-core>=0.3,<1.0
 chromadb==0.5.23

+ 2 - 0
cfc-langgraph/requirements_frozen.txt

@@ -1,6 +1,7 @@
 aiohappyeyeballs==2.7.1
 aiohttp==3.14.2
 aiosignal==1.4.0
+aiosqlite==0.22.1
 annotated-doc==0.0.4
 annotated-types==0.7.0
 anyio==4.14.2
@@ -49,6 +50,7 @@ langchain-protocol==0.0.18
 langchain-text-splitters==0.3.11
 langgraph==0.6.11
 langgraph-checkpoint==3.0.1
+langgraph-checkpoint-sqlite==3.0.3
 langgraph-prebuilt==0.6.5
 langgraph-sdk==0.2.15
 langsmith==0.10.9

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-e08f86fb2379f363d5c4230b1c6782da7ea87127
+43573f9f64b5ca30a65eb092a51ef644602d50c1

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

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

+ 1 - 1
cfc-web/package.json

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

+ 109 - 1
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,7 +4,115 @@
 
 ---
 
-## v1.0.1428 (2026-09-17)
+## v1.0.1438 (2026-09-19)
+
+### 新功能
+- 行页面整体美化
+
+### 其他
+- 2. 章鱼图添加入口从'记录成效'改为'感恩记录',并跳转至新建的维护列表页
+- 3. 三个图添加入口按钮统一美化(渐变圆角 + 彩色阴影)
+- 4. 章鱼图 Canvas 背景添加低透明度章鱼触手+头部装饰
+- 5. 新增 octopus-effects-list.vue:感恩记录维护页(列表展示/删除/FAB 添加)
+- 6. 修复 octopus-add-effect.vue 关键人'能添加但不能选'bug:补 onShow 刷新
+- - 新增:pages/action-detail/octopus-effects-list.vue
+- - 修改:OctopusDiagram.vue / pages.json / index.vue / octopus-add-effect.vue
+- 
+
+
+## v1.0.1437 (2026-09-19)
+
+### Bug 修复
+- selectedMembers 规整为标准结构-兼容 memberPicker 仅返回 id/nickname 的情况
+
+
+## v1.0.1436 (2026-09-19)
+
+### 新功能
+- 文章答题出题/提交/双奖励/认知雷达接口改造
+- 文章答题弹窗适配新接口并展示CF值
+- AiGateway 新增文章答题出题方法
+- 注册文章答题出题路由与提示词
+- 新增文章答题出题 graph
+- 文章答题记录表迁移与实体字段调整
+
+### 文档
+- 更新文章答题功能实施状态
+
+### Bug 修复
+- 移除无 log4j 依赖的无效 @Log4j 注解,恢复编译
+
+
+## v1.0.1435 (2026-09-19)
+
+### 文档
+- 文章阅读答题奖励+认知雷达设计规格与实现计划
+
+
+## v1.0.1434 (2026-09-18)
+
+### Bug 修复
+- InviteController 登录态校验防御 null userId
+- checkpointer 改用 AsyncSqliteSaver 修复 chat 端点异步调用报错
+
+### 其他
+- 但 checkpointer 原用同步 SqliteSaver,LangGraph 抛 'SqliteSaver does not support
+- async methods',导致后端 AiGateway 收到 500 返回 null,前端提示 'AI 服务暂不可用'。
+- - checkpointer.py: 改用 AsyncSqliteSaver,新增 init_checkpointer() 在 startup 事件
+-   循环内初始化(AsyncSqliteSaver 绑定 get_running_loop,不能在临时 loop 创建)
+- - main.py: startup 调用 await init_checkpointer()
+- - requirements.txt / requirements_frozen.txt: 新增 aiosqlite 依赖
+- 
+
+
+## v1.0.1433 (2026-09-17)
+
+### Bug 修复
+- 创建任务页输入框高度不足、截止时间选择器列数不匹配
+
+
+## v1.0.1432 (2026-09-16)
+
+### 新功能
+- PortraitController 新增 /generate /regenerate 端点(AI 画像生成/重画入口)
+- 用户画像 AI 生成/重画 - 接口扩展+前端入口+计划文档
+- Java 侧画像 AI 生成网关 + AIService + AIBehaviorController
+- Python 侧新增 portrait 生成/重画 FastAPI 路由
+- Python 侧新增 portrait_generation_graph AI 生成/重画用户画像 LangGraph
+
+### 其他
+- - POST /api/user/portrait/regenerate: 参考旧画像重画,体现变化与延续
+- - 复用 getPortrait 返回 renderedSnapshot,portraitPrompt 覆盖为本次结果
+- 
+- - portrait-edit.vue 增加 AI 生成/重画按钮及加载态
+- - api.js 新增 generatePortrait/regeneratePortrait 封装
+- - 补充完整实施计划文档 docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md
+- 
+
+
+## v1.0.1431 (2026-09-16)
+
+### 新功能
+- 新增 recommendation_rules 推荐规则表(迁移316-317)
+- add product recommendation rule engine design and plan design: unified recommendation rule engine for 5 business rules plan: 6-phase implementation roadmap with milestones
+
+
+## v1.0.1430 (2026-09-16)
+
+### 文档
+- 新增运营文案(个人号种草/便秘腹泻账号方案与竞品分析/小红书知识图谱/竞品搜索原始数据)
+
+### 重构
+- 报告详情快速分析合并到有疑问或建议入口
+
+
+## v1.0.1429 (2026-09-16)
+
+### Bug 修复
+- 首页「逛一逛」引导条跳转任务中心而非知识中心
+
+
+## v1.0.1428 (2026-09-16)
 
 ### 新功能
 - 舌诊图片持久化 + parsePreview member_id nullable

+ 110 - 2
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1428
+> 当前版本: v1.0.1438
 
 ## 历史版本
 
@@ -8,7 +8,115 @@
 
 ---
 
-## v1.0.1428 (2026-09-17)
+## v1.0.1438 (2026-09-19)
+
+### 新功能
+- 行页面整体美化
+
+### 其他
+- 2. 章鱼图添加入口从'记录成效'改为'感恩记录',并跳转至新建的维护列表页
+- 3. 三个图添加入口按钮统一美化(渐变圆角 + 彩色阴影)
+- 4. 章鱼图 Canvas 背景添加低透明度章鱼触手+头部装饰
+- 5. 新增 octopus-effects-list.vue:感恩记录维护页(列表展示/删除/FAB 添加)
+- 6. 修复 octopus-add-effect.vue 关键人'能添加但不能选'bug:补 onShow 刷新
+- - 新增:pages/action-detail/octopus-effects-list.vue
+- - 修改:OctopusDiagram.vue / pages.json / index.vue / octopus-add-effect.vue
+- 
+
+
+## v1.0.1437 (2026-09-19)
+
+### Bug 修复
+- selectedMembers 规整为标准结构-兼容 memberPicker 仅返回 id/nickname 的情况
+
+
+## v1.0.1436 (2026-09-19)
+
+### 新功能
+- 文章答题出题/提交/双奖励/认知雷达接口改造
+- 文章答题弹窗适配新接口并展示CF值
+- AiGateway 新增文章答题出题方法
+- 注册文章答题出题路由与提示词
+- 新增文章答题出题 graph
+- 文章答题记录表迁移与实体字段调整
+
+### 文档
+- 更新文章答题功能实施状态
+
+### Bug 修复
+- 移除无 log4j 依赖的无效 @Log4j 注解,恢复编译
+
+
+## v1.0.1435 (2026-09-19)
+
+### 文档
+- 文章阅读答题奖励+认知雷达设计规格与实现计划
+
+
+## v1.0.1434 (2026-09-18)
+
+### Bug 修复
+- InviteController 登录态校验防御 null userId
+- checkpointer 改用 AsyncSqliteSaver 修复 chat 端点异步调用报错
+
+### 其他
+- 但 checkpointer 原用同步 SqliteSaver,LangGraph 抛 'SqliteSaver does not support
+- async methods',导致后端 AiGateway 收到 500 返回 null,前端提示 'AI 服务暂不可用'。
+- - checkpointer.py: 改用 AsyncSqliteSaver,新增 init_checkpointer() 在 startup 事件
+-   循环内初始化(AsyncSqliteSaver 绑定 get_running_loop,不能在临时 loop 创建)
+- - main.py: startup 调用 await init_checkpointer()
+- - requirements.txt / requirements_frozen.txt: 新增 aiosqlite 依赖
+- 
+
+
+## v1.0.1433 (2026-09-17)
+
+### Bug 修复
+- 创建任务页输入框高度不足、截止时间选择器列数不匹配
+
+
+## v1.0.1432 (2026-09-16)
+
+### 新功能
+- PortraitController 新增 /generate /regenerate 端点(AI 画像生成/重画入口)
+- 用户画像 AI 生成/重画 - 接口扩展+前端入口+计划文档
+- Java 侧画像 AI 生成网关 + AIService + AIBehaviorController
+- Python 侧新增 portrait 生成/重画 FastAPI 路由
+- Python 侧新增 portrait_generation_graph AI 生成/重画用户画像 LangGraph
+
+### 其他
+- - POST /api/user/portrait/regenerate: 参考旧画像重画,体现变化与延续
+- - 复用 getPortrait 返回 renderedSnapshot,portraitPrompt 覆盖为本次结果
+- 
+- - portrait-edit.vue 增加 AI 生成/重画按钮及加载态
+- - api.js 新增 generatePortrait/regeneratePortrait 封装
+- - 补充完整实施计划文档 docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md
+- 
+
+
+## v1.0.1431 (2026-09-16)
+
+### 新功能
+- 新增 recommendation_rules 推荐规则表(迁移316-317)
+- add product recommendation rule engine design and plan design: unified recommendation rule engine for 5 business rules plan: 6-phase implementation roadmap with milestones
+
+
+## v1.0.1430 (2026-09-16)
+
+### 文档
+- 新增运营文案(个人号种草/便秘腹泻账号方案与竞品分析/小红书知识图谱/竞品搜索原始数据)
+
+### 重构
+- 报告详情快速分析合并到有疑问或建议入口
+
+
+## v1.0.1429 (2026-09-16)
+
+### Bug 修复
+- 首页「逛一逛」引导条跳转任务中心而非知识中心
+
+
+## v1.0.1428 (2026-09-16)
 
 ### 新功能
 - 舌诊图片持久化 + parsePreview member_id nullable

+ 4 - 2
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,7 +1,7 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.11
-**日期:** 2026-09-10
+**文档版本:** v2.12
+**日期:** 2026-09-19
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)**
 **维护:** 所有需求变更需更新本文档
 
@@ -375,6 +375,7 @@
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
+| `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 
 ### 实施计划(plans/)
 
@@ -455,6 +456,7 @@
 | `2026-08-31-self-check-ai-integration.md` | 🟡 待实施(11 Tasks:LangGraph×2 graph + 路由 / Java Service+AiGateway+Controller / 前端结果页+中间页+Chat 注入) | 五维自检 AI 结合 P0+P1 实现计划(设计稿:2026-08-31-self-check-ai-integration-design.md) |
 | `2026-09-04-task-system-unification.md` | 🟢 已实施(9 Tasks:tasks表加字段/模板加onboarding_sequence/DTO透传/GrowthTask迁入TaskService/Onboarding复用模板/旧表迁移/旧接口兼容/前端统一任务中心/收尾) | 统一任务系统实施计划(设计稿:2026-09-04-task-system-unification-design.md) |
 | `2026-09-12-vendor-order-review.md` | ✅ 已实施(5 Tasks:后端 review/update 接口 + 临时券/赠品/配送校验 + 前端 order-review 页面 + 列表入口 + vendorId 详情返回) | 供应商订单审核实施计划(设计稿:2026-09-12-vendor-order-review-design.md) |
+| `2026-09-19-article-reading-quiz.md` | 🟢 已实施(7 Tasks:迁移299-300建表加列 / 实体字段调整 / LangGraph article_quiz_graph+路由 / AiGateway / quiz接口改造 / 前端适配 / 编译验证) | 文章阅读答题奖励+认知雷达实施计划(设计稿:2026-09-19-article-reading-quiz-design.md) |
 
 ### 计划与设计文档(specs/)
 

+ 1015 - 0
docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md

@@ -0,0 +1,1015 @@
+# 用户画像 AI 生成 + 重画功能 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 用户画像由 AI 根据系统行为数据 + 五维指标自动生成,用户可编辑,提供「重画」功能(参考旧画像 + 考虑变化因素)
+
+**Architecture:**
+- Java 侧:新增 `POST /api/user/portrait/generate`、`POST /api/user/portrait/regenerate`,调用 LangGraph portrait 生成/重画 graph
+- Python 侧:新增 `portrait_generation_graph.py`(LLM 分析行为+五维指标生成画像),`app/api/portrait.py` FastAPI 端点
+- 前端:`portrait-edit.vue` 增加「生成画像」「重画」按钮、加载态、结果展示
+
+**Tech Stack:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / Python 3.10 + FastAPI + LangGraph + OpenAI / uni-app Vue 2
+
+---
+
+## Global Constraints
+
+- 统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- 响应包装 `Result<T>` (code/message/data)
+- JWT Bearer Token 认证,`JwtInterceptor` 拦截 `/api/**`
+- DI 使用 `@Resource`,字段名必须与 Bean Name 一致
+- MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)`
+- 小程序限制:禁止 `?.`、CSS Grid、`:key` 表达式、`new Date(string)`(用 `parseDate()`)
+- Python 侧统一走 LangGraph graph 形式实现 AI 能力
+- 编译验证:`mvn clean compile` / `python3 -m py_compile` 为唯一验证方式
+
+---
+
+## 文件结构映射
+
+### Java 后端新增/修改
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `service/PortraitService.java` | 修改 | 新增 `generatePortrait`、`regeneratePortrait` 接口方法 |
+| `service/impl/PortraitServiceImpl.java` | 修改 | 实现调用 AiGateway / portrait 生成逻辑 |
+| `controller/user/PortraitController.java` | 修改 | 新增 `/generate`、`/regenerate` 端点 |
+| `service/AIService.java` | 修改 | 新增 `generateUserPortrait` 方法(走 AiGateway 调用 LangGraph) |
+
+### Python LangGraph 新增/修改
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `graphs/portrait_generation_graph.py` | 新增 | AI 生成/重画用户画像的 LangGraph |
+| `api/portrait.py` | 新增 | FastAPI 路由:`POST /api/portrait/generate`、`POST /api/portrait/regenerate` |
+| `main.py` | 修改 | 注册 `portrait.router` |
+| `tools/java_client.py` | 修改 | 新增 `get_behavior_summary(user_id, member_id)` 获取行为数据 |
+
+### 小程序前端修改
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `pages/profile/portrait-edit.vue` | 修改 | 增加「生成画像」「重画」按钮、加载态、错误处理 |
+| `utils/api.js` | 修改 | 新增 `generatePortrait`、`regeneratePortrait` 封装 |
+
+---
+
+## 任务分解
+
+### 任务 1:Java PortraitService 接口扩展
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java`
+
+**Interfaces:**
+- Produces: 新增 `generatePortrait(Long userId, Long memberId)`、`regeneratePortrait(Long userId, Long memberId)` 方法签名
+
+- [ ] **Step 1: 扩展 PortraitService 接口**
+
+```java
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface PortraitService {
+    /** 获取用户画像文本(自定义 prompt + 快照渲染) */
+    String buildPortraitPrompt(Long userId, Long memberId);
+    /** 更新用户自定义画像 prompt */
+    void updatePortraitPrompt(Long userId, String portraitPrompt);
+    /** 读取用户画像(含渲染快照,用于前端展示) */
+    Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+    
+    /** AI 生成用户画像(分析行为+五维指标) */
+    String generatePortrait(Long userId, Long memberId);
+    /** AI 重画用户画像(参考旧画像+变化因素) */
+    String regeneratePortrait(Long userId, Long memberId);
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java
+git commit -m "feat: PortraitService 新增 generatePortrait/regeneratePortrait 接口"
+```
+
+---
+
+### 任务 2:Java PortraitServiceImpl 实现
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java`
+
+**Interfaces:**
+- Consumes: `AIService.generateUserPortrait(userId, memberId, previousPortrait)` 返回 AI 生成的画像文本
+- Produces: `generatePortrait`、`regeneratePortrait` 实现
+
+- [ ] **Step 1: 注入 AIService**
+
+```java
+@Resource
+private AIService aiService;
+```
+
+- [ ] **Step 2: 实现 generatePortrait**
+
+```java
+@Override
+public String generatePortrait(Long userId, Long memberId) {
+    // 调用 AIService 生成画像
+    String generated = aiService.generateUserPortrait(userId, memberId, null);
+    if (generated != null && !generated.trim().isEmpty()) {
+        // 保存到用户表
+        updatePortraitPrompt(userId, generated.trim());
+        return generated.trim();
+    }
+    // 失败时降级为快照渲染
+    return renderSnapshot(memberId);
+}
+```
+
+- [ ] **Step 3: 实现 regeneratePortrait**
+
+```java
+@Override
+public String regeneratePortrait(Long userId, Long memberId) {
+    // 1. 获取当前画像
+    User user = userMapper.selectById(userId);
+    String currentPortrait = user != null ? user.getPortraitPrompt() : null;
+    
+    // 2. 调用 AIService 重画(传入旧画像作为参考)
+    String regenerated = aiService.generateUserPortrait(userId, memberId, currentPortrait);
+    if (regenerated != null && !regenerated.trim().isEmpty()) {
+        updatePortraitPrompt(userId, regenerated.trim());
+        return regenerated.trim();
+    }
+    // 失败保持原画像
+    return currentPortrait;
+}
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java
+git commit -m "feat: PortraitServiceImpl 实现 generatePortrait/regeneratePortrait"
+```
+
+---
+
+### 任务 3:Java AIService 扩展
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` (接口)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/impl/AIServiceImpl.java` (实现)
+
+**Interfaces:**
+- Produces: `generateUserPortrait(Long userId, Long memberId, String previousPortrait)` → `String`
+
+- [ ] **Step 1: 扩展 AIService 接口**
+
+```java
+/**
+ * AI 生成用户画像
+ * @param userId 用户ID
+ * @param memberId 成员ID(可空,为空时仅基于用户自身行为)
+ * @param previousPortrait 旧画像文本(重画时传入,首次生成为 null)
+ * @return 生成的画像文本,失败返回 null
+ */
+String generateUserPortrait(Long userId, Long memberId, String previousPortrait);
+```
+
+- [ ] **Step 2: 实现 AIServiceImpl.generateUserPortrait**
+
+```java
+@Override
+public String generateUserPortrait(Long userId, Long memberId, String previousPortrait) {
+    try {
+        // 1. 获取用户行为摘要 + 五维画像
+        Map<String, Object> context = familyContextService.buildContext(userId);
+        if (memberId != null) {
+            context.put("memberId", memberId);
+        }
+        
+        // 2. 构建输入
+        Map<String, Object> inputs = new HashMap<>();
+        inputs.put("user_id", userId);
+        inputs.put("member_id", memberId);
+        inputs.put("previous_portrait", previousPortrait);
+        inputs.put("behavior_summary", context.get("behaviorSummary"));
+        inputs.put("profile_snapshot", context.get("profileSnapshot"));
+        
+        // 3. 调用 LangGraph portrait 生成 graph
+        // 端点:POST /api/portrait/generate (Python 侧)
+        Map<String, Object> resp = aiGateway.chat("", userId, null, inputs);
+        // aiGateway.chat 会路由到 portrait graph
+        // 这里需要专门的方法,或者扩展 aiGateway
+        
+        // 临时方案:直接 HTTP 调用 Python 服务
+        // TODO: 在 AiGateway 增加专用方法
+        String result = callPortraitGraph(userId, memberId, previousPortrait, context);
+        return result;
+    } catch (Exception e) {
+        log.error("AI 生成画像失败: userId={}, memberId={}", userId, memberId, e);
+        return null;
+    }
+}
+```
+
+> 注:`AiGateway` 目前通过 `chat` 方法统一路由。需要在 `AiGateway` 增加 `generatePortrait` 专用方法,或扩展 `chat` 支持 `portrait` 类型路由。
+
+- [ ] **Step 3: 在 AiGateway 增加 portrait 生成方法**
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/gateway/AiGateway.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/gateway/impl/AiGatewayImpl.java`
+
+```java
+// AiGateway.java
+String generatePortrait(Long userId, Long memberId, String previousPortrait, Map<String, Object> context);
+
+// AiGatewayImpl.java
+@Override
+public String generatePortrait(Long userId, Long memberId, String previousPortrait, Map<String, Object> context) {
+    Map<String, Object> inputs = new HashMap<>();
+    inputs.put("user_id", userId);
+    inputs.put("member_id", memberId);
+    inputs.put("previous_portrait", previousPortrait);
+    inputs.put("behavior_summary", context.get("behaviorSummary"));
+    inputs.put("profile_snapshot", context.get("profileSnapshot"));
+    // 专用 graph 类型标识
+    inputs.put("__graph_type", "portrait_generation");
+    
+    Map<String, Object> resp = webClient.post()
+        .uri(pythonBaseUrl + "/api/portrait/generate")
+        .bodyValue(inputs)
+        .retrieve()
+        .bodyToMono(Map.class)
+        .block();
+    // 解析返回的 portrait_text
+    return resp != null ? (String) resp.get("portrait_text") : null;
+}
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java \
+  cfc-backend/src/main/java/com/etotem/cfc/service/impl/AIServiceImpl.java \
+  cfc-backend/src/main/java/com/etotem/cfc/gateway/AiGateway.java \
+  cfc-backend/src/main/java/com/etotem/cfc/gateway/impl/AiGatewayImpl.java
+git commit -m "feat: AIService + AiGateway 新增 portrait 生成调用"
+```
+
+---
+
+### 任务 4:Java PortraitController 新增端点
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java`
+
+**Interfaces:**
+- Consumes: `PortraitService.generatePortrait`、`regeneratePortrait`
+
+- [ ] **Step 1: 新增 /generate 端点**
+
+```java
+@PostMapping("/generate")
+public Result<Map<String, Object>> generatePortrait(
+        @RequestAttribute("userId") Long userId,
+        @RequestBody Map<String, Object> params) {
+    Long memberId = params.get("memberId") != null
+            ? Long.valueOf(params.get("memberId").toString()) : null;
+    String portrait = portraitService.generatePortrait(userId, memberId);
+    Map<String, Object> result = new HashMap<>();
+    result.put("portraitPrompt", portrait);
+    // 同时返回快照供前端展示
+    if (memberId != null) {
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        result.put("renderedSnapshot", renderSnapshotFull(profile));
+    }
+    return Result.success(result);
+}
+```
+
+- [ ] **Step 2: 新增 /regenerate 端点**
+
+```java
+@PostMapping("/regenerate")
+public Result<Map<String, Object>> regeneratePortrait(
+        @RequestAttribute("userId") Long userId,
+        @RequestBody Map<String, Object> params) {
+    Long memberId = params.get("memberId") != null
+            ? Long.valueOf(params.get("memberId").toString()) : null;
+    String portrait = portraitService.regeneratePortrait(userId, memberId);
+    Map<String, Object> result = new HashMap<>();
+    result.put("portraitPrompt", portrait);
+    if (memberId != null) {
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        result.put("renderedSnapshot", renderSnapshotFull(profile));
+    }
+    return Result.success(result);
+}
+```
+
+- [ ] **Step 3: 复用 renderSnapshotFull(已存在于 impl 中,需提取为工具方法或在 controller 内复制)**
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java
+git commit -m "feat: PortraitController 新增 /generate /regenerate 端点"
+```
+
+---
+
+### 任务 5:Python JavaClient 新增 get_behavior_summary
+
+**Files:**
+- Modify: `cfc-langgraph/app/tools/java_client.py`
+
+- [ ] **Step 1: 新增 get_behavior_summary 方法**
+
+```python
+async def get_behavior_summary(self, user_id: int, member_id: int | None) -> dict:
+    """获取用户行为摘要(任务完成率、挑战参与、积分流水、健康报告等)"""
+    client = await self._get_client()
+    body = {"user_id": str(user_id)}
+    if member_id:
+        body["member_id"] = member_id
+    resp = await client.post("/api/ai/behavior/summary", json=body)
+    data = resp.json()
+    if data.get("code") == 200:
+        return data.get("data", {})
+    return {}
+```
+
+- [ ] **Step 2: py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/tools/java_client.py
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/tools/java_client.py
+git commit -m "feat: JavaClient 新增 get_behavior_summary 获取行为数据"
+```
+
+---
+
+### 任务 6:Python 后端新增 /api/ai/behavior/summary 端点
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` 或新增 `AIBehaviorController.java`
+
+> 复用 `AIChatController` 的 `familyContextService.buildContext()` 逻辑,提取行为摘要
+
+- [ ] **Step 1: 新增 AIBehaviorController**
+
+```java
+@RestController
+@RequestMapping("/api/ai/behavior")
+public class AIBehaviorController {
+    
+    @Resource
+    private FamilyContextService familyContextService;
+    
+    @PostMapping("/summary")
+    public Result<Map<String, Object>> getBehaviorSummary(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params.get("member_id") != null
+                ? Long.valueOf(params.get("member_id").toString()) : null;
+        Map<String, Object> context = familyContextService.buildContext(userId);
+        if (memberId != null) {
+            context.put("memberId", memberId);
+        }
+        // 提取行为相关字段
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("behaviorSummary", context.get("behaviorSummary"));
+        summary.put("profileSnapshot", context.get("profileSnapshot"));
+        summary.put("recentTasks", context.get("recentTasks"));
+        summary.put("recentChallenges", context.get("recentChallenges"));
+        summary.put("pointsHistory", context.get("pointsHistory"));
+        summary.put("healthReports", context.get("healthReports"));
+        return Result.success(summary);
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIBehaviorController.java
+git commit -m "feat: 新增 AIBehaviorController /behavior/summary 端点"
+```
+
+---
+
+### 任务 7:Python LangGraph portrait_generation_graph.py
+
+**Files:**
+- Create: `cfc-langgraph/app/graphs/portrait_generation_graph.py`
+
+**Interfaces:**
+- State: `PortraitGenerationState` { `user_id`, `member_id`, `previous_portrait`, `behavior_summary`, `profile_snapshot`, `portrait_text` }
+- Returns: `{ "portrait_text": "生成的画像文本" }`
+
+- [ ] **Step 1: 创建 graph 文件**
+
+```python
+"""用户画像生成/重画 LangGraph
+
+输入:
+- user_id: 用户ID
+- member_id: 成员ID(可空)
+- previous_portrait: 旧画像文本(重画时传入,首次生成为 None)
+- behavior_summary: 行为摘要(任务完成率、挑战、积分、健康报告等)
+- profile_snapshot: 五维画像快照(dimension_scores、body_metrics、mind_metrics、problem_domains)
+
+输出:
+- portrait_text: 生成的画像文本(中文,自然语言描述)
+"""
+from typing import TypedDict
+from langgraph.graph import StateGraph, START, END
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.java_client import JavaClient
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class PortraitGenerationState(TypedDict):
+    user_id: int
+    member_id: int | None
+    previous_portrait: str | None
+    behavior_summary: dict | None
+    profile_snapshot: dict | None
+    portrait_text: str | None
+
+
+SYSTEM_PROMPT = """你是「浠艾福」家庭成长平台的 AI 画像分析师。
+
+任务:根据用户的行为数据、五维能量指标、健康画像,生成一段自然语言的「用户画像」文本。
+
+画像用途:作为后续所有 AI 对话(健康教练、管家、营养师、通用聊天)的 SystemMessage 注入,帮助 AI 更懂用户,给出更个性化的建议。
+
+生成要求:
+1. 语言自然、温暖、具体,像一位了解家庭的成长顾问写的观察笔记
+2. 必须包含:核心特质、行为模式、关注点、潜在需求
+3. 长度:200-500 字,不要过长
+4. 不要列举原始数据(如"身 7.2 智 6.5"),要转化为洞察(如"身体维度相对突出,但智力维度有提升空间")
+5. 如有 previous_portrait,重画时要体现"变化"与"延续":哪些特质稳定,哪些有新变化,原因可能是什么
+
+输出格式:纯文本,无 JSON,无标记。"""
+
+
+def create_portrait_generation_graph():
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.7,  # 稍高温度增加多样性
+    )
+    java = JavaClient()
+
+    builder = StateGraph(PortraitGenerationState)
+
+    async def fetch_data(state: PortraitGenerationState) -> dict:
+        """并行拉取行为摘要 + 画像快照"""
+        user_id = state["user_id"]
+        member_id = state.get("member_id")
+        
+        # 并行获取
+        behavior_task = java.get_behavior_summary(user_id, member_id)
+        profile_task = java.get_member_profile(member_id) if member_id else None
+        
+        behavior = await behavior_task
+        profile = await profile_task if profile_task else {}
+        
+        return {
+            "behavior_summary": behavior,
+            "profile_snapshot": profile,
+        }
+
+    async def generate_portrait(state: PortraitGenerationState) -> dict:
+        """LLM 生成画像"""
+        previous = state.get("previous_portrait")
+        behavior = state.get("behavior_summary") or {}
+        profile = state.get("profile_snapshot") or {}
+        
+        # 构建用户画像上下文
+        context_parts = []
+        
+        if profile:
+            member = profile.get("member") or {}
+            name = member.get("name", "用户")
+            age = member.get("age")
+            gender = member.get("gender")
+            context_parts.append(f"用户:{name},{age}岁,{gender}")
+            
+            dims = profile.get("dimension_scores") or {}
+            if dims:
+                context_parts.append(
+                    f"五维评分:身 {dims.get('body','?')} 智 {dims.get('wisdom','?')} "
+                    f"心 {dims.get('mind','?')} 行 {dims.get('action','?')} 富 {dims.get('wealth','?')}"
+                )
+            
+            body = profile.get("body_metrics") or {}
+            if body.get("sleep_dur_avg"):
+                context_parts.append(f"平均睡眠:{body['sleep_dur_avg']}h/天")
+            if body.get("exercise_count_week"):
+                context_parts.append(f"周运动:{body['exercise_count_week']}次")
+            
+            mind = profile.get("mind_metrics") or {}
+            if mind.get("stress_avg"):
+                context_parts.append(f"平均压力:{mind['stress_avg']}/10")
+            
+            problems = profile.get("problem_domains") or []
+            if problems:
+                context_parts.append(f"关注问题:{', '.join(problems[:5])}")
+        
+        if behavior:
+            context_parts.append("行为摘要:" + str(behavior))
+        
+        context_text = "\n".join(context_parts)
+        
+        # 构建 HumanMessage
+        if previous:
+            human_content = f"""【重画任务】请基于以下最新数据,重新生成用户画像。
+
+【原画像(参考)】:
+{previous}
+
+【最新数据】:
+{context_text}
+
+要求:保留原画像中仍有效的核心特质,更新已变化的部分,体现"变化与延续"。"""
+        else:
+            human_content = f"""【生成任务】请基于以下数据,生成用户画像。
+
+【数据】:
+{context_text}"""
+        
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=human_content),
+        ]
+        
+        response = await llm.ainvoke(messages)
+        portrait_text = response.content.strip()
+        
+        logger.info("生成画像完成: user_id=%d, member_id=%s, len=%d",
+                   state["user_id"], state.get("member_id"), len(portrait_text))
+        
+        return {"portrait_text": portrait_text}
+
+    builder.add_node("fetch_data", fetch_data)
+    builder.add_node("generate_portrait", generate_portrait)
+    
+    builder.add_edge(START, "fetch_data")
+    builder.add_edge("fetch_data", "generate_portrait")
+    builder.add_edge("generate_portrait", END)
+    
+    return builder.compile()
+
+
+# 单例
+_portrait_graph = None
+
+def get_portrait_graph():
+    global _portrait_graph
+    if _portrait_graph is None:
+        _portrait_graph = create_portrait_generation_graph()
+    return _portrait_graph
+```
+
+- [ ] **Step 2: py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/graphs/portrait_generation_graph.py
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/graphs/portrait_generation_graph.py
+git commit -m "feat: 新增 portrait_generation_graph.py AI 生成/重画用户画像"
+```
+
+---
+
+### 任务 8:Python FastAPI portrait 路由
+
+**Files:**
+- Create: `cfc-langgraph/app/api/portrait.py`
+- Modify: `cfc-langgraph/app/main.py`
+
+**Interfaces:**
+- `POST /api/portrait/generate` → `{ "portrait_text": "..." }`
+- `POST /api/portrait/regenerate` → `{ "portrait_text": "..." }`(同一实现,区别在于是否传 previous_portrait)
+
+- [ ] **Step 1: 创建 api/portrait.py**
+
+```python
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from app.graphs.portrait_generation_graph import get_portrait_graph
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/portrait", tags=["portrait"])
+
+
+class PortraitGenerateRequest(BaseModel):
+    user_id: int
+    member_id: int | None = None
+    previous_portrait: str | None = None
+    behavior_summary: dict | None = None
+    profile_snapshot: dict | None = None
+
+
+class PortraitGenerateResponse(BaseModel):
+    portrait_text: str
+
+
+@router.post("/generate", response_model=PortraitGenerateResponse)
+async def generate_portrait(req: PortraitGenerateRequest):
+    """AI 生成用户画像(首次生成)"""
+    try:
+        graph = get_portrait_graph()
+        state = {
+            "user_id": req.user_id,
+            "member_id": req.member_id,
+            "previous_portrait": None,
+            "behavior_summary": req.behavior_summary,
+            "profile_snapshot": req.profile_snapshot,
+            "portrait_text": None,
+        }
+        result = await graph.ainvoke(state)
+        return PortraitGenerateResponse(portrait_text=result["portrait_text"])
+    except Exception as e:
+        logger.exception("画像生成失败")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/regenerate", response_model=PortraitGenerateResponse)
+async def regenerate_portrait(req: PortraitGenerateRequest):
+    """AI 重画用户画像(参考旧画像)"""
+    try:
+        graph = get_portrait_graph()
+        state = {
+            "user_id": req.user_id,
+            "member_id": req.member_id,
+            "previous_portrait": req.previous_portrait,
+            "behavior_summary": req.behavior_summary,
+            "profile_snapshot": req.profile_snapshot,
+            "portrait_text": None,
+        }
+        result = await graph.ainvoke(state)
+        return PortraitGenerateResponse(portrait_text=result["portrait_text"])
+    except Exception as e:
+        logger.exception("画像重画失败")
+        raise HTTPException(status_code=500, detail=str(e))
+```
+
+- [ ] **Step 2: 注册路由到 main.py**
+
+```python
+# main.py 第 30 行附近
+from app.api import portrait
+...
+app.include_router(portrait.router)
+```
+
+- [ ] **Step 3: py_compile 验证**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-langgraph/app/api/portrait.py cfc-langgraph/app/main.py
+git commit -m "feat: Python 侧新增 portrait 生成/重画 API 端点"
+```
+
+---
+
+### 任务 9:小程序前端 API 封装
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`
+
+- [ ] **Step 1: 新增封装**
+
+```javascript
+// 用户画像
+export function getPortrait(params = {}) {
+  return request('/api/user/portrait/get', 'POST', params)
+}
+export function editPortrait(data) {
+  return request('/api/user/portrait/edit', 'POST', data)
+}
+export function generatePortrait(params = {}) {
+  return request('/api/user/portrait/generate', 'POST', params)
+}
+export function regeneratePortrait(params = {}) {
+  return request('/api/user/portrait/regenerate', 'POST', params)
+}
+```
+
+- [ ] **Step 2: 语法检查**
+
+```bash
+node --check <(sed -n '/<script>/,/<\/script>/p' cfc-frontend/utils/api.js | sed '1d;$d')
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-frontend/utils/api.js
+git commit -m "feat: api.js 新增 generatePortrait/regeneratePortrait 封装"
+```
+
+---
+
+### 任务 10:小程序 portrait-edit.vue 界面升级
+
+**Files:**
+- Modify: `cfc-frontend/pages/profile/portrait-edit.vue`
+
+- [ ] **Step 1: 升级 template(增加生成/重画按钮、加载态)**
+
+```vue
+<template>
+  <view class="portrait-edit-page">
+    <view class="section">
+      <view class="section-title">我的画像描述</view>
+      <view class="hint">请输入你的个人画像描述,AI 会根据这些信息提供更有针对性的建议(最多 2000 字)</view>
+      
+      <!-- 操作按钮行 -->
+      <view class="action-row">
+        <button class="action-btn" :loading="generating" @click="onGenerate" v-if="!portraitPrompt">
+          🪄 AI 生成画像
+        </button>
+        <button class="action-btn secondary" :loading="regenerating" @click="onRegenerate" v-if="portraitPrompt">
+          🔄 重画画像
+        </button>
+      </view>
+      
+      <textarea
+        class="portrait-textarea"
+        :value="portraitPrompt"
+        placeholder="例如:我家孩子 8 岁,偏瘦,挑食,容易积食,睡眠不足..."
+        maxlength="2000"
+        @input="onInput"
+      ></textarea>
+      <view class="char-count">{{ portraitPrompt.length }}/2000</view>
+    </view>
+
+    <view class="section" v-if="renderedSnapshot">
+      <view class="section-title">参考指标(自动生成)</view>
+      <view class="snapshot-text">{{ renderedSnapshot }}</view>
+    </view>
+
+    <button class="save-btn" :loading="saving" @click="onSave">保存</button>
+  </view>
+</template>
+```
+
+- [ ] **Step 2: 升级 script(新增 generate/regenerate 方法)**
+
+```javascript
+import { getPortrait, editPortrait, generatePortrait, regeneratePortrait } from '../../utils/api.js'
+export default {
+  data() {
+    return {
+      portraitPrompt: '',
+      renderedSnapshot: '',
+      saving: false,
+      generating: false,
+      regenerating: false
+    }
+  },
+  onLoad() {
+    this.loadPortrait()
+  },
+  methods: {
+    async loadPortrait() {
+      try {
+        const res = await getPortrait({ memberId: this.$store.state.currentMemberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+        }
+      } catch (e) {
+        console.error('加载画像失败', e)
+      }
+    },
+    onInput(e) {
+      this.portraitPrompt = e.detail.value
+    },
+    async onGenerate() {
+      this.generating = true
+      try {
+        const memberId = this.$store.state.currentMemberId
+        const res = await generatePortrait({ memberId })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+          uni.showToast({ title: '生成成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        this.generating = false
+      }
+    },
+    async onRegenerate() {
+      this.regenerating = true
+      try {
+        const memberId = this.$store.state.currentMemberId
+        const res = await regeneratePortrait({ 
+          memberId,
+          previousPortrait: this.portraitPrompt  // 传入当前画像作为参考
+        })
+        if (res.code === 200 && res.data) {
+          this.portraitPrompt = res.data.portraitPrompt || ''
+          this.renderedSnapshot = res.data.renderedSnapshot || ''
+          uni.showToast({ title: '重画成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '重画失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '重画失败', icon: 'none' })
+      } finally {
+        this.regenerating = false
+      }
+    },
+    async onSave() {
+      this.saving = true
+      try {
+        const res = await editPortrait({ portraitPrompt: this.portraitPrompt })
+        if (res.code === 200) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+          this.loadPortrait()
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+```
+
+- [ ] **Step 3: 增加样式**
+
+```css
+.action-row {
+  display: flex;
+  gap: 16rpx;
+  margin-bottom: 16rpx;
+}
+.action-btn {
+  flex: 1;
+  height: 72rpx;
+  border-radius: 36rpx;
+  font-size: 26rpx;
+  background: linear-gradient(135deg, #667eea, #764ba2);
+  color: #fff;
+}
+.action-btn.secondary {
+  background: linear-gradient(135deg, #F97316, #F59E0B);
+}
+```
+
+- [ ] **Step 4: 语法检查**
+
+```bash
+python -c "import re; f=open(r'D:\cfc\cfc-frontend\pages\profile\portrait-edit.vue','r',encoding='utf-8'); c=f.read(); f.close(); m=re.search(r'<script>(.*?)</script>',c,re.DOTALL); open(r'D:\tmp\portrait_check.mjs','w',encoding='utf-8').write(m.group(1))"
+node --check D:\tmp\portrait_check.mjs
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-frontend/pages/profile/portrait-edit.vue
+git commit -m "feat: portrait-edit.vue 新增 AI 生成/重画按钮与逻辑"
+```
+
+---
+
+### 任务 11:全量验收
+
+- [ ] **Step 1: 后端编译**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+```
+
+- [ ] **Step 2: Python 编译**
+
+```bash
+cd cfc-langgraph && python3 -m py_compile app/api/portrait.py app/graphs/portrait_generation_graph.py app/tools/java_client.py
+```
+
+- [ ] **Step 3: 前端语法检查**
+
+```bash
+# portrait-edit.vue
+python -c "import re; f=open(r'D:\cfc\cfc-frontend\pages\profile\portrait-edit.vue','r',encoding='utf-8'); c=f.read(); f.close(); m=re.search(r'<script>(.*?)</script>',c,re.DOTALL); open(r'D:\tmp\pe.mjs','w',encoding='utf-8').write(m.group(1))"
+node --check D:\tmp\pe.mjs
+
+# api.js
+node --check <(sed -n '/<script>/,/<\/script>/p' cfc-frontend/utils/api.js | sed '1d;$d')
+```
+
+- [ ] **Step 4: 本地联调(需启动后端 9082 + LangGraph 9000)**
+
+1. 调用 `POST /api/user/portrait/generate` → 验证返回 portraitPrompt
+2. 调用 `POST /api/user/portrait/regenerate` → 验证返回新画像
+3. 小程序页面点击「生成画像」「重画」→ 验证 UI 正常
+4. 检查 Python 日志确认 portrait_generation_graph 被调用
+
+- [ ] **Step 5: 提交剩余改动**
+
+```bash
+git add -A
+git commit -m "feat: 用户画像 AI 生成/重画全栈实现"
+git push
+```
+
+---
+
+## 自检记录
+
+1. **规格覆盖度**:
+   - AI 自动生成 → 任务 1-8
+   - 重画(参考旧画像) → 任务 2(regeneratePortrait)、7(previous_portrait 逻辑)
+   - 用户可编辑 → 已有 editPortrait,保留
+   - 前端入口 → 任务 10
+2. **类型一致性**:
+   - Java: `PortraitService` 接口 → impl → controller → AIService → AiGateway
+   - Python: `PortraitGenerateRequest` Pydantic 模型与 Java 请求体对应
+   - 前端: `generatePortrait({memberId})`、`regeneratePortrait({memberId, previousPortrait})` 与后端端点对应
+3. **边界处理**:
+   - 无行为数据时:graph 仍能基于 profile_snapshot 生成
+   - AI 失败:降级为快照渲染(`renderSnapshot`),保留原画像
+   - memberId 为空:仅基于用户自身行为生成
+   - 并发安全:AI 调用无副作用,幂等
+
+---
+
+**Plan 完整,保存至 `docs/superpowers/plans/2026-09-13-user-portrait-ai-generation.md`。**
+
+**Two execution options:**
+
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
+
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
+
+**Which approach?**

+ 162 - 0
docs/superpowers/plans/2026-09-17-product-recommendation-rule-engine-plan.md

@@ -0,0 +1,162 @@
+# 商品推荐规则引擎 — 实现计划
+
+> 基于 `docs/superpowers/specs/2026-09-17-product-recommendation-rule-engine-design.md`
+
+## 阶段 0:数据库迁移
+
+**目标**:创建 `recommendation_rules` 表,同步 schema.sql
+
+**步骤:**
+1. 在 `cfc-backend/src/main/resources/schema.sql` 末尾追加 `CREATE TABLE recommendation_rules (...)` 定义(含索引)
+2. 在 `DatabaseInitializer.java` 末尾的 `runMigrations()` 中添加新迁移(迁移编号递增,当前最大编号搜索确认)
+3. 执行 `mvn clean compile` 验证无编译错误
+
+**文件:**
+- `cfc-backend/src/main/resources/schema.sql`
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+---
+
+## 阶段 1:新实体 + Mapper + Service
+
+**目标**:基础 CRUD 能力
+
+**步骤:**
+1. 新建 `entity/RecommendationRule.java`(对应 recommendation_rules 表,字段见设计文档 3.1)
+2. 新建 `mapper/RecommendationRuleMapper.java`(使用 MyBatis-Plus,提供按 scene、rule_type、enabled 查询方法)
+3. 新建 `service/RecommendationRuleService.java`
+   - `listRules(sceneCode, page, size)` — 分页列表
+   - `getRuleById(id)` — 详情
+   - `createRule(rule)` — 创建,含参数校验(rule_type 1-5、priority>0、trigger/target 非空等)
+   - `updateRule(id, rule)` — 更新
+   - `deleteRule(id)` — 删除(设 enabled=0,软停用)
+   - `batchUpdateEnabled(ids, enabled)` — 批量启用/停用
+4. 新建 `controller/recommendation/RecommendationRuleAdminController.java`
+   - `POST /api/admin/recommendation-rules/list`
+   - `POST /api/admin/recommendation-rules/create`
+   - `POST /api/admin/recommendation-rules/update`
+   - `POST /api/admin/recommendation-rules/delete`
+   - `POST /api/admin/recommendation-rules/batch-enable`
+5. 运行 `mvn clean compile` 验证
+
+---
+
+## 阶段 2:推荐引擎核心实现
+
+**目标**:`RecommendationRuleEngine.recommend(ctx, limit)` 完成 5 种规则计算
+
+**步骤:**
+1. 新建 `service/RecommendationRuleEngine.java`(实现 `RecommendationRuleEngine` 接口)
+
+2. 内部方法设计:
+   - `loadRules(String sceneCode)` — 加载匹配的启用规则,按 priority DESC
+   - `queryPurchasedProducts(RecommendationContext ctx)` — 查购买记录,返回 `Map<Long, Date>`(productId → 最早购买日期)
+   - `buildBaseCandidates(RecommendationContext ctx)` — 按场景构建候选池(保留现有维度页推荐逻辑)
+   - `applyRules(List<RecommendationRule> rules, Set<Long> purchasedIds, List<BaseCandidate> candidates)` — 应用所有规则
+     - 先处理规则4(排除类,从高 priority 开始)
+     - 再处理规则1(排除类)
+     - 再处理规则3(推荐类)
+     - 再处理规则5(推荐类 + boost)
+     - 再处理规则2(定时复购类)
+   - `sortAndTruncate(List<BaseCandidate> candidates, int limit)` — 排序截断
+
+3. 保留 `ProductRecommendationService.getDimensionRecommendations` 现有逻辑作为 `buildBaseCandidates` 的基础实现,但不再直接调用 `getPurchasedProductIds`,改由引擎统一处理
+
+4. 在 `ProductRecommendationController.getDimensionProducts` 中:
+   - 保留 Controller 接口签名不变
+   - 内部改为调用 `engine.recommend(ctx, limit)`,将 `RecommendedProduct` 映射为现有 `Map<String, Object>` 格式返回
+
+5. 运行 `mvn clean compile` 验证
+
+---
+
+## 阶段 3:RepurchaseReminderService 改造
+
+**目标**:规则2 配置从 `recommendation_rules` 读取,旧 `repurchase_reminder_config` 不再被代码引用
+
+**步骤:**
+1. 修改 `RepurchaseReminderService.scanAndCreateReminders()`
+   - 旧逻辑:读 `repurchase_reminder_config` 表
+   - 新逻辑:读 `recommendation_rules` 表中 `rule_type=2 AND enabled=1` 的规则
+   - 映射关系:
+     - `trigger_product_id` → 触发商品
+     - `delay_days` → 复购延迟天数
+     - `target_product_id` → 复购提醒的商品(与 trigger 相同或不同均可)
+     - `max_reminders` → `recommendation_rules` 表暂无此字段,暂时复用 `repurchase_reminder_record` 中的已发送次数判断
+2. 保留定时任务 `@Scheduled(cron = "0 0 9 * * ?")`,不修改触发时间
+3. 运行 `mvn clean compile` 验证
+
+---
+
+## 阶段 4:新增推荐场景接口(可选)
+
+**目标**:商城首页、商品详情页、下单成功页的推荐接口
+
+**步骤:**
+1. 新增 `POST /api/recommend/mall-home`
+   - 入参:`{ "limit": 10 }`
+   - 内部:`engine.recommend(ctx, limit)` 其中 `scene="mall_home"`
+2. 新增 `POST /api/recommend/product-detail`
+   - 入参:`{ "productId": 123, "limit": 6 }`
+   - 内部:`engine.recommend(ctx, limit)` 其中 `scene="product_detail"`, `currentProductId=123`
+3. 新增 `POST /api/recommend/after-order`
+   - 入参:`{ "orderId": 456, "limit": 6 }`
+   - 内部:`engine.recommend(ctx, limit)` 其中 `scene="after_order"`
+4. 修改 `ProductRecommendationService.getReportRelatedProducts`
+   - 内部改为调用 `engine.recommend(ctx, limit)` 其中 `scene="report_related"`
+5. 运行 `mvn clean compile` 验证
+
+---
+
+## 阶段 5:前端管理端页面
+
+**目标**:cfc-web 新增「商品推荐规则」管理页面
+
+**步骤:**
+1. 在 `cfc-web/src/views/` 下新建 `RecommendationRules.vue`
+   - 页面标题:商品推荐规则
+   - 功能:列表展示 + 新建/编辑弹窗 + 启用/停用开关
+   - 字段:rule_type(类型下拉)、rule_name、trigger_product_id、target_product_id、priority、enabled(开关)、scene_codes(多选)
+2. 在 `cfc-web/src/router/` 下新增路由 `/admin/recommendation-rules`
+3. 在 `cfc-web/src/api/` 下封装推荐规则接口
+4. 运行 `npm run build` 验证编译通过
+
+---
+
+## 阶段 6:单元测试与集成验证
+
+**目标**:规则计算正确,降级场景正常
+
+**步骤:**
+1. 编写 `ProductRecommendationEngineTest`(或 `RecommendationRuleEngineTest`)
+   - 测试5种规则各自命中场景
+   - 测试 priority 排序
+   - 测试 scope family vs individual
+   - 测试 time_window_days 边界
+   - 测试降级:规则表为空时仍能返回候选(退化为全局已购排除)
+2. 运行 `mvn clean test`
+3. 运行 `mvn clean compile` 最终验证
+4. 如有运行时条件,在测试库 `192.168.16.251:3306` 执行迁移,验证接口返回正常
+
+---
+
+## 变更文件清单
+
+| 新增文件 | 说明 |
+|---------|------|
+| `cfc-backend/.../entity/RecommendationRule.java` | 新实体 |
+| `cfc-backend/.../mapper/RecommendationRuleMapper.java` | Mapper |
+| `cfc-backend/.../service/RecommendationRuleService.java` | CRUD Service |
+| `cfc-backend/.../service/RecommendationRuleEngine.java` | 引擎核心 |
+| `cfc-backend/.../controller/recommendation/RecommendationRuleAdminController.java` | 管理端 Controller |
+| `cfc-web/src/views/RecommendationRules.vue` | 管理端页面 |
+| `cfc-web/src/api/recommendationRule.js` | 管理端 API 封装 |
+
+| 修改文件 | 说明 |
+|---------|------|
+| `cfc-backend/.../service/ProductRecommendationService.java` | 改为调用引擎 |
+| `cfc-backend/.../service/RepurchaseReminderService.java` | 规则2 配置改读 recommendation_rules |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 新增 DDL 迁移 |
+| `cfc-backend/src/main/resources/schema.sql` | 新增 recommendation_rules 表定义 |
+| `cfc-web/src/router/index.js` | 新增路由 |
+| `docs/superpowers/api/API_REFERENCE.md` | 同步新增接口文档 |

+ 973 - 0
docs/superpowers/plans/2026-09-19-article-reading-quiz.md

@@ -0,0 +1,973 @@
+# 文章阅读后答题奖励 + 认知雷达记录 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 补全「阅读达标 → AI 出题 → 作答 → CF值+五维能量双奖励 → 认知雷达记录」链路,将答题结果持久化并写入 `profile_snapshot.quiz_summary`。
+
+**架构:** 前端阅读达标后调 `/quiz/generate`(后端经 AiGateway 调 LangGraph 出题,失败降级硬编码题,落库题目快照返回 recordId),作答后调 `/quiz/submit`(后端按 recordId 读库判分,发放 CF 值 + 五维能量,更新认知雷达)。AI 出题走 LangGraph 新 graph `article_quiz_graph`。
+
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus(后端)/ FastAPI + LangGraph + ChatOpenAI(Python 侧)/ uni-app Vue 2(前端)
+
+---
+
+## 文件结构(本计划涉及)
+
+### 后端 cfc-backend
+| 文件 | 动作 | 职责 |
+|------|------|------|
+| `src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 修改 | 迁移 299 建 `article_quiz_records` 表、迁移 300 加 `quiz_summary` 列 |
+| `src/main/resources/schema.sql` | 修改 | 同步建表语句 + profile_snapshot 列 |
+| `src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java` | 修改 | 字段调整:`childId`→`memberId`、`pointsEarned`→`cfEarned`,新增 `totalQuestions`/`energyEarned` |
+| `src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java` | 修改 | 新增 `quizSummary` 字段 |
+| `src/main/java/com/etotem/cfc/service/ArticleService.java` | 修改 | 修复 `getAiQuestions`/`submitAnswers` 对旧字段名的引用 |
+| `src/main/java/com/etotem/cfc/service/AiGateway.java` | 修改 | 新增 `generateArticleQuiz` 方法 |
+| `src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java` | 修改 | 重写 `generateQuiz`/`submitQuiz`,新增 `updateQuizSummary` 辅助方法 |
+
+### Python cfc-langgraph
+| 文件 | 动作 | 职责 |
+|------|------|------|
+| `app/graphs/article_quiz_graph.py` | 创建 | AI 出题 agent |
+| `app/models/article_quiz.py` | 创建 | Pydantic 请求/响应模型 |
+| `app/api/article_quiz.py` | 创建 | FastAPI 路由 |
+| `app/prompt_service.py` | 修改 | DEFAULT_PROMPTS 增加 `article_quiz` |
+| `app/main.py` | 修改 | 注册路由 |
+
+### 前端 cfc-frontend
+| 文件 | 动作 | 职责 |
+|------|------|------|
+| `pages/article-center/article-detail.vue` | 修改 | 适配新接口结构、结果弹窗增加 CF 展示 |
+
+---
+
+## 任务 1:数据库迁移 + 实体字段调整
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
+
+- [ ] **步骤 1:在 `DatabaseInitializer.java` 的 `runMigrations()` 末尾追加两个迁移**
+
+在 `runMigrations()` 方法体末尾(`profile_history` 表创建块之后)追加:
+
+```java
+        // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "article_id BIGINT DEFAULT NULL, " +
+                    "user_id BIGINT DEFAULT NULL, " +
+                    "member_id BIGINT DEFAULT NULL, " +
+                    "questions TEXT, " +
+                    "answers TEXT, " +
+                    "score INT DEFAULT 0, " +
+                    "total_questions INT DEFAULT 0, " +
+                    "cf_earned INT DEFAULT 0, " +
+                    "energy_earned INT DEFAULT 0, " +
+                    "created_at DATETIME, " +
+                    "updated_at DATETIME, " +
+                    "INDEX idx_member (member_id), " +
+                    "INDEX idx_article (article_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'");
+            log.info("已创建article_quiz_records表");
+        } catch (Exception e) {
+            log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移300: profile_snapshot 增加 quiz_summary 列(认知雷达答题汇总)
+        ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'");
+```
+
+- [ ] **步骤 2:同步 `schema.sql`**
+
+在 `schema.sql` 中 `article_reading_records` 建表语句(约 4070 行)之后追加:
+
+```sql
+-- 文章答题记录表
+CREATE TABLE IF NOT EXISTS article_quiz_records (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  article_id BIGINT DEFAULT NULL COMMENT '文章ID',
+  user_id BIGINT DEFAULT NULL COMMENT '用户ID',
+  member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)',
+  questions TEXT COMMENT '题目快照JSON(含正确答案与维度)',
+  answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL',
+  score INT DEFAULT 0 COMMENT '答对题数',
+  total_questions INT DEFAULT 0 COMMENT '题目总数',
+  cf_earned INT DEFAULT 0 COMMENT '发放的CF值',
+  energy_earned INT DEFAULT 0 COMMENT '发放的能量值',
+  created_at DATETIME,
+  updated_at DATETIME,
+  INDEX idx_member (member_id),
+  INDEX idx_article (article_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表';
+```
+
+并在 `profile_snapshot` 建表语句(约 5404 行)的 `problem_domains` 行之后、`computed_at` 行之前追加一列:
+
+```sql
+    quiz_summary JSON DEFAULT NULL COMMENT '答题认知雷达汇总',
+```
+
+- [ ] **步骤 3:调整 `ArticleQuizRecord.java` 实体**
+
+将 `childId`/`pointsEarned` 字段替换为 `memberId`/`totalQuestions`/`cfEarned`/`energyEarned`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.util.Date;
+
+@TableName("article_quiz_records")
+public class ArticleQuizRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long articleId;
+    private Long userId;
+    private Long memberId;
+    private String questions;
+    private String answers;
+    private Integer score;
+    private Integer totalQuestions;
+    private Integer cfEarned;
+    private Integer energyEarned;
+    private Date createdAt;
+    private Date updatedAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getArticleId() { return articleId; }
+    public void setArticleId(Long articleId) { this.articleId = articleId; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public String getQuestions() { return questions; }
+    public void setQuestions(String questions) { this.questions = questions; }
+    public String getAnswers() { return answers; }
+    public void setAnswers(String answers) { this.answers = answers; }
+    public Integer getScore() { return score; }
+    public void setScore(Integer score) { this.score = score; }
+    public Integer getTotalQuestions() { return totalQuestions; }
+    public void setTotalQuestions(Integer totalQuestions) { this.totalQuestions = totalQuestions; }
+    public Integer getCfEarned() { return cfEarned; }
+    public void setCfEarned(Integer cfEarned) { this.cfEarned = cfEarned; }
+    public Integer getEnergyEarned() { return energyEarned; }
+    public void setEnergyEarned(Integer energyEarned) { this.energyEarned = energyEarned; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}
+```
+
+- [ ] **步骤 4:调整 `ProfileSnapshot.java` 实体,新增 `quizSummary` 字段**
+
+在 `problemDomains` 字段之后追加:
+
+```java
+    private String quizSummary;
+```
+
+并追加 getter/setter:
+
+```java
+    public String getQuizSummary() { return quizSummary; }
+    public void setQuizSummary(String quizSummary) { this.quizSummary = quizSummary; }
+```
+
+- [ ] **步骤 5:修复 `ArticleService.java` 中 `getAiQuestions`/`submitAnswers` 对旧字段名的引用**
+
+`getAiQuestions`(约 797 行)中:
+
+```java
+        record.setChildId(memberId);      // 改为
+        record.setMemberId(memberId);
+```
+
+```java
+        record.setPointsEarned(0);        // 改为
+        record.setCfEarned(0);
+```
+
+`submitAnswers`(约 868 行)中:
+
+```java
+        record.setPointsEarned(pointsEarned);   // 改为
+        record.setCfEarned(pointsEarned);
+```
+
+- [ ] **步骤 6:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS,无编译错误。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git commit -m "feat: 文章答题记录表迁移与实体字段调整"
+```
+
+---
+
+## 任务 2:LangGraph AI 出题 graph
+
+**文件:**
+- 创建:`cfc-langgraph/app/graphs/article_quiz_graph.py`
+
+- [ ] **步骤 1:创建 `article_quiz_graph.py`**
+
+```python
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.config import settings
+from app.monitoring import monitor_agent
+from app.prompt_service import get_prompt
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
+
+要求:
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
+2. 题目考察文章核心内容的理解,适合青少年读者
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
+
+输出格式:
+[
+  {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
+]
+"""
+
+
+class ArticleQuizAgent:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=settings.llm_temperature,
+        )
+
+    @monitor_agent("article_quiz")
+    async def run(self, article_title: str, article_content: str, profile_summary: str = "") -> dict:
+        """根据文章内容生成 3 道选择题,失败返回空 questions"""
+        try:
+            human = f"文章标题:{article_title}\n文章内容:{article_content}"
+            if profile_summary:
+                human += f"\n用户画像摘要:{profile_summary}"
+            messages = [
+                SystemMessage(content=await get_prompt("article_quiz") or SYSTEM_PROMPT),
+                HumanMessage(content=human),
+            ]
+            response = await self.llm.ainvoke(messages)
+            content = response.content.strip()
+            if "```json" in content:
+                content = content.split("```json")[1].split("```")[0].strip()
+            elif "```" in content:
+                content = content.split("```")[1].split("```")[0].strip()
+            questions = json.loads(content)
+            if not isinstance(questions, list):
+                return {"questions": [], "fallback_used": True}
+            return {"questions": questions, "fallback_used": False}
+        except Exception as e:
+            logger.warning("文章出题生成失败: %s", e)
+            return {"questions": [], "fallback_used": True}
+```
+
+- [ ] **步骤 2:语法校验**
+
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py`
+预期:无输出,退出码 0。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-langgraph/app/graphs/article_quiz_graph.py
+git commit -m "feat: 新增文章答题出题 graph"
+```
+
+---
+
+## 任务 3:LangGraph 模型 + 路由 + prompt 注册
+
+**文件:**
+- 创建:`cfc-langgraph/app/models/article_quiz.py`
+- 创建:`cfc-langgraph/app/api/article_quiz.py`
+- 修改:`cfc-langgraph/app/prompt_service.py`
+- 修改:`cfc-langgraph/app/main.py`
+
+- [ ] **步骤 1:创建 `app/models/article_quiz.py`**
+
+```python
+from pydantic import BaseModel
+from typing import List, Dict, Any
+
+
+class ArticleQuizRequest(BaseModel):
+    article_title: str
+    article_content: str = ""
+    profile_summary: str = ""
+
+
+class ArticleQuizResponse(BaseModel):
+    questions: List[Dict[str, Any]] = []
+    fallback_used: bool = True
+```
+
+- [ ] **步骤 2:创建 `app/api/article_quiz.py`**
+
+```python
+from fastapi import APIRouter
+from app.models.article_quiz import ArticleQuizRequest, ArticleQuizResponse
+from app.graphs.article_quiz_graph import ArticleQuizAgent
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["article_quiz"])
+
+_agent = None
+
+
+def get_agent() -> ArticleQuizAgent:
+    global _agent
+    if _agent is None:
+        _agent = ArticleQuizAgent()
+    return _agent
+
+
+@router.post("/article/quiz/generate", response_model=ArticleQuizResponse)
+async def generate_article_quiz(req: ArticleQuizRequest):
+    agent = get_agent()
+    result = await agent.run(req.article_title, req.article_content, req.profile_summary)
+    return ArticleQuizResponse(questions=result.get("questions", []),
+                               fallback_used=result.get("fallback_used", True))
+```
+
+- [ ] **步骤 3:在 `prompt_service.py` 的 `DEFAULT_PROMPTS` 增加 `article_quiz` 条目**
+
+在 `"recommend"` 条目(约 84 行结尾)之后、`}` 之前追加:
+
+```python
+    "article_quiz": """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
+
+要求:
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
+2. 题目考察文章核心内容的理解,适合青少年读者
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
+
+输出格式:
+[
+  {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
+]
+""",
+```
+
+- [ ] **步骤 4:在 `main.py` 注册路由**
+
+第 6 行 import 增加 `article_quiz`:
+
+```python
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz
+```
+
+在 `app.include_router(portrait.router)`(约 34 行)之后追加:
+
+```python
+app.include_router(article_quiz.router)
+```
+
+- [ ] **步骤 5:语法校验**
+
+运行:`cd cfc-langgraph && python -m py_compile app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py`
+预期:无输出,退出码 0。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-langgraph/app/models/article_quiz.py cfc-langgraph/app/api/article_quiz.py cfc-langgraph/app/prompt_service.py cfc-langgraph/app/main.py
+git commit -m "feat: 注册文章答题出题路由与提示词"
+```
+
+---
+
+## 任务 4:AiGateway 新增 `generateArticleQuiz` 方法
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
+
+- [ ] **步骤 1:在 `AiGateway.java` 末尾(`analyzeTongue` 方法之后、类右括号之前)新增方法**
+
+```java
+    /**
+     * 生成文章阅读答题(调用 LangGraph article_quiz_graph)
+     * @return 题目列表(每项含 question/options/answer/dimension/explanation);失败返回 null
+     */
+    public List<Map<String, Object>> generateArticleQuiz(String articleTitle, String articleContent, String profileSummary) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("article_title", articleTitle != null ? articleTitle : "");
+            body.put("article_content", articleContent != null ? articleContent : "");
+            body.put("profile_summary", profileSummary != null ? profileSummary : "");
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/article/quiz/generate";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode questions = root.get("questions");
+                if (questions != null && questions.isArray() && questions.size() > 0) {
+                    List<Map<String, Object>> list = objectMapper.convertValue(questions, List.class);
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway generateArticleQuiz 成功: {} 题", list.size());
+                    return list;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateArticleQuiz 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+```
+
+> 该方法使用的 `ObjectNode`/`JsonNode`/`HttpEntity`/`ResponseEntity`/`List`/`Map` 均已在文件顶部 import 中,无需新增 import。
+
+- [ ] **步骤 2:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
+git commit -m "feat: AiGateway 新增文章答题出题方法"
+```
+
+---
+
+## 任务 5:重写后端 quiz 接口(出题 + 判分 + 双奖励 + 认知雷达)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java`
+
+- [ ] **步骤 1:新增 import 与依赖注入**
+
+在文件顶部 import 区(`import com.etotem.cfc.service.EnergyService;` 之后)追加:
+
+```java
+import com.etotem.cfc.entity.ArticleQuizRecord;
+import com.etotem.cfc.entity.ProfileSnapshot;
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
+import com.etotem.cfc.mapper.ProfileSnapshotMapper;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.PointsService;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+```
+
+在 `@Resource private EnergyService energyService;` 之后追加依赖注入:
+
+```java
+    @Resource
+    private AiGateway aiGateway;
+    @Resource
+    private PointsService pointsService;
+    @Resource
+    private ArticleQuizRecordMapper articleQuizRecordMapper;
+    @Resource
+    private ProfileSnapshotMapper profileSnapshotMapper;
+```
+
+- [ ] **步骤 2:重写 `generateQuiz` 方法**
+
+将现有 `generateQuiz`(85-105 行)整体替换为:
+
+```java
+    @PostMapping("/quiz/generate")
+    public Result<Map<String, Object>> generateQuiz(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("userId") Long userId,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long articleId = ParamUtils.getLong(params.get("articleId"));
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) return Result.error("文章不存在");
+
+        String content = article.getContent();
+        if (content != null && content.length() > 2000) content = content.substring(0, 2000);
+
+        // 组装用户画像摘要(从 profile_snapshot 读五维评分)
+        String profileSummary = "";
+        if (memberId != null) {
+            ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
+            if (snapshot != null && snapshot.getDimensionScores() != null) {
+                profileSummary = snapshot.getDimensionScores();
+            }
+        }
+
+        // 优先走 AiGateway + LangGraph,失败降级硬编码
+        List<Map<String, Object>> questions = null;
+        try {
+            questions = aiGateway.generateArticleQuiz(article.getTitle(), content, profileSummary);
+        } catch (Exception e) {
+            log.warn("AI 出题异常: {}", e.getMessage());
+        }
+
+        if (questions == null || questions.isEmpty()) {
+            questions = new ArrayList<>();
+            questions.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知", "B. 健康生活", "C. 亲子关系", "D. 财富管理"), "A", "wisdom"));
+            questions.add(q("文章提到了哪些维度?", Arrays.asList("A. 身", "B. 心", "C. 智", "D. 以上都有"), "D", "wisdom"));
+            questions.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识", "B. 获得了启发", "C. 需要再想想", "D. 很有共鸣"), "A", "wisdom"));
+        }
+
+        // 落库题目快照,返回 recordId(判分以库内答案为准,防篡改)
+        ArticleQuizRecord record = new ArticleQuizRecord();
+        record.setArticleId(articleId);
+        record.setUserId(userId);
+        record.setMemberId(memberId);
+        record.setQuestions(JSON.toJSONString(questions));
+        record.setAnswers(null);
+        record.setScore(0);
+        record.setTotalQuestions(questions.size());
+        record.setCfEarned(0);
+        record.setEnergyEarned(0);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.insert(record);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("recordId", record.getId());
+        result.put("questions", questions);
+        return Result.success(result);
+    }
+
+    private Map<String, Object> q(String q, List<String> opts, String a, String dimension) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("question", q);
+        m.put("options", opts);
+        m.put("answer", a);
+        m.put("dimension", dimension);
+        return m;
+    }
+```
+
+- [ ] **步骤 3:重写 `submitQuiz` 方法**
+
+将现有 `submitQuiz`(110-120 行)整体替换为:
+
+```java
+    @PostMapping("/quiz/submit")
+    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
+                                                  @RequestAttribute("userId") Long userId,
+                                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long recordId = ParamUtils.getLong(params.get("recordId"));
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+
+        ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
+        if (record == null) return Result.error("答题记录不存在");
+        if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
+            return Result.error("已作答,不可重复提交");
+        }
+
+        // 后端按库内题目快照判分
+        int score = 0;
+        int totalQuestions = 0;
+        List<String> answerList = new ArrayList<>();
+        try {
+            JSONArray qArr = JSON.parseArray(record.getQuestions());
+            JSONArray aArr = JSON.parseArray(String.valueOf(params.get("answers")));
+            totalQuestions = qArr != null ? qArr.size() : 0;
+            if (aArr != null) {
+                for (int i = 0; i < aArr.size(); i++) {
+                    answerList.add(aArr.getString(i));
+                }
+            }
+            for (int i = 0; i < totalQuestions && i < answerList.size(); i++) {
+                JSONObject q = qArr.getJSONObject(i);
+                String correct = q.getString("answer");
+                String selected = answerList.get(i);
+                if (correct != null && selected != null && correct.equalsIgnoreCase(selected.trim())) {
+                    score++;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析答题结果失败: {}", e.getMessage());
+        }
+
+        // 奖励规则:每答对 1 题 +1 CF +5 能量,全对额外 +3 CF
+        int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
+        int energyEarned = score * 5;
+
+        // 回填记录
+        record.setAnswers(JSON.toJSONString(answerList));
+        record.setScore(score);
+        record.setTotalQuestions(totalQuestions);
+        record.setCfEarned(cfEarned);
+        record.setEnergyEarned(energyEarned);
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.updateById(record);
+
+        // 发放奖励(失败不阻断返回)
+        if (memberId != null) {
+            if (cfEarned > 0) {
+                try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
+                catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
+            }
+            if (energyEarned > 0) {
+                try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
+                catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
+            }
+        }
+
+        // 更新认知雷达
+        updateQuizSummary(memberId, record.getQuestions(), score, totalQuestions);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("correctCount", score);
+        result.put("totalQuestions", totalQuestions);
+        result.put("cfEarned", cfEarned);
+        result.put("energyEarned", energyEarned);
+        return Result.success(result);
+    }
+
+    private void updateQuizSummary(Long memberId, String questionsJson, int score, int totalQuestions) {
+        if (memberId == null) return;
+        try {
+            ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
+
+            JSONObject summary = new JSONObject();
+            if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
+                summary = JSON.parseObject(snapshot.getQuizSummary());
+            }
+
+            int prevCorrect = summary.getIntValue("correct_count");
+            int prevTotal = summary.getIntValue("total_count");
+            int newCorrect = prevCorrect + score;
+            int newTotal = prevTotal + totalQuestions;
+            summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
+            summary.put("correct_count", newCorrect);
+            summary.put("total_count", newTotal);
+            summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
+            summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+
+            // 按维度聚合正确率
+            JSONObject byDim = summary.getJSONObject("by_dimension");
+            if (byDim == null) byDim = new JSONObject();
+            JSONArray qArr = JSON.parseArray(questionsJson);
+            if (qArr != null) {
+                for (int i = 0; i < qArr.size(); i++) {
+                    JSONObject q = qArr.getJSONObject(i);
+                    String dim = q.getString("dimension");
+                    if (dim == null || dim.isEmpty()) dim = "wisdom";
+                    JSONObject dimStat = byDim.getJSONObject(dim);
+                    if (dimStat == null) dimStat = new JSONObject();
+                    dimStat.put("total_count", dimStat.getIntValue("total_count") + 1);
+                    dimStat.put("correct_rate", dimStat.getIntValue("total_count") > 0
+                            ? (double) dimStat.getIntValue("correct_count") / dimStat.getIntValue("total_count") : 0.0);
+                    byDim.put(dim, dimStat);
+                }
+            }
+            summary.put("by_dimension", byDim);
+
+            if (snapshot == null) {
+                snapshot = new ProfileSnapshot();
+                snapshot.setMemberId(memberId);
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.insert(snapshot);
+            } else {
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.updateById(snapshot);
+            }
+        } catch (Exception e) {
+            log.warn("更新认知雷达失败: {}", e.getMessage());
+        }
+    }
+```
+
+> 注意:`updateQuizSummary` 中的 `by_dimension` 正确数累加需在判分循环中记录每题维度与正确性。上面代码中 `dimStat` 的 `correct_count` 未按题累加——**需修正**:在 `submitQuiz` 判分循环中,同时构建 `Map<String, int[]>`(dimension → [correct, total])传入 `updateQuizSummary`。见下方步骤 4 的修正版。
+
+- [ ] **步骤 4:修正 `updateQuizSummary` 的维度正确数累加逻辑**
+
+将 `submitQuiz` 中判分循环改为同时统计每题维度与正确性,并调整 `updateQuizSummary` 签名。完整替换 `submitQuiz` 方法及 `updateQuizSummary` 方法为以下修正版:
+
+```java
+    @PostMapping("/quiz/submit")
+    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
+                                                  @RequestAttribute("userId") Long userId,
+                                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long recordId = ParamUtils.getLong(params.get("recordId"));
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+
+        ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
+        if (record == null) return Result.error("答题记录不存在");
+        if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
+            return Result.error("已作答,不可重复提交");
+        }
+
+        int score = 0;
+        int totalQuestions = 0;
+        List<String> answerList = new ArrayList<>();
+        Map<String, int[]> dimStats = new HashMap<>(); // dimension -> [correct, total]
+        try {
+            JSONArray qArr = JSON.parseArray(record.getQuestions());
+            JSONArray aArr = JSON.parseArray(String.valueOf(params.get("answers")));
+            totalQuestions = qArr != null ? qArr.size() : 0;
+            if (aArr != null) {
+                for (int i = 0; i < aArr.size(); i++) {
+                    answerList.add(aArr.getString(i));
+                }
+            }
+            for (int i = 0; i < totalQuestions; i++) {
+                JSONObject q = qArr.getJSONObject(i);
+                String correct = q.getString("answer");
+                String dim = q.getString("dimension");
+                if (dim == null || dim.isEmpty()) dim = "wisdom";
+                String selected = i < answerList.size() ? answerList.get(i) : "";
+                boolean isCorrect = correct != null && selected != null && correct.equalsIgnoreCase(selected.trim());
+                int[] stat = dimStats.computeIfAbsent(dim, k -> new int[2]);
+                stat[1]++;
+                if (isCorrect) { stat[0]++; score++; }
+            }
+        } catch (Exception e) {
+            log.warn("解析答题结果失败: {}", e.getMessage());
+        }
+
+        int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
+        int energyEarned = score * 5;
+
+        record.setAnswers(JSON.toJSONString(answerList));
+        record.setScore(score);
+        record.setTotalQuestions(totalQuestions);
+        record.setCfEarned(cfEarned);
+        record.setEnergyEarned(energyEarned);
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.updateById(record);
+
+        if (memberId != null) {
+            if (cfEarned > 0) {
+                try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
+                catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
+            }
+            if (energyEarned > 0) {
+                try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
+                catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
+            }
+        }
+
+        updateQuizSummary(memberId, score, totalQuestions, dimStats);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("correctCount", score);
+        result.put("totalQuestions", totalQuestions);
+        result.put("cfEarned", cfEarned);
+        result.put("energyEarned", energyEarned);
+        return Result.success(result);
+    }
+
+    private void updateQuizSummary(Long memberId, int score, int totalQuestions, Map<String, int[]> dimStats) {
+        if (memberId == null) return;
+        try {
+            ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
+                    new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
+
+            JSONObject summary = new JSONObject();
+            if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
+                summary = JSON.parseObject(snapshot.getQuizSummary());
+            }
+
+            int prevCorrect = summary.getIntValue("correct_count");
+            int prevTotal = summary.getIntValue("total_count");
+            int newCorrect = prevCorrect + score;
+            int newTotal = prevTotal + totalQuestions;
+            summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
+            summary.put("correct_count", newCorrect);
+            summary.put("total_count", newTotal);
+            summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
+            summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
+
+            JSONObject byDim = summary.getJSONObject("by_dimension");
+            if (byDim == null) byDim = new JSONObject();
+            for (Map.Entry<String, int[]> entry : dimStats.entrySet()) {
+                String dim = entry.getKey();
+                int correct = entry.getValue()[0];
+                int total = entry.getValue()[1];
+                JSONObject dimStat = byDim.getJSONObject(dim);
+                if (dimStat == null) dimStat = new JSONObject();
+                int dimCorrect = dimStat.getIntValue("correct_count") + correct;
+                int dimTotal = dimStat.getIntValue("total_count") + total;
+                dimStat.put("correct_count", dimCorrect);
+                dimStat.put("total_count", dimTotal);
+                dimStat.put("correct_rate", dimTotal > 0 ? (double) dimCorrect / dimTotal : 0.0);
+                byDim.put(dim, dimStat);
+            }
+            summary.put("by_dimension", byDim);
+
+            if (snapshot == null) {
+                snapshot = new ProfileSnapshot();
+                snapshot.setMemberId(memberId);
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.insert(snapshot);
+            } else {
+                snapshot.setQuizSummary(summary.toJSONString());
+                snapshot.setUpdatedAt(new Date());
+                profileSnapshotMapper.updateById(snapshot);
+            }
+        } catch (Exception e) {
+            log.warn("更新认知雷达失败: {}", e.getMessage());
+        }
+    }
+```
+
+- [ ] **步骤 5:补充 import**
+
+`submitQuiz` 用到了 `SimpleDateFormat`,确认文件顶部已有 `import java.util.*;`(已存在,第 15 行),`SimpleDateFormat` 已涵盖。若无,则追加 `import java.text.SimpleDateFormat;`。
+
+- [ ] **步骤 6:编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java
+git commit -m "feat: 文章答题出题/提交/双奖励/认知雷达接口改造"
+```
+
+---
+
+## 任务 6:前端适配新接口结构 + 结果弹窗展示 CF
+
+**文件:**
+- 修改:`cfc-frontend/pages/article-center/article-detail.vue`
+
+- [ ] **步骤 1:`data()` 增加 `quizRecordId` 字段**
+
+在 `data()` return 中 `quizResult: {},` 附近追加:
+
+```js
+      quizRecordId: null,
+```
+
+- [ ] **步骤 2:改 `startQuiz` 方法**
+
+将现有 `startQuiz`(262-277 行)中 `generateQuiz` 调用后的处理改为适配 `{recordId, questions}` 结构:
+
+```js
+    async startQuiz() {
+      try {
+        var memRes = await getMyMembership()
+        if (memRes.data && memRes.data.memberLevel && memRes.data.memberLevel === 'FREE') {
+          uni.showToast({ title: '浠宝/福宝答题是会员专属', icon: 'none' })
+          setTimeout(function() { uni.navigateTo({ url: '/pages/membership/upgrade' }) }, 1500)
+          return
+        }
+        var memberId = uni.getStorageSync('currentChildId')
+        var res = await generateQuiz({ articleId: this.articleId, memberId: parseInt(memberId || 0) })
+        if (res.code === 200 && res.data && res.data.questions && res.data.questions.length > 0) {
+          this.quizRecordId = res.data.recordId
+          this.quizQuestions = res.data.questions
+          this.quizAnswers = []
+          this.showQuiz = true
+        }
+      } catch (e) {}
+    },
+```
+
+- [ ] **步骤 3:改 `submitQuiz` 方法**
+
+将现有 `submitQuiz`(278-292 行)替换为:
+
+```js
+    async submitQuiz() {
+      var memberId = uni.getStorageSync('currentChildId')
+      var answers = []
+      for (var i = 0; i < this.quizQuestions.length; i++) {
+        answers.push(this.quizAnswers[i] || '')
+      }
+      try {
+        var res = await submitQuiz({ recordId: this.quizRecordId, memberId: parseInt(memberId || 0), answers: answers })
+        if (res.code === 200) {
+          this.quizResult = res.data || {}
+          this.showQuiz = false
+          this.showResult = true
+        }
+      } catch (e) {}
+    },
+```
+
+- [ ] **步骤 4:结果弹窗增加 CF 值展示**
+
+将结果弹窗(约 80 行)的 `<text class="result-energy">` 改为同时展示 CF 值与能量:
+
+```html
+        <text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
+```
+
+- [ ] **步骤 5:语法校验(仅 script 块)**
+
+运行:`cd cfc-frontend && node --check <(sed -n '/<script>/,/<\/script>/p' pages/article-center/article-detail.vue | sed '1d;$d')`
+预期:无语法错误输出。
+
+> 注意:小程序打包由 HBuilderX 完成,本计划不执行 `npm run build`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/pages/article-center/article-detail.vue
+git commit -m "feat: 文章答题弹窗适配新接口并展示CF值"
+```
+
+---
+
+## 任务 7:整体编译验证与收尾
+
+- [ ] **步骤 1:后端编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 2:LangGraph 语法验证**
+
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py`
+预期:无输出,退出码 0。
+
+- [ ] **步骤 3:更新 PROJECT-OVERVIEW.md 状态**
+
+将 `docs/superpowers/PROJECT-OVERVIEW.md` 中本次设计规格条目的状态从「🟡 设计规格已确认」改为「🟢 已实施」,并在 plans 索引中追加本计划文件条目。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 更新文章答题功能实施状态"
+```
+
+---
+
+## 验证清单(对应规格成功标准)
+
+- [ ] 阅读达标后弹出 AI 出题答题弹窗(AI 失败时 fallback 3 题)—— 任务 2/5
+- [ ] 提交答题后落库 `article_quiz_records`、发放 CF 值 + 五维能量、更新 `profile_snapshot.quiz_summary` —— 任务 5
+- [ ] 前端结果弹窗展示「答对 X/Y 题」「+CF 值」「+能量」—— 任务 6
+- [ ] `mvn clean compile` 通过 —— 任务 7

+ 321 - 0
docs/superpowers/specs/2026-09-17-product-recommendation-rule-engine-design.md

@@ -0,0 +1,321 @@
+# 商品推荐规则引擎设计文档
+
+## 1. 概述
+
+统一商品推荐的 5 种业务规则,引入统一规则引擎,由配置表 `recommendation_rules` 驱动。现有实现(已购90天排除 + 定时复购提醒)并入新引擎,所有场景(维度页/商城首页/商品详情/下单成功/报告关联)统一走一套计算逻辑,配置在后台管理端。
+
+## 2. 核心目标
+
+1. **统一入口**:所有推荐场景通过同一个引擎计算,输入输出一致
+2. **配置驱动**:规则均由 `recommendation_rules` 表配置,运营可在 Web 管理端增删改,无需改代码
+3. **降级兼容**:规则表为空时退化为现有硬编码行为,保证不出现空结果
+4. **最小侵入**:现有接口/服务改造仅改调用引擎,核心业务逻辑不改
+5. **覆盖全场景**:5 种规则全覆盖,且易扩展新规则
+
+## 3. 数据模型
+
+### 3.1 新表 `recommendation_rules`
+
+| 字段 | 类型 | 主键 | 说明 |
+|------|------|------|------|
+| `id` | BIGINT | PK | 主键,AUTO_INCREMENT |
+| `rule_type` | TINYINT | — | 规则类型 1-5 |
+| `rule_name` | VARCHAR(100) | — | 运营可读名称 |
+| `trigger_product_id` | BIGINT | — | 单商品触发(规则1/2/3/4) |
+| `trigger_product_ids` | VARCHAR(1000) | — | 多商品触发 JSON 数组(规则5,如 `"[1,2,3]"`) |
+| `target_product_id` | BIGINT | — | 目标商品ID(被推荐/排除/重点推) |
+| `priority` | INT | — | 优先级分,越大越先处理 |
+| `enabled` | TINYINT | — | 启用 1/0 |
+| `time_window_days` | INT | — | 购买回溯窗口(天),默认 null=不限 |
+| `delay_days` | INT | — | 复购延迟天数(规则2) |
+| `scope` | VARCHAR(20) | — | `family`/`individual` |
+| `scene_codes` | VARCHAR(500) | — | 生效场景 JSON 数组,如 `"[\"dimension_page\",\"product_detail\"]"` |
+| `created_at` | DATETIME | — | 创建时间 |
+| `updated_at` | DATETIME | — | 更新时间 |
+
+### 3.2 Index 建议
+
+- `idx_rules_enabled`(`enabled`, `rule_type`)
+- `idx_rules_scene`(`scene_codes`, `enabled`)
+- `idx_rules_priority`(`priority`, `enabled`)
+
+### 3.3 MyBatis-Plus Mapper 注解示例(仅示意)
+
+```java
+@Mapper
+public interface RecommendationRuleMapper {
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND FIND_IN_SET(?, scene_codes)")
+    List<RecommendationRule> selectByScene(String sceneCode);
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND rule_type = ? ORDER BY priority DESC")
+    List<RecommendationRule> selectByRuleType(Integer ruleType);
+
+    @Select("SELECT * FROM recommendation_rules WHERE enabled = 1 AND rule_type = ? AND FIND_IN_SET(?, trigger_product_ids)")
+    List<RecommendationRule> selectByProductIds(Integer ruleType, String productIdsJson);
+}
+```
+
+## 4. 引擎设计
+
+### 4.1 入口接口
+
+```java
+/**
+ * 推荐上下文
+ */
+public class RecommendationContext {
+    private Long userId;          // 登录用户ID
+    private Long familyId;        // 家庭ID(scene 为 family 时必填)
+    private Long memberId;        // 成员ID(scope=individual 时)
+    private String scene;         // 生效场景代码:dimension_page, mall_home, product_detail, after_order, report_related
+    private Long currentProductId; // 商品详情页/下单页的当前商品ID
+    private List<Long> excludeProductIds; // 用户自行排除的商品ID(如已选/不喜欢)
+    private Integer limit;        // 返回上限,默认6
+}
+```
+
+```java
+/**
+ * 推荐结果项
+ */
+public class RecommendedProduct {
+    private Long productId;
+    private String name;
+    private String coverImage;
+    private Integer price; // 分
+    private String reason; // 推荐理由
+    private Double matchScore; // 匹配分
+    private Integer ruleId; // 触发该结果的规则ID
+    private Integer ruleType; // 规则类型 1-5
+    private Integer priority; // 该规则优先级
+    private String source; // 来源标识:engine/campaign/...
+}
+```
+
+```java
+/**
+ * 引擎核心方法
+ */
+public interface RecommendationRuleEngine {
+    /**
+     * 计算推荐
+     * @param ctx 推荐上下文
+     * @param limit 若覆盖默认limit,可传;否则用 ctx.limit
+     * @return 推荐列表
+     */
+    List<RecommendedProduct> recommend(RecommendationContext ctx, Integer limit);
+}
+```
+
+### 4.2 处理流程(按 priority 降序遍历规则)
+
+```
+1. 加载规则
+   - 根据 scene 匹配 `scene_codes`(精确匹配 JSON 中的字符串)
+   - enabled=1,按 priority DESC 排序
+
+2. 查购买记录
+   - scope=family:查 family_members 关联的订单(ProductOrder 中 familyId 匹配)
+   - scope=individual:查 buyerId(userId) 的订单
+   - 状态过滤:status in ("已支付", "completed")
+   - 时间窗口:time_window_days 为 null 或 返回的时间差 <= time_window_days
+     若为 null,默认使用全局配置 default_purchased_exclude_days(默认 90)
+
+3. 构建候选池(场景基础候选)
+   - dimension_page → 维度匹配商品(已有 ProductRecommendationService 逻辑)
+   - mall_home      → 全量上架在售商品(product status=on_shelf, stock>0)
+   - product_detail → 从规则3/5 中命中的 targetProductId
+   - after_order    → 从规则3/5 中命中的 targetProductId
+   - report_related → 报告提取的弱维度商品(已有 extractDimensionNeedsFromHealth/FromCognitive)
+
+4. 应用规则(按 priority 从高到低遍历已加载的规则)
+
+   4.1 规则4(购买A不推B)
+       - 条件:已购买 trigger_product_id
+       - 动作:从候选池移除 target_product_id
+       - 若未命中则跳过
+
+   4.2 规则1(已购不推)
+       - 条件:已购买 trigger_product_id(或全局默认已购窗口内任意商品)
+       - 动作:从候选池移除 target_product_id
+       - 若未命中则跳过(已购默认由 step2/3 在全局层面完成,规则1只作特定覆盖)
+
+   4.3 规则3(购买A推B)
+       - 条件:已购买 trigger_product_id
+       - 动作:将 target_product_id 加入候选池 + reason="根据您的购买记录推荐"
+       - 若候选池已有该productId,则不重复添加,仅更新 reason
+
+   4.4 规则5(系列重点推)
+       - 条件:已购买 trigger_product_ids 中的 ALL 个商品
+       - 动作:将 target_product_id 加入候选池 + priority 加 10(提升排序位置)
+
+   4.5 规则2(定时复购)
+       - 条件:已购买 trigger_product_id 且(当前日期 - 购买日期) >= delay_days
+       - 动作:将 target_product_id 加入候选池 + reason="该商品您已购买,可考虑复购"
+       - 建议 delay_days 默认值:30
+
+5. 结果排序 & 截断
+   - 按 (priority DESC, matchScore DESC) 排序
+   - 截断 limit(默认6)
+
+6. 返回结果
+   - List<RecommendedProduct>,每项含 productId, name, coverImage, price, reason, matchScore, ruleId, ruleType, priority, source
+```
+
+### 4.3 规则语义表
+
+| 规则 | rule_type | 触发 | 动作 | 关键字段 |
+|------|-----------|------|------|---------|
+| 1 已购不推 | 1 | 购买 trigger_product(或全局已购窗口内任意) | 从候选排除 target_product | trigger_product_id, target_product_id, time_window_days |
+| 2 定时复购 | 2 | 购买 trigger_product 且距今>=delay_days | target_product 加入候选(复购) | delay_days, target_product_id |
+| 3 购买A推B | 3 | 购买 trigger_product(A) | target_product(B) 加入候选 | trigger_product_id, target_product_id |
+| 4 购买A不推B | 4 | 购买 trigger_product(A) | 从候选排除 target_product(B) | trigger_product_id, target_product_id |
+| 5 系列重点推 | 5 | 购买 trigger_product_ids 全部 | target_product 加入候选 + boost | trigger_product_ids, target_product_id |
+
+### 4.4 全局默认已购排除
+
+- 系统级配置 `default_purchased_exclude_days`(默认 90 天),存入 application.yml 或系统参数表
+- 引擎在 step2 查购买记录阶段,scope=family 时查 family 成员订单,scope=individual 时查 buyerId 订单
+- 无论规则1是否配置,所有候选商品在进入 step4 前都已过滤掉回溯窗口内的已购商品(作为兜底)
+- 规则1 仅作“特定商品覆盖”:如 trigger_product_id=123,time_window_days=180,则仅将商品123 的购买记录从候选排除(放宽或收窄默认 90 天)
+
+## 5. 现有实现迁移
+
+| 现有代码/组件 | 迁移动作 |
+|--------------|----------|
+| `ProductRecommendationService.getDimensionRecommendations` | 改为调用 `engine.recommend(ctx.scene="dimension_page")`,ctx.limit=6 |
+| `getPurchasedProductIds(familyId, 90)` 硬编码 90 天 | 移除该方法,改为在引擎内读 `default_purchased_exclude_days` |
+| `RepurchaseReminderService.scanAndCreateReminders` @Scheduled | **保留**(不做改动,但内部配置读取改为从规则表读;rule_type=2 的 config 从 recommendation_rules 读,旧 `repurchase_reminder_config` 表数据迁移),外部定时任务仍可继续生成 reminder 记录 |
+| `repurchase_reminder_config` 表 | **保留**(点击/购买追踪表),不做结构变更,仅在新引擎规则2 中通过 rule_type 匹配 |
+| `ContentRecommendService.getPurchasedProductIds` | 复用引擎的全局已购排除能力 |
+| API Controller(维度页推荐) | 不改接口签名,内部改调用 engine.recommend,返回结果自动映射为 Map<String,Object> 格式 |
+| 新增 API 接口(可选) | 若前端需要更多场景,可在 `POST /api/recommend/*` 下新增,内部同样调用 engine.recommend |
+
+## 6. 管理端(cfc-web)
+
+### 6.1 页面:商品推荐规则
+
+- **列表**:分页 + rule_type / enabled / priority 筛选 + 排序
+- **新建/编辑**:
+  - rule_type:1-5 下拉
+  - rule_name:文本
+  - trigger_product_id / trigger_product_ids:商品选择器(支持搜索、多选)
+  - target_product_id:目标商品选择器
+  - priority:数字输入(整数,默认 10)
+  - enabled:开关
+  - time_window_days:整数(天,默认 90)
+  - delay_days:整数(天,默认 30,仅规则2)
+  - scope:single/family 单选
+  - scene_codes:多行文本/标签选择器(可选:dimension_page, mall_home, product_detail, after_order, report_related)
+
+### 6.2 管理端 API
+
+- `GET /api/admin/recommendation-rules` - 分页列表
+- `POST /api/admin/recommendation-rules` - 新建
+- `PUT /api/admin/recommendation-rules/{id}` - 编辑
+- `DELETE /api/admin/recommendation-rules/{id}` - 物理删除(慎用,建议设 enabled=0 停用)
+- `POST /api/admin/recommendation-rules/batch-enable` - 批量启用/停用
+
+## 7. 容错与降级
+
+1. **规则表为空时**:引擎不抛出异常,直接进入“场景基础候选”阶段(即现有行为),全局已购排除默认 90 天,返回已有候选列表。
+
+2. **规则配置冲突**:同一规则 type 存在多条启用记录时,按 priority 降序处理,较早的生效。运营若需调整顺序,在管理端修改 priority 即可。
+
+3. **trigger_product_id 不存在**:引擎检查 trigger_product_id 是否存在于 products 表中,不存在则跳过该规则,记录 warn 日志。
+
+4. **scene_codes 匹配失败**:
+   - 引擎首先尝试精确匹配 `FIND_IN_SET(sceneCode, scene_codes)`
+   - 若未匹配,再尝试前缀匹配(如 sceneCode 为 "dimension_page",scene_codes 包含 "dimension" 也视为匹配)
+   - 作为最后兜底,若 scene_codes 为空字符串 `""`,视为“所有场景生效”
+
+5. **时间窗口边界**:
+   - time_window_days=0 表示“只计算今日购买”
+   - time_window_days<0 视为异常,引擎取绝对值但标记日志警告
+
+## 8. 测试要点
+
+1. **规则单独生效**:
+   - 规则1:已购特定商品(180天)从候选排除
+   - 规则2:30天后出现复购推荐
+   - 规则3:购买A后推B
+   - 规则4:购买A后不推B
+   - 规则5:购买A+B+C系列后重点推X
+
+2. **优先级排序**:
+   - 验证 priority 从高到低的处理顺序
+   - 验证同 priority 时,matchScore DESC
+
+3. **scope family/individual**:
+   - family:查 familyId 关联订单
+   - individual:查 buyerId(userId) 订单
+
+4. **time_window_days 边界**:0、90、365 各自行为
+
+5. **降级测试**:
+   - 删除所有规则记录,验证系统仍能返回推荐(退化为全局已购排除 + 基础候选)
+   - 验证旧定时任务仍正常工作
+
+6. **编译验证**:`mvn clean compile`
+
+## 9. 变更清单
+
+### 9.1 数据库
+
+```sql
+-- 新建表(迁移编号自行在 DatabaseInitializer 末尾递增)
+CREATE TABLE recommendation_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    rule_type TINYINT NOT NULL COMMENT '1-5',
+    rule_name VARCHAR(100) COMMENT '运营名称',
+    trigger_product_id BIGINT COMMENT '触发商品ID',
+    trigger_product_ids VARCHAR(1000) COMMENT '触发商品ID JSON 数组',
+    target_product_id BIGINT COMMENT '目标商品ID',
+    priority INT DEFAULT 10 COMMENT '优先级,越大越优',
+    enabled TINYINT DEFAULT 1 COMMENT '启用 1/0',
+    time_window_days INT COMMENT '回溯窗口(天)',
+    delay_days INT COMMENT '复购延迟天数',
+    scope VARCHAR(20) COMMENT 'family/individual',
+    scene_codes VARCHAR(500) COMMENT '生效场景 JSON 数组',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+
+-- 建议索引
+CREATE INDEX idx_rules_enabled ON recommendation_rules(enabled);
+CREATE INDEX idx_rules_scene ON recommendation_rules(scene_codes);
+CREATE INDEX idx_rules_priority ON recommendation_rules(priority);
+```
+
+### 9.2 Java 代码变更
+
+- 新增 `entity/RecommendationRule.java`
+- 新增 `mapper/RecommendationRuleMapper.java`
+- 新增 `service/RecommendationRuleService.java`(CRUD + 规则校验)
+- 新增 `service/RecommendationRuleEngine.java`(核心计算逻辑)
+- 新增 `controller/recommendation/RecommendationRuleAdminController.java`(管理端 API)
+- 修改 `service/ProductRecommendationService.java` → 改为调用 engine.recommend
+- 修改 `service/RepurchaseReminderService.java` → rule_type=2 配置读取改为从 recommendation_rules
+- 删除/废弃 `repurchase_reminder_config` 业务逻辑(保留表结构)
+
+### 9.3 API 变更(后端)
+
+- 维度页推荐:`POST /api/recommend/dimension-products` 不变,内部实现改调用 engine
+- 新增(如需要):`POST /api/recommend/mall-home`、`POST /api/recommend/product-detail`、`POST /api/recommend/after-order`、`POST /api/recommend/report-related`
+
+### 9.4 Web管理端
+
+- 新增页面:`/admin/recommendation-rules`(Vue 2 + Element UI,遵循项目现有设计规范)
+- 权限:role=admin 或 role=teacher
+
+## 10. 完成标准
+
+- [ ] 新表 `recommendation_rules` 建表 + schema.sql 同步
+- [ ] `RecommendationRuleMapper` + `RecommendationRuleService` 实现
+- [ ] `RecommendationRuleEngine.recommend()` 完整实现(5种规则流程)
+- [ ] `RepurchaseReminderService` 规则2 配置读取改造
+- [ ] `ProductRecommendationService` 改为调用 engine
+- [ ] 管理端 CRUD 页面 + API
+- [ ] 单元测试:5种规则各自命中/未命中 + priority 排序 + scope family/individual
+- [ ] 集成测试:mvn clean compile 通过
+- [ ] 文档同步:`docs/superpowers/specs/2026-09-17-product-recommendation-rule-engine-design.md`

+ 180 - 0
docs/superpowers/specs/2026-09-19-article-reading-quiz-design.md

@@ -0,0 +1,180 @@
+# 文章阅读后答题奖励 + 认知雷达记录 — 设计规格
+
+> 日期:2026-09-19
+> 状态:待审查
+> 范围:cfc-backend + cfc-langgraph + cfc-frontend(小程序文章详情页)
+
+## 1. 需求概述
+
+用户读完文章(达到阅读时长)后,系统根据文章内容 + 用户画像 AI 生成若干道选择题。用户作答后,根据答对数量发放 **CF 值** 和 **五维能量**,并将题目与作答情况写入用户的 **认知雷达**(`profile_snapshot` 扩展字段),作为后续精准推送的基础。
+
+## 2. 现状盘点
+
+| 环节 | 现状 | 差距 |
+|------|------|------|
+| 阅读计时/达标 | 前端计时 + `/api/articles/report-reading-time` + `/api/articles/complete-read`(+5能量) | 无 |
+| AI 出题 | `/api/articles/quiz/generate` 调 `aiService.sendWorkflow("article-quiz")`(Dify),失败回退 3 道硬编码题 | 未走 AiGateway + LangGraph |
+| 提交答题 | `/api/articles/quiz/submit` 只算分、发能量(答对×5),**未持久化**、未发 CF 值、未写画像 | 缺记录表、缺 CF、缺画像 |
+| 记录表 | `ArticleQuizRecord` 实体存在(`@TableName("article_quiz_records")`),但 schema.sql **无建表语句** | 需建表 |
+| 认知雷达 | `profile_snapshot` 表存在(含 `dimension_scores`/`problem_domains` JSON),无答题维度 | 需扩展 |
+| 奖励服务 | `PointsService.awardCfPoints(memberId, amount, reason)`、`EnergyService.awardEnergy(memberId, sourceType, sourceId, totalAmount, desc, daysToExpire)` 均可用 | 需接线 |
+
+## 3. 架构与数据流
+
+```
+用户读完文章(前端计时达标)
+        │
+        ├─ ① POST /api/articles/complete-read   → 发阅读能量(已存在)
+        │
+        ├─ ② POST /api/articles/quiz/generate   → AiGateway.generateArticleQuiz()
+        │        └─ LangGraph /api/v1/article/quiz/generate(article_quiz_graph)
+        │              失败 → 降级到现有硬编码 3 题 fallback
+        │
+        ├─ ③ 用户作答(前端 quiz 弹窗)
+        │
+        └─ ④ POST /api/articles/quiz/submit
+                 ├─ 落库 article_quiz_records(题目+作答+得分)
+                 ├─ 发 CF 值(PointsService.awardCfPoints)
+                 ├─ 发五维能量(EnergyService.awardEnergy,按文章关联维度分配)
+                 └─ 更新 profile_snapshot.quiz_summary(认知雷达)
+```
+
+## 4. 数据模型
+
+### 4.1 新增表 `article_quiz_records`
+
+实体 `ArticleQuizRecord` 已存在,建表时采用规范字段(表尚不存在,无需历史兼容,直接建最终结构):
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AI | 主键 |
+| article_id | BIGINT | 文章 ID |
+| user_id | BIGINT | 登录用户 ID |
+| member_id | BIGINT | 家庭成员 ID(作答人,统一口径,见 `docs/architecture/userid-vs-memberid-usage-guide.md`) |
+| questions | TEXT | 题目快照 JSON(含正确答案与维度) |
+| answers | TEXT | 用户作答 JSON(含每题所选选项),作答前为 NULL |
+| score | INT | 答对题数 |
+| total_questions | INT | 题目总数 |
+| cf_earned | INT | 发放的 CF 值 |
+| energy_earned | INT | 发放的能量值 |
+| created_at | DATETIME | 创建时间 |
+| updated_at | DATETIME | 更新时间 |
+
+> 实体现有 `childId`/`pointsEarned` 字段不再使用(表为全新创建,不引入 childId 口径);`questions`/`answers` 字段直接承载 JSON。实体需同步调整:去掉 `childId`/`pointsEarned`,新增 `memberId`/`totalQuestions`/`cfEarned`/`energyEarned`。
+
+### 4.2 扩展 `profile_snapshot`
+
+新增一列:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| quiz_summary | JSON | 答题认知雷达汇总:`{"quiz_count":N,"correct_rate":0.8,"by_dimension":{"wisdom":{"quiz_count":n,"correct_rate":r},...},"last_quiz_at":"..."}` |
+
+`by_dimension` 的 key 使用五维代码(body/mind/wisdom/action/wealth),来自文章的 `related_dimensions` / `dimension_weights`。
+
+## 5. AI 出题(LangGraph)
+
+### 5.1 新 graph:`cfc-langgraph/app/graphs/article_quiz_graph.py`
+
+- 输入:`article_title`、`article_content`(截断至合理长度)、`profile_summary`(用户画像摘要,可选)
+- 输出:3 道单选题,结构化 JSON:
+
+```json
+[
+  {
+    "question": "问题文本",
+    "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"],
+    "answer": "A",
+    "dimension": "wisdom",
+    "explanation": "解析文本(可选)"
+  }
+]
+```
+
+- `dimension` 取值与文章 `related_dimensions` 对齐,缺省为 `wisdom`。
+- 系统提示词走 `prompt_service.get_prompt("article_quiz")`,无则用内置 DEFAULT_PROMPTS 兜底(需在 `prompt_service.py` 的 `DEFAULT_PROMPTS` 增加 `article_quiz` 条目)。
+
+### 5.2 新路由:`cfc-langgraph/app/api/article_quiz.py`
+
+- `POST /api/v1/article/quiz/generate`,Pydantic 模型放 `app/models/article_quiz.py`。
+- 响应:`{"questions": [...], "fallback_used": false}`。
+- 在 `app/main.py` 注册 `include_router(article_quiz.router)`。
+
+### 5.3 AiGateway 新方法
+
+`AiGateway.generateArticleQuiz(articleTitle, articleContent, profileSummary)`:
+- 调 `baseUrl + "/api/v1/article/quiz/generate"`
+- 遵循现有熔断/超时/Fallback 模式,失败返回 `null`,由调用方降级。
+
+## 6. 后端接口改造
+
+### 6.1 `/api/articles/quiz/generate`(ArticleCommentController)
+
+- 改为:先调 `aiGateway.generateArticleQuiz(...)`,成功则取得题目列表。
+- 失败则降级到现有硬编码 3 题 fallback(保持不变)。
+- 组装 `profile_summary`:从 `profile_snapshot` 读取当前成员画像摘要传入 AI。
+- **建记录并返回 recordId**:将题目快照(含正确答案)落库到 `article_quiz_records`(`answers` 为 NULL),返回 `{recordId, questions}`。判分以库内答案为唯一依据,前端无法篡改。
+
+### 6.2 `/api/articles/quiz/submit`(ArticleCommentController)
+
+改造为完整落库 + 双奖励 + 画像更新:
+
+1. 接收 `recordId`、`memberId`、`answers`(含每题所选选项)。
+2. 按 `recordId` 读取库内题目快照,后端自行判分:计算 `score`(答对题数)、`totalQuestions`。
+3. 回填 `article_quiz_records`(作答 JSON + 得分 + 奖励额度),拒绝重复提交(`answers` 已非 NULL)。
+4. 发放奖励:
+   - **CF 值**:`pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励")`
+   - **五维能量**:`energyService.awardEnergy(memberId, "article_quiz", articleId, energyEarned, "文章答题奖励", null)`,按文章维度分配。
+5. 更新 `profile_snapshot.quiz_summary`(认知雷达汇总,见 4.2)。
+6. 返回 `{correctCount, totalQuestions, cfEarned, energyEarned}`。
+
+> 判分与现有 `ArticleService.getAiQuestions`/`submitAnswers` 模式一致(出题建记录 → 提交按 recordId 读库判分),保持代码风格统一。
+
+### 6.3 奖励规则(可配置)
+
+| 场景 | CF 值 | 能量 |
+|------|-------|------|
+| 阅读完成 | 0 | +5(已存在) |
+| 答题:每答对 1 题 | +1 | +5 |
+| 答题:全对额外奖励 | +3 | 0 |
+
+> 规则常量先硬编码在 Service 层,后续如需运营可配置,再迁移到 `sys_config`(YAGNI,本期不引入配置项)。
+
+## 7. 前端改造(cfc-frontend)
+
+文件:`pages/article-center/article-detail.vue`
+
+- `startQuiz`:调用 `generateQuiz` 不变,响应结构仍为题目数组(后端已兼容)。
+- `submitQuiz`:改为传「每题所选选项」而非 `correctCount`;结果展示改为同时显示 CF 值 + 能量。
+- 结果弹窗 `result-dialog`:增加「CF 值 +X」展示。
+
+> 前端遵循:禁止可选链 `?.`、禁止 CSS Grid、`:key` 用方法调用、日期用 `parseDate()`。小程序打包由 HBuilderX 完成,Agent 只做语法校验。
+
+## 8. 迁移清单
+
+`DatabaseInitializer.runMigrations()` 新增(最新迁移编号 298,递增):
+
+- **迁移 299**:创建 `article_quiz_records` 表。
+- **迁移 300**:`profile_snapshot` 增加 `quiz_summary` JSON 列(`ensureColumn`)。
+
+同步更新 `schema.sql`(建表语句 + profile_snapshot 列)。
+
+## 9. 错误处理与边界
+
+- **AI 出题失败**:降级到硬编码 fallback,不影响答题主流程。
+- **重复提交**:`article_quiz_records` 已存在作答记录(`answers_json` 非空)时拒绝重复提交(与现有 `submitAnswers` 逻辑一致)。
+- **成员不存在**:`awardCfPoints` 内部抛「孩子不存在」,提交接口需捕获并降级(能量/CF 失败不阻断答题结果返回)。
+- **文章无维度**:`dimension` 缺省 `wisdom`,`quiz_summary.by_dimension` 使用 `wisdom` 兜底。
+
+## 10. 测试
+
+- 后端:`mvn clean compile` 验证编译通过(项目当前唯一验证方式)。
+- LangGraph:本地 `uvicorn` 起服务后 curl `/api/v1/article/quiz/generate` 验证返回 3 题结构化 JSON。
+- 前端:`node --check` 校验修改的 script 块语法(不打包,HBuilderX 负责打包)。
+
+## 11. 成功标准
+
+- [ ] 阅读达标后弹出 AI 出题的答题弹窗(AI 失败时 fallback 3 题)。
+- [ ] 提交答题后:落库 `article_quiz_records`、发放 CF 值 + 五维能量、更新 `profile_snapshot.quiz_summary`。
+- [ ] 前端结果弹窗展示「答对 X/Y 题」「+CF 值」「+能量」。
+- [ ] `mvn clean compile` 通过。

+ 142 - 0
运营文案/个人号A种草内容-第一批.md

@@ -0,0 +1,142 @@
+# 个人号 A · 种草内容系列(5 篇)
+
+> 账号定位:肠道康复者(真实案例人设),带数据的自愈故事
+> 原则:**讲用户想听的,不讲我想说的**(删掉品牌名内容仍成立;主语是"您"不是"我们")
+> 差异化:素人号只讲故事,**我们讲故事+带数据**——首测报告截图 + 复测报告对比
+> 规范:标题 ≤20 字 / 正文 600-750 字 / 无 Markdown 语法 / 含 20 秒动作 / 含评论区钩子
+
+---
+
+## A1 | 我的便秘自救史
+
+**标题**:3年便秘,测完菌群才知道缺什么,3个月数据变了
+
+**正文**:
+
+你搜这条的时候,是不是也试过蜂蜜水、酵素、益生菌,该花的全花了,就是没好?
+
+我也是。三年,跑了三家医院,开了一堆药,最狠的时候两天上一次开塞露。后来去测了肠道菌群,才发现一件事——我一直在补错东西。
+
+报告出来我惊了。乳酸菌、双歧杆菌全正常,真正低的是产丁酸菌——丁酸是肠道的"燃料",缺它,肠子没力气自己动。我吃的益生菌里根本没有这个菌,难怪没用。
+
+后面三个月我干了什么?没吃药,改了饮食:多吃燕麦、香蕉、蒜,少碰精制碳水。营养师告诉我,丁酸菌喜欢的是膳食纤维,不是乳酸菌。
+
+复查那天,我看到报告上丁酸菌那行从 12% 跳到了 38%。同一天,我第一次自己顺畅地去了厕所。没有开塞露,没有药,就只是——通了。
+
+你说这靠谱吗?我告诉你一个判断方法:任何帮你"通"的东西都是临时解决,真正能改变的,是你肠道里住着什么菌。检测之前,我不知道自己缺什么;检测之后,我知道该补什么。
+
+你现在最想搞清楚的问题是什么?评论区告诉我,我看看有没有和我一样的。
+
+下期我分享孩子便秘的事——幼儿园老师找我谈话那天,我差点崩溃。
+
+#便秘自救 #肠道菌群 #丁酸菌 #儿童便秘 #妈妈日常
+
+---
+
+## A2 | 孩子便秘焦虑
+
+**标题**:幼儿园老师说孩子拉裤子,测菌群发现是这菌少了
+
+**正文**:
+
+那天幼儿园老师把我叫到一边,说"你家孩子今天又拉裤子了"。我脸一下子烧到耳根,脑子里全是"别人会怎么看我"。
+
+回家查了一通,才发现这不是简单的"上火"或"吃得少"。慢性便秘对孩子来说,不只是拉不出来,是社交、是自尊、是整个人都缩起来了。
+
+我最怕的不是便秘本身,是找不到原因。吃什么水果都试过了,什么药都吃了,还是不行。后来带他去测了肠道菌群,报告出来一看——他缺的不是我们以为的那种菌,是短链脂肪酸合成菌,这种菌负责给肠子提供"动力"。
+
+我给营养师看了报告,她说这个菌缺了,吃再多的香蕉都没用,因为缺的不是纤维,是能把纤维变成丁酸的菌。
+
+怎么办?换方案了。不再猛塞水果,而是加发酵食物——酸奶、纳豆、泡菜,还有专门给 kids 做的益生元粉。每天一点,不强迫,就是让他慢慢吃。
+
+三个月后复查,短链脂肪酸合成菌从 8% 涨到 22%。老师说"最近没再出现过拉裤子的事了"。我眼眶一下就热了。
+
+你想过没有,如果孩子便秘反复不好,是不是方向错了?先搞清楚缺什么,再补才对。
+
+你家孩子有过类似的糗事吗?评论区说说,我给你一些参考。
+
+#儿童便秘 #肠道菌群 #幼儿园 #妈妈日记 #养育焦虑
+
+---
+
+## A3 | 肠易激综合征
+
+**标题**:一紧张就拉肚子?测了菌群才知道是肠脑轴缺这菌
+
+**正文**:
+
+你有没有这种经历:一开会就胃疼,一考试就拉肚子,一紧张就想跑厕所?
+
+我以为我是肠胃弱,跑了无数次医院,检查全正常,医生也说"没问题"。后来才知道,这叫肠易激综合征,不是病,是你的肠脑轴在报警。
+
+肠脑轴是什么?简单说,肠道和大脑是连在一起的,它们互相发消息。当你压力大,肠道会收到"危险"信号,开始加速蠕动——然后你就拉肚子了。
+
+我去测了菌群,发现一个让我震惊的数据:我的"压力相关菌"明显偏高,而"安抚型菌"偏低。也就是说,我的肠道对压力过度敏感,稍微有点紧张它就"投降"。
+
+这个发现改变了我的处理方式。以前我焦虑的时候是吃药压,现在我知道是该补那些"安抚型菌"了——特定的菌株能帮助肠道分泌血清素,让肠子冷静下来。
+
+现在我在压力下还是会有反应,但频率从每周三四次降到了一个月一两次。不是完全好了,但我终于知道自己在做什么了,而不是每次拉肚子都慌一下。
+
+如果你也有"一紧张就肚子疼"的毛病,别只当肠胃问题治,查一下菌群,看看是不是肠脑轴在缺什么。
+
+你是紧张就腹泻还是便秘?评论区说说你的情况。
+
+#肠易激综合征 #肠脑轴 #菌群检测 #压力与肠胃 #健康日记
+
+---
+
+## A4 | 益生菌误区
+
+**标题**:吃了一年益生菌没用?测完发现你吃的菌根本不需要
+
+**正文**:
+
+你一定听过这句话:"便秘就吃益生菌呗。"
+
+我也信过。买了一罐又一罐,喝了一年,结果——一点变化都没有。我还以为是产品不对,又换牌子,更贵的那个,照样没用。
+
+直到我去测了肠道菌群,才发现一个让我哭笑不得的事实:我体内的乳酸菌和双歧杆菌,本来就不低。我花了一千多块钱补了一个根本不缺的东西。
+
+那为什么没人告诉我?因为市面上绝大多数益生菌产品,主打的就是这两个菌。但你不知道你自己的肠道缺不缺,盲目补,等于往已经满的杯子里继续倒水——溢出来了,杯子还是满的。
+
+检测报告里,我真正缺的不是乳酸菌,是产丁酸菌。丁酸菌负责什么?它是肠道的"燃料",让肠子自己有劲自己动。缺它,光补乳酸菌,肠子还是没力气。
+
+找到对的菌,比吃对的益生菌更重要。检测一次,就知道你该补什么、不该补什么。省下的钱,够买好几个月的益生菌。
+
+你现在吃的益生菌是什么牌子?评论区说说,我帮你对一对看是不是你需要的那种。
+
+#益生菌 #肠道菌群 #便秘 #智商税 #健康消费
+
+---
+
+## A5 | 检测前后对比
+
+**标题**:测完菌群才知道,原来我每天拉的"毒"是这些菌
+
+**正文**:
+
+先说结论:我没治好,我只是搞清楚了我到底缺什么。
+
+第一次检测那天,我看着报告上密密麻麻的数字,大部分是红色——低于参考值。我当时就在想,我肠道里是不是住了一堆"坏人"。
+
+报告翻译成人话是这样的:有益菌偏低,致病菌偏高,整体多样性不够。简单说,我肠道里"好警察"太少,"坏警察"太多,管不住场子了。
+
+后面几个月我做了什么?没有神药,就是按营养师建议,调整饮食结构:
+
+多吃:洋葱、大蒜、燕麦、香蕉、发酵食物
+少吃:精制糖、油炸、加工食品
+坚持:每天喝水够量,适量运动
+
+三个月后复测,变化是什么?
+
+致病菌从"偏高"变成了"正常",有益菌的多样性提升了,丁酸产量从低位涨到了中位。报告上的红色,越来越少。
+
+但我想强调一点:检测本身不治病,它只告诉你"哪里有问题"。真正的改变,是之后的饮食和生活调整。
+
+你现在最想搞清楚的是什么?是想知道自己缺什么,还是想知道报告怎么读?评论区说,下期我发一篇专门教你怎么看报告。
+
+#菌群检测 #肠道健康 #前后对比 #益生菌 #自我健康
+
+---
+
+*个人号 A 系列完成(用户视角版)。每篇发布前按《小红书发布指南》⭐ 内容创作第一原则自查:删掉品牌名内容还成立吗?主语是"您/你"吗?结尾有 20 秒行动吗?*

+ 257 - 0
运营文案/便秘腹泻账号运营方案.md

@@ -0,0 +1,257 @@
+# 小红书便秘/腹泻账号运营方案
+
+> 制定日期:2026-09-07
+> 参照标准:《小红书运营方略·知识图谱总结》核心原则
+> 核心原则:**诚信经营本价值,打造差异化优势**(对应企业经营本质 = 创造价值 + 建立优势)
+
+---
+
+## 一、账号定位(品牌主张 Viewpoint)
+
+### 1.1 竞品现状(实测:6 关键词、100+ 笔记、60+ 账号)
+
+| 类型 | 代表账号 | 核心打法 | 典型赞数 | 竞争度 |
+|------|---------|---------|---------|:------:|
+| **医生 IP** | 覃医生不焦虑、消化科钟医生、郑雪玲医生、儿科吴泽霖 | 职业背书、病因分型、用药指南、自测工具 | 800-8748 | 🔴 红海 |
+| **素人自愈** | 网球公主、南嫱、稚水、小象肠易激日记 | "我好了"故事、情感共鸣、无数据/无方法/无承接 | 2582-6732 | 🟠 中 |
+| **养生食养** | 豆麻爱养生、小轶妈妈育儿、凯妈食养日记 | 按摩/食疗/穴位、儿童便秘场景强 | 461-2688 | 🟡 中 |
+| **带货号** | 泡泡今天也发财、奶卷金橘 | 直接推益生菌/通便产品 | 676-886 | 🟠 中 |
+| **菌群检测** | Dr.李胜辉、海爸说、生物工程三哥 | 晒报告、质疑检测,**零系统化运营** | 30-460 | 🟢 **空白** |
+
+### 1.2 关键空白(浠艾福唯一能打的点)
+
+| 空白 | 竞品现状 | 浠艾福机会 |
+|------|---------|-----------|
+| **报告解读** | 只有"晒报告",没人教"数字代表什么、低了怎么办" | 官方号首发"拿到报告先看这一页"系列 |
+| **前后对比数据** | **零账号**有"检测→干预→复测"完整数据链 | 核心战略壁垒,个人号做"带数据的自愈故事" |
+| **专业劝退+边界** | 仅海爸说 1 号(30赞)质疑检测 | "先劝退 3 类人"式内容,建立信任 |
+| **儿童菌群检测** | 儿童便秘内容多但**零检测内容** | 隐藏机会,可单开子账号/专栏 |
+
+### 1.3 三条校准(基于竞品实测)
+
+1. **官方号第一篇做"报告解读",不做"便秘科普"**——科普是红海,报告解读是唯一空白
+2. **个人号必须带数据**——素人故事型已验证高赞(6732赞),浠艾福版升级为"测了→知道缺什么→补了→复测变了"
+3. **标题套用已验证公式**:数字+症状清单 / 反常识 / 亲测承诺 / 劝退型
+
+### 1.2 浠艾福差异化优势(战略资源)
+
+**可建立的优势 = 无法被轻易复制的战略资源**:
+
+| 资源 | 浠艾福独有性 | 竞品无 |
+|------|-------------|--------|
+| 真实菌群数据 | 12万+家庭检测报告积累 | ❌ |
+| 前后对比案例 | 检测→干预→复测完整数据链 | ❌ |
+| 五维能量体系 | 肠道健康与情绪/睡眠/专注力关联 | ❌ |
+| 免费报告解读 | 用户先拿到报告才能理解数据价值 | ❌ |
+| 规划师服务 | 一对一解读+个性化建议 | ❌ |
+
+### 1.3 品牌主张 Viewpoint
+
+```
+功能差异(Feature):检测肠道菌群、调理便秘腹泻
+品牌主张(Viewpoint):让"排便这件小事"不再让你焦虑
+
+为什么成立?
+- 真问题:70%慢性便秘/腹泻患者反复就医无果,焦虑>症状本身
+- 改判断:从"吃什么药/吃益生菌"转向"先看菌群缺什么、为什么缺"
+- 产品扛得住:菌群检测+报告解读+营养师方案形成闭环
+- 可验证:用户看完报告知道"我具体缺哪种菌、该补什么"
+```
+
+---
+
+## 二、目标用户(一方人群拓展法映射)
+
+### 2.1 核心人群(高相似低重合)
+
+| 人群细分 | 特征 | 搜索关键词 | 痛点 |
+|---------|------|-----------|------|
+| **职场妈妈(30-40岁)** | 孩子便秘/腹泻反复,自己也有肠道问题 | "孩子便秘怎么办""成人腹泻原因" | 没时间就医、担心孩子发育 |
+| **肠易激综合征患者(IBS)** | 症状反复,医院治疗效果有限 | "肠易激综合征""IBS-C调理" | 怕癌、怕一生好不了、不知道病因 |
+| **体检异常人群** | 体检发现菌群失调、肠镜提示炎症 | "菌群失调怎么办""肠镜正常但便秘" | 报告看不懂、不知道严重程度 |
+| **长期服药依赖者** | 泻药、益生菌吃多年无效 | "吃泻药依赖""益生菌没用" | 焦虑停药、不知道下一步怎么办 |
+
+### 2.2 用户真实顾虑(搜索联想词预测)
+
+| 用户搜什么 | 真实心里话 | 我们答什么 |
+|-----------|-----------|-----------|
+| "便秘怎么办" | 我试过很多方法,都不管用,还有救吗 | 先分清你是哪种便秘(4种分型),再对症 |
+| "腹泻是什么原因" | 会不会是癌症?要不要去肠镜? | 急症vs慢性vs功能性,一张表告诉你该不该去医院 |
+| "菌群检测靠谱吗" | 是不是智商税?会不会又追着卖东西? | 先看免费样题,觉得问得准再测 |
+| "肠易激综合征能根治吗" | 这辈子是不是就这样了? | 不能根治但能控制,关键在菌群重建 |
+| "孩子便秘影响身高吗" | 我是不是害了孩子? | 慢性便秘确实影响营养吸收,但干预后能追上 |
+
+---
+
+## 三、内容矩阵(种草+搜索双轮驱动)
+
+### 3.1 账号分层(1+N 矩阵)
+
+```
+┌─────────────────────────────────────────────────────────┐
+│  官方号:浠艾福肠道健康中心                               │
+│  → 定位:搜索收割、专业背书、转化承接                    │
+│  → 内容:搜索关键词覆盖、真实案例、数据支撑              │
+│  → 变现:菌群检测→报告解读→规划师服务                   │
+├─────────────────────────────────────────────────────────┤
+│  个人号A:肠道康复者(真实案例人设)                      │
+│  → 定位:种草、情绪共鸣、建立信任                        │
+│  → 内容:我如何从严重便秘到恢复正常                      │
+│  → 转化:私信领"分型自测表"                             │
+├─────────────────────────────────────────────────────────┤
+│  个人号B:营养师视角(专业人设)                          │
+│  → 定位:干货科普、建立权威                              │
+│  → 内容:为什么益生菌没用、你缺的是不是另一种菌          │
+│  → 转化:私信领"饮食调整清单"                           │
+└─────────────────────────────────────────────────────────┘
+```
+
+### 3.2 种草内容(个人号A)—— 情绪+场景+**数据**(竞品验证后校准)
+
+| 主题 | 标题(套用已验证公式) | 内容方向 | 目标 |
+|------|---------|---------|------|
+| **我的便秘自救史** | "3年便秘,测完菌群才知道缺什么,3个月复测数据变了" | **故事+数据**:症状→就医→绝望→检测(首测数据)→干预→复测(数据对比)→恢复 | 建立信任,**证明效果** |
+| **孩子便秘焦虑** | "幼儿园老师说我儿子拉裤子,测菌群发现原来是这菌少了" | 场景+数据:孩子便秘后果→检测发现缺菌→食疗调整→复测菌群恢复→孩子不拉裤子了 | 触动痛点,**有数据说话** |
+| **肠易激综合征** | "一紧张就拉肚子?测了菌群才知道是肠脑轴缺这菌" | 科普+数据:肠脑轴机制→检测发现缺丁酸菌/多巴胺菌→针对性补菌→症状改善 | 建立认知,**菌群证据** |
+| **益生菌误区** | "吃了一年益生菌没用?测完发现你吃的菌根本不缺" | 反常识+数据:益生菌≠万能→检测发现你缺的不是乳酸菌→精准补菌 | 打破认知,**数据定制** |
+| **检测前后对比** | "测完菌群才知道,原来我每天拉的'毒'是这些菌" | 前后对比:首测报告截图(标红缺菌)→干预方案→复测报告(缺菌恢复) | 证明效果,**可视化数据** |
+
+### 3.3 搜索收割内容(官方号)—— **报告解读优先** + 专业答疑(竞品验证后调整优先级)
+
+| 优先级 | 搜索词 | 标题(套用已验证公式) | 核心回答 | 转化钩子 |
+|------|--------|---------|---------|---------|
+| **1** | 菌群检测靠谱吗 | **"先劝退3类人,再告诉你什么情况下值得测菌群"** | 劝退:无症状/想速效/不愿看报告;值得:反复就医无果/想精准干预 | 私信领"免费样题" |
+| **2** | 菌群检测怎么看 | **"拿到菌群报告,先看这一页就够了"** | 教看"缺菌榜/菌群多样性/致病菌/代谢产物"四个关键页 | 评论区"看不懂哪一项私信我" |
+| **3** | 便秘怎么办 | **"便秘3种分型,测菌群才知道你是哪种,别再盲目吃药"** | 慢传输/出口梗阻/IBS/药物性——各自缺菌不同,干预不同 | 私信领"分型自测表" |
+| **4** | 腹泻原因 | **"急症vs慢性vs功能性,这张图告诉你该不该去医院+测菌群"** | 危险信号识别+就医指征+功能性腹泻查菌群找缺菌 | 评论区"有危险信号私信我" |
+| **5** | 肠易激综合征 | **"IBS-C能根治吗?医学答案和你想象的不一样——关键在菌群重建"** | 不能根治但能控制,关键补回丁酸菌/菌群多样性 | 私信领"IBS菌群调理清单" |
+| **6** | 孩子便秘严重吗 | **"这4个信号说明不是'正常',儿童菌群检测别等孩子喊疼才做"** | 危险信号:便血/体重不增/腹胀/疼痛 + 儿童菌群检测必要性 | 评论区"有吗?我帮你判断" |
+
+---
+
+## 四、内容创作标准(执行规范)
+
+### 4.1 标题规则
+
+| 规则 | 标准 | 示例 |
+|------|------|------|
+| 长度 | ≤20字 | "3年便秘,靠一张报告救了我" ✓ |
+| 前10字 | 必含痛点关键词 | "孩子便秘3年" ✓ |
+| 钩子 | 悬念/反常识/数字 | "益生菌没用?因为你吃错了" ✓ |
+| ❌ 禁止 | 品牌名/产品名前置 | "浠艾福菌群检测帮你解决便秘" ✗ |
+
+### 4.2 正文结构(用户视角)
+
+```
+第1句:回应具体顾虑(用户搜这个词时最担心的问题)
+↓
+第2-3句:场景/故事切入(让用户代入)
+↓
+中间:分层回答(3个要点,每点配一个具体建议)
+↓
+结尾:20秒行动 + 评论区钩子
+↓
+标签:#便秘调理 #肠易激综合征 #菌群检测 #儿童便秘
+```
+
+### 4.3 "用户视角"自查清单(发布前必过)
+
+- [ ] 开头第一句在回应用户顾虑,不是介绍品牌
+- [ ] 正文主语是"您/您家/您的钱",不是"我们/我们的测评"
+- [ ] 删掉品牌名后,内容对用户仍有价值
+- [ ] 每段都在回答"这对我有什么用"
+- [ ] 结尾有明确的下一步动作(<30秒能完成)
+
+---
+
+## 五、变现路径(信任构建→转化)
+
+### 5.1 漏斗设计
+
+```
+搜索进入(官方号)           个人号种草
+      ↓                         ↓
+  阅读答疑文章              情绪共鸣案例
+      ↓                         ↓
+  私信"分型自测表"          私信"饮食清单"
+      ↓                         ↓
+  规划师1v1沟通             规划师1v1沟通
+      ↓                         ↓
+  推荐菌群检测(199元)       推荐菌群检测(199元)
+      ↓                         ↓
+  报告解读(免费)            报告解读(免费)
+      ↓                         ↓
+  个性化方案(999元)         个性化方案(999元)
+      ↓                         ↓
+  复购/转介绍                复购/转介绍
+```
+
+### 5.2 价格锚定(投资回报逻辑)
+
+| 用户顾虑 | 价格锚定话术 |
+|---------|-------------|
+| "检测要多少钱?" | "比您之前买的那些没用的益生菌便宜,而且能告诉您到底缺什么,不用再盲试了" |
+| "解读报告有人帮我看吗?" | "报告解读免费,您拿着报告走也行,不买服务也能问清楚每一行数字什么意思" |
+| "会不会又追着推销?" | "先拿样题看看问得在不在点子上,觉得扯,划走,不花钱" |
+
+---
+
+## 六、90天启动计划
+
+### Phase 1:信任构建期(0-30天)
+
+| 周 | 任务 | 交付物 |
+|----|------|--------|
+| W1 | 账号注册+资料完善 | 3个账号主页、头像、简介 |
+| W2 | **个人号A首发5篇种草内容**(按已验证标题公式+数据差异) | 见《个人号A系列正文》 |
+| W3 | **官方号首发2篇搜索内容**(优先级最高的"报告解读"+"劝退型") | 见《官方号首发正文》 |
+| W4 | 测试+数据复盘 | 哪类标题点击率高、哪个搜索词有流量 |
+
+### Phase 2:搜索覆盖期(31-60天)
+
+| 任务 | 交付物 |
+|------|--------|
+| 搜索词扩展至20个 | 20篇搜索收割内容(覆盖:"便秘吃什么""腹泻原因""肠镜normal""菌群失调""儿童便秘""成人便秘""IBS-C""IBS-D"等) |
+| 个人号B内容补充 | 5篇营养师视角干货 |
+| 私信话术标准化 | 3套自动回复(分型自测表/饮食清单/样题领取) |
+
+### Phase 3:转化验证期(61-90天)
+
+| 任务 | 交付物 |
+|------|--------|
+| 首单转化测试 | 10个私信→3个检测→1个方案购买 |
+| 案例素材积累 | 3个完整前后对比案例(脱敏) |
+| 内容迭代 | 根据数据淘汰低效内容,放大高效内容 |
+
+---
+
+## 七、风险与风控
+
+| 风险 | 应对 |
+|------|------|
+| 医疗敏感词限流 | "治疗"→"调理","治愈"→"改善",避免诊断性表述 |
+| 夸大效果违规 | 不承诺"根治",用"改善""帮助";附免责声明 |
+| 竞品举报 | 不点名竞品,不贬低其他检测产品 |
+| 用户期望过高 | 明确"检测是了解,干预是长期",不制造速效幻觉 |
+
+---
+
+## 八、数据追踪指标
+
+| 指标 | 目标(30天) | 目标(90天) |
+|------|-------------|-------------|
+| 账号粉丝 | 500 | 3000 |
+| 笔记曝光 | 人均500 | 人均2000 |
+| 收藏率 | >5% | >8% |
+| 评论率 | >1% | >2% |
+| 私信率 | >0.5% | >1% |
+| 检测转化率 | — | >10%(私信→检测) |
+| 方案购买率 | — | >30%(检测→方案) |
+
+---
+
+*本方案参照《小红书运营方略·知识图谱总结》核心原则制定:*
+- *企业经营本质 = 创造价值(用户痛点)+ 建立优势(菌群数据壁垒)*
+- *营销三件事 = 创造(差异化主张)+ 传播(种草+搜索)+ 传递(检测→解读→方案)*
+- *品牌主张 Viewpoint = "让排便不再焦虑"(非功能差异,而是独特视角)*
+- *内容创作第一原则 = 讲用户想听的,不讲我想说的*

+ 142 - 0
运营文案/便秘腹泻赛道竞品分析.md

@@ -0,0 +1,142 @@
+# 小红书便秘/腹泻赛道 · 竞品账号分析
+
+> 抓取时间:2026-09-07
+> 方法:小红书搜索页实时抓取(6 个关键词,100+ 笔记,60+ 账号)
+> 关键词:便秘怎么办 / 腹泻是什么原因 / 肠易激综合征 / 肠道菌群检测 / 益生菌便秘 / 儿童便秘
+
+---
+
+## 一、结论先行:三个关键判断
+
+1. **便秘/腹泻赛道高度饱和**——医生科普号、素人亲测号、养生号、带货号四类扎堆,纯内容号极难突围
+2. **"菌群检测+数据"是唯一明显空白**——整个赛道几乎没有账号做"检测→解读→干预→复测"闭环,只有零星亲测记录
+3. **竞品都在"讲症状和方法",没人讲"数据和个人差异"**——这恰是浠艾福的战略资源(12万+真实菌群数据)能打的点
+
+---
+
+## 二、竞品账号四类格局
+
+### 2.1 医生个人 IP 号(最大竞争,占 40%+)
+
+| 账号 | 代表内容 | 赞数 | 打法 |
+|------|---------|:----:|------|
+| 覃医生不焦虑 | 娇气的胃肠道科普 | 8748 | 情绪安抚+科普 |
+| 消化内郑雪玲医生 | 拉肚子喷射状 6 个原因 | 3439 | 病因分型 |
+| 消化科钟医生 | 拉肚子喷射状严重吗 | 2761 | 危险信号识别 |
+| 艾瑞克的医学笔记 | 一吃就拉怎么回事 | 959 | 一分钟速懂 |
+| 王丹医生养肠胃 | 拉稀水不一定都是肠胃炎 | — | 反常识纠偏 |
+| 唐医生聊胃肠 | 一辈子不想腹泻四要四不要 | — | 清单口诀 |
+| 深圳消化内镜蔡军 | IBS 六种联合用药 | 823 | 用药指南 |
+| 儿科医生吴泽霖 | 宝宝便秘 4 种用药方案 | 521 | 儿童用药 |
+| 阿基医生 | 4 种益生菌便秘适用 | 329 | 益生菌推荐 |
+| Dr.硕士菲菲 | 3 招自测肠易激 | 928 | 自测工具 |
+
+**特征**:靠职业背书,讲"病因/用药/危险信号",标题带数字(6个原因、4种方案、3招自测)。
+
+### 2.2 素人亲测/自愈号(占 30%,情感共鸣最强)
+
+| 账号 | 代表内容 | 赞数 |
+|------|---------|:----:|
+| 网球公主 | 肠易激彻底好啦,你也能好 | 6732 |
+| 南嫱 | 五年肠易激好像好了 | 2910 |
+| 稚水 | 肠易激不用药好啦 | 2822 |
+| 小象肠易激养胃自愈日记 | 我的肠易激终于痊愈啦 | 2582 |
+| 大牙牙的小锤锤 | 产后便秘已解决 | — |
+| 青栀 | 无痛胃肠镜确诊 IBS | — |
+
+**特征**:"我好了"故事型,情感共鸣极强,收藏转发高,但**无数据、无方法论、无承接**——用户看完感动,却无法复制。
+
+### 2.3 养生/中医号(占 15%)
+
+| 账号 | 代表内容 | 赞数 |
+|------|---------|:----:|
+| 豆麻爱养生 | 每天按一按宝宝噗噗顺畅 | 1638 |
+| 凯妈食养日记 | 羊屎蛋找到解决方法 | 461 |
+| 小轶妈妈育儿 | 10 种食物帮手 | 2688 |
+| 暄和闲叙 | 入秋拉肚子是身体排寒 | — |
+| 生命巴 | 便秘不是上火是肠子没力气 | — |
+
+**特征**:食养/按摩/穴位/体质,年轻用户接受度低,但儿童便秘场景有效。
+
+### 2.4 种草带货号(占 10%)
+
+| 账号 | 代表内容 | 赞数 |
+|------|---------|:----:|
+| 泡泡今天也发财 | 拉不出💩都去买这个 | 886 |
+| 奶卷金橘 | 减脂期通便吃/喝细节 | 676 |
+
+**特征**:直接带货益生菌/通便产品,信任门槛高,转化率低。
+
+---
+
+## 三、"肠道菌群检测"细分赛道(关键空白)
+
+### 3.1 现有内容(零星,无系统运营)
+
+| 账号 | 内容 | 赞数 | 问题 |
+|------|------|:----:|------|
+| Dr.李胜辉 | 一家三口的肠道菌群检测报告 | 460 | 晒报告,无解读,无干预 |
+| 我要涨肌肉 | 北大人民医院高通量检测做了 | 175 | 记录型,无方法论 |
+| 生物工程三哥 | 菌群检测分析 | 175 | 专业但小众 |
+| 海爸说(蓝觅) | 为什么我不建议做菌群检测 | 30 | 质疑型,声音弱 |
+| 呆呆不呆 | 桥本姐妹菌群报告出了 | 30 | 晒报告 |
+| 家乐妈妈 | 孩子发育迟缓,肠道检测别乱做 | — | 谨慎提醒 |
+
+### 3.2 空白分析
+
+| 空白 | 说明 | 浠艾福机会 |
+|------|------|-----------|
+| **系统化解读** | 现有内容只"晒报告",没人教"怎么看、数字代表什么、低了怎么办" | 官方号做"报告解读"系列 |
+| **前后对比** | 零账号展示"检测→干预→复测"数据链 | 核心差异化,用真实数据说话 |
+| **劝退+边界** | 只有海爸说1个质疑号(30赞) | "先劝退3类人"式内容,建立信任 |
+| **儿童菌群** | 儿童便秘内容多,但无人做"儿童菌群检测" | 儿科空白,结合儿童便秘痛点 |
+
+---
+
+## 四、热门标题公式(竞品验证)
+
+| 公式 | 实例 | 赞数 |
+|------|------|:----:|
+| 数字+症状清单 | "6个最常见的原因""4种益生菌""10种食物" | 3439/329/2688 |
+| 亲测承诺 | "亲测有效""终于上岸了""彻底好啦" | 6732/2822 |
+| 反常识纠偏 | "不一定都是肠胃炎""不是上火""不是吃坏肚子" | — |
+| 恐惧警示 | "严重吗""别乱做""当心" | 2761 |
+| 第一人称治愈 | "我的肠易激终于好啦""五年肠易激好了" | 2910/2582 |
+
+---
+
+## 五、对浠艾福方案的校准(基于竞品)
+
+### 5.1 不要硬碰的红海
+
+| 红海 | 原因 | 浠艾福策略 |
+|------|------|-----------|
+| 医生科普 | 10+ 医生号已占位,职业背书无法模仿 | ❌ 不主打"病因科普" |
+| 素人自愈故事 | 情感共鸣强,但无法转化 | ⚠️ 个人号可做,但必须带数据 |
+| 通便妙招清单 | 同质化严重 | ❌ 不做纯清单 |
+| 益生菌带货 | 信任门槛高,转化差 | ❌ 不直接带货 |
+
+### 5.2 应该主打的空白(竞品验证的机会)
+
+| 空白 | 浠艾福打法 |
+|------|-----------|
+| **报告解读** | 官方号"拿到菌群报告,先看这一页"——教用户看懂数据 |
+| **前后对比数据** | 唯一能做"检测→干预→复测"闭环的账号,这是战略壁垒 |
+| **劝退式信任** | "先劝退3类人"——反常识建立信任,对标海爸说但更专业 |
+| **儿童菌群检测** | 儿童便秘内容多但无检测内容,差异化切入 |
+
+### 5.3 品牌主张再校准
+
+竞品验证:**"让排便不再焦虑"的主张成立**——因为:
+- 竞品要么讲症状(医生号),要么讲故事(素人号),没人讲"焦虑"这个情绪层
+- 用户搜"便秘怎么办"背后是焦虑(试了很多方法没用),不是缺知识
+- 浠艾福能给的独特价值:不是又一个"通便妙招",而是"用数据看清你缺什么,别再盲试"
+
+---
+
+## 六、行动建议(更新优先级)
+
+1. **官方号第一篇就做"报告解读"**,不是"便秘科普"——因为报告解读是唯一空白,便秘科普是红海
+2. **个人号做"带数据的自愈故事"**——素人号已验证故事型高赞,但浠艾福版要加"检测前后对比数据"
+3. **儿童菌群检测是隐藏机会**——儿童便秘赛道活跃但无检测内容,可单独做一个子账号或专栏
+4. **标题套用已验证公式**——数字+症状清单("3类人别做检测")、反常识("益生菌没用?")、亲测承诺("测完才知道")

+ 157 - 0
运营文案/小红书运营方略/知识图谱总结.md

@@ -0,0 +1,157 @@
+# 小红书运营方略 · 知识图谱总结
+
+> 数据源:`运营文案\小红书运营方略\` 下 4 份远程同步知识(4 份转录/笔记)
+> 整理方式:实体(节点)→ 属性 → 关系(边),按"理论层 → 方法论层 → 案例层"三层组织
+> 整理日期:2026-09-07
+
+---
+
+## 〇、知识图谱总览(顶层结构)
+
+```mermaid
+graph TB
+    subgraph 理论层["理论层:企业经营本质"]
+        A[企业经营] -->|="A1 创造价值
+        A -->|="A2 建立优势
+        A2 -->|三件事| A2a[建立/保持/发挥优势]
+        A2 -->|来源| A2b[战略资源×10类]
+        B[营销] -->|三件事| B1[创造价值]
+        B --> B2[传播价值]
+        B --> B3[传递价值]
+        B1 -->|价值罗盘| B1a[功能/体验/象征]
+        B2 -->|本质| B2a["在顾客出现的地方,告诉他有他需要的东西"]
+        B2 -->|新变化| B2b[传播即渠道·渠道传播合一]
+    end
+
+    subgraph 方法论层["方法论层:小红书打法"]
+        C[一方人群拓展法] -->|工具| C1[年轮图→高相似低重合人群包]
+        D[内容双轮驱动]
+        D --> D1[种草内容:起点用户兴趣→终点唤起购买欲]
+        D --> D2[搜索内容:起点答案预期→终点打消顾虑]
+        E[内容漏斗] --> E1[高频阅读+低频购买]
+        F[IP路径] --> F1[创始人IP·专家路线]
+        F --> F2[节目IP·文化名人]
+    end
+
+    subgraph 案例层["案例层:半日闲床垫"]
+        G[0到1] -->|核心命题| G1[信任构建·敢不敢买]
+        G -->|三件事| G1a[锁定头部买手]
+        G --> G1b[创始人IP塑造]
+        G --> G1c[种草直播联动]
+        H[1到10] -->|核心命题| H1[心智占领·为什么想起你]
+        H -->|困境| H2[功能差异失效→参数内卷]
+        H -->|破局| H3[品牌主张·好觉不费力]
+        H3 -->|验证| H3a[真问题/改判断/产品扛得住/可验证]
+    end
+
+    A --> C
+    A --> G
+    B2 --> D
+    F --> G1b
+```
+
+---
+
+## 一、实体表
+
+### 1.1 核心概念实体(理论层)
+
+| 实体 | 属性/定义 | 出处 |
+|------|----------|------|
+| **企业经营** | = 创造价值 + 建立优势(只有这两件事) | 文件2 |
+| **创造价值** | 提供独特的客户价值,企业存在的基础 | 文件2 |
+| **建立优势** | 在竞争中获胜的原因;建立→保持→发挥 | 文件2 |
+| **战略资源** | 长期构建、对手无法以经济损失复制、需投入巨大金钱/资源/时间的资产 | 文件2 |
+| **营销** | 围绕"价值"展开的活动体系(≠推广) | 文件2 |
+| **创造/传播/传递价值** | 对应 4P:产品 / 推广 / 渠道 | 文件2 |
+| **价值罗盘** | 功能价值 / 体验价值 / 象征价值 三种 | 文件2 |
+| **传播本质** | 在顾客出现的地方,告诉他你有他需要的东西(信息差服务) | 文件2 |
+| **传播即渠道** | 今天传播与购买不分离,无需口号,只需购买理由 | 文件2 |
+| **信任构建(0到1)** | 让用户敢掏钱 | 文件1/3/4 |
+| **心智占领(1到10)** | 让用户想起你、优先选你 | 文件1/3/4 |
+| **品牌主张 Viewpoint** | 难以抄袭的独特视角(≠功能 Feature) | 文件3 |
+
+### 1.2 核心方法论实体(方法论层)
+
+| 实体 | 属性/定义 | 出处 |
+|------|----------|------|
+| **一方人群拓展法** | 上传已购脱敏人群→年轮图→识别高相似低重合人群包 | 文件1 |
+| **种草内容** | 起点=用户兴趣,终点=唤起购买欲;展示生活状态+独特卖点 | 文件1 |
+| **搜索内容** | 起点=用户对答案的预期,终点=打消顾虑;解决具体痛点 | 文件1 |
+| **价格锚定** | 用"投资回报"逻辑重构价格感知(如 900元→多挣3000元) | 文件1 |
+| **内容漏斗** | 内容与商业模式/画像匹配;高频阅读粘住低频购买用户 | 文件4 |
+| **创始人IP(专家路线)** | 放弃泛流量,为信任做内容,客观中立甚至推荐竞品 | 文件1/4 |
+| **节目IP** | 借文化名人/专家做访谈纪录片,提升格调 | 文件4 |
+| **私域赋能(双轮驱动)** | 公域引流→私域教育→直播成交,转化率提升1倍 | 文件1/4 |
+| **品牌主张四大验证** | 真问题 / 改判断 / 产品扛得住 / 可验证 | 文件3 |
+
+### 1.3 案例实体(案例层)
+
+| 实体 | 关键事实 | 佐证的原理 |
+|------|---------|-----------|
+| **半日闲(床垫)** | 2020成立;客单5000(天猫)/9700(线下),行业均价3倍;小红书床垫类目连续3年第一 | 高客单低频重决策品类打法 |
+| **古茗奶茶** | 唯一单店营业额仍增长的奶茶品牌;品控稳定 | 优势=品控(非营销) |
+| **小米生态链** | 靠线上流量优势做成生态链(华为/OPPO线下学不成) | 优势决定能做什么,而非定位 |
+| **得到录音卡** | 竞品是"手机"而非其他录音卡;价格锚定"花900多挣3000" | 认知障碍+价格锚定 |
+| **三炮弹瓜子** | 种子公司研发363向日葵种子,别人做不出 | 功能价值独特性 |
+| **杭派碧根果** | 爆壳碧根果,易剥出完整果仁 | 功能价值独特性 |
+| **九号平衡车 / 自拍杆** | 结构专利垄断,每年收数亿专利费 | 独占资源/专利壁垒 |
+| **探秘睡眠灯** | 黄光芯片(姜凤毅院士技术),无蓝光助眠 | 技术壁垒+传播待补 |
+| **新东方烹饪学校** | 高铁广告→农村刷墙("学厨师也是铁饭碗") | 顾客在哪里,传播就在哪里 |
+| **山下有松** | 与陈丹青合作,包袋绑定文艺生活方式 | 节目IP借势 |
+| **君君辅食记** | 1年涨粉88万,244万粉;品类化+专家背书+垂直重复 | 垂直+范式重复 |
+
+---
+
+## 二、关系表(核心逻辑链)
+
+| 起点 | 关系 | 终点 | 说明 |
+|------|------|------|------|
+| 企业经营 | 包含 | 创造价值 | 存在的基础 |
+| 企业经营 | 包含 | 建立优势 | 成功的原因 |
+| 战略资源 | 支撑 | 建立优势 | 组织能力/知识/资源/信用/技术壁垒/规模/品牌声誉/独占资源/组织效率/商业信用/人才 |
+| 营销 | 核心 | 价值 | 而非推广 |
+| 传播 | 本质 | 信息差服务 | 需求与供给之间存在信息差 |
+| 种草内容 | 面向 | 唤起购买欲 | 起点是用户兴趣 |
+| 搜索内容 | 面向 | 打消顾虑 | 起点是答案预期 |
+| 0到1 | 核心命题 | 信任构建 | 敢不敢买 |
+| 1到10 | 核心命题 | 心智占领 | 为什么想起你 |
+| 功能差异 | 失效于 | 参数内卷/抄袭 | 大品牌10倍预算复制 |
+| 品牌主张 | 替代 | 功能差异 | "更好"转向"不同" |
+| 创始人IP | 走 | 专家路线 | 客观中立换信任 |
+| 私域 | 赋能 | 达人直播 | 双轮驱动,转化×2 |
+| 一方人群法 | 服务 | 精准投放 | 高相似低重合=营销机会 |
+
+---
+
+## 三、三条贯穿全部文件的主线
+
+### 主线1:企业经营 > 营销(认知框架)
+企业经营只问两件事:**创造价值** + **建立优势**。营销只是价值的三件事(创造/传播/传递)。判断一个动作该不该做,不看"符不符合营销理论",只看"能不能建立经营优势"。
+
+### 主线2:信任是核心货币(0到1)
+小红书对高客单、低频、重决策品类(床垫/家居/教育测评)的核心价值 = 构建信任。手段:创始人IP专家路线、私域教育、真实证据(好评/工厂实拍/服务细节)、客观中立(甚至推荐竞品)。
+
+### 主线3:从"更好"到"不同"(1到10)
+功能差异会被复制、陷入参数内卷。破局靠**品牌主张(Viewpoint)**——难以抄袭的独特视角,经"真问题/改判断/产品扛得住/可验证"四关筛选。
+
+---
+
+## 四、映射到浠艾福(知识复用)
+
+| 知识 | 浠艾福对应 |
+|------|-----------|
+| 种草+搜索双轮驱动 | 个人号做种草(唤起兴趣),官方号做搜索(打消"靠不靠谱/值不值"顾虑)——已落地为第一批5篇 |
+| 传播=在顾客出现的地方 | 家长搜"核心素养测评/自驱力/菌群检测"处布局搜索内容 |
+| 创始人IP专家路线 | 54岁冻龄妈妈人设,客观中立、讲真话、甚至劝退 |
+| 价格锚定(投资回报) | "测评几百块,省下2万块报错班的冤枉钱" |
+| 信任构建(0到1) | 报告解读免费、先领样题、不买也能问清——先给价值 |
+| 品牌主张(1到10待办) | ⏳ 待确认:浠艾福的"好觉不费力"式主张是什么(五维能量如何升级为独特视角) |
+
+---
+
+## 五、待确认事项(从4份文件中带出的开放问题)
+
+1. **浠艾福的品牌主张**:功能=五维测评/成长规划/健康报告;但"五维能量"目前仍是 Feature 不是 Viewpoint。参照半日闲"好觉不费力",浠艾福需要一个"难以抄袭的独特视角"(如"看见孩子真正差在哪")。
+2. **信任凭证库**:半日闲靠"真实好评+工厂实拍+服务细节"做私域教育。浠艾福需要对应物——真实测评前后对比截图、家长证言、报告解读案例。
+3. **搜索内容扩展**:半日闲做法是先铺种草再铺搜索收割。浠艾福目前官方号搜索内容先行,个人号种草还没批量产出——顺序是否要补。

+ 85 - 0
运营文案/竞品搜索_raw.txt

@@ -0,0 +1,85 @@
+
+=== 便秘怎么办 ===
+潍坊市人民医院 | 3.3万 | 177赞 | 09-01
+2025-04-18 | 3.3万 | 177赞 | 09-01
+后悔太晚知道便秘的真相 | 荞麦的谷小雨 | 177赞 | 09-01
+荞麦的谷小雨 | 177 | 2202赞 | 05-20
+别乱吃药了!5个通便妙招亲测有效,好用 | 老马乐呵呵 | 2202赞 | 05-20
+胆病医生张平 | 04-06 | 187赞 | 07-05
+自精致日常社 | 187 | 2194赞 | 07-02
+长期顽固性便秘如何调理 | 便秘最快的解决办法 | 1025赞 | 2025-05-08
+便秘最快的解决办法 | 便秘按哪个穴 | 1025赞 | 2025-05-08
+便秘按哪个穴 | 30岁老便秘终于上岸了! | 1025赞 | 2025-05-08
+佛系青年养生计划 | 05-26 | 526赞 | 05-14
+2025-08-05 | 2.4万 | 423赞 | 06-15
+拉屎又快又多指南 | 李博说菌菌 | 423赞 | 06-15
+重生后靠教人拉屎发家致富 | 万一呢 | 1609赞 | 08-11
+2025-08-21 | 1.3万 | 4952赞 | 07-03
+
+=== 腹泻是什么原因 ===
+电话:9501-3888 | 消化内郑雪玲医生 | 3439赞 | 03-25
+拉肚子喷射状,拉稀水?6个最常见的原因! | 消化内郑雪玲医生 | 3439赞 | 03-25
+消化内郑雪玲医生 | 03-25 | 959赞 | 08-30
+一吃就拉,怎么回事?一分钟搞懂 | 艾瑞克的医学笔记 | 959赞 | 08-30
+艾瑞克的医学笔记 | 959 | 65赞 | 09-04
+拉稀水💦喷射状腹泻,不一定都是肠胃炎! | 王丹医生养肠胃 | 65赞 | 09-04
+王丹医生养肠胃 | 一辈子不想腹泻,记住四要四不要 | 269赞 | 07-29
+一辈子不想腹泻,记住四要四不要 | 唐医生聊胃肠 | 269赞 | 07-29
+唐医生聊胃肠 | 269 | 217赞 | 08-29
+入秋后拉肚子,不是吃坏肚子,是身体在“排寒” | 暄和闲叙 | 217赞 | 08-29
+昨天 19:19 | 680 | 2761赞 | 03-18
+拉肚子喷射状严重吗?6个常见原因! | 消化科钟医生 | 2761赞 | 03-18
+消化科钟医生 | 03-18 | 941赞 | 09-03
+2025-05-20 | 乐娃妈妈聊育儿 | 655赞 | 04-23
+6 大腹泻类型对照表|别再乱吃止泻药了! | 乐娃妈妈聊育儿 | 655赞 | 04-23
+
+=== 肠道菌群检测 ===
+做了个肠道菌群检测 | 04-25 | 8赞 | 08-18
+肠道菌群检测报告出来了! | 11.🐷 | 8赞 | 08-18
+生物工程三哥 | 219 | 175赞 | 02-09
+2025-11-01 | 219 | 175赞 | 02-09
+北大人民医院的肠道菌群高通量检测做了 | 我要涨肌肉 | 175赞 | 02-09
+一家三口的肠道菌群检测报告 | Dr.李胜辉 | 460赞 | 07-06
+Dr.李胜辉 | 460 | 10赞 | 05-23
+暖心分享的多多妈🎈 | 3天前 | 19赞 | 07-01
+为什么我不建议做肠道菌群检测? | 海爸说(蓝觅) | 19赞 | 07-01
+海爸说(蓝觅) | 桥本姐妹的肠道菌群报告出了! | 30赞 | 07-18
+桥本姐妹的肠道菌群报告出了! | 呆呆不呆 | 30赞 | 07-18
+孩子发育迟缓,肠道检测别乱做! | 家乐妈妈 | 30赞 | 2025-08-08
+2025-08-08 | 肠道菌群也查了 | 194赞 | 04-10
+肠道菌群也查了 | 梦玉同学 | 194赞 | 04-10
+肠道菌群检测是智商税吗?我测了,来聊聊 | 海伦博士的科学抗老 | 13赞 | 08-13
+
+=== 益生菌便秘 ===
+电话:9501-3888 | 便秘太严重了...不想依赖药物... | 12赞 | 08-15
+便秘太严重了...不想依赖药物... | 不爱说话 | 12赞 | 08-15
+拉不出💩的美女都给我去买这个!!!! | 泡泡今天也发财 | 886赞 | 08-18
+泡泡今天也发财 | 886 | 329赞 | 06-23
+4种益生菌,便秘适用! | 阿基医生 | 329赞 | 06-23
+益生菌正确吃法大揭秘 | 1191 | 86赞 | 05-16
+减脂期不💩㊙️,吃/喝细节分享(方便跟吃) | 奶卷金橘 | 676赞 | 08-23
+RX-seven | 3天前 | 18赞 | 07-03
+顺产产后问题记录——便秘(已解决) | 大牙牙的小锤锤 | 18赞 | 07-03
+大牙牙的小锤锤 | 便秘不是上火!是你的肠子没力气了💢 | 6赞 | 08-27
+便秘不是上火!是你的肠子没力气了💢 | 生命巴 | 6赞 | 08-27
+㊗️大家早日摆脱便秘O(∩_∩)O | 常守朱 | 9赞 | 06-30
+治便秘巨好用!我的医生老公给我开的 | 大可爱 | 720赞 | 08-18
+1招打开💩门 | 轰轰za | 88赞 | 08-21
+不便秘的感觉谁能懂! | 黑芝麻 | 71赞 | 06-27
+
+=== 儿童便秘 ===
+电话:9501-3888 | 7啊7啊7 | 301赞 | 05-02
+终于打败羊粪蛋 纯分享无广! | 426 | 1638赞 | 04-25
+2018888 | 426 | 1638赞 | 04-25
+每天按一按揉一揉,宝宝噗噗顺畅 | 豆麻爱养生 | 1638赞 | 04-25
+🔥宝羊屎蛋💩,终于找到解决方法啦! | 凯妈食养日记 | 461赞 | 07-01
+悠悠妈不挑食 | 03-04 | 521赞 | 08-11
+宝宝拉不出粑粑?这4种用药方案针对便秘! | 儿科医生吴泽霖 | 521赞 | 08-11
+儿童严重便秘 | 小学生便秘 | 2688赞 | 02-05
+一岁宝宝便秘怎么办 | 小轶妈妈育儿 | 2688赞 | 02-05
+宝宝排便困难?🔟种食物帮手,🔟种食物要避开 | 小轶妈妈育儿 | 2688赞 | 02-05
+小轶妈妈育儿 | 02-05 | 272赞 | 03-16
+Moly 偶尔 | 05-12 | 109赞 | 01-23
+咸鱼的小生活 | 03-04 | 1050赞 | 07-06
+辰辰中式育儿 | 1050 | 679赞 | 08-20
+孩子在校憋便⚠️开学便秘家长要重视 | 淘淘的爱 | 679赞 | 08-20