2026-08-14-profile-recommendation-design.md 14 KB

用户画像驱动推荐系统设计

1. 概述

利用 AI 动态问卷引擎生成的用户画像(维度得分 + 需求),建立结构化的画像存储,并通过多维度加权匹配算法向用户推荐商品、文章和活动。

2. 背景与现状

2.1 已有基础设施

组件 现状 说明
AiQProfile userProfileJson / needProfileJson JSON 存储 画像原始快照,不可查询
ProductDimensionMapping 商品-维度映射表(dimensionCode, matchScore, matchReason) 仅覆盖商品
RecommendationService 基于标签关键词的跨表搜索 无维度权重计算
ContentRecommendService 基于 dimensionCode 筛选推荐 权重单一,无多维度聚合
RecommendationResult 统一推荐结果 DTO 格式可用
RecommendationLog 行为追踪表(show/click/consume) 可用

2.2 核心缺口

  • 画像维度得分未结构化存储,无法被推荐系统查询
  • 推荐引擎只做标签匹配,没有利用维度权重做多维度聚合评分
  • 文章和活动缺少统一的维度映射表

3. 架构

┌──────────────────────────────────────────────────────────────────┐
│ AI 问卷完成(AiQuestionnaireService.finish)                      │
│  ├─ doGenerateProfile() 写 AiQProfile(原始 JSON 快照,不变)     │
│  └─ finish() 末尾调用 profileDimensionService.syncFromProfile()  │
│       → 同步写入 member_profile_dimension(结构化维度得分)        │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ ProfileRecommendService.recommend(memberId, types, limit)        │
│                                                                   │
│  1. 查 member_profile_dimension(取最新 session 的画像)          │
│  2. 对每种 content type:                                         │
│     a. 查对应的 dimension_mapping 表                              │
│     b. 计算加权匹配得分 = Σ(profile_dim × content_dim_match)     │
│     c. 需求加成:匹配 need 的维度 +20 分                          │
│  3. 合并排序 → 取 top N,hasMore = (total > limit)               │
│  4. 记录推荐日志(show)                                          │
└──────────────────────────┬───────────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│ 推荐端点 /api/recommend/profile(在 RecommendationController 中) │
│ 统一返回 RecommendItemVO 列表                                     │
└──────────────────────────────────────────────────────────────────┘

4. 数据模型

4.1 画像维度表(新建)

CREATE TABLE member_profile_dimension (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
    session_id BIGINT NOT NULL COMMENT '来源会话ID(ai_q_session.id,用于取最新)',
    scene_id BIGINT COMMENT '来源场景ID(ai_q_scene.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 '证据列表 ["ev1","ev2"]',
    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='成员画像维度得分';

4.2 内容维度映射表

商品:复用现有 ProductDimensionMapping(dimensionCode, matchScore, matchReason, enabled)。

文章(新建):

CREATE TABLE 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 '关联原因',
    INDEX idx_article_dim (article_id, dimension_code),
    INDEX idx_dim_article (dimension_code, match_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章-维度映射';

活动(新建):

CREATE TABLE 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 '关联原因',
    INDEX idx_activity_dim (activity_id, dimension_code),
    INDEX idx_dim_activity (dimension_code, match_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动-维度映射';

4.3 维度编码映射规则

AI 问卷场景的 dimensions_json 字段(JSON)结构如下:

{
  "user": [
    {"key": "肠道菌群状态", "score": 85, "description": "肠道健康良好"},
    {"key": "压力倾向", "score": 72, "description": "中等压力水平"}
  ],
  "need": [
    {"key": "焦虑倾向", "score": 68},
    {"key": "消化改善", "score": 80}
  ]
}

映射规则(key → dimension_code):

key 关键词包含 dimension_code
肠道/菌群/消化/营养/免疫/睡眠 body
压力/焦虑/情绪/抑郁/自信/神经质 mind
认知/学习/专注/记忆/思维 wisdom
亲子/人际/社交/沟通/关系 action
财富/收入/规划/储蓄/消费 wealth

映射优先级:精确匹配 > 关键词包含匹配 > 不映射(跳过该维度)。

4.4 数据迁移

历史画像:现有 AiQProfile 数据不入 member_profile_dimension(历史数据无 key→dimension_code 映射逻辑)。

新问卷:每次问卷完成时自动同步。

Article.dimensionIds 迁移(一次性脚本):

  • 现有字段:Article.dimensionIds(VARCHAR,逗号分隔,如 "body,mind")、dimensionWeights(VARCHAR,逗号分隔,如 "0.8,0.6"
  • 迁移逻辑:按逗号拆分 → 配对 → 逐行 INSERT IGNORE INTO article_dimension_mapping(match_score = weight × 100 取整)

Activity.dimensionCode 迁移(一次性脚本):

  • 现有字段:Activity.dimensionCode(VARCHAR,单个值如 "mind")、dimensionWeights(VARCHAR,如 "0.7"
  • 迁移逻辑:直接映射为一条记录,match_score = weight × 100

幂等性:所有迁移使用 INSERT IGNORE,可重复执行。

5. 推荐算法

5.1 核心公式

对每个内容项 c(类型为 t ∈ {product, article, activity}):

  基础得分 = Σ( profile_dim_score_i × mapping_match_score_i )
             ─────────────────────────────────────────────
                     Σ( profile_dim_score_i )

  需求加成 = 20 × (该内容匹配的 need_profile 维度数)

  最终得分 = 基础得分 + 需求加成

5.2 步骤

  1. 根据 memberId 查询最新的 ai_q_session(按 finished_at 降序取 1 条)
  2. 根据 session_id 查询 member_profile_dimensionprofile_type='user' 的所有维度得分
  3. 查询 profile_type='need' 的维度编码集合
  4. 对每种请求的 content type,查询对应的 dimension_mapping 表,JOIN 步骤 2 的维度得分
  5. 按公式计算每项内容的最终得分
  6. 按最终得分降序排列,取 top limit
  7. 去重(同一内容可能映射多个维度,只保留最高分条目)
  8. 计算 hasMore:SELECT COUNT(*) WHERE dimension_code IN (...),若 total > limit 则 hasMore=true

多次画像处理:只取最新一次问卷完成后的画像(session_id 最大),避免历史低质量画像干扰推荐。

5.3 无画像时的降级

  • member_profile_dimension 记录(无有效 session)→ 按 ContentRecommendService 现有逻辑按 dimensionCode 筛选
  • 无维度映射的内容 → 不参与画像推荐(可走 RecommendationService 标签搜索兜底)

6. 服务与接口

6.1 ProfileRecommendService(新建)

@Service
public class ProfileRecommendService {
    public List<RecommendItemVO> recommend(Long memberId, List<String> types, int limit);
    // 内部方法
    private Long getLatestSessionId(Long memberId);
    private List<MemberProfileDimension> loadUserProfiles(Long sessionId);
    private List<MemberProfileDimension> loadNeedProfiles(Long sessionId);
    private List<RecommendItemVO> recommendByDimension(
        Long memberId, String type, List<MemberProfileDimension> profiles, int limit);
    private double calcScore(List<DimensionScore> dims, int matchScore, boolean isNeedMatch);
}

6.2 AiQuestionnaireService 改造

finish() 方法末尾(非 doGenerateProfile() 内部)追加同步调用:

// AiQuestionnaireService.finish() 末尾
public AiQProfile finish(Long userId, Long sessionId) {
    AiQProfile profile = doGenerateProfile(session, scene, history);
    // ... 原有保存逻辑 ...

    // 新增:同步画像维度到 member_profile_dimension
    profileDimensionService.syncFromProfile(profile, session);

    return profile;
}

syncFromProfile 逻辑

  1. 解析 profile.getUserProfileJson()profile.getNeedProfileJson()(JSON 字符串)
  2. 按 §4.3 映射规则将 key → dimension_code
  3. DELETE 该 session_id 对应的旧记录(幂等)
  4. INSERT 新记录

6.3 推荐端点(新增)

位置RecommendationController(或新建 ProfileRecommendController),路径 /api/recommend/profile

@PostMapping("/profile")
public Result<Map<String, Object>> profileRecommend(
    @RequestBody Map<String, Object> params,
    @RequestAttribute("userId") Long userId) {
    // 参数: memberId(可选,默认userId), types(可选,默认["product","article","activity"]), limit(默认10)
    // 返回: {
    //   recommendations: [{id, title, summary, coverImage, dimensionCode, recommendScore, recommendReason, badge}],
    //   hasMore: bool,
    //   profileSessionId: Long  // 本次推荐使用的画像会话ID
    // }
}

6.4 管理端维度映射配置(新增)

Web 管理端增加文章/活动的维度映射编辑:

  • 文章编辑页(ArticleEdit.vue)增加维度选择器(多选 + 匹配度滑块 + 原因输入)
  • 活动编辑页(ActivityEdit.vue)同理
  • 复用 ProductDimensionMapping 的 UI 模式(已有 ProductEdit.vue 中的实现参考)
  • 后端新增 /api/admin/article/{id}/dimensions/api/admin/activity/{id}/dimensions 接口

7. 推荐日志

复用现有 RecommendationLog 表,追加 recommend_type 字段:

字段
contentType article / product / activity
action show / click / consume
recommendScore 算法计算的最终得分
recommendType(新增) profile(画像驱动)/ dimension(维度筛选)/ tag(标签搜索)

迁移:ALTER TABLE recommendation_logs ADD COLUMN recommend_type VARCHAR(20) DEFAULT 'tag' COMMENT '推荐方式'

8. 实施步骤

阶段 1:数据层

  1. 建表迁移:member_profile_dimension + article_dimension_mapping + activity_dimension_mapping
  2. RecommendationLog 追加 recommend_type 字段
  3. 现有数据迁移脚本(Article/Activity 维度映射,含 key→dimension_code 解析)

阶段 2:后端服务

  1. 创建 MemberProfileDimension Entity + Mapper
  2. 创建 ArticleDimensionMapping / ActivityDimensionMapping Entity + Mapper
  3. 创建 ProfileDimensionService(画像维度同步 + key→dimension_code 映射)
  4. 创建 ProfileRecommendService(推荐引擎核心)
  5. 改造 AiQuestionnaireService.finish() 接入同步

阶段 3:控制器与前端

  1. 新增 /api/recommend/profile 端点(ProfileRecommendController)
  2. 管理端文章/活动维度映射编辑页(ArticleEdit.vue / ActivityEdit.vue 扩展)
  3. 小程序画像结果页接入推荐(pages/mind-detail/emotion-report.vue 或独立推荐卡片)

9. 约束与边界

  • 维度编码:五维体系为 body/mind/wisdom/action/wealth(action = 行维度,非 social)
  • 维度映射:AI 问卷 dimensions_json 中的 key 通过关键词匹配规则映射到五维编码(见 §4.3)
  • 并发:画像同步在 finish() 末尾串行执行,不额外加锁(画像只写一次)
  • 降级:画像维度为空时走原始推荐逻辑
  • 不涉及:实时画像更新、协同过滤、向量检索

10. 测试策略

  • 单元测试:ProfileRecommendService 的加权得分计算逻辑(含需求加成)
  • 单元测试:ProfileDimensionService.syncFromProfile() 的 key→dimension_code 映射正确性
  • 集成测试:画像同步 → 推荐查询 → 结果排序 → 日志记录
  • 迁移测试:mvn clean compile + 迁移幂等性验证(INSERT IGNORE 可重复)