优先级: 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 阻塞性 | 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 校验(编辑资料/移除成员仍限管理员)RuntimeException + Result.error 项目既有风格)FamilyMemberStrip 6 项菜单结构、不动 isParent 链路ai.etotem.com.cn 部署)interaction_bonus 的 +360 天业务计算(已确认 RelationshipQualityService 不含该计算;本次保留 BigDecimal.ZERO + // rewardBonus 待引入 注释)按 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 成功日志。
位置: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 参数:
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 变更、零设计风险。
放弃 RelationshipQuestionnaireService.calculateScoresFromAnswers 现有之 mock 固定 75/70/80,改为后端按题目维度标签加权。
{
"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.0options[*].id: 选项 ID(题内唯一)options[*].score: 选项驱动型题的分值scale.min/max: 滑尺驱动型题的取值范围(输出归一到 0–100)前端 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直接复用。
snapshot.aiGeneratedJson 拿到 questions 索引(题目维度/方向/权重/选项分值或 scale)answersJson——前端格式为 Map(详见 3.3.1.1),键 questionId、值 {id, score} 或 {scoreValue};按 questionId 与 questions 配对options):取 answer.id,回查题目 options 中 options[*].id==answer.id 拿 score,而非信任前端 answer.score(后端权威计分,防篡改)scale):取 answer.scoreValue,线性归一到 0–100: normalized = (scoreValue - scale.min) / (scale.max - scale.min) * 100direction=negative 的题(无论题型):rawScore = maxScore - rawScore(反向),maxScore 对题型 A 是 max(options[*].score)、对题型 B 是 100dimScore = Σ(adjustedScore × weight) / Σ(weight),结果范围 0–100Trust/Intimacy/Communication 进行按题维度归属分配;总计 total = mean(trust, intimacy, communication)(仅平均有题的维度,不平均空维度)interactionBonus 保留——已确认 RelationshipQualityService 不做该计算,本版保留 BigDecimal.ZERO + // rewardBonus 待引入 注释。totalScore 最终 = mean + interactionBonus,封顶 100(interactionBonus 当前为 0,预留扩展位)。若因前 6 步输错,某 dim 无题,该 dim 记 0,只有题维度参与 mean 计算——避免 0 拖低均分。
snapshot.aiGeneratedJson 损坏 / JSON 不合规:trustScore = intimacyScore = communicationScore = -1 标记(前端展示"本次答题无效"),不抛错、不回滚 snapshot 写入但 response 仍存total 仅平均有题维度接入锚点已被 AiGateway 占好:python.enabled=true、python.base-url=http://localhost:9000、AiGateway 已封装 python.enabled/熔断/fallback 模式,已有 /api/v1/recommend、/api/v1/chat 两端点。
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
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 解耦。
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。
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)LLM key/model 由 .env 注入,不进 git。
application.yml 不需要新增 key(python.enabled、python.base-url 已存在)cfc-langgraph/.env.example 里列出 Python 服务侧 envcfc-backend/AGENTS.md 的 COMMANDS 区域追加:
cd cfc-langgraph && uvicorn src.app:app --port 9000 # 启动 Python LangGraph 服务(部署到 ai.etotem.com.cn)
AiGateway.generateQuestionnaire + Service 改造 + 完整 cfc-langgraph/ Python 项目骨架(graph、client、schemas、prompts、FASTAPI endpoint、README、tests)cfc-langgraph 到 ai.etotem.com.cn,配 .env LLM keymvn clean package -DskipTests 仍通过(Python 不参与 Java 编译)requirements.txt 锁版本规避按用户已确认选项"仅拦截跨家庭资源访问"——对 4 个 Controller 端点校验"操作者所属家庭 == 被操作资源所属家庭",不要求操作者必须是家庭创建者(既有的"编辑资料/移除成员"管理员校验不动)。
| 资产 | 位置 | 用途 |
|---|---|---|
@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。
| 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 |
无需校验(静态字典) | — |
不把校验逻辑散进每个 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("无权操作其他家庭资源");
}
RuntimeException 与 FamilyMemberService.kickMember 现有风格一致(已用 throw new RuntimeException("只有家庭管理员才能踢出成员")),由 Controller @ExceptionHandler 或全局 ResultAdvice 包装为 Result.error。
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 分层:
RelationshipQuestionnaireServiceTest — 评分正向/反向/选项驱动/滑尺驱动/降级 mock 5 个 caseInteractionLogServiceTest — 跨家庭拦截 1 caseRelationshipQuestionnaireServicePermissionTest — 跨家庭生成问卷/提交答题拦截 2 casePython 侧: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 阶段写成完整实施计划):
mvn clean compile(V1-V3)calculateScoresFromAnswers,加 assertMemberInCallerFamily,端点调用补齐AiGateway.generateQuestionnaire 方法(V7)cfc-langgraph/ Python 项目骨架 — graph + endpoint + schemas + prompts + tests(V6, V11)FamilyMemberStrip.vue 修复(V9)cfc-langgraph 的 .env.example + README 部署说明(交付给运维)每步通过对应 V 验收。
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 实现"——本设计落地这两档isMemberOfFamily 不存在(已验证):写代码阶段 grep 确认无该方法;按 3.5.2 规划补一个最小 Service 方法,不新增 utility 类。RelationshipQualityService 不含 interactionBonus 计算(已验证):本设计评分聚合直接放进 RelationshipQuestionnaireService,与 RelationshipQualityService 完全解耦;interactionBonus 本版保留 BigDecimal.ZERO + // rewardBonus 待引入 注释,不引用 RelationshipQualityService。后续若引入 +360 天加权,再单独评估挂钩点。.env 注入,不进 git。dimension/direction/weight/options 或 scale 中的一种",不出题数量硬上限。tests/AGENTS.md 早已声明);新增测试须独立可跑、不被牵连。按 docs/superpowers/AGENTS.md 约定,本设计文档提交后同步更新 docs/superpowers/PROJECT-OVERVIEW.md 中"家庭关系域"条目(状态、版本号、文档索引)。