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

feat(recommend): 画像驱动推荐系统(补全)

- 简化:复用现有 dimension_weights 表(Article/Activity 维度映射已存在),
  移除冗余的 article_dimension_mapping/activity_dimension_mapping 表
- 迁移231: member_profile_dimension 表
- 迁移232: recommendation_logs 追加 recommend_type 字段
- 迁移233: 历史 Article/Activity dimension_weights JSON → dimension_weights 表
- ProfileDimensionService: 画像同步 + key→dimension_code 关键词映射
- ProfileRecommendService: 加权匹配算法(基础得分+需求加成)
- RecommendationController: POST /api/recommend/profile 端点
- AiQuestionnaireService.finish() 接入 syncFromProfile
- RecommendationLog 实体新增 recommendType 字段
iwt 1 месяц назад
Родитель
Сommit
007ef736c1

+ 19 - 51
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8904,7 +8904,7 @@ private void runMigration100() {
 			log.warn("插入admin_menu种子数据失败: {}", ex.getMessage());
 		}
 
-		// 迁移231: 画像驱动推荐 - member_profile_dimension + article/activity_dimension_mapping 
+		// 迁移231: 画像驱动推荐 - member_profile_dimension 表
 		try {
 			jdbcTemplate.execute(
 				"CREATE TABLE IF NOT EXISTS member_profile_dimension (" +
@@ -8928,75 +8928,43 @@ private void runMigration100() {
 		} 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 维度映射一次性迁移(从现有字段导入)
+		// 迁移233: 将 Article/Activity 现有 dimension_weights JSON 同步到 dimension_weights 表
 		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 " +
+				"INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " +
+				"SELECT 'article', id, 'body', COALESCE(CAST(JSON_EXTRACT(dimension_weights, '$.body') AS SIGNED), 0), 1 " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT(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 " +
+				"SELECT 'article', id, 'mind', COALESCE(CAST(JSON_EXTRACT(dimension_weights, '$.mind') AS SIGNED), 0), 1 " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT(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 " +
+				"SELECT 'article', id, 'wisdom', COALESCE(CAST(JSON_EXTRACT(dimension_weights, '$.wisdom') AS SIGNED), 0), 1 " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT(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 " +
+				"SELECT 'article', id, 'action', COALESCE(CAST(JSON_EXTRACT(dimension_weights, '$.action') AS SIGNED), 0), 1 " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT(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"
+				"SELECT 'article', id, 'wealth', COALESCE(CAST(JSON_EXTRACT(dimension_weights, '$.wealth') AS SIGNED), 0), 1 " +
+				"FROM article WHERE dimension_weights IS NOT NULL AND dimension_weights != '' AND JSON_EXTRACT(dimension_weights, '$.wealth') IS NOT NULL"
 			);
-			log.info("已完成article_dimension_mapping一次性迁移");
+			log.info("已完成article维度权重到dimension_weights表的一次性迁移");
 		} catch (Exception ex) {
-			log.warn("article维度映射迁移失败(可能MySQL版本不支持JSON_EXTRACT_CAST): {}", ex.getMessage());
+			log.warn("article维度迁移失败: {}", 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), '自动迁移' " +
+				"INSERT IGNORE INTO dimension_weights (target_type, target_id, dimension, weight, enabled) " +
+				"SELECT 'activity', id, dimension_code, COALESCE(CAST(dimension_weights AS SIGNED), 50), 1 " +
 				"FROM activity WHERE dimension_code IS NOT NULL AND dimension_code != '' AND dimension_weights IS NOT NULL"
 			);
-			log.info("已完成activity_dimension_mapping一次性迁移");
+			log.info("已完成activity维度权重到dimension_weights表的一次性迁移");
 		} catch (Exception ex) {
-			log.warn("activity维度映射迁移失败: {}", ex.getMessage());
+			log.warn("activity维度迁移失败: {}", ex.getMessage());
 		}
 	}
 }

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

@@ -1,29 +0,0 @@
-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;
-}

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

@@ -1,29 +0,0 @@
-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;
-}

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

@@ -1,9 +0,0 @@
-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> {
-}

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

@@ -1,9 +0,0 @@
-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> {
-}

+ 41 - 34
cfc-backend/src/main/java/com/etotem/cfc/service/ProfileRecommendService.java

@@ -12,14 +12,13 @@ import java.util.*;
 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 DimensionWeightService dimensionWeightService;
     @Resource private ArticleMapper articleMapper;
     @Resource private ActivityMapper activityMapper;
     @Resource private ProductMapper productMapper;
     @Resource private RecommendationLogMapper recommendationLogMapper;
+    @Resource private ProductDimensionMappingMapper productDimMapper;
 
     public Map<String, Object> recommend(Long memberId, List<String> types, int limit) {
         Long sessionId = getLatestFinishedSession(memberId);
@@ -48,7 +47,7 @@ public class ProfileRecommendService {
             for (ItemScore item : items) {
                 Map<String, Object> m = new LinkedHashMap<>();
                 m.put("id", item.id);
-                m.put("type", type);
+                m.put("type", item.type);
                 m.put("title", item.title);
                 m.put("summary", item.summary);
                 m.put("coverImage", item.coverImage);
@@ -88,46 +87,47 @@ public class ProfileRecommendService {
 
         if ("product".equals(type)) {
             List<ProductDimensionMapping> mappings = productDimMapper.selectList(
-                    new LambdaQueryWrapper<ProductDimensionMapping>()
-                            .eq(ProductDimensionMapping::getEnabled, 1));
+                    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();
+                        double base = (d.getScore() * m.getMatchScore()) / totalProfileScore;
+                        double finalScore = base + (needCodes.contains(m.getDimensionCode()) ? 20 : 0);
+                        scores.merge(m.getProductId(), finalScore, Double::sum);
                         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) {
+            List<Article> articles = articleMapper.selectList(null);
+            for (Article a : articles) {
+                List<DimensionWeight> dw = dimensionWeightService.getByTarget("article", a.getId());
                 double dimScore = 0;
-                for (MemberProfileDimension d : userDims) {
-                    if (d.getDimensionCode().equals(m.getDimensionCode())) {
-                        dimScore += d.getScore() * m.getMatchScore();
-                        break;
+                for (DimensionWeight w : dw) {
+                    if (!Boolean.TRUE.equals(w.getEnabled())) continue;
+                    for (MemberProfileDimension d : userDims) {
+                        if (d.getDimensionCode().equals(w.getDimension())) {
+                            dimScore += d.getScore() * w.getWeight();
+                            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);
+                    long matchedNeed = 0;
+                    for (DimensionWeight w : dw) {
+                        if (Boolean.TRUE.equals(w.getEnabled()) && needCodes.contains(w.getDimension())) matchedNeed++;
+                    }
+                    double finalScore = base + (20.0 * matchedNeed / Math.max(dw.size(), 1));
+                    scores.merge(a.getId(), finalScore, Double::sum);
                 }
             }
             scores.forEach((aid, score) -> {
@@ -135,23 +135,30 @@ public class ProfileRecommendService {
                 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) {
+            List<Activity> activities = activityMapper.selectList(null);
+            for (Activity act : activities) {
+                List<DimensionWeight> dw = dimensionWeightService.getByTarget("activity", act.getId());
                 double dimScore = 0;
-                for (MemberProfileDimension d : userDims) {
-                    if (d.getDimensionCode().equals(m.getDimensionCode())) {
-                        dimScore += d.getScore() * m.getMatchScore();
-                        break;
+                for (DimensionWeight w : dw) {
+                    if (!Boolean.TRUE.equals(w.getEnabled())) continue;
+                    for (MemberProfileDimension d : userDims) {
+                        if (d.getDimensionCode().equals(w.getDimension())) {
+                            dimScore += d.getScore() * w.getWeight();
+                            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);
+                    long matchedNeed = 0;
+                    for (DimensionWeight w : dw) {
+                        if (Boolean.TRUE.equals(w.getEnabled()) && needCodes.contains(w.getDimension())) matchedNeed++;
+                    }
+                    double finalScore = base + (20.0 * matchedNeed / Math.max(dw.size(), 1));
+                    scores.merge(act.getId(), finalScore, Double::sum);
                 }
             }
             scores.forEach((actId, score) -> {

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

@@ -4792,29 +4792,7 @@ CREATE TABLE IF NOT EXISTS member_profile_dimension (
   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='活动-维度映射';
 
 -- =============================================
 -- 后台菜单管理

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-63a5bca6b077b3a7d0696289e63cc3b51e55d5aa
+e6589e829b1e261cbf1d13688bd5fee662c414cd

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1037",
+  "version": "1.0.1038",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1037",
+      "version": "1.0.1038",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",