Ver código fonte

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

iwt 3 semanas atrás
pai
commit
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());
 		}
+
+		// 迁移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(未选择)
     private String mascot;
 
+    // 用户自定义画像 prompt
+    @TableField("portrait_prompt")
+    private String portraitPrompt;
+
     // 家庭管理员标记
     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'),
     birth_hour VARCHAR(10) COMMENT '出生时辰: 子丑寅卯辰巳午未申酉戌亥',
     mascot VARCHAR(20) DEFAULT NULL COMMENT 'AI助手形象: xibao(浠宝)/fubao(福宝)',
+    portrait_prompt TEXT COMMENT '用户自定义画像 prompt(为空时自动从 profile_snapshot 渲染)',
     province VARCHAR(50) DEFAULT NULL COMMENT '省份',
     city VARCHAR(50) DEFAULT NULL COMMENT '城市',
     district VARCHAR(50) DEFAULT NULL COMMENT '区县',