2026-08-28-innate-portrait-mind-dimension.md 81 KB

先天画像体系实现计划

面向 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


文件结构

后端(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 之后)添加:

// ==================== 迁移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 后紧接添加:

// ==================== 迁移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 后紧接添加:

// ==================== 迁移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

    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

    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

    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

    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

    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

    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> {
    }
    
    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> {
    }
    
    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

    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

    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

    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

    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

    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:编写失败的测试

    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

    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

    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(聚合画像)

    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 类中临时添加:

/**
 * 先天画像 AI 解读(stub,任务8完善为真实HTTP调用)
 */
public Map<String, Object> generateInnateReading(Object portrait) {
    return null;
}
  • 步骤 2:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 3:Commit

    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

    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 用类型判断读取:

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:检查路由重复

运行:

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

    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 类中新增注入(现有字段附近):

@Resource
private com.etotem.cfc.mapper.InnatePortraitConfigMapper innatePortraitConfigMapper;

@Resource
private com.etotem.cfc.mapper.NumSoulDetailConfigMapper numSoulDetailConfigMapper;
  • 步骤 2:替换加权求和部分

将 calcInnateScore 中硬编码加权(原 293-296 行):

// --- 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;

替换为:

// --- 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 成功后调用新增方法:

/**
 * 权重配置变化时清除所有已缓存的先天分,触发重算
 * 说明:本服务继承 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

    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 之后)新增:

/**
 * 调用 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

    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

    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

    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 部分新增:

from app.api import innate

并在 app.include_router(...) 区段新增:

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

    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

    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

    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

运行:

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

    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 的表单+表格模式,核心结构:

<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

    <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

    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 等方法新增:

// 先天画像
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 数组末尾追加:

{
  "path": "innate-portrait",
  "style": { "navigationBarTitleText": "先天画像" }
},
{
  "path": "innate-trajectory",
  "style": { "navigationBarTitleText": "先天成长轨迹" }
}
  • [ ] 步骤 3:Commit

    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()):

<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

    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

    <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 数组(现有"家庭天盘"附近)新增:

{ 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

    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:路由查重

运行:

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

    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>