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

feat: 用户画像数据层+PortraitService(迁移263+实体+接口+Controller)

iwt 3 недель назад
Родитель
Сommit
823a611202

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

@@ -9199,6 +9199,14 @@ private void runMigration100() {
 			// 列/索引已存在,忽略错误
 			// 列/索引已存在,忽略错误
 			log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
 			log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
 		}
 		}
+
+		// 迁移263: users 表添加 portrait_prompt 列(用户画像 prompt 拼装需求)
+		try {
+			jdbcTemplate.execute("ALTER TABLE users ADD COLUMN portrait_prompt TEXT COMMENT '用户自定义画像 prompt' AFTER mascot");
+			log.info("已添加 portrait_prompt 列到 users 表");
+		} catch (Exception e) {
+			log.warn("users.portrait_prompt可能已存在: {}", e.getMessage());
+		}
 	}
 	}
 
 
 	/**
 	/**

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/user/PortraitController.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.controller.user;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.PortraitService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/user/portrait")
+public class PortraitController {
+
+    @Resource
+    private PortraitService portraitService;
+
+    @PostMapping("/get")
+    public Result<Map<String, Object>> getPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long targetUserId = params.get("userId") != null
+                ? Long.valueOf(params.get("userId").toString()) : null;
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : null;
+        return Result.success(portraitService.getPortrait(userId, targetUserId, memberId));
+    }
+
+    @PostMapping("/edit")
+    public Result<String> editPortrait(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Object promptObj = params.get("portraitPrompt");
+        String portraitPrompt = promptObj != null ? promptObj.toString() : null;
+        if (portraitPrompt != null && portraitPrompt.length() > 2000) {
+            return Result.error("画像描述最多 2000 字符");
+        }
+        portraitService.updatePortraitPrompt(userId, portraitPrompt);
+        return Result.success("保存成功");
+    }
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/User.java

@@ -113,6 +113,10 @@ public class User implements Serializable {
     // AI助手形象: xibao(浠宝-女孩)/fubao(福宝-男孩)/null(未选择)
     // AI助手形象: xibao(浠宝-女孩)/fubao(福宝-男孩)/null(未选择)
     private String mascot;
     private String mascot;
 
 
+    // 用户自定义画像 prompt
+    @TableField("portrait_prompt")
+    private String portraitPrompt;
+
     // 家庭管理员标记
     // 家庭管理员标记
     private Boolean isFamilyAdmin;
     private Boolean isFamilyAdmin;
 
 

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PortraitService.java

@@ -0,0 +1,12 @@
+package com.etotem.cfc.service;
+
+import java.util.Map;
+
+public interface PortraitService {
+    /** 获取用户画像文本(自定义 prompt + 快照渲染) */
+    String buildPortraitPrompt(Long userId, Long memberId);
+    /** 更新用户自定义画像 prompt */
+    void updatePortraitPrompt(Long userId, String portraitPrompt);
+    /** 读取用户画像(含渲染快照,用于前端展示) */
+    Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId);
+}

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/PortraitServiceImpl.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.ProfileReadService;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+public class PortraitServiceImpl implements PortraitService {
+
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private ProfileReadService profileReadService;
+
+    @Override
+    public String buildPortraitPrompt(Long userId, Long memberId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) return null;
+        String custom = user.getPortraitPrompt();
+        if (custom == null || custom.trim().isEmpty()) {
+            return renderSnapshot(memberId);
+        }
+        String snapshot = renderSnapshot(memberId);
+        if (snapshot == null || snapshot.trim().isEmpty()) {
+            return custom.trim();
+        }
+        return custom.trim() + "\n\n---参考指标---\n" + snapshot;
+    }
+
+    @Override
+    public void updatePortraitPrompt(Long userId, String portraitPrompt) {
+        User user = new User();
+        user.setId(userId);
+        user.setPortraitPrompt(portraitPrompt);
+        userMapper.updateById(user);
+    }
+
+    @Override
+    public Map<String, Object> getPortrait(Long userId, Long targetUserId, Long memberId) {
+        Long effectiveUserId = (targetUserId != null) ? targetUserId : userId;
+        User user = userMapper.selectById(effectiveUserId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("portraitPrompt", user != null ? user.getPortraitPrompt() : null);
+        if (memberId != null) {
+            Map<String, Object> profile = profileReadService.getProfile(memberId);
+            result.put("renderedSnapshot", renderSnapshotFull(profile));
+        }
+        return result;
+    }
+
+    private String renderSnapshot(Long memberId) {
+        if (memberId == null) return null;
+        Map<String, Object> profile = profileReadService.getProfile(memberId);
+        return renderSnapshotFull(profile);
+    }
+
+    private String renderSnapshotFull(Map<String, Object> profile) {
+        if (profile == null || profile.containsKey("error")) return null;
+        StringBuilder sb = new StringBuilder();
+        Map<String, Object> member = (Map<String, Object>) profile.get("member");
+        if (member != null) {
+            sb.append("## 画像对象:");
+            sb.append(member.getOrDefault("name", "用户"));
+            sb.append("(").append(member.getOrDefault("age", "?")).append("岁,");
+            String gender = String.valueOf(member.getOrDefault("gender", ""));
+            sb.append("男".equals(gender) ? "男" : "女".equals(gender) ? "女" : "未知");
+            sb.append(")\n");
+        }
+        Map<String, Object> dims = (Map<String, Object>) profile.get("dimension_scores");
+        if (dims != null && !dims.isEmpty()) {
+            sb.append(String.format("五维评分:身 %s 智 %s 心 %s 行 %s 富 %s\n",
+                    dims.getOrDefault("body", "?"), dims.getOrDefault("wisdom", "?"),
+                    dims.getOrDefault("mind", "?"), dims.getOrDefault("action", "?"),
+                    dims.getOrDefault("wealth", "?")));
+        }
+        Map<String, Object> body = (Map<String, Object>) profile.get("body_metrics");
+        if (body != null) {
+            if (body.get("sleep_dur_avg") != null)
+                sb.append(String.format("平均睡眠:%s小时/天\n", body.get("sleep_dur_avg")));
+            if (body.get("exercise_count_week") != null)
+                sb.append(String.format("周运动频次:%s次\n", body.get("exercise_count_week")));
+        }
+        Map<String, Object> mind = (Map<String, Object>) profile.get("mind_metrics");
+        if (mind != null && mind.get("stress_avg") != null)
+            sb.append(String.format("平均压力:%s/10\n", mind.get("stress_avg")));
+        @SuppressWarnings("unchecked")
+        java.util.List<String> problems = (java.util.List<String>) profile.get("problem_domains");
+        if (problems != null && !problems.isEmpty())
+            sb.append("关注问题域:").append(String.join(", ", problems.subList(0, Math.min(5, problems.size())))).append("\n");
+        return sb.toString().trim().isEmpty() ? null : sb.toString().trim();
+    }
+}

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

@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
     gender ENUM('male','female'),
     gender ENUM('male','female'),
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',