2026-08-04-relationship-feedback-fix-design.md 20 KB

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

优先级: P1
预计工时: 1.5 个工作日
日期: 2026-08-04
状态: 设计稿(待用户审阅)
关联: specs/2026-06-15-family-member-cards-design.mdplans/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-283onEditRelation() 补全 query 参数:

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 出题必须遵循)

{
  "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 对象:

{
  "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=truepython.base-url=http://localhost:9000AiGateway 已封装 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_namerelationship_type(child/parent)。

评分不走 LangGraph——calculateScoresFromAnswers 实现在 Java,完全按 3.3 契约解析 + 维度加权,与 LangGraph 解耦。

3.4.3 HTTP 契约(Java ↔ Python)

Request POST /api/v1/questionnaire/generate

{
  "member_name": "小明",
  "relationship_type": "child",
  "family_context": {
    "parent_count": 2,
    "sibling_count": 1
  }
}

Response 200

{
  "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_llmclient.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.enabledpython.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-langgraphai.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 来源不同——直接抽公共类反增耦合):

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("无权操作其他家庭资源");
}

RuntimeExceptionFamilyMemberService.kickMember 现有风格一致(已用 throw new RuntimeException("只有家庭管理员才能踢出成员")),由 Controller @ExceptionHandler 或全局 ResultAdvice 包装为 Result.error

3.5.5 不变更的部分

  • FamilyMembersController 的"编辑资料/移除成员"已有 creatorId.equals(userId) 校验,不动
  • 前端 FamilyMemberStripcanEdit prop 既有 isParent 判断保持不动
  • 前端 canEditMembersource === '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 中"家庭关系域"条目(状态、版本号、文档索引)。