Просмотр исходного кода

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

Sisyphus Agent 1 месяц назад
Родитель
Сommit
a32778d07a

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

@@ -11205,5 +11205,48 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
 
         // 迁移315: article_reading_records 添加 reading_completed 列(阅读达标后标记,用于推荐过滤)
         ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
+
+        // 迁移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) {
+            // 索引已存在,忽略错误
+        }
     }
 }

+ 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);
+    }
+}

+ 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);
+    }
 }

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

+ 30 - 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;
+        }
+    }
+
     /**
      * 识别图片中的食材
      */

+ 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);
 }

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

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

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

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

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

+ 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

+ 3 - 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
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -29,7 +29,9 @@ 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.on_event("startup")

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-6e6f82da6c49c5718598b662f24c817434da66bb
+68a9c8026db8ec2166f394d4b368317fd18f2590

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1428",
+  "version": "1.0.1431",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1428",
+      "version": "1.0.1431",
       "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.1429",
+  "version": "1.0.1432",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,41 @@
 
 ---
 
+## 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 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1429
+> 当前版本: v1.0.1432
 
 ## 历史版本
 
@@ -8,6 +8,41 @@
 
 ---
 
+## 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 修复

+ 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` | 同步新增接口文档 |

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