瀏覽代碼

docs: 商品推荐功能实施计划

Xiaogang Liao 2 月之前
父節點
當前提交
47e5391dcf

+ 113 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/KnowledgeBaseController.java

@@ -3,15 +3,28 @@ package com.etotem.cfc.controller.admin;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.service.DanKnowledgeBaseService;
+import com.etotem.cfc.service.DifySyncService;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.util.StringUtils;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 @RestController
 @RequestMapping("/api/admin/knowledge-base")
@@ -20,6 +33,11 @@ public class KnowledgeBaseController {
     @Resource
     private DanKnowledgeBaseService danKnowledgeBaseService;
 
+    @Resource
+    private DifySyncService difySyncService;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
         String keyword = (String) params.get("keyword");
@@ -54,9 +72,21 @@ public class KnowledgeBaseController {
             knowledge.setStatus(((Number) params.get("status")).intValue());
         }
         knowledge.setRemark((String) params.get("remark"));
+        knowledge.setFileUrl((String) params.get("fileUrl"));
+        knowledge.setFileName((String) params.get("fileName"));
+        knowledge.setSourceUrl((String) params.get("sourceUrl"));
+        knowledge.setSourceType((String) params.get("sourceType"));
         List<Long> dimensionIds = (List<Long>) params.get("dimensionIds");
         List<Long> tagIds = (List<Long>) params.get("tagIds");
-        return Result.success(danKnowledgeBaseService.saveWithAssociations(knowledge, dimensionIds, tagIds));
+        DanKnowledgeBase saved = danKnowledgeBaseService.saveWithAssociations(knowledge, dimensionIds, tagIds);
+        if (saved.getId() != null && StringUtils.hasText(saved.getContent())) {
+            try {
+                List<String> codes = danKnowledgeBaseService.getDimensionCodesForKnowledge(saved.getId());
+                difySyncService.syncToDify(saved, codes);
+            } catch (Exception e) {
+            }
+        }
+        return Result.success(saved);
     }
 
     @PostMapping("/delete")
@@ -82,4 +112,85 @@ public class KnowledgeBaseController {
         danKnowledgeBaseService.bindTags(knowledgeId, tagIds);
         return Result.success(null);
     }
-}
+
+    @PostMapping("/upload-file")
+    public Result<Map<String, String>> uploadFile(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+        String originalFilename = file.getOriginalFilename();
+        String ext = "";
+        if (originalFilename != null && originalFilename.contains(".")) {
+            ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+        }
+        String savedName = UUID.randomUUID().toString().replace("-", "") + ext;
+        try {
+            Path uploadDir = Paths.get(System.getProperty("user.dir"), "uploads", "knowledge");
+            Files.createDirectories(uploadDir);
+            Path targetPath = uploadDir.resolve(savedName);
+            Files.copy(file.getInputStream(), targetPath);
+            String fileUrl = "/uploads/knowledge/" + savedName;
+            Map<String, String> result = new HashMap<>();
+            result.put("fileUrl", fileUrl);
+            result.put("fileName", originalFilename);
+            return Result.success(result);
+        } catch (IOException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/fetch-url")
+    public Result<Map<String, String>> fetchUrl(@RequestBody Map<String, String> params) {
+        String url = params.get("url");
+        if (!StringUtils.hasText(url)) {
+            return Result.error("网址不能为空");
+        }
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
+            HttpEntity<String> entity = new HttpEntity<>(headers);
+            org.springframework.http.ResponseEntity<String> resp = restTemplate.exchange(
+                url, org.springframework.http.HttpMethod.GET, entity, String.class);
+            String html = resp.getBody();
+            if (html == null) {
+                return Result.error("无法获取网页内容");
+            }
+            String title = extractTitle(html);
+            String content = extractContent(html);
+            Map<String, String> result = new HashMap<>();
+            result.put("title", title);
+            result.put("content", content);
+            result.put("sourceUrl", url);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error("获取失败: " + e.getMessage());
+        }
+    }
+
+    private String extractTitle(String html) {
+        int titleStart = html.indexOf("<title");
+        if (titleStart == -1) return "";
+        int tagEnd = html.indexOf(">", titleStart);
+        int titleEnd = html.indexOf("</title>");
+        if (tagEnd == -1 || titleEnd == -1) return "";
+        return html.substring(tagEnd + 1, titleEnd).replaceAll("<[^>]+>", "").trim();
+    }
+
+    private String extractContent(String html) {
+        int bodyStart = html.indexOf("<body");
+        if (bodyStart == -1) bodyStart = 0;
+        int bodyTagEnd = html.indexOf(">", bodyStart);
+        String content = bodyTagEnd > -1 ? html.substring(bodyTagEnd + 1) : html;
+        content = content.replaceAll("(?s)<script[^>]*>.*?</script>", "");
+        content = content.replaceAll("(?s)<style[^>]*>.*?</style>", "");
+        content = content.replaceAll("(?s)<nav[^>]*>.*?</nav>", "");
+        content = content.replaceAll("(?s)<footer[^>]*>.*?</footer>", "");
+        content = content.replaceAll("(?s)<header[^>]*>.*?</header>", "");
+        content = content.replaceAll("(?s)<[^>]+>", " ");
+        content = content.replaceAll("\\s+", " ").trim();
+        if (content.length() > 5000) {
+            content = content.substring(0, 5000);
+        }
+        return content;
+    }
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DanKnowledgeBase.java

@@ -25,6 +25,14 @@ public class DanKnowledgeBase implements Serializable {
 
     private String remark;
 
+    private String fileUrl;
+
+    private String fileName;
+
+    private String sourceUrl;
+
+    private String sourceType;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -6,9 +6,11 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.DanKnowledgeBase;
 import com.etotem.cfc.entity.DanKnowledgeBaseDimension;
 import com.etotem.cfc.entity.DanKnowledgeBaseTag;
+import com.etotem.cfc.entity.ProductDimensionConfig;
 import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
+import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -27,6 +29,9 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
     @Resource
     private DanKnowledgeBaseTagMapper danKnowledgeBaseTagMapper;
 
+    @Resource
+    private ProductDimensionConfigMapper productDimensionConfigMapper;
+
     @Resource
     private DifySyncService difySyncService;
 
@@ -136,4 +141,27 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
             danKnowledgeBaseTagMapper.insert(rel);
         }
     }
+
+    public List<String> getDimensionCodesForKnowledge(Long knowledgeId) {
+        List<DanKnowledgeBaseDimension> dims = danKnowledgeBaseDimensionMapper.selectList(
+            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                .eq(DanKnowledgeBaseDimension::getKnowledgeId, knowledgeId));
+        if (dims == null || dims.isEmpty()) return new java.util.ArrayList<>();
+        List<Long> dimIds = dims.stream().map(DanKnowledgeBaseDimension::getDimensionId).collect(Collectors.toList());
+        ProductDimensionConfig cfg = new ProductDimensionConfig();
+        cfg.setId(dimIds.get(0));
+        List<ProductDimensionConfig> configs = new java.util.ArrayList<>();
+        for (Long dimId : dimIds) {
+            ProductDimensionConfig c = new ProductDimensionConfig();
+            c.setId(dimId);
+            configs.add(c);
+        }
+        return configs.stream()
+            .map(c -> {
+                ProductDimensionConfig full = productDimensionConfigMapper.selectById(c.getId());
+                return full != null ? full.getDimensionCode() : null;
+            })
+            .filter(code -> code != null)
+            .collect(Collectors.toList());
+    }
 }

+ 19 - 0
cfc-web/src/api/dimension.js

@@ -89,6 +89,25 @@ export function bindKnowledgeTags(data) {
   })
 }
 
+export function uploadKnowledgeFile(file) {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request({
+    url: '/api/admin/knowledge-base/upload-file',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function fetchUrlContent(url) {
+  return request({
+    url: '/api/admin/knowledge-base/fetch-url',
+    method: 'post',
+    data: { url }
+  })
+}
+
 // 标签 API
 export function getTagList() {
   return request({

+ 151 - 21
cfc-web/src/components/KnowledgeDialog/index.vue

@@ -2,23 +2,68 @@
   <el-dialog
     :title="isEdit ? '编辑知识' : '新增知识'"
     :visible="visible"
-    width="700px"
+    width="760px"
     @close="handleClose"
   >
     <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-      <el-form-item label="标题" prop="title">
-        <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
-      </el-form-item>
-      <el-form-item label="内容" prop="content">
-        <el-input
-          v-model="form.content"
-          type="textarea"
-          :rows="6"
-          placeholder="知识内容"
-          maxlength="5000"
-          show-word-limit
-        />
+      <el-form-item label="来源方式">
+        <el-radio-group v-model="form.sourceType" @change="onSourceTypeChange">
+          <el-radio label="manual">手动输入</el-radio>
+          <el-radio label="file">上传文件</el-radio>
+          <el-radio label="url">网址获取</el-radio>
+        </el-radio-group>
       </el-form-item>
+
+      <template v-if="form.sourceType === 'manual'">
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <el-input
+            v-model="form.content"
+            type="textarea"
+            :rows="6"
+            placeholder="知识内容"
+            maxlength="5000"
+            show-word-limit
+          />
+        </el-form-item>
+      </template>
+
+      <template v-else-if="form.sourceType === 'file'">
+        <el-form-item label="文件">
+          <el-upload
+            ref="upload"
+            :auto-upload="false"
+            :limit="1"
+            accept=".txt,.pdf,.doc,.docx,.md"
+            :file-list="fileList"
+            :on-change="handleFileChange"
+            :on-remove="handleFileRemove"
+          >
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" style="color:#999;font-size:12px;margin-top:4px">支持 .txt .pdf .doc .docx .md 文件</div>
+          </el-upload>
+        </el-form-item>
+        <el-form-item v-if="form.fileName" label="已选文件">
+          <span>{{ form.fileName }}</span>
+        </el-form-item>
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="知识标题" maxlength="200" />
+        </el-form-item>
+      </template>
+
+      <template v-else-if="form.sourceType === 'url'">
+        <el-form-item label="网址">
+          <el-input v-model="form.sourceUrl" placeholder="请输入网址,如 https://..." style="margin-bottom:8px">
+            <el-button slot="append" :loading="fetching" @click="handleFetchUrl">获取内容</el-button>
+          </el-input>
+        </el-form-item>
+        <el-form-item v-if="form.title || form.content" label="标题">
+          <span>{{ form.title }}</span>
+        </el-form-item>
+      </template>
+
       <el-form-item label="维度">
         <dimension-selector v-model="form.dimensionIds" :multiple="true" />
       </el-form-item>
@@ -48,6 +93,7 @@
 <script>
 import DimensionSelector from '@/components/DimensionSelector'
 import TagSelector from '@/components/TagSelector'
+import { uploadKnowledgeFile, fetchUrlContent } from '@/api/dimension'
 
 export default {
   name: 'KnowledgeDialog',
@@ -59,6 +105,8 @@ export default {
   data() {
     return {
       submitting: false,
+      fetching: false,
+      fileList: [],
       form: this.initForm(),
       rules: {
         title: [{ required: true, message: '请输入知识标题', trigger: 'blur' }],
@@ -76,13 +124,20 @@ export default {
       if (val) {
         this.form = {
           id: val.id,
-          title: val.title,
-          content: val.content,
+          title: val.title || '',
+          content: val.content || '',
           dimensionIds: val.dimensionIds || [],
           tagIds: val.tagIds || [],
           sort: val.sort || 0,
           status: val.status !== undefined ? val.status : 1,
-          remark: val.remark || ''
+          remark: val.remark || '',
+          fileUrl: val.fileUrl || '',
+          fileName: val.fileName || '',
+          sourceUrl: val.sourceUrl || '',
+          sourceType: val.sourceType || 'manual'
+        }
+        if (this.form.fileUrl) {
+          this.fileList = [{ name: this.form.fileName || '已上传文件' }]
         }
       } else {
         this.form = this.initForm()
@@ -99,18 +154,93 @@ export default {
         tagIds: [],
         sort: 0,
         status: 1,
-        remark: ''
+        remark: '',
+        fileUrl: '',
+        fileName: '',
+        sourceUrl: '',
+        sourceType: 'manual'
+      }
+    },
+    onSourceTypeChange() {
+      if (this.form.sourceType !== 'file') {
+        this.fileList = []
+      }
+    },
+    handleFileChange(file, files) {
+      this.fileList = files.slice(-1)
+    },
+    handleFileRemove() {
+      this.fileList = []
+      this.form.fileUrl = ''
+      this.form.fileName = ''
+    },
+    async handleUploadFile() {
+      if (this.fileList.length === 0) {
+        this.$message.warning('请先选择文件')
+        return
+      }
+      const file = this.fileList[0].raw
+      try {
+        const res = await uploadKnowledgeFile(file)
+        if (res.code === 200 && res.data) {
+          this.form.fileUrl = res.data.fileUrl
+          this.form.fileName = res.data.fileName
+          this.$message.success('文件上传成功')
+        } else {
+          this.$message.error(res.message || '上传失败')
+        }
+      } catch (e) {
+        this.$message.error(e.message || '上传失败')
+      }
+    },
+    async handleFetchUrl() {
+      if (!this.form.sourceUrl) {
+        this.$message.warning('请输入网址')
+        return
+      }
+      this.fetching = true
+      try {
+        const res = await fetchUrlContent(this.form.sourceUrl)
+        if (res.code === 200 && res.data) {
+          this.form.title = res.data.title || '未获取到标题'
+          this.form.content = res.data.content || ''
+          this.form.sourceType = 'url'
+          this.$message.success('内容获取成功')
+        } else {
+          this.$message.error(res.message || '获取失败')
+        }
+      } catch (e) {
+        this.$message.error(e.message || '获取失败')
+      } finally {
+        this.fetching = false
       }
     },
     handleClose() {
       this.$refs.form.resetFields()
+      this.fileList = []
       this.$emit('update:visible', false)
     },
     async handleSubmit() {
-      try {
-        await this.$refs.form.validate()
-      } catch {
-        return
+      if (this.form.sourceType === 'manual') {
+        try {
+          await this.$refs.form.validateField(['title', 'content'])
+        } catch {
+          return
+        }
+      } else if (this.form.sourceType === 'file') {
+        if (!this.form.title) {
+          this.$message.warning('请填写标题')
+          return
+        }
+      } else if (this.form.sourceType === 'url') {
+        if (!this.form.sourceUrl) {
+          this.$message.warning('请输入网址')
+          return
+        }
+        if (!this.form.content) {
+          this.$message.warning('请先点击"获取内容"')
+          return
+        }
       }
       this.submitting = true
       try {

+ 420 - 0
docs/product-recommendation/PLAN.md

@@ -0,0 +1,420 @@
+# 商品推荐功能实施计划
+
+## 目标
+
+在小程序中实现三类商品推荐场景:
+
+1. **维度页推荐组件**:在身/智/心/行/富页面底部展示维度关联商品,按匹配分排序
+2. **AI对话推荐**:对话过程中根据上下文推荐相关商品/服务
+3. **报告关联推荐**:上传健康报告或认知测评后,关联推荐相关商品
+
+---
+
+## 第一阶段:基础设施(P0)
+
+### 1.1 新建数据库表
+
+**步骤 1:`product_dimension_mapping` 表**
+
+路径:`cfc-backend/src/main/resources/schema.sql`
+在 `products` 表定义之后添加:
+
+```sql
+CREATE TABLE product_dimension_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    dimension_code VARCHAR(32) NOT NULL COMMENT '维度: body/wisdom/mind/action/wealth',
+    match_score INT DEFAULT 100 COMMENT '匹配度 0-100',
+    match_reason VARCHAR(200) COMMENT '匹配原因,如"专注力提升"',
+    tags VARCHAR(500) COMMENT '推荐标签 JSON',
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME,
+    updated_at DATETIME,
+    INDEX idx_product (product_id),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB COMMENT='商品维度关联表';
+```
+
+**步骤 2:`product_recommendation_log` 表**
+
+```sql
+CREATE TABLE product_recommendation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT,
+    family_id BIGINT,
+    product_id BIGINT NOT NULL,
+    scene VARCHAR(32) NOT NULL COMMENT 'dimension_page/ai_chat/report_upload/repurchase',
+    reason VARCHAR(200),
+    match_score INT,
+    was_clicked TINYINT DEFAULT 0,
+    was_purchased TINYINT DEFAULT 0,
+    clicked_at DATETIME,
+    purchased_at DATETIME,
+    created_at DATETIME,
+    INDEX idx_user_scene (user_id, scene),
+    INDEX idx_product_purchased (product_id, was_purchased)
+) ENGINE=InnoDB COMMENT='推荐曝光日志';
+```
+
+**步骤 3:`repurchase_reminder_record` 表**
+
+```sql
+CREATE TABLE repurchase_reminder_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL,
+    product_id BIGINT NOT NULL,
+    order_id BIGINT COMMENT '关联订单ID',
+    reminder_days INT DEFAULT 30,
+    sent_at DATETIME,
+    clicked TINYINT DEFAULT 0,
+    purchased TINYINT DEFAULT 0,
+    INDEX idx_user_pending (user_id, purchased)
+) ENGINE=InnoDB COMMENT='复购提醒发送记录';
+```
+
+**步骤 4:`repurchase_reminder_config` 表**
+
+```sql
+CREATE TABLE repurchase_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_category VARCHAR(100),
+    product_id BIGINT COMMENT '特定商品ID(优先于category)',
+    reminder_days INT DEFAULT 30,
+    reminder_template VARCHAR(500) COMMENT '提醒话术模板',
+    max_reminders INT DEFAULT 3,
+    enabled TINYINT DEFAULT 1,
+    created_at DATETIME
+) ENGINE=InnoDB COMMENT='复购提醒配置表';
+```
+
+**步骤 5:`products` 表新增字段**
+
+```sql
+ALTER TABLE products ADD COLUMN recommendation_tags VARCHAR(500) COMMENT '推荐标签 JSON';
+ALTER TABLE products ADD COLUMN purchase_count_threshold INT DEFAULT 0;
+ALTER TABLE products ADD COLUMN repurchase_interval_days INT DEFAULT 30;
+```
+
+### 1.2 数据库迁移脚本
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+在 `runMigrations()` 方法末尾添加迁移N(编号递增),使用 `ensureColumn` 和 `jdbcTemplate.execute` 创建新表。
+
+### 1.3 创建实体类
+
+| 类 | 路径 | 说明 |
+|---|------|------|
+| `ProductDimensionMapping` | `entity/ProductDimensionMapping.java` | 商品-维度关联 |
+| `ProductRecommendationLog` | `entity/ProductRecommendationLog.java` | 推荐曝光日志 |
+| `RepurchaseReminderRecord` | `entity/RepurchaseReminderRecord.java` | 复购提醒记录 |
+| `RepurchaseReminderConfig` | `entity/RepurchaseReminderConfig.java` | 复购提醒配置 |
+
+### 1.4 创建 Mapper
+
+| 类 | 路径 | 说明 |
+|---|------|------|
+| `ProductDimensionMappingMapper` | `mapper/ProductDimensionMappingMapper.java` | 继承 BaseMapper |
+| `ProductRecommendationLogMapper` | `mapper/ProductRecommendationLogMapper.java` | 继承 BaseMapper |
+| `RepurchaseReminderRecordMapper` | `mapper/RepurchaseReminderRecordMapper.java` | 继承 BaseMapper |
+| `RepurchaseReminderConfigMapper` | `mapper/RepurchaseReminderConfigMapper.java` | 继承 BaseMapper |
+
+---
+
+## 第二阶段:维度页推荐(P0)
+
+### 2.1 后端接口
+
+**接口:`POST /api/recommend/dimension-products`**
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java`
+
+**请求体:**
+```json
+{
+  "dimensionCode": "wisdom",
+  "familyId": 1,
+  "memberId": 5,
+  "excludeProductIds": [3, 7],
+  "limit": 6
+}
+```
+
+**响应:**
+```json
+{
+  "code": 200,
+  "data": [
+    {
+      "id": 10,
+      "name": "认知能力测评套餐",
+      "coverImage": "https://...",
+      "price": 29900,
+      "memberPrice": 19900,
+      "reason": "专注力得分偏低,推荐优先提升",
+      "matchScore": 85,
+      "productType": "assessment",
+      "url": "/pages/shop/detail?id=10"
+    }
+  ]
+}
+```
+
+### 2.2 推荐算法(`ProductRecommendationService`)
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java`
+
+**算法逻辑:**
+
+```
+1. 查询所有上架且有库存的商品(status='上架', stock > 0)
+2. 匹配维度:
+   a. Product.domain == dimensionCode(权重1.0)
+   b. 或 product_dimension_mapping.dimension_code == dimensionCode(权重1.5,从mapping表读取)
+3. 查询 members 的 five_dimension_scores,按 dimension_code 排序
+   → 得分低的维度 → 对应商品推荐权重 × 1.2
+4. 查询 ProductOrder,确认排除已购商品(buyerId 或 familyId 在 90 天内购买过)
+5. 按 match_score = 基础分 × 维度缺口加权 × 已购惩罚 排序
+6. 取前 limit 条返回
+```
+
+**新增方法:**
+- `getDimensionRecommendations(String dimensionCode, Long familyId, Long memberId, List<Long> excludeProductIds, int limit)`
+- `getPurchasedProductIds(Long userId, Long familyId, int daysAgo)`
+- `getMemberDimensionScores(Long familyId, Long memberId)`
+
+### 2.3 前端组件
+
+路径:`cfc-frontend/components/DimensionProductList.vue`
+
+**功能:**
+- Props: `dimensionCode`, `familyId`, `memberScores`, `excludeProductIds`, `limit`
+- 加载时调用 `POST /api/recommend/dimension-products`
+- 显示:商品封面图、名称、价格、推荐理由、匹配分 badge
+- 点击跳转商品详情页
+
+**嵌入位置:**
+- `cfc-frontend/pages/wisdom/index.vue`:在认知雷达图下方添加 `<DimensionProductList dimensionCode="wisdom" ... />`
+- 其他维度页(body/mind/action/wealth)同步添加
+
+### 2.4 推荐日志写入
+
+每次返回推荐结果前,写入 `product_recommendation_log`(scene=`dimension_page`),记录 product_id / user_id / match_score / created_at。
+
+---
+
+## 第三阶段:AI对话推荐(P1)
+
+### 3.1 扩展 FamilyContextService
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyContextService.java`
+
+**修改 `buildContext(Long userId)` 方法:**
+
+在返回的 inputs Map 中新增3个字段:
+
+```java
+// 新增:成员维度得分(供 Dify 理解家庭短板)
+inputs.put("dimensionScores", buildDimensionScoresContext(userId));
+
+// 新增:最近认知测评摘要
+inputs.put("recentCognitiveResult", buildCognitiveContext(userId));
+
+// 新增:已购买商品标签(避免重复推荐)
+inputs.put("purchasedProductTags", buildPurchasedTagsContext(userId));
+```
+
+**新增私有方法:**
+- `buildDimensionScoresContext(Long userId)` → 查询 `five_dimension_scores` 返回 `[{dimension, score, memberName}]`
+- `buildCognitiveContext(Long userId)` → 查询 `dan_assessment_results` 最新一条,返回 `{weakDimensions: [...], overallScore}`
+- `buildPurchasedTagsContext(Long userId)` → 查询 `product_orders` 中用户已购商品的 `recommendation_tags`
+
+### 3.2 修改 AIChatController 解析逻辑
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
+
+**扩展 `[RECOMMEND]` 解析:**
+
+在 `sendNutritionMessage()` 的 `[RECOMMEND:]` 解析块中新增:
+
+```java
+// 新增:从 RecommendationQuery 中取 dimensionCode 和 userId,过滤已购
+if (tags != null && !tags.isEmpty()) {
+    RecommendationQuery rq = new RecommendationQuery();
+    rq.setNutritionTags(tags);
+    rq.setTypes(types != null && !types.isEmpty() ? types : null);
+    rq.setLimit(limit);
+    rq.setUserId(userId);  // 新增:传入userId用于过滤已购
+    rq.setFamilyId(familyId);  // 新增:传入familyId用于过滤已购
+    recommendations = recommendationService.search(rq);
+}
+```
+
+### 3.3 扩展 RecommendationService
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java`
+
+**修改 `search(RecommendationQuery query)` 方法:**
+
+```java
+// 在 searchProducts() 中新增过滤逻辑:
+// 1. 如果 query.userId 或 query.familyId 存在,排除 90 天内已购商品
+// 2. 如果 query.dimensionCode 存在,按 match_score 排序时加权
+```
+
+**新增字段到 `RecommendationQuery` DTO:**
+```java
+private Long userId;
+private Long familyId;
+private String dimensionCode;  // 用于维度加权
+```
+
+### 3.4 扩展前端聊天页展示
+
+路径:`cfc-frontend/pages/ai/chat.vue`
+
+在现有的 recommendation 卡片展示逻辑中,新增:
+- 推荐理由展示(从返回结果的 `reason` 字段读取)
+- 已购商品标记(接口返回时已过滤,前端无需额外处理)
+
+---
+
+## 第四阶段:报告关联推荐(P1)
+
+### 4.1 健康报告上传后触发
+
+**触发点:** `HealthReportService.analyze(reportId)` 执行完成后
+
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java` 的 `analyze()` 方法末尾添加:
+
+```java
+// 触发维度推荐
+try {
+    Map<String, Object> analysisResult = parseAnalysisResult(reportId);
+    List<String> dimensionNeeds = extractDimensionNeeds(analysisResult);
+    List<RecommendationResult> products =
+        productRecommendationService.getReportRelatedProducts(
+            "health_report", analysisResult, userId, 3);
+    // 记录推荐日志,scene = 'report_upload'
+    for (RecommendationResult r : products) {
+        productRecommendationLogService.log(userId, r, "report_upload",
+            "健康报告分析触发:" + String.join(",", dimensionNeeds));
+    }
+} catch (Exception e) {
+    log.warn("报告关联推荐生成失败: {}", e.getMessage());
+}
+```
+
+### 4.2 认知测评上传后触发
+
+**触发点:** `DanAssessmentResult` 写入完成(source=parent_upload)
+
+在 `cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveService.java` 的 `saveAssessmentResult()` 或相关写入方法末尾添加类似逻辑:
+
+```java
+// 提取6维得分中最低的2个维度
+List<String> weakDims = findWeakDimensions(result);  // e.g. ["focusScore", "processingSpeedScore"]
+// 映射到 dimensionCode:focusScore/processingSpeedScore → "wisdom"
+// 查询对应维度商品
+List<RecommendationResult> products =
+    productRecommendationService.getReportRelatedProducts(
+        "cognitive_assessment", weakDims, userId, 3);
+// 记录推荐日志
+```
+
+### 4.3 新增 ProductRecommendationService 方法
+
+```java
+public List<RecommendationResult> getReportRelatedProducts(
+    String reportType, Object analysis, Long userId, int limit) {
+    // reportType = "health_report" 或 "cognitive_assessment"
+    // 根据 reportType 提取关联维度码和标签
+    // 调用 search() 时设置 dimensionCode 和已购过滤
+}
+```
+
+---
+
+## 第五阶段:复购提醒(P2)
+
+### 5.1 定时任务
+
+路径:`cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseReminderService.java`
+
+**定时扫描(每天 09:00):**
+
+```java
+@Scheduled(cron = "0 0 9 * * ?")
+public void scanAndCreateReminders() {
+    // 1. 查询过去 30~60 天内有已支付订单的用户
+    // 2. 对每个订单商品,匹配 repurchase_reminder_config
+    // 3. 检查是否已发送过 reminder 且未过期
+    // 4. 创建 repurchase_reminder_record(sent_at = now)
+    // 5. 发送小程序订阅消息(调用现有消息通知机制)
+}
+```
+
+### 5.2 记录点击/购买行为
+
+```java
+public void onReminderClicked(Long reminderId) { ... }
+public void onReminderPurchased(Long reminderId, Long orderId) { ... }
+```
+
+### 5.3 前端复购提醒组件
+
+路径:`cfc-frontend/components/RepurchaseReminder.vue`
+
+- 在首页或消息 Tab 展示待处理复购提醒卡片
+- 点击跳商品详情页(携带 `from=repurchase` 参数)
+- 前端 API:`POST /api/recommend/repurchase-reminders` → `GET /api/recommend/repurchase-reminders`
+
+---
+
+## 验证步骤
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|---------|
+| 1 | `mvn clean compile` | 编译通过,无错误 |
+| 2 | `curl -X POST /api/recommend/dimension-products` | 返回维度关联商品列表 |
+| 3 | 小程序打开智页 | 底部显示推荐商品(DimensionProductList) |
+| 4 | 上传健康报告 | 推荐日志写入,scene=report_upload |
+| 5 | AI营养对话触发 [RECOMMEND] | 返回过滤已购后的商品 |
+| 6 | 查看 `product_recommendation_log` 表 | 有 dimension_page 和 report_upload 记录 |
+
+---
+
+## 文件清单
+
+| 操作 | 文件路径 |
+|------|---------|
+| 新增表 | `resources/schema.sql` 中 4 个 CREATE TABLE + ALTER TABLE |
+| 新增迁移 | `config/DatabaseInitializer.java` runMigrations() |
+| 新增实体 ×4 | `entity/ProductDimensionMapping.java` 等 |
+| 新增 Mapper ×4 | `mapper/ProductDimensionMappingMapper.java` 等 |
+| 新增 Service | `service/ProductRecommendationService.java` |
+| 新增 Service | `service/RepurchaseReminderService.java` |
+| 新增 Controller | `controller/recommendation/ProductRecommendationController.java` |
+| 修改 Service | `service/FamilyContextService.java` — buildContext() |
+| 修改 DTO | `dto/RecommendationQuery.java` — 新增 userId/familyId/dimensionCode |
+| 修改 Service | `service/RecommendationService.java` — 过滤已购 |
+| 修改 Controller | `controller/ai/AIChatController.java` — 传入 userId/familyId |
+| 修改 Service | `service/HealthAnalysisService.java` — 报告上传触发推荐 |
+| 修改 Service | `service/CognitiveService.java` — 测评上传触发推荐 |
+| 修改 Service | `service/ProductRecommendationLogService.java`(新建) |
+| 新增前端组件 | `components/DimensionProductList.vue` |
+| 新增前端组件 | `components/RepurchaseReminder.vue` |
+| 修改前端页面 | `pages/wisdom/index.vue` 等 — 嵌入 DimensionProductList |
+| 修改前端页面 | `pages/ai/chat.vue` — 推荐理由展示 |
+| 修改前端 API | `utils/api.js` — 新增推荐相关接口 |
+
+---
+
+## 风险与依赖
+
+| 风险 | 缓解 |
+|------|------|
+| Dify 幻觉推荐 | 后端兜底过滤(status=上架,stock>0),仅在 nutrition/send 接口触发 |
+| 冷启动(mapping 表空) | 初期用 `Product.domain` 隐式匹配;mapping 表由运营后台手动标注或导入 |
+| 推荐效果未验证 | `product_recommendation_log` 记录曝光,后续可做转化率统计 |
+| 复购周期判断不准 | `repurchase_interval_days` 可按商品类别配置,默认 30 天 |