Ver Fonte

docs: 关系问卷与互动记录功能补全设计稿

iwt há 3 meses atrás
pai
commit
1079a588d4

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -67,6 +67,7 @@
 | 家庭成员关系图谱(Canvas重设计) | ✅ 已实施,广泛集成(FamilyRelationGraph.vue 797行,10+页面使用) | `specs/2026-06-19-family-relation-graph-design.md` + `specs/2026-06-22-family-relation-graph-redesign.md` |
 | 家庭成员卡片(跨页展示+可见性) | ✅ 已上线 | `specs/2026-06-15-family-member-cards-design.md` + `plans/2026-06-15-family-member-cards.md` |
 | 成员详情页维度专属内容区 | ✅ 已实施(member-detail.vue 505行 + 4维度section组件 879行) | `specs/2026-07-14-member-detail-dimension-sections.md` |
+| 关系问卷与互动记录功能补全 | 🟡 设计稿(2026-08-04) | `specs/2026-08-04-relationship-feedback-fix-design.md` |
 
 ---
 

+ 349 - 0
docs/superpowers/specs/2026-08-04-relationship-feedback-fix-design.md

@@ -0,0 +1,349 @@
+# 关系问卷与互动记录功能补全设计
+
+**优先级:** 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": "<stringified JSON per 3.3.1 contract>",
+  "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<FamilyMember>()
+            .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` 中"家庭关系域"条目(状态、版本号、文档索引)。