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

feat(recommend): 画像驱动推荐系统(阶段1-2)

- 迁移231: member_profile_dimension + article/activity_dimension_mapping 三表
- 迁移232: recommendation_logs 追加 recommend_type 字段
- 迁移233: Article/Activity 维度映射一次性迁移脚本
- schema.sql 同步新增三张表 + ALTER TABLE
- Entity: MemberProfileDimension / ArticleDimensionMapping / ActivityDimensionMapping
- Mapper: 三个对应 Mapper 接口
- ProfileDimensionService: 画像同步 + key→dimension_code 关键词映射(body/mind/wisdom/action/wealth)
- ProfileRecommendService: 加权匹配算法(基础得分+需求加成20分)
- RecommendationController: 新增 POST /api/recommend/profile 端点
- AiQuestionnaireService.finish() 末尾接入 syncFromProfile
- RecommendationLog 实体新增 recommendType 字段
iwt 1 месяц назад
Родитель
Сommit
63a5bca6b0

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

@@ -8903,5 +8903,100 @@ private void runMigration100() {
 		} catch (Exception ex) {
 			log.warn("插入admin_menu种子数据失败: {}", ex.getMessage());
 		}
+
+		// 迁移231: 画像驱动推荐 - member_profile_dimension + article/activity_dimension_mapping 表
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS member_profile_dimension (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+				"session_id BIGINT NOT NULL COMMENT '来源会话ID', " +
+				"scene_id BIGINT COMMENT '来源场景ID', " +
+				"dimension_code VARCHAR(50) NOT NULL COMMENT '五维编码: body/mind/wisdom/action/wealth', " +
+				"score INT NOT NULL COMMENT '得分 0-100', " +
+				"description VARCHAR(500) COMMENT '维度描述', " +
+				"evidence JSON COMMENT '证据列表', " +
+				"profile_type VARCHAR(10) NOT NULL DEFAULT 'user' COMMENT 'user/need', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_member_dim (member_id, dimension_code), " +
+				"INDEX idx_member_type (member_id, profile_type), " +
+				"INDEX idx_session (session_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员画像维度得分'"
+			);
+			log.info("已创建member_profile_dimension表");
+		} catch (Exception ex) {
+			log.warn("创建member_profile_dimension表失败: {}", ex.getMessage());
+		}
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS article_dimension_mapping (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"article_id BIGINT NOT NULL, " +
+				"dimension_code VARCHAR(50) NOT NULL, " +
+				"match_score INT DEFAULT 50 COMMENT '关联度 0-100', " +
+				"match_reason VARCHAR(200) COMMENT '关联原因', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_article_dim (article_id, dimension_code), " +
+				"INDEX idx_dim_article (dimension_code, match_score)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章-维度映射'"
+			);
+			log.info("已创建article_dimension_mapping表");
+		} catch (Exception ex) {
+			log.warn("创建article_dimension_mapping表失败: {}", ex.getMessage());
+		}
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS activity_dimension_mapping (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"activity_id BIGINT NOT NULL, " +
+				"dimension_code VARCHAR(50) NOT NULL, " +
+				"match_score INT DEFAULT 50 COMMENT '关联度 0-100', " +
+				"match_reason VARCHAR(200) COMMENT '关联原因', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+				"INDEX idx_activity_dim (activity_id, dimension_code), " +
+				"INDEX idx_dim_activity (dimension_code, match_score)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动-维度映射'"
+			);
+			log.info("已创建activity_dimension_mapping表");
+		} catch (Exception ex) {
+			log.warn("创建activity_dimension_mapping表失败: {}", ex.getMessage());
+		}
+
+		// 迁移232: recommendation_logs 追加 recommend_type 字段
+		ensureColumn("recommendation_logs", "recommend_type", "VARCHAR(20) DEFAULT 'tag' COMMENT '推荐方式: profile/dimension/tag'");
+		log.info("已添加recommendation_logs.recommend_type列");
+
+		// 迁移233: Article/Activity 维度映射一次性迁移(从现有字段导入)
+		try {
+			jdbcTemplate.execute(
+				"INSERT IGNORE INTO article_dimension_mapping (article_id, dimension_code, match_score, match_reason) " +
+				"SELECT id, 'body',  COALESCE(JSON_EXTRACT_CAST(dimension_weights, '$.body'), 50), CONCAT('自动迁移: 文章原始维度配置') " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT_CAST(dimension_weights, '$.body') IS NOT NULL " +
+				"UNION ALL " +
+				"SELECT id, 'mind',  COALESCE(JSON_EXTRACT_CAST(dimension_weights, '$.mind'), 50), '自动迁移' FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT_CAST(dimension_weights, '$.mind') IS NOT NULL " +
+				"UNION ALL " +
+				"SELECT id, 'wisdom', COALESCE(JSON_EXTRACT_CAST(dimension_weights, '$.wisdom'), 50), '自动迁移' FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT_CAST(dimension_weights, '$.wisdom') IS NOT NULL " +
+				"UNION ALL " +
+				"SELECT id, 'action', COALESCE(JSON_EXTRACT_CAST(dimension_weights, '$.action'), 50), '自动迁移' FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT_CAST(dimension_weights, '$.action') IS NOT NULL " +
+				"UNION ALL " +
+				"SELECT id, 'wealth', COALESCE(JSON_EXTRACT_CAST(dimension_weights, '$.wealth'), 50), '自动迁移' FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT_CAST(dimension_weights, '$.wealth') IS NOT NULL"
+			);
+			log.info("已完成article_dimension_mapping一次性迁移");
+		} catch (Exception ex) {
+			log.warn("article维度映射迁移失败(可能MySQL版本不支持JSON_EXTRACT_CAST): {}", ex.getMessage());
+		}
+		try {
+			jdbcTemplate.execute(
+				"INSERT IGNORE INTO activity_dimension_mapping (activity_id, dimension_code, match_score, match_reason) " +
+				"SELECT id, dimension_code, COALESCE(CAST(dimension_weights AS UNSIGNED), 50), '自动迁移' " +
+				"FROM activity WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NOT NULL"
+			);
+			log.info("已完成activity_dimension_mapping一次性迁移");
+		} catch (Exception ex) {
+			log.warn("activity维度映射迁移失败: {}", ex.getMessage());
+		}
 	}
 }

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.RecommendationQuery;
 import com.etotem.cfc.dto.RecommendationResult;
 import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.ProfileRecommendService;
 import com.etotem.cfc.service.RecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,7 +18,9 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 
 @Tag(name = "营养推荐", description = "精准营养推荐搜索")
 @RestController
@@ -32,6 +35,9 @@ public class RecommendationController {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private ProfileRecommendService profileRecommendService;
+
     @Operation(summary = "按营养标签搜索推荐内容")
     @PostMapping("/search")
     public Result<List<RecommendationResult>> search(
@@ -70,4 +76,22 @@ public class RecommendationController {
         recommendationService.markRepurchaseClicked(id);
         return Result.success(null);
     }
+
+    @Operation(summary = "画像驱动推荐")
+    @PostMapping("/profile")
+    public Result<Map<String, Object>> profileRecommend(
+            @RequestBody Map<String, Object> params,
+            HttpServletRequest request) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString())
+                : (Long) request.getAttribute("userId");
+        @SuppressWarnings("unchecked")
+        List<String> types = params.get("types") != null
+                ? (List<String>) params.get("types")
+                : Arrays.asList("product", "article", "activity");
+        int limit = params.get("limit") != null
+                ? Integer.parseInt(params.get("limit").toString())
+                : 10;
+        return Result.success(profileRecommendService.recommend(memberId, types, limit));
+    }
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityDimensionMapping.java

@@ -0,0 +1,29 @@
+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;
+
+@Data
+@TableName("activity_dimension_mapping")
+public class ActivityDimensionMapping implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+
+    private String dimensionCode;
+
+    private Integer matchScore;
+
+    private String matchReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleDimensionMapping.java

@@ -0,0 +1,29 @@
+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;
+
+@Data
+@TableName("article_dimension_mapping")
+public class ArticleDimensionMapping implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long articleId;
+
+    private String dimensionCode;
+
+    private Integer matchScore;
+
+    private String matchReason;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MemberProfileDimension.java

@@ -0,0 +1,37 @@
+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;
+
+@Data
+@TableName("member_profile_dimension")
+public class MemberProfileDimension implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long memberId;
+
+    private Long sessionId;
+
+    private Long sceneId;
+
+    private String dimensionCode;
+
+    private Integer score;
+
+    private String description;
+
+    private String evidence;
+
+    private String profileType;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/RecommendationLog.java

@@ -36,5 +36,8 @@ public class RecommendationLog implements Serializable {
     /** 行为: show/click/consume */
     private String action;
 
+    /** 推荐方式: profile/dimension/tag */
+    private String recommendType;
+
     private Date createdAt;
 }

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

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

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

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

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

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

+ 122 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileDimensionService.java

@@ -0,0 +1,122 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.AiQProfile;
+import com.etotem.cfc.entity.AiQSession;
+import com.etotem.cfc.entity.MemberProfileDimension;
+import com.etotem.cfc.mapper.AiQSessionMapper;
+import com.etotem.cfc.mapper.MemberProfileDimensionMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+import java.util.*;
+
+@Service
+public class ProfileDimensionService extends ServiceImpl<MemberProfileDimensionMapper, MemberProfileDimension> {
+
+    @Resource
+    private AiQSessionMapper aiQSessionMapper;
+
+    private static final List<DimensionRule> DIMENSION_RULES = Arrays.asList(
+            new DimensionRule("body",   new String[]{"肠道","菌群","消化","营养","免疫","睡眠","生长发育","睡眠质量","视力","免疫力","营养均衡","肠胃","运动","exercise","sleep","growth","vision","immunity","nutrition","gut"}),
+            new DimensionRule("mind",   new String[]{"压力","焦虑","情绪","抑郁","自信","神经质","情绪稳定","开放性","尽责性","外向性","宜人性","neurotic","openness","conscientiousness","extraversion","agreeableness","emotion"}),
+            new DimensionRule("wisdom", new String[]{"认知","学习","专注","记忆","思维","逻辑","空间","加工速度","感知","成长型思维","cognitive","focus","memory","logic","spatial","processing","perception","growthMindset"}),
+            new DimensionRule("action", new String[]{"亲子","人际","社交","沟通","关系","师生","夫妻","长辈","social","interpersonal","parentChild","teacherStudent","marital"}),
+            new DimensionRule("wealth", new String[]{"财富","收入","规划","储蓄","消费","积分","任务","社交积分","学习积分","wealth","income","saving","points","education","social"})
+    );
+
+    public void syncFromProfile(AiQProfile profile, AiQSession session) {
+        if (profile == null || session == null) return;
+        Long memberId = session.getMemberId();
+        Long sessionId = session.getId();
+        Long sceneId = session.getSceneId();
+
+        deleteBySession(sessionId);
+
+        List<Map<String, Object>> userDims = parseJsonArray(profile.getUserProfileJson());
+        List<Map<String, Object>> needDims = parseJsonArray(profile.getNeedProfileJson());
+
+        for (Map<String, Object> dim : userDims) {
+            insertDimension(memberId, sessionId, sceneId, dim, "user");
+        }
+        for (Map<String, Object> dim : needDims) {
+            insertDimension(memberId, sessionId, sceneId, dim, "need");
+        }
+    }
+
+    private void insertDimension(Long memberId, Long sessionId, Long sceneId,
+                                  Map<String, Object> dim, String profileType) {
+        String key = (String) dim.get("key");
+        if (key == null || key.isEmpty()) return;
+
+        String dimCode = matchDimension(key);
+        if (dimCode == null) return;
+
+        Integer score = dim.get("score") instanceof Number ? ((Number) dim.get("score")).intValue() : null;
+        if (score == null) score = 50;
+
+        String description = (String) dim.get("description");
+        if (description == null) description = (String) dim.get("key");
+
+        MemberProfileDimension entity = new MemberProfileDimension();
+        entity.setMemberId(memberId);
+        entity.setSessionId(sessionId);
+        entity.setSceneId(sceneId);
+        entity.setDimensionCode(dimCode);
+        entity.setScore(score);
+        entity.setDescription(description);
+        entity.setProfileType(profileType);
+        save(entity);
+    }
+
+    private String matchDimension(String key) {
+        for (DimensionRule rule : DIMENSION_RULES) {
+            for (String keyword : rule.keywords) {
+                if (key.toLowerCase().contains(keyword.toLowerCase())) {
+                    return rule.code;
+                }
+            }
+        }
+        return null;
+    }
+
+    public void deleteBySession(Long sessionId) {
+        remove(new LambdaQueryWrapper<MemberProfileDimension>()
+                .eq(MemberProfileDimension::getSessionId, sessionId));
+    }
+
+    public List<MemberProfileDimension> getProfilesBySession(Long sessionId) {
+        return list(new LambdaQueryWrapper<MemberProfileDimension>()
+                .eq(MemberProfileDimension::getSessionId, sessionId));
+    }
+
+    public Long getLatestSessionId(Long memberId) {
+        AiQSession s = aiQSessionMapper.selectOne(new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getMemberId, memberId)
+                .eq(AiQSession::getStatus, "finished")
+                .orderByDesc(AiQSession::getFinishedAt)
+                .last("LIMIT 1"));
+        return s != null ? s.getId() : null;
+    }
+
+    private List<Map<String, Object>> parseJsonArray(String json) {
+        if (json == null || json.isEmpty()) return Collections.emptyList();
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper()
+                    .readValue(json, List.class);
+        } catch (Exception e) {
+            return Collections.emptyList();
+        }
+    }
+
+    private static class DimensionRule {
+        final String code;
+        final String[] keywords;
+        DimensionRule(String code, String[] keywords) {
+            this.code = code;
+            this.keywords = keywords;
+        }
+    }
+}

+ 220 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileRecommendService.java

@@ -0,0 +1,220 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class ProfileRecommendService {
+
+    @Resource private MemberProfileDimensionMapper profileDimMapper;
+    @Resource private ArticleDimensionMappingMapper articleDimMapper;
+    @Resource private ActivityDimensionMappingMapper activityDimMapper;
+    @Resource private ProductDimensionMappingMapper productDimMapper;
+    @Resource private AiQSessionMapper sessionMapper;
+    @Resource private ArticleMapper articleMapper;
+    @Resource private ActivityMapper activityMapper;
+    @Resource private ProductMapper productMapper;
+    @Resource private RecommendationLogMapper recommendationLogMapper;
+
+    public Map<String, Object> recommend(Long memberId, List<String> types, int limit) {
+        Long sessionId = getLatestFinishedSession(memberId);
+        if (sessionId == null) {
+            return emptyResult(types, limit);
+        }
+
+        List<MemberProfileDimension> userDims = profileDimMapper.selectList(
+                new LambdaQueryWrapper<MemberProfileDimension>()
+                        .eq(MemberProfileDimension::getSessionId, sessionId)
+                        .eq(MemberProfileDimension::getProfileType, "user"));
+        List<MemberProfileDimension> needDims = profileDimMapper.selectList(
+                new LambdaQueryWrapper<MemberProfileDimension>()
+                        .eq(MemberProfileDimension::getSessionId, sessionId)
+                        .eq(MemberProfileDimension::getProfileType, "need"));
+
+        Set<String> needCodes = new HashSet<>();
+        for (MemberProfileDimension d : needDims) needCodes.add(d.getDimensionCode());
+
+        List<Map<String, Object>> results = new ArrayList<>();
+        int totalAcrossTypes = 0;
+
+        for (String type : types) {
+            List<ItemScore> items = recommendByType(memberId, type, userDims, needCodes, limit * 3);
+            totalAcrossTypes += items.size();
+            for (ItemScore item : items) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("id", item.id);
+                m.put("type", type);
+                m.put("title", item.title);
+                m.put("summary", item.summary);
+                m.put("coverImage", item.coverImage);
+                m.put("dimensionCode", item.dimensionCode);
+                m.put("recommendScore", item.score);
+                m.put("recommendReason", item.reason);
+                m.put("badge", item.badge);
+                results.add(m);
+            }
+        }
+
+        results.sort((a, b) -> Double.compare((double) b.get("recommendScore"), (double) a.get("recommendScore")));
+        if (results.size() > limit) results = results.subList(0, limit);
+
+        for (Map<String, Object> r : results) {
+            logShow(memberId, (String) r.get("type"), ((Number) r.get("id")).longValue(),
+                    ((Number) r.get("recommendScore")).doubleValue(), sessionId);
+        }
+
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("recommendations", results);
+        out.put("hasMore", totalAcrossTypes > limit);
+        out.put("profileSessionId", sessionId);
+        return out;
+    }
+
+    private List<ItemScore> recommendByType(Long memberId, String type,
+                                             List<MemberProfileDimension> userDims,
+                                             Set<String> needCodes, int maxItems) {
+        if (userDims.isEmpty()) return Collections.emptyList();
+
+        double totalProfileScore = 0;
+        for (MemberProfileDimension d : userDims) totalProfileScore += d.getScore();
+        if (totalProfileScore == 0) return Collections.emptyList();
+
+        List<ItemScore> items = new ArrayList<>();
+
+        if ("product".equals(type)) {
+            List<ProductDimensionMapping> mappings = productDimMapper.selectList(
+                    new LambdaQueryWrapper<ProductDimensionMapping>()
+                            .eq(ProductDimensionMapping::getEnabled, 1));
+            Map<Long, Double> scores = new HashMap<>();
+            for (ProductDimensionMapping m : mappings) {
+                double dimScore = 0;
+                for (MemberProfileDimension d : userDims) {
+                    if (d.getDimensionCode().equals(m.getDimensionCode())) {
+                        dimScore += d.getScore() * m.getMatchScore();
+                        break;
+                    }
+                }
+                if (dimScore > 0) {
+                    double base = dimScore / totalProfileScore;
+                    boolean isNeed = needCodes.contains(m.getDimensionCode());
+                    double finalScore = base + (isNeed ? 20 : 0);
+                    scores.merge(m.getProductId(), finalScore, Double::sum);
+                }
+            }
+            scores.forEach((pid, score) -> {
+                Product p = productMapper.selectById(pid);
+                if (p != null) items.add(new ItemScore(pid, "product", p.getName(),
+                        p.getDescription(), p.getCoverImage(), score, "高匹配"));
+            });
+        } else if ("article".equals(type)) {
+            List<ArticleDimensionMapping> mappings = articleDimMapper.selectList(
+                    new LambdaQueryWrapper<ArticleDimensionMapping>());
+            Map<Long, Double> scores = new HashMap<>();
+            for (ArticleDimensionMapping m : mappings) {
+                double dimScore = 0;
+                for (MemberProfileDimension d : userDims) {
+                    if (d.getDimensionCode().equals(m.getDimensionCode())) {
+                        dimScore += d.getScore() * m.getMatchScore();
+                        break;
+                    }
+                }
+                if (dimScore > 0) {
+                    double base = dimScore / totalProfileScore;
+                    boolean isNeed = needCodes.contains(m.getDimensionCode());
+                    double finalScore = base + (isNeed ? 20 : 0);
+                    scores.merge(m.getArticleId(), finalScore, Double::sum);
+                }
+            }
+            scores.forEach((aid, score) -> {
+                Article a = articleMapper.selectById(aid);
+                if (a != null) items.add(new ItemScore(aid, "article", a.getTitle(),
+                        a.getSummary(), a.getCoverImage(), score, "高匹配"));
+            });
+        } else if ("activity".equals(type)) {
+            List<ActivityDimensionMapping> mappings = activityDimMapper.selectList(
+                    new LambdaQueryWrapper<ActivityDimensionMapping>());
+            Map<Long, Double> scores = new HashMap<>();
+            for (ActivityDimensionMapping m : mappings) {
+                double dimScore = 0;
+                for (MemberProfileDimension d : userDims) {
+                    if (d.getDimensionCode().equals(m.getDimensionCode())) {
+                        dimScore += d.getScore() * m.getMatchScore();
+                        break;
+                    }
+                }
+                if (dimScore > 0) {
+                    double base = dimScore / totalProfileScore;
+                    boolean isNeed = needCodes.contains(m.getDimensionCode());
+                    double finalScore = base + (isNeed ? 20 : 0);
+                    scores.merge(m.getActivityId(), finalScore, Double::sum);
+                }
+            }
+            scores.forEach((actId, score) -> {
+                Activity act = activityMapper.selectById(actId);
+                if (act != null) items.add(new ItemScore(actId, "activity", act.getTitle(),
+                        act.getDescription(), act.getCoverImage(), score, "高匹配"));
+            });
+        }
+
+        items.sort((a, b) -> Double.compare(b.score, a.score));
+        return items.size() > maxItems ? items.subList(0, maxItems) : items;
+    }
+
+    private Long getLatestFinishedSession(Long memberId) {
+        AiQSession s = sessionMapper.selectOne(new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getMemberId, memberId)
+                .eq(AiQSession::getStatus, "finished")
+                .orderByDesc(AiQSession::getFinishedAt)
+                .last("LIMIT 1"));
+        return s != null ? s.getId() : null;
+    }
+
+    private Map<String, Object> emptyResult(List<String> types, int limit) {
+        Map<String, Object> out = new LinkedHashMap<>();
+        out.put("recommendations", Collections.emptyList());
+        out.put("hasMore", false);
+        out.put("profileSessionId", null);
+        return out;
+    }
+
+    private void logShow(Long memberId, String type, Long contentId, double score, Long sessionId) {
+        try {
+            RecommendationLog log = new RecommendationLog();
+            log.setUserId(memberId);
+            log.setContentType(type);
+            log.setContentId(contentId);
+            log.setRecommendScore((int) score);
+            log.setAction("show");
+            log.setRecommendType("profile");
+            recommendationLogMapper.insert(log);
+        } catch (Exception e) {
+            // log silently
+        }
+    }
+
+    private static class ItemScore {
+        final long id;
+        final String type;
+        final String title;
+        final String summary;
+        final String coverImage;
+        final double score;
+        final String reason;
+        final String badge;
+        final String dimensionCode;
+
+        ItemScore(long id, String type, String title, String summary, String coverImage,
+                  double score, String reason) {
+            this.id = id; this.type = type; this.title = title;
+            this.summary = summary; this.coverImage = coverImage;
+            this.score = score; this.reason = reason;
+            this.badge = score >= 70 ? "优秀匹配" : score >= 40 ? "一般匹配" : "低匹配";
+            this.dimensionCode = null;
+        }
+    }
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AiQuestionnaireServiceImpl.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.mapper.AiQSessionMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.AiQuestionnaireService;
+import com.etotem.cfc.service.ProfileDimensionService;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -40,6 +41,9 @@ public class AiQuestionnaireServiceImpl implements AiQuestionnaireService {
     @Resource
     private AiQProfileMapper aiQProfileMapper;
 
+    @Resource
+    private ProfileDimensionService profileDimensionService;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -240,6 +244,12 @@ public class AiQuestionnaireServiceImpl implements AiQuestionnaireService {
         session.setFinishedAt(new Date());
         session.setCurrentQuestionJson(null);
         aiQSessionMapper.updateById(session);
+
+        // 同步画像维度到 member_profile_dimension
+        try { profileDimensionService.syncFromProfile(profile, session); } catch (Exception e) {
+            log.warn("画像维度同步失败, sessionId={}: {}", session.getId(), e.getMessage());
+        }
+
         return profile;
     }
 

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

@@ -4053,6 +4053,7 @@ CREATE TABLE IF NOT EXISTS recommendation_logs (
     content_id BIGINT NOT NULL COMMENT '内容ID',
     recommend_score INT COMMENT '推荐分值',
     action VARCHAR(20) COMMENT '行为: show/click/consume',
+    recommend_type VARCHAR(20) DEFAULT 'tag' COMMENT '推荐方式: profile/dimension/tag',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_user_content (user_id, content_type, content_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推荐行为记录';
@@ -4771,6 +4772,50 @@ CREATE TABLE IF NOT EXISTS ai_q_profile (
   INDEX idx_member (member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-画像结果';
 
+-- =============================================
+-- 画像驱动推荐
+-- =============================================
+CREATE TABLE IF NOT EXISTS member_profile_dimension (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+  session_id BIGINT NOT NULL COMMENT '来源会话ID',
+  scene_id BIGINT COMMENT '来源场景ID',
+  dimension_code VARCHAR(50) NOT NULL COMMENT '五维编码: body/mind/wisdom/action/wealth',
+  score INT NOT NULL COMMENT '得分 0-100',
+  description VARCHAR(500) COMMENT '维度描述',
+  evidence JSON COMMENT '证据列表',
+  profile_type VARCHAR(10) NOT NULL DEFAULT 'user' COMMENT 'user/need',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_member_dim (member_id, dimension_code),
+  INDEX idx_member_type (member_id, profile_type),
+  INDEX idx_session (session_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成员画像维度得分';
+
+CREATE TABLE IF NOT EXISTS article_dimension_mapping (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  article_id BIGINT NOT NULL,
+  dimension_code VARCHAR(50) NOT NULL,
+  match_score INT DEFAULT 50 COMMENT '关联度 0-100',
+  match_reason VARCHAR(200) COMMENT '关联原因',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_article_dim (article_id, dimension_code),
+  INDEX idx_dim_article (dimension_code, match_score)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章-维度映射';
+
+CREATE TABLE IF NOT EXISTS activity_dimension_mapping (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  activity_id BIGINT NOT NULL,
+  dimension_code VARCHAR(50) NOT NULL,
+  match_score INT DEFAULT 50 COMMENT '关联度 0-100',
+  match_reason VARCHAR(200) COMMENT '关联原因',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_activity_dim (activity_id, dimension_code),
+  INDEX idx_dim_activity (dimension_code, match_score)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动-维度映射';
+
 -- =============================================
 -- 后台菜单管理
 -- =============================================