Forráskód Böngészése

fix(build): 恢复被合并提交 9ebf9366a 损坏的3个文件并补齐缺失类

- DatabaseInitializer.java / schema.sql: 采用有效版本 6be3f0cb7 重建, 并补回迁移374 user_content_weights
- ProductRecommendationService.java: 三方合并恢复 (cb458fa78 x 6be3f0cb7), 保留 UserContentWeightService 与 ArticleMapper
- 新增 cb458fa78 未提交的 UserContentWeight / UserContentWeightMapper / UserContentWeightService
- 后端 mvn clean package BUILD SUCCESS
Xiaogang Liao 1 hete
szülő
commit
8d7cd88ac0

+ 311 - 26
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1,30 +1,288 @@
-// 迁移 354: 创建 user_content_weights 表(用户内容偏好权重表)
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.mapper.*;
+import javax.annotation.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import lombok.var;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StreamUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+@Component
+public class DatabaseInitializer implements CommandLineRunner {
+
+    private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class);
+
+    /** 迁移是否已全部完成(供定时任务判断,避免启动期时序竞态) */
+    private static volatile boolean migrationsComplete = false;
+
+    public static boolean isMigrationsComplete() {
+        return migrationsComplete;
+    }
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    @Resource
+    private ResourceLoader resourceLoader;
+
+    @Value("${app.init-test-data:false}")
+    private boolean initTestData;
+
+    @Override
+    public void run(String... args) throws Exception {
+        log.info("开始初始化数据库...");
+
+        // 1. 检查并创建数据库
+        initializeDatabase();
+
+        // 2. 检查并创建表
+        initializeTables();
+
+        // 3. 执行数据库迁移(添加新列)
+        runMigrations();
+
+        // 4. 初始化食品相关表(DDL,始终执行)
+        initFoodTables();
+
+        // 5. 初始化测试/种子数据(仅测试/开发环境)
+        if (initTestData) {
+            log.info("init-test-data=true,开始初始化测试/种子数据...");
+            initializeDefaultData();
+            log.info("测试/种子数据初始化完成!");
+        } else {
+            log.info("init-test-data=false,跳过测试/种子数据初始化(生产模式)");
+        }
+
+        migrationsComplete = true;
+        log.info("数据库初始化完成!");
+    }
+
+    private void initializeDatabase() {
         try {
-            jdbcTemplate.execute(
-                "CREATE TABLE IF NOT EXISTS user_content_weights (" +
-                "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
-                "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
-                "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
-                "content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article'," +
-                "item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)'," +
-                "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth'," +
-                "weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先'," +
-                "reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile'," +
-                "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_user_content (user_id, content_type, item_id), " +
-                "INDEX idx_family_dimension (family_id, dimension_code), " +
-                "UNIQUE KEY uk_user_item (user_id, content_type, item_id)" +
-                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表'"
-            );
-            log.info("已添加 user_content_weights 表到数据库");
+            // 尝试连接数据库,如果不存在则创建
+            jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS zxyj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+            log.info("数据库检查完成");
         } catch (Exception e) {
-            // 表已存在,忽略错误
-<<<<<<< HEAD
-            log.warn("user_content_weights 表可能已存在,跳过创建", e);
+            log.warn("创建数据库失败: {}", e.getMessage());
+        }
+    }
+
+    private void initializeTables() {
+        try {
+            org.springframework.core.io.Resource resource = resourceLoader.getResource("classpath:schema.sql");
+            String content;
+            try (java.io.InputStream is = resource.getInputStream()) {
+                content = new String(StreamUtils.copyToByteArray(is), StandardCharsets.UTF_8);
+            }
+
+            // Remove SQL single-line comments
+            content = content.replaceAll("(?m)^\\s*--.*$", "");
+
+            // 提取所有 CREATE TABLE IF NOT EXISTS 语句。
+            // 不能用非贪婪 ".*?;" 匹配:COMMENT 字符串字面量中可能含分号(如
+            // task_reminder_config.offset_minutes 的注释 "截止前N分钟; after_timeout..."),
+            // 会被提前截断导致语句不完整而建表失败。此处按字符扫描,跳过引号内部的分号。
+            List<String> createTableSQLs = new ArrayList<>();
+            Pattern createTableHead = Pattern.compile(
+                    "^\\s*CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\b", Pattern.CASE_INSENSITIVE);
+            StringBuilder stmt = new StringBuilder();
+            boolean inSingleQuote = false;
+            boolean inBacktick = false;
+            for (int i = 0; i < content.length(); i++) {
+                char c = content.charAt(i);
+                if (c == '\'' && !inBacktick) {
+                    if (inSingleQuote && i + 1 < content.length() && content.charAt(i + 1) == '\'') {
+                        stmt.append("''");
+                        i++;
+                        continue;
+                    }
+                    inSingleQuote = !inSingleQuote;
+                    stmt.append(c);
+                } else if (c == '`' && !inSingleQuote) {
+                    inBacktick = !inBacktick;
+                    stmt.append(c);
+                } else if (c == ';' && !inSingleQuote && !inBacktick) {
+                    String sql = stmt.toString().trim();
+                    if (!sql.isEmpty() && createTableHead.matcher(sql).find()) {
+                        createTableSQLs.add(sql + ";");
+                    }
+                    stmt.setLength(0);
+                } else {
+                    stmt.append(c);
+                }
+            }
+
+            log.info("从 schema.sql 加载了 {} 个建表语句", createTableSQLs.size());
+
+            for (String sql : createTableSQLs) {
+                try {
+                    jdbcTemplate.execute(sql);
+                } catch (Exception e) {
+                    log.warn("创建表失败: {}", e.getMessage());
+                }
+            }
+
+            log.info("数据表检查完成");
+        } catch (Exception e) {
+            log.error("读取 schema.sql 失败", e);
+        }
+    }
+
+    private void runMigrations() {
+        // 迁移1: 任务表添加执行者字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_type ENUM('child', 'parent') DEFAULT 'child' COMMENT '执行者类型'");
+            log.info("已添加executor_type列到tasks表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
         }
-=======
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN executor_id BIGINT COMMENT '执行者ID'");
+            log.info("已添加executor_id列到tasks表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE tasks ADD INDEX idx_executor (executor_type, executor_id)");
+            log.info("已添加idx_executor索引到tasks表");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
+
+        // 迁移2: 用户表添加角色和积分字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN roles VARCHAR(100) COMMENT '角色列表'");
+            log.info("已添加roles列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN total_points INT DEFAULT 0 COMMENT '家长总积分'");
+            log.info("已添加total_points列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_roles (roles(20))");
+            log.info("已添加idx_roles索引到users表");
+        } catch (Exception e) {
+            // 索引已存在,忽略错误
+        }
+
+        // 迁移3: users表添加新字段
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN id_card VARCHAR(18) COMMENT '身份证号'");
+            log.info("已添加id_card列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN real_name VARCHAR(50) COMMENT '真实姓名'");
+            log.info("已添加real_name列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN phone VARCHAR(11) COMMENT '手机号'");
+            log.info("已添加phone列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN birthday DATE COMMENT '生日'");
+            log.info("已添加birthday列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD COLUMN gender ENUM('male', 'female') COMMENT '性别'");
+            log.info("已添加gender列到users表");
+        } catch (Exception e) {
+            // 列已存在,忽略错误
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users ADD INDEX idx_phone (phone)");
+            log.info("已添加idx_phone索引到users表");
+} catch (Exception e) {
+
+        }
+
+        try {
+            jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher') NOT NULL");
+            log.info("已更新users.role字段支持teacher角色");
+        } catch (Exception e) {
+            // 修改失败,忽略错误
+        }
+
+        // 迁移5.1: 修改users.role为支持admin
+        try {
+            jdbcTemplate.execute("ALTER TABLE users MODIFY COLUMN role ENUM('parent', 'child', 'teacher', 'admin') NOT NULL");
+            log.info("已更新users.role字段支持admin角色");
+        } catch (Exception e) {
+            // 修改失败,忽略错误
+        }
+
+        // 迁移5.2: 确保管理员角色为admin(修复旧数据遗留问题)
+        try {
+            int updated = jdbcTemplate.update("UPDATE admins SET role = 'admin' WHERE username = 'admin'");
+            if (updated > 0) {
+                log.info("已修正管理员角色为admin");
+            }
+        } catch (Exception e) {
+            // 更新失败,忽略错误
+        }
+
+        // 迁移6: 创建streets表(街道地址维度表)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS streets (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "province VARCHAR(50) COMMENT '省份', " +
+                    "province_code VARCHAR(20) COMMENT '省份编码', " +
+                    "city VARCHAR(50) COMMENT '城市', " +
+                    "city_code VARCHAR(20) COMMENT '城市编码', " +
+                    "district VARCHAR(50) COMMENT '区县', " +
+                    "district_code VARCHAR(20) COMMENT '区县编码', " +
+                    "street VARCHAR(100) COMMENT '街道', " +
+                    "street_code VARCHAR(20) COMMENT '街道编码', " +
+                    "full_name VARCHAR(200) COMMENT '完整地址', " +
+                    "level INT COMMENT '层级:1省2市3区4街道', " +
+                    "parent_id BIGINT COMMENT '父级ID', " +
+                    "sort_order INT DEFAULT 0 COMMENT '排序', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_level (level), " +
+                    "INDEX idx_parent_id (parent_id), " +
+                    "INDEX idx_street_code (street_code), " +
+                    "INDEX idx_full_name (full_name(50))" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+            log.info("已创建streets表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
         }
 
         // 迁移7: families表添加地址字段
@@ -2798,6 +3056,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移373: products 添加 tags 列(文章-商品标签关联推荐)
         addProductTagsColumn();
+
+        // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
+        addUserContentWeightsTable();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -12345,6 +12606,31 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移374: 创建 user_content_weights 表(用户内容偏好权重表)
+    private void addUserContentWeightsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_content_weights (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID'," +
+                    "user_id BIGINT NOT NULL COMMENT '用户ID(users.id)'," +
+                    "family_id BIGINT NOT NULL COMMENT '家庭ID'," +
+                    "content_type VARCHAR(16) NOT NULL COMMENT '内容类型: product/activity/article'," +
+                    "item_id BIGINT NOT NULL COMMENT '内容ID(商品ID/活动ID/文章ID)'," +
+                    "dimension_code VARCHAR(32) DEFAULT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth'," +
+                    "weight DECIMAL(5,2) NOT NULL DEFAULT 1.00 COMMENT '权重值 0.00-10.00,越大越优先'," +
+                    "reason VARCHAR(200) DEFAULT NULL COMMENT '权重来源: user_set/auto_click/auto_purchase/manual/auto_profile'," +
+                    "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_user_content (user_id, content_type, item_id)," +
+                    "INDEX idx_family_dimension (family_id, dimension_code)," +
+                    "UNIQUE KEY uk_user_item (user_id, content_type, item_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表'");
+            log.info("迁移374: 已创建/确认 user_content_weights 表");
+        } catch (Exception e) {
+            log.warn("迁移374: 创建 user_content_weights 表失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移372: nutrition_product 添加 efficacy_tags 列(成分功效标签)
     private void addNutritionProductEfficacyTags() {
         if (!columnExists("nutrition_product", "efficacy_tags")) {
@@ -12358,5 +12644,4 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         ensureColumn("products", "tags", "VARCHAR(500) DEFAULT '' COMMENT '商品标签 JSON 数组,用于文章-商品标签匹配推荐'");
         log.info("迁移373: products.tags 已确认存在");
     }
-}
->>>>>>> 6be3f0cb7b10c6fe0aa222c2c00bc1ed63e84c70
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserContentWeight.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 用户内容偏好权重表(user_content_weights)
+ * 用于记录用户/家庭对某条内容(商品/活动/文章)的偏好权重,供推荐排序使用。
+ */
+@Data
+@TableName("user_content_weights")
+public class UserContentWeight implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID(users.id) */
+    private Long userId;
+
+    /** 家庭ID */
+    private Long familyId;
+
+    /** 内容类型: product/activity/article */
+    private String contentType;
+
+    /** 内容ID(商品ID/活动ID/文章ID) */
+    private Long itemId;
+
+    /** 维度编码: body/mind/wisdom/action/wealth */
+    private String dimensionCode;
+
+    /** 权重值 0.00-10.00,越大越优先 */
+    private BigDecimal weight;
+
+    /** 权重来源: user_set/auto_click/auto_purchase/manual/auto_profile */
+    private String reason;
+
+    /** 创建人ID(manual场景记录运营人员) */
+    private Long createdBy;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    /** 更新时间 */
+    private Date updatedAt;
+}

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

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

+ 986 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -1 +1,986 @@
-"213: return sortAndReturn(candidates, limit, userId, familyId);"
+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 javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+import com.etotem.cfc.util.SortUtil;
+
+/**
+ * 商品推荐服务
+ * 支持三类推荐场景:
+ * 1. 维度页推荐(基于维度匹配 + 关联关系)
+ * 2. AI对话推荐(基于画像缺口 + 购买关联)
+ * 3. 报告关联推荐(健康报告/认知测评触发)
+ * <p>
+ * 核心思路:<strong>静态维度映射</strong> 为基础,<strong>动态关联关系表</strong> 为补充,
+ * 实现<strong>画像驱动</strong>与<strong>购买关联</strong>双轮驱动的个性化推荐。
+ */
+@Slf4j
+@Service
+public class ProductRecommendationService {
+
+    @Resource
+    private ProductMapper productMapper;
+
+    @Resource
+    private ProductDimensionMappingMapper dimensionMappingMapper;
+
+    @Resource
+    private ProductOrderMapper orderMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private ProductRecommendationLogMapper productRecommendationLogMapper;
+
+    @Resource
+    private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
+
+    @Resource
+    private HealthFilterService healthFilterService;
+
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+
+    @Resource
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private SupplySystemService supplySystemService;
+
+    @Resource
+    private UserContentWeightService userContentWeightService;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    /**
+     * 维度页推荐
+     *
+     * @param dimensionCode    维度编码: body/wisdom/mind/action/wealth
+     * @param familyId         家庭ID
+     * @param memberId         成员ID(可选)
+     * @param excludeProductIds 排除的商品ID列表
+     * @param limit            返回数量
+     * @param userId           当前登录用户ID(用于供应商优先级计算,可为null)
+     * @return 推荐商品列表
+     */
+    public List<Map<String, Object>> getDimensionRecommendations(
+            String dimensionCode, Long familyId, Long memberId,
+            List<Long> excludeProductIds, int limit, Long userId) {
+
+        // 0. 加载成员健康画像(硬拦截基础)
+        HealthFilterService.HealthProfile healthProfile = healthFilterService.loadProfile(memberId, null, null);
+
+        // 1. 获取已购商品ID(90天内)
+        Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
+        if (excludeProductIds != null) {
+            purchasedIds.addAll(excludeProductIds);
+        }
+
+        // 2. 获取成员维度得分(画像)
+        Map<String, Integer> memberScores = getMemberDimensionScores(familyId, memberId);
+
+        // 3. 从维度匹配表获取候选商品(静态基础)
+        List<Map<String, Object>> candidates = new ArrayList<>();
+
+        // 从 mapping 表获取关联商品
+        LambdaQueryWrapper<ProductDimensionMapping> mappingQw = new LambdaQueryWrapper<ProductDimensionMapping>()
+                .eq(ProductDimensionMapping::getEnabled, 1);
+        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+            mappingQw.eq(ProductDimensionMapping::getDimensionCode, dimensionCode);
+        }
+        SortUtil.applySort(mappingQw);
+        List<ProductDimensionMapping> mappings = dimensionMappingMapper.selectList(mappingQw);
+
+        if (mappings != null && !mappings.isEmpty()) {
+            List<Long> mappedProductIds = mappings.stream()
+                    .map(ProductDimensionMapping::getProductId)
+                    .filter(id -> !purchasedIds.contains(id))
+                    .collect(Collectors.toList());
+
+            if (!mappedProductIds.isEmpty()) {
+                List<Product> mapped = productMapper.selectList(
+                        new LambdaQueryWrapper<Product>()
+                        .in(Product::getId, mappedProductIds)
+                                .eq(Product::getStatus, "on_shelf")
+                                .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
+                                .orderByAsc(Product::getSortOrder)
+                );
+
+                HealthFilterService.FilterResult mappedFilter = healthFilterService.filterProducts(healthProfile, mapped);
+                mapped = mappedFilter.allowed;
+
+                // 构建 productId → mapping 得分
+                Map<Long, Integer> mappingScores = mappings.stream()
+                        .collect(Collectors.toMap(
+                                ProductDimensionMapping::getProductId,
+                                ProductDimensionMapping::getMatchScore,
+                                (a, b) -> a
+                        ));
+                Map<Long, String> mappingReasons = mappings.stream()
+                        .collect(Collectors.toMap(
+                                ProductDimensionMapping::getProductId,
+                                m -> m.getMatchReason() != null ? m.getMatchReason() : "",
+                                (a, b) -> a
+                        ));
+
+                for (Product p : mapped) {
+                    Integer baseScore = mappingScores.getOrDefault(p.getId(), 100);
+                    String reason = mappingReasons.getOrDefault(p.getId(), "");
+                    double finalScore = applyDimensionBoost(baseScore, dimensionCode, memberScores);
+                    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-detail/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);
+                }
+            }
+        }
+
+        // 3.5 fallback: 当 dimension_mapping 表无数据时,从 Product.domain 匹配
+        if (candidates.isEmpty()) {
+            LambdaQueryWrapper<Product> domainQw = new LambdaQueryWrapper<Product>()
+                    .eq(Product::getStatus, "on_shelf")
+                    .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
+                    .orderByAsc(Product::getSortOrder);
+            if (dimensionCode != null && !dimensionCode.isEmpty()) {
+                domainQw.eq(Product::getDomain, dimensionCode);
+            }
+            if (!purchasedIds.isEmpty()) {
+                domainQw.notIn(Product::getId, purchasedIds);
+            }
+            SortUtil.applySort(domainQw);
+            List<Product> domainMatched = productMapper.selectList(domainQw);
+             HealthFilterService.FilterResult domainFilter = healthFilterService.filterProducts(healthProfile, domainMatched);
+             domainMatched = domainFilter.allowed;
+            for (Product p : domainMatched) {
+                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-detail/product-detail/product-detail?id=" + p.getId());
+                String reason = buildRecommendationReason(p, dimensionCode, memberScores);
+                item.put("reason", reason);
+                double matchScore = calculateMatchScore(p, dimensionCode, memberScores);
+                item.put("matchScore", matchScore);
+                candidates.add(item);
+            }
+        }
+
+        // 4. 关联关系推荐(动态:画像缺口、共购、复购)
+        List<Map<String, Object>> assocResults = queryAssociationRecommendations(
+                dimensionCode, familyId, memberId, purchasedIds, excludeProductIds, limit - candidates.size(), healthProfile);
+
+        // 合并并去重(关联推荐优先级高于维度映射,但保留维度的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);
+            }
+        }
+
+        // 5. 最终排序并返回
+        return sortAndReturn(candidates, limit, userId, familyId);
+    }
+
+    /**
+     * 查询关联关系推荐(画像缺口 + 共购 + 复购)
+     */
+    private List<Map<String, Object>> queryAssociationRecommendations(
+            String dimensionCode, Long familyId, Long memberId,
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
+
+        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, healthProfile);
+            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(), healthProfile);
+            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(), healthProfile);
+            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,
+            HealthFilterService.HealthProfile healthProfile) {
+
+        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);
+            }
+        } else {
+            // 无画像数据时,默认中等强度
+            qw.orderByDesc(RecommendationProductAssociation::getStrength);
+        }
+
+        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()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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_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);
+
+            results.add(item);
+        }
+        return results;
+    }
+
+    /**
+     * 共购关联:从订单数据挖掘“买了A的用户也买了B”
+     */
+    private List<Map<String, Object>> queryCoPurchaseAssociations(
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
+
+        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);
+        }
+
+        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()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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;
+    }
+
+    /**
+     * 复购关联:买了A后X天再买相关商品的关联
+     */
+    private List<Map<String, Object>> queryRepurchaseAssociations(
+            Set<Long> purchasedIds, List<Long> excludeProductIds, int reqCount,
+            HealthFilterService.HealthProfile healthProfile) {
+
+        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);
+        }
+
+        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()) || !hasSellableStock(product)) continue;
+            if (!healthFilterService.checkProductBlocks(healthProfile, product).isEmpty()) 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;
+    }
+
+    /**
+     * 计算供应商优先级加分
+     * <p>
+     * 规则1:若直接引荐人是某产品供应商(supply_system.admin),优先推荐其体系内产品
+     * 规则2:沿引荐链向上追溯,找到任一层级是供应商的引荐人,优先推荐其产品
+     * 规则3:同一供应商体系引荐的用户越多,权重越高(按体系成员数加权)
+     * 补充规则:用户所属多个供应商体系时,其关联产品均优先推荐
+     */
+    private Map<Long, Double> getSupplierPriorityScores(Long userId) {
+        if (userId == null) return Collections.emptyMap();
+
+        // 1. 收集引荐链上的所有祖先(直接推荐人、间接推荐人)
+        List<ReferralTree> ancestorNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getChildId, userId)
+                        .orderByAsc(ReferralTree::getLevel));
+        if (ancestorNodes.isEmpty()) return Collections.emptyMap();
+
+        // 祖先 userId -> 层级(level 越小=关系越近,权重越高)
+        Map<Long, Integer> ancestorLevels = new HashMap<>();
+        for (ReferralTree node : ancestorNodes) {
+            ancestorLevels.putIfAbsent(node.getParentId(), node.getLevel());
+        }
+
+        // 2. 找出引荐链中的供应商(adminId 对应 supply_system)
+        //    supplySystemId -> 引荐层级(取最小即最近层级,权重最高)
+        Map<Long, Integer> supplierSystemToNearestLevel = new HashMap<>();
+        for (Map.Entry<Long, Integer> entry : ancestorLevels.entrySet()) {
+            Long ancestorId = entry.getKey();
+            Integer level = entry.getValue();
+            SupplySystem system = supplySystemService.getByAdminId(ancestorId);
+            if (system != null) {
+                supplierSystemToNearestLevel.merge(system.getId(), level,
+                        (existing, newVal) -> Math.min(existing, newVal));
+            }
+        }
+
+        // 3. 也检查当前用户本身是否属于某些供应商体系(补充规则)
+        List<SupplySystemMember> userSystems = supplySystemMemberMapper.selectList(
+                new LambdaQueryWrapper<SupplySystemMember>()
+                        .eq(SupplySystemMember::getUserId, userId)
+                        .eq(SupplySystemMember::getStatus, "active"));
+        for (SupplySystemMember mem : userSystems) {
+            supplierSystemToNearestLevel.merge(mem.getSystemId(), 0,
+                    (existing, newVal) -> Math.min(existing, newVal));
+        }
+
+        if (supplierSystemToNearestLevel.isEmpty()) return Collections.emptyMap();
+
+        // 4. 计算每个供应商体系的成员数作为权重(规则3)
+        //    supplySystemId -> 加分系数 = memberCount * 0.1 / levelPenalty
+        //    levelPenalty: 直接推荐人(level=1) 系数为1.0,每远一层衰减
+        Map<Long, Double> systemBoosts = new HashMap<>();
+        for (Map.Entry<Long, Integer> sysEntry : supplierSystemToNearestLevel.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Integer level = sysEntry.getValue();
+            // 成员数
+            Long memberCount = supplySystemMemberMapper.selectCount(
+                    new LambdaQueryWrapper<SupplySystemMember>()
+                            .eq(SupplySystemMember::getSystemId, systemId)
+                            .eq(SupplySystemMember::getStatus, "active"));
+            int count = memberCount == null ? 0 : memberCount.intValue();
+            // 层级衰减:level=1(直接推荐人)不打折,level越大折扣越大
+            double levelFactor = Math.max(0.1, 1.0 - (level - 1) * 0.15);
+            // 加分系数 = 成员数 * 0.1 * 层级因子,基础分50,最大加成5分
+            double boost = Math.min(5.0, count * 0.1 * levelFactor);
+            systemBoosts.put(systemId, boost);
+        }
+
+        // 5. 按 distributionSystemId 聚合加分
+        //    同一个产品的 distributionSystemId 对应多个体系时,取最高加分
+        Map<Long, Double> productIdBoost = new HashMap<>();
+        for (Map.Entry<Long, Double> sysEntry : systemBoosts.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Double boost = sysEntry.getValue();
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>()
+                            .eq(Product::getDistributionSystemId, systemId)
+                            .eq(Product::getStatus, "on_shelf"));
+            for (Product p : products) {
+                productIdBoost.merge(p.getId(), boost, Math::max);
+            }
+        }
+
+        return productIdBoost;
+    }
+
+    /**
+     * 根据 matchScore 和供应商优先级进行最终排序
+     * 权重表有数据时按家庭权重总和排序,无权重数据时回退 matchScore
+     */
+    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId, Long familyId) {
+        if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
+        
+        // 计算供应商优先级加分(规则1~3)
+        Map<Long, Double> supplierPriorityScores = getSupplierPriorityScores(userId);
+        
+        // 将供应商优先级加入 matchScore
+        if (!supplierPriorityScores.isEmpty()) {
+            for (Map<String, Object> item : candidates) {
+                Long productId = (Long) item.get("productId");
+                Double priorityBoost = productId != null ? supplierPriorityScores.get(productId) : null;
+                Double currentScore = (Double) item.get("matchScore");
+                if (priorityBoost != null && priorityBoost > 0) {
+                    double boostedScore = (currentScore != null ? currentScore : 0) + priorityBoost;
+                    item.put("matchScore", boostedScore);
+                }
+                item.put("supplierPriorityBoost", priorityBoost != null ? priorityBoost : 0);
+            }
+        }
+        
+        // 计算每个商品的家庭权重总和(家庭聚合:Σ成员权重,方向一致均为正向)
+        Map<Long, Double> productWeightSum = new HashMap<>();
+        if (familyId != null) {
+            for (Map<String, Object> item : candidates) {
+                Long productId = (Long) item.get("productId");
+                if (productId == null) continue;
+                // 查该家庭所有成员对该商品的权重,求和
+                List<UserContentWeight> weights = userContentWeightService.listWeights(null, familyId, "product", productId);
+                double sum = 0.0;
+                if (weights != null) {
+                    for (UserContentWeight w : weights) {
+                        sum += w.getWeight() != null ? w.getWeight().doubleValue() : 0.0;
+                    }
+                }
+                productWeightSum.put(productId, sum);
+                item.put("userWeight", sum);
+            }
+        }
+        
+        // 排序:优先按用户权重(降序),权重相同或无权重数据时回退 matchScore(降序)
+        candidates.sort((a, b) -> {
+            Double weightA = productWeightSum.getOrDefault((Long) a.get("productId"), 0.0);
+            Double weightB = productWeightSum.getOrDefault((Long) b.get("productId"), 0.0);
+            int cmp = weightB.compareTo(weightA);
+            if (cmp != 0) return cmp;
+            
+            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;
+    }
+
+    // ── 原有私有辅助方法(保持不变)──
+
+    // 仅实物商品要求库存>0(库存NULL视为可购),与 ProductOrderService 下单校验口径一致;虚拟商品不看库存
+    private boolean hasSellableStock(Product product) {
+        if (!"physical".equals(product.getProductType())) {
+            return true;
+        }
+        return product.getStock() == null || product.getStock() > 0;
+    }
+
+    /**
+     * 获取家庭成员在90天内购买过的商品ID
+     */
+    private Set<Long> getPurchasedProductIds(Long familyId, int daysAgo) {
+        Set<Long> ids = new HashSet<>();
+        if (familyId == null) return ids;
+
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_YEAR, -daysAgo);
+        Date cutoff = cal.getTime();
+
+        List<ProductOrder> orders = orderMapper.selectList(
+                new LambdaQueryWrapper<ProductOrder>()
+                        .eq(ProductOrder::getFamilyId, familyId)
+                        .in(ProductOrder::getStatus, "paid", "pending_receipt", "shipped", "completed")
+                        .gt(ProductOrder::getPaidAt, cutoff)
+        );
+
+        for (ProductOrder order : orders) {
+            if (order.getProductId() != null) {
+                ids.add(order.getProductId());
+            }
+        }
+        return ids;
+    }
+
+    /**
+     * 获取成员维度得分
+     */
+    private Map<String, Integer> getMemberDimensionScores(Long familyId, Long memberId) {
+        Map<String, Integer> scores = new HashMap<>();
+
+        LambdaQueryWrapper<FiveDimensionScore> qw = new LambdaQueryWrapper<>();
+        qw.eq(familyId != null, FiveDimensionScore::getFamilyId, familyId);
+        qw.eq(memberId != null, FiveDimensionScore::getMemberId, memberId);
+        qw.orderByDesc(FiveDimensionScore::getAssessedAt);
+
+        SortUtil.applySort(qw);
+        List<FiveDimensionScore> records = fiveDimensionScoreMapper.selectList(qw);
+
+        // 取每个维度的最新得分
+        Set<String> seen = new HashSet<>();
+        for (FiveDimensionScore s : records) {
+            if (s.getDimensionCode() != null && !seen.contains(s.getDimensionCode())) {
+                seen.add(s.getDimensionCode());
+                scores.put(s.getDimensionCode(), s.getScore() != null ? s.getScore() : 0);
+            }
+        }
+        return scores;
+    }
+
+    /**
+     * 对低得分维度加权 boost
+     */
+    private double applyDimensionBoost(int baseScore, String dimensionCode, Map<String, Integer> memberScores) {
+        if (dimensionCode == null || dimensionCode.isEmpty()) {
+            return baseScore;
+        }
+        Integer score = memberScores.get(dimensionCode);
+        if (score == null || score >= 70) {
+            return baseScore;
+        } else if (score >= 50) {
+            return baseScore * 1.1;
+        } else {
+            return baseScore * 1.2;
+        }
+    }
+
+    /**
+     * 计算综合匹配分
+     */
+    private double calculateMatchScore(Product product, String dimensionCode, Map<String, Integer> memberScores) {
+        int base = 70;
+        if (dimensionCode == null || dimensionCode.isEmpty()) {
+            Integer minScore = memberScores.values().stream().min(Integer::compareTo).orElse(null);
+            if (minScore != null) {
+                if (minScore < 50) base = 90;
+                else if (minScore < 70) base = 80;
+            }
+            return base;
+        }
+        Integer myScore = memberScores.get(dimensionCode);
+        if (myScore != null) {
+            if (myScore < 50) base = 90;
+            else if (myScore < 70) base = 80;
+            else base = 70;
+        }
+        return base;
+    }
+
+    /**
+     * 构建推荐理由
+     */
+    private String buildRecommendationReason(Product product, String dimensionCode, Map<String, Integer> memberScores) {
+        if (dimensionCode == null || dimensionCode.isEmpty()) {
+            return "为您推荐的好物";
+        }
+        Integer score = memberScores.get(dimensionCode);
+        if (score == null) {
+            return "根据您的维度匹配为您推荐";
+        }
+        if (score < 50) {
+            return "该维度得分偏低,重点推荐";
+        } else if (score < 70) {
+            return "该维度有提升空间,推荐关注";
+        }
+        return "丰富您的维度生活";
+    }
+
+    /**
+     * 从健康分析结果提取维度需求
+     */
+    @SuppressWarnings("unchecked")
+    private List<String> extractDimensionNeedsFromHealth(Object analysis) {
+        List<String> dims = new ArrayList<>();
+        if (analysis == null) return dims;
+
+        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");
+
+                if (nutritionScore instanceof Number && ((Number) nutritionScore).intValue() < 60) {
+                    dims.add("body");
+                }
+                if (gutScore instanceof Number && ((Number) gutScore).intValue() < 60) {
+                    dims.add("body");
+                }
+                if (overallScore instanceof Number && ((Number) overallScore).intValue() < 60) {
+                    dims.add("body");
+                    dims.add("mind");
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析健康分析结果维度失败: {}", e.getMessage());
+        }
+        return dims;
+    }
+
+    /**
+     * 从认知测评结果提取弱维度
+     */
+    @SuppressWarnings("unchecked")
+    private List<String> extractDimensionNeedsFromCognitive(Object analysis) {
+        List<String> dims = new ArrayList<>();
+        if (analysis == null) return dims;
+
+        try {
+            if (analysis instanceof List) {
+                List<String> weakDims = (List<String>) analysis;
+                for (String dim : weakDims) {
+                    if ("focusScore".equals(dim) || "processingSpeedScore".equals(dim)) {
+                        dims.add("wisdom");
+                    } else if ("attentionScore".equals(dim)) {
+                        dims.add("mind");
+                    } else if ("memoryScore".equals(dim)) {
+                        dims.add("wisdom");
+                    } else {
+                        dims.add(dim);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析认知测评结果维度失败: {}", e.getMessage());
+        }
+        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(), null);
+            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;
+    }
+
+    /**
+     * 文章关联商品推荐:通过文章标签与商品标签匹配,按 标签匹配数 &gt; 用户权重 &gt; 家庭权重 &gt; 销量 排序
+     *
+     * @param articleId 文章ID
+     * @param userId    当前用户ID(可为空,用于用户权重)
+     * @param familyId  家庭ID(可为空,用于家庭权重)
+     * @param limit     返回数量
+     * @return 关联商品列表
+     */
+    public List<Map<String, Object>> getArticleRelatedProducts(Long articleId, Long userId, Long familyId, int limit) {
+        if (articleId == null || limit <= 0) return Collections.emptyList();
+
+        // 1. 获取文章标签
+        Article article = articleMapper.selectById(articleId);
+        if (article == null || article.getTags() == null || article.getTags().trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<String> articleTags = parseTagList(article.getTags());
+        if (articleTags.isEmpty()) return Collections.emptyList();
+        Set<String> articleTagSet = new HashSet<>(articleTags);
+
+        // 2. 查询所有在售且有库存的商品
+        List<Product> products = productMapper.selectList(
+                new LambdaQueryWrapper<Product>()
+                        .eq(Product::getStatus, "on_shelf")
+                        .and(w -> w.ne(Product::getProductType, "physical").or().isNull(Product::getStock).or().gt(Product::getStock, 0))
+        );
+        if (products == null || products.isEmpty()) return Collections.emptyList();
+
+        // 3. 预加载用户权重(推荐曝光日志:购买=1.0,点击=0.6)
+        Map<Long, Double> userWeights = new HashMap<>();
+        if (userId != null) {
+            List<ProductRecommendationLog> logs = productRecommendationLogMapper.selectList(
+                    new LambdaQueryWrapper<ProductRecommendationLog>()
+                            .eq(ProductRecommendationLog::getUserId, userId));
+            if (logs != null) {
+                for (ProductRecommendationLog log : logs) {
+                    if (log.getProductId() == null) continue;
+                    double w = userWeights.getOrDefault(log.getProductId(), 0.0);
+                    if (log.getWasPurchased() != null && log.getWasPurchased() == 1) {
+                        w += 1.0;
+                    } else if (log.getWasClicked() != null && log.getWasClicked() == 1) {
+                        w += 0.6;
+                    }
+                    userWeights.put(log.getProductId(), w);
+                }
+            }
+        }
+
+        // 4. 预加载家庭权重(家庭订单数量)
+        Map<Long, Double> familyWeights = new HashMap<>();
+        if (familyId != null) {
+            List<ProductOrder> orders = orderMapper.selectList(
+                    new LambdaQueryWrapper<ProductOrder>()
+                            .eq(ProductOrder::getFamilyId, familyId)
+                            .in(ProductOrder::getStatus, "paid", "pending_receipt", "shipped", "completed"));
+            if (orders != null) {
+                for (ProductOrder order : orders) {
+                    if (order.getProductId() == null) continue;
+                    familyWeights.merge(order.getProductId(), 1.0, Double::sum);
+                }
+            }
+        }
+
+        // 5. 计算标签匹配数与权重,构建候选列表
+        List<Map<String, Object>> candidates = new ArrayList<>();
+        for (Product product : products) {
+            if (product.getTags() == null || product.getTags().trim().isEmpty()) continue;
+            List<String> productTags = parseTagList(product.getTags());
+            if (productTags.isEmpty()) continue;
+
+            int matchCount = 0;
+            for (String tag : productTags) {
+                if (articleTagSet.contains(tag)) matchCount++;
+            }
+            if (matchCount == 0) continue;
+
+            double userWeight = userWeights.getOrDefault(product.getId(), 0.0);
+            double familyWeight = familyWeights.getOrDefault(product.getId(), 0.0);
+
+            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.getMemberPrice() != null ? product.getMemberPrice() : product.getPrice());
+            item.put("productType", product.getProductType());
+            item.put("tagMatchCount", matchCount);
+            item.put("userWeight", userWeight);
+            item.put("familyWeight", familyWeight);
+            item.put("reason", "与文章标签匹配 " + matchCount + " 个标签");
+            item.put("source", "article_tag");
+            item.put("url", "/pages/discover-detail/product-detail/product-detail?id=" + product.getId());
+            candidates.add(item);
+        }
+
+        // 6. 排序:标签匹配数(降序) > 用户权重(降序) > 家庭权重(降序) > 销量(降序)
+        candidates.sort((a, b) -> {
+            int matchA = (Integer) a.get("tagMatchCount");
+            int matchB = (Integer) b.get("tagMatchCount");
+            if (matchA != matchB) return matchB - matchA;
+
+            double userA = (Double) a.get("userWeight");
+            double userB = (Double) b.get("userWeight");
+            if (userA != userB) return Double.compare(userB, userA);
+
+            double familyA = (Double) a.get("familyWeight");
+            double familyB = (Double) b.get("familyWeight");
+            if (familyA != familyB) return Double.compare(familyB, familyA);
+
+            Product pa = findProductById(products, (Long) a.get("productId"));
+            Product pb = findProductById(products, (Long) b.get("productId"));
+            int salesA = pa != null && pa.getSalesCount() != null ? pa.getSalesCount() : 0;
+            int salesB = pb != null && pb.getSalesCount() != null ? pb.getSalesCount() : 0;
+            return salesB - salesA;
+        });
+
+        // 7. 截取 limit 条
+        if (candidates.size() > limit) {
+            return candidates.subList(0, limit);
+        }
+        return candidates;
+    }
+
+    /** 解析标签 JSON 数组字符串(兼容逗号分隔) */
+    private List<String> parseTagList(String tagsJson) {
+        if (tagsJson == null || tagsJson.trim().isEmpty()) return Collections.emptyList();
+        String trimmed = tagsJson.trim();
+        try {
+            if (trimmed.startsWith("[")) {
+                com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(trimmed);
+                if (arr == null) return Collections.emptyList();
+                List<String> tags = new ArrayList<>();
+                for (int i = 0; i < arr.size(); i++) {
+                    Object o = arr.get(i);
+                    if (o != null && !o.toString().trim().isEmpty()) {
+                        tags.add(o.toString().trim());
+                    }
+                }
+                return tags;
+            }
+            List<String> tags = new ArrayList<>();
+            for (String raw : trimmed.split(",")) {
+                String tag = raw.trim();
+                if (!tag.isEmpty()) tags.add(tag);
+            }
+            return tags;
+        } catch (Exception e) {
+            log.warn("解析标签失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /** 在产品列表中查找商品 */
+    private Product findProductById(List<Product> products, Long id) {
+        if (products == null || id == null) return null;
+        for (Product p : products) {
+            if (id.equals(p.getId())) return p;
+        }
+        return null;
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserContentWeightService.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.UserContentWeight;
+import com.etotem.cfc.mapper.UserContentWeightMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 用户内容偏好权重服务。
+ * 表为空时返回空列表,推荐排序将自动回退到 matchScore。
+ */
+@Slf4j
+@Service
+public class UserContentWeightService {
+
+    @Resource
+    private UserContentWeightMapper userContentWeightMapper;
+
+    /**
+     * 查询内容偏好权重列表。
+     * 家庭聚合场景:userId 为 null 时返回该家庭下所有成员对该内容的权重。
+     *
+     * @param userId      用户ID(users.id),可为 null(不限成员)
+     * @param familyId    家庭ID
+     * @param contentType 内容类型: product/activity/article
+     * @param itemId      内容ID
+     * @return 权重列表(表为空时返回空列表)
+     */
+    public List<UserContentWeight> listWeights(Long userId, Long familyId, String contentType, Long itemId) {
+        if (familyId == null || contentType == null || itemId == null) {
+            return Collections.emptyList();
+        }
+        try {
+            return userContentWeightMapper.selectList(
+                    new LambdaQueryWrapper<UserContentWeight>()
+                            .eq(UserContentWeight::getFamilyId, familyId)
+                            .eq(UserContentWeight::getContentType, contentType)
+                            .eq(UserContentWeight::getItemId, itemId)
+                            .eq(userId != null, UserContentWeight::getUserId, userId)
+            );
+        } catch (Exception e) {
+            log.warn("查询用户内容偏好权重失败: familyId={}, contentType={}, itemId={}, error={}",
+                    familyId, contentType, itemId, e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+}

+ 229 - 6
cfc-backend/src/main/resources/schema.sql

@@ -1,7 +1,3 @@
-<<<<<<< HEAD
--- 迁移354: 用户内容偏好权重表(user_content_weights)
-CREATE TABLE IF NOT EXISTS user_content_weights (
-=======
 -- 浠艾福数据库初始化脚本
 -- 自动创建表
 
@@ -6147,7 +6143,234 @@ CREATE TABLE IF NOT EXISTS product_recommendation_log (
 
 -- 迁移347a: 复购提醒记录表(repurchase_reminder_record)
 CREATE TABLE IF NOT EXISTS repurchase_reminder_record (
->>>>>>> 6be3f0cb7b10c6fe0aa222c2c00bc1ed63e84c70
+    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='商品推荐关联关系表';
+
+-- 迁移353: 任务提醒配置表(用户级,一行/用户)
+CREATE TABLE IF NOT EXISTS task_reminder_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    user_id BIGINT NOT NULL COMMENT '用户ID(users.id),家长账号',
+    channels VARCHAR(100) DEFAULT 'wechat' COMMENT '提醒渠道(逗号分隔): in_app/wechat/sms/phone/email',
+    timing_mode VARCHAR(20) DEFAULT 'after_timeout' COMMENT '提醒时机: before_deadline=截止前提醒 after_timeout=超时后提醒',
+    offset_minutes INT DEFAULT 60 COMMENT '偏移分钟: before_deadline=截止前N分钟; after_timeout=超时后N分钟',
+    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 '更新时间',
+    UNIQUE KEY uk_user (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表';
+
+-- 迁移355: 减脂专题 - 体重记录表
+CREATE TABLE IF NOT EXISTS fat_weight_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    weight DECIMAL(5,2) NOT NULL COMMENT '体重(kg)',
+    body_fat_percent DECIMAL(4,2) DEFAULT NULL COMMENT '体脂率(%)',
+    waist_circumference DECIMAL(4,2) DEFAULT NULL COMMENT '腰围(cm)',
+    measure_date DATE NOT NULL COMMENT '测量日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_weight_member (member_id, measure_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-体重体脂记录';
+
+-- 迁移356: 减脂专题 - 饮食记录表
+CREATE TABLE IF NOT EXISTS fat_diet_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    meal_type VARCHAR(20) NOT NULL COMMENT 'meal_type: breakfast/lunch/dinner/snack',
+    food_name VARCHAR(255) NOT NULL COMMENT '食物名称',
+    food_weight DECIMAL(6,2) DEFAULT NULL COMMENT '食物重量(克)',
+    calories INT DEFAULT 0 COMMENT '热量(kcal)',
+    protein DECIMAL(6,2) DEFAULT 0 COMMENT '蛋白质(g)',
+    fat DECIMAL(6,2) DEFAULT 0 COMMENT '脂肪(g)',
+    carbs DECIMAL(6,2) DEFAULT 0 COMMENT '碳水(g)',
+    eat_date DATE NOT NULL COMMENT '进餐日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_diet_member (member_id, eat_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-饮食记录';
+
+-- 迁移357: 减脂专题 - 运动记录表
+CREATE TABLE IF NOT EXISTS fat_exercise_record (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    exercise_type VARCHAR(50) NOT NULL COMMENT '运动类型',
+    duration_min INT DEFAULT 0 COMMENT '运动时长(分钟)',
+    distance_km DECIMAL(5,2) DEFAULT NULL COMMENT '距离(km)',
+    calories_burned INT DEFAULT 0 COMMENT '消耗热量(kcal)',
+    exercise_date DATE NOT NULL COMMENT '运动日期',
+    remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_fat_exercise_member (member_id, exercise_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-运动记录';
+
+-- 迁移358: 减脂专题 - 减脂目标表
+CREATE TABLE IF NOT EXISTS fat_goal (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    target_weight DECIMAL(5,2) NOT NULL COMMENT '目标体重(kg)',
+    start_weight DECIMAL(5,2) NOT NULL COMMENT '初始体重(kg)',
+    target_body_fat DECIMAL(4,2) DEFAULT NULL COMMENT '目标体脂率(%)',
+    target_date DATE DEFAULT NULL COMMENT '目标达成日期',
+    status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/completed/abandoned',
+    start_date DATE NOT NULL COMMENT '开始日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_fat_goal_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='减脂专题-减脂目标';
+-- 迁移359: 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)
+-- cfc 与 train 共用本库(zxyj)。签发方写入 ticket,目标方原子占用(used 0→1)后按 cfc_user_id 建立登录态。
+CREATE TABLE IF NOT EXISTS sso_ticket (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    ticket VARCHAR(64) NOT NULL COMMENT '一次性票据(SecureRandom 生成)',
+    source_system VARCHAR(16) NOT NULL COMMENT '签发方系统: cfc / train',
+    target_system VARCHAR(16) NOT NULL COMMENT '目标系统: cfc / train',
+    source_user_id BIGINT NOT NULL COMMENT '签发方用户ID(cfc=users.id / train=train_user.id)',
+    cfc_user_id BIGINT NOT NULL COMMENT 'cfc 统一会员ID(users.id),跨系统换票依据',
+    used TINYINT NOT NULL DEFAULT 0 COMMENT '是否已使用: 0=未用 1=已用',
+    expire_at DATETIME NOT NULL COMMENT '过期时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY uk_ticket (ticket),
+    INDEX idx_expire (expire_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';
+
+-- 迁移359: 指标归一化映射规则(连接各来源原始名与统一 indicator_definitions)
+CREATE TABLE IF NOT EXISTS indicator_mapping_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension',
+    raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)',
+    alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配',
+    definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id',
+    match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created',
+    priority INT DEFAULT 0 COMMENT '多规则冲突时取最高',
+    status TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_raw (source_type, raw_name),
+    INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则';
+
+-- 迁移360: 指标关联关系(人工定义 + 数据校准)
+CREATE TABLE IF NOT EXISTS indicator_correlations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标',
+    definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标',
+    relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source',
+    direction VARCHAR(10) NOT NULL COMMENT 'positive|negative',
+    manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1',
+    computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)',
+    method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman',
+    sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数',
+    confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)',
+    effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual',
+    manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖',
+    cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending',
+    computed_at DATETIME NULL COMMENT '最后校准时间',
+    created_by BIGINT NULL COMMENT '创建者',
+    description VARCHAR(500) NULL COMMENT '给用户看的解释',
+    source_note VARCHAR(255) NULL COMMENT '依据/出处',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_pair (definition_id_a, definition_id_b),
+    INDEX idx_def_a (definition_id_a),
+    INDEX idx_def_b (definition_id_b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';
+
+-- 迁移368: 药物-食物禁忌对照表
+CREATE TABLE IF NOT EXISTS drug_interactions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    drug_class VARCHAR(50) NOT NULL COMMENT '药物类别(如CCB/他汀/抗凝药/抗生素/铁剂/甲状腺药/利尿剂/MAOI/头孢/降糖药)',
+    drug_specifics VARCHAR(100) NOT NULL COMMENT '具体药名(如硝苯地平/华法林)',
+    interacting_subst VARCHAR(100) NOT NULL COMMENT '禁忌/需间隔的食材或成分(如西柚/维生素K)',
+    risk_level TINYINT NOT NULL COMMENT '1=拦截 2=警告 3=需间隔',
+    action_type VARCHAR(20) NOT NULL COMMENT 'block=硬拦截 warn=软警告 space=间隔',
+    interval_minutes INT DEFAULT NULL COMMENT '需间隔时长(分钟)',
+    note VARCHAR(255) COMMENT '风险说明',
+    UNIQUE KEY uk_drug_food (drug_class, drug_specifics, interacting_subst),
+    INDEX idx_interacting (interacting_subst)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='药物-食物禁忌对照表';
+
+-- 迁移369: 疾病-饮食摄入校验阈值表
+CREATE TABLE IF NOT EXISTS disease_intake_limits (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    disease_type VARCHAR(50) NOT NULL COMMENT '疾病类型(慢性肾病/糖尿病/高血压/痛风/肝病/甲状腺疾病)',
+    nutrient VARCHAR(20) NOT NULL COMMENT '校验营养素(protein/potassium/phosphorus/sodium/carb/glycemic/fructose/purine/alcohol/iodine)',
+    unit VARCHAR(20) NOT NULL COMMENT '单位',
+    threshold_max DECIMAL(8,2) DEFAULT NULL COMMENT '每日上限',
+    threshold_min DECIMAL(8,2) DEFAULT NULL COMMENT '每日下限',
+    block_foods VARCHAR(500) DEFAULT NULL COMMENT '硬拦截食材列表,逗号分隔(如杨桃)',
+    stage_dependent TINYINT(1) DEFAULT 0 COMMENT '是否按病情分期(1=是 0=否)',
+    INDEX idx_disease_nutrient (disease_type, nutrient)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='疾病-饮食摄入校验阈值表';
+
+-- 迁移363: 创建采购任务表(商品组合推荐与采购任务生命周期管理)
+CREATE TABLE IF NOT EXISTS procurement_tasks (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    task_id VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
+    user_id BIGINT NOT NULL COMMENT '所属用户ID',
+    family_member_id BIGINT NOT NULL COMMENT '所属家庭成员ID',
+    combo_tier VARCHAR(20) NOT NULL COMMENT '选择的套餐等级: high|medium|low',
+    original_combo_tier VARCHAR(20) DEFAULT NULL COMMENT '原始套餐等级(用于自定义追踪)',
+    selected_product_sku_ids TEXT COMMENT '最终选中的产品SKU列表(JSON数组)',
+    custom_addition_sku_ids TEXT COMMENT '用户自新增的产品SKU列表(JSON数组)',
+    custom_removal_sku_ids TEXT COMMENT '用户移除的产品SKU列表(JSON数组)',
+    status VARCHAR(32) NOT NULL COMMENT '任务状态: SELECTED|PROCURING|PROCURED|EXECUTING|COMPLETED|CANCELLED',
+    create_time DATETIME NOT NULL COMMENT '创建时间',
+    complete_time DATETIME DEFAULT NULL COMMENT '完成时间',
+    message_reminder_sent TINYINT DEFAULT 0 COMMENT '是否已发送消息提醒',
+    execution_task_id VARCHAR(64) DEFAULT NULL COMMENT '关联的执行任务ID',
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_task_id (task_id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_family_member_id (family_member_id),
+    INDEX idx_status (status),
+    INDEX idx_create_time (create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购任务表(商品组合推荐与采购任务生命周期管理)';
+
+-- 迁移374: 用户内容偏好权重表(user_content_weights)
+CREATE TABLE IF NOT EXISTS user_content_weights (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
     user_id BIGINT NOT NULL COMMENT '用户ID(users.id)',
     family_id BIGINT NOT NULL COMMENT '家庭ID',
@@ -6162,4 +6385,4 @@ CREATE TABLE IF NOT EXISTS repurchase_reminder_record (
     INDEX idx_user_content (user_id, content_type, item_id),
     INDEX idx_family_dimension (family_id, dimension_code),
     UNIQUE KEY uk_user_item (user_id, content_type, item_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户内容偏好权重表';