Преглед изворни кода

docs: 关系问卷与互动记录功能补全实施计划

iwt пре 1 месец
родитељ
комит
831a4bf69c
1 измењених фајлова са 1506 додато и 0 уклоњено
  1. 1506 0
      docs/superpowers/plans/2026-08-04-relationship-feedback-fix.md

+ 1506 - 0
docs/superpowers/plans/2026-08-04-relationship-feedback-fix.md

@@ -0,0 +1,1506 @@
+# 关系问卷与互动记录功能补全 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 修复关系问卷与互动记录两大功能的阻塞性缺陷(3 张表缺失 + 编辑资料 bug),并补齐 AI 出题(LangGraph)、评分算法、跨家庭权限校验。
+
+**Architecture:** 三横一纵——后端补 3 张表 + 评分算法 + 权限校验(Java 侧),前端修复 FamilyMemberStrip 编辑 bug,新增 `cfc-langgraph/` Python 项目做 LangGraph 出题 graph,`AiGateway` 统一调用入口。
+
+**Tech Stack:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / Python 3.11+ / LangGraph / FastAPI / Mockito 5 / uni-app Vue 2
+
+**Spec:** `docs/superpowers/specs/2026-08-04-relationship-feedback-fix-design.md`
+
+---
+
+## 文件结构总览
+
+| 操作 | 文件路径 |
+|------|---------|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` |
+| **Modify** | `cfc-backend/src/main/resources/schema.sql` |
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` |
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/InteractionLogService.java` |
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` |
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java` |
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java` |
+| **Modify** | `cfc-frontend/components/FamilyMemberStrip.vue` |
+| **Modify** | `cfc-backend/src/main/resources/application.yml` |
+| **Modify** | `cfc-backend/AGENTS.md` |
+| **Create** | `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java` |
+| **Create** | `cfc-backend/src/test/java/com/etotem/cfc/service/InteractionLogServiceTest.java` |
+| **Create** | `cfc-langgraph/README.md` |
+| **Create** | `cfc-langgraph/pyproject.toml` |
+| **Create** | `cfc-langgraph/requirements.txt` |
+| **Create** | `cfc-langgraph/.env.example` |
+| **Create** | `cfc-langgraph/.gitignore` |
+| **Create** | `cfc-langgraph/src/__init__.py` |
+| **Create** | `cfc-langgraph/src/app.py` |
+| **Create** | `cfc-langgraph/src/graphs/__init__.py` |
+| **Create** | `cfc-langgraph/src/graphs/questionnaire.py` |
+| **Create** | `cfc-langgraph/src/llm/__init__.py` |
+| **Create** | `cfc-langgraph/src/llm/client.py` |
+| **Create** | `cfc-langgraph/src/schemas/__init__.py` |
+| **Create** | `cfc-langgraph/src/schemas/questionnaire.py` |
+| **Create** | `cfc-langgraph/src/prompts/__init__.py` |
+| **Create** | `cfc-langgraph/src/prompts/questionnaire.py` |
+| **Create** | `cfc-langgraph/tests/__init__.py` |
+| **Create** | `cfc-langgraph/tests/test_graph.py` |
+
+---
+
+## Task 1: 补建 3 张缺失数据表 + schema.sql 同步
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (在 7532 行 `runMigrations()` 末尾、`}` 之前追加)
+- Modify: `cfc-backend/src/main/resources/schema.sql` (文件末尾追加)
+
+- [ ] **Step 1: 在 DatabaseInitializer.java 迁移154/155/156 追加建表**
+
+在 `DatabaseInitializer.java` 的 `runMigrations()` 方法末尾(7532 行 `}` 之前),追加以下代码块:
+
+```java
+        // 迁移154: 创建 interaction_logs 表(关系互动记录)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS interaction_logs (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_id BIGINT NOT NULL COMMENT '所属家庭ID', " +
+                    "from_member_id BIGINT NOT NULL COMMENT '互动发起方成员ID', " +
+                    "to_member_id BIGINT NOT NULL COMMENT '互动接收方成员ID', " +
+                    "interaction_type VARCHAR(20) NOT NULL COMMENT '互动类型: dining/outgoing/call/video/chat/play/gift', " +
+                    "description VARCHAR(500) COMMENT '互动描述', " +
+                    "happened_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_ilog_family (family_id), " +
+                    "INDEX idx_ilog_to_member (to_member_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录'");
+            log.info("已创建interaction_logs表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+
+        // 迁移155: 创建 relationship_questionnaire_snapshots 表(关系问卷快照)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+                    "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
+                    "member_name VARCHAR(50) COMMENT '成员名称', " +
+                    "relationship_type VARCHAR(20) COMMENT '关系类型: parent/child', " +
+                    "ai_generated_json TEXT COMMENT 'AI生成的题目JSON', " +
+                    "version VARCHAR(20) COMMENT '版本号', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_q_snap_member (family_member_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照'");
+            log.info("已创建relationship_questionnaire_snapshots表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+
+        // 迁移156: 创建 relationship_questionnaire_responses 表(关系问卷回答)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID', " +
+                    "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
+                    "answers_json TEXT COMMENT '用户回答JSON', " +
+                    "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分', " +
+                    "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分', " +
+                    "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分', " +
+                    "interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分', " +
+                    "total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分', " +
+                    "calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_q_resp_snap (snapshot_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录'");
+            log.info("已创建relationship_questionnaire_responses表");
+        } catch (Exception e) {
+            // 表已存在,忽略
+        }
+```
+
+- [ ] **Step 2: 在 schema.sql 末尾追加三条 CREATE TABLE**
+
+在 `cfc-backend/src/main/resources/schema.sql` 文件末尾追加:
+
+```sql
+CREATE TABLE IF NOT EXISTS interaction_logs (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '所属家庭ID',
+    from_member_id BIGINT NOT NULL COMMENT '互动发起方成员ID',
+    to_member_id BIGINT NOT NULL COMMENT '互动接收方成员ID',
+    interaction_type VARCHAR(20) NOT NULL COMMENT '互动类型: dining/outgoing/call/video/chat/play/gift',
+    description VARCHAR(500) COMMENT '互动描述',
+    happened_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_ilog_family (family_id),
+    INDEX idx_ilog_to_member (to_member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录';
+
+CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID',
+    member_name VARCHAR(50) COMMENT '成员名称',
+    relationship_type VARCHAR(20) COMMENT '关系类型: parent/child',
+    ai_generated_json TEXT COMMENT 'AI生成的题目JSON',
+    version VARCHAR(20) COMMENT '版本号',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_q_snap_member (family_member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照';
+
+CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID',
+    respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID',
+    answers_json TEXT COMMENT '用户回答JSON',
+    trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分',
+    intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分',
+    communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分',
+    interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分',
+    total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分',
+    calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_q_resp_snap (snapshot_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录';
+```
+
+- [ ] **Step 3: 编译验证**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
+Expected: EXIT 0
+
+- [ ] **Step 4: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
+git commit -m "fix(backend): 迁移154-156 创建 interaction_logs/questionnaire_snapshots/questionnaire_responses 三张缺失表"
+```
+
+---
+
+## Task 2: FamilyMemberStrip 编辑资料 bug 修复
+
+**Files:**
+- Modify: `cfc-frontend/components/FamilyMemberStrip.vue` (124-130 行 `editMember` 方法)
+
+- [ ] **Step 1: 修改 editMember 方法**
+
+将 `FamilyMemberStrip.vue` 第 124-130 行的 `editMember` 方法替换为:
+
+```js
+    editMember: function() {
+      var member = this.actionMember
+      this.closeMenu()
+      if (member) {
+        var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')
+        if (member.gender) params += '&gender=' + member.gender
+        if (member.phone) params += '&phone=' + member.phone
+        if (member.birthday) params += '&birthday=' + member.birthday
+        uni.navigateTo({ url: '/pages/family/add-member?' + params })
+      }
+    },
+```
+
+- [ ] **Step 2: 前端构建验证**
+
+Run: `cd /sc-data/cfc/cfc-frontend && npm run build:mp-weixin 2>&1 | tail -3; echo "EXIT: $?"`
+Expected: EXIT 0
+
+- [ ] **Step 3: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-frontend/components/FamilyMemberStrip.vue
+git commit -m "fix(frontend): FamilyMemberStrip 编辑资料补全 nickname/gender/phone/birthday query 参数"
+```
+
+---
+
+## Task 3: 跨家庭权限校验 — FamilyMemberService 补 isCallerInSameFamily 方法
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java` (在类内追加新方法)
+
+- [ ] **Step 1: 追加方法**
+
+在 `FamilyMemberService.java` 类内(`kickMember` 方法之后、最后一个 `}` 之前),追加以下方法:
+
+```java
+    /**
+     * 校验操作者是否与指定成员属于同一家庭
+     * @param familyMemberId 被操作的 family_members.id
+     * @param callerUserId 操作者的 JWT userId (users.id)
+     * @return true=同家庭  false=不同家庭或不存在
+     */
+    public boolean isCallerInSameFamily(Long familyMemberId, Long callerUserId) {
+        if (familyMemberId == null || callerUserId == null) return false;
+        FamilyMember member = familyMemberMapper.selectById(familyMemberId);
+        if (member == null) return false;
+        long familyId = member.getFamilyId();
+        long count = familyMemberMapper.selectCount(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+                        .eq(FamilyMember::getUserId, callerUserId)
+                        .last("LIMIT 1"));
+        return count > 0;
+    }
+
+    /**
+     * 校验操作者是否与指定 familyId 的成员属于同一家庭(用于 list/add)
+     */
+    public boolean isCallerInFamily(Long familyId, Long callerUserId) {
+        if (familyId == null || callerUserId == null) return false;
+        long count = familyMemberMapper.selectCount(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, familyId)
+                        .eq(FamilyMember::getUserId, callerUserId)
+                        .last("LIMIT 1"));
+        return count > 0;
+    }
+```
+
+- [ ] **Step 2: 编译验证**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
+Expected: EXIT 0
+
+- [ ] **Step 3: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
+git commit -m "feat(backend): FamilyMemberService 补 isCallerInSameFamily/isCallerInFamily 跨家庭校验方法"
+```
+
+---
+
+## Task 4: 评分算法替换 mock — TDD
+
+**Files:**
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` (195-201 行 `calculateScoresFromAnswers` 方法体替换)
+
+- [ ] **Step 1: 写失败的测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireResponseMapper;
+import com.etotem.cfc.mapper.RelationshipQuestionnaireSnapshotMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * RelationshipQuestionnaireService 评分算法单元测试
+ * MockitoExtension — 无 Spring 上下文,无 MySQL 连接
+ */
+public class RelationshipQuestionnaireServiceTest {
+
+    private RelationshipQuestionnaireService service;
+
+    @Mock
+    private RelationshipQuestionnaireSnapshotMapper snapshotMapper;
+    @Mock
+    private RelationshipQuestionnaireResponseMapper responseMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @BeforeEach
+    void setup() throws Exception {
+        MockitoAnnotations.openMocks(this);
+        service = new RelationshipQuestionnaireService();
+        setField(service, "snapshotMapper", snapshotMapper);
+        setField(service, "responseMapper", responseMapper);
+        setField(service, "familyMemberMapper", familyMemberMapper);
+    }
+
+    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);
+    }
+
+    private BigDecimal[] callCalculate(String answersJson, String questionsJson) throws Exception {
+        Method m = RelationshipQuestionnaireService.class
+                .getDeclaredMethod("calculateScoresFromAnswers", String.class, String.class);
+        m.setAccessible(true);
+        return (BigDecimal[]) m.invoke(service, answersJson, questionsJson);
+    }
+
+    // ===== 评分契约:questions 和 answers 均为 Map 字符串 =====
+
+    /** 正向 trust 2 题全选 score=2(满分),intimacy 1 题 score=0(最低分) */
+    @Test
+    void calculateScores_positive_allMax_intimacyMin() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"c\",\"score\":2}," +
+                "\"q3\":{\"id\":\"a\",\"score\":0},\"q4\":{\"id\":\"b\",\"score\":1}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        // trust = mean(100,100) = 100, intimacy = 0, communication = 50
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00"))); // trust
+        assertEquals(0, scores[1].compareTo(new BigDecimal("0.00")));   // intimacy
+        assertEquals(0, scores[2].compareTo(new BigDecimal("50.00")));  // communication
+    }
+
+    /** direction=negative 反向:选 score=0 → 反向后=100 */
+    @Test
+    void calculateScores_negative_direction_reverse() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"negative\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"a\",\"score\":0}," +
+                "\"q2\":{\"id\":\"c\",\"score\":2},\"q3\":{\"id\":\"c\",\"score\":2}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        // q1 negative: raw=0, maxScore=2, reversed=(2-0)/2*100=100
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00"))); // trust (reversed)
+        assertEquals(0, scores[1].compareTo(new BigDecimal("100.00"))); // intimacy
+        assertEquals(0, scores[2].compareTo(new BigDecimal("100.00"))); // communication
+    }
+
+    /** 权重不同:q1 weight=2, q2 weight=1 → 加权 mean */
+    @Test
+    void calculateScores_weighted_average() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":2.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
+                "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"a\",\"score\":0}," +
+                "\"q3\":{\"id\":\"b\",\"score\":1},\"q4\":{\"id\":\"b\",\"score\":1}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        // trust: q1(100*2=200) + q2(0*1=0) / (2+1) = 200/3 = 66.67
+        // intimacy: 50, communication: 50
+        assertEquals(0, scores[0].compareTo(new BigDecimal("66.67"))); // trust weighted
+        assertEquals(0, scores[1].compareTo(new BigDecimal("50.00"))); // intimacy
+        assertEquals(0, scores[2].compareTo(new BigDecimal("50.00"))); // communication
+    }
+
+    /** answersJson 为 null/空/非法 JSON → 三档均为 -1(降级标记) */
+    @Test
+    void calculateScores_invalidJson_returnsNegativeOne() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[\"id\":\"q1\",\"dimension\":\"trust\"]}";
+        BigDecimal[] scores1 = callCalculate(null, questions);
+        assertEquals(0, scores1[0].compareTo(new BigDecimal("-1.00")));
+        assertEquals(0, scores1[1].compareTo(new BigDecimal("-1.00")));
+        assertEquals(0, scores1[2].compareTo(new BigDecimal("-1.00")));
+
+        BigDecimal[] scores2 = callCalculate("not-json!", questions);
+        assertEquals(0, scores2[0].compareTo(new BigDecimal("-1.00")));
+    }
+
+    /** 某维度无题:仅均值有题维度(不平均空维度) */
+    @Test
+    void calculateScores_missingDimension_onlyAveragesExisting() throws Exception {
+        String questions = "{\"version\":1,\"questions\":[" +
+                "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}," +
+                "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
+                " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}" +
+                "]}";
+        String answers = "{\"q1\":{\"id\":\"b\",\"score\":2},\"q2\":{\"id\":\"b\",\"score\":2}}";
+
+        BigDecimal[] scores = callCalculate(answers, questions);
+        // trust=100, intimacy=100, communication 无题=0
+        assertEquals(0, scores[0].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[1].compareTo(new BigDecimal("100.00")));
+        assertEquals(0, scores[2].compareTo(new BigDecimal("0.00")));
+    }
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn test -pl . -Dtest=RelationshipQuestionnaireServiceTest -q 2>&1 | tail -15`
+Expected: 编译失败(`calculateScoresFromAnswers` 当前只有 2 个参数,测试传了 3 个)
+
+- [ ] **Step 3: 修改 calculateScoresFromAnswers**
+
+将 `RelationshipQuestionnaireService.java` 中 `calculateScoresFromAnswers` 方法(195-201 行)替换为以下完整实现。同时修改 `submitQuestionnaire` 方法(106 行附近)传入 `snapshot.getAiGeneratedJson()` 作为第二参数。
+
+先修改方法签名和调用点,再替换实现:
+
+在 `submitQuestionnaire` 方法中(106 行),将:
+```java
+BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson());
+```
+改为:
+```java
+BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson(), snapshot.getAiGeneratedJson());
+```
+
+然后替换整个 `calculateScoresFromAnswers` 方法(195-201 行)为:
+
+```java
+    private BigDecimal[] calculateScoresFromAnswers(String answersJson, String questionsJson) {
+        // 降级:inputs 异常时标记 -1
+        if (answersJson == null || answersJson.isEmpty() || questionsJson == null || questionsJson.isEmpty()) {
+            return new BigDecimal[]{
+                    new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
+        }
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode questionsRoot = mapper.readTree(questionsJson);
+            com.fasterxml.jackson.databind.JsonNode questionsNode = questionsRoot.get("questions");
+            if (questionsNode == null || !questionsNode.isArray()) {
+                return new BigDecimal[]{
+                        new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
+            }
+
+            com.fasterxml.jackson.databind.JsonNode answersNode = mapper.readTree(answersJson);
+
+            // 按 dimension 聚合加权分
+            java.util.Map<String, double[]> dimSums = new java.util.HashMap<>(); // {dim: [weightedSum, totalWeight]}
+            for (com.fasterxml.jackson.databind.JsonNode q : questionsNode(questionsJson)) {
+                String qId = q.get("id").asText();
+                String dim = q.get("dimension").asText();
+                String direction = q.has("direction") ? q.get("direction").asText("positive") : "positive";
+                double weight = q.has("weight") ? q.get("weight").asDouble(1.0) : 1.0;
+
+                if (!answersNode(answersJson).has(qId)) continue; // 缺题跳过
+
+                double rawScore;
+                if (q.has("options")) {
+                    // 选项驱动:后端按 optionId 查 score,不信前端 answer.score
+                    String answerOptionId = answersNode(answersJson).get(qId).has("id")
+                            ? answersNode(answersJson).get(qId).get("id").asText() : null;
+                    double maxScore = 0;
+                    rawScore = 0;
+                    boolean found = false;
+                    for (com.fasterxml.jackson.databind.JsonNode opt : q.get("options")) {
+                        double s = opt.get("score").asDouble(0);
+                        if (s > maxScore) maxScore = s;
+                        if (answerOptionId != null && answerOptionId.equals(opt.get("id").asText())) {
+                            rawScore = s;
+                            found = true;
+                        }
+                    }
+                    if (!found) continue;
+                    rawScore = (maxScore > 0) ? (rawScore / maxScore * 100.0) : 0;
+                } else if (q.has("scale")) {
+                    // 滑尺驱动:取 scoreValue 归一到 0-100
+                    double scaleMin = q.get("scale").get("min").asDouble(0);
+                    double scaleMax = q.get("scale").get("max").asDouble(4);
+                    double scoreVal = answersNode(answersJson).get(qId).has("scoreValue")
+                            ? answersNode(answersJson).get(qId).get("scoreValue").asDouble(0) : 0;
+                    rawScore = (scaleMax > scaleMin) ? ((scoreVal - scaleMin) / (scaleMax - scaleMin) * 100.0) : 0;
+                } else {
+                    continue;
+                }
+
+                if ("negative".equals(direction)) {
+                    rawScore = 100.0 - rawScore;
+                }
+
+                dimSums.computeIfAbsent(dim, k -> new double[]{0, 0});
+                double[] arr = dimSums.get(dim);
+                arr[0] += rawScore * weight;
+                arr[1] += weight;
+            }
+
+            BigDecimal trust = computeDimScore(dimSums.get("trust"));
+            BigDecimal intimacy = computeDimScore(dimSums.get("intimacy"));
+            BigDecimal communication = computeDimScore(dimSums.get("communication"));
+            return new BigDecimal[]{trust, intimacy, communication};
+        }
+```
+
+同时在 `RelationshipQuestionnaireService.java` 类内追加两个 private helper(在 `calculateScoresFromAnswers` 方法之后):
+
+```java
+    private com.fasterxml.jackson.databind.JsonNode questionsNode(String json) {
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(json);
+            return root.has("questions") ? root.get("questions") : mapper.createArrayNode();
+        } catch (Exception e) {
+            return mapper.createArrayNode();
+        }
+    }
+
+    private com.fasterxml.jackson.databind.JsonNode answersNode(String json) {
+        try {
+            return new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
+        } catch (Exception e) {
+            return new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode();
+        }
+    }
+
+    private BigDecimal computeDimScore(double[] arr) {
+        if (arr == null || arr[1] == 0) return new BigDecimal("0.00");
+        double score = arr[0] / arr[1];
+        score = Math.min(100.0, Math.max(0.0, score));
+        return new BigDecimal(String.format("%.2f", score));
+    }
+```
+
+> 注:`questionsNode` 和 `answersNode` 内的 `mapper` 变量需改为在方法内创建,避免编译报错。将 `questionsNode` 内第一行改为 `com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();`,`answersNode` 同理。
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn test -pl . -Dtest=RelationshipQuestionnaireServiceTest -q 2>&1 | tail -10`
+Expected: 5 tests PASS
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java
+git commit -m "feat(backend): 关系问卷评分算法替换 mock — 维度加权/反向/降级/缺题处理 + 5 个单测"
+```
+
+---
+
+## Task 5: 互动记录/问卷 Controller 加跨家庭权限校验
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java` (add/list 方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java` (4 个端点)
+
+- [ ] **Step 1: InteractionLogController — 注入 FamilyMemberService + add/list 加校验**
+
+在 `InteractionLogController.java` 中添加注入(24 行之后):
+
+```java
+    @Resource
+    private com.etotem.cfc.service.FamilyMemberService familyMemberService;
+```
+
+将 `addInteraction` 方法(29-51 行)替换为:
+
+```java
+ @Operation(summary = "添加互动记录")
+ @PostMapping("/add")
+ public Result<InteractionLogVO> addInteraction(@RequestBody AddInteractionDTO dto,
+  @RequestAttribute("userId") Long userId) {
+  if (userId == null) {
+    return Result.error("请先登录");
+  }
+  if (dto.getFamilyId() == null) {
+    User user = userMapper.selectById(userId);
+    if (user == null || user.getFamilyId() == null) {
+      return Result.error("用户未关联家庭,无法添加互动记录");
+    }
+    dto.setFamilyId(user.getFamilyId());
+  }
+  // 跨家庭校验:操作者必须在该家庭
+  if (!familyMemberService.isCallerInFamily(dto.getFamilyId(), userId)) {
+    return Result.error("无权操作其他家庭");
+  }
+  // 校验 fromMemberId/toMemberId 均属于该家庭
+  if (dto.getFromMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getFromMemberId(), userId)) {
+    return Result.error("无权操作其他家庭成员");
+  }
+  if (dto.getToMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getToMemberId(), userId)) {
+    return Result.error("无权操作其他家庭成员");
+  }
+  try {
+   InteractionLogVO vo = interactionLogService.addInteraction(dto);
+   return Result.success(vo);
+  } catch (IllegalArgumentException e) {
+    return Result.error(e.getMessage());
+  } catch (RuntimeException e) {
+    return Result.error(e.getMessage());
+  }
+ }
+```
+
+将 `listInteractions` 方法(54-74 行)中的分页查询替换为:
+
+```java
+@Operation(summary = "查询互动记录列表")
+@PostMapping("/list")
+public Result<Page<InteractionLogVO>> listInteractions(@RequestBody Map<String, Object> body,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+        Long familyId = body.get("familyId") != null
+                ? Long.valueOf(body.get("familyId").toString()) : null;
+        Long fromMemberId = body.get("fromMemberId") != null
+                ? Long.valueOf(body.get("fromMemberId").toString()) : null;
+        Long toMemberId = body.get("toMemberId") != null
+                ? Long.valueOf(body.get("toMemberId").toString()) : null;
+        Integer page = body.get("page") != null
+                ? Integer.valueOf(body.get("page").toString()) : 1;
+        Integer size = body.get("size") != null
+                ? Integer.valueOf(body.get("size").toString()) : 10;
+
+        // 跨家庭校验
+        if (familyId != null && !familyMemberService.isCallerInFamily(familyId, userId)) {
+          return Result.error("无权查看其他家庭的互动记录");
+        }
+        if (fromMemberId != null && !familyMemberService.isCallerInSameFamily(fromMemberId, userId)) {
+          return Result.error("无权查看其他家庭成员的互动记录");
+        }
+        if (toMemberId != null && !familyMemberService.isCallerInSameFamily(toMemberId, userId)) {
+          return Result.error("无权查看其他家庭成员的互动记录");
+        }
+
+        Page<InteractionLogVO> result = interactionLogService.listInteractions(
+                familyId, fromMemberId, toMemberId, page, size);
+        return Result.success(result);
+    }
+```
+
+- [ ] **Step 2: RelationshipQuestionnaireController — 注入 FamilyMemberService + 每个端点加校验**
+
+在 `RelationshipQuestionnaireController.java` 中添加注入(23 行之后):
+
+```java
+    @Resource
+    private com.etotem.cfc.service.FamilyMemberService familyMemberService;
+```
+
+将 `generateQuestionnaire` 方法(26-41 行)替换为:
+
+```java
+@Operation(summary = "AI 生成关系问卷")
+@PostMapping("/generate")
+public Result<QuestionnaireSnapshotVO> generateQuestionnaire(@RequestBody GenerateQuestionnaireDTO dto,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ dto.setRespondentId(userId);
+ // 跨家庭校验:memberId 所在家庭 == 用户所在家庭
+ if (dto.getMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getMemberId(), userId)) {
+   return Result.error("无权操作其他家庭成员");
+ }
+ try {
+   QuestionnaireSnapshotVO vo = questionnaireService.generateQuestionnaire(dto);
+   return Result.success(vo);
+ } catch (IllegalArgumentException e) {
+   return Result.error(e.getMessage());
+ } catch (RuntimeException e) {
+   return Result.error(e.getMessage());
+ }
+}
+```
+
+将 `submitQuestionnaire` 方法(44-59 行)替换为:
+
+```java
+@Operation(summary = "提交问卷答案")
+@PostMapping("/submit")
+public Result<RelationshipQuestionnaireResponse> submitQuestionnaire(@RequestBody SubmitQuestionnaireDTO dto,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ dto.setRespondentId(userId);
+ // 跨家庭校验:snapshot 所属成员所在家庭 == 用户所在家庭
+ if (dto.getSnapshotId() != null) {
+   var snapshot = questionnaireService.getSnapshotById(dto.getSnapshotId());
+   if (snapshot != null && !familyMemberService.isCallerInSameFamily(snapshot.getFamilyMemberId(), userId)) {
+     return Result.error("无权操作其他家庭的问卷");
+   }
+ }
+ try {
+   RelationshipQuestionnaireResponse resp = questionnaireService.submitQuestionnaire(dto);
+   return Result.success(resp);
+ } catch (IllegalArgumentException e) {
+   return Result.error(e.getMessage());
+ } catch (RuntimeException e) {
+   return Result.error(e.getMessage());
+ }
+}
+```
+
+将 `getSnapshot` 方法(62-74 行)替换为:
+
+```java
+@Operation(summary = "获取最新问卷快照")
+@PostMapping("/snapshot/{memberId}")
+public Result<QuestionnaireSnapshotVO> getSnapshot(@PathVariable Long memberId,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ // 跨家庭校验
+ if (!familyMemberService.isCallerInSameFamily(memberId, userId)) {
+   return Result.error("无权查看其他家庭的问卷");
+ }
+ try {
+   QuestionnaireSnapshotVO vo = questionnaireService.getSnapshot(memberId);
+   return Result.success(vo);
+ } catch (IllegalArgumentException e) {
+   return Result.error(e.getMessage());
+ }
+}
+```
+
+将 `getHistory` 方法(77-89 行)替换为:
+
+```java
+@Operation(summary = "获取问卷历史记录")
+@PostMapping("/history/{memberId}")
+public Result<List<RelationshipQuestionnaireResponse>> getHistory(@PathVariable Long memberId,
+ @RequestAttribute("userId") Long userId) {
+ if (userId == null) {
+   return Result.error("请先登录");
+ }
+ // 跨家庭校验
+ if (!familyMemberService.isCallerInSameFamily(memberId, userId)) {
+   return Result.error("无权查看其他家庭的问卷历史");
+ }
+ try {
+   List<RelationshipQuestionnaireResponse> history = questionnaireService.getHistory(memberId);
+   return Result.success(history);
+ } catch (IllegalArgumentException e) {
+   return Result.error(e.getMessage());
+ }
+}
+```
+
+> 注:`submitQuestionnaire` 方法中调用了 `questionnaireService.getSnapshotById(snapshotId)`,该方法需要在 Task 4 的 `RelationshipQuestionnaireService` 中追加(见 Step 3 下方补充)。
+
+- [ ] **Step 3: RelationshipQuestionnaireService — 追加 getSnapshotById 公开方法**
+
+在 `RelationshipQuestionnaireService.java` 中 `getSnapshot` 方法(130 行)之后,追加:
+
+```java
+    /**
+     * 按 ID 查询快照(供 Controller 跨家庭校验使用)
+     */
+    public RelationshipQuestionnaireSnapshot getSnapshotById(Long snapshotId) {
+        if (snapshotId == null) return null;
+        return snapshotMapper.selectById(snapshotId);
+    }
+```
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
+Expected: EXIT 0
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
+git commit -m "feat(backend): 互动/问卷 Controller 加跨家庭权限校验 + getSnapshotById 查询方法"
+```
+
+---
+
+## Task 6: AiGateway.generateQuestionnaire + Service 层 LangGraph 接入 + fallback mock
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` (追加新方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` (注入 AiGateway + generateQuestionnaire 改造)
+- Modify: `cfc-backend/src/main/resources/application.yml` (python.base-url 改为真实地址)
+
+- [ ] **Step 1: AiGateway — 追加 generateQuestionnaire 方法**
+
+在 `AiGateway.java` 的 `chat` 方法(182 行 `}` 之后),追加:
+
+```java
+    /**
+     * 调用 Python LangGraph 生成关系问卷 JSON
+     * @param inputs { member_name, relationship_type, family_context? }
+     * @return { "questionnaire_json": "...", "version": "..." } | null on failure
+     */
+    public Map<String, Object> generateQuestionnaire(Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("member_name", inputs.getOrDefault("member_name", "").toString());
+            body.put("relationship_type", inputs.getOrDefault("relationship_type", "").toString());
+            if (inputs.containsKey("family_context")) {
+                body.set("family_context", objectMapper.valueToTree(inputs.get("family_context")));
+            }
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/questionnaire/generate";
+
+            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("questionnaire_json", root.get("questionnaire_json").asText());
+                result.put("version", root.has("version") ? root.get("version").asText() : "");
+                consecutiveFailures.set(0);
+                log.debug("AiGateway generateQuestionnaire 成功");
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateQuestionnaire 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+```
+
+- [ ] **Step 2: RelationshipQuestionnaireService — 注入 AiGateway + generateQuestionnaire 改造**
+
+在 `RelationshipQuestionnaireService.java` 注入区(38 行之后)追加:
+
+```java
+    @Resource
+    private AiGateway aiGateway;
+```
+
+将 `generateQuestionnaire` 方法(47-86 行)中 `String aiJson = generateMockQuestionnaireJson(member);` 这一行替换为:
+
+```java
+        // 优先 LangGraph 出题,失败 fallback mock
+        java.util.Map<String, Object> aiInputs = new java.util.HashMap<>();
+        aiInputs.put("member_name", member.getNickname());
+        aiInputs.put("relationship_type", member.getGeneration() != null && member.getGeneration() < 0 ? "child" : "parent");
+        java.util.Map<String, Object> aiResult = aiGateway.generateQuestionnaire(aiInputs);
+
+        String aiJson;
+        if (aiResult != null && aiResult.get("questionnaire_json") != null) {
+            aiJson = (String) aiResult.get("questionnaire_json");
+        } else {
+            aiJson = generateMockQuestionnaireJson(member);
+            log.warn("LangGraph 不可用,fallback mock 问卷: memberId={}", dto.getMemberId());
+        }
+```
+
+> 注:原来 `String aiJson = generateMockQuestionnaireJson(member);` 整行替换为上述 if-else 块。
+
+- [ ] **Step 3: application.yml — python.base-url 改为真实 LangGraph 地址**
+
+将 `application.yml` 第 99 行从:
+```yaml
+  base-url: http://localhost:9000
+```
+改为:
+```yaml
+  base-url: https://ai.etotem.com.cn
+```
+
+- [ ] **Step 4: 编译验证**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
+Expected: EXIT 0
+
+- [ ] **Step 5: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java cfc-backend/src/main/resources/application.yml
+git commit -m "feat(backend): AiGateway.generateQuestionnaire LangGraph 接入 + fallback mock + python.base-url 指向 ai.etotem.com.cn"
+```
+
+---
+
+## Task 7: cfc-langgraph/ Python 项目骨架 + graph + endpoint + 测试
+
+**Files:**
+- Create: `cfc-langgraph/README.md`
+- Create: `cfc-langgraph/pyproject.toml`
+- Create: `cfc-langgraph/requirements.txt`
+- Create: `cfc-langgraph/.env.example`
+- Create: `cfc-langgraph/.gitignore`
+- Create: `cfc-langgraph/src/__init__.py`
+- Create: `cfc-langgraph/src/app.py`
+- Create: `cfc-langgraph/src/graphs/__init__.py`
+- Create: `cfc-langgraph/src/graphs/questionnaire.py`
+- Create: `cfc-langgraph/src/llm/__init__.py`
+- Create: `cfc-langgraph/src/llm/client.py`
+- Create: `cfc-langgraph/src/schemas/__init__.py`
+- Create: `cfc-langgraph/src/schemas/questionnaire.py`
+- Create: `cfc-langgraph/src/prompts/__init__.py`
+- Create: `cfc-langgraph/src/prompts/questionnaire.py`
+- Create: `cfc-langgraph/tests/__init__.py`
+- Create: `cfc-langgraph/tests/test_graph.py`
+
+- [ ] **Step 1: 创建项目骨架文件**
+
+```bash
+mkdir -p cfc-langgraph/src/graphs cfc-langgraph/src/llm cfc-langgraph/src/schemas cfc-langgraph/src/prompts cfc-langgraph/tests
+```
+
+- [ ] **Step 2: cfc-langgraph/pyproject.toml**
+
+```toml
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.backends._legacy:_Backend"
+
+[project]
+name = "cfc-langgraph"
+version = "0.1.0"
+description = "CFC LangGraph 问卷生成服务"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.110,<1.0",
+    "uvicorn[standard]>=0.29,<1.0",
+    "langgraph>=0.2,<1.0",
+    "langchain-openai>=0.2,<1.0",
+    "langchain-core>=0.3,<1.0",
+    "pydantic>=2.0,<3.0",
+    "python-dotenv>=1.0,<2.0",
+    "httpx>=0.27,<1.0",
+]
+```
+
+- [ ] **Step 3: cfc-langgraph/requirements.txt**
+
+```
+fastapi>=0.110,<1.0
+uvicorn[standard]>=0.29,<1.0
+langgraph>=0.2,<1.0
+langchain-openai>=0.2,<1.0
+langchain-core>=0.3,<1.0
+pydantic>=2.0,<3.0
+python-dotenv>=1.0,<2.0
+httpx>=0.27,<1.0
+pytest>=8.0,<9.0
+```
+
+- [ ] **Step 4: cfc-langgraph/.env.example**
+
+```
+OPENAI_API_KEY=sk-your-key-here
+OPENAI_BASE_URL=https://api.openai.com/v1
+LLM_MODEL=gpt-4o-mini
+HOST=0.0.0.0
+PORT=9000
+```
+
+- [ ] **Step 5: cfc-langgraph/.gitignore**
+
+```
+__pycache__/
+*.pyc
+.env
+.venv/
+dist/
+*.egg-info/
+```
+
+- [ ] **Step 6: cfc-langgraph/src/schemas/questionnaire.py**
+
+```python
+from pydantic import BaseModel, Field
+from typing import List, Optional, Union
+
+
+class Option(BaseModel):
+    id: str
+    score: float
+
+
+class Scale(BaseModel):
+    min: float = 0
+    max: float = 4
+
+
+class Question(BaseModel):
+    id: str
+    dimension: str = Field(pattern="^(trust|intimacy|communication)$")
+    direction: str = Field(default="positive", pattern="^(positive|negative)$")
+    weight: float = 1.0
+    text: str
+    options: Optional[List[Option]] = None
+    scale: Optional[Scale] = None
+
+
+class Questionnaire(BaseModel):
+    version: int = 1
+    questions: List[Question]
+
+    def validate_structure(self) -> bool:
+        """至少 1 题,每题有 options 或 scale"""
+        if not self.questions:
+            return False
+        for q in self.questions:
+            if q.options is None and q.scale is None:
+                return False
+        return True
+
+
+class GenerateRequest(BaseModel):
+    member_name: str
+    relationship_type: str = Field(pattern="^(parent|child)$")
+    family_context: Optional[dict] = None
+
+
+class GenerateResponse(BaseModel):
+    questionnaire_json: str
+    version: str
+```
+
+- [ ] **Step 7: cfc-langgraph/src/prompts/questionnaire.py**
+
+```python
+PARENT_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""
+
+CHILD_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""
+```
+
+- [ ] **Step 8: cfc-langgraph/src/llm/client.py**
+
+```python
+import os
+from langchain_openai import ChatOpenAI
+
+
+def get_llm() -> ChatOpenAI:
+    return ChatOpenAI(
+        model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
+        api_key=os.getenv("OPENAI_API_KEY", ""),
+        base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
+        temperature=0.7,
+    )
+```
+
+- [ ] **Step 9: cfc-langgraph/src/graphs/questionnaire.py**
+
+```python
+import json
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import HumanMessage
+
+from ..llm.client import get_llm
+from ..prompts.questionnaire import PARENT_TEMPLATE, CHILD_TEMPLATE
+from ..schemas.questionnaire import Questionnaire, GenerateRequest
+
+
+class GraphState(TypedDict):
+    request: GenerateRequest
+    raw_response: str
+    questionnaire: Optional[dict]
+    error: Optional[str]
+
+
+def build_prompt(state: GraphState) -> GraphState:
+    req = state["request"]
+    template = PARENT_TEMPLATE if req.relationship_type == "parent" else CHILD_TEMPLATE
+    prompt = template.format(member_name=req.member_name)
+    return {**state, "_prompt": prompt}
+
+
+def call_llm(state: GraphState) -> GraphState:
+    llm = get_llm()
+    messages = [HumanMessage(content=state["_prompt"])]
+    response = llm.invoke(messages)
+    return {**state, "raw_response": response.content}
+
+
+def validate(state: GraphState) -> GraphState:
+    raw = state["raw_response"]
+    # 提取 JSON 块
+    text = raw.strip()
+    if "```json" in text:
+        text = text.split("```json")[1].split("```")[0].strip()
+    elif "```" in text:
+        text = text.split("```")[1].split("```")[0].strip()
+
+    try:
+        data = json.loads(text)
+        q = Questionnaire(**data)
+        if not q.validate_structure():
+            return {**state, "error": "问卷结构校验失败:至少需要1题且每题有options或scale"}
+        return {**state, "questionnaire": data, "error": None}
+    except Exception as e:
+        return {**state, "error": f"JSON 解析失败: {str(e)}"}
+
+
+def build_questionnaire_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("build_prompt", build_prompt)
+    graph.add_node("call_llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "build_prompt")
+    graph.add_edge("build_prompt", "call_llm")
+    graph.add_edge("call_llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_questionnaire_graph = None
+
+
+def get_questionnaire_graph():
+    global _questionnaire_graph
+    if _questionnaire_graph is None:
+        _questionnaire_graph = build_questionnaire_graph()
+    return _questionnaire_graph
+```
+
+- [ ] **Step 10: cfc-langgraph/src/app.py**
+
+```python
+import os
+import json
+import uuid
+from datetime import datetime
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+
+from .schemas.questionnaire import GenerateRequest, GenerateResponse
+from .graphs.questionnaire import get_questionnaire_graph
+
+app = FastAPI(title="CFC LangGraph 问卷生成服务")
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+@app.post("/api/v1/questionnaire/generate")
+async def generate_questionnaire(req: GenerateRequest):
+    graph = get_questionnaire_graph()
+    try:
+        result = graph.invoke({
+            "request": req,
+            "raw_response": "",
+            "questionnaire": None,
+            "error": None,
+        })
+        if result.get("error"):
+            return JSONResponse(
+                status_code=500,
+                content={"error": result["error"]}
+            )
+        version = datetime.now().strftime("%Y%m%d%H%M%S")
+        return GenerateResponse(
+            questionnaire_json=json.dumps(result["questionnaire"], ensure_ascii=False),
+            version=version,
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"error": f"graph 执行失败: {str(e)}"}
+        )
+```
+
+- [ ] **Step 11: cfc-langgraph/src/__init__.py + 各子目录 __init__.py**
+
+```bash
+touch cfc-langgraph/src/__init__.py cfc-langgraph/src/graphs/__init__.py cfc-langgraph/src/llm/__init__.py cfc-langgraph/src/schemas/__init__.py cfc-langgraph/src/prompts/__init__.py cfc-langgraph/tests/__init__.py
+```
+
+- [ ] **Step 12: cfc-langgraph/tests/test_graph.py**
+
+```python
+import json
+from unittest.mock import patch, MagicMock
+from src.schemas.questionnaire import GenerateRequest, Questionnaire, Question, Option
+
+
+def _mock_llm_response():
+    """返回合规 JSON 的 mock LLM 响应"""
+    mock_msg = MagicMock()
+    mock_msg.content = json.dumps({
+        "version": 1,
+        "questions": [
+            {
+                "id": "q1",
+                "dimension": "trust",
+                "direction": "positive",
+                "weight": 1.0,
+                "text": "您对小明的信任程度如何?",
+                "options": [
+                    {"id": "a", "score": 0},
+                    {"id": "b", "score": 1},
+                    {"id": "c", "score": 2},
+                ],
+            }
+        ]
+    }, ensure_ascii=False)
+    return mock_msg
+
+
+def _mock_llm_invalid_response():
+    mock_msg = MagicMock()
+    mock_msg.content = "这不是JSON"
+    return mock_msg
+
+
+class TestQuestionnaireSchema:
+    def test_valid_questionnaire(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test",
+                     options=[Option(id="a", score=0)])
+        ])
+        assert q.validate_structure() is True
+
+    def test_empty_questions_fails_validation(self):
+        q = Questionnaire(version=1, questions=[])
+        assert q.validate_structure() is False
+
+    def test_question_without_options_or_scale_fails(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test")
+        ])
+        assert q.validate_structure() is False
+
+
+class TestGraph:
+    def test_graph_valid_output(self):
+        with patch("src.llm.client.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is None
+            assert result["questionnaire"] is not None
+            assert len(result["questionnaire"]["questions"]) == 1
+
+    def test_graph_invalid_llm_response_rejected(self):
+        with patch("src.llm.client.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_invalid_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is not None
+            assert result["questionnaire"] is None
+```
+
+- [ ] **Step 13: cfc-langgraph/README.md**
+
+```markdown
+# CFC LangGraph 问卷生成服务
+
+关系评估问卷 AI 出题 graph,部署到 `ai.etotem.com.cn`。
+
+## 启动
+
+```bash
+cd cfc-langgraph
+pip install -r requirements.txt
+cp .env.example .env  # 填写 OPENAI_API_KEY / OPENAI_BASE_URL / LLM_MODEL
+uvicorn src.app:app --host 0.0.0.0 --port 9000
+```
+
+## 端点
+
+| Method | Path | 说明 |
+|--------|------|------|
+| GET | `/health` | 健康检查 |
+| POST | `/api/v1/questionnaire/generate` | 生成关系问卷 JSON |
+
+## 测试
+
+```bash
+cd cfc-langgraph
+pytest tests/ -v
+```
+
+## 契约
+
+Request:
+```json
+{
+  "member_name": "小明",
+  "relationship_type": "child",
+  "family_context": {}
+}
+```
+
+Response:
+```json
+{
+  "questionnaire_json": "<stringified JSON>",
+  "version": "20260804115900"
+}
+```
+```
+
+- [ ] **Step 14: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-langgraph/
+git commit -m "feat(langgraph): cfc-langgraph 项目骨架 + 问卷生成 graph + FastAPI endpoint + 测试"
+```
+
+---
+
+## Task 8: cfc-backend/AGENTS.md 同步 + PROJECT-OVERVIEW 更新
+
+**Files:**
+- Modify: `cfc-backend/AGENTS.md` (COMMANDS 区域追加 Python 服务启动命令)
+- Modify: `docs/superpowers/PROJECT-OVERVIEW.md` (关系域条目从"设计稿"改为"已实施")
+
+- [ ] **Step 1: cfc-backend/AGENTS.md COMMANDS 区域追加**
+
+在 `cfc-backend/AGENTS.md` 的 COMMANDS 区域(`mvn test` 行之后)追加:
+
+```markdown
+# Python LangGraph 服务(部署到 ai.etotem.com.cn)
+cd cfc-langgraph && pip install -r requirements.txt && uvicorn src.app:app --port 9000
+```
+
+- [ ] **Step 2: PROJECT-OVERVIEW.md 关系域条目更新**
+
+将 `docs/superpowers/PROJECT-OVERVIEW.md` 中:
+```
+| 关系问卷与互动记录功能补全 | 🟡 设计稿(2026-08-04) | `specs/2026-08-04-relationship-feedback-fix-design.md` |
+```
+改为:
+```
+| 关系问卷与互动记录功能补全 | ✅ 已实施(3表迁移154-156+评分算法+LangGraph接入+跨家庭校验+编辑bug修复) | `specs/2026-08-04-relationship-feedback-fix-design.md` + `plans/2026-08-04-relationship-feedback-fix.md` |
+```
+
+- [ ] **Step 3: 提交**
+
+```bash
+cd /sc-data/cfc
+git add cfc-backend/AGENTS.md docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: AGENTS.md 追加 LangGraph 启动命令 + PROJECT-OVERVIEW 关系域标记已实施"
+```
+
+---
+
+## Task 9: 全量验证 + 最终推送
+
+**Files:** 无新文件修改
+
+- [ ] **Step 1: 后端全量打包**
+
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean package -DskipTests -q; echo "EXIT: $?"`
+Expected: EXIT 0
+
+- [ ] **Step 2: 小程序构建**
+
+Run: `cd /sc-data/cfc/cfc-frontend && npm run build:mp-weixin 2>&1 | tail -3; echo "EXIT: $?"`
+Expected: EXIT 0
+
+- [ ] **Step 3: 管理端构建**
+
+Run: `cd /sc-data/cfc/cfc-web && npm run build 2>&1 | tail -3; echo "EXIT: $?"`
+Expected: EXIT 0
+
+- [ ] **Step 4: 推送**
+
+```bash
+cd /sc-data/cfc
+git status
+git push origin cfclub
+```
+
+Expected: 推送成功,分支与 origin 同步。
+
+---
+
+## Self-Review 清单
+
+| 检查项 | 结果 |
+|--------|------|
+| Spec 覆盖:A 档 3 张表 | ✅ Task 1 |
+| Spec 覆盖:B 档 FamilyMemberStrip bug | ✅ Task 2 |
+| Spec 覆盖:C-2 评分算法 | ✅ Task 4 (5 个测试 case 覆盖正向/反向/权重/降级/缺题) |
+| Spec 覆盖:C-3 权限校验 | ✅ Task 3 + Task 5 (FamilyMemberService + 两个 Controller) |
+| Spec 覆盖:C-1 LangGraph | ✅ Task 6 (Java 侧) + Task 7 (Python 侧) |
+| Spec 覆盖:迁移编号 154/155/156 | ✅ Task 1 |
+| Spec 覆盖:application.yml base-url | ✅ Task 6 Step 3 |
+| Spec 覆盖:PROJECT-OVERVIEW | ✅ Task 8 |
+| 占位符扫描:无 TBD/TODO | ✅ |
+| 类型一致性:answersJson Map 格式 vs calculateScoresFromAnswers | ✅ Task 4 测试用例与 spec 3.3.1.1 对齐 |
+| 验收项 V1-V11 覆盖 | ✅ Task 1→V2,V3; Task 2→V9,V10; Task 4→V4,V5; Task 5→V8; Task 6→V7; Task 7→V6,V11; Task 9→V1,V10 |