# 关系问卷与互动记录功能补全设计 **优先级:** P1 **预计工时:** 1.5 个工作日 **日期:** 2026-08-04 **状态:** 设计稿(待用户审阅) **关联:** `specs/2026-06-15-family-member-cards-design.md`、`plans/2026-07-22-comprehensive-implementation.md`(Companion Task T15/T16) ## 一、用户故事 **As a** 家庭家长(家庭创建者)和家庭成员, **I want** 在小程序中长按家庭成员后能"答关系问卷"和"记录互动",并能稳定看到关系质量评分, **So that** 我们可以量化与每位家人的信任/亲密/沟通水平,追踪互动记录。 **关键漏洞**:现有代码已有后端骨架(Entity/DTO/Mapper/Service/Controller 全在)和前端页面(已注册),但跑起来必然报错——3 张数据表从未建。同时出题走的是 mock,FamilyMemberStrip 长按菜单的"编辑资料"还有参数缺失 bug。 ## 二、范围(A+B+C,全补齐) | 档位 | 工作内容 | 验证点 | |------|---------|--------| | A 阻塞性 | 3 张表迁移 + schema.sql 同步 | 重启后端不报 Table doesn't exist | | B 显式 bug | FamilyMemberStrip 编辑资料回显 | 长按编辑后表单字段已回填 | | C-1 AI 出题 | 用 LangGraph(Python 独立项目,部署到 `ai.etotem.com.cn`)替代 mock 出题 | LangGraph 离线时 fallback mock 不阻塞 | | C-2 评分机制 | 后端按题目维度标签加权算法(替换 sketch 75/70/80 mock) | 答题后 trust/intimacy/communication 与手算一致 | | C-3 权限校验 | 拦截跨家庭资源访问(不限管理员) | 跨家庭请求被拒 | **不做(YAGNI)**: - ❌ 不动 `FamilyMembersController` 已有的 admin 校验(编辑资料/移除成员仍限管理员) - ❌ 不引入新 DTO 异常类(沿用 `RuntimeException` + `Result.error` 项目既有风格) - ❌ 不引入 migration 测试套件(按 AGENTS.md "幂等即视为完成") - ❌ 不改造前端 `FamilyMemberStrip` 6 项菜单结构、不动 `isParent` 链路 - ❌ 不实现 Python 服务部署脚本/Docker 化(由运维处理 `ai.etotem.com.cn` 部署) - ❌ 不补 `interaction_bonus` 的 +360 天业务计算(已确认 `RelationshipQualityService` 不含该计算;本次保留 `BigDecimal.ZERO` + `// rewardBonus 待引入` 注释) ## 三、技术设计 ### 3.1 数据层补全(A 档) 按 `cfc-backend/AGENTS.md` "数据库迁移工作流",在 `DatabaseInitializer.runMigrations()` 末尾按编号递增(当前最大编号 **153**,新增用 **154/155/156**),同步追加 `schema.sql` 的 CREATE TABLE。 | 表名 | 实体类(已存在) | 关键列 | 索引 | |------|--------------|------|------| | `interaction_logs` | `InteractionLog` | id, from_member_id, to_member_id, family_id, interaction_type, description, happened_at, created_at | `idx_ilog_family (family_id)`、`idx_ilog_to_member (to_member_id)` | | `relationship_questionnaire_snapshots` | `RelationshipQuestionnaireSnapshot` | id, family_member_id, respondent_id, member_name, relationship_type, ai_generated_json (TEXT), version, created_at | `idx_q_snap_member (family_member_id)` | | `relationship_questionnaire_responses` | `RelationshipQuestionnaireResponse` | id, snapshot_id, respondent_id, answers_json (TEXT), trust_score, intimacy_score, communication_score, interaction_bonus, total_score, calculated_at | `idx_q_resp_snap (snapshot_id)` | 字段名严格对齐已有实体类。所有迁移 `try-catch` 幂等 + `log.info` 成功日志。 ### 3.2 FamilyMemberStrip 编辑资料 Bug(B 档) **位置**:`cfc-frontend/components/FamilyMemberStrip.vue:124-130` `editMember()` **根因**:只传 `memberId`,不传 nickname/gender/phone/birthday,导致 `add-member.vue` 进入编辑模式时 `onLoad` 因缺少这些 query 参数而表单空白。 **修复**:参照 `cfc-frontend/pages/profile-extra/family-members.vue:277-283` 的 `onEditRelation()` 补全 query 参数: ```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 }) } } ``` 无新增依赖、无 schema 变更、零设计风险。 ### 3.3 评分机制(C-2 档) 放弃 `RelationshipQuestionnaireService.calculateScoresFromAnswers` 现有之 mock 固定 75/70/80,改为**后端按题目维度标签加权**。 #### 3.3.1 题目 JSON 契约(LangGraph 出题必须遵循) ```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} ] } ] } ``` 或滑尺题型(scale 型)用 `"scale": {"min": 0, "max": 4}` 替代 `options`。 字段说明: - `dimension`: `trust` | `intimacy` | `communication`(对应实体三档字段名) - `direction`: `positive` | `negative`(negative 题做反向计分) - `weight`: 题目相对权重,默认 1.0 - `options[*].id`: 选项 ID(题内唯一) - `options[*].score`: 选项驱动型题的分值 - `scale.min/max`: 滑尺驱动型题的取值范围(输出归一到 0–100) #### 3.3.1.1 答案 JSON 契约(前端 → 后端) 前端 `onSubmit` 提交的 `answersJson` 是字符串化的 **Map(非数组)**,键为 questionId,值为题目模板里整个 option 对象: ```json { "q1": {"id": "a", "score": 0}, "q2": {"id": "b", "score": 2} } ``` **滑尺题型**对应值为 `{"scoreValue": <用户选择的数值>}`(在 scale.min/max 区间内)。 > 注:当前 `cfc-frontend/pages/action-detail/relationship-questionnaire.vue:selectOption` 直接赋值整个选中 option,符合此 Map 结构;前端无需改造,spec 仅约定前端已生产结构作为契约。LangGraph 出题模板需保持 `options[*].id/score` 字段名一致以便前端 `selectOption` 直接复用。 #### 3.3.2 评分流程 1. 解析 `snapshot.aiGeneratedJson` 拿到 questions 索引(题目维度/方向/权重/选项分值或 scale) 2. 解析 `answersJson`——前端格式为 Map(详见 3.3.1.1),键 `questionId`、值 `{id, score}` 或 `{scoreValue}`;按 questionId 与 questions 配对 3. 题型 A(选项驱动,题目有 `options`):取 `answer.id`,回查题目 options 中 `options[*].id==answer.id` 拿 score,而非信任前端 answer.score(后端权威计分,防篡改) 4. 题型 B(滑尺驱动,题目有 `scale`):取 `answer.scoreValue`,线性归一到 0–100: `normalized = (scoreValue - scale.min) / (scale.max - scale.min) * 100` 5. `direction=negative` 的题(无论题型):`rawScore = maxScore - rawScore`(反向),`maxScore` 对题型 A 是 max(options[*].score)、对题型 B 是 100 6. 按 dimension 聚合:`dimScore = Σ(adjustedScore × weight) / Σ(weight)`,结果范围 0–100 7. 取 threshold/100 的 `Trust`/`Intimacy`/`Communication` 进行按题维度归属分配;总计 `total = mean(trust, intimacy, communication)`(仅平均**有题**的维度,不平均空维度) 8. `interactionBonus` 保留——已确认 `RelationshipQualityService` 不做该计算,本版保留 `BigDecimal.ZERO` + `// rewardBonus 待引入` 注释。`totalScore` 最终 = `mean + interactionBonus`,**封顶 100**(interactionBonus 当前为 0,预留扩展位)。 > 若因前 6 步输错,某 dim 无题,该 dim 记 0,**只**有题维度参与 mean 计算——避免 0 拖低均分。 #### 3.3.3 降级策略 - `snapshot.aiGeneratedJson` 损坏 / JSON 不合规:`trustScore = intimacyScore = communicationScore = -1` 标记(前端展示"本次答题无效"),不抛错、不回滚 snapshot 写入但 response 仍存 - 答案缺题:对应题记 score=0 不影响聚合 - 某维度无题:dimScore = 0,`total` 仅平均有题维度 ### 3.4 LangGraph 接入(C-1 档) 接入锚点已被 `AiGateway` 占好:`python.enabled=true`、`python.base-url=http://localhost:9000`、`AiGateway` 已封装 `python.enabled`/熔断/fallback 模式,已有 `/api/v1/recommend`、`/api/v1/chat` 两端点。 #### 3.4.1 cfc-langgraph/ 目录结构(与 `cfc-backend` 平级新建) ``` cfc-langgraph/ ├── README.md # 部署/启动说明 ├── pyproject.toml # 依赖与构建元数据 ├── requirements.txt # 锁定依赖 ├── .env.example # 模板: OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL, HOST, PORT ├── .gitignore ├── src/ │ ├── app.py # FastAPI 入口,暴露 /api/v1/* 端点 │ ├── graphs/ │ │ └── questionnaire.py # LangGraph 问卷生成 graph │ ├── llm/ │ │ └── client.py # OPENAI 兼容 client(base_url + model + key) │ ├── schemas/ │ │ └── questionnaire.py # Pydantic 契约(Question, Questionnaire) │ └── prompts/ │ └── questionnaire.py # 题目生成 prompt 模板(parent/child 分支) └── tests/ └── test_graph.py ``` #### 3.4.2 Java 侧调用改造 **`AiGateway.java`** 新增方法 `generateQuestionnaire(inputs)`:复用既有熔断/fallback,失败返回 null 由调用方决定 fallback。 **`RelationshipQuestionnaireService.generateQuestionnaire`**:替换 `generateMockQuestionnaireJson` 调用——优先 `aiGateway.generateQuestionnaire(inputs)`,失败/null 时 fallback 现有 mock(保留 mock 方法不删)。Inputs 至少:`member_name`、`relationship_type`(child/parent)。 **评分不走 LangGraph**——`calculateScoresFromAnswers` 实现在 Java,完全按 3.3 契约解析 + 维度加权,与 LangGraph 解耦。 #### 3.4.3 HTTP 契约(Java ↔ Python) **Request** `POST /api/v1/questionnaire/generate`: ```json { "member_name": "小明", "relationship_type": "child", "family_context": { "parent_count": 2, "sibling_count": 1 } } ``` **Response 200**: ```json { "questionnaire_json": "", "version": "20260804115900" } ``` `questionnaire_json` 是字符串化,直接写入 `snapshots.ai_generated_json` TEXT 列——与现有 SQLite-style 字符串字段一致,不改实体。 **错误**:Python 服务 5xx/超时 → Java `generateQuestionnaire()` 返回 null → Service fallback mock。 #### 3.4.4 LangGraph graph 设计(Python 侧) `graphs/questionnaire.py` 一个 StateGraph: - 入口 → `build_prompt`(基于 `relationship_type` 选 parent/child 模板) - → `call_llm`(`client.py` 的 OPENAI 兼容调用,要求 LLM 返回严格 JSON) - → `validate`(Pydantic 校验题目结构、≥1 题、每题有 `dimension/direction/weight/options 或 scale`) - → 出口返回 State LLM key/model 由 `.env` 注入,不进 git。 #### 3.4.5 配置变更 - `application.yml` 不需要新增 key(`python.enabled`、`python.base-url` 已存在) - 仅在 `cfc-langgraph/.env.example` 里列出 Python 服务侧 env - `cfc-backend/AGENTS.md` 的 COMMANDS 区域追加: ``` cd cfc-langgraph && uvicorn src.app:app --port 9000 # 启动 Python LangGraph 服务(部署到 ai.etotem.com.cn) ``` #### 3.4.6 履约边界 - **我交付**:Java 侧 `AiGateway.generateQuestionnaire` + Service 改造 + 完整 `cfc-langgraph/` Python 项目骨架(graph、client、schemas、prompts、FASTAPI endpoint、README、tests) - **你交付**:部署 `cfc-langgraph` 到 `ai.etotem.com.cn`,配 `.env` LLM key - **测试**:LangGraph 不可用走 fallback mock;`mvn clean package -DskipTests` 仍通过(Python 不参与 Java 编译) - **风险**:Python 项目首次提交时可能依赖版本漂移——`requirements.txt` 锁版本规避 ### 3.5 权限校验(C-3 档) #### 3.5.1 范围 按用户已确认选项"仅拦截跨家庭资源访问"——对 4 个 Controller 端点校验"操作者所属家庭 == 被操作资源所属家庭",**不**要求操作者必须是家庭创建者(既有的"编辑资料/移除成员"管理员校验不动)。 #### 3.5.2 已有 Java 资产(复用) | 资产 | 位置 | 用途 | |------|------|------| | `@RequestAttribute("userId")` | JwtInterceptor 已注入 | 取操作者 JWT userId | | `FamilyMember.familyId` | FamilyMember 实体 | 反查家庭归属 | | `FamilyMemberMapper.selectOne(familyId + userId == ?)` | 现有 Mapper | 鉴别 userId 是否在 family_members 里 | 写代码阶段已确认 `FamilyMemberService.isMemberOfFamily(familyId, userId)` **不存在**——补一个最小实现(查 `FamilyMemberMapper.selectCount(familyId + userId == ?)`,返回 boolean),**不新增 utility 类**。本设计直接规划该 helper。 #### 3.5.3 端点校验矩阵 | Controller / 端点 | 校验内容 | 失败响应 | |----|----|----| | `InteractionLogController.add` | 取 JWT userId → 找其 familyId(查 FamilyMemberMapper where user_id=userId limit 1);取 dto.fromMemberId → 查其 familyId;相等才放行 | `Result.error("无权操作其他家庭")` | | `InteractionLogController.list` | 校验 dto.familyId == JWT 用户所在家庭 | 同上 | | `RelationshipQuestionnaireController.generate` | 取 dto.memberId → 查 familyId;JWT userId 在该家庭 | 同上 | | `RelationshipQuestionnaireController.submit` | 取 snapshotId → snapshot.familyMemberId → familyId;JWT userId 在该家庭 | 同上 | | `RelationshipQuestionnaireController.snapshot/{memberId}` | 校验 memberId 所在家庭 == JWT 家庭 | 同上 | | `RelationshipQuestionnaireController.history/{memberId}` | 同上 | 同上 | | `InteractionLogController.types` | **无需校验**(静态字典) | — | #### 3.5.4 校验封装 不把校验逻辑散进每个 Controller 方法,抽 Service 私有方法(两 Service 各持一份局部实现,因两 Service 的 familyId 来源不同——直接抽公共类反增耦合): ```java private void assertMemberInCallerFamily(Long memberId, Long callerUserId) { FamilyMember member = familyMemberMapper.selectById(memberId); if (member == null) throw new RuntimeException("成员不存在"); FamilyMember caller = familyMemberMapper.selectOne( new LambdaQueryWrapper() .eq(FamilyMember::getUserId, callerUserId) .eq(FamilyMember::getFamilyId, member.getFamilyId()) .last("LIMIT 1") ); if (caller == null) throw new RuntimeException("无权操作其他家庭资源"); } ``` `RuntimeException` 与 `FamilyMemberService.kickMember` 现有风格一致(已用 `throw new RuntimeException("只有家庭管理员才能踢出成员")`),由 Controller `@ExceptionHandler` 或全局 `ResultAdvice` 包装为 `Result.error`。 #### 3.5.5 不变更的部分 - `FamilyMembersController` 的"编辑资料/移除成员"已有 `creatorId.equals(userId)` 校验,**不动** - 前端 `FamilyMemberStrip` 的 `canEdit` prop 既有 `isParent` 判断保持不动 - 前端 `canEditMember` 的 `source === 'family_member'` 检查保持不动 ## 四、验收标准 每项以"完全可验证"为准。 | 序号 | 验收项 | 命令/方法 | 通过判据 | |------|------|---------|------| | V1 | 后端编译 | `/bwydata/maven/bin/mvn clean compile` (workdir=`cfc-backend`) | EXIT 0 | | V2 | 3 张表迁移幂等 | 静态校验:迁移代码均 `try-catch` 包裹;`schema.sql` 三条 `CREATE TABLE` 与迁移对齐 | 按 `cfc-backend/AGENTS.md`"迁移代码幂等即视为完成",不要求运行时验证 | | V3 | schema.sql 完整性 | `grep` 3 张表名都在 schema.sql | 三条 CREATE TABLE 都在 | | V4 | 评分机制替换 mock | 单测 `RelationshipQuestionnaireServiceTest.calculateScores_positive_negative_options` | trust/intimacy/communication 得分与手算一致 | | V5 | 评分降级 | 单测 `calculateScores_invalidJson_returnsNegativeOne` | 不抛异常,dimScore=-1 | | V6 | LangGraph 接入可用(在线时) | Python 服务在线时 POST `/api/v1/questionnaire/generate` | 含 `questionnaire_json` 字段 | | V7 | LangGraph fallback(离线时) | Python 离线(端口未监听)时 Service 调 `aiGateway.generateQuestionnaire()` 返回 null → fallback mock | snapshot 仍写入,日志含 "fallback mock" | | V8 | 跨家庭资源拦截 | 单测 `assertMemberInCallerFamily_diffFamily_throws` | RuntimeException 抛出 "无权操作其他家庭资源" | | V9 | 编辑资料回显 | 手动:家长长按家庭成员工具条 → 编辑资料 → 表单字段已回填 nickname/gender/phone/birthday | 不空白 | | V10 | 两前端构建 | `npm run build:mp-weixin` (workdir=`cfc-frontend`) + `npm run build` (workdir=`cfc-web`) | 退出码 0 | | V11 | Python 项目可启动 | `cd cfc-langgraph && pip install -r requirements.txt && uvicorn src.app:app --port 9000` | FastAPI 启动,`/health` 200 | V4-V5、V8 是新增单元测试,其余依赖运行/手动验收。 ## 五、测试策略 **后端**(`tests/`),按 `tests/AGENTS.md` 分层: - **unit** 层加 3 个测试类: - `RelationshipQuestionnaireServiceTest` — 评分正向/反向/选项驱动/滑尺驱动/降级 mock 5 个 case - `InteractionLogServiceTest` — 跨家庭拦截 1 case - `RelationshipQuestionnaireServicePermissionTest` — 跨家庭生成问卷/提交答题拦截 2 case - 避开预存 166 个 BeanCreation 测试噪音(AGENTS.md 已声明这是预存,与本次无关) **Python 侧**:`cfc-langgraph/tests/test_graph.py` - `test_graph_valid_output` — mock LLM client 返回合规 JSON,validate 通过 - `test_graph_invalid_llm_response_rejected` — mock LLM 返回非法 JSON,validate 抛错 **前端**:V9 手动冒烟,不引入端到端测试框架(Vue 2 Options API + uni-app 测试成本高,沿用现状)。 ## 六、交付里程碑 按依赖顺序拆分(在 writing-plans 阶段写成完整实施计划): 1. **迁移 + schema.sql 同步** + `mvn clean compile`(V1-V3) 2. **后端单元测试先行**(TDD 风格)— 评分降级用例、跨家庭拦截用例红 → 实现 → 绿(V4-V5, V8) 3. **后端 Service 实现** — 替换 `calculateScoresFromAnswers`,加 `assertMemberInCallerFamily`,端点调用补齐 4. **Java `AiGateway.generateQuestionnaire`** 方法(V7) 5. **`cfc-langgraph/` Python 项目骨架** — graph + endpoint + schemas + prompts + tests(V6, V11) 6. **`FamilyMemberStrip.vue` 修复**(V9) 7. **两前端构建**(V10) 8. **`cfc-langgraph` 的 `.env.example` + README 部署说明**(交付给运维) 每步通过对应 V 验收。 ## 七、关联设计与风险 ### 7.1 关联 - `specs/2026-06-15-family-member-cards-design.md`:FamilyMemberStrip/家庭卡片本身的设计 - `plans/2026-07-22-comprehensive-implementation.md` 的 Task T15/T16:原计划标注 AI 问卷生成(T15)与复杂评分计算(T16)"实际在 T15/T16 实现"——本设计落地这两档 ### 7.2 风险与备注 1. **`isMemberOfFamily` 不存在(已验证)**:写代码阶段 grep 确认无该方法;按 3.5.2 规划补一个最小 Service 方法,不新增 utility 类。 2. **`RelationshipQualityService` 不含 interactionBonus 计算(已验证)**:本设计评分聚合**直接放进** `RelationshipQuestionnaireService`,与 `RelationshipQualityService` 完全解耦;`interactionBonus` 本版保留 `BigDecimal.ZERO` + `// rewardBonus 待引入` 注释,**不引用** `RelationshipQualityService`。后续若引入 +360 天加权,再单独评估挂钩点。 3. **OpenAI 兼容 LLM 配置**:Python 服务的 LLM key 由 `.env` 注入,不进 git。 4. **题量/题目维度**:不强约束;Python validate 仅校验"每题有 `dimension/direction/weight/options 或 scale` 中的一种",不出题数量硬上限。 5. **历史预存测试失败**:测试套件有 BeanCreation/NoClassDefFound 类历史失败(与本工作无关,`tests/AGENTS.md` 早已声明);新增测试须独立可跑、不被牵连。 ## 八、变更追踪 按 `docs/superpowers/AGENTS.md` 约定,本设计文档提交后同步更新 `docs/superpowers/PROJECT-OVERVIEW.md` 中"家庭关系域"条目(状态、版本号、文档索引)。