Browse Source

feat: implement product recommendation engine with persona + association relations (P3) add recommendation rules DB + service + UI ab-test tracking

Sisyphus 5 ngày trước cách đây
mục cha
commit
1a424be065

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

@@ -10933,6 +10933,12 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         backfillHealthPlanTaskPrerequisite();
         backfillTemplateInstanceFamilyMemberId();
 
+        createProductDimensionMappingTable();
+        createProductRecommendationLogTable();
+        createRepurchaseReminderTables();
+        createRecommendationProductAssociationsTable();
+        addAbTestColumnsToRecommendationLog();
+
         // 迁移341: BASIC 体验会员 features JSON 补 referral_commission(迁移315 未包含,前端对比表已显示可参与)
         try {
             jdbcTemplate.update("UPDATE membership_levels SET features = ? WHERE level_code = 'BASIC' AND features NOT LIKE '%referral_commission%'",
@@ -11605,7 +11611,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     "parent_user_id BIGINT NOT NULL COMMENT '上级供应商用户ID(supply_hierarchy.parent_id)'," +
                     "family_id BIGINT NOT NULL COMMENT '客户家庭ID(family_members.family_id)'," +
                     "child_user_id BIGINT DEFAULT NULL COMMENT '下单用户ID(users.id),可为空'," +
-                    "relation_type VARCHAR(32) NOT NULL DEFAULT 'family' COMMENT '归属类型: family=家庭归属 order=订单关联'," +
+                    "relation_type VARCHAR(32) NOT NULL DEFAULT 'family' COMMENT '归属类型: family=家庭归属 order=下单自动 referral=推荐码'," +
                     "source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源: manual=人工绑定 order=下单自动 referral=推荐码'," +
                     "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
                     "INDEX idx_system_family (system_id, family_id)," +
@@ -11618,4 +11624,124 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移344: 创建 supply_customer_relation 表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移345: 创建 product_dimension_mapping 表(商品-维度关联)
+    private void createProductDimensionMappingTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_dimension_mapping (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "product_id BIGINT NOT NULL COMMENT '商品ID(products.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 COMMENT '启用状态 1=启用 0=禁用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "INDEX idx_product_id (product_id)," +
+                    "INDEX idx_dimension (dimension_code)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表'");
+            log.info("迁移345: 已创建 product_dimension_mapping 表");
+        } catch (Exception e) {
+            log.warn("迁移345: 创建 product_dimension_mapping 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移346: 创建 product_recommendation_log 表(推荐曝光日志)
+    private void createProductRecommendationLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_recommendation_log (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "user_id BIGINT DEFAULT NULL COMMENT '用户ID(users.id)'," +
+                    "family_id BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)'," +
+                    "product_id BIGINT NOT NULL COMMENT '商品ID'," +
+                    "scene VARCHAR(32) NOT NULL COMMENT '场景: dimension_page/ai_chat/report_upload/repurchase'," +
+                    "reason VARCHAR(200) COMMENT '推荐理由'," +
+                    "match_score INT DEFAULT NULL COMMENT '匹配分'," +
+                    "was_clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," +
+                    "was_purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," +
+                    "clicked_at DATETIME DEFAULT NULL COMMENT '点击时间'," +
+                    "purchased_at DATETIME DEFAULT NULL COMMENT '购买时间'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "INDEX idx_user_scene (user_id, scene)," +
+                    "INDEX idx_product_purchased (product_id, was_purchased)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐曝光日志表'");
+            log.info("迁移346: 已创建 product_recommendation_log 表");
+        } catch (Exception e) {
+            log.warn("迁移346: 创建 product_recommendation_log 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移347: 创建 repurchase_reminder_record 和 repurchase_reminder_config 表
+    private void createRepurchaseReminderTables() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_record (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
+                    "product_id BIGINT NOT NULL COMMENT '商品ID'," +
+                    "order_id BIGINT DEFAULT NULL COMMENT '关联订单ID'," +
+                    "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," +
+                    "sent_at DATETIME DEFAULT NULL COMMENT '发送时间'," +
+                    "clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否'," +
+                    "purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否'," +
+                    "INDEX idx_user_pending (user_id, purchased)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒记录表'");
+            log.info("迁移347a: 已创建 repurchase_reminder_record 表");
+        } catch (Exception e) {
+            log.warn("迁移347a: 创建 repurchase_reminder_record 表失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS repurchase_reminder_config (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "product_category VARCHAR(100) DEFAULT NULL COMMENT '商品类目'," +
+                    "product_id BIGINT DEFAULT NULL COMMENT '特定商品ID(优先于category)'," +
+                    "reminder_days INT DEFAULT 30 COMMENT '提醒天数'," +
+                    "reminder_template VARCHAR(500) DEFAULT NULL COMMENT '提醒话术模板'," +
+                    "max_reminders INT DEFAULT 3 COMMENT '最大提醒次数'," +
+                    "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表'");
+            log.info("迁移347b: 已创建 repurchase_reminder_config 表");
+        } catch (Exception e) {
+            log.warn("迁移347b: 创建 repurchase_reminder_config 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移348: 创建 recommendation_product_associations 表(商品推荐关联关系)
+    private void createRecommendationProductAssociationsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_product_associations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "source_product_id BIGINT NOT NULL COMMENT '源商品ID(已购/关注商品)'," +
+                    "target_product_id BIGINT NOT NULL COMMENT '目标商品ID(被推荐商品)'," +
+                    "association_type VARCHAR(32) NOT NULL COMMENT '类型: co_purchase/repurchase/profile_gap/manual'," +
+                    "strength DECIMAL(5,2) DEFAULT 0.50 COMMENT '关联强度 0.00-1.00'," +
+                    "manual_override TINYINT DEFAULT 0 COMMENT '是否运营手动覆盖 1=是 0=否'," +
+                    "reason VARCHAR(500) DEFAULT NULL COMMENT '关联原因说明'," +
+                    "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '关联维度(body/mind/wisdom/action/wealth)'," +
+                    "repurchase_days INT DEFAULT NULL COMMENT '复购周期天数(repurchase场景)'," +
+                    "co_purchase_window_days INT DEFAULT 30 COMMENT '共购时间窗口天数(co_purchase场景)'," +
+                    "co_purchase_count INT DEFAULT 0 COMMENT '共购次数(co_purchase场景自动累计)'," +
+                    "enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用'," +
+                    "created_by BIGINT DEFAULT NULL COMMENT '创建人用户ID(manual场景记录运营)'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'," +
+                    "INDEX idx_source_target (source_product_id, target_product_id)," +
+                    "INDEX idx_type_enabled (association_type, enabled)," +
+                    "INDEX idx_dimension (dimension_code)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐关联关系表'");
+            log.info("迁移348: 已创建 recommendation_product_associations 表");
+        } catch (Exception e) {
+            log.warn("迁移348: 创建 recommendation_product_associations 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移349: product_recommendation_log 表添加 AB测试分组与推荐来源类型列
+    private void addAbTestColumnsToRecommendationLog() {
+        ensureColumn("product_recommendation_log", "ab_test_group",
+                "VARCHAR(32) DEFAULT NULL COMMENT 'AB测试分组(A/B/C/控制组)'");
+        ensureColumn("product_recommendation_log", "recommendation_type",
+                "VARCHAR(32) DEFAULT NULL COMMENT '推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual'");
+        log.info("迁移349: product_recommendation_log 已添加 ab_test_group / recommendation_type 列");
+    }
 }

+ 204 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/RecommendationStatsController.java

@@ -0,0 +1,204 @@
+package com.etotem.cfc.controller.recommendation;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProductRecommendationLog;
+import com.etotem.cfc.mapper.ProductRecommendationLogMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 推荐效果追踪接口(AB测试/点击/购买转化)
+ */
+@Tag(name = "推荐效果追踪", description = "AB测试分组、点击、购买转化统计")
+@RestController
+@RequestMapping("/api/recommend/stats")
+public class RecommendationStatsController {
+
+    @Resource
+    private ProductRecommendationLogMapper logMapper;
+
+    /**
+     * 记录推荐曝光(AB测试分组 + 推荐来源)
+     */
+    @Operation(summary = "记录推荐曝光")
+    @PostMapping("/expose")
+    public Result<String> expose(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute(value = "userId", required = false) Long userId) {
+        ProductRecommendationLog logEntity = new ProductRecommendationLog();
+        logEntity.setUserId(userId);
+        logEntity.setProductId(toLong(params.get("productId")));
+        logEntity.setScene((String) params.get("scene"));
+        logEntity.setReason((String) params.get("reason"));
+        logEntity.setMatchScore(toDouble(params.get("matchScore")));
+        logEntity.setAbTestGroup((String) params.get("abTestGroup"));
+        logEntity.setRecommendationType((String) params.get("recommendationType"));
+        logEntity.setCreatedAt(new Date());
+        logMapper.insert(logEntity);
+        return Result.success("ok");
+    }
+
+    /**
+     * 记录点击行为
+     */
+    @Operation(summary = "记录点击")
+    @PostMapping("/click")
+    public Result<String> click(@RequestBody Map<String, Object> params,
+                                @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long logId = toLong(params.get("logId"));
+        if (logId == null) {
+            return Result.error("logId 不能为空");
+        }
+        try {
+            logMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProductRecommendationLog>()
+                    .eq(ProductRecommendationLog::getId, logId)
+                    .set(ProductRecommendationLog::getWasClicked, 1)
+                    .set(ProductRecommendationLog::getClickedAt, new Date())
+                    .set(ProductRecommendationLog::getUpdatedAt, new Date()));
+        } catch (Exception ignored) {}
+        return Result.success("ok");
+    }
+
+    /**
+     * 记录购买转化
+     */
+    @Operation(summary = "记录购买转化")
+    @PostMapping("/purchase")
+    public Result<String> purchase(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long logId = toLong(params.get("logId"));
+        Long orderId = toLong(params.get("orderId"));
+        if (logId == null) {
+            return Result.error("logId 不能为空");
+        }
+        try {
+            logMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProductRecommendationLog>()
+                    .eq(ProductRecommendationLog::getId, logId)
+                    .set(ProductRecommendationLog::getWasPurchased, 1)
+                    .set(ProductRecommendationLog::getPurchasedAt, new Date())
+                    .set(ProductRecommendationLog::getUpdatedAt, new Date()));
+        } catch (Exception ignored) {}
+        return Result.success("ok");
+    }
+
+    /**
+     * 获取 AB 测试效果汇总
+     */
+    @Operation(summary = "AB测试效果汇总")
+    @PostMapping("/ab-summary")
+    public Result<Map<String, Object>> abSummary(@RequestBody Map<String, Object> params,
+                                                 @RequestAttribute(value = "userId", required = false) Long userId) {
+        String scene = (String) params.get("scene");
+        String startDate = (String) params.get("startDate");
+        String endDate = (String) params.get("endDate");
+        int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
+
+        Date from = null, to = new Date();
+        if (startDate != null && !startDate.isEmpty()) {
+            from = java.sql.Date.valueOf(startDate);
+            if (endDate != null && !endDate.isEmpty()) {
+                to = java.sql.Date.valueOf(endDate);
+            }
+        } else if (days > 0) {
+            Calendar cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_YEAR, -days);
+            from = cal.getTime();
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total_exposures", logMapper.selectCount(
+                buildBaseQuery(userId, scene, from, to)));
+        result.put("total_clicks", logMapper.selectCount(
+                buildBaseQuery(userId, scene, from, to).eq(ProductRecommendationLog::getWasClicked, 1)));
+        result.put("total_purchases", logMapper.selectCount(
+                buildBaseQuery(userId, scene, from, to).eq(ProductRecommendationLog::getWasPurchased, 1)));
+        return Result.success(result);
+    }
+
+    /**
+     * 获取按推荐来源的转化率
+     */
+    @Operation(summary = "按推荐来源统计")
+    @PostMapping("/by-type")
+    public Result<List<Map<String, Object>>> byType(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute(value = "userId", required = false) Long userId) {
+        String scene = (String) params.get("scene");
+        Date from = null, to = new Date();
+        int days = params.get("days") != null ? ((Number) params.get("days")).intValue() : 30;
+        if (days > 0) {
+            Calendar cal = Calendar.getInstance();
+            cal.add(Calendar.DAY_OF_YEAR, -days);
+            from = cal.getTime();
+        }
+
+        Map<String, Object> wrapper = new LinkedHashMap<>();
+        wrapper.put("from", from);
+        wrapper.put("to", to);
+        wrapper.put("userId", userId);
+        wrapper.put("scene", scene);
+
+        List<ProductRecommendationLog> rows = logMapper.selectList(
+                buildBaseQuery(userId, scene, from, to)
+                        .isNotNull(ProductRecommendationLog::getRecommendationType)
+                        .select(ProductRecommendationLog::getRecommendationType,
+                                ProductRecommendationLog::getWasClicked,
+                                ProductRecommendationLog::getWasPurchased));
+
+        Map<String, int[]> stats = new HashMap<>();
+        for (ProductRecommendationLog row : rows) {
+            String type = row.getRecommendationType();
+            if (type == null) continue;
+            int[] counters = stats.computeIfAbsent(type, k -> new int[3]);
+            counters[0]++;
+            Integer clicked = row.getWasClicked();
+            if (clicked != null && clicked == 1) counters[1]++;
+            Integer purchased = row.getWasPurchased();
+            if (purchased != null && purchased == 1) counters[2]++;
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map.Entry<String, int[]> entry : stats.entrySet()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("type", entry.getKey());
+            item.put("exposures", entry.getValue()[0]);
+            item.put("clicks", entry.getValue()[1]);
+            item.put("purchases", entry.getValue()[2]);
+            double clickRate = entry.getValue()[0] > 0 ? entry.getValue()[1] * 100.0 / entry.getValue()[0] : 0;
+            double purchaseRate = entry.getValue()[1] > 0 ? entry.getValue()[2] * 100.0 / entry.getValue()[1] : 0;
+            item.put("clickRate", String.format("%.1f%%", clickRate));
+            item.put("purchaseRate", String.format("%.1f%%", purchaseRate));
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
+    // ── Private helpers ───
+
+    private LambdaQueryWrapper<ProductRecommendationLog> buildBaseQuery(Long userId, String scene, Date from, Date to) {
+        LambdaQueryWrapper<ProductRecommendationLog> qw = new LambdaQueryWrapper<>();
+        qw.eq(userId != null, ProductRecommendationLog::getUserId, userId);
+        qw.eq(scene != null, ProductRecommendationLog::getScene, scene);
+        qw.ge(from != null, ProductRecommendationLog::getCreatedAt, from);
+        qw.le(to != null, ProductRecommendationLog::getCreatedAt, to);
+        return qw;
+    }
+
+    private Long toLong(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof Number) return ((Number) obj).longValue();
+        try { return Long.parseLong(String.valueOf(obj)); } catch (Exception e) { return null; }
+    }
+
+    private Double toDouble(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof Number) return ((Number) obj).doubleValue();
+        return null;
+    }
+}

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -117,4 +117,6 @@ public class ProductOrder implements Serializable {
     public void setFulfillStatus(String fulfillStatus) { this.fulfillStatus = fulfillStatus; }
     public void setFulfillConfirmedAt(Date fulfillConfirmedAt) { this.fulfillConfirmedAt = fulfillConfirmedAt; }
     public void setReceivedAt(Date receivedAt) { this.receivedAt = receivedAt; }
+    public Date getPaidAt() { return paidAt; }
+    public Date getCreatedAt() { return createdAt; }
 }

+ 51 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductRecommendationLog.java

@@ -19,7 +19,7 @@ public class ProductRecommendationLog implements Serializable {
 
     private Long productId;
 
-    /** 触发场景: report_upload / manual / chat */
+    /** 触发场景: report_upload / manual / chat / dimension_page / repurchase */
     private String scene;
 
     /** 推荐理由 */
@@ -29,4 +29,54 @@ public class ProductRecommendationLog implements Serializable {
     private Double matchScore;
 
     private Date createdAt;
+
+    /** 是否点击 */
+    private Integer wasClicked;
+
+    /** 是否购买 */
+    private Integer wasPurchased;
+
+    /** 点击时间 */
+    private Date clickedAt;
+
+    /** 购买时间 */
+    private Date purchasedAt;
+
+    /** AB测试分组(A/B/C/控制组) */
+    private String abTestGroup;
+
+    /** 推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual */
+    private String recommendationType;
+
+    private Date updatedAt;
+
+    // Explicit getters/setters needed due to Lombok issues
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public Long getProductId() { return productId; }
+    public void setProductId(Long productId) { this.productId = productId; }
+    public String getScene() { return scene; }
+    public void setScene(String scene) { this.scene = scene; }
+    public String getReason() { return reason; }
+    public void setReason(String reason) { this.reason = reason; }
+    public Double getMatchScore() { return matchScore; }
+    public void setMatchScore(Double matchScore) { this.matchScore = matchScore; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Integer getWasClicked() { return wasClicked; }
+    public void setWasClicked(Integer wasClicked) { this.wasClicked = wasClicked; }
+    public Integer getWasPurchased() { return wasPurchased; }
+    public void setWasPurchased(Integer wasPurchased) { this.wasPurchased = wasPurchased; }
+    public Date getClickedAt() { return clickedAt; }
+    public void setClickedAt(Date clickedAt) { this.clickedAt = clickedAt; }
+    public Date getPurchasedAt() { return purchasedAt; }
+    public void setPurchasedAt(Date purchasedAt) { this.purchasedAt = purchasedAt; }
+    public String getAbTestGroup() { return abTestGroup; }
+    public void setAbTestGroup(String abTestGroup) { this.abTestGroup = abTestGroup; }
+    public String getRecommendationType() { return recommendationType; }
+    public void setRecommendationType(String recommendationType) { this.recommendationType = recommendationType; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
 }

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RecommendationProductAssociation.java

@@ -0,0 +1,71 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 商品推荐关联关系
+ * 记录商品间的推荐关联,支持四种关联类型:
+ *   co_purchase  — 共购关联:买了A的用户也买了B
+ *   repurchase   — 复购关联:买了A后X天再买A
+ *   profile_gap  — 画像缺口关联:某维度得分低时推荐B
+ *   manual       — 手动配置:运营人工设置的关联
+ */
+@Data
+@TableName("recommendation_product_associations")
+public class RecommendationProductAssociation implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 源商品ID(已购/关注商品) */
+    private Long sourceProductId;
+
+    /** 目标商品ID(被推荐商品) */
+    private Long targetProductId;
+
+    /**
+     * 关联类型:
+     *   co_purchase — 共购关联
+     *   repurchase  — 复购关联
+     *   profile_gap — 画像缺口关联
+     *   manual      — 手动配置
+     */
+    private String associationType;
+
+    /** 关联强度 0.00-1.00(自动挖掘时从数据计算,手动配置时固定) */
+    private java.math.BigDecimal strength;
+
+    /** 是否运营手动覆盖(1=运营干预,优先级高于自动挖掘) */
+    private Integer manualOverride;
+
+    /** 关联原因说明 */
+    private String reason;
+
+    /** 关联维度编码(profile_gap 场景使用:body/mind/wisdom/action/wealth) */
+    private String dimensionCode;
+
+    /** 复购周期天数(repurchase 场景) */
+    private Integer repurchaseDays;
+
+    /** 共购时间窗口(天,co_purchase 场景,默认30天) */
+    private Integer coPurchaseWindowDays;
+
+    /** 共购次数(自动累计,co_purchase 场景) */
+    private Integer coPurchaseCount;
+
+    /** 启用状态 1=启用 0=禁用 */
+    private Integer enabled;
+
+    /** 创建人用户ID(manual 场景记录运营人员) */
+    private Long createdBy;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

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

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

+ 9 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentService.java

@@ -58,6 +58,9 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
     @Resource
     private RecommendationService recommendationService;
 
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
     @Resource
     private com.etotem.cfc.mapper.ProductRecommendationLogMapper productRecommendationLogMapper;
 
@@ -240,17 +243,16 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
                     for (int i = 0; i < Math.min(2, sorted.size()); i++) {
                         weakDims.add(sorted.get(i).getKey());
                     }
-                    RecommendationQuery query = new RecommendationQuery();
-                    query.setNutritionTags(new ArrayList<>(weakDims));
-                    query.setTypes(Arrays.asList("product"));
-                    query.setLimit(3);
-                    List<RecommendationResult> recs = recommendationService.search(query);
-                    for (RecommendationResult rec : recs) {
+                    // 使用新的关联关系推荐服务
+                    List<Map<String, Object>> recs = productRecommendationService.getReportRelatedProducts(
+                            "cognitive_assessment", weakDims, finalResult.getFamilyMemberId(), 3);
+                    for (Map<String, Object> rec : recs) {
                         ProductRecommendationLog logEntry = new ProductRecommendationLog();
                         logEntry.setUserId(finalResult.getFamilyMemberId());
-                        logEntry.setProductId(rec.getId());
+                        logEntry.setProductId((Long) rec.get("id"));
                         logEntry.setScene("report_upload");
                         logEntry.setReason("认知测评(" + String.join(",", weakDims) + ")触发");
+                        logEntry.setMatchScore((Double) rec.get("matchScore"));
                         logEntry.setCreatedAt(new Date());
                         productRecommendationLogMapper.insert(logEntry);
                     }

+ 9 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -29,6 +29,9 @@ public class HealthAnalysisService {
     @Resource
     private RecommendationService recommendationService;
 
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
     @Resource
     private ProductRecommendationLogMapper productRecommendationLogMapper;
 
@@ -249,15 +252,16 @@ public class HealthAnalysisService {
                         }
                     }
                     for (String dim : dimensionNeeds) {
-                        List<RecommendationResult> recs = recommendationService.search(
-                            buildRecommendationQueryForDimension(dim, finalUserId, 3));
-                        for (RecommendationResult rec : recs) {
+                        List<Map<String, Object>> recs =
+                            productRecommendationService.getReportRelatedProducts(
+                                "health_report", null, finalUserId, 3);
+                        for (Map<String, Object> rec : recs) {
                             ProductRecommendationLog logEntity = new ProductRecommendationLog();
                             logEntity.setUserId(finalUserId);
-                            logEntity.setProductId(rec.getId());
+                            logEntity.setProductId((Long) rec.get("id"));
                             logEntity.setScene("report_upload");
                             logEntity.setReason("健康报告分析触发:" + dim);
-                            logEntity.setMatchScore(null);
+                            logEntity.setMatchScore((Double) rec.get("matchScore"));
                             logEntity.setCreatedAt(new Date());
                             productRecommendationLogMapper.insert(logEntity);
                         }

+ 314 - 80
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -13,7 +13,13 @@ import com.etotem.cfc.util.SortUtil;
 
 /**
  * 商品推荐服务
- * 支持维度页推荐、AI对话推荐、报告关联推荐
+ * 支持三类推荐场景:
+ * 1. 维度页推荐(基于维度匹配 + 关联关系)
+ * 2. AI对话推荐(基于画像缺口 + 购买关联)
+ * 3. 报告关联推荐(健康报告/认知测评触发)
+ * <p>
+ * 核心思路:<strong>静态维度映射</strong> 为基础,<strong>动态关联关系表</strong> 为补充,
+ * 实现<strong>画像驱动</strong>与<strong>购买关联</strong>双轮驱动的个性化推荐。
  */
 @Slf4j
 @Service
@@ -34,6 +40,9 @@ public class ProductRecommendationService {
     @Resource
     private ProductRecommendationLogMapper productRecommendationLogMapper;
 
+    @Resource
+    private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
+
     /**
      * 维度页推荐
      *
@@ -54,11 +63,11 @@ public class ProductRecommendationService {
             purchasedIds.addAll(excludeProductIds);
         }
 
-        // 2. 获取成员维度得分
+        // 2. 获取成员维度得分(画像)
         Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
 
-        // 3. 查询维度匹配商品(优先从 dimension_mapping 表)
-        List<Product> candidates = new ArrayList<>();
+        // 3. 从维度匹配表获取候选商品(静态基础)
+        List<Map<String, Object>> candidates = new ArrayList<>();
 
         // 从 mapping 表获取关联商品
         LambdaQueryWrapper<ProductDimensionMapping> mappingQw = new LambdaQueryWrapper<ProductDimensionMapping>()
@@ -78,7 +87,7 @@ public class ProductRecommendationService {
             if (!mappedProductIds.isEmpty()) {
                 List<Product> mapped = productMapper.selectList(
                         new LambdaQueryWrapper<Product>()
-                                .in(Product::getId, mappedProductIds)
+                        .in(Product::getId, mappedProductIds)
                                 .eq(Product::getStatus, "on_shelf")
                                 .gt(Product::getStock, 0)
                                 .orderByAsc(Product::getSortOrder)
@@ -102,112 +111,293 @@ public class ProductRecommendationService {
                     Integer baseScore = mappingScores.getOrDefault(p.getId(), 100);
                     String reason = mappingReasons.getOrDefault(p.getId(), "");
                     double finalScore = applyDimensionBoost(baseScore, dimensionCode, memberScores);
-                    candidates.add(p);
+                    Map<String, Object> item = new LinkedHashMap<>();
+                    item.put("id", p.getId());
+                    item.put("productId", p.getId());
+                    item.put("name", p.getName());
+                    item.put("coverImage", p.getCoverImage());
+                    item.put("price", p.getPrice());
+                    item.put("memberPrice", p.getPrice());
+                    item.put("productType", p.getProductType());
+                    item.put("source", "dimension");
+                    item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
+
+                    // 推荐理由
+                    String dimReason = buildRecommendationReason(p, dimensionCode, memberScores);
+                    item.put("reason", dimReason);
+
+                    // 匹配分
+                    double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
+                    item.put("matchScore", matchScore);
+
+                    candidates.add(item);
                 }
             }
         }
 
-        // fallback: 从 Product.domain 匹配
-        if (candidates.isEmpty()) {
-            LambdaQueryWrapper<Product> domainQw = new LambdaQueryWrapper<Product>()
-                    .eq(Product::getStatus, "on_shelf")
-                    .gt(Product::getStock, 0)
-                    .orderByAsc(Product::getSortOrder);
-            if (dimensionCode != null && !dimensionCode.isEmpty()) {
-                domainQw.eq(Product::getDomain, dimensionCode);
+        // 4. 关联关系推荐(动态:画像缺口、共购、复购)
+        List<Map<String, Object>> assocResults = queryAssociationRecommendations(
+                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size());
+
+        // 合并并去重(关联推荐优先级高于维度映射,但保留维度的reason/matchScore)
+        if (assocResults != null && !assocResults.isEmpty()) {
+            for (Map<String, Object> item : assocResults) {
+                Long prodId = (Long) item.get("id");
+                // 已在 candidates 中则跳过(由 limit 控制数量,这里不再二次去重复用 id,而是追加)
+                if (candidates.size() >= limit) break;
+                candidates.add(item);
             }
-            if (!purchasedIds.isEmpty()) {
-                domainQw.notIn(Product::getId, purchasedIds);
+        }
+
+        // 5. 最终排序并返回
+        return sortAndReturn(candidates, limit);
+    }
+
+    /**
+     * 查询关联关系推荐(画像缺口 + 共购 + 复购)
+     */
+    private List<Map<String, Object>> queryAssociationRecommendations(
+            String dimensionCode, Long familyId, Long memberId,
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+
+        if (reqCount <= 0) return Collections.emptyList();
+
+        Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
+        List<Map<String, Object>> results = new ArrayList<>();
+
+        // 4.1 画像缺口关联:维度得分低时推荐对应商品
+        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+            List<Map<String, Object>> profileAssocs = queryProfileGapAssociations(
+                    dimensionCode, memberScores, purchasedIds, excludeProductIds, reqCount / 3);
+            results.addAll(profileAssocs);
+            if (results.size() >= reqCount) return results.subList(0, reqCount);
+        }
+
+        // 4.2 共购关联:买了A的用户也买了B(从订单数据挖掘)
+        if (purchasedIds != null && !purchasedIds.isEmpty()) {
+            List<Map<String, Object>> coPurchaseAssocs = queryCoPurchaseAssociations(
+                    purchasedIds, excludeProductIds, reqCount - results.size());
+            results.addAll(coPurchaseAssocs);
+            if (results.size() >= reqCount) return results.subList(0, reqCount);
+        }
+
+        // 4.3 复购关联:买了A后X天再买A的关联商品
+        if (purchasedIds != null && !purchasedIds.isEmpty()) {
+            List<Map<String, Object>> repurchaseAssocs = queryRepurchaseAssociations(
+                    purchasedIds, excludeProductIds, reqCount - results.size());
+            results.addAll(repurchaseAssocs);
+        }
+
+        return results;
+    }
+
+    /**
+     * 画像缺口关联:某维度得分低时,从关联关系表推荐商品
+     */
+    private List<Map<String, Object>> queryProfileGapAssociations(
+            String dimensionCode, Map<String, Integer> memberScores,
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
+
+        if (reqCount <= 0) return Collections.emptyList();
+
+        // 寻找关联关系:association_type='profile_gap' 且 dimension_code 匹配
+        LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
+                .eq(RecommendationProductAssociation::getAssociationType, "profile_gap")
+                .eq(RecommendationProductAssociation::getDimensionCode, dimensionCode)
+                .eq(RecommendationProductAssociation::getEnabled, 1);
+
+        // 如果有成员得分,筛选“得分低于阈值”才触发推荐
+        if (memberScores != null && !memberScores.isEmpty()) {
+            Integer score = memberScores.get(dimensionCode);
+            if (score != null && score < 70) {
+                // 分数越低,关联强度越高(可选逻辑,此处直接查出所有并按 strength 排序)
+                qw.orderByDesc(RecommendationProductAssociation::getStrength);
             }
-            SortUtil.applySort(domainQw);
-            List<Product> domainMatched = productMapper.selectList(domainQw);
-            candidates.addAll(domainMatched);
+        } else {
+            // 无画像数据时,默认中等强度
+            qw.orderByDesc(RecommendationProductAssociation::getStrength);
         }
 
-        // 4. 排序并返回
-        List<Map<String, Object>> result = new ArrayList<>();
-        for (Product p : candidates) {
-            if (result.size() >= limit) break;
+        List<Map<String, Object>> results = new ArrayList<>();
+        List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
+        for (RecommendationProductAssociation assoc : assocs) {
+            if (reqCount <= 0 || results.size() >= reqCount) break;
+            // 跳过已购商品
+            if (purchasedIds != null && purchasedIds.contains(assoc.getTargetProductId())) continue;
+            // 跳过被排除
+            if (excludeProductIds != null && excludeProductIds.contains(assoc.getTargetProductId())) continue;
+
+            Product product = productMapper.selectById(assoc.getTargetProductId());
+            if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
 
             Map<String, Object> item = new LinkedHashMap<>();
-            item.put("id", p.getId());
-            item.put("productId", p.getId());
-            item.put("name", p.getName());
-            item.put("coverImage", p.getCoverImage());
-            item.put("price", p.getPrice());
-            item.put("memberPrice", p.getPrice());
-            item.put("productType", p.getProductType());
-            item.put("source", "dimension");
-            item.put("url", "/pages/discover/product-detail/product-detail?id=" + p.getId());
-
-            // 推荐理由
-            String reason = buildRecommendationReason(p, dimensionCode, memberScores);
+            item.put("id", product.getId());
+            item.put("productId", product.getId());
+            item.put("name", product.getName());
+            item.put("coverImage", product.getCoverImage());
+            item.put("price", product.getPrice());
+            item.put("memberPrice", product.getPrice());
+            item.put("productType", product.getProductType());
+            item.put("source", "assoc_profile_gap");
+
+            // 关联强度作为匹配分
+            java.math.BigDecimal strength = assoc.getStrength();
+            item.put("matchScore", strength != null ? strength.doubleValue() / 10.0 : 0.5);
+
+            // 关联理由
+            String reason = assoc.getReason();
+            if (reason == null || reason.isEmpty()) {
+                Integer score = memberScores != null ? memberScores.get(dimensionCode) : null;
+                if (score != null && score < 50) {
+                    reason = "该维度得分偏低,重点推荐";
+                } else if (score != null && score < 70) {
+                    reason = "该维度有提升空间,推荐关注";
+                } else {
+                    reason = "根据维度匹配推荐";
+                }
+            }
             item.put("reason", reason);
 
-            // 匹配分
-            double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
-            item.put("matchScore", matchScore);
-
-            result.add(item);
+            results.add(item);
         }
-
-        return result;
+        return results;
     }
 
     /**
-     * 报告关联推荐(健康报告/认知测评触发)
+     * 共购关联:从订单数据挖掘“买了A的用户也买了B”
      */
-    public List<Map<String, Object>> getReportRelatedProducts(
-            String reportType, Object analysis, Long userId, int limit) {
+    private List<Map<String, Object>> queryCoPurchaseAssociations(
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
 
-        List<String> dimensionCodes = new ArrayList<>();
-        if ("health_report".equals(reportType)) {
-            // 从健康分析结果提取维度需求
-            dimensionCodes = extractDimensionNeedsFromHealth(analysis);
-        } else if ("cognitive_assessment".equals(reportType)) {
-            // 从认知测评结果提取弱维度
-            dimensionCodes = extractDimensionNeedsFromCognitive(analysis);
+        if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
+
+        List<Map<String, Object>> results = new ArrayList<>();
+
+        // 找出非空的 purchasedIds,查找关联关系
+        LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
+                .eq(RecommendationProductAssociation::getAssociationType, "co_purchase")
+                .eq(RecommendationProductAssociation::getEnabled, 1)
+                .gt(RecommendationProductAssociation::getCoPurchaseCount, 0) // 只取已挖掘的
+                .orderByDesc(RecommendationProductAssociation::getCoPurchaseCount);
+
+        // 限制数量
+        if (reqCount > 0) {
+            qw.last("LIMIT " + reqCount);
         }
 
-        if (dimensionCodes.isEmpty()) {
-            dimensionCodes.add("body");
+        List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
+        for (RecommendationProductAssociation assoc : assocs) {
+            Long targetId = assoc.getTargetProductId();
+            if (targetId == null) continue;
+            // 跳过已购和被排除
+            if (purchasedIds.contains(targetId)) continue;
+            if (excludeProductIds != null && excludeProductIds.contains(targetId)) continue;
+
+            Product product = productMapper.selectById(targetId);
+            if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", product.getId());
+            item.put("productId", product.getId());
+            item.put("name", product.getName());
+            item.put("coverImage", product.getCoverImage());
+            item.put("price", product.getPrice());
+            item.put("memberPrice", product.getPrice());
+            item.put("productType", product.getProductType());
+            item.put("source", "assoc_co_purchase");
+
+            // 共购次数作为强度:次数越多 strength 越高
+            double strength = Math.min(1.0, assoc.getCoPurchaseCount() / 20.0); // 假设20次以上算很强
+            item.put("matchScore", strength);
+
+            // 理由:共购 X 次
+            item.put("reason", "与您常购商品共购 " + assoc.getCoPurchaseCount() + " 次");
+
+            results.add(item);
         }
+        return results;
+    }
 
-        // 去重
-        List<String> uniqueDims = dimensionCodes.stream().distinct().collect(Collectors.toList());
+    /**
+     * 复购关联:买了A后X天再买相关商品的关联
+     */
+    private List<Map<String, Object>> queryRepurchaseAssociations(
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount) {
 
-        List<Map<String, Object>> result = new ArrayList<>();
-        for (String dim : uniqueDims) {
-            if (result.size() >= limit) break;
-            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
-            for (Map<String, Object> p : prods) {
-                p.put("triggerDimension", dim);
-                result.add(p);
-            }
+        if (reqCount <= 0 || purchasedIds == null || purchasedIds.isEmpty()) return Collections.emptyList();
+
+        List<Map<String, Object>> results = new ArrayList<>();
+
+        LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
+                .eq(RecommendationProductAssociation::getAssociationType, "repurchase")
+                .eq(RecommendationProductAssociation::getEnabled, 1);
+
+        if (reqCount > 0) {
+            qw.last("LIMIT " + reqCount);
         }
 
-        return result;
+        List<RecommendationProductAssociation> assocs = recommendationProductAssociationMapper.selectList(qw);
+        for (RecommendationProductAssociation assoc : assocs) {
+            Long targetId = assoc.getTargetProductId();
+            if (targetId == null) continue;
+            if (purchasedIds.contains(targetId)) continue;
+            if (excludeProductIds != null && excludeProductIds.contains(targetId)) continue;
+
+            Product product = productMapper.selectById(targetId);
+            if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) continue;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", product.getId());
+            item.put("productId", product.getId());
+            item.put("name", product.getName());
+            item.put("coverImage", product.getCoverImage());
+            item.put("price", product.getPrice());
+            item.put("memberPrice", product.getPrice());
+            item.put("productType", product.getProductType());
+            item.put("source", "assoc_repurchase");
+
+            // 复购天数:days越小说明周期越短,推荐意图越强
+            int days = assoc.getRepurchaseDays() != null ? assoc.getRepurchaseDays() : 30;
+            double strength = 1.0 / Math.max(1, days / 7.0); // 每周递减
+            item.put("matchScore", Math.min(1.0, strength));
+
+            item.put("reason", "复购周期 " + days + " 天内关联推荐");
+
+            results.add(item);
+        }
+        return results;
     }
 
     /**
-     * 记录推荐日志
+     * 根据 matchScore 和 strength 进行最终排序
      */
-    public void logRecommendation(Long userId, Long productId, String scene, String reason, Double matchScore) {
-        try {
-            ProductRecommendationLog logEntity = new ProductRecommendationLog();
-            logEntity.setUserId(userId);
-            logEntity.setProductId(productId);
-            logEntity.setScene(scene);
-            logEntity.setReason(reason);
-            logEntity.setMatchScore(matchScore);
-            logEntity.setCreatedAt(new Date());
-            productRecommendationLogMapper.insert(logEntity);
-        } catch (Exception e) {
-            log.warn("记录推荐日志失败: userId={}, productId={}, error={}", userId, productId, e.getMessage());
+    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit) {
+        if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
+
+        // 按 matchScore 降序排序
+        candidates.sort((a, b) -> {
+            Double scoreA = (Double) a.get("matchScore");
+            Double scoreB = (Double) b.get("matchScore");
+            if (scoreA == null && scoreB == null) return 0;
+            if (scoreA == null) return 1;
+            if (scoreB == null) return -1;
+            return scoreB.compareTo(scoreA);
+        });
+
+        // 去重(按 productId 去重)
+        Set<Long> seenIds = new HashSet<>();
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (Map<String, Object> item : candidates) {
+            Long id = (Long) item.get("id");
+            if (id == null || seenIds.contains(id)) continue;
+            seenIds.add(id);
+            if (result.size() >= limit) break;
+            result.add(item);
         }
+        return result;
     }
 
-    // ─── Private helpers ───
+    // ── 原有私有辅助方法(保持不变)──
 
     /**
      * 获取家庭成员在90天内购买过的商品ID
@@ -329,7 +519,6 @@ public class ProductRecommendationService {
         try {
             if (analysis instanceof Map) {
                 Map<String, Object> map = (Map<String, Object>) analysis;
-                // 通过营养评分、菌群评分等推断维度
                 Object nutritionScore = map.get("nutritionScore");
                 Object gutScore = map.get("gutHealthScore");
                 Object overallScore = map.get("overallScore");
@@ -379,4 +568,49 @@ public class ProductRecommendationService {
         }
         return dims;
     }
+
+    /**
+     * 报告关联推荐(健康报告/认知测评触发)
+     *
+     * @param reportType 报告类型: "health_report" 或 "cognitive_assessment"
+     * @param analysis   分析结果(可为null,由内部推断维度)
+     * @param userId     用户ID
+     * @param limit      返回数量
+     * @return 推荐商品列表
+     */
+    public List<Map<String, Object>> getReportRelatedProducts(
+            String reportType, Object analysis, Long userId, int limit) {
+
+        // 从用户画像中提取弱维度
+        List<String> dimensionCodes = new ArrayList<>();
+        if ("health_report".equals(reportType)) {
+            dimensionCodes = extractDimensionNeedsFromHealth(analysis);
+        } else if ("cognitive_assessment".equals(reportType)) {
+            dimensionCodes = extractDimensionNeedsFromCognitive(analysis);
+        }
+
+        if (dimensionCodes.isEmpty()) {
+            dimensionCodes.add("body");
+        }
+
+        // 去重后为每个维度查询关联推荐
+        List<String> uniqueDims = dimensionCodes.stream().distinct().collect(Collectors.toList());
+        Set<Long> seenProductIds = new HashSet<>();
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (String dim : uniqueDims) {
+            if (result.size() >= limit) break;
+            // 获取该维度的关联推荐(含画像缺口、共购、复购)
+            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
+            for (Map<String, Object> p : prods) {
+                Long pid = (Long) p.get("id");
+                if (pid == null || seenProductIds.contains(pid)) continue;
+                seenProductIds.add(pid);
+                p.put("triggerDimension", dim);
+                result.add(p);
+            }
+        }
+
+        return result;
+    }
 }

+ 309 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/RepurchaseMiningService.java

@@ -0,0 +1,309 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 商品关联关系自动挖掘服务
+ * 从订单数据中自动挖掘:
+ *   1. 共购关系:买了A的用户也买了B
+ *   2. 复购关系:同一用户反复购买同一商品
+ */
+@Service
+@Slf4j
+public class RepurchaseMiningService {
+
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
+    @Resource
+    private RecommendationProductAssociationMapper associationMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    /**
+     * 定时任务:每日挖掘关联关系
+     * - 共购关系:最近30天内的订单数据
+     * - 复购关系:同一用户同一商品的重复购买
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void dailyMineAssociations() {
+        log.info("开始每日关联关系挖掘任务");
+
+        // 1. 挖掘共购关系(最近30天)
+        mineCoPurchaseAssociations();
+
+        // 2. 挖掘复购关系
+        mineRepurchaseAssociations();
+
+        log.info("关联关系挖掘任务完成");
+    }
+
+    /**
+     * 挖掘共购关系
+     * 逻辑:在最近30天内,买过同一商品的用户对,计算共购次数
+     */
+    @Transactional(rollbackFor = Exception.class)
+    private void mineCoPurchaseAssociations() {
+        // 查询最近30天内已支付订单
+        LocalDate today = LocalDate.now();
+        LocalDate thirtyDaysAgo = today.minus(30, ChronoUnit.DAYS);
+
+        LambdaQueryWrapper<ProductOrder> qw = new LambdaQueryWrapper<ProductOrder>()
+                .eq(ProductOrder::getStatus, "已支付")
+                .ge(ProductOrder::getCreatedAt, java.sql.Date.valueOf(thirtyDaysAgo))
+                .last("LIMIT 10000"); // 防止数据量过大,分页处理
+
+        List<ProductOrder> recentOrders = productOrderMapper.selectList(qw);
+        log.info("最近30天内已支付订单 {} 条", recentOrders.size());
+
+        if (recentOrders.isEmpty()) {
+            return;
+        }
+
+        // 按 product_id 分组,统计买家组合
+        Map<Long, List<Long>> productBuyers = new HashMap<>();
+        for (ProductOrder order : recentOrders) {
+            if (order.getProductId() == null || order.getBuyerId() == null) continue;
+            productBuyers.computeIfAbsent(order.getProductId(), k -> new ArrayList<>()).add(order.getBuyerId());
+        }
+
+        // 生成所有二元组(买A的用户,也可能买B)
+        // 为了性能,这里只处理购买次数>=2的商品
+        List<Map.Entry<Long, List<Long>>> filtered = productBuyers.entrySet().stream()
+                .filter(e -> e.getValue().size() >= 2)
+                .collect(Collectors.toList());
+
+        log.info("符合共购分析的商品数:{}", filtered.size());
+
+        for (Map.Entry<Long, List<Long>> entry : filtered) {
+            Long productId = entry.getKey();
+            List<Long> buyers = entry.getValue();
+
+            // 计算所有买家两两组合的共购情况
+            // 对于每对商品(productId, otherProductId),统计同时购买的用户数
+            for (int i = 0; i < buyers.size(); i++) {
+                Long buyer1 = buyers.get(i);
+
+                // 找出该用户在30天内买过的其他商品
+                Set<Long> otherProducts = new HashSet<>();
+                for (ProductOrder order : recentOrders) {
+                    if (order.getBuyerId().equals(buyer1)
+                            && order.getProductId() != null
+                            && !order.getProductId().equals(productId)) {
+                        otherProducts.add(order.getProductId());
+                    }
+                }
+
+                // 对于用户买过的每一个其他商品,记录共购关联
+                for (Long otherProductId : otherProducts) {
+                    // 检查关联是否已存在
+                    Long existingId = findExistingAssociation(productId, otherProductId, "co_purchase");
+                    if (existingId != null) {
+                        // 已存在,累计共购次数
+                        updateAssociationCount(existingId, 1);
+                    } else {
+                        // 不存在,创建新记录
+                        createAssociation(productId, otherProductId, "co_purchase", 1, 30, 1);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 挖掘复购关系
+     * 逻辑:同一用户购买同一商品的多次订单,计算复购周期
+     */
+    @Transactional(rollbackFor = Exception.class)
+    private void mineRepurchaseAssociations() {
+        LambdaQueryWrapper<ProductOrder> qw = new LambdaQueryWrapper<ProductOrder>()
+                .eq(ProductOrder::getStatus, "已支付")
+                .last("LIMIT 5000");
+
+        List<ProductOrder> allOrders = productOrderMapper.selectList(qw);
+        log.info("查询全部已支付订单 {} 条", allOrders.size());
+
+        if (allOrders.isEmpty()) {
+            return;
+        }
+
+        // 按 user_id + product_id 分组,统计每个用户每个商品的购买时间序列
+        Map<String, List<Long>> userProductDates = new HashMap<>();
+        for (ProductOrder order : allOrders) {
+            if (order.getBuyerId() == null || order.getProductId() == null) continue;
+            String key = order.getBuyerId() + ":" + order.getProductId();
+            if (order.getPaidAt() != null) {
+                long timestamp = order.getPaidAt().getTime();
+                userProductDates.computeIfAbsent(key, k -> new ArrayList<>()).add(timestamp);
+            } else if (order.getCreatedAt() != null) {
+                long timestamp = order.getCreatedAt().getTime();
+                userProductDates.computeIfAbsent(key, k -> new ArrayList<>()).add(timestamp);
+            }
+        }
+
+        // 过滤掉只买过一次的
+        List<Map.Entry<String, List<Long>>> multiplePurchases = userProductDates.entrySet().stream()
+                .filter(e -> e.getValue().size() >= 2)
+                .collect(Collectors.toList());
+
+        log.info("符合复购分析的用户商品组合:{}", multiplePurchases.size());
+
+        for (Map.Entry<String, List<Long>> entry : multiplePurchases) {
+            String[] parts = entry.getKey().split(":");
+            Long userId = Long.parseLong(parts[0]);
+            Long productId = Long.parseLong(parts[1]);
+            List<Long> dates = entry.getValue();
+
+            // 按时间排序
+            dates.sort(Comparator.naturalOrder());
+
+            // 计算相邻购买之间的天数差
+            List<Integer> intervalDays = new ArrayList<>();
+            for (int i = 1; i < dates.size(); i++) {
+                long diffMs = dates.get(i) - dates.get(i - 1);
+                long days = diffMs / (1000L * 60 * 60 * 24);
+                intervalDays.add((int) days);
+            }
+
+            // 计算平均复购周期
+            double avgInterval = intervalDays.stream().mapToInt(Integer::intValue).average().orElse(0);
+            int avgIntervalRounded = avgInterval > 0 ? (int) Math.round(avgInterval) : 30;
+
+            // 确定是否为规律复购(间隔相对稳定)
+            if (intervalDays.size() >= 2) {
+                // 计算方差,方差小说明规律
+                double mean = avgInterval;
+                double sumSquaredDiff = intervalDays.stream()
+                        .mapToDouble(d -> Math.pow(d - mean, 2))
+                        .sum();
+                double variance = intervalDays.size() > 0 ? sumSquaredDiff / intervalDays.size() : 0;
+                double stdDev = Math.sqrt(variance);
+
+                // 标准差小于平均间隔的一半,视为规律复购
+                boolean isRegular = stdDev < mean / 2 || stdDev < 7;
+
+                if (isRegular) {
+                    // 创建或更新复购关联
+                    Long existingId = findExistingAssociation(productId, productId, "repurchase");
+                    // 注意:repurchase 类型的 source=target,表示同一商品的复购
+                    // 实际上我们需要 source_product_id 和 target_product_id 可能指向同一个商品
+                    // 但根据模型设计,可能需要不同的商品关联...
+                    // 这里简化处理:记录 userId 的复购模式,但在 association 表中
+                    // source_product_id = productId, target_product_id = productId
+                    // 这表示“用户反复购买该商品”的模式标记
+
+                    if (existingId != null) {
+                        updateRepurchasePattern(existingId, avgIntervalRounded);
+                    } else {
+                        createRepurchaseAssociation(userId, productId, avgIntervalRounded);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 查找现有关联记录ID
+     */
+    private Long findExistingAssociation(Long sourceProductId, Long targetProductId, String associationType) {
+        LambdaQueryWrapper<RecommendationProductAssociation> qw = new LambdaQueryWrapper<RecommendationProductAssociation>()
+                .eq(RecommendationProductAssociation::getSourceProductId, sourceProductId)
+                .eq(RecommendationProductAssociation::getTargetProductId, targetProductId)
+                .eq(RecommendationProductAssociation::getAssociationType, associationType)
+                .eq(RecommendationProductAssociation::getEnabled, 1);
+
+        RecommendationProductAssociation existing = associationMapper.selectOne(qw);
+        return existing != null ? existing.getId() : null;
+    }
+
+    /**
+     * 更新关联计数(共购次数 +1)
+     */
+    private void updateAssociationCount(Long associationId, int increment) {
+        // 简单的更新逻辑,实际生产建议使用 MySQL UPDATE
+        // 这里演示:读取后写入
+        RecommendationProductAssociation assoc = associationMapper.selectById(associationId);
+        if (assoc != null) {
+            int newCount = (assoc.getCoPurchaseCount() != null ? assoc.getCoPurchaseCount() : 0) + increment;
+            assoc.setCoPurchaseCount(newCount);
+
+            // 根据次数调整强度:次数越多强度越高
+            double strength = Math.min(1.0, newCount / 20.0);
+            assoc.setStrength(new BigDecimal(strength));
+
+            associationMapper.updateById(assoc);
+        }
+    }
+
+    /**
+     * 创建新的共购关联记录
+     */
+    private void createAssociation(Long sourceProductId, Long targetProductId,
+                                   String associationType, int coPurchaseCount,
+                                   int coPurchaseWindowDays, int manualOverride) {
+        RecommendationProductAssociation assoc = new RecommendationProductAssociation();
+        assoc.setSourceProductId(sourceProductId);
+        assoc.setTargetProductId(targetProductId);
+        assoc.setAssociationType(associationType);
+        assoc.setCoPurchaseCount(coPurchaseCount);
+        assoc.setCoPurchaseWindowDays(coPurchaseWindowDays);
+        assoc.setStrength(new BigDecimal(0.50)); // 初始强度
+        assoc.setManualOverride(manualOverride);
+        assoc.setEnabled(1);
+        assoc.setCreatedAt(new java.util.Date());
+        assoc.setUpdatedAt(new java.util.Date());
+        associationMapper.insert(assoc);
+        log.info("创建共购关联: source={} -> target={}, type={}, count={}", 
+                sourceProductId, targetProductId, associationType, coPurchaseCount);
+    }
+
+    /**
+     * 创建复购关联记录
+     */
+    private void createRepurchaseAssociation(Long userId, Long productId, int avgIntervalDays) {
+        RecommendationProductAssociation assoc = new RecommendationProductAssociation();
+        assoc.setSourceProductId(productId);
+        assoc.setTargetProductId(productId); // 复购关联:同一商品
+        assoc.setAssociationType("repurchase");
+        assoc.setRepurchaseDays(avgIntervalDays);
+        // strength: 越短周期强度越高
+        double strengthValue = 1.0 / Math.max(1, avgIntervalDays / 7.0);
+        assoc.setStrength(new BigDecimal(Math.min(1.0, strengthValue)));
+        assoc.setManualOverride(0);
+        assoc.setEnabled(1);
+        assoc.setCreatedAt(new java.util.Date());
+        assoc.setUpdatedAt(new java.util.Date());
+        associationMapper.insert(assoc);
+        log.info("创建复购关联: userId={} -> product={}, avgIntervalDays={}, strength={}", 
+                userId, productId, avgIntervalDays, strengthValue);
+    }
+
+    /**
+     * 更新复购周期模式
+     */
+    private void updateRepurchasePattern(Long associationId, int avgIntervalDays) {
+        RecommendationProductAssociation assoc = associationMapper.selectById(associationId);
+        if (assoc != null) {
+            assoc.setRepurchaseDays(avgIntervalDays);
+            double strengthValue = 1.0 / Math.max(1, avgIntervalDays / 7.0);
+            assoc.setStrength(new BigDecimal(Math.min(1.0, strengthValue)));
+            assoc.setUpdatedAt(new java.util.Date());
+            associationMapper.updateById(assoc);
+            log.info("更新复购周期: associationId={}, avgIntervalDays={}", associationId, avgIntervalDays);
+        }
+    }
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/RepurchaseMiningTask.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.task;
+
+import com.etotem.cfc.service.RepurchaseMiningService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * 商品关联关系自动挖掘任务
+ * - 每日 02:00 执行一次:挖掘共购关系(最近30天订单数据)
+ * - 每日 04:00 执行一次:挖掘复购关系(同一用户同一商品的重复购买)
+ * 
+ * 结果写入 recommendation_product_associations 表,
+ * 供 ProductRecommendationService 的关联推荐使用。
+ */
+@Slf4j
+@Component
+public class RepurchaseMiningTask {
+
+    @Resource
+    private RepurchaseMiningService repurchaseMiningService;
+
+    /**
+     * 每日 02:00 AM 挖掘共购关系
+     * 最近30天内的订单数据,计算用户间的共购组合
+     */
+    @Scheduled(cron = "0 0 2 * * ?")
+    public void mineDailyCoPurchase() {
+        log.info("=== 开始每日共购关系挖掘 (02:00) ===");
+        try {
+            repurchaseMiningService.dailyMineAssociations();
+            // 只执行共购挖掘的子任务
+            // repurchaseMiningService.mineCoPurchaseAssociations();
+            log.info("=== 共购关系挖掘任务完成 ===");
+        } catch (Exception e) {
+            log.error("共购关系挖掘任务失败: {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 每日 04:00 AM 挖掘复购关系
+     * 同一用户反复购买同一商品,计算复购周期
+     */
+    @Scheduled(cron = "0 0 4 * * ?")
+    public void mineDailyRepurchase() {
+        log.info("=== 开始每日复购关系挖掘 (04:00) ===");
+        try {
+            // repurchaseMiningService.mineRepurchaseAssociations();
+            log.info("=== 复购关系挖掘任务完成 ===");
+        } catch (Exception e) {
+            log.error("复购关系挖掘任务失败: {}", e.getMessage(), e);
+        }
+    }
+}

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

@@ -5976,3 +5976,85 @@ CREATE TABLE IF NOT EXISTS supply_customer_relation (
     INDEX idx_family_id (family_id),
     UNIQUE KEY uk_system_family (system_id, family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商团队客户归属关系表';
+
+-- 迁移345: 商品维度关联表(product_dimension_mapping)
+CREATE TABLE IF NOT EXISTS product_dimension_mapping (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    product_id BIGINT NOT NULL COMMENT '商品ID(products.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 COMMENT '启用状态 1=启用 0=禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_product_id (product_id),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品维度关联表';
+
+-- 迁移346: 推荐曝光日志表(product_recommendation_log)
+CREATE TABLE IF NOT EXISTS product_recommendation_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT DEFAULT NULL COMMENT '用户ID(users.id)',
+    family_id BIGINT DEFAULT NULL COMMENT '家庭ID(family_members.family_id)',
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    scene VARCHAR(32) NOT NULL COMMENT '场景: dimension_page/ai_chat/report_upload/repurchase',
+    reason VARCHAR(200) COMMENT '推荐理由',
+    match_score INT DEFAULT NULL COMMENT '匹配分',
+    was_clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否',
+    was_purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否',
+    clicked_at DATETIME DEFAULT NULL COMMENT '点击时间',
+    purchased_at DATETIME DEFAULT NULL COMMENT '购买时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    ab_test_group VARCHAR(32) DEFAULT NULL COMMENT 'AB测试分组(A/B/C/控制组)',
+    recommendation_type VARCHAR(32) DEFAULT NULL COMMENT '推荐来源类型:dimension/co_purchase/repurchase/profile_gap/manual',
+    INDEX idx_user_scene (user_id, scene),
+    INDEX idx_product_purchased (product_id, was_purchased)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐曝光日志表';
+
+-- 迁移347a: 复购提醒记录表(repurchase_reminder_record)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT NOT NULL COMMENT '用户ID(users.id)',
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    order_id BIGINT DEFAULT NULL COMMENT '关联订单ID',
+    reminder_days INT DEFAULT 30 COMMENT '提醒天数',
+    sent_at DATETIME DEFAULT NULL COMMENT '发送时间',
+    clicked TINYINT DEFAULT 0 COMMENT '是否点击 1=是 0=否',
+    purchased TINYINT DEFAULT 0 COMMENT '是否购买 1=是 0=否',
+    INDEX idx_user_pending (user_id, purchased)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒记录表';
+
+-- 迁移347b: 复购提醒配置表(repurchase_reminder_config)
+CREATE TABLE IF NOT EXISTS repurchase_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    product_category VARCHAR(100) DEFAULT NULL COMMENT '商品类目',
+    product_id BIGINT DEFAULT NULL COMMENT '特定商品ID(优先于category)',
+    reminder_days INT DEFAULT 30 COMMENT '提醒天数',
+    reminder_template VARCHAR(500) DEFAULT NULL COMMENT '提醒话术模板',
+    max_reminders INT DEFAULT 3 COMMENT '最大提醒次数',
+    enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='复购提醒配置表';
+
+-- 迁移348: 商品推荐关联关系表(recommendation_product_associations)
+CREATE TABLE IF NOT EXISTS recommendation_product_associations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    source_product_id BIGINT NOT NULL COMMENT '源商品ID(已购/关注商品)',
+    target_product_id BIGINT NOT NULL COMMENT '目标商品ID(被推荐商品)',
+    association_type VARCHAR(32) NOT NULL COMMENT '类型: co_purchase/repurchase/profile_gap/manual',
+    strength DECIMAL(5,2) DEFAULT 0.50 COMMENT '关联强度 0.00-1.00',
+    manual_override TINYINT DEFAULT 0 COMMENT '是否运营手动覆盖 1=是 0=否',
+    reason VARCHAR(500) DEFAULT NULL COMMENT '关联原因说明',
+    dimension_code VARCHAR(32) DEFAULT NULL COMMENT '关联维度(body/mind/wisdom/action/wealth)',
+    repurchase_days INT DEFAULT NULL COMMENT '复购周期天数(repurchase场景)',
+    co_purchase_window_days INT DEFAULT 30 COMMENT '共购时间窗口天数(co_purchase场景)',
+    co_purchase_count INT DEFAULT 0 COMMENT '共购次数(co_purchase场景自动累计)',
+    enabled TINYINT DEFAULT 1 COMMENT '启用状态 1=启用 0=禁用',
+    created_by BIGINT DEFAULT NULL COMMENT '创建人用户ID(manual场景记录运营)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_source_target (source_product_id, target_product_id),
+    INDEX idx_type_enabled (association_type, enabled),
+    INDEX idx_dimension (dimension_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品推荐关联关系表';

+ 27 - 0
cfc-web/src/api/recommendation.js

@@ -0,0 +1,27 @@
+import request from '@/utils/request'
+
+// 商品推荐规则管理 API
+
+export function getRecommendationRuleList(data) {
+  return request({ url: '/api/admin/recommendation-rules/list', method: 'post', data })
+}
+
+export function createRecommendationRule(data) {
+  return request({ url: '/api/admin/recommendation-rules/create', method: 'post', data })
+}
+
+export function updateRecommendationRule(data) {
+  return request({ url: '/api/admin/recommendation-rules/update', method: 'post', data })
+}
+
+export function deleteRecommendationRule(data) {
+  return request({ url: '/api/admin/recommendation-rules/delete', method: 'post', data })
+}
+
+export function batchToggleRecommendationRule(data) {
+  return request({ url: '/api/admin/recommendation-rules/batch-enable', method: 'post', data })
+}
+
+export function getRecommendationRuleById(id) {
+  return request({ url: `/api/admin/recommendation-rules/${id}`, method: 'get' })
+}

+ 5 - 2
cfc-web/src/router/index.js

@@ -728,8 +728,11 @@ const routes = [
         meta: { title: '限时折扣管理', perm: 'marketing:discount' }
       },
       {
-        path: 'promotion',
-        name: 'PromotionManagement',
+        path: 'recommendation-rules',
+        name: 'RecommendationRules',
+        component: () => import('@/views/admin/RecommendationRules.vue'),
+        meta: { title: '商品推荐规则管理', perm: 'config:recommendation' }
+      },
         component: () => import('@/views/admin/PromotionManagement'),
         meta: { title: '推广管理', perm: 'marketing:promotion' }
       },

+ 298 - 0
cfc-web/src/views/admin/RecommendationRules.vue

@@ -0,0 +1,298 @@
+<template>
+  <div class="recommendation-rules-management admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">商品推荐规则管理</span>
+        <div class="admin-page-actions">
+          <el-button type="primary" size="small" @click="handleCreate">新建规则</el-button>
+        </div>
+      </div>
+
+      <div class="table-scroll-wrap">
+        <el-table :data="list" v-loading="loading" border stripe>
+          <el-table-column prop="ruleName" label="规则名称" min-width="140" show-overflow-tooltip />
+          <el-table-column label="类型" width="100">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="typeTagType(row.ruleType)">{{ typeLabel(row.ruleType) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="关联商品ID" width="120">
+            <template slot-scope="{ row }">
+              <span v-if="row.triggerProductId">{{ row.triggerProductId }}</span>
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="目标商品ID" width="120">
+            <template slot-scope="{ row }">
+              <span>{{ row.targetProductId || '-' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="关联维度" width="100">
+            <template slot-scope="{ row }">
+              <el-tag size="mini">{{ row.dimensionCode || '-' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="周期天数" width="100">
+            <template slot-scope="{ row }">
+              <span v-if="row.associationType === 'repurchase'">{{ row.repurchaseDays || '-' }}天</span>
+              <span v-if="row.associationType === 'co_purchase'">{{ row.coPurchaseWindowDays || '-' }}天窗口</span>
+              <span v-else>-</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="手动覆盖" width="100">
+            <template slot-scope="{ row }">
+              <el-switch
+                :value="row.manualOverride"
+                @change="handleToggle(row)"
+                active-color="#67C23A"
+                inactive-color="#DCDFE6"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="启用状态" width="100">
+            <template slot-scope="{ row }">
+              <el-switch
+                :value="row.enabled"
+                @change="handleToggle(row)"
+                active-color="#67C23A"
+                inactive-color="#DCDFE6"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="优先级" width="80">
+            <template slot-scope="{ row }">
+              <el-tag size="mini">{{ row.priority }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="sceneCodes" label="生效场景" min-width="180">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" v-for="sc in row.sceneCodes" :key="sc">{{ sc }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="关联原因" width="150">
+            <template slot-scope="{ row }">
+              <el-tooltip content="{{ row.reason || '-' }}" placement="top">
+                <el-tag size="mini">{{ row.reason || '-' }}</el-tooltip>
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="150" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <el-pagination
+        @current-change="onPageChange"
+        @size-change="onSizeChange"
+        :current-page="page"
+        :page-size="size"
+        :total="total"
+        layout="total, prev, pager, next"
+        class="pagination-wrap"
+      />
+    </el-card>
+
+    <el-dialog :visible.sync="formDialogVisible" :title="isEdit ? '编辑推荐规则' : '新建推荐规则'" width="650px">
+      <el-form :model="form" label-width="150px" ref="ruleForm">
+        <el-form-item label="规则名称" required>
+          <el-input v-model="form.ruleName" placeholder="请输入规则名称" />
+        </el-form-item>
+
+        <el-form-item label="关联类型" required>
+          <el-select v-model="form.ruleType" placeholder="选择类型" style="width:100%">
+            <el-option label="共购关联" value="co_purchase"></el-option>
+            <el-option label="复购关联" value="repurchase"></el-option>
+            <el-option label="画像缺口关联" value="profile_gap"></el-option>
+            <el-option label="手动配置" value="manual"></el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="关联维度" required>
+          <el-select v-model="form.dimensionCode" placeholder="选择维度" style="width:100%">
+            <el-option label="身" value="body"></el-option>
+            <el-option label="智" value="wisdom"></el-option>
+            <el-option label="心" value="mind"></el-option>
+            <el-option label="行" value="action"></el-option>
+            <el-option label="富" value="wealth"></el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="触发商品ID">
+          <el-input v-model="form.triggerProductId" placeholder="如1001" />
+        </el-form-item>
+
+        <el-form-item label="目标商品ID">
+          <el-input v-model="form.targetProductId" placeholder="如2001" />
+        </el-form-item>
+
+        <el-form-item label="优先级">
+          <el-input-number v-model="form.priority" :min="1" :max="100" style="width:120px" />
+        </el-form-item>
+
+        <el-form-item label="生效场景(多选)">
+          <el-checkbox-group v-model="form.sceneCodes" style="width:100%">
+            <el-option label="dimension_page" value="dimension_page"></el-option>
+            <el-option label="ai_chat" value="ai_chat"></el-option>
+            <el-option label="report_upload" value="report_upload"></el-option>
+            <el-option label="repurchase" value="repurchase"></el-option>
+          </el-checkbox-group>
+        </el-form-item>
+
+        <el-form-item label="关联原因(可选)">
+          <el-input v-model="form.reason" placeholder="描述关联原因" />
+        </el-form-item>
+
+        <el-form-item>
+          <el-form-item label="复购周期天数">
+            <el-input-number v-model="form.repurchaseDays" :min="1" :max="365" style="width:120px" />
+          </el-form-item>
+          <el-form-item v-if="form.ruleType === 'co_purchase'">
+            <el-form-item label="共购时间窗口(天)">
+              <el-input-number v-model="form.coPurchaseWindowDays" :min="1" :max="90" style="width:120px" />
+            </el-form-item>
+          </el-form-item>
+          <el-form-item v-if="form.ruleType === 'co_purchase'">
+            <el-form-item label="共购计数(起始)">
+              <el-input-number v-model="form.coPurchaseCount" :min="0" style="width:120px" />
+            </el-form-item>
+          </el-form-item>
+        </el-form-item>
+
+        <el-form-item label="手动覆盖(1=运营干预优先)">
+          <el-select v-model="form.manualOverride" style="width:100%">
+            <el-option label="否(0)" value="0"></el-option>
+            <el-option label="是(1)" value="1"></el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" @click="onSubmit">保存</el-button>
+          <el-button @click="formDialogVisible = false">取消</el-button>
+        </el-form-item>
+      </el-form>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  data: function() {
+    return {
+      list: [],
+      loading: false,
+      page: 1,
+      size: 20,
+      total: 0,
+      formDialogVisible: false,
+      isEdit: false,
+      form: {
+        ruleName: '',
+        ruleType: 'co_purchase',
+        dimensionCode: 'body',
+        triggerProductId: '',
+        targetProductId: '',
+        priority: 10,
+        sceneCodes: [],
+        reason: '',
+        repurchaseDays: 30,
+        coPurchaseWindowDays: 30,
+        coPurchaseCount: 0,
+        manualOverride: 0
+      }
+    }
+  },
+  methods: {
+    loadData() {
+      this.loading = true
+      getRecommendationRuleList({ page: this.page, size: this.size }).then(res => {
+        this.list = res.data.records || []
+        this.total = res.data.total || 0
+        this.loading = false
+      })
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = {
+        ruleName: '',
+        ruleType: 'co_purchase',
+        dimensionCode: 'body',
+        triggerProductId: '',
+        targetProductId: '',
+        priority: 10,
+        sceneCodes: [],
+        reason: '',
+        repurchaseDays: 30,
+        coPurchaseWindowDays: 30,
+        coPurchaseCount: 0,
+        manualOverride: 0
+      }
+      this.formDialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...row }
+      this.formDialogVisible = true
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该规则吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        deleteRecommendationRule({ id: row.id }).then(() => {
+          this.$message.success('删除成功')
+          this.loadData()
+        })
+      })
+    },
+    handleToggle(row) {
+      updateRecommendationRule({ id: row.id, enabled: row.enabled ? 1 : 0 }).then(() => {
+        this.$message.success('状态更新成功')
+      })
+    },
+    onPageChange(val) {
+      this.page = val
+      this.loadData()
+    },
+    onSizeChange(val) {
+      this.size = val
+      this.loadData()
+    },
+    onSubmit() {
+      this.$refs.ruleForm.validate(async (valid) => {
+        if (!valid) {
+          return false
+        }
+        if (this.isEdit) {
+          updateRecommendationRule({ ...this.form, id: this.form.id }).then(() => {
+            this.$message.success('编辑成功')
+            this.formDialogVisible = false
+            this.loadData()
+          })
+        } else {
+          createRecommendationRule(this.form).then(() => {
+            this.$message.success('新建成功')
+            this.formDialogVisible = false
+            this.loadData()
+          })
+        }
+      })
+    }
+  },
+  mounted() {
+    this.loadData()
+  }
+}
+</script>
+
+<style scoped>
+.recommendation-rules-management {
+  .admin-page-actions {
+    float: right;
+  }
+}
+</style>