Просмотр исходного кода

Merge remote-tracking branch 'origin/docs/2026-10-25-t24-t25' into merge/t24-t25

zxyj-web 2 недель назад
Родитель
Сommit
b85facdef9
1 измененных файлов с 80 добавлено и 0 удалено
  1. 80 0
      docs/superpowers/plans/2026-10-25-unified-member-selector.md

+ 80 - 0
docs/superpowers/plans/2026-10-25-unified-member-selector.md

@@ -0,0 +1,80 @@
+# 统一成员选择器(去重双身份)设计
+
+> 关联:T24(生产 bug:睡眠打卡 500 + 双身份)。本文件是 T24-C「前端统一选择器」子项的单一事实源。
+> 日期:2026-10-25
+> 触发:用户指出"食材查询页面也显示了本人和本人对应的成员;凡事有成员选择的地方,应使用一个组件统一处理"。
+
+---
+
+## 一、问题定义
+
+### 1.1 现象
+- 任务列表页、食材查询页等**所有带成员选择的页面**,同一用户被显示成两项:「本人」与「本人所对应的成员」。
+- 两项切换后**任务/数据列表不同**(因为两条 `family_members` 行各自挂着不同的任务外键)。
+
+### 1.2 根因(已核实)
+1. **数据层**:`family_members` 对同一 `user_id` 存在多条"家长本人"重复记录(已知存量,见 `EnergyService.java:121/263`)。
+2. **后端 `isSelf` 单点标记**:`FamilyMemberService.java:344` `vo.setIsSelf(viewerMember.getId().equals(fm.getId()))`——`viewerMember` 由 `JwtInterceptor` 用 `LIMIT 1` 解析(非确定性)。两条重复行里只有被解析到的那条标 `isSelf=true`,另一条沦为普通"成员"。
+3. **前端无去重**:所有选择器直接迭代成员列表,重复行原样渲染成两个选项。
+4. **身份键不统一**:部分页面用 `familyMemberId`(如 `tasks.vue`、`FamilyMemberStrip`、`DimensionMemberTabs`),部分用 `userId`(如 `food-query.vue`:`currentMemberId === m.userId`、`switchMember` 设 `member.userId`)。`userId` 在重复行下完全无法区分两条记录,必然重复。
+
+### 1.3 关键工程判断(务必先读)
+**纯前端去重会丢数据。** 两条重复行各自挂着不同任务(`tasks.family_member_id`、`task_executions.member_id`、`health_*`、`points_log.child_id` 等)。若仅在 UI 隐藏一行,被隐藏行的数据将不可达。
+→ 因此正确顺序是:**先做数据去重与合并迁移(T24-C 数据部分,根),再做统一组件(防御 + 一致性)**。组件去重只是过渡期防护,不是最终解。
+
+---
+
+## 二、统一方案(三层)
+
+### 层 1 · 数据根治(T24-C 数据部分,根,必做,需授权)
+- 诊断 SQL(见 `2026-10-25-task-complete-500-bug.md` §三)确认 `(user_id, family_id)` 重复范围。
+- 合并:保留一条主成员(优先 `status='active'`、外键引用最多、积分/打卡数据最全),将其余重复行的外键迁移到保留行,删除重复行。
+- 加约束 `UNIQUE(user_id, family_id)`(允许 `user_id IS NULL` 的 local 成员多条)。
+
+### 层 2 · 后端确定性(T24-C 后端部分,必做)
+- `JwtInterceptor:177-182` 的 `LIMIT 1` 改为确定性取值(优先 `status='active'` 且 `userId` 匹配的"主成员")。
+- 成员列表接口(`FamilyMemberService` 列表方法)与 `isSelf` 计算去重感知:同一 `user_id` 只暴露一条,且 `isSelf` 唯一。
+- 若暂未合并数据,列表接口应在后端先按 `user_id` 折叠(同 user 多行 → 一行,canonical `id` = 主成员),避免重复下发给前端。
+
+### 层 3 · 前端统一组件(本文件主体,必做,防御 + 一致性)
+- 新增共享**去重规范化工具** `cfc-frontend/utils/memberNormalize.js`:
+  - 入参:原始成员数组 + 当前 viewer(`userId` 或 `memberId`)。
+  - 折叠同 `user_id` 的多行为一条;canonical `memberId` 选取优先级:`isSelf` 行 → `status='active'` → 外键数据最多 → `id` 最小。
+  - 保证每个真实用户**至多一条**且 `isSelf` 唯一;输出稳定 `memberId`。
+  - 统一身份键为 **`familyMemberId`(即 `id`)**;`userId` 仅作为折叠依据,不再作为选择器主键。
+- 新增统一组件 `cfc-frontend/components/MemberSelector.vue`,支持 `variant` 形态:
+  - `strip`:替代 `FamilyMemberStrip`(大卡 + 长按操作菜单)。
+  - `tabs`:替代 `DimensionMemberTabs`(轻 chip)。
+  - `minimal`:替代 `food-query.vue` 等内联 `member-bar`(含「家庭总览 / 个人 / 各成员」形态)。
+  - Props:`members`(可选,缺省自取)、`selectedMemberId`、`variant`、`dimensionCode`(tabs 用)、`scoreMap`、`showOverview`(minimal 用)。
+  - 内部先调 `memberNormalize` 再渲染;`emit('select', member)` 带 canonical `memberId`;`emit('switch')` 等保持。
+- 改造现有两个组件与高优页面,**全部改用 `MemberSelector` + `memberNormalize`**:
+  - P1(用户可见 bug 页,必做):`food-query.vue`(迁移 `userId`→`memberId`)、`tasks.vue`、改用 `MemberSelector` 替代 `FamilyMemberStrip` 与 `DimensionMemberTabs`(4 个维度页 + 3 个 strip 页)。
+  - P2(其余内联选择器,列表见 §四):逐页替换为 `MemberSelector`,消除 `userId` 作主键的写法。
+
+---
+
+## 三、food-query.vue 专项(用户点名页)
+- 现:`/api/food/recommendation/members` 返回成员,选择器按 `m.userId` 匹配与切换(`currentMemberId === m.userId`、`switchMember` 设 `member.userId`)。
+- 改:调用 `memberNormalize` 折叠后渲染;选择器主键改为 `member.id`(familyMemberId);`switchMember` 设 `this.currentMemberId = member.id`;「个人」项 = `isSelf` 行,「家庭总览」保持聚合。
+- 后端 `/api/food/recommendation/members` 须返回 `familyMemberId` 字段(若无则前端用 `id`);若该接口内部也按 `userId` 聚合,需同步改为按 canonical `memberId` 聚合,避免重复下发。
+
+---
+
+## 四、内联选择器清单(P2,按出现顺序,需逐页替换)
+health/survey-questionnaire、indicator-curve、health-survey、report-list、water-index、survey-history、indicator-quicklook、sleep-index、health-plan-summary、profile-main/profile、promotion/team、diet/index、diet/records、diet/record-detail、psych-assessment/index、index-home/index、diet/preferences、gut-flora-detail、gut-flora-foods-detail、doa-*、energy/index、health/daily-checkin、health/diet-index、exercise-index、growth-main/index、profile/portrait-edit、home-pages/child-index、profile/message-history、teacher/review-family、growth/exercise-checkin、growth/diet-checkin、growth/sleep-checkin、mind-detail/emotion-checkin、health/bodyfat-confirm、home-pages/child-index、mind-detail/emotion-trend、growth/caution-detail、growth/challenge-progress、tasks/task-upload-purchase、action-detail/interaction-log、shop/payment/payment 等(凡含 `memberList`/`currentMemberId`/内联 `member-tab` 且未用统一组件者)。
+
+---
+
+## 五、验收
+- 全量回归:任一成员选择页,同一用户**只出现一次**「本人/个人」项,不再有"本人 + 本人所对应的成员"两项。
+- 切换「本人」→ 路由到 canonical `memberId`,其下任务/打卡/健康数据**全部可见**(验证去重未丢数据,需数据已合并或规范化覆盖)。
+- 身份键统一为 `familyMemberId`;`userId` 不再作为选择器主键(grep 全仓确认)。
+- `mvn clean compile` + `cfc-frontend` `node --check` / `npm run build` 通过;无新增 ESLint 阻断。
+
+---
+
+## 六、风险与边界
+- **数据合并(层 1)是唯一真解**;层 3 组件去重是过渡防护,单独上线无法消除"列表不同"(仅隐藏一项会丢数据)——故层 1 必须先于或同步于层 3 全量落地。
+- 层 1 写库 + 外键迁移属高危,必须备份 + DBA 复核 + 用户授权,不在前端分支范围。
+- `FamilyMemberStrip` 的长按操作菜单(移除/切换/编辑)逻辑复杂,迁移到 `MemberSelector` 的 `strip` 变体时须完整保留。