|
|
@@ -0,0 +1,2316 @@
|
|
|
+# 先天画像体系实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 将星座/八字/数字能量统一为"心维度先天画像"体系——聚合先天画像 + AI 解读 + 数字能量补强 + 先天后天打通 + 管理端可配置化。
|
|
|
+
|
|
|
+**架构:** 新增 `InnatePortraitService` 聚合层,复用现有 `FamilyMemberAttributeService.calcInnateScore()` 计算 mind_base_score(管理端可配置权重替换硬编码),通过新增 LangGraph `innate_portrait_graph` 生成 AI 解读,`AiGateway` 调用并模板降级。小程序新增先天画像报告页 + 成长轨迹页,管理端新增 2 个配置页。
|
|
|
+
|
|
|
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / Python 3.11 + LangGraph + FastAPI / uni-app Vue 2 / Vue 2 + Element UI
|
|
|
+
|
|
|
+**规格:** [2026-08-28-innate-portrait-mind-dimension-design.md](../specs/2026-08-28-innate-portrait-mind-dimension-design.md)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+### 后端(cfc-backend)
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `src/main/java/com/etotem/cfc/entity/InnatePortraitConfig.java` | 来源权重配置实体 |
|
|
|
+| `src/main/java/com/etotem/cfc/entity/InnatePortraitReport.java` | AI 报告缓存实体 |
|
|
|
+| `src/main/java/com/etotem/cfc/entity/NumSoulDetailConfig.java` | 数字能量详细配置实体 |
|
|
|
+| `src/main/java/com/etotem/cfc/mapper/InnatePortraitConfigMapper.java` | 配置 Mapper |
|
|
|
+| `src/main/java/com/etotem/cfc/mapper/InnatePortraitReportMapper.java` | 报告 Mapper |
|
|
|
+| `src/main/java/com/etotem/cfc/mapper/NumSoulDetailConfigMapper.java` | 数字配置 Mapper |
|
|
|
+| `src/main/java/com/etotem/cfc/service/NumSoulCalculator.java` | 数字能量计算(纯逻辑,可单测) |
|
|
|
+| `src/main/java/com/etotem/cfc/service/InnatePortraitService.java` | 先天画像聚合服务 |
|
|
|
+| `src/main/java/com/etotem/cfc/dto/InnatePortraitVO.java` | 先天画像 VO |
|
|
|
+| `src/main/java/com/etotem/cfc/dto/NumSoulDetailVO.java` | 数字能量 VO |
|
|
|
+| `src/main/java/com/etotem/cfc/dto/InnatePortraitReportVO.java` | AI 报告 VO |
|
|
|
+| `src/main/java/com/etotem/cfc/dto/InnateTrajectoryVO.java` | 成长轨迹 VO |
|
|
|
+| `src/main/java/com/etotem/cfc/controller/mind/InnatePortraitController.java` | 先天画像接口 |
|
|
|
+| `src/main/java/com/etotem/cfc/controller/admin/InnatePortraitConfigController.java` | 管理端配置接口 |
|
|
|
+| `src/main/java/com/etotem/cfc/controller/admin/NumSoulDetailConfigController.java` | 管理端数字配置接口 |
|
|
|
+| `src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 迁移 267-269 |
|
|
|
+| `src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java` | calcInnateScore 权重替换改造 |
|
|
|
+| `src/main/java/com/etotem/cfc/service/AiGateway.java` | 新增 generateInnateReading |
|
|
|
+
|
|
|
+### AI(cfc-langgraph)
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `app/graphs/innate_portrait_graph.py` | 先天画像 AI 解读 graph |
|
|
|
+| `app/api/innate.py` | FastAPI 端点 `/api/v1/innate/reading` |
|
|
|
+| `app/main.py` | 注册 innate 路由 |
|
|
|
+
|
|
|
+### 管理端(cfc-web)
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `src/views/admin/InnatePortraitConfig.vue` | 来源权重配置页 |
|
|
|
+| `src/views/admin/NumSoulDetailConfig.vue` | 数字能量配置页 |
|
|
|
+
|
|
|
+### 小程序(cfc-frontend)
|
|
|
+
|
|
|
+| 文件 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `pages/mind-detail/innate-portrait.vue` | 先天画像报告页 |
|
|
|
+| `pages/mind-detail/innate-trajectory.vue` | 成长轨迹页 |
|
|
|
+| `pages/mind-detail/index.vue` | 心维度首页增强 |
|
|
|
+| `pages.json` | 注册新页面 |
|
|
|
+| `utils/api.js` | 新增接口 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 1:数据库迁移 + 后端数据层
|
|
|
+
|
|
|
+### 任务 1:创建 3 张新表(迁移 267-269)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`
|
|
|
+
|
|
|
+- [ ] **步骤 1:在 DatabaseInitializer 新增迁移 267(innate_portrait_config)**
|
|
|
+
|
|
|
+在 `runMigrations()` 方法末尾(最新迁移 266 之后)添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// ==================== 迁移267: 先天画像来源权重配置表 ====================
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_config (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "source_type VARCHAR(20) NOT NULL," +
|
|
|
+ "source_name VARCHAR(50) NOT NULL," +
|
|
|
+ "weight INT DEFAULT 25," +
|
|
|
+ "enabled TINYINT(1) DEFAULT 1," +
|
|
|
+ "ai_enabled TINYINT(1) DEFAULT 1," +
|
|
|
+ "sort_order INT DEFAULT 0," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
|
|
|
+ "UNIQUE KEY uk_source (source_type)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像来源权重配置'");
|
|
|
+ // 种子数据(4来源默认权重25%)
|
|
|
+ Integer cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM innate_portrait_config", Integer.class);
|
|
|
+ if (cnt == null || cnt == 0) {
|
|
|
+ jdbcTemplate.batchUpdate(
|
|
|
+ "INSERT INTO innate_portrait_config (source_type, source_name, weight, sort_order) VALUES (?, ?, ?, ?)",
|
|
|
+ java.util.Arrays.asList(new Object[][]{
|
|
|
+ {"zodiac", "星座", 2500, 1},
|
|
|
+ {"bazi", "八字", 2500, 2},
|
|
|
+ {"blood", "血型", 2500, 3},
|
|
|
+ {"numsoul", "数字能量", 2500, 4},
|
|
|
+ })
|
|
|
+ );
|
|
|
+ log.info("迁移267: innate_portrait_config 种子数据已加载 (4条)");
|
|
|
+ }
|
|
|
+ log.info("迁移267: innate_portrait_config 表创建完成");
|
|
|
+} catch (Exception e) {
|
|
|
+ log.warn("迁移267失败: {}", e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:新增迁移 268(innate_portrait_report)**
|
|
|
+
|
|
|
+在迁移 267 后紧接添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// ==================== 迁移268: 先天画像报告缓存表 ====================
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS innate_portrait_report (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "member_id BIGINT NOT NULL," +
|
|
|
+ "member_type VARCHAR(10) NOT NULL," +
|
|
|
+ "family_id BIGINT NOT NULL," +
|
|
|
+ "portrait_json TEXT," +
|
|
|
+ "ai_reading TEXT," +
|
|
|
+ "generated_at DATETIME," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "UNIQUE KEY uk_member (member_id, member_type)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像报告缓存'");
|
|
|
+ log.info("迁移268: innate_portrait_report 表创建完成");
|
|
|
+} catch (Exception e) {
|
|
|
+ log.warn("迁移268失败: {}", e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:新增迁移 269(numsoul_detail_config)**
|
|
|
+
|
|
|
+在迁移 268 后紧接添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// ==================== 迁移269: 数字能量详细配置表 ====================
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS numsoul_detail_config (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "number_type VARCHAR(20) NOT NULL," +
|
|
|
+ "number_value INT NOT NULL," +
|
|
|
+ "title VARCHAR(50)," +
|
|
|
+ "keywords VARCHAR(200)," +
|
|
|
+ "mind_base INT DEFAULT 0," +
|
|
|
+ "mind_advice TEXT," +
|
|
|
+ "color_hex VARCHAR(10)," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "UNIQUE KEY uk_type_num (number_type, number_value)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字能量详细配置'");
|
|
|
+ Integer cnt2 = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM numsoul_detail_config", Integer.class);
|
|
|
+ if (cnt2 == null || cnt2 == 0) {
|
|
|
+ // 生命灵数种子(1-9 + 主数11/22/33)
|
|
|
+ jdbcTemplate.batchUpdate(
|
|
|
+ "INSERT INTO numsoul_detail_config (number_type, number_value, title, keywords, mind_base, mind_advice, color_hex) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
+ java.util.Arrays.asList(new Object[][]{
|
|
|
+ {"life_path", 1, "开创者", "独立、领导、创新", 68, "培养团队协作意识,避免独断专行", "#FF4444"},
|
|
|
+ {"life_path", 2, "和平者", "温和、合作、敏感", 72, "增强自我主张,避免过度迎合", "#FF8C00"},
|
|
|
+ {"life_path", 3, "表达者", "创意、社交、乐观", 78, "保持专注,避免浅尝辄止", "#FFD700"},
|
|
|
+ {"life_path", 4, "建设者", "务实、稳定、有序", 70, "学会变通,避免过于固执", "#4CAF50"},
|
|
|
+ {"life_path", 5, "自由者", "冒险、变化、好奇", 75, "培养持久力,避免缺乏恒心", "#2196F3"},
|
|
|
+ {"life_path", 6, "关怀者", "责任、关爱、奉献", 80, "学会自我关爱,避免过度付出", "#9C27B0"},
|
|
|
+ {"life_path", 7, "探索者", "智慧、分析、内省", 76, "与人分享见解,避免过度封闭", "#673AB7"},
|
|
|
+ {"life_path", 8, "成就者", "雄心、管理、决策", 72, "关注内在成长,避免过度追求外在", "#3F51B5"},
|
|
|
+ {"life_path", 9, "博爱者", "包容、智慧、善良", 82, "设定个人边界,避免过度牺牲", "#E91E63"},
|
|
|
+ {"life_path", 11, "启明者", "直觉、灵感、理想", 85, "落地执行,避免空想", "#7C4DFF"},
|
|
|
+ {"life_path", 22, "建造师", "远见、实践、宏图", 88, "分步落地大目标", "#536DFE"},
|
|
|
+ {"life_path", 33, "引路人", "大爱、奉献、疗愈", 90, "先照顾好自己再照亮他人", "#FF4081"},
|
|
|
+ })
|
|
|
+ );
|
|
|
+ log.info("迁移269: numsoul_detail_config 生命灵数种子已加载");
|
|
|
+ }
|
|
|
+ log.info("迁移269: numsoul_detail_config 表创建完成");
|
|
|
+} catch (Exception e) {
|
|
|
+ log.warn("迁移269失败: {}", e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:同步 schema.sql**
|
|
|
+
|
|
|
+在 `cfc-backend/src/main/resources/schema.sql` 末尾追加 3 张表的完整 CREATE TABLE 定义(与迁移 267-269 的 DDL 完全一致,含 COMMENT)。
|
|
|
+
|
|
|
+- [ ] **步骤 5:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
+git commit -m "feat: 迁移267-269 创建先天画像三张新表"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 2:后端实体类
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitConfig.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitReport.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulDetailConfig.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 InnatePortraitConfig.java**
|
|
|
+
|
|
|
+```java
|
|
|
+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("innate_portrait_config")
|
|
|
+public class InnatePortraitConfig implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /** 来源: zodiac/bazi/blood/numsoul */
|
|
|
+ private String sourceType;
|
|
|
+
|
|
|
+ /** 来源名称 */
|
|
|
+ private String sourceName;
|
|
|
+
|
|
|
+ /** 权重(基点,2500=25%) */
|
|
|
+ private Integer weight;
|
|
|
+
|
|
|
+ /** 是否启用 0/1 */
|
|
|
+ private Integer enabled;
|
|
|
+
|
|
|
+ /** 是否启用AI解读 0/1 */
|
|
|
+ private Integer aiEnabled;
|
|
|
+
|
|
|
+ /** 排序 */
|
|
|
+ private Integer sortOrder;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 InnatePortraitReport.java**
|
|
|
+
|
|
|
+```java
|
|
|
+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("innate_portrait_report")
|
|
|
+public class InnatePortraitReport implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long memberId;
|
|
|
+
|
|
|
+ /** 成员类型: child/parent */
|
|
|
+ private String memberType;
|
|
|
+
|
|
|
+ private Long familyId;
|
|
|
+
|
|
|
+ /** 先天画像数据JSON */
|
|
|
+ private String portraitJson;
|
|
|
+
|
|
|
+ /** AI解读文案 */
|
|
|
+ private String aiReading;
|
|
|
+
|
|
|
+ private Date generatedAt;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:创建 NumSoulDetailConfig.java**
|
|
|
+
|
|
|
+```java
|
|
|
+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("numsoul_detail_config")
|
|
|
+public class NumSoulDetailConfig implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /** 类型: life_path/talent/birthday/destiny */
|
|
|
+ private String numberType;
|
|
|
+
|
|
|
+ /** 数字值 1-9 或主数 11/22/33 */
|
|
|
+ private Integer numberValue;
|
|
|
+
|
|
|
+ private String title;
|
|
|
+
|
|
|
+ private String keywords;
|
|
|
+
|
|
|
+ /** 心先天基础分(百分位) */
|
|
|
+ private Integer mindBase;
|
|
|
+
|
|
|
+ private String mindAdvice;
|
|
|
+
|
|
|
+ private String colorHex;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitConfig.java cfc-backend/src/main/java/com/etotem/cfc/entity/InnatePortraitReport.java cfc-backend/src/main/java/com/etotem/cfc/entity/NumSoulDetailConfig.java
|
|
|
+git commit -m "feat: 先天画像3个实体类"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 3:Mapper 接口 + DTO
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/InnatePortraitConfigMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/InnatePortraitReportMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/NumSoulDetailConfigMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitVO.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/NumSoulDetailVO.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/InnatePortraitReportVO.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/dto/InnateTrajectoryVO.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 3 个 Mapper**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.InnatePortraitConfig;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface InnatePortraitConfigMapper extends BaseMapper<InnatePortraitConfig> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.InnatePortraitReport;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface InnatePortraitReportMapper extends BaseMapper<InnatePortraitReport> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.NumSoulDetailConfig;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface NumSoulDetailConfigMapper extends BaseMapper<NumSoulDetailConfig> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 InnatePortraitVO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class InnatePortraitVO {
|
|
|
+
|
|
|
+ /** 成员ID */
|
|
|
+ private Long memberId;
|
|
|
+
|
|
|
+ private String memberType;
|
|
|
+
|
|
|
+ /** 八字四柱 {"year":"甲子","month":"丙寅","day":"戊辰","hour":"壬申"} */
|
|
|
+ private Map<String, String> eightCharacters;
|
|
|
+
|
|
|
+ /** 五行 {"wood":30,"fire":45,"earth":60,"metal":25,"water":40} */
|
|
|
+ private Map<String, Integer> wuxingElements;
|
|
|
+
|
|
|
+ /** 生肖: rat/ox/... */
|
|
|
+ private String zodiac;
|
|
|
+
|
|
|
+ /** 血型: A/B/AB/O */
|
|
|
+ private String bloodType;
|
|
|
+
|
|
|
+ /** 心先天基础分 */
|
|
|
+ private Integer mindBaseScore;
|
|
|
+
|
|
|
+ /** 智先天基础分 */
|
|
|
+ private Integer wisdomBaseScore;
|
|
|
+
|
|
|
+ /** 数字能量详情 */
|
|
|
+ private NumSoulDetailVO numSoul;
|
|
|
+
|
|
|
+ /** 各来源贡献明细 */
|
|
|
+ private List<Map<String, Object>> sourceContributions;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:创建 NumSoulDetailVO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class NumSoulDetailVO {
|
|
|
+
|
|
|
+ /** 生命灵数 */
|
|
|
+ private Integer lifePath;
|
|
|
+
|
|
|
+ private String lifePathTitle;
|
|
|
+
|
|
|
+ private String lifePathKeywords;
|
|
|
+
|
|
|
+ private String lifePathAdvice;
|
|
|
+
|
|
|
+ /** 天赋数(可能有2个) */
|
|
|
+ private Integer talent1;
|
|
|
+
|
|
|
+ private Integer talent2;
|
|
|
+
|
|
|
+ private String talentTitle;
|
|
|
+
|
|
|
+ /** 生日数 */
|
|
|
+ private Integer birthday;
|
|
|
+
|
|
|
+ private String birthdayTitle;
|
|
|
+
|
|
|
+ /** 命运数 */
|
|
|
+ private Integer destiny;
|
|
|
+
|
|
|
+ private String destinyTitle;
|
|
|
+
|
|
|
+ /** 生命灵数代表色 */
|
|
|
+ private String colorHex;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:创建 InnatePortraitReportVO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class InnatePortraitReportVO {
|
|
|
+
|
|
|
+ private InnatePortraitVO portrait;
|
|
|
+
|
|
|
+ /** AI解读文案 */
|
|
|
+ private String aiReading;
|
|
|
+
|
|
|
+ /** 解读生成时间 */
|
|
|
+ private String generatedAt;
|
|
|
+
|
|
|
+ /** 是否来自缓存 */
|
|
|
+ private Boolean fromCache;
|
|
|
+
|
|
|
+ /** 是否降级为模板文案 */
|
|
|
+ private Boolean fallbackUsed;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:创建 InnateTrajectoryVO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class InnateTrajectoryVO {
|
|
|
+
|
|
|
+ private InnatePortraitVO innatePortrait;
|
|
|
+
|
|
|
+ /** 五维先天分 {"mind":72,"body":65,...} */
|
|
|
+ private Map<String, Integer> innateDimensionScores;
|
|
|
+
|
|
|
+ /** 后天能量 {"mindBalance":120,"mindTotalEarned":350} */
|
|
|
+ private Map<String, Object> currentEnergy;
|
|
|
+
|
|
|
+ /** 先天vs后天 {"mindInnate":72,"mindCurrent":81,"growth":"+9"} */
|
|
|
+ private Map<String, Object> innateVsCurrent;
|
|
|
+
|
|
|
+ /** 成长趋势文案 */
|
|
|
+ private String growthTrend;
|
|
|
+
|
|
|
+ /** 成长建议 */
|
|
|
+ private String advice;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 6:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper cfc-backend/src/main/java/com/etotem/cfc/dto
|
|
|
+git commit -m "feat: 先天画像Mapper和DTO"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 2:数字能量计算
|
|
|
+
|
|
|
+### 任务 4:NumSoulCalculator(纯计算逻辑 + TDD)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/NumSoulCalculator.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/NumSoulCalculatorTest.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:编写失败的测试**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import java.time.LocalDate;
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+class NumSoulCalculatorTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void lifePath_19900515_shouldBe3() {
|
|
|
+ // 1+9+9+0+0+5+1+5=30 -> 3+0=3
|
|
|
+ assertEquals(3, NumSoulCalculator.calcLifePath(1990, 5, 15));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void lifePath_masterNumber_shouldKeep11() {
|
|
|
+ // 1987+11+29 = 1+9+8+7+1+1+2+9=38 -> 3+8=11 (主数保留)
|
|
|
+ assertEquals(11, NumSoulCalculator.calcLifePath(1987, 11, 29));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void birthday_shouldReduceDay() {
|
|
|
+ // 15 -> 1+5=6
|
|
|
+ assertEquals(6, NumSoulCalculator.calcBirthday(15));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void talent_shouldReturnTwoNumbers() {
|
|
|
+ // 1990: 1+9+9+0=19, 05: 5, 15: 6 -> 天赋数 19 和 11(5+6)
|
|
|
+ assertArrayEquals(new int[]{19, 11}, NumSoulCalculator.calcTalent(1990, 5, 15));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void destiny_shouldReduceFullSum() {
|
|
|
+ // 1990-05-15 sum=30 -> 3
|
|
|
+ assertEquals(3, NumSoulCalculator.calcDestiny(1990, 5, 15));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行测试验证失败**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=NumSoulCalculatorTest`
|
|
|
+预期:FAIL(类不存在)
|
|
|
+
|
|
|
+- [ ] **步骤 3:实现 NumSoulCalculator**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+@Component
|
|
|
+public class NumSoulCalculator {
|
|
|
+
|
|
|
+ /** 主数(大师数),不化简 */
|
|
|
+ private static final int[] MASTER_NUMBERS = {11, 22, 33};
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生命灵数:年月日所有数字相加直至个位(主数11/22/33保留)
|
|
|
+ */
|
|
|
+ public static int calcLifePath(int year, int month, int day) {
|
|
|
+ int sum = digitSum(year) + digitSum(month) + digitSum(day);
|
|
|
+ return reduceToSingleOrMaster(sum);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 天赋数:年化简、月+日合并化简,返回两个两位数
|
|
|
+ * 例: 1990-05-15 -> 年=19, 日+月=5+6=11 -> [19, 11]
|
|
|
+ */
|
|
|
+ public static int[] calcTalent(int year, int month, int day) {
|
|
|
+ int yearNum = digitSum(year); // 1990 -> 19
|
|
|
+ int monthDayNum = digitSum(month) + digitSum(day); // 5 + 6 = 11
|
|
|
+ return new int[]{yearNum, monthDayNum};
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生日数:出生日化简至个位
|
|
|
+ */
|
|
|
+ public static int calcBirthday(int day) {
|
|
|
+ return reduceToSingleOrMaster(day);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 命运数(简化版,不含姓名):出生年月日总和化简
|
|
|
+ */
|
|
|
+ public static int calcDestiny(int year, int month, int day) {
|
|
|
+ int sum = digitSum(year) + digitSum(month) + digitSum(day);
|
|
|
+ return reduceToSingleOrMaster(sum);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int digitSum(int n) {
|
|
|
+ int sum = 0;
|
|
|
+ while (n > 0) {
|
|
|
+ sum += n % 10;
|
|
|
+ n /= 10;
|
|
|
+ }
|
|
|
+ return sum;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int reduceToSingleOrMaster(int n) {
|
|
|
+ while (n >= 10 && !isMasterNumber(n)) {
|
|
|
+ n = digitSum(n);
|
|
|
+ }
|
|
|
+ return n;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean isMasterNumber(int n) {
|
|
|
+ for (int m : MASTER_NUMBERS) {
|
|
|
+ if (m == n) return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试验证通过**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=NumSoulCalculatorTest`
|
|
|
+预期:PASS(5个测试全绿)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/NumSoulCalculator.java cfc-backend/src/test/java/com/etotem/cfc/service/NumSoulCalculatorTest.java
|
|
|
+git commit -m "feat: 数字能量计算器 NumSoulCalculator + 单元测试"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 3:先天画像聚合服务 + 接口
|
|
|
+
|
|
|
+### 任务 5:InnatePortraitService(聚合 + AI编排)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/InnatePortraitService.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:实现 InnatePortraitService(聚合画像)**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.dto.InnatePortraitReportVO;
|
|
|
+import com.etotem.cfc.dto.InnatePortraitVO;
|
|
|
+import com.etotem.cfc.dto.InnateTrajectoryVO;
|
|
|
+import com.etotem.cfc.dto.NumSoulDetailVO;
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class InnatePortraitService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(InnatePortraitService.class);
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberAttributeService familyMemberAttributeService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private InnatePortraitConfigMapper innatePortraitConfigMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private InnatePortraitReportMapper innatePortraitReportMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private AiGateway aiGateway;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private EnergyBalanceMapper energyBalanceMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private EnergyDimensionMapper energyDimensionMapper;
|
|
|
+
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聚合先天画像(复用 calcInnateScore 计算 mind_base_score)
|
|
|
+ */
|
|
|
+ public InnatePortraitVO getInnatePortrait(Long memberId, String memberType) {
|
|
|
+ FamilyMemberAttributes attrs = familyMemberAttributeService.getByMember(memberId, memberType);
|
|
|
+ if (attrs == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ InnatePortraitVO vo = new InnatePortraitVO();
|
|
|
+ vo.setMemberId(memberId);
|
|
|
+ vo.setMemberType(memberType);
|
|
|
+ vo.setEightCharacters(parseJson(attrs.getEightCharacters()));
|
|
|
+ vo.setWuxingElements(parseJsonInt(attrs.getWuxingElements()));
|
|
|
+ vo.setZodiac(attrs.getZodiac());
|
|
|
+ vo.setBloodType(attrs.getBloodType());
|
|
|
+ // 复用现有计算(含缓存),不重复实现
|
|
|
+ vo.setMindBaseScore(familyMemberAttributeService.calcInnateScore(memberId, memberType, "mind"));
|
|
|
+ vo.setWisdomBaseScore(familyMemberAttributeService.calcInnateScore(memberId, memberType, "wisdom"));
|
|
|
+
|
|
|
+ // 数字能量
|
|
|
+ if (attrs.getBirthDatetime() != null) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(attrs.getBirthDatetime());
|
|
|
+ vo.setNumSoul(calculateNumSoul(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 来源贡献明细
|
|
|
+ vo.setSourceContributions(listSourceContributions(memberId, memberType, attrs));
|
|
|
+
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 数字能量计算
|
|
|
+ */
|
|
|
+ public NumSoulDetailVO calculateNumSoul(int year, int month, int day) {
|
|
|
+ NumSoulDetailVO vo = new NumSoulDetailVO();
|
|
|
+ int lifePath = NumSoulCalculator.calcLifePath(year, month, day);
|
|
|
+ int[] talent = NumSoulCalculator.calcTalent(year, month, day);
|
|
|
+ int birthday = NumSoulCalculator.calcBirthday(day);
|
|
|
+ int destiny = NumSoulCalculator.calcDestiny(year, month, day);
|
|
|
+
|
|
|
+ vo.setLifePath(lifePath);
|
|
|
+ vo.setTalent1(talent[0]);
|
|
|
+ vo.setTalent2(talent[1]);
|
|
|
+ vo.setBirthday(birthday);
|
|
|
+ vo.setDestiny(destiny);
|
|
|
+
|
|
|
+ // 从配置表补充解读
|
|
|
+ NumSoulDetailConfig lifeCfg = getNumSoulConfig("life_path", lifePath);
|
|
|
+ if (lifeCfg != null) {
|
|
|
+ vo.setLifePathTitle(lifeCfg.getTitle());
|
|
|
+ vo.setLifePathKeywords(lifeCfg.getKeywords());
|
|
|
+ vo.setLifePathAdvice(lifeCfg.getMindAdvice());
|
|
|
+ vo.setColorHex(lifeCfg.getColorHex());
|
|
|
+ }
|
|
|
+ NumSoulDetailConfig talentCfg = getNumSoulConfig("talent", talent[0]);
|
|
|
+ if (talentCfg != null) {
|
|
|
+ vo.setTalentTitle(talentCfg.getTitle());
|
|
|
+ }
|
|
|
+ NumSoulDetailConfig birthdayCfg = getNumSoulConfig("birthday", birthday);
|
|
|
+ if (birthdayCfg != null) {
|
|
|
+ vo.setBirthdayTitle(birthdayCfg.getTitle());
|
|
|
+ }
|
|
|
+ NumSoulDetailConfig destinyCfg = getNumSoulConfig("destiny", destiny);
|
|
|
+ if (destinyCfg != null) {
|
|
|
+ vo.setDestinyTitle(destinyCfg.getTitle());
|
|
|
+ }
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ private NumSoulDetailConfig getNumSoulConfig(String type, int value) {
|
|
|
+ LambdaQueryWrapper<NumSoulDetailConfig> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(NumSoulDetailConfig::getNumberType, type)
|
|
|
+ .eq(NumSoulDetailConfig::getNumberValue, value);
|
|
|
+ return numSoulDetailConfigMapper.selectOne(wrapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 来源贡献明细(用于管理端可配置权重展示)
|
|
|
+ */
|
|
|
+ private List<Map<String, Object>> listSourceContributions(Long memberId, String memberType, FamilyMemberAttributes attrs) {
|
|
|
+ List<Map<String, Object>> list = new ArrayList<>();
|
|
|
+ List<InnatePortraitConfig> configs = innatePortraitConfigMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<InnatePortraitConfig>().orderByAsc(InnatePortraitConfig::getSortOrder));
|
|
|
+ for (InnatePortraitConfig cfg : configs) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("sourceType", cfg.getSourceType());
|
|
|
+ item.put("sourceName", cfg.getSourceName());
|
|
|
+ item.put("weight", cfg.getWeight());
|
|
|
+ item.put("enabled", cfg.getEnabled());
|
|
|
+ list.add(item);
|
|
|
+ }
|
|
|
+ return list;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成/获取 AI 解读(缓存到 innate_portrait_report)
|
|
|
+ */
|
|
|
+ public InnatePortraitReportVO generateAiReading(Long memberId, String memberType, Long familyId) {
|
|
|
+ // 1. 检查缓存
|
|
|
+ InnatePortraitReport report = innatePortraitReportMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<InnatePortraitReport>()
|
|
|
+ .eq(InnatePortraitReport::getMemberId, memberId)
|
|
|
+ .eq(InnatePortraitReport::getMemberType, memberType));
|
|
|
+ boolean fallbackUsed = false;
|
|
|
+ if (report != null && report.getAiReading() != null && !report.getAiReading().isEmpty()) {
|
|
|
+ InnatePortraitReportVO cached = new InnatePortraitReportVO();
|
|
|
+ cached.setPortrait(getInnatePortrait(memberId, memberType));
|
|
|
+ cached.setAiReading(report.getAiReading());
|
|
|
+ cached.setGeneratedAt(String.valueOf(report.getGeneratedAt()));
|
|
|
+ cached.setFromCache(true);
|
|
|
+ cached.setFallbackUsed(false);
|
|
|
+ return cached;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 聚合先天画像
|
|
|
+ InnatePortraitVO portrait = getInnatePortrait(memberId, memberType);
|
|
|
+ if (portrait == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 调用 AiGateway 生成解读(失败返回 null)
|
|
|
+ String aiReading = null;
|
|
|
+ try {
|
|
|
+ Map<String, Object> result = aiGateway.generateInnateReading(portrait);
|
|
|
+ if (result != null && result.get("reading") != null) {
|
|
|
+ aiReading = (String) result.get("reading");
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AI解读生成失败,降级模板: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 降级:模板拼接
|
|
|
+ if (aiReading == null || aiReading.isEmpty()) {
|
|
|
+ aiReading = buildFallbackReading(portrait);
|
|
|
+ fallbackUsed = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 缓存
|
|
|
+ InnatePortraitReport newReport = new InnatePortraitReport();
|
|
|
+ newReport.setMemberId(memberId);
|
|
|
+ newReport.setMemberType(memberType);
|
|
|
+ newReport.setFamilyId(familyId);
|
|
|
+ newReport.setPortraitJson(safeToJson(portrait));
|
|
|
+ newReport.setAiReading(aiReading);
|
|
|
+ newReport.setGeneratedAt(new Date());
|
|
|
+ if (report != null) {
|
|
|
+ newReport.setId(report.getId());
|
|
|
+ innatePortraitReportMapper.updateById(newReport);
|
|
|
+ } else {
|
|
|
+ innatePortraitReportMapper.insert(newReport);
|
|
|
+ }
|
|
|
+
|
|
|
+ InnatePortraitReportVO vo = new InnatePortraitReportVO();
|
|
|
+ vo.setPortrait(portrait);
|
|
|
+ vo.setAiReading(aiReading);
|
|
|
+ vo.setGeneratedAt(String.valueOf(newReport.getGeneratedAt()));
|
|
|
+ vo.setFromCache(false);
|
|
|
+ vo.setFallbackUsed(fallbackUsed);
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 降级模板文案(AI 不可用时)
|
|
|
+ */
|
|
|
+ private String buildFallbackReading(InnatePortraitVO portrait) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ if (portrait.getZodiac() != null) {
|
|
|
+ sb.append("生肖").append(portrait.getZodiac()).append(",");
|
|
|
+ }
|
|
|
+ if (portrait.getNumSoul() != null && portrait.getNumSoul().getLifePath() != null) {
|
|
|
+ sb.append("生命灵数").append(portrait.getNumSoul().getLifePath())
|
|
|
+ .append(portrait.getNumSoul().getLifePathTitle() != null ? "(" + portrait.getNumSoul().getLifePathTitle() + ")" : "").append(",");
|
|
|
+ }
|
|
|
+ sb.append("心先天基础分 ").append(portrait.getMindBaseScore()).append("。");
|
|
|
+ sb.append("建议通过情绪打卡、家庭互动与阅读持续滋养心能量。");
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 先天+后天成长轨迹
|
|
|
+ */
|
|
|
+ public InnateTrajectoryVO getTrajectory(Long memberId, String memberType, Long familyId) {
|
|
|
+ InnateTrajectoryVO vo = new InnateTrajectoryVO();
|
|
|
+ InnatePortraitVO portrait = getInnatePortrait(memberId, memberType);
|
|
|
+ if (portrait == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ vo.setInnatePortrait(portrait);
|
|
|
+
|
|
|
+ Map<String, Integer> innateScores = new LinkedHashMap<>();
|
|
|
+ innateScores.put("mind", portrait.getMindBaseScore());
|
|
|
+ innateScores.put("wisdom", portrait.getWisdomBaseScore());
|
|
|
+ vo.setInnateDimensionScores(innateScores);
|
|
|
+
|
|
|
+ // 后天能量:从 energy_balance 读取(按 dimension code 匹配)
|
|
|
+ Map<String, Object> currentEnergy = new LinkedHashMap<>();
|
|
|
+ Map<String, Object> innateVsCurrent = new LinkedHashMap<>();
|
|
|
+ List<EnergyDimension> allDims = energyDimensionMapper.selectList(null);
|
|
|
+ Integer mindCurrent = null;
|
|
|
+ for (EnergyDimension dim : allDims) {
|
|
|
+ if (dim.getStatus() != null && dim.getStatus() != 1) continue;
|
|
|
+ EnergyBalance balance = energyBalanceMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<EnergyBalance>()
|
|
|
+ .eq(EnergyBalance::getChildId, memberId)
|
|
|
+ .eq(EnergyBalance::getDimensionId, dim.getId()));
|
|
|
+ int bal = balance != null && balance.getBalance() != null ? balance.getBalance() : 0;
|
|
|
+ currentEnergy.put(dim.getCode() + "Balance", bal);
|
|
|
+ if (balance != null && balance.getTotalEarned() != null) {
|
|
|
+ currentEnergy.put(dim.getCode() + "TotalEarned", balance.getTotalEarned());
|
|
|
+ }
|
|
|
+ if ("mind".equals(dim.getCode())) {
|
|
|
+ mindCurrent = bal;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ vo.setCurrentEnergy(currentEnergy);
|
|
|
+
|
|
|
+ // 先天 vs 后天(心维度)
|
|
|
+ int mindInnate = portrait.getMindBaseScore() != null ? portrait.getMindBaseScore() : 0;
|
|
|
+ int mindCur = mindCurrent != null ? mindCurrent : 0;
|
|
|
+ Map<String, Object> vs = new LinkedHashMap<>();
|
|
|
+ vs.put("mindInnate", mindInnate);
|
|
|
+ vs.put("mindCurrent", mindCur);
|
|
|
+ vs.put("growth", mindCur >= mindInnate ? "+" + (mindCur - mindInnate) : String.valueOf(mindCur - mindInnate));
|
|
|
+ vo.setInnateVsCurrent(vs);
|
|
|
+
|
|
|
+ // 成长趋势文案(确定性生成,AI 增强见任务9)
|
|
|
+ if (mindCur >= mindInnate) {
|
|
|
+ vo.setGrowthTrend("先天心能量基础分 " + mindInnate + ",后天通过情绪打卡/家庭互动成长至 " + mindCur + ",持续向好。");
|
|
|
+ } else {
|
|
|
+ vo.setGrowthTrend("先天心能量基础分 " + mindInnate + ",当前能量 " + mindCur + ",建议增加情绪打卡与家庭共处时间。");
|
|
|
+ }
|
|
|
+ vo.setAdvice("保持情绪打卡频率,增加家庭共处时间,通过阅读与亲子对话持续滋养心能量。");
|
|
|
+ return vo;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> parseJson(String json) {
|
|
|
+ if (json == null || json.isEmpty()) return new HashMap<>();
|
|
|
+ try {
|
|
|
+ return objectMapper.readValue(json, Map.class);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return new HashMap<>();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Integer> parseJsonInt(String json) {
|
|
|
+ Map<String, Object> raw = parseJson(json);
|
|
|
+ Map<String, Integer> result = new LinkedHashMap<>();
|
|
|
+ for (Map.Entry<String, Object> e : raw.entrySet()) {
|
|
|
+ result.put(e.getKey(), e.getValue() instanceof Number ? ((Number) e.getValue()).intValue() : 0);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String safeToJson(Object obj) {
|
|
|
+ try {
|
|
|
+ return objectMapper.writeValueAsString(obj);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "{}";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **依赖处理**:`AiGateway.generateInnateReading` 在任务 8 实现。本步骤(任务 5)先保证聚合逻辑可编译——**先在 `AiGateway` 添加返回 null 的 stub 方法**(见下方步骤 1b),任务 8 再完善为真实 HTTP 调用。AI 方法返回 null 时 `generateAiReading` 走降级模板路径。
|
|
|
+
|
|
|
+- [ ] **步骤 1b:在 AiGateway 添加 stub(任务 8 完善)**
|
|
|
+
|
|
|
+在 `AiGateway.java` 类中临时添加:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 先天画像 AI 解读(stub,任务8完善为真实HTTP调用)
|
|
|
+ */
|
|
|
+public Map<String, Object> generateInnateReading(Object portrait) {
|
|
|
+ return null;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/InnatePortraitService.java cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
|
|
|
+git commit -m "feat: InnatePortraitService 先天画像聚合服务"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 6:InnatePortraitController(4 个接口)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/mind/InnatePortraitController.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:实现 Controller**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.mind;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.dto.InnatePortraitReportVO;
|
|
|
+import com.etotem.cfc.dto.InnatePortraitVO;
|
|
|
+import com.etotem.cfc.dto.InnateTrajectoryVO;
|
|
|
+import com.etotem.cfc.dto.NumSoulDetailVO;
|
|
|
+import com.etotem.cfc.service.InnatePortraitService;
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/mind/innate")
|
|
|
+public class InnatePortraitController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private InnatePortraitService innatePortraitService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取先天画像
|
|
|
+ */
|
|
|
+ @PostMapping("/portrait")
|
|
|
+ public Result<InnatePortraitVO> portrait(@RequestBody Map<String, Object> params) {
|
|
|
+ try {
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"));
|
|
|
+ String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
|
|
|
+ InnatePortraitVO vo = innatePortraitService.getInnatePortrait(memberId, memberType);
|
|
|
+ if (vo == null) {
|
|
|
+ return Result.error("成员不存在或未设置出生信息");
|
|
|
+ }
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取先天画像失败", e);
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成/获取 AI 解读
|
|
|
+ */
|
|
|
+ @PostMapping("/reading")
|
|
|
+ public Result<InnatePortraitReportVO> reading(@RequestBody Map<String, Object> params) {
|
|
|
+ try {
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"));
|
|
|
+ String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
|
|
|
+ Long familyId = ParamUtils.getLong(params.get("familyId"));
|
|
|
+ InnatePortraitReportVO vo = innatePortraitService.generateAiReading(memberId, memberType, familyId);
|
|
|
+ if (vo == null) {
|
|
|
+ return Result.error("成员不存在或未设置出生信息");
|
|
|
+ }
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("生成AI解读失败", e);
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取数字能量详情
|
|
|
+ */
|
|
|
+ @PostMapping("/numsoul")
|
|
|
+ public Result<NumSoulDetailVO> numsoul(@RequestBody Map<String, Object> params) {
|
|
|
+ try {
|
|
|
+ Integer year = params.get("year") instanceof Number ? ((Number) params.get("year")).intValue() : null;
|
|
|
+ Integer month = params.get("month") instanceof Number ? ((Number) params.get("month")).intValue() : null;
|
|
|
+ Integer day = params.get("day") instanceof Number ? ((Number) params.get("day")).intValue() : null;
|
|
|
+ if (year == null || month == null || day == null) {
|
|
|
+ return Result.error("请提供出生年月日");
|
|
|
+ }
|
|
|
+ return Result.success(innatePortraitService.calculateNumSoul(year, month, day));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取数字能量失败", e);
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取先天+后天成长轨迹
|
|
|
+ */
|
|
|
+ @PostMapping("/trajectory")
|
|
|
+ public Result<InnateTrajectoryVO> trajectory(@RequestBody Map<String, Object> params) {
|
|
|
+ try {
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"));
|
|
|
+ String memberType = params.containsKey("memberType") ? params.get("memberType").toString() : "child";
|
|
|
+ Long familyId = ParamUtils.getLong(params.get("familyId"));
|
|
|
+ InnateTrajectoryVO vo = innatePortraitService.getTrajectory(memberId, memberType, familyId);
|
|
|
+ if (vo == null) {
|
|
|
+ return Result.error("成员不存在或未设置出生信息");
|
|
|
+ }
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取成长轨迹失败", e);
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:检查 ParamUtils 是否有 getInt**
|
|
|
+
|
|
|
+运行:`grep -n "getInt\|public static Integer get" cfc-backend/src/main/java/com/etotem/cfc/util/ParamUtils.java`
|
|
|
+预期:**无 `getInt`**(已确认)。因此 `numsoul` 接口中 `year/month/day` 用类型判断读取:
|
|
|
+
|
|
|
+```java
|
|
|
+Integer year = params.get("year") instanceof Number ? ((Number) params.get("year")).intValue() : null;
|
|
|
+Integer month = params.get("month") instanceof Number ? ((Number) params.get("month")).intValue() : null;
|
|
|
+Integer day = params.get("day") instanceof Number ? ((Number) params.get("day")).intValue() : null;
|
|
|
+```
|
|
|
+
|
|
|
+将任务 6 步骤 1 中 `numsoul` 方法的 `ParamUtils.getInt` 替换为上述写法。
|
|
|
+
|
|
|
+- [ ] **步骤 3:检查路由重复**
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep "mind/innate"
|
|
|
+```
|
|
|
+预期:仅输出 /portrait /reading /numsoul /trajectory,无重复
|
|
|
+
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/mind/InnatePortraitController.java
|
|
|
+git commit -m "feat: 先天画像4个接口(portrait/reading/numsoul/trajectory)"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 7:calcInnateScore 权重替换改造
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java:214-306`
|
|
|
+
|
|
|
+- [ ] **步骤 1:注入 InnatePortraitConfigMapper**
|
|
|
+
|
|
|
+在 `FamilyMemberAttributeService` 类中新增注入(现有字段附近):
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.mapper.InnatePortraitConfigMapper innatePortraitConfigMapper;
|
|
|
+
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.mapper.NumSoulDetailConfigMapper numSoulDetailConfigMapper;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:替换加权求和部分**
|
|
|
+
|
|
|
+将 `calcInnateScore` 中硬编码加权(原 293-296 行):
|
|
|
+
|
|
|
+```java
|
|
|
+// --- Weighted sum: zodiac×50% + bazi×30% + blood×20% ---
|
|
|
+// Weights in bps: 50% = 5000, 30% = 3000, 20% = 2000
|
|
|
+Integer result = (zodiacScore * 5000 + baziScore * 3000 + bloodScore * 2000) / 10000;
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+// --- Weighted sum: 从 innate_portrait_config 读取权重(可配置)---
|
|
|
+// 未配置时回退硬编码 zodiac×50% + bazi×30% + blood×20%
|
|
|
+int wZodiac = 5000, wBazi = 3000, wBlood = 2000, wNumsoul = 0;
|
|
|
+List<InnatePortraitConfig> configs = innatePortraitConfigMapper.selectList(null);
|
|
|
+if (configs != null && !configs.isEmpty()) {
|
|
|
+ for (InnatePortraitConfig cfg : configs) {
|
|
|
+ int w = (cfg.getWeight() != null ? cfg.getWeight() : 0);
|
|
|
+ if (cfg.getEnabled() != null && cfg.getEnabled() == 0) {
|
|
|
+ w = 0;
|
|
|
+ }
|
|
|
+ switch (cfg.getSourceType()) {
|
|
|
+ case "zodiac": wZodiac = w; break;
|
|
|
+ case "bazi": wBazi = w; break;
|
|
|
+ case "blood": wBlood = w; break;
|
|
|
+ case "numsoul": wNumsoul = w; break;
|
|
|
+ default: break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// numsoul 贡献:按生命灵数匹配 life_path 配置
|
|
|
+int numsoulScore = 0;
|
|
|
+if (wNumsoul > 0 && attrs.getBehaviorModifier() != null) {
|
|
|
+ Map<String, Object> modifier = fromJson(attrs.getBehaviorModifier());
|
|
|
+ Object lifeNum = modifier.get("lifeNumber");
|
|
|
+ if (lifeNum instanceof Number) {
|
|
|
+ LambdaQueryWrapper<NumSoulDetailConfig> nsWrapper = new LambdaQueryWrapper<>();
|
|
|
+ nsWrapper.eq(NumSoulDetailConfig::getNumberType, "life_path")
|
|
|
+ .eq(NumSoulDetailConfig::getNumberValue, ((Number) lifeNum).intValue());
|
|
|
+ NumSoulDetailConfig nsCfg = numSoulDetailConfigMapper.selectOne(nsWrapper);
|
|
|
+ if (nsCfg != null && nsCfg.getMindBase() != null) {
|
|
|
+ numsoulScore = nsCfg.getMindBase();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 累加启用来源的权重,归一化
|
|
|
+int totalWeight = wZodiac + wBazi + wBlood + wNumsoul;
|
|
|
+int result;
|
|
|
+if (totalWeight > 0) {
|
|
|
+ result = (zodiacScore * wZodiac + baziScore * wBazi + bloodScore * wBlood + numsoulScore * wNumsoul) / totalWeight;
|
|
|
+} else {
|
|
|
+ result = 0;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:处理缓存失效问题(关键)**
|
|
|
+
|
|
|
+**问题**:`calcInnateScore` 开头有缓存逻辑 `if (attrs.getMindBaseScore() != null && attrs.getMindBaseScore() > 0) return attrs.getMindBaseScore();`——权重修改后已缓存的值不会重算。
|
|
|
+
|
|
|
+**改造**:在权重配置变化时清除缓存。在 `InnatePortraitConfigController.update/delete` 成功后调用新增方法:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 权重配置变化时清除所有已缓存的先天分,触发重算
|
|
|
+ * 说明:本服务继承 ServiceImpl<FamilyMemberAttributesMapper, FamilyMemberAttributes>,
|
|
|
+ * 可直接使用 this.list() / this.updateById()
|
|
|
+ */
|
|
|
+public void invalidateAllInnateScores() {
|
|
|
+ List<FamilyMemberAttributes> all = this.list();
|
|
|
+ for (FamilyMemberAttributes a : all) {
|
|
|
+ a.setMindBaseScore(0);
|
|
|
+ a.setWisdomBaseScore(0);
|
|
|
+ this.updateById(a);
|
|
|
+ }
|
|
|
+ log.info("已清除 {} 条成员先天分缓存", all.size());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+(在 `FamilyMemberAttributeService` 中新增该方法。因该类继承 `ServiceImpl<FamilyMemberAttributesMapper, FamilyMemberAttributes>`,直接用 `this.list()` / `this.updateById()`,无需额外注入 mapper。)
|
|
|
+
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(`this.list()` / `this.updateById()` 来自 ServiceImpl 基类,已确认可用)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberAttributeService.java
|
|
|
+git commit -m "feat: calcInnateScore 支持可配置权重+数字能量参与+缓存失效"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 4:AI 解读(LangGraph)
|
|
|
+
|
|
|
+### 任务 8:AiGateway.generateInnateReading
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:实现 generateInnateReading**
|
|
|
+
|
|
|
+在 `AiGateway` 类中(`generateQuestionnaire` 之后)新增:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 调用 Python 先天画像解读服务
|
|
|
+ * @param portrait 先天画像数据(InnatePortraitVO 转 Map)
|
|
|
+ * @return 含 "reading" 字段的 Map;失败返回 null(调用方降级模板)
|
|
|
+ */
|
|
|
+public Map<String, Object> generateInnateReading(Object portrait) {
|
|
|
+ if (!enabled || isCircuitOpen()) return null;
|
|
|
+ try {
|
|
|
+ ObjectNode body = objectMapper.createObjectNode();
|
|
|
+ body.set("portrait", objectMapper.valueToTree(portrait));
|
|
|
+
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
|
|
|
+ String url = baseUrl + "/api/v1/innate/reading";
|
|
|
+
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
|
|
+ JsonNode root = objectMapper.readTree(response.getBody());
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("reading", root.has("reading") ? root.get("reading").asText() : "");
|
|
|
+ consecutiveFailures.set(0);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AiGateway generateInnateReading 调用失败: {}", e.getMessage());
|
|
|
+ recordFailure();
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
|
|
|
+git commit -m "feat: AiGateway 新增 generateInnateReading"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 9:innate_portrait_graph(LangGraph)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-langgraph/app/graphs/innate_portrait_graph.py`
|
|
|
+- 创建:`cfc-langgraph/app/api/innate.py`
|
|
|
+- 修改:`cfc-langgraph/app/main.py`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 innate_portrait_graph.py**
|
|
|
+
|
|
|
+```python
|
|
|
+from typing import TypedDict
|
|
|
+from langgraph.graph import StateGraph, START, END
|
|
|
+from langchain_openai import ChatOpenAI
|
|
|
+from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
+from app.config import settings
|
|
|
+import json
|
|
|
+import logging
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+class InnatePortraitState(TypedDict):
|
|
|
+ portrait: dict
|
|
|
+ reading: str | None
|
|
|
+ raw_output: str | None
|
|
|
+
|
|
|
+
|
|
|
+SYSTEM_PROMPT = (
|
|
|
+ "你是一位家庭教育成长陪伴师,擅长用东西方智慧解读孩子的先天特质。"
|
|
|
+ "你的任务是根据用户的先天画像数据(八字五行、星座生肖、血型、生命灵数等),"
|
|
|
+ "生成一段温暖、积极、可操作的个性化解读。"
|
|
|
+ "原则:"
|
|
|
+ "1. 强调'先天特质 + 后天成长'视角,避免宿命论措辞"
|
|
|
+ "2. 心维度对应五行'火',代表情绪稳定、理解包容、传承传递"
|
|
|
+ "3. 措辞要面向家长,给出亲子互动建议"
|
|
|
+ "4. 输出 300 字以内,直接输出解读文本,不要 JSON 包装"
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def create_innate_portrait_graph():
|
|
|
+ async def build_prompt(state: InnatePortraitState) -> dict:
|
|
|
+ portrait = state["portrait"]
|
|
|
+ # 提取关键画像数据
|
|
|
+ summary = {
|
|
|
+ "zodiac": portrait.get("zodiac"),
|
|
|
+ "bloodType": portrait.get("bloodType"),
|
|
|
+ "mindBaseScore": portrait.get("mindBaseScore"),
|
|
|
+ "wisdomBaseScore": portrait.get("wisdomBaseScore"),
|
|
|
+ "numSoul": portrait.get("numSoul"),
|
|
|
+ "wuxingElements": portrait.get("wuxingElements"),
|
|
|
+ "eightCharacters": portrait.get("eightCharacters"),
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ "portrait": portrait,
|
|
|
+ "raw_output": json.dumps(summary, ensure_ascii=False),
|
|
|
+ }
|
|
|
+
|
|
|
+ async def generate(state: InnatePortraitState) -> dict:
|
|
|
+ llm = ChatOpenAI(
|
|
|
+ model=settings.llm_model,
|
|
|
+ api_key=settings.llm_api_key,
|
|
|
+ base_url=settings.llm_base_url,
|
|
|
+ temperature=0.7,
|
|
|
+ )
|
|
|
+ user_msg = (
|
|
|
+ f"请为以下孩子生成先天心维度画像解读:\n"
|
|
|
+ f"{state['raw_output']}\n\n"
|
|
|
+ f"请按此结构输出:\n"
|
|
|
+ f"一、先天特质(基于五行/星座/灵数综合分析)\n"
|
|
|
+ f"二、先天优势(列出2-3个)\n"
|
|
|
+ f"三、心维度成长建议\n"
|
|
|
+ f"四、家长互动建议\n"
|
|
|
+ f"五、五行平衡提示"
|
|
|
+ )
|
|
|
+ resp = await llm.ainvoke([
|
|
|
+ SystemMessage(content=SYSTEM_PROMPT),
|
|
|
+ HumanMessage(content=user_msg),
|
|
|
+ ])
|
|
|
+ return {"reading": resp.content}
|
|
|
+
|
|
|
+ graph = StateGraph(InnatePortraitState)
|
|
|
+ graph.add_node("build_prompt", build_prompt)
|
|
|
+ graph.add_node("generate", generate)
|
|
|
+ graph.add_edge(START, "build_prompt")
|
|
|
+ graph.add_edge("build_prompt", "generate")
|
|
|
+ graph.add_edge("generate", END)
|
|
|
+ return graph.compile()
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 app/api/innate.py**
|
|
|
+
|
|
|
+```python
|
|
|
+import uuid
|
|
|
+from fastapi import APIRouter
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+from app.graphs.innate_portrait_graph import create_innate_portrait_graph
|
|
|
+
|
|
|
+router = APIRouter(prefix="/api/v1", tags=["innate"])
|
|
|
+
|
|
|
+_graph = None
|
|
|
+
|
|
|
+
|
|
|
+def get_graph():
|
|
|
+ global _graph
|
|
|
+ if _graph is None:
|
|
|
+ _graph = create_innate_portrait_graph()
|
|
|
+ return _graph
|
|
|
+
|
|
|
+
|
|
|
+class InnateReadingRequest(BaseModel):
|
|
|
+ portrait: dict = Field(..., description="先天画像数据")
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/innate/reading")
|
|
|
+async def innate_reading(req: InnateReadingRequest):
|
|
|
+ """先天画像 AI 解读"""
|
|
|
+ trace_id = str(uuid.uuid4())
|
|
|
+ graph = get_graph()
|
|
|
+ initial_state = {
|
|
|
+ "portrait": req.portrait,
|
|
|
+ "reading": None,
|
|
|
+ "raw_output": None,
|
|
|
+ }
|
|
|
+ try:
|
|
|
+ result = await graph.ainvoke(initial_state)
|
|
|
+ return {
|
|
|
+ "reading": result.get("reading", ""),
|
|
|
+ "trace_id": trace_id,
|
|
|
+ }
|
|
|
+ except Exception as e:
|
|
|
+ logger = __import__("logging").getLogger(__name__)
|
|
|
+ logger.error("innate reading failed: %s", e)
|
|
|
+ return {"reading": "", "trace_id": trace_id}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:注册路由到 main.py**
|
|
|
+
|
|
|
+在 `app/main.py` 中,import 部分新增:
|
|
|
+
|
|
|
+```python
|
|
|
+from app.api import innate
|
|
|
+```
|
|
|
+
|
|
|
+并在 `app.include_router(...)` 区段新增:
|
|
|
+
|
|
|
+```python
|
|
|
+app.include_router(innate.router)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:验证配置字段语义**
|
|
|
+
|
|
|
+运行:`grep -n "llm_model\|llm_api_key\|llm_base_url" cfc-langgraph/app/config.py`
|
|
|
+预期:找到 3 个字段(已确认存在)——`llm_model: str = "deepseek-chat"`、`llm_api_key: str`、`llm_base_url: str`。本计划步骤 1 中的 `settings.llm_model` / `settings.llm_api_key` / `settings.llm_base_url` 引用正确,无需替换。
|
|
|
+
|
|
|
+- [ ] **步骤 5:Python 语法检查**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/innate_portrait_graph.py app/api/innate.py`
|
|
|
+预期:无输出(编译通过)
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-langgraph/app/graphs/innate_portrait_graph.py cfc-langgraph/app/api/innate.py cfc-langgraph/app/main.py
|
|
|
+git commit -m "feat: 先天画像AI解读graph + /api/v1/innate/reading端点"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 5:管理端配置页
|
|
|
+
|
|
|
+### 任务 10:管理端 Controller(2 个)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/InnatePortraitConfigController.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/NumSoulDetailConfigController.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 InnatePortraitConfigController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.InnatePortraitConfig;
|
|
|
+import com.etotem.cfc.mapper.InnatePortraitConfigMapper;
|
|
|
+import com.etotem.cfc.service.FamilyMemberAttributeService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/innate-portrait-config")
|
|
|
+public class InnatePortraitConfigController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private InnatePortraitConfigMapper innatePortraitConfigMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberAttributeService familyMemberAttributeService;
|
|
|
+
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<InnatePortraitConfig>> list() {
|
|
|
+ return Result.success(innatePortraitConfigMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<InnatePortraitConfig>().orderByAsc(InnatePortraitConfig::getSortOrder)));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<InnatePortraitConfig> update(@RequestBody InnatePortraitConfig config) {
|
|
|
+ if (config.getId() == null) {
|
|
|
+ return Result.error("缺少id");
|
|
|
+ }
|
|
|
+ innatePortraitConfigMapper.updateById(config);
|
|
|
+ // 权重变化:清除所有先天分缓存,触发重算
|
|
|
+ familyMemberAttributeService.invalidateAllInnateScores();
|
|
|
+ return Result.success(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<InnatePortraitConfig> create(@RequestBody InnatePortraitConfig config) {
|
|
|
+ innatePortraitConfigMapper.insert(config);
|
|
|
+ return Result.success(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<Void> delete(@RequestParam Long id) {
|
|
|
+ innatePortraitConfigMapper.deleteById(id);
|
|
|
+ familyMemberAttributeService.invalidateAllInnateScores();
|
|
|
+ return Result.success(null);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 NumSoulDetailConfigController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.NumSoulDetailConfig;
|
|
|
+import com.etotem.cfc.mapper.NumSoulDetailConfigMapper;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/numsoul-detail-config")
|
|
|
+public class NumSoulDetailConfigController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
|
|
|
+
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<List<NumSoulDetailConfig>> list(@RequestBody(required = false) java.util.Map<String, Object> params) {
|
|
|
+ LambdaQueryWrapper<NumSoulDetailConfig> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ if (params != null && params.get("numberType") != null) {
|
|
|
+ wrapper.eq(NumSoulDetailConfig::getNumberType, params.get("numberType").toString());
|
|
|
+ }
|
|
|
+ wrapper.orderByAsc(NumSoulDetailConfig::getNumberType).orderByAsc(NumSoulDetailConfig::getNumberValue);
|
|
|
+ return Result.success(numSoulDetailConfigMapper.selectList(wrapper));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/create")
|
|
|
+ public Result<NumSoulDetailConfig> create(@RequestBody NumSoulDetailConfig config) {
|
|
|
+ numSoulDetailConfigMapper.insert(config);
|
|
|
+ return Result.success(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/update")
|
|
|
+ public Result<NumSoulDetailConfig> update(@RequestBody NumSoulDetailConfig config) {
|
|
|
+ if (config.getId() == null) {
|
|
|
+ return Result.error("缺少id");
|
|
|
+ }
|
|
|
+ numSoulDetailConfigMapper.updateById(config);
|
|
|
+ return Result.success(config);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/delete")
|
|
|
+ public Result<Void> delete(@RequestParam Long id) {
|
|
|
+ numSoulDetailConfigMapper.deleteById(id);
|
|
|
+ return Result.success(null);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:编译验证 + 路由查重**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep -E "innate-portrait|numsoul-detail"
|
|
|
+```
|
|
|
+预期:仅输出新接口路径,无重复
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/InnatePortraitConfigController.java cfc-backend/src/main/java/com/etotem/cfc/controller/admin/NumSoulDetailConfigController.java
|
|
|
+git commit -m "feat: 管理端先天画像/数字能量配置接口"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 11:管理端页面(2 个)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-web/src/views/admin/InnatePortraitConfig.vue`
|
|
|
+- 创建:`cfc-web/src/views/admin/NumSoulDetailConfig.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 InnatePortraitConfig.vue**
|
|
|
+
|
|
|
+参照现有 `ZodiacConfigs.vue` 的表单+表格模式,核心结构:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <el-card shadow="never">
|
|
|
+ <div slot="header">
|
|
|
+ <span>先天画像来源权重配置</span>
|
|
|
+ <el-button style="float: right" type="primary" size="small" @click="load">刷新</el-button>
|
|
|
+ </div>
|
|
|
+ <el-table :data="list" border stripe>
|
|
|
+ <el-table-column prop="sourceName" label="来源" width="120" />
|
|
|
+ <el-table-column label="权重" width="220">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-input-number
|
|
|
+ v-model="scope.row.weight"
|
|
|
+ :min="0" :max="10000" :step="500"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ <span style="margin-left: 8px; color: #999">
|
|
|
+ {{ (scope.row.weight / 100).toFixed(1) }}%
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="启用" width="100">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-switch v-model="scope.row.enabled" :active-value="1" :inactive-value="0" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="AI解读" width="100">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-switch v-model="scope.row.aiEnabled" :active-value="1" :inactive-value="0" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="排序" width="120">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-input-number v-model="scope.row.sortOrder" :min="0" size="small" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button type="primary" size="mini" @click="save(scope.row)">保存</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ <div style="margin-top: 12px; color: #999; font-size: 12px">
|
|
|
+ 提示:权重按基点配置(2500=25%)。保存后自动清除所有成员先天分缓存并触发重算。启用来源权重之和不足100%时按比例归一化。
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'InnatePortraitConfig',
|
|
|
+ data() {
|
|
|
+ return { list: [] }
|
|
|
+ },
|
|
|
+ created() { this.load() },
|
|
|
+ methods: {
|
|
|
+ load() {
|
|
|
+ this.$http.post('/api/admin/innate-portrait-config/list').then(res => {
|
|
|
+ this.list = res.data || []
|
|
|
+ })
|
|
|
+ },
|
|
|
+ save(row) {
|
|
|
+ this.$http.post('/api/admin/innate-portrait-config/update', row).then(() => {
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ this.load()
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 NumSoulDetailConfig.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="app-container">
|
|
|
+ <el-card shadow="never">
|
|
|
+ <div slot="header">
|
|
|
+ <span>数字能量配置</span>
|
|
|
+ <div style="float: right">
|
|
|
+ <el-select v-model="filterType" placeholder="筛选类型" clearable size="small" style="width: 160px" @change="load">
|
|
|
+ <el-option label="生命灵数" value="life_path" />
|
|
|
+ <el-option label="天赋数" value="talent" />
|
|
|
+ <el-option label="生日数" value="birthday" />
|
|
|
+ <el-option label="命运数" value="destiny" />
|
|
|
+ </el-select>
|
|
|
+ <el-button type="primary" size="small" style="margin-left: 8px" @click="showCreate">新增</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-table :data="list" border stripe>
|
|
|
+ <el-table-column prop="numberType" label="类型" width="110">
|
|
|
+ <template slot-scope="scope">{{ typeLabel(scope.row.numberType) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="numberValue" label="数字" width="80" />
|
|
|
+ <el-table-column prop="title" label="称号" width="120" />
|
|
|
+ <el-table-column prop="keywords" label="关键词" />
|
|
|
+ <el-table-column prop="mindBase" label="心先天分" width="100" />
|
|
|
+ <el-table-column prop="mindAdvice" label="成长建议" show-overflow-tooltip />
|
|
|
+ <el-table-column label="代表色" width="80">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <span :style="{ display: 'inline-block', width: '20px', height: '20px', background: scope.row.colorHex, borderRadius: '4px' }" />
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="140">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button type="text" @click="showEdit(scope.row)">编辑</el-button>
|
|
|
+ <el-button type="text" style="color: #f56c6c" @click="remove(scope.row)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <el-dialog :title="form.id ? '编辑' : '新增'" :visible.sync="dialogVisible" width="520px">
|
|
|
+ <el-form :model="form" label-width="100px">
|
|
|
+ <el-form-item label="类型">
|
|
|
+ <el-select v-model="form.numberType">
|
|
|
+ <el-option label="生命灵数" value="life_path" />
|
|
|
+ <el-option label="天赋数" value="talent" />
|
|
|
+ <el-option label="生日数" value="birthday" />
|
|
|
+ <el-option label="命运数" value="destiny" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="数字"><el-input-number v-model="form.numberValue" :min="1" :max="99" /></el-form-item>
|
|
|
+ <el-form-item label="称号"><el-input v-model="form.title" /></el-form-item>
|
|
|
+ <el-form-item label="关键词"><el-input v-model="form.keywords" /></el-form-item>
|
|
|
+ <el-form-item label="心先天分"><el-input-number v-model="form.mindBase" :min="0" :max="100" /></el-form-item>
|
|
|
+ <el-form-item label="成长建议"><el-input type="textarea" v-model="form.mindAdvice" /></el-form-item>
|
|
|
+ <el-form-item label="代表色"><el-color-picker v-model="form.colorHex" /></el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <div slot="footer">
|
|
|
+ <el-button @click="dialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="save">保存</el-button>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'NumSoulDetailConfig',
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ list: [],
|
|
|
+ filterType: '',
|
|
|
+ dialogVisible: false,
|
|
|
+ form: { id: null, numberType: 'life_path', numberValue: 1, title: '', keywords: '', mindBase: 50, mindAdvice: '', colorHex: '#FF4444' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created() { this.load() },
|
|
|
+ methods: {
|
|
|
+ typeLabel(t) {
|
|
|
+ return { life_path: '生命灵数', talent: '天赋数', birthday: '生日数', destiny: '命运数' }[t] || t
|
|
|
+ },
|
|
|
+ load() {
|
|
|
+ this.$http.post('/api/admin/numsoul-detail-config/list', { numberType: this.filterType || undefined }).then(res => {
|
|
|
+ this.list = res.data || []
|
|
|
+ })
|
|
|
+ },
|
|
|
+ showCreate() {
|
|
|
+ this.form = { id: null, numberType: 'life_path', numberValue: 1, title: '', keywords: '', mindBase: 50, mindAdvice: '', colorHex: '#FF4444' }
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+ showEdit(row) {
|
|
|
+ this.form = Object.assign({}, row)
|
|
|
+ this.dialogVisible = true
|
|
|
+ },
|
|
|
+ save() {
|
|
|
+ const url = this.form.id ? '/api/admin/numsoul-detail-config/update' : '/api/admin/numsoul-detail-config/create'
|
|
|
+ this.$http.post(url, this.form).then(() => {
|
|
|
+ this.$message.success('保存成功')
|
|
|
+ this.dialogVisible = false
|
|
|
+ this.load()
|
|
|
+ })
|
|
|
+ },
|
|
|
+ remove(row) {
|
|
|
+ this.$confirm('确认删除该配置?', '提示', { type: 'warning' }).then(() => {
|
|
|
+ this.$http.post('/api/admin/numsoul-detail-config/delete', { id: row.id }).then(() => {
|
|
|
+ this.$message.success('删除成功')
|
|
|
+ this.load()
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:删除请求方式已确认**
|
|
|
+
|
|
|
+已确认现有项目约定:后端 `delete` 接口用 `@RequestParam Long id`,前端通过 `data: { id }`(body JSON)传参可正常工作(参照 `ZodiacConfigs.vue` 的 `deleteZodiacConfig` → `data: { id }`)。本计划任务 11 步骤 2 的 `remove()` 已按此约定编写,无需调整。
|
|
|
+
|
|
|
+- [ ] **步骤 4:注册路由**
|
|
|
+
|
|
|
+在 `cfc-web/src/router/` 中新增 2 条路由(参照现有 ZodiacConfigs 路由),菜单挂到"五维能量/先天画像"分组。
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/InnatePortraitConfig.vue cfc-web/src/views/admin/NumSoulDetailConfig.vue cfc-web/src/router/
|
|
|
+git commit -m "feat: 管理端先天画像配置页 + 数字能量配置页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 6:小程序
|
|
|
+
|
|
|
+### 任务 12:API 封装 + 页面注册
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js`
|
|
|
+- 修改:`cfc-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **步骤 1:api.js 新增接口**
|
|
|
+
|
|
|
+在 `cfc-frontend/utils/api.js` 中,参照现有 `traditionalMirror` 等方法新增:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 先天画像
|
|
|
+export function getInnatePortrait(data) { return request('/api/mind/innate/portrait', data) }
|
|
|
+export function getInnateReading(data) { return request('/api/mind/innate/reading', data) }
|
|
|
+export function getInnateNumsoul(data) { return request('/api/mind/innate/numsoul', data) }
|
|
|
+export function getInnateTrajectory(data) { return request('/api/mind/innate/trajectory', data) }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:pages.json 注册 2 个新页面**
|
|
|
+
|
|
|
+在 `pages/mind-detail` 的 pages 数组末尾追加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "innate-portrait",
|
|
|
+ "style": { "navigationBarTitleText": "先天画像" }
|
|
|
+},
|
|
|
+{
|
|
|
+ "path": "innate-trajectory",
|
|
|
+ "style": { "navigationBarTitleText": "先天成长轨迹" }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js cfc-frontend/pages.json
|
|
|
+git commit -m "feat: 小程序先天画像API封装+页面注册"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 13:先天画像报告页 innate-portrait.vue
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/mind-detail/innate-portrait.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:实现页面**
|
|
|
+
|
|
|
+参照 `traditional-mirror.vue` 的现有样式,遵守小程序限制(无 `?.`、无 Grid、`:key` 用方法、`parseDate()`):
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="portrait-container">
|
|
|
+ <view class="loading-state" v-if="loading">
|
|
|
+ <text>解读生成中...</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <template v-if="!loading && report">
|
|
|
+ <!-- 头部:心先天分 -->
|
|
|
+ <view class="portrait-header">
|
|
|
+ <text class="header-score">{{ report.portrait.mindBaseScore || 0 }}</text>
|
|
|
+ <text class="header-label">心维度先天能量</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 先天画像 -->
|
|
|
+ <view class="section">
|
|
|
+ <view class="section-title">先天画像</view>
|
|
|
+ <view class="bazi-grid" v-if="hasEightChars">
|
|
|
+ <view class="bazi-column" v-for="(pillar, key) in report.portrait.eightCharacters" :key="key">
|
|
|
+ <text class="bazi-label">{{ pillarLabel(key) }}</text>
|
|
|
+ <text class="bazi-value">{{ pillar }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="info-row" v-if="report.portrait.zodiac">
|
|
|
+ <text class="info-label">生肖</text>
|
|
|
+ <text class="info-value">{{ report.portrait.zodiac }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="info-row" v-if="report.portrait.bloodType">
|
|
|
+ <text class="info-label">血型</text>
|
|
|
+ <text class="info-value">{{ report.portrait.bloodType }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="info-row" v-if="numSoulData">
|
|
|
+ <text class="info-label">生命灵数</text>
|
|
|
+ <text class="info-value">{{ numSoulData.lifePath }} {{ numSoulData.lifePathTitle || '' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- AI 解读 -->
|
|
|
+ <view class="section" v-if="report.aiReading">
|
|
|
+ <view class="section-title">
|
|
|
+ <text>AI 解读</text>
|
|
|
+ <text class="fallback-tag" v-if="report.fallbackUsed">模板解读</text>
|
|
|
+ </view>
|
|
|
+ <text class="reading-text">{{ report.aiReading }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 五行平衡 -->
|
|
|
+ <view class="section" v-if="hasWuxing">
|
|
|
+ <view class="section-title">五行能量</view>
|
|
|
+ <view class="wuxing-item" v-for="(wv, wk) in report.portrait.wuxingElements" :key="wk">
|
|
|
+ <text class="wuxing-label">{{ wuxingLabel(wk) }}</text>
|
|
|
+ <view class="wuxing-track">
|
|
|
+ <view class="wuxing-fill" :style="{ width: wv + '%', background: wuxingColor(wk) }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="wuxing-value">{{ wv }}%</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 成长轨迹入口 -->
|
|
|
+ <view class="trajectory-entry" @click="goTrajectory">
|
|
|
+ <text>查看先天+后天成长轨迹</text>
|
|
|
+ <text class="arrow">›</text>
|
|
|
+ </view>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <view class="empty-state" v-if="!loading && !report && loadError">
|
|
|
+ <text>{{ loadError }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getInnateReading } from '../../utils/api'
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ memberId: null,
|
|
|
+ memberType: 'child',
|
|
|
+ familyId: null,
|
|
|
+ loading: false,
|
|
|
+ report: null,
|
|
|
+ loadError: '',
|
|
|
+ wuxingConfig: {
|
|
|
+ wood: { label: '木', color: '#10B981' },
|
|
|
+ fire: { label: '火', color: '#FF6B9D' },
|
|
|
+ earth: { label: '土', color: '#FF8C42' },
|
|
|
+ metal: { label: '金', color: '#6366F1' },
|
|
|
+ water: { label: '水', color: '#3B82F6' }
|
|
|
+ },
|
|
|
+ pillarLabels: { year: '年柱', month: '月柱', day: '日柱', hour: '时柱' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ numSoulData() {
|
|
|
+ if (!this.report || !this.report.portrait || !this.report.portrait.numSoul) return null
|
|
|
+ return this.report.portrait.numSoul
|
|
|
+ },
|
|
|
+ hasEightChars() {
|
|
|
+ return !!this.report && !!this.report.portrait && !!this.report.portrait.eightCharacters
|
|
|
+ },
|
|
|
+ hasWuxing() {
|
|
|
+ return !!this.report && !!this.report.portrait && !!this.report.portrait.wuxingElements
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.memberId = options.memberId ? Number(options.memberId) : null
|
|
|
+ this.memberType = options.memberType || 'child'
|
|
|
+ this.familyId = options.familyId ? Number(options.familyId) : null
|
|
|
+ if (!this.memberId) {
|
|
|
+ this.loadError = '缺少成员ID'
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ load() {
|
|
|
+ var self = this
|
|
|
+ self.loading = true
|
|
|
+ getInnateReading({ memberId: self.memberId, memberType: self.memberType, familyId: self.familyId }).then(function (res) {
|
|
|
+ self.loading = false
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ self.report = res.data
|
|
|
+ } else {
|
|
|
+ self.loadError = (res && res.message) || '加载失败'
|
|
|
+ }
|
|
|
+ }).catch(function () {
|
|
|
+ self.loading = false
|
|
|
+ self.loadError = '网络异常'
|
|
|
+ })
|
|
|
+ },
|
|
|
+ pillarLabel(key) {
|
|
|
+ return this.pillarLabels[key] || key
|
|
|
+ },
|
|
|
+ wuxingLabel(key) {
|
|
|
+ var cfg = this.wuxingConfig[key]
|
|
|
+ return cfg ? cfg.label : key
|
|
|
+ },
|
|
|
+ wuxingColor(key) {
|
|
|
+ var cfg = this.wuxingConfig[key]
|
|
|
+ return cfg ? cfg.color : '#ccc'
|
|
|
+ },
|
|
|
+ goTrajectory() {
|
|
|
+ uni.navigateTo({
|
|
|
+ url: '/pages/mind-detail/innate-trajectory?memberId=' + this.memberId + '&memberType=' + this.memberType + '&familyId=' + (this.familyId || '')
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style>
|
|
|
+/* 参照 traditional-mirror.vue 现有样式体系,flexbox 布局,禁止 Grid */
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:确认 onLoad 参数来源**
|
|
|
+
|
|
|
+在 `pages/mind-detail/index.vue` 的"先天画像"入口卡片跳转时传 `memberId/memberType/familyId`(任务 14 实现),此处确保从 `options` 读取。
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind-detail/innate-portrait.vue
|
|
|
+git commit -m "feat: 小程序先天画像报告页"
|
|
|
+```
|
|
|
+
|
|
|
+### 任务 14:成长轨迹页 + 心维度首页增强
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/mind-detail/innate-trajectory.vue`
|
|
|
+- 修改:`cfc-frontend/pages/mind-detail/index.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 innate-trajectory.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="trajectory-container">
|
|
|
+ <view class="loading-state" v-if="loading">
|
|
|
+ <text>加载中...</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <template v-if="!loading && trajectory">
|
|
|
+ <!-- 先天 vs 后天 -->
|
|
|
+ <view class="section">
|
|
|
+ <view class="section-title">先天 vs 后天</view>
|
|
|
+ <view class="compare-row">
|
|
|
+ <view class="compare-item">
|
|
|
+ <text class="compare-label">先天心能量</text>
|
|
|
+ <text class="compare-value">{{ trajectory.innateVsCurrent.mindInnate || 0 }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="compare-item">
|
|
|
+ <text class="compare-label">当前心能量</text>
|
|
|
+ <text class="compare-value">{{ trajectory.innateVsCurrent.mindCurrent || 0 }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="compare-item">
|
|
|
+ <text class="compare-label">成长</text>
|
|
|
+ <text class="compare-value" :style="{ color: growthColor }">{{ trajectory.innateVsCurrent.growth || '0' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 五维先天分 -->
|
|
|
+ <view class="section" v-if="hasInnateScores">
|
|
|
+ <view class="section-title">五维先天基础分</view>
|
|
|
+ <view class="dim-row" v-for="(score, dim) in trajectory.innateDimensionScores" :key="dim">
|
|
|
+ <text class="dim-label">{{ dimLabel(dim) }}</text>
|
|
|
+ <view class="dim-track">
|
|
|
+ <view class="dim-fill" :style="{ width: score + '%' }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="dim-value">{{ score }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 成长趋势 -->
|
|
|
+ <view class="section" v-if="trajectory.growthTrend">
|
|
|
+ <view class="section-title">成长趋势</view>
|
|
|
+ <text class="trend-text">{{ trajectory.growthTrend }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 成长建议 -->
|
|
|
+ <view class="section" v-if="trajectory.advice">
|
|
|
+ <view class="section-title">成长建议</view>
|
|
|
+ <text class="advice-text">{{ trajectory.advice }}</text>
|
|
|
+ </view>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <view class="empty-state" v-if="!loading && !trajectory && loadError">
|
|
|
+ <text>{{ loadError }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getInnateTrajectory } from '../../utils/api'
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ memberId: null,
|
|
|
+ memberType: 'child',
|
|
|
+ familyId: null,
|
|
|
+ loading: false,
|
|
|
+ trajectory: null,
|
|
|
+ loadError: '',
|
|
|
+ dimNames: { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ hasInnateScores() {
|
|
|
+ return !!this.trajectory && !!this.trajectory.innateDimensionScores
|
|
|
+ },
|
|
|
+ growthColor() {
|
|
|
+ if (!this.trajectory || !this.trajectory.innateVsCurrent) return '#999'
|
|
|
+ var g = this.trajectory.innateVsCurrent.growth || '0'
|
|
|
+ if (g.indexOf('-') === 0) return '#f56c6c'
|
|
|
+ return '#07c160'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.memberId = options.memberId ? Number(options.memberId) : null
|
|
|
+ this.memberType = options.memberType || 'child'
|
|
|
+ this.familyId = options.familyId ? Number(options.familyId) : null
|
|
|
+ if (!this.memberId) {
|
|
|
+ this.loadError = '缺少成员ID'
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ load() {
|
|
|
+ var self = this
|
|
|
+ self.loading = true
|
|
|
+ getInnateTrajectory({ memberId: self.memberId, memberType: self.memberType, familyId: self.familyId }).then(function (res) {
|
|
|
+ self.loading = false
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ self.trajectory = res.data
|
|
|
+ } else {
|
|
|
+ self.loadError = (res && res.message) || '加载失败'
|
|
|
+ }
|
|
|
+ }).catch(function () {
|
|
|
+ self.loading = false
|
|
|
+ self.loadError = '网络异常'
|
|
|
+ })
|
|
|
+ },
|
|
|
+ dimLabel(dim) {
|
|
|
+ return this.dimNames[dim] || dim
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:心维度首页增加"先天画像"入口卡片**
|
|
|
+
|
|
|
+在 `pages/mind-detail/index.vue` 的 `funcList` 数组(现有"家庭天盘"附近)新增:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{ icon: '\u{1F31F}', label: '先天画像', needLogin: true, page: '/pages/mind-detail/innate-portrait' },
|
|
|
+```
|
|
|
+
|
|
|
+跳转时需传当前成员信息:找到 funcList 点击处理方法,跳转前追加 `memberId/memberType/familyId` 参数(参照现有"家庭天盘"的跳转方式从 `currentMember` 或 `familyId` data 读取)。
|
|
|
+
|
|
|
+- [ ] **步骤 3:verify 页面跳转参数**
|
|
|
+
|
|
|
+检查 `pages/mind-detail/index.vue` 中现有哪些 `currentMember`/`familyId` data 字段可用,确保跳转 innate-portrait 时能取到 memberId。
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind-detail/innate-trajectory.vue cfc-frontend/pages/mind-detail/index.vue
|
|
|
+git commit -m "feat: 小程序先天成长轨迹页 + 心维度首页入口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 阶段 7:收尾验证
|
|
|
+
|
|
|
+### 任务 15:全量验证 + 文档更新
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端全量编译 + 测试**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile && mvn test -Dtest=NumSoulCalculatorTest`
|
|
|
+预期:BUILD SUCCESS,测试全绿
|
|
|
+
|
|
|
+- [ ] **步骤 2:路由查重**
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
|
|
|
+```
|
|
|
+预期:新接口 `/api/mind/innate/*` 与 `/api/admin/innate-portrait-config/*`、`/api/admin/numsoul-detail-config/*` 无重复
|
|
|
+
|
|
|
+- [ ] **步骤 3:Bean 命名冲突检查**
|
|
|
+
|
|
|
+新类名:`InnatePortraitService`、`InnatePortraitController`、`InnatePortraitConfig`(entity)、`InnatePortraitConfigController`、`InnatePortraitReportMapper`、`NumSoulDetailConfigController`——运行 `grep -rn "class InnatePortrait" cfc-backend/src/main/java/` 确认无重名类。
|
|
|
+
|
|
|
+- [ ] **步骤 4:Python 编译检查**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/innate_portrait_graph.py app/api/innate.py app/main.py`
|
|
|
+预期:无输出
|
|
|
+
|
|
|
+- [ ] **步骤 5:更新 API_REFERENCE.md**
|
|
|
+
|
|
|
+在 `docs/superpowers/api/API_REFERENCE.md` 的"其他接口"或"管理员接口"区段补充:
|
|
|
+- `POST /api/mind/innate/portrait` — 先天画像
|
|
|
+- `POST /api/mind/innate/reading` — AI 解读
|
|
|
+- `POST /api/mind/innate/numsoul` — 数字能量
|
|
|
+- `POST /api/mind/innate/trajectory` — 成长轨迹
|
|
|
+- `POST /api/admin/innate-portrait-config/*` — 先天画像配置
|
|
|
+- `POST /api/admin/numsoul-detail-config/*` — 数字能量配置
|
|
|
+
|
|
|
+- [ ] **步骤 6:更新 PROJECT-OVERVIEW.md**
|
|
|
+
|
|
|
+在 `docs/superpowers/PROJECT-OVERVIEW.md` 新增本设计 + 实现计划的文档索引条目(版本、状态、路径)。
|
|
|
+
|
|
|
+- [ ] **步骤 7:最终 Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -A
|
|
|
+git commit -m "docs: 先天画像体系实现完成 - 更新API文档与项目概览"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检记录
|
|
|
+
|
|
|
+**1. 规格覆盖度:**
|
|
|
+- ✔ 数据模型(3表)→ 任务 1-3
|
|
|
+- ✔ 后端服务层 → 任务 5-7
|
|
|
+- ✔ AI 解读 graph → 任务 8-9
|
|
|
+- ✔ 先天+后天打通(trajectory)→ 任务 5(getTrajectory)+ 任务 6(接口)+ 任务 14(页面)
|
|
|
+- ✔ 管理端可配置化 → 任务 10-11
|
|
|
+- ✔ 小程序展示 → 任务 12-14
|
|
|
+- ✔ 数据库迁移 267-269 → 任务 1
|
|
|
+- ✔ 测试策略 → 任务 4(NumSoulCalculator TDD)+ 任务 15(验证)
|
|
|
+
|
|
|
+**2. 占位符扫描:** 无"待定/TODO/后续实现";所有代码步骤含完整代码块。
|
|
|
+
|
|
|
+**3. 类型一致性:**
|
|
|
+- `InnatePortraitService.generateAiReading` 返回 `InnatePortraitReportVO`(portrait/aiReading/generatedAt/fromCache/fallbackUsed)— 前端 `innate-portrait.vue` 读取 `report.portrait`/`report.aiReading`/`report.fallbackUsed` ✔
|
|
|
+- `AiGateway.generateInnateReading(Object portrait)` 返回 `Map` 含 `reading` key — 服务层读取 `result.get("reading")` ✔
|
|
|
+- `NumSoulCalculator` 静态方法签名与 `InnatePortraitService.calculateNumSoul` 调用一致 ✔
|
|
|
+- `FamilyMemberAttributeService.calcInnateScore(memberId, memberType, dimensionCode)` 签名被复用 ✔
|
|
|
+- `InnateTrajectoryVO.growth` 前端读取 `innateVsCurrent.growth` ✔
|
|
|
+
|
|
|
+**已验证的实现细节(无需实现时再摸索):**
|
|
|
+- `AiGateway.generateInnateReading` 先加 stub(任务 5 步骤 1b),任务 8 完善为真实 HTTP 调用
|
|
|
+- `getTrajectory` 后天能量读取:注入 `EnergyBalanceMapper` + `EnergyDimensionMapper`,按 dimension code 匹配,已写死实现
|
|
|
+- `ParamUtils` 无 `getInt`——numsoul 接口用 `instanceof Number` 判断,已写死
|
|
|
+- 管理端删除请求:`data: { id }` body JSON + 后端 `@RequestParam Long id`(现有约定,已确认可工作)
|
|
|
+- `calcInnateScore` 缓存失效:继承 `ServiceImpl` 用 `this.list()` / `this.updateById()` 清除
|
|
|
+- `cfc-langgraph` 配置字段名 `llm_model` / `llm_api_key` / `llm_base_url` 已确认存在
|
|
|
+- `FamilyMemberAttributeService` 继承 `ServiceImpl<FamilyMemberAttributesMapper, FamilyMemberAttributes>`
|