Sfoglia il codice sorgente

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 3 settimane fa
parent
commit
5b2104df43

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.AdminRoles;
 import com.etotem.cfc.common.Result;
@@ -1339,6 +1340,104 @@ public class AdminController {
         return Result.success();
     }
 
+    /**
+     * 会员维护:按用户ID修改会员等级和推荐人
+     * body: { userId, memberLevel?, referrerId? }
+     */
+    @Operation(summary = "会员维护:修改会员等级与推荐人")
+    @PostMapping("/membership/user-update")
+    public Result<String> updateMembershipUser(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long adminUserId,
+                                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        if (params.get("userId") == null) {
+            return Result.error("参数缺失: userId");
+        }
+        Long targetUserId = ((Number) params.get("userId")).longValue();
+        User user = userMapper.selectById(targetUserId);
+        if (user == null) {
+            return Result.error("用户不存在");
+        }
+
+        StringBuilder changes = new StringBuilder();
+        String oldLevel = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
+        String newLevel = null;
+        LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(User::getId, targetUserId);
+
+        if (params.get("referrerId") != null) {
+            String raw = String.valueOf(params.get("referrerId"));
+            if (raw.trim().isEmpty()) {
+                if (user.getReferrerId() != null) {
+                    wrapper.set(User::getReferrerId, null);
+                    changes.append("清除推荐人; ");
+                }
+            } else {
+                try {
+                    Long referrerId = Long.parseLong(raw.trim());
+                    if (referrerId.equals(user.getId())) {
+                        return Result.error("推荐人不能是用户本人");
+                    }
+                    wrapper.set(User::getReferrerId, referrerId);
+                    changes.append("推荐人=").append(referrerId).append("; ");
+                } catch (NumberFormatException e) {
+                    return Result.error("推荐人ID格式不正确");
+                }
+            }
+        }
+
+        if (params.get("memberLevel") != null) {
+            String level = String.valueOf(params.get("memberLevel")).trim().toUpperCase();
+            if (level.isEmpty()) {
+                return Result.error("memberLevel 不能为空");
+            }
+            java.util.List<MembershipLevel> levels = membershipLevelMapper.selectList(null);
+            boolean valid = false;
+            for (MembershipLevel l : levels) {
+                if (l.getLevelCode() != null && l.getLevelCode().equalsIgnoreCase(level)) {
+                    valid = true;
+                    break;
+                }
+            }
+            if (!valid) {
+                return Result.error("无效的会员等级: " + level);
+            }
+            newLevel = level;
+            wrapper.set(User::getMemberLevel, newLevel);
+            if ("LIFETIME".equals(newLevel)) {
+                wrapper.set(User::getMemberExpireTime, null);
+            }
+            if (!oldLevel.equals(newLevel)) {
+                changes.append("等级=").append(oldLevel).append("->").append(newLevel).append("; ");
+            }
+        }
+
+        if (changes.length() > 0) {
+            wrapper.set(User::getUpdatedAt, new Date());
+            userMapper.update(null, wrapper);
+
+            if (newLevel != null && !oldLevel.equals(newLevel)) {
+                MemberUpgradeRecord record = new MemberUpgradeRecord();
+                record.setUserId(targetUserId);
+                record.setFromLevel(oldLevel);
+                record.setToLevel(newLevel);
+                record.setUpgradeType("admin");
+                record.setApproveUserId(adminUserId);
+                record.setApproveStatus("approved");
+                record.setApproveTime(new Date());
+                record.setApproveRemark("管理员会员维护");
+                record.setCreatedAt(new Date());
+                memberUpgradeRecordMapper.insert(record);
+            }
+        }
+
+        return Result.success(changes.length() > 0
+                ? "更新成功: " + changes.toString()
+                : "未做任何修改");
+    }
+
     // ========== 测评待分配规划师 ==========
 
     @PostMapping("/assessment/pending-list")

+ 27 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java

@@ -5,7 +5,9 @@ import com.etotem.cfc.dto.SelfCheckQuestionVO;
 import com.etotem.cfc.dto.SelfCheckResultVO;
 import com.etotem.cfc.dto.SubmitSelfCheckDTO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -39,6 +41,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     private FiveDimensionSelfCheckMapper selfCheckMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private WuxingSourcingService wuxingSourcingService;
 
@@ -268,7 +273,7 @@ public class FiveDimensionSelfCheckService {
         // 保存到现有记录(复用 scoresJson,以 sub_ 前缀区分)
         FiveDimensionSelfCheck record = new FiveDimensionSelfCheck();
         record.setUserId(userId);
-        record.setFamilyMemberId(dto.getFamilyMemberId());
+        record.setFamilyMemberId(resolveFamilyMemberId(dto.getFamilyMemberId(), userId));
         record.setAnswersJson(toJson(dto.getAnswers()));
         // 合并到现有 scoresJson(保留 dimension 级 key + 新增 sub_ 前缀 key)
         try {
@@ -384,7 +389,7 @@ public class FiveDimensionSelfCheckService {
         // 保存记录
         FiveDimensionSelfCheck record = new FiveDimensionSelfCheck();
         record.setUserId(userId);
-        record.setFamilyMemberId(dto.getFamilyMemberId());
+        record.setFamilyMemberId(resolveFamilyMemberId(dto.getFamilyMemberId(), userId));
         record.setAnswersJson(toJson(dto.getAnswers()));
         record.setScoresJson(toJson(scoreMap));
         record.setTotalScore(totalScore);
@@ -474,6 +479,26 @@ public class FiveDimensionSelfCheckService {
         return 0;
     }
 
+    /**
+     * 解析自检关联的家庭成员ID。
+     * familyMemberId 未传时默认解析为当前用户本人对应的家庭成员(注册时已自动创建),
+     * 保证前端"加入今日微行动"等后续联动能拿到有效的 memberId。
+     */
+    private Long resolveFamilyMemberId(Long memberId, Long userId) {
+        if (memberId != null) {
+            return memberId;
+        }
+        if (userId == null) {
+            return null;
+        }
+        FamilyMember own = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1")
+        );
+        return own != null ? own.getId() : null;
+    }
+
     private String toJson(Object obj) {
         try {
             return objectMapper.writeValueAsString(obj);

+ 130 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FiveDimensionSelfCheckServiceTest.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.SelfCheckResultVO;
+import com.etotem.cfc.dto.SubmitSelfCheckDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * FiveDimensionSelfCheckService 纯单元测试(无 SpringBootTest,避免 MySQL 连接)
+ *
+ * 测试范围:
+ * - submitSelfCheck: familyMemberId 未传时自动解析为当前用户本人对应的家庭成员(Bug 修复回归)
+ * - submitSelfCheck: familyMemberId 已传时直接使用,不覆盖
+ */
+public class FiveDimensionSelfCheckServiceTest {
+
+    @Mock
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Mock
+    private WuxingSourcingService wuxingSourcingService;
+
+    @Mock
+    private OnboardingService onboardingService;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    private FiveDimensionSelfCheckService service;
+
+    @BeforeEach
+    public void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new FiveDimensionSelfCheckService();
+        setField(service, "selfCheckMapper", selfCheckMapper);
+        setField(service, "wuxingSourcingService", wuxingSourcingService);
+        setField(service, "onboardingService", onboardingService);
+        setField(service, "familyMemberMapper", familyMemberMapper);
+
+        // 默认 mock:全部维度健康,无寻源建议
+        when(wuxingSourcingService.getAdvicesForLowScores(anyMap())).thenReturn(new ArrayList<>());
+        when(wuxingSourcingService.levelOf(anyInt())).thenReturn("healthy");
+        when(wuxingSourcingService.levelName(anyString())).thenReturn("健康");
+    }
+
+    @Test
+    public void submitSelfCheck_resolvesOwnFamilyMemberId_whenNotProvided() throws Exception {
+        Long userId = 7L;
+
+        // 当前用户本人对应一个家庭成员(注册时自动创建)
+        FamilyMember ownMember = new FamilyMember();
+        ownMember.setId(100L);
+        ownMember.setUserId(userId);
+        when(familyMemberMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(ownMember);
+
+        // 捕获 insert 的记录并赋予 id(toVO 读取)
+        ArgumentCaptor<FiveDimensionSelfCheck> captor = ArgumentCaptor.forClass(FiveDimensionSelfCheck.class);
+        doAnswer(inv -> {
+            FiveDimensionSelfCheck r = inv.getArgument(0);
+            r.setId(5L);
+            return 1;
+        }).when(selfCheckMapper).insert(captor.capture());
+
+        SubmitSelfCheckDTO dto = new SubmitSelfCheckDTO();
+        dto.setAnswers(fullAnswers());
+
+        SelfCheckResultVO vo = service.submitSelfCheck(dto, userId);
+
+        // 返回给前端的 VO 应带上解析出的家庭成员ID
+        assertNotNull(vo.getFamilyMemberId(), "未传 familyMemberId 时应自动解析为当前用户本人的家庭成员");
+        assertEquals(100L, vo.getFamilyMemberId().longValue());
+        // 落库记录也应带解析出的家庭成员ID
+        assertEquals(100L, captor.getValue().getFamilyMemberId().longValue());
+    }
+
+    @Test
+    public void submitSelfCheck_keepsProvidedFamilyMemberId() throws Exception {
+        Long userId = 7L;
+
+        ArgumentCaptor<FiveDimensionSelfCheck> captor = ArgumentCaptor.forClass(FiveDimensionSelfCheck.class);
+        doAnswer(inv -> {
+            FiveDimensionSelfCheck r = inv.getArgument(0);
+            r.setId(6L);
+            return 1;
+        }).when(selfCheckMapper).insert(captor.capture());
+
+        SubmitSelfCheckDTO dto = new SubmitSelfCheckDTO();
+        dto.setFamilyMemberId(200L);
+        dto.setAnswers(fullAnswers());
+
+        SelfCheckResultVO vo = service.submitSelfCheck(dto, userId);
+
+        assertEquals(200L, vo.getFamilyMemberId().longValue());
+        assertEquals(200L, captor.getValue().getFamilyMemberId().longValue());
+        // 未触发家庭成员查询
+        verify(familyMemberMapper, never()).selectOne(any(LambdaQueryWrapper.class));
+    }
+
+    private List<SubmitSelfCheckDTO.AnswerItem> fullAnswers() {
+        List<SubmitSelfCheckDTO.AnswerItem> answers = new ArrayList<>();
+        for (int i = 1; i <= 15; i++) {
+            SubmitSelfCheckDTO.AnswerItem item = new SubmitSelfCheckDTO.AnswerItem();
+            item.setQuestionId(i);
+            item.setAnswer("A");
+            answers.add(item);
+        }
+        return answers;
+    }
+
+    private static void setField(Object target, String fieldName, Object value) throws Exception {
+        java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}

+ 14 - 1
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -314,6 +314,10 @@ export default {
     }
   },
   onLoad: function(options) {
+    // 兼容旧分享链接:inviteCode 参数(shareMixin onLoad 也会处理,此处显式兜底)
+    if (options && options.inviteCode) {
+      this.autoBindInviteCode(options.inviteCode)
+    }
     var aid = ''
     if (options && options.id) {
       aid = options.id
@@ -323,6 +327,8 @@ export default {
         var sceneDecoded = decodeURIComponent(options.scene)
         var idMatch = sceneDecoded.match(/id=(\d+)/)
         if (idMatch && idMatch[1]) aid = idMatch[1]
+        var refMatch = sceneDecoded.match(/ref=([^&]+)/)
+        if (refMatch && refMatch[1]) this.autoBindInviteCode(refMatch[1])
       } catch (e) {}
     }
     if (aid) {
@@ -663,7 +669,14 @@ export default {
       }
       uni.showLoading({ title: '生成海报中...' })
       try {
-        var res = await getShareQrCode('pages/activity/activity-detail/activity-detail', 'id=' + this.activity.id)
+        if (!this.referralCode) {
+          await this.loadReferralCode()
+        }
+        var scene = 'id=' + this.activity.id + (this.referralCode ? '&ref=' + this.referralCode : '')
+        if (scene.length > 32) {
+          scene = scene.substring(0, 32)
+        }
+        var res = await getShareQrCode('pages/activity/activity-detail/activity-detail', scene)
         if (res && res.data) {
           this.posterQrCode = res.data.qrCodeBase64 || ''
           this.posterSummary = this.buildPosterSummary()

+ 14 - 1
cfc-frontend/pages/article-center/article-detail.vue

@@ -105,6 +105,10 @@ export default {
   onLoad(options) {
     this.mascotIcon = this.mascotList[Math.floor(Math.random() * 2)].icon
     this.mascotName = this.mascotList[Math.floor(Math.random() * 2)].name
+    // 兼容旧分享链接:inviteCode 参数(shareMixin onLoad 也会处理,此处显式兜底)
+    if (options && options.inviteCode) {
+      this.autoBindInviteCode(options.inviteCode)
+    }
     var aid = ''
     if (options && options.id) {
       aid = options.id
@@ -113,6 +117,8 @@ export default {
         var sceneDecoded = decodeURIComponent(options.scene)
         var idMatch = sceneDecoded.match(/id=(\d+)/)
         if (idMatch && idMatch[1]) aid = idMatch[1]
+        var refMatch = sceneDecoded.match(/ref=([^&]+)/)
+        if (refMatch && refMatch[1]) this.autoBindInviteCode(refMatch[1])
       } catch (e) {}
     }
     if (aid) { this.articleId = aid; this.loadDetail(aid) }
@@ -219,7 +225,14 @@ export default {
       if (!this.articleId) return
       uni.showLoading({ title: '生成海报中...' })
       try {
-        var res = await getShareQrCode('pages/article-center/article-detail', 'id=' + this.articleId)
+        if (!this.referralCode) {
+          await this.loadReferralCode()
+        }
+        var scene = 'id=' + this.articleId + (this.referralCode ? '&ref=' + this.referralCode : '')
+        if (scene.length > 32) {
+          scene = scene.substring(0, 32)
+        }
+        var res = await getShareQrCode('pages/article-center/article-detail', scene)
         if (res && res.data) {
           this.posterQrCode = res.data.qrCodeBase64 || ''
           this.posterDimension = this.getMainDimension()

+ 4 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -283,6 +283,10 @@ export default {
     }
   },
   onLoad(options) {
+    // 兼容旧分享链接:inviteCode 参数(shareMixin onLoad 也会处理,此处显式兜底)
+    if (options && options.inviteCode) {
+      this.autoBindInviteCode(options.inviteCode)
+    }
     var pid = options && options.id ? options.id : ''
     if (options && options.scene) {
       try {

+ 61 - 0
cfc-frontend/pages/health/challenge-manage.vue

@@ -176,6 +176,25 @@
         </view>
       </view>
     </view>
+
+    <!-- 家庭成员不足提示弹窗 -->
+    <view class="overlay" v-if="showMemberPrompt" @click="showMemberPrompt = false">
+      <view class="modal prompt-modal" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">无法创建挑战</text>
+          <text class="modal-close" @click="showMemberPrompt = false">×</text>
+        </view>
+        <view class="prompt-content">
+          <text class="prompt-icon">👨‍👩‍👧‍👦</text>
+          <text class="prompt-title">家庭成员不足</text>
+          <text class="prompt-desc">家庭挑战至少需要 2 名成员参与,请先添加家庭成员</text>
+        </view>
+        <view class="modal-footer">
+          <button class="btn-cancel" @click="showMemberPrompt = false">暂不添加</button>
+          <button class="btn-submit" @click="goToAddMember">去添加成员</button>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -192,6 +211,7 @@ export default {
       familyMembers: [],
       selectedMemberIds: [],
       showModal: false,
+      showMemberPrompt: false,
       editMode: false,
       editingId: null,
       form: {
@@ -292,12 +312,17 @@ export default {
       }
     },
     openCreateModal() {
+      if (this.familyMembers.length < 2) {
+        this.showMemberPrompt = true
+        return
+      }
       this.editMode = false
       this.editingId = null
       this.form = {
         title: '', description: '', challengeType: 'custom',
         targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50'
       }
+      this.selectedMemberIds = this.familyMembers.map(m => m.id)
       this.showModal = true
     },
     openEditModal(ch) {
@@ -327,6 +352,10 @@ export default {
         uni.showToast({ title: '目标值必须大于0', icon: 'none' })
         return
       }
+      if (!this.editMode && this.selectedMemberIds.length < 2) {
+        uni.showToast({ title: '家庭挑战至少需要选择2名成员参与', icon: 'none' })
+        return
+      }
       var data = {
         title: this.form.title.trim(),
         description: this.form.description.trim(),
@@ -381,6 +410,10 @@ export default {
     viewChallenge(ch) {
       // 跳转到挑战详情
       uni.navigateTo({ url: '/pages/activity/index' })
+    },
+    goToAddMember() {
+      this.showMemberPrompt = false
+      uni.navigateTo({ url: '/pages/profile-extra/family-members' })
     }
   }
 }
@@ -763,4 +796,32 @@ export default {
   background: linear-gradient(135deg, #10B981, #34D399);
   color: #fff;
 }
+
+/* 家庭成员不足提示弹窗 */
+.prompt-modal {
+  max-height: 60vh;
+  padding: 32rpx 28rpx 40rpx;
+}
+.prompt-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 24rpx 0 16rpx;
+}
+.prompt-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.prompt-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+  margin-bottom: 12rpx;
+}
+.prompt-desc {
+  font-size: 26rpx;
+  color: #64748B;
+  text-align: center;
+  line-height: 1.6;
+}
 </style>

+ 26 - 11
cfc-frontend/pages/invite/join.vue

@@ -173,7 +173,17 @@ export default {
     }
   },
   onLoad(query) {
-    // token:优先从 query 参数,其次从场景值
+    // 邀请码(推荐人)统一解析:query.inviteCode / query.refCode / 场景值 ref=xxx
+    // 分享链接用 inviteCode,落地页内部用 refCode,二维码场景值用 ref= 前缀
+    var refCode = query.refCode || query.inviteCode || ''
+    if (!refCode && query.scene) {
+      var refScene = decodeURIComponent(query.scene)
+      if (refScene.indexOf('ref=') === 0) {
+        refCode = refScene.substring(4)
+      }
+    }
+
+    // token(家庭邀请):优先从 query 参数,其次从场景值
     var token = query.token || query.inviteToken || ''
     if (!token) {
       var scene = query.scene
@@ -190,23 +200,24 @@ export default {
         } else if (sceneDecoded.indexOf('invite=') === 0) {
           // 旧格式兼容
           token = sceneDecoded.substring(7)
+        } else if (sceneDecoded.indexOf('ref=') === 0) {
+          // 推广码场景值(ref=xxx),已在上方解析为 refCode,不作为 token
+          token = ''
+        } else if (sceneDecoded.length === 6) {
+          // 裸邀请码场景值(InviteController 生成的二维码 scene=referralCode,6位)
+          // 归为推广码,不作为家庭 token
+          refCode = refCode || sceneDecoded
+          token = ''
         } else {
           token = sceneDecoded
         }
       }
     }
 
-    // refCode:query.refCode 或场景值(ref=xxx)— 推广码注册绑定
-    var refCode = query.refCode || ''
-    if (!refCode && query.scene) {
-      var refScene = decodeURIComponent(query.scene)
-      if (refScene.indexOf('ref=') === 0) {
-        refCode = refScene.substring(4)
-      }
-    }
     if (!token && !refCode) {
-      this.error = '邀请链接无效'
-      this.loading = false
+      // 兼容旧分享链接(promotion/index.vue 旧版 onShareAppMessage 不带参数)
+      // 无有效邀请信息时跳转首页,不显示"邀请链接无效"死页面
+      uni.reLaunch({ url: '/pages/index-home/index' })
       return
     }
     if (refCode) {
@@ -327,6 +338,10 @@ export default {
 
           this.isLoggedIn = true
           uni.showToast({ title: '登录成功', icon: 'success' })
+          // 兼容旧分享链接:登录成功后自动绑定推广码(与 login.vue 流程一致)
+          if (this.refCode && !this.bindDone) {
+            await this.bindRef()
+          }
           // 重新检查家庭状态
           await this.checkFamily()
         }

+ 14 - 8
cfc-frontend/pages/promotion/index.vue

@@ -264,6 +264,7 @@ export default {
   },
   onLoad() {
     this.loadData()
+    this.ensureShareCode()
   },
   onShow() {
     this.loadStats()
@@ -272,6 +273,9 @@ export default {
   },
   onShareAppMessage() {
     var path = '/pages/invite/join'
+    if (this.shareReferralCode) {
+      path = path + '?inviteCode=' + this.shareReferralCode
+    }
     return {
       title: '我在浠艾福发现了好用的家庭成长工具,快来加入!',
       path: path,
@@ -279,6 +283,15 @@ export default {
     }
   },
   methods: {
+    async ensureShareCode() {
+      if (this.shareReferralCode) return
+      try {
+        var codeRes = await getReferralCode()
+        if (codeRes && codeRes.data) {
+          this.shareReferralCode = codeRes.data.referralCode || codeRes.data.code || ''
+        }
+      } catch (e) {}
+    },
     async loadData() {
       try {
         var summaryRes = await getReferralSummary()
@@ -485,14 +498,7 @@ export default {
     async shareToFriend() {
       uni.showLoading({ title: '生成中...' })
       try {
-        if (!this.shareReferralCode) {
-          try {
-            var codeRes = await getReferralCode()
-            if (codeRes && codeRes.data) {
-              this.shareReferralCode = codeRes.data.referralCode || codeRes.data.code || ''
-            }
-          } catch (e) {}
-        }
+        await this.ensureShareCode()
         var qrRes = await getInviteQrCode()
         if (qrRes && qrRes.data) {
           this.inviteQrCode = qrRes.data.qrCodeBase64 || ''

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-3c072274d4797d6768b18ac16561d1ffc5d2598f
+f812fcc244d22581b411f7e70048d77d708cc655

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1236",
+  "version": "1.0.1239",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1236",
+      "version": "1.0.1239",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1237",
+  "version": "1.0.1240",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 55 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,61 @@
 
 ---
 
+## v1.0.1240 (2026-08-29)
+
+### 新功能
+- 优化家庭挑战创建流程,增加成员数量前置校验和友好提示
+
+### 其他
+- - submitForm 新增客户端校验:选择成员 < 2 时阻止提交并提示友好信息
+- - 新增 goToAddMember 方法:引导用户前往家庭成员页添加成员
+- - 避免后端 500 错误'家庭挑战至少需要2名成员参与'直接暴露给用户
+- 
+
+
+## v1.0.1239 (2026-08-29)
+
+### Bug 修复
+- 统一文章/商品/活动推广分享邀请人参数 + 兼容旧分享链接
+- 五维自检未传familyMemberId时自动解析为当前用户本人
+
+### 其他
+- - 三个详情页: onLoad 显式处理 inviteCode + 海报二维码 scene 带 &ref= 邀请码
+- - promotion/index.vue: onShareAppMessage 带上 inviteCode=分享码(与 invite.vue 一致)
+- 
+-   按 userId 修改 memberLevel(含 LIFETIME 置空到期时间)与 referrerId,
+-   等级变化写入 MemberUpgradeRecord(upgradeType=admin)
+- - 用 LambdaUpdateWrapper 显式 SET,修复 referrerId 清除(null)不生效的 bug
+- - MembershipCenter.vue 新增「会员维护」卡片:搜索用户 → 修改等级/推荐人
+- - 修复会员中心打不开:loadLevels/loadUpgradeRecords 过滤 null 元素,
+-   所有表格 scoped slot 加空值保护(row 为 null 时不再抛 TypeError)
+- 
+- 根因:submitSelfCheck/submitSubDimensionCheck 直接存储 dto.familyMemberId,
+- 前端提交问卷时不传该字段,导致自检记录与返回VO的 familyMemberId 为 null,
+- 前端 joinMicroAction 无法拿到 memberId。
+- 修复:familyMemberId 为空时,按 userId 自动解析用户本人对应的家庭成员(注册时已自动创建)。
+- 
+
+### 新功能
+- 后台会员维护功能 - 修改会员等级/推荐人 + 修复会员中心打不开
+
+### 文档
+- 先天画像体系实现计划(7阶段15任务,含数字能量TDD与权重替换改造)
+- 修复先天画像设计规格5处缺陷(复用calcInnateScore/数字能量mind_base/权重替换/报告表职责/配置表关系)
+- 星座/八字/数字能量引入心维度先天画像体系设计
+
+
+## v1.0.1238 (2026-08-28)
+
+### Bug 修复
+- 恢复健康方案组件原貌,移除远程误加的FamilyMemberStrip成员条
+
+### 其他
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
 ## v1.0.1237 (2026-08-28)
 
 ### Bug 修复

+ 56 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1237
+> 当前版本: v1.0.1240
 
 ## 历史版本
 
@@ -8,6 +8,61 @@
 
 ---
 
+## v1.0.1240 (2026-08-29)
+
+### 新功能
+- 优化家庭挑战创建流程,增加成员数量前置校验和友好提示
+
+### 其他
+- - submitForm 新增客户端校验:选择成员 < 2 时阻止提交并提示友好信息
+- - 新增 goToAddMember 方法:引导用户前往家庭成员页添加成员
+- - 避免后端 500 错误'家庭挑战至少需要2名成员参与'直接暴露给用户
+- 
+
+
+## v1.0.1239 (2026-08-29)
+
+### Bug 修复
+- 统一文章/商品/活动推广分享邀请人参数 + 兼容旧分享链接
+- 五维自检未传familyMemberId时自动解析为当前用户本人
+
+### 其他
+- - 三个详情页: onLoad 显式处理 inviteCode + 海报二维码 scene 带 &ref= 邀请码
+- - promotion/index.vue: onShareAppMessage 带上 inviteCode=分享码(与 invite.vue 一致)
+- 
+-   按 userId 修改 memberLevel(含 LIFETIME 置空到期时间)与 referrerId,
+-   等级变化写入 MemberUpgradeRecord(upgradeType=admin)
+- - 用 LambdaUpdateWrapper 显式 SET,修复 referrerId 清除(null)不生效的 bug
+- - MembershipCenter.vue 新增「会员维护」卡片:搜索用户 → 修改等级/推荐人
+- - 修复会员中心打不开:loadLevels/loadUpgradeRecords 过滤 null 元素,
+-   所有表格 scoped slot 加空值保护(row 为 null 时不再抛 TypeError)
+- 
+- 根因:submitSelfCheck/submitSubDimensionCheck 直接存储 dto.familyMemberId,
+- 前端提交问卷时不传该字段,导致自检记录与返回VO的 familyMemberId 为 null,
+- 前端 joinMicroAction 无法拿到 memberId。
+- 修复:familyMemberId 为空时,按 userId 自动解析用户本人对应的家庭成员(注册时已自动创建)。
+- 
+
+### 新功能
+- 后台会员维护功能 - 修改会员等级/推荐人 + 修复会员中心打不开
+
+### 文档
+- 先天画像体系实现计划(7阶段15任务,含数字能量TDD与权重替换改造)
+- 修复先天画像设计规格5处缺陷(复用calcInnateScore/数字能量mind_base/权重替换/报告表职责/配置表关系)
+- 星座/八字/数字能量引入心维度先天画像体系设计
+
+
+## v1.0.1238 (2026-08-28)
+
+### Bug 修复
+- 恢复健康方案组件原貌,移除远程误加的FamilyMemberStrip成员条
+
+### 其他
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
 ## v1.0.1237 (2026-08-28)
 
 ### Bug 修复

+ 8 - 0
cfc-web/src/api/admin.js

@@ -716,6 +716,14 @@ export function updateMembershipLevel(data) {
   })
 }
 
+export function updateMembershipUser(data) {
+  return request({
+    url: '/api/admin/membership/user-update',
+    method: 'post',
+    data
+  })
+}
+
 // ========== 分类管理 API ==========
 
 export function getCategoryTree(includeDisabled) {

+ 153 - 18
cfc-web/src/views/admin/MembershipCenter.vue

@@ -42,36 +42,36 @@
       <el-table :max-height="tableHeight" :data="levels" v-loading="levelsLoading" border stripe>
         <el-table-column prop="levelCode" label="等级编码" width="120">
           <template slot-scope="{ row }">
-            <el-tag :type="levelTagType(row.levelCode)" size="mini">{{ row.levelCode }}</el-tag>
+            <el-tag v-if="row" :type="levelTagType(row.levelCode)" size="mini">{{ row.levelCode }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="levelName" label="等级名称" width="150" />
         <el-table-column prop="levelDesc" label="描述" min-width="180" show-overflow-tooltip />
         <el-table-column label="月费(元)" width="100">
-          <template slot-scope="{ row }">{{ formatPrice(row.priceMonthly) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatPrice(row.priceMonthly) : '—' }}</template>
         </el-table-column>
         <el-table-column label="季费(元)" width="100">
-          <template slot-scope="{ row }">{{ formatPrice(row.priceQuarterly) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatPrice(row.priceQuarterly) : '—' }}</template>
         </el-table-column>
         <el-table-column label="年费(元)" width="100">
-          <template slot-scope="{ row }">{{ formatPrice(row.priceYearly) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatPrice(row.priceYearly) : '—' }}</template>
         </el-table-column>
         <el-table-column prop="maxChildren" label="孩子上限" width="90" />
         <el-table-column prop="maxTasksPerDay" label="每日任务" width="90" />
         <el-table-column label="AI审核" width="80">
           <template slot-scope="{ row }">
-            <el-tag :type="row.aiReviewEnabled ? 'success' : 'info'" size="mini">{{ row.aiReviewEnabled ? '是' : '否' }}</el-tag>
+            <el-tag v-if="row" :type="row.aiReviewEnabled ? 'success' : 'info'" size="mini">{{ row.aiReviewEnabled ? '是' : '否' }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="优先客服" width="80">
           <template slot-scope="{ row }">
-            <el-tag :type="row.prioritySupport ? 'success' : 'info'" size="mini">{{ row.prioritySupport ? '是' : '否' }}</el-tag>
+            <el-tag v-if="row" :type="row.prioritySupport ? 'success' : 'info'" size="mini">{{ row.prioritySupport ? '是' : '否' }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
           <template slot-scope="{ row }">
-            <el-button size="mini" type="primary" @click="handleEditLevel(row)">编辑</el-button>
-            <el-dropdown trigger="hover" @command="(cmd) => handleLevelCmd(row, cmd)">
+            <el-button v-if="row" size="mini" type="primary" @click="handleEditLevel(row)">编辑</el-button>
+            <el-dropdown v-if="row" trigger="hover" @command="(cmd) => handleLevelCmd(row, cmd)">
               <el-button size="mini">
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
               </el-button>
@@ -92,29 +92,29 @@
     <el-table :data="upgradeRecords" v-loading="recordsLoading" border stripe :max-height="tableHeight">
         <el-table-column prop="id" label="ID" width="70" />
         <el-table-column label="用户" width="120">
-          <template slot-scope="{ row }">{{ row.userName || '用户' + row.userId }}</template>
+          <template slot-scope="{ row }">{{ row ? (row.userName || '用户' + row.userId) : '—' }}</template>
         </el-table-column>
         <el-table-column label="从" width="100">
           <template slot-scope="{ row }">
-            <el-tag size="mini">{{ row.fromLevel || 'FREE' }}</el-tag>
+            <el-tag v-if="row" size="mini">{{ row.fromLevel || 'FREE' }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column label="到" width="100">
           <template slot-scope="{ row }">
-            <el-tag type="success" size="mini">{{ row.toLevel }}</el-tag>
+            <el-tag v-if="row" type="success" size="mini">{{ row.toLevel }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="upgradeType" label="升级方式" width="120">
           <template slot-scope="{ row }">
-            <el-tag :type="upgradeTypeTag(row.upgradeType)" size="mini">{{ upgradeTypeLabel(row.upgradeType) }}</el-tag>
+            <el-tag v-if="row" :type="upgradeTypeTag(row.upgradeType)" size="mini">{{ upgradeTypeLabel(row.upgradeType) }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column prop="orderId" label="订单ID" width="80" />
         <el-table-column prop="expireTime" label="到期时间" width="160">
-          <template slot-scope="{ row }">{{ formatTime(row.expireTime) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatTime(row.expireTime) : '—' }}</template>
         </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
-          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          <template slot-scope="{ row }">{{ row ? formatTime(row.createdAt) : '—' }}</template>
         </el-table-column>
     </el-table>
     <el-pagination
@@ -130,6 +130,71 @@
       />
     </el-card>
 
+    <!-- 会员维护 -->
+    <el-card style="margin-top:20px;">
+      <div slot="header">
+        <span>会员维护</span>
+      </div>
+      <div class="maintain-row">
+        <el-input
+          v-model="maintainSearch"
+          placeholder="输入手机号/昵称/用户ID搜索"
+          clearable
+          style="width:280px;margin-right:10px;"
+          @keyup.enter.native="handleMaintainSearch"
+        />
+        <el-button type="primary" size="small" @click="handleMaintainSearch">搜索</el-button>
+      </div>
+      <div v-if="maintainUsers.length" style="margin-top:14px;">
+        <el-table :data="maintainUsers" border stripe size="small">
+          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column prop="nickname" label="昵称" width="140" />
+          <el-table-column prop="phone" label="手机号" width="140" />
+          <el-table-column label="当前等级" width="110">
+            <template slot-scope="{ row }">
+              <el-tag v-if="row" :type="levelTagType(row.memberLevel)" size="mini">{{ row.memberLevel || 'FREE' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="推荐人ID" width="100">
+            <template slot-scope="{ row }">{{ row ? (row.referrerId || '—') : '—' }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="160">
+            <template slot-scope="{ row }">
+              <el-button v-if="row" size="mini" type="warning" @click="openMaintainDialog(row)">维护</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 会员维护对话框 -->
+    <el-dialog title="会员维护" :visible.sync="maintainVisible" width="520px" @close="maintainVisible = false">
+      <el-form :model="maintainForm" label-width="100px" size="small">
+        <el-form-item label="用户">
+          <span>{{ maintainForm.nickname }}(ID: {{ maintainForm.id }})</span>
+        </el-form-item>
+        <el-form-item label="会员等级">
+          <el-select v-model="maintainForm.memberLevel" style="width:200px;" placeholder="选择等级">
+            <el-option
+              v-for="lv in levels"
+              :key="lv.levelCode"
+              :label="lv.levelName + ' (' + lv.levelCode + ')'"
+              :value="lv.levelCode"
+            />
+          </el-select>
+          <span style="color:#999;margin-left:8px;">选终身会员则永久有效</span>
+        </el-form-item>
+        <el-form-item label="推荐人ID">
+          <el-input v-model="maintainForm.referrerIdStr" placeholder="留空不变,填0清除" style="width:200px;" />
+          <span style="color:#999;margin-left:8px;">填目标用户ID</span>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="maintainVisible = false">取消</el-button>
+        <el-button type="primary" :loading="maintainSubmitting" @click="handleMaintainSubmit">保存</el-button>
+      </div>
+    </el-dialog>
+
     <!-- 权限详情对话框 -->
     <el-dialog title="功能权限详情" :visible.sync="featuresVisible" width="500px">
       <div v-if="currentLevel" class="features-content">
@@ -197,7 +262,7 @@
 </template>
 
 <script>
-import { searchUsers } from '@/api/admin'
+import { searchUsers, updateMembershipUser } from '@/api/admin'
 
 export default {
   name: 'MembershipCenter',
@@ -230,7 +295,18 @@ export default {
       },
       // dialogs
       featuresVisible: false,
-      currentLevel: null
+      currentLevel: null,
+      // membership maintain
+      maintainSearch: '',
+      maintainUsers: [],
+      maintainVisible: false,
+      maintainSubmitting: false,
+      maintainForm: {
+        id: null,
+        nickname: '',
+        memberLevel: '',
+        referrerIdStr: ''
+      }
     }
   },
   created() {
@@ -295,7 +371,8 @@ export default {
       try {
         const res = await this._request('/api/admin/membership/levels')
         if (res.data && res.data.code === 200) {
-          this.levels = res.data.data || []
+          const list = res.data.data || []
+          this.levels = Array.isArray(list) ? list.filter(function(l) { return l && typeof l === 'object' }) : []
         }
       } catch (e) {
         this.$message && this.$message.error && this.$message.error('加载等级配置失败')
@@ -311,7 +388,8 @@ export default {
           size: this.recordFilter.size
         })
 if (res.data && res.data.code === 200) {
-            this.upgradeRecords = res.data.data.records || []
+            const records = (res.data.data && res.data.data.records) || []
+            this.upgradeRecords = Array.isArray(records) ? records.filter(function(r) { return r && typeof r === 'object' }) : []
             this.recordsTotal = res.data.data.total || 0
             await this.resolveUserNames(this.upgradeRecords, 'userId')
           }
@@ -366,6 +444,63 @@ if (res.data && res.data.code === 200) {
           item.userName = nameMap[item[idField]] || '用户' + item[idField]
         })
       })
+    },
+    async handleMaintainSearch() {
+      const kw = (this.maintainSearch || '').trim()
+      if (!kw) {
+        this.$message && this.$message.warning && this.$message.warning('请输入搜索关键词')
+        return
+      }
+      try {
+        const res = await searchUsers({ keyword: kw })
+        if (res.data && res.data.length > 0) {
+          this.maintainUsers = res.data
+        } else {
+          this.maintainUsers = []
+          this.$message && this.$message.info && this.$message.info('未找到用户')
+        }
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('搜索失败')
+      }
+    },
+    openMaintainDialog(row) {
+      this.maintainForm = {
+        id: row.id,
+        nickname: row.nickname || row.realName || ('用户' + row.id),
+        memberLevel: row.memberLevel || 'FREE',
+        referrerIdStr: row.referrerId ? String(row.referrerId) : ''
+      }
+      this.maintainVisible = true
+    },
+    async handleMaintainSubmit() {
+      if (!this.maintainForm.id) return
+      this.maintainSubmitting = true
+      try {
+        const payload = { userId: this.maintainForm.id }
+        if (this.maintainForm.memberLevel) {
+          payload.memberLevel = this.maintainForm.memberLevel
+        }
+        const refStr = (this.maintainForm.referrerIdStr || '').trim()
+        if (refStr === '0') {
+          payload.referrerId = ''
+        } else if (refStr !== '') {
+          payload.referrerId = refStr
+        }
+        const res = await updateMembershipUser(payload)
+        if (res.code === 200) {
+          this.$message && this.$message.success && this.$message.success(res.data || '更新成功')
+          this.maintainVisible = false
+          this.handleMaintainSearch()
+          this.loadOverview()
+          this.loadUpgradeRecords()
+        } else {
+          this.$message && this.$message.error && this.$message.error(res.message || '更新失败')
+        }
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('更新失败')
+      } finally {
+        this.maintainSubmitting = false
+      }
     }
   }
 }

+ 2316 - 0
docs/superpowers/plans/2026-08-28-innate-portrait-mind-dimension.md

@@ -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>`

+ 493 - 0
docs/superpowers/specs/2026-08-28-innate-portrait-mind-dimension-design.md

@@ -0,0 +1,493 @@
+# 星座/八字/数字能量引入心维度 · 先天画像体系设计
+
+**日期**: 2026-08-28
+**状态**: 已确认
+**版本**: v1.0
+
+**关联文档:**
+- [五维能量系统设计](./2026-06-05-five-dimension-energy-design.md) — 五维能量体系
+- [五行哲学体系](./2026-06-08-five-dimension-wuxing-philosophy.md) — 心=火,五行生克
+- [LangGraph 迁移设计](./2026-07-20-langgraph-migration-design.md) — AI 服务架构
+- [用户画像 prompt 设计](./2026-08-24-user-portrait-prompt-design.md) — AI 画像注入
+
+---
+
+## 一、背景与目标
+
+### 1.1 现状
+
+项目已有分散的"传统文化镜像"基础设施,各玄学来源各自有配置表和展示:
+
+| 来源 | 配置表 | 先天分字段 | 服务 |
+|------|--------|-----------|------|
+| 星座 | `zodiac_config` | `mind_base`/`wisdom_base` | `ZodiacAnnualEnergyService` |
+| 八字 | `bazi_config` | `mind_base`/`wisdom_base` | `BaziConfigService` |
+| 血型 | `blood_type_config` | `mind_base`/`wisdom_base` | `BloodTypeConfigService` |
+| 数字能量 | `numsoul_config`(灵数1-9) | — | `FamilyMemberAttributeService.getNumSoulConfig()` |
+
+成员先天属性存于 `family_member_attributes` 表(八字四柱/五行/生肖/血型/`mind_base_score`/`wisdom_base_score`/`behavior_modifier`含灵数)。
+
+已有接口:`/api/mind/traditional/mirror`、`/family-dashboard`、`/compatibility`、`/api/zodiac/energy`、`/api/tianpan/*`。
+
+已有前端:`pages/mind-detail/` 下 `traditional-mirror.vue`、`duo-compatibility.vue`、`family-dashboard.vue`、`index.vue`。
+
+### 1.2 问题
+
+1. **分散**:各玄学来源各自展示,无统一"先天画像"聚合
+2. **数字能量薄弱**:只有灵数1-9基础配置,缺天赋数/生日数/命运数
+3. **先天后天未打通**:`mind_base_score`(先天)与 `energy_balance`(后天)未打通展示成长轨迹
+4. **无 AI 个性化解读**:目前只有 `bazi_reading_template` 模板文案
+5. **管理端不可配置**:各来源权重/启停/文案模板无法配置
+
+### 1.3 目标
+
+在现有基础上**深化/整合**,将星座/八字/数字能量统一为"心维度先天画像"体系:
+
+1. **统一先天画像 + AI 解读**:聚合各来源,AI 主导生成个性化解读
+2. **补强数字能量**:生日数字体系(生命灵数/天赋数/生日数/命运数)
+3. **先天+后天打通**:展示先天起点 → 后天成长轨迹
+4. **管理端可配置化**:各来源权重/启停/AI开关
+
+### 1.4 产品定位(已确认)
+
+**服务于五维能量体系**——星座/八字/数字能量作为心维度先天基础分来源,最终汇入五维能量,强调与后天能量打通、成长轨迹。
+
+### 1.5 AI 定位(已确认)
+
+**AI 主导生成**——先天画像和解读主要由 AI 生成,后端只提供原始数据。AI 服务不可用时降级为模板文案。
+
+---
+
+## 二、架构总览
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                    数据源(现有,不动)                        │
+│  zodiac_config / bazi_config / blood_type_config /          │
+│  numsoul_config / life_number_relationship /                │
+│  bazi_reading_template                                      │
+└──────────────────────────┬──────────────────────────────────┘
+                           │ 读取
+┌──────────────────────────▼──────────────────────────────────┐
+│          先天画像聚合层(新增 InnatePortraitService)          │
+│  · 聚合八字/五行/星座/血型/灵数/天赋数/命运数                  │
+│  · 复用 calcInnateScore 计算 mind_base_score                 │
+│  · 数字能量补强(天赋数/生日数/命运数计算)                    │
+└──────────────┬───────────────────────────┬─────────────────┘
+               │ 原始数据                    │ 先天画像
+┌──────────────▼──────────┐   ┌─────────────▼─────────────────┐
+│  LangGraph AI graph     │   │  五维能量体系(现有)           │
+│  (innate_portrait_graph)│   │  mind_base_score → energy_balance│
+│  AI 主导生成解读文案     │   │  先天+后天成长轨迹              │
+└──────────────┬──────────┘   └─────────────┬─────────────────┘
+               │ AI解读                     │ 展示
+┌──────────────▼───────────────────────────▼─────────────────┐
+│              小程序心维度页面(增强)                         │
+│  · 先天画像报告(AI解读)                                    │
+│  · 先天+后天成长轨迹                                         │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 三、数据模型设计
+
+### 3.1 新增表:`innate_portrait_config`(先天画像来源权重配置)
+
+管理端可配置各玄学来源对 `mind_base_score` 的权重、启停开关、AI 解读开关。
+
+> **与现有硬编码权重的关系**:现有 `FamilyMemberAttributeService.calcInnateScore()` 硬编码 zodiac×50% + bazi×30% + blood×20%。本表权重将**替换**该硬编码逻辑——`calcInnateScore` 改为读取本表权重(详见 §7.5)。数字能量(numsoul)作为第 4 个来源参与加权。
+
+```sql
+CREATE TABLE IF NOT EXISTS innate_portrait_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(20) NOT NULL COMMENT '来源: zodiac/bazi/blood/numsoul',
+    source_name VARCHAR(50) NOT NULL COMMENT '来源名称',
+    weight INT DEFAULT 25 COMMENT '权重(基点,2500=25%)',
+    enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用',
+    ai_enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用AI解读',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    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='先天画像来源权重配置';
+```
+
+### 3.2 新增表:`innate_portrait_report`(先天画像报告缓存)
+
+AI 解读结果缓存,避免重复调用 LLM。
+
+> **职责边界**:本表只存 `portrait_json`(画像数据)+ `ai_reading`(AI解读)。**不重复存 `mind_base_score`**——先天分从 `family_member_attributes.mind_base_score` 读取,避免职责重叠。
+
+```sql
+CREATE TABLE IF NOT EXISTS innate_portrait_report (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '成员ID',
+    member_type VARCHAR(10) NOT NULL COMMENT '成员类型: child/parent',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    portrait_json TEXT COMMENT '先天画像数据JSON',
+    ai_reading TEXT COMMENT 'AI解读文案',
+    generated_at DATETIME COMMENT '生成时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_member (member_id, member_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='先天画像报告缓存';
+```
+
+### 3.3 新增表:`numsoul_detail_config`(数字能量详细配置)
+
+覆盖所有数字类型(生命灵数/天赋数/生日数/命运数),每个类型每个数字都有解读文案。
+
+> **与现有 `numsoul_config` 的关系**:`numsoul_config`(灵数1-9基础配置)**保留不动**,兼容现有 `getNumSoulConfig()` 调用。`numsoul_detail_config` 是**扩展**,覆盖更细的数字类型(生命灵数/天赋数/生日数/命运数),并新增 `mind_base` 字段使数字能量参与 `mind_base_score` 加权计算。
+
+```sql
+CREATE TABLE IF NOT EXISTS numsoul_detail_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    number_type VARCHAR(20) NOT NULL COMMENT '类型: life_path/talent/birthday/destiny',
+    number_value INT NOT NULL COMMENT '数字值 1-9 或主数 11/22/33',
+    title VARCHAR(50) COMMENT '称号',
+    keywords VARCHAR(200) COMMENT '性格关键词',
+    mind_base INT DEFAULT 0 COMMENT '心先天基础分(百分位)',
+    mind_advice TEXT COMMENT '心维度成长建议',
+    color_hex VARCHAR(10) COMMENT '代表色',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_type_num (number_type, number_value)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字能量详细配置表';
+```
+
+### 3.4 数字能量计算规则(后端 Service 实现)
+
+从出生日期推导:
+- **生命灵数**(life_path):年月日所有数字相加直至个位(主数 11/22/33 保留)
+- **天赋数**(talent):生命灵数计算过程中化简前的两位数
+- **生日数**(birthday):出生日化简至个位
+- **命运数**(destiny):出生年月日总和化简(简化版,不含姓名)
+
+---
+
+## 四、后端服务层设计
+
+### 4.1 新增 `InnatePortraitService`(先天画像聚合服务)
+
+核心服务,职责:
+1. **聚合先天画像**:从 `family_member_attributes` + 各配置表聚合出完整先天画像(八字/五行/星座/血型/灵数/天赋数/生日数/命运数)
+2. **复用 `calcInnateScore` 计算 mind_base_score**:**不重复实现**加权逻辑,直接调用现有 `FamilyMemberAttributeService.calcInnateScore()`(该服务已实现 zodiac×50% + bazi×30% + blood×20% 加权并缓存到 `mind_base_score`)
+3. **数字能量计算**:从出生日期推导生命灵数/天赋数/生日数/命运数
+4. **AI 解读编排**:调用 LangGraph graph 生成解读,结果缓存到 `innate_portrait_report`
+
+> **关键决策**:`mind_base_score` 的计算逻辑**已存在于** `FamilyMemberAttributeService.calcInnateScore()`(zodiac×50% + bazi×30% + blood×20%,已缓存)。`InnatePortraitService` **复用**该方法,不重复实现。管理端可配置权重(`innate_portrait_config`)将**替换** `calcInnateScore` 中的硬编码权重(详见 §7.5)。
+
+```java
+@Service
+public class InnatePortraitService {
+
+    @Resource
+    private FamilyMemberAttributeService familyMemberAttributeService;
+
+    @Resource
+    private InnatePortraitConfigMapper innatePortraitConfigMapper;
+
+    @Resource
+    private InnatePortraitReportMapper innatePortraitReportMapper;
+
+    @Resource
+    private NumSoulDetailConfigMapper numSoulDetailConfigMapper;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    /**
+     * 聚合先天画像 + 复用 calcInnateScore 计算 mind_base_score
+     */
+    public InnatePortraitVO getInnatePortrait(Long memberId, String memberType) {
+        // 1. 读取 family_member_attributes
+        // 2. 聚合各来源数据(八字/五行/星座/血型/灵数)
+        // 3. 计算数字能量(生命灵数/天赋数/生日数/命运数)
+        // 4. 复用 familyMemberAttributeService.calcInnateScore(memberId, memberType, "mind")
+        // 5. 返回完整画像
+    }
+
+    /**
+     * 生成 AI 解读(缓存到 innate_portrait_report)
+     */
+    public InnatePortraitReportVO generateAiReading(Long memberId, String memberType) {
+        // 1. 检查缓存,未过期直接返回
+        // 2. 聚合先天画像
+        // 3. 调用 AiGateway 生成 AI 解读
+        // 4. 缓存结果
+    }
+
+    /**
+     * 数字能量计算
+     */
+    public NumSoulDetailVO calculateNumSoul(Date birthDatetime) {
+        // 生命灵数/天赋数/生日数/命运数
+    }
+
+    /**
+     * 先天+后天成长轨迹
+     */
+    public InnateTrajectoryVO getTrajectory(Long memberId, String memberType, Long familyId) {
+        // 1. 读取先天画像 + mind_base_score
+        // 2. 读取 energy_balance(后天当前能量)
+        // 3. 计算先天 vs 后天的差值/趋势
+        // 4. 结合 AI 解读生成成长建议
+    }
+}
+```
+
+### 4.2 新增 DTO
+
+- `InnatePortraitVO`:完整先天画像(八字/五行/星座/血型/灵数/天赋数/生日数/命运数 + mind_base_score)
+- `NumSoulDetailVO`:数字能量详情(各类型数字 + 配置解读)
+- `InnatePortraitReportVO`:AI 解读报告(画像 + AI 文案 + 分数)
+- `InnateTrajectoryVO`:先天+后天成长轨迹
+
+### 4.3 新增 Controller
+
+`InnatePortraitController`(`/api/mind/innate`):
+- `POST /api/mind/innate/portrait` — 获取先天画像
+- `POST /api/mind/innate/reading` — 生成/获取 AI 解读
+- `POST /api/mind/innate/numsoul` — 获取数字能量详情
+- `POST /api/mind/innate/trajectory` — 获取先天+后天成长轨迹
+
+### 4.4 新增 Mapper
+
+- `InnatePortraitConfigMapper`
+- `InnatePortraitReportMapper`
+- `NumSoulDetailConfigMapper`
+
+---
+
+## 五、AI 解读(LangGraph graph)设计
+
+### 5.1 新增 `innate_portrait_graph`(先天画像解读 graph)
+
+在 `cfc-langgraph/app/graphs/` 新增 `innate_portrait_graph.py`,接收先天画像原始数据,AI 主导生成个性化解读。
+
+```
+┌─────────────┐   ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
+│ 收集画像数据  │ → │ 组装解读Prompt│ → │ LLM生成解读   │ → │ 结构化输出    │
+│ (portrait)  │   │ (prompt)     │   │ (解读文案)    │   │ (JSON)       │
+└─────────────┘   └──────────────┘   └──────────────┘   └──────────────┘
+```
+
+**graph 节点设计**:
+1. **build_prompt**:接收先天画像数据(八字四柱/五行/星座/血型/灵数/天赋数/生日数/命运数),组装解读 prompt(含角色设定:家庭教育/成长陪伴语境)
+2. **generate**:调用 LLM 生成解读(心维度特质 + 成长建议 + 亲子互动建议)
+3. **parse**:解析 LLM 输出为结构化 JSON
+
+**输出结构**:
+```json
+{
+  "mindTraits": "心维度特质描述(基于五行/星座/灵数综合分析)",
+  "strengths": ["先天优势1", "先天优势2"],
+  "growthAdvice": "心维度成长建议",
+  "parentAdvice": "家长互动建议",
+  "wuxingBalance": "五行平衡提示"
+}
+```
+
+### 5.2 新增 FastAPI 端点
+
+在 `app/api/adapter.py` 或新增 `app/api/innate.py`:
+- `POST /api/v1/innate/reading` — 接收先天画像数据,返回 AI 解读
+
+### 5.3 AiGateway 扩展
+
+在 `AiGateway.java` 新增方法:
+```java
+public Map<String, Object> generateInnateReading(Map<String, Object> portrait) {
+    // POST baseUrl + "/api/v1/innate/reading"
+    // 熔断 + fallback(fallback 返回模板文案)
+}
+```
+
+**Fallback 策略**(关键):
+- AI 服务不可用时,降级为 `bazi_reading_template` 模板文案 + `numsoul_detail_config` 静态文案拼接
+- 保证功能可用,AI 是增强不是依赖
+
+### 5.4 Prompt 设计要点
+
+解读 prompt 需:
+- 角色设定:家庭教育成长陪伴师
+- 结合五行哲学(心=火)与五维能量体系
+- 强调"先天特质 + 后天成长"视角
+- 避免宿命论措辞,强调"了解先天特质 → 针对性培养"
+- 输出限定 JSON 结构
+
+---
+
+## 六、先天+后天打通设计
+
+### 6.1 数据流打通
+
+现有五维能量体系用 `energy_balance`(当前能量)+ `energy_log`(流水)。先天基础分(`mind_base_score`)需要与后天能量打通,展示成长轨迹。
+
+**设计思路**:
+- **先天**:`mind_base_score`(0-100)作为心维度的"起点基线"
+- **后天**:`energy_balance`(当前能量值)作为心维度的"当前状态"
+- **成长轨迹**:先天 vs 后天对比,展示"天赋起点 → 后天成长"
+
+### 6.2 新增接口:`POST /api/mind/innate/trajectory`
+
+返回某成员的先天画像 + 后天成长轨迹:
+
+```json
+{
+  "innatePortrait": { "mindBaseScore": 72, "zodiac": "狮子座", "lifeNumber": 3, "..." },
+  "innateDimensionScores": { "body": 65, "mind": 72, "wisdom": 68, "action": 60, "wealth": 55 },
+  "currentEnergy": { "mindBalance": 120, "mindTotalEarned": 350, "..." },
+  "trajectory": {
+    "innateVsCurrent": { "mindInnate": 72, "mindCurrent": 81, "growth": "+9" },
+    "growthTrend": "先天心能量充沛,后天通过情绪打卡/家庭互动持续成长",
+    "advice": "保持当前情绪打卡频率,建议增加家庭共处时间..."
+  }
+}
+```
+
+### 6.3 打通逻辑
+
+`InnatePortraitService.getTrajectory(memberId, memberType, familyId)`:
+1. 读取先天画像 + `mind_base_score`
+2. 读取 `energy_balance`(后天当前能量)
+3. 计算先天 vs 后天的差值/趋势
+4. 结合 AI 解读生成成长建议
+
+### 6.4 展示位置
+
+小程序 `pages/mind-detail/index.vue` 心维度首页,新增"先天画像"卡片:
+- 显示先天心能量基础分 + 后天当前能量分
+- 先天数字能量画像(生命灵数/天赋数等)
+- 点击进入 AI 解读报告页
+
+---
+
+## 七、管理端可配置化设计
+
+### 7.1 新增管理端页面:`InnatePortraitConfig.vue`
+
+在 `cfc-web/src/views/admin/` 新增,管理先天画像来源权重配置:
+
+**功能**:
+- 各来源(星座/八字/血型/数字能量)的权重配置(基点)
+- 启停开关(enabled)
+- AI 解读开关(ai_enabled)
+- 排序
+
+### 7.2 新增管理端页面:`NumSoulDetailConfig.vue`
+
+管理数字能量详细配置(`numsoul_detail_config`):
+
+**功能**:
+- 按数字类型(生命灵数/天赋数/生日数/命运数)筛选
+- 每个数字(1-9 + 主数11/22/33)的称号/关键词/心维度成长建议/代表色
+- 增删改查
+
+### 7.3 新增管理端 Controller
+
+- `InnatePortraitConfigController`(`/api/admin/innate-portrait-config`):list/create/update/delete
+- `NumSoulDetailConfigController`(`/api/admin/numsoul-detail-config`):list/create/update/delete
+
+### 7.4 菜单接入
+
+在管理端菜单(`SysMenuController` / 前端路由)新增入口:
+- 先天画像配置
+- 数字能量配置
+
+### 7.5 权重替换硬编码逻辑(缺陷修复)
+
+**现有逻辑**:`FamilyMemberAttributeService.calcInnateScore()` 硬编码:
+```java
+Integer result = (zodiacScore * 5000 + baziScore * 3000 + bloodScore * 2000) / 10000;
+```
+
+**改造**:`calcInnateScore()` 改为读取 `innate_portrait_config` 表权重:
+```java
+// 读取配置(默认 zodiac/bazi/blood/numsoul 各 25%)
+// 只累加 enabled=1 的来源
+// 权重未配置时回退到现有硬编码 50/30/20
+Integer result = (zodiacScore * wZodiac + baziScore * wBazi + bloodScore * wBlood + numsoulScore * wNumsoul) / 10000;
+```
+
+**要求**:
+- 权重未配置(表空)时回退到现有 50/30/20 硬编码,保证存量数据不变化
+- `numsoul_score` 从 `numsoul_detail_config.life_path.mind_base` 读取(按生命灵数匹配)
+- 启用的来源权重之和不足 100% 时按比例归一化,超过则按比例缩放
+
+---
+
+## 八、前端小程序展示设计
+
+### 8.1 新增页面:`pages/mind-detail/innate-portrait.vue`(先天画像报告页)
+
+展示 AI 解读报告:
+- **头部**:成员头像 + 先天心能量基础分
+- **先天画像**:八字四柱 / 五行能量 / 星座 / 血型 / 数字能量(生命灵数/天赋数/生日数/命运数)
+- **AI 解读**:心维度特质 / 先天优势 / 成长建议 / 家长互动建议
+- **五行平衡**:五行能量条
+
+### 8.2 新增页面:`pages/mind-detail/innate-trajectory.vue`(先天+后天成长轨迹页)
+
+展示成长轨迹:
+- **先天 vs 后天**:心维度先天基础分 vs 当前能量分对比
+- **成长趋势**:趋势图/文案
+- **成长建议**:AI 生成的针对性建议
+
+### 8.3 心维度首页增强:`pages/mind-detail/index.vue`
+
+在现有首页新增"先天画像"入口卡片:
+- 显示先天心能量基础分
+- 点击进入先天画像报告页
+- 显示数字能量画像(生命灵数等)
+
+### 8.4 小程序限制遵守
+
+- 禁止可选链 `?.`(用 `&&` 替代)
+- 禁止 CSS Grid(用 flexbox)
+- 禁止 `:key` 表达式(用方法调用)
+- 禁止直接 `new Date(string)`(用 `parseDate()`)
+- Vue 2 Options API
+
+---
+
+## 九、数据库迁移
+
+在 `DatabaseInitializer.runMigrations()` 新增迁移(编号从 267 开始):
+
+1. **迁移267**:创建 `innate_portrait_config` 表 + 种子数据(4 来源默认权重 25%)
+2. **迁移268**:创建 `innate_portrait_report` 表
+3. **迁移269**:创建 `numsoul_detail_config` 表 + 种子数据(4 类型 × 数字 1-9 + 主数,含 `mind_base` 字段)
+
+同步更新 `schema.sql` 的 CREATE TABLE 定义。
+
+> **注意**:`numsoul_detail_config` 的 `mind_base` 字段是数字能量参与 `mind_base_score` 加权计算的关键。种子数据需为每个数字类型(life_path/talent/birthday/destiny)的每个数字(1-9 + 主数 11/22/33)配置 `mind_base` 值。
+
+---
+
+## 十、测试策略
+
+### 10.1 后端单元测试
+
+- `InnatePortraitServiceTest`:数字能量计算(生命灵数/天赋数/生日数/命运数)、先天画像聚合
+- `NumSoulDetailConfigMapperTest`:配置表 CRUD
+- `FamilyMemberAttributeServiceTest`(扩展):`calcInnateScore` 权重替换后——配置表权重生效、表空时回退硬编码 50/30/20、numsoul 参与加权、权重归一化
+
+### 10.2 后端集成测试
+
+- `InnatePortraitControllerTest`:portrait/reading/numsoul/trajectory 接口
+- AI 解读 fallback 测试(AI 不可用时降级模板文案)
+- `calcInnateScore` 权重替换回归测试(存量数据 mind_base_score 不因权重替换而变化)
+
+### 10.3 前端测试
+
+- 小程序页面渲染测试(先天画像报告页/成长轨迹页)
+- 管理端配置页 CRUD 测试
+
+---
+
+## 十一、非目标(YAGNI)
+
+- 不重构现有 `zodiac_config`/`bazi_config`/`blood_type_config` 表结构
+- 不新增手机号/车牌号等后天数字能量分析
+- 不重构现有天盘/合盘/镜像页面
+- 不引入新的中间件或依赖

+ 396 - 0
docs/superpowers/specs/2026-08-29-membership-pay-switch-design.md

@@ -0,0 +1,396 @@
+# 会员费虚拟支付开关设计
+
+**日期:** 2026-08-29
+**状态:** 设计稿 v1
+**优先级:** P1
+**范围:** 会员费支付方式可切换(虚拟支付 ↔ 普通微信支付)
+
+---
+
+## 1. 需求与背景
+
+### 用户故事
+
+As 平台管理员, I want 在后台通过配置开关控制会员费走虚拟支付还是普通微信支付, So that 在虚拟支付联调/上线过渡期可灵活切换,无需改代码重新发版。
+
+### 现状(勘察结论)
+
+- `POST /api/membership/orders`(`MembershipController.createOrder()`)目前**强制**走虚拟支付:
+  1. `createOrder()` 创建订单(`payment_orders`,前缀 `ORD`,status=pending)
+  2. 非 testMode 时调用 `virtualPayService.generateMembershipPayParams()` 生成 `VirtualPayParamsDTO`(含 `orderInfo/sign/signature`)
+  3. 前端 `pay.vue` 收到 `orderInfo` → `wx.requestVirtualPayment`
+- **会员无普通微信支付(v3 JSAPI)路径**。现有 JSAPI 能力:
+  - `PaymentService.createWechatPrepay(orderNo, description, totalFee, openid)` — 通用 v3 JSAPI 预付单(已用于 `PackagePaymentService` 的套餐支付)
+  - `PaymentService.handleWechatNotify()` — v3 回调已按订单前缀路由,但 `ORD` 前缀未接入
+  - 前端 `payment.vue`(商城)已有 `wechatPayParams` + `wx.requestPayment` 分支先例
+- `sys_config` 表(`SysConfigService`)已提供 key-value 配置能力,后端 `admin` 角色可经 `POST /api/admin/config/update` 修改——**后台开关可直接复用,无需新表**
+
+### 已确认决策
+
+| 决策项 | 结论 |
+|--------|------|
+| 开关范围 | **全局开关**(所有会员等级同时生效) |
+| 开关机制 | `sys_config` 表 `member_pay_virtual_enabled`(`1`=虚拟支付,`0`=普通微信支付;缺省=1 保持现状) |
+| 前端口径 | **payType 分流**:后端在响应中明确 `payType=virtual/wechat`,前端分支调用 `requestVirtualPayment` / `wx.requestPayment` |
+| 普通支付 | WeChat v3 JSAPI(`wx.requestPayment`),复用 `PaymentService.createWechatPrepay` |
+
+---
+
+## 2. 整体架构
+
+```
+┌───────────────────── cfc-frontend pay.vue ─────────────────────┐
+│  handlePay()                                                    │
+│    ① createOrder(levelCode, 'pay', period, couponId, openid)   │
+│        │  ② 后端返回统一响应体: { payType, orderXXX, ... }      │
+│        ▼                                                       │
+│    payType == 'virtual'? ──是──► requestVirtualPayment(dto)     │
+│        │ 否                                                    │
+│        ▼                                                       │
+│    wx.requestPayment(wechatPayParams)  ③ 拉起微信支付           │
+└────────────┬─────────────────────────────────────────────────────┘
+             ▼  微信支付回调(notify_url 指向后端)
+┌───────────────────── cfc-backend ──────────────────────────────┐
+│  PaymentService.handleWechatNotify()(v3 回调已按前缀路由)      │
+│    ④ ORD 前缀 → MembershipService.processPaymentCallback()      │
+│        (幂等已内置,与虚拟支付发货推送同一入口)                  │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 3. 配置开关
+
+### `sys_config` key:`member_pay_virtual_enabled`
+
+| 值 | 行为 |
+|:--:|------|
+| `1`(或不存在,缺省) | 虚拟支付:返回 `VirtualPayParamsDTO`(现状不变) |
+| `0` | 普通微信支付:返回订单信息 + `wechatPayParams` |
+
+- **读取方式**:`SysConfigService.getValue("member_pay_virtual_enabled")`,解析 `"0"` 为关闭,其余(含 null)视为开启
+- **后台操作路径(已存在,零新增)**:
+  - 查看:`POST /api/admin/config/all` / `/list`
+  - 修改:`POST /api/admin/config/update`(body: `{configKey, configValue, description}`)
+  - Web 管理端:`SysConfig.xml` 页面已有编辑入口(如无该菜单项则手动补一行即可,不属本次范围)
+- **迁移要求**:无。`sys_config` 表已存在;缺省 null 即虚拟支付,向后兼容
+
+---
+
+## 4. 后端变更
+
+### 4.1 `MembershipService` — 新增 `createOrderWithPayType()`
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`
+
+```java
+/**
+ * 创建会员订单并按后台开关决定支付方式。
+ * <p>开关 member_pay_virtual_enabled:1/缺省=虚拟支付;0=普通微信支付(JSAPI)。
+ * @param openid 普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
+ */
+public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
+        String paymentType, String period, Long userCouponId, String openid) {
+
+    // 1. 创建订单(现状逻辑不变,trial 直接开通返回)
+    PaymentOrderDTO order = createOrder(userId, familyId, levelCode, paymentType, period, userCouponId);
+
+    // 类型统一返回(避免 controller 判断 trial/testMode)
+    Map<String, Object> resp = new HashMap<>();
+    resp.put("orderNo", order.getOrderNo());
+    resp.put("status", order.getStatus());
+    resp.put("levelCode", order.getLevelCode());
+    resp.put("amount", order.getAmount());
+    resp.put("groupNo", order.getGroupNo());
+    resp.put("periodNo", order.getPeriodNo());
+    resp.put("totalPeriods", order.getTotalPeriods());
+
+    // 2. trial 单已直接开通,无需支付
+    if ("trial".equals(paymentType)) {
+        resp.put("payType", "none");
+        return resp;
+    }
+
+    // 3. 判定支付方式:开关缺省=虚拟支付
+    boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));
+
+    if (virtualEnabled) {
+        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(order.getOrderNo(), sessionKey);
+        // 注:sessionKey 由 controller 传入(Controller 持 user,VirtualPayService 不读库)
+        //     —— 具体签名:本方法加参 String sessionKey,controller 传 user.getSessionKey()
+        if (dto == null) {
+            // 道具未配置 → 沿用现状 46001(由 controller 转 error)
+        }
+        resp.put("payType", "virtual");
+        resp.putAll(dto 字段);   // orderInfo/sign/signature/env/signType/currentPeriod/totalPeriods
+        return resp;
+    }
+
+    // 4. 普通微信支付:创建 v3 JSAPI 预付单
+    String description = "浠艾福-" + levelCode + "会员";
+    Map<String, Object> payParams = paymentService.createWechatPrepay(
+            order.getOrderNo(), description, order.getAmount(), openid);
+    resp.put("payType", "wechat");
+    resp.put("wechatPayParams", payParams);
+    return resp;
+}
+```
+
+> ⚠️ 实现注意:
+> - `MembershipService` 需注入 `PaymentService`(`@Resource`,字段名 `paymentService`)——两者无循环依赖(PaymentService 不依赖 MembershipService)
+> - `createOrder()` 内部已有拆单逻辑(`groupNo/periodNo`),普通支付模式**不支持拆单**(微信 JSAPI 单订单金额可任意,道具拆单逻辑仅虚拟支付需要)。即:开关关闭时,`createOrder()` 中基于 `virtual_goods_config.goods_price` 的拆单判断应跳过。**拆单判断在 createOrder 内部通过查 MEMBERSHIP 道具配置触发**(L370-L384),需用参数控制或按开关跳过:
+>   - 方案:给 `createOrder()` 加 `boolean splitEnabled` 参数或开关读取 → 关闭虚拟支付时不查道具、不拆单
+>   - 简化:`createOrder()` 内部本来就是在 `goodsConfig != null` 时才拆——关闭虚拟支付时业务上可正常跳过(金额不要求是道具价整数倍),但为稳妥,按开关显式跳过拆单
+
+### 4.2 `MembershipController.createOrder()` 改造
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`
+
+```java
+@PostMapping("/orders")
+public Result<Object> createOrder(@RequestBody Map<String, Object> params,
+        @RequestAttribute("userId") Long userId) {
+    // ... 现有参数解析不变
+    String openid = (String) params.get("openid");   // 新增:普通支付 JSAPI 需要
+
+    // trial 单:直接返回(createOrder 内已开通)
+    // testMode 单:返回订单信息(现状兼容)
+    //       ↑ 注:testMode 分支保留在 controller 或下沉 service——下沉后 testMode 返回 payType='wechat' 会调微信 mock,也可接受
+    
+    User user = userMapper.selectById(userId);
+    if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+        return Result.error(400, "会话失效,请重新登录");
+    }
+    
+    Map<String, Object> result = membershipService.createOrderWithPayType(
+            userId, familyId, levelCode, paymentType, period, couponId, user.getSessionKey(), openid);
+    
+    if ("virtual".equals(result.get("payType"))
+            && (result.get("orderInfo") == null || result.get("sign") == null)) {
+        return Result.error(46001, "该商品暂未开放购买");   // 道具未配置
+    }
+    return Result.success(result);
+}
+```
+
+**Controller 调整点汇总:**
+
+| 变化 | 说明 |
+|------|------|
+| 请求体新增 `openid`(可选) | 普通支付时需要;虚拟支付/试用不含也兼容 |
+| 返回体新增 `payType` | `virtual` / `wechat` / `none`(trial) |
+| `testMode` 分支 | 保留测试模式捷径:不在 service 内调微信(testMode 时 `PaymentService.doPost` 返回 mock 预付单——**可接受**,前端仍走 JSAPI 拉起,微信端 mock 失败属预期测试行为) |
+| 46001 错误码 | 仅虚拟支付时道具缺失返回 |
+
+### 4.3 `PaymentService.handleWechatNotify()` — ORD 前缀接入会员回调
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java`
+
+现状(L329-334):
+```java
+if (orderNo != null && orderNo.startsWith("PO")) {
+    productOrderService.handlePaymentSuccess(orderNo, transactionId);
+} else {
+    handlePaymentCallback(orderNo, transactionId, "wechat");   // 走 package_orders
+}
+```
+
+改造:`ORD` 前缀 → `MembershipService.processPaymentCallback`:
+```java
+if (orderNo != null && orderNo.startsWith("ORD")) {
+    membershipService.processPaymentCallback(orderNo, transactionId, "wechat");
+} else if (orderNo != null && orderNo.startsWith("PO")) {
+    productOrderService.handlePaymentSuccess(orderNo, transactionId);
+} else {
+    handlePaymentCallback(orderNo, transactionId, "wechat");
+}
+```
+
+> ⚠️ `PaymentService` 注入 `MembershipService`:PaymentService 当前不依赖 MembershipService,无循环依赖风险。
+> 注意 `processPaymentCallback` 内部已有幂等(status=paid 短路 + 补开通逻辑),拆单组全部付清后自动开通——与虚拟支付发货推送同一入口,行为一致。
+
+---
+
+## 5. 前端变更
+
+### 5.1 `utils/api.js` — `createOrder()` 增加 openid
+
+```js
+export const createOrder = (levelCode, paymentType, period, couponId, openid) => {
+  var data = { levelCode: levelCode, paymentType: paymentType }
+  if (period) data.period = period
+  if (couponId) data.couponId = couponId
+  if (openid) data.openid = openid
+  return request('/api/membership/orders', 'POST', data)
+}
+```
+
+### 5.2 `pages/membership/pay.vue` — payType 分流
+
+```js
+handlePay: function() {
+  var self = this
+  if (this.paying) return
+  this.paying = true
+  var couponId = this.selectedCoupon ? this.selectedCoupon.id : null
+  var openid = uni.getStorageSync('openid') || ''
+  createOrder(this.levelCode, 'pay', this.period, couponId, openid).then(function(res) {
+    var data = res.data || {}
+    self.orderNo = data.orderNo
+
+    if (data.payType === 'virtual' && data.orderInfo && data.sign) {
+      // 虚拟支付(现状逻辑不变)
+      self.totalPeriods = data.totalPeriods || 1
+      self.currentPeriod = data.currentPeriod || 1
+      self.splitAmount = data.orderInfo.goodsPrice || (self.finalPrice / self.totalPeriods)
+      self.payInstallments(data)
+    } else if (data.payType === 'wechat' && data.wechatPayParams) {
+      // 普通微信支付(新增)
+      self.payWechat(data.wechatPayParams)
+    } else {
+      // testMode/trial:模拟成功(现状逻辑不变)
+      uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+      setTimeout(function() {
+        self.paying = false
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+      }, 800)
+    }
+  }).catch(function(e) {
+    self.paying = false
+    if (e.code === 46001) {
+      uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
+    } else {
+      uni.showToast({ title: e.message || '下单失败', icon: 'none' })
+    }
+  })
+},
+
+// 普通微信支付(新增方法,复用商城 payment.vue 的分支模式)
+payWechat: function(pp) {
+  var self = this
+  uni.requestPayment({
+    provider: 'wxpay',
+    timeStamp: pp.timeStamp,
+    nonceStr: pp.nonceStr,
+    package: pp.package,
+    signType: pp.signType || 'RSA',
+    paySign: pp.paySign || pp.sign,
+    success: function() {
+      // 后端回调异步开通,跳结果页轮询(沿用 membership/result 现状)
+      uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+      setTimeout(function() {
+        self.paying = false
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+      }, 800)
+    },
+    fail: function(err) {
+      self.paying = false
+      uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=fail' })
+    }
+  })
+}
+```
+
+> ⚠️ 小程序限制遵守:不用可选链、不用 `:key` 表达式、Vue2 Options API。
+
+---
+
+## 6. API 响应体(新)
+
+### 6.1 虚拟支付开启时(现状不变 + payType 字段)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "payType": "virtual",
+    "orderNo": "ORD20260829xxx",
+    "status": "pending",
+    "levelCode": "FAMILY",
+    "amount": 131400,
+    "totalPeriods": 1,
+    "currentPeriod": 1,
+    "orderInfo": { "offerId": "...", "productId": "member1314", "goodsPrice": 131400, "outTradeNo": "ORD..." , "attach": "membership:FAMILY"},
+    "extInfo": {},
+    "sign": "...",
+    "signature": "...",
+    "env": 0,
+    "signType": "HMAC-SHA256"
+  }
+}
+```
+
+### 6.2 普通微信支付开启时(新增)
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "payType": "wechat",
+    "orderNo": "ORD20260829xxx",
+    "status": "pending",
+    "levelCode": "FAMILY",
+    "amount": 131400,
+    "totalPeriods": 1,
+    "periodNo": 1,
+    "wechatPayParams": {
+      "appId": "wx5ba8038ef16fb245",
+      "timeStamp": "1724900000",
+      "nonceStr": "abc123",
+      "package": "prepay_id=wx...",
+      "signType": "RSA",
+      "paySign": "..."
+    }
+  }
+}
+```
+
+---
+
+## 7. 边界与异常处理
+
+| 场景 | 行为 |
+|------|------|
+| 开关=0 且未传 openid | JSAPI 预付单创建失败(微信报错),返回 500——**可接受**:前端正常流程必带 openid(登录时已存 storage) |
+| 开关=0 且支付成功回调 | `PaymentService.handleWechatNotify` → ORD 前缀 → `processPaymentCallback`(幂等) |
+| 拆单 | 普通支付模式**不拆单**(`createOrder` 按开关跳过道具金额拆单逻辑),单次支付全额 |
+| 开关在支付中途切换 | 订单按创建时的 payType 走(支付参数已生成),不影响已生成支付参数;下次下单按新开关 |
+| 优惠券 | 两种模式均走 `createOrder` 现有券逻辑,行为一致 |
+| testMode | 保留现状捷径:直接返回订单信息,前端模拟成功 |
+| 道具缺配(虚拟支付) | 保留 46001「该商品暂未开放购买」 |
+
+---
+
+## 8. 测试策略
+
+1. **单测**(新增 `MembershipPaySwitchTest` 或并入现有):
+   - 开关=1/缺省 → `payType=virtual` 且含 `orderInfo/sign`
+   - 开关=0 → `payType=wechat` 且含 `wechatPayParams`;`createWechatPrepay` 被调用(mock)
+   - `trial` 单 → `payType=none`
+   - 开关=0 时拆单被跳过(`groupNo=null`、单订单、金额=全价)
+2. **回调路由单测**:`handleWechatNotify` ORD 前缀 → `processPaymentCallback`;重复回调幂等
+3. **回归**:开关缺省时行为与现状完全一致(虚拟支付路径零改动)
+4. **编译**:`mvn clean compile`;路由重复检查 `grep -rn '@Mapping' ... | sort -u`
+5. **前端**:`node --check` 语法校验 pay.vue 的 script 块(不打包)
+
+---
+
+## 9. 验收标准
+
+- [ ] `sys_config` 添加 `member_pay_virtual_enabled=0` 后,会员下单返回 `payType=wechat` 且含 `wechatPayParams`,前端拉起普通微信支付
+- [ ] 开关恢复 `1` 后,会员下单返回 `payType=virtual`(现状行为不变)
+- [ ] 普通支付成功 → v3 回调 → ORD 前缀路由 → 会员开通(FamilyMembership + 用户升级 + 佣金),与虚拟支付等价
+- [ ] 普通支付拆单行为:单订单一次支付全额,无 groupNo
+- [ ] trial / testMode 路径不受影响
+- [ ] 46001 道具未配置行为在虚拟支付模式下保留
+- [ ] `mvn clean compile` 通过;无路由重复
+
+---
+
+## 10. 未覆盖范围(后续迭代)
+
+- 测评订单 / 订阅订单的支付开关(本次仅会员;如需要按同一模式扩展)
+- Web 管理端开关 UI 美化(当前直接用 `sys_config` 编辑入口即可)
+- 普通支付的退款链路(会员退款现有 `refundByOrderNo` 基于虚拟支付推送触发;普通支付退款需再接 v3 refund 回调,另行评估)