# 家谱项目全量治理实施计划 > **执行要求:** 实施时使用 `superpowers:executing-plans` 按任务顺序执行,并在每个批次结束后由主代理、Lagrange、Bernoulli 三人复核;宣称批次或阶段完成前必须使用 `superpowers:verification-before-completion`。步骤使用复选框跟踪。用户已明确授权三人依据证据直接确定技术方案,不再为可从页面、接口和现有合同推出的细节反复请示。 > > 日期:2026-07-22 > 状态:阶段 0、导航任务 1—10、TAC 认证客户端任务 20—22、领域上下文任务 24 与 M07 反馈客户端任务 25 已完成;家谱工作区任务 26、M06 帮助任务 27、个人资料读取任务 28、通知读取任务 29、通知已读写入任务 30、个人资料写入任务 31、M10 服务端退出任务 32、M04 密码凭证任务 33、M05 手机号换绑任务 34 和 G03 原子创建任务 35 已完成三人审查及 OpenAPI 红灯;T01、认证、工作区、G03、帮助、profile、通知、logout、password 与 phone-change 后端门禁明确阻塞,MuMu 原生矩阵待执行;当前继续无依赖业务域接口审查 **目标:** 以测试先行方式消除全项目导航栈歧义,完成可安全上线的认证闭环,并在新后端图窗口合同通过后,把 T01 建成可稳定阅读几十代、几百代且连接线连续的大规模世系图;随后逐域关闭真实接口、领域持久化、无障碍与发布门禁。 **架构:** 导航阶段由 `utils/navigation-routes.js` 持有活动路由语义、`utils/navigation.js` 持有全部 Uni 导航调用。T01 先锁定 Apifox 规范图窗口合同,再按“规范化与校验 → 可见投影 → 布局 → Scene 与空间索引 → 单 Canvas 视口 → 页面交互”实施。认证由场景合同、TAC 浮层、供应商适配器和 HTTP owner 四层组成;所有阶段各有独立硬门禁,外部接口红灯只阻止其消费者上线,不阻止无依赖业务域继续审查。 **技术栈:** UniApp、Vue 3、JavaScript、SCSS、PowerShell、Node.js、OpenAPI 3.0.1、Apifox、MuMu Android、ADB、App-vue renderjs、Canvas 2D。 ## 全局约束 - 全程使用中文沟通;当前五份权威文档继续使用中文文件名和中文内容,不新增并行计划入口。 - 新增或修改的复杂 JavaScript、Vue、SCSS 和测试逻辑必须有详细中文注释,说明当前步骤、输入、状态变化、失败处理与下一步;注释不得掩盖职责混乱的大函数。 - 代码只实现当前合同需要的最小能力;不得复制路由、参数、图关系、布局或密码规则,不建立“以后也许有用”的抽象。 - 主代理负责唯一写入;Lagrange 与 Bernoulli 均完整复核页面美观、业务、接口、交互、测试和 MuMu 证据,三人互相补漏,不割裂成三个单项岗位。 - 不新增第四位代理,不创建新的专家角色。 - 不执行 Git add、commit、push、restore、checkout、reset 或其他 Git 变更命令。 - 不改写 `APP.openapi.json` 与 `APP.openapi.yaml`;Apifox 是接口唯一源头,两份本地文件只接受用户重新导出覆盖。 - 不启动、关闭 MuMu,不调整模拟器窗口缩放比例、设备分辨率、方向或系统字体;只使用当前 `emulator-5554 / 720×1280 / 320dpi`。T01 页面内双指缩放属于必须验证的产品功能,不属于调整模拟器。 - 浏览器运行时测试可以验证逻辑,但不能替代 MuMu 视觉和真实 Android 返回验收。 - 每次代码修改后依次运行本任务聚焦合同、`tests/project-responsive-layout-contract.ps1`、`tests/compile-audit.ps1`,再在 MuMu 复核受影响流程;阶段关闭前运行全部 `tests/*.ps1`。 - 不重新进行响应式迁移,不对单一尺寸写页面补丁,不压缩字号、行高或控件尺寸掩盖问题。 - 普通内容高度由内容决定;弹窗只允许视口 `max-height` 和内部滚动。 - G01—G10 只有在本阶段测试或 MuMu 提供明确全局回归证据时才改动,且只改与当前阶段直接相关的行。 - 用户可见页面提示使用“某某某堂侄”等通用表达;fixture 与 mock 中的姓名不做无关清理。 - 导航阶段允许只迁移 T01 的现有导航调用;导航门禁通过前,不得实施 T01 新图合同、投影、布局、Scene、Canvas 或新接口接入。导航门禁关闭后,各业务域必须保持独立批次;T01 或认证因后端合同阻塞时,可以继续不依赖该合同的接口审查和客户端批次,但不得伪造服务端成功或混合所有权。 ## 阶段硬门禁 1. **导航门禁:** 页面和活动组件直接调用五种 Uni 导航 API 的扫描为零,52 条注册路由精确闭合,导航全量合同通过,MuMu 完成进入、返回、取消、完成和重复进入矩阵。 2. **T01 接口门禁:** 用户重新导出的 JSON/YAML 同时包含规范 `LineageGraphWindow`、概览和定位接口,图合同测试通过;当前递归合同不得由客户端兼容。 3. **T01 几何门禁:** 103→106、普通节点 4rpx 间隙和 10×12 多父干线三个已知缺陷先红后绿,十条“连线永不断”不变量全部自动验证。 4. **阶段隔离:** 任一门禁未通过时只修复本阶段问题,不提前混入下一阶段代码。 5. **认证后端门禁:** `API-AUTH-TAC-001`—`004` 关闭、同版本 JSON/YAML 通过 `tests/auth-tac-openapi-contract.ps1`、真实 challenge 不再空 500,才允许把 `runtimeConfig.mode` 改为 `remote`。 6. **认证 Android 发布门禁:** 非拖动等价验证、真实票据原子消费和三人 MuMu/TalkBack/外接键盘证据齐全,`tests/auth-android-accessibility-release-gate.ps1` 才能由 `ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED` 转绿。 ## 文件职责图 ### 导航阶段新增 - `utils/navigation-routes.js`:52 条活动路由的唯一语义注册表。 - `utils/navigation.js`:唯一 Uni 导航网关、一次性结果和统一返回优先级。 - `tests/navigation-routes-contract.ps1`:注册表、`pages.json`、参数和唯一所有权合同。 - `tests/navigation-gateway-runtime-smoke.js`:在伪造 Uni 栈上验证网关算法。 - `tests/navigation-source-scan-contract.ps1`:禁止页面和组件直接调用五种 Uni 导航 API。 - `tests/navigation-flow-contract.ps1`:锁定各页面使用的语义方法与流程终点。 ### 导航阶段修改 - `components/AppTabbar.vue`、`components/PageHeader.vue`、G01/F01/M01 三个根页消费者。 - `pages/auth/*.vue`、`pages/genealogy/*.vue`、`pages/tree/*.vue`、`pages/family/*.vue`、`pages/records/*.vue`、`pages/notification/*.vue`、`pages/profile/*.vue` 中实际包含导航行为的文件。 - 与上述行为直接对应的现有聚焦合同;旧的 `fallbackUrl`、原始 URL 和直接 Uni 调用断言必须同轮删除。 ### T01 阶段新增 - `utils/lineage/normalize.js`:只把新图窗口响应规范化为内部图;不兼容旧递归结构。 - `utils/lineage/validate.js`:图引用、主入边、版本、边界和 ID 的唯一运行时校验。 - `utils/lineage/project.js`:从完整规范图生成当前可见真实节点与虚拟聚合节点,不修改源图。 - `utils/lineage/layout.js`:消费可见投影的确定性 family-unit tidy-tree 世界坐标布局。 - `utils/lineage/scene.js`:把布局转换为节点、文字、端点和连接器 Scene。 - `utils/lineage/spatial-index.js`:视口裁剪、命中测试和路径穿越查询。 - `utils/lineage/camera.js`:相机矩阵、LOD、缩放锚点和边界计算。 - `components/LineageViewport.vue`:App-vue/H5 renderjs 与 mp-weixin Canvas 适配器;节点与线同画布、同矩阵、同帧。 - `components/LineageAccessibleList.vue`:消费同一规范图的可搜索线性阅读模式。 - `tests/lineage-graph-contract-runtime-smoke.js`、`tests/lineage-projection-runtime-smoke.js`、`tests/lineage-layout-runtime-smoke.js`、`tests/lineage-scene-runtime-smoke.js`、`tests/t01-navigation-integration-contract.ps1`、`tests/t01-large-lineage-contract.ps1`。 ### T01 阶段修改 - `utils/api.js`、`data/mock.js`、`pages/tree/t01-tree-overview.vue`。 - `pages/tree/t03-member-profile.vue`、`pages/tree/t04-add-relative.vue`、`pages/tree/t06-edit-relationship.vue`、`pages/tree/t07-member-directory.vue` 只修改新版本和焦点回流所必需的部分。 - 现有 `tests/t01-*.ps1`、`tests/t01-*.js` 中已经被新图合同替代的断言。 ## 每个代码批次的固定验证 ```powershell powershell -ExecutionPolicy Bypass -File tests/navigation-routes-contract.ps1 powershell -ExecutionPolicy Bypass -File tests/project-responsive-layout-contract.ps1 powershell -ExecutionPolicy Bypass -File tests/compile-audit.ps1 ``` 第一行以任务中明确列出的聚焦合同为准;上例是任务 1 的实际命令,其他任务均在各自步骤写出精确文件名。 阶段关闭时运行: ```powershell $failed = @() Get-ChildItem -LiteralPath tests -File -Filter '*.ps1' | Sort-Object Name | ForEach-Object { & powershell -ExecutionPolicy Bypass -File $_.FullName if ($LASTEXITCODE -ne 0) { $failed += $_.Name } } if ($failed.Count -gt 0) { throw "失败合同:$($failed -join ', ')" } ``` --- ## 第一阶段:导航栈语义统一 ### 任务 1:建立路由语义注册表 **文件:** - 新建:`utils/navigation-routes.js` - 新建:`tests/navigation-routes-contract.ps1` - 修改:`tests/interface-page-mapping-contract.ps1` **接口:** - 输出:`ROUTES`、`ROOT_ROUTE_KEYS`、`getRoute(routeKey)`、`getRouteKeyByPath(path)`。 - 路由项:`{ path, kind, parent, parentParamMap, requiredParams, optionalParams, allowedSources, resultOperations }`;所有数组和映射均冻结。 - 路径统一以 `/pages/...` 表示;ID 参数均为字符串;`sourceKey` 是网关保留参数,不进入页面业务参数数组。 `utils/navigation-routes.js` 已成为 52 个活动页面路由语义的唯一数据所有者;实施计划、页面、测试和映射总表都不得再维护完整父页、参数、来源或结果操作影子表。注册表路径和编号由 `pages.json` 独立交叉,具体字段只在该模块修改。 独立验收只保留不能由错误注册表自证的高风险边界: - 根语义固定为 A01、G01、F01、M01;A01 是认证根,其余三个是业务根。 - T03 是唯一单实例页面;人物页回 T01 的行为必须把子页 `personId` 映射为父页 `selectedId`。 - 所有父链无环并落到四根之一;所有非根页面可从四根沿允许来源到达。 - ID 和业务参数只接受非空字符串;`sourceKey` 是网关保留参数,旧 URL、快照和领域对象不得进入查询。 - 目标结果操作只从注册表读取;未声明的操作由运行时立即拒绝。 - [x] **步骤 1:先写失败合同** `tests/navigation-routes-contract.ps1` 必须读取 `pages.json` 与 `utils/navigation-routes.js`,断言 52 个路径集合和顺序精确相等、四个根语义恰为 A01/G01/F01/M01、父页与来源均存在、参数名不重复、`parentParamMap` 两端字段都合法、`resultOperations` 在目标页内唯一且非空字符串,并禁止 `previous`、`returnUrl`、`fallbackUrl`、`targetUrl`、`state`、`count`、`saveResult`、`step`、`genealogyName` 进入业务参数。 - [x] **步骤 2:确认红灯原因正确** 运行: ```powershell powershell -ExecutionPolicy Bypass -File tests/navigation-routes-contract.ps1 ``` 首次运行记录:因 `utils/navigation-routes.js` 不存在而按预期失败,不是 PowerShell 语法或编码失败;当前 owner 已实现并转绿。 - [x] **步骤 3:实现最小注册表** 实现只保留一个冻结注册表和两个严格字符串查询函数。四根安全 oracle 固定为: ```js export const ROOT_ROUTE_KEYS = Object.freeze(["A01", "G01", "F01", "M01"]); ``` `defineRoute` 必须冻结路由项、四个数组和 `parentParamMap`;`getRoute` 只接受原始字符串和对象自身键,`getRouteKeyByPath` 只接受原始字符串路径。52 项具体内容以 `utils/navigation-routes.js` 为准,计划不再保存可漂移的代码快照。 - [x] **步骤 4:验证绿灯并检查旧合同迁移** 运行路由合同、接口页面映射合同、全局响应式合同与编译审计。预期全部通过;`tests/interface-page-mapping-contract.ps1` 不再把旧代际父键布局或“阶段 1 尚未开始”作为当前真相。 ### 任务 2:建立唯一导航网关 **文件:** - 新建:`utils/navigation.js` - 新建:`tests/navigation-gateway-runtime-smoke.js` **接口:** - 消费:任务 1 的 `ROOT_ROUTE_KEYS`、`getRoute()`、`getRouteKeyByPath()`;具体路由数据只经注册表查询函数读取。 - 输出:`buildRouteUrl`、`openPage`、`goBack`、`returnTo`、`finishPage`、`goRoot`、`consumeNavigationResult`、`resolveBackAction`、`runBackGuard`、`handleBackPress`。 - 一次性结果唯一形状:`{ operation, entityId?, refresh }`;字段集合必须精确,`operation` 必须属于目标路由 `resultOperations`,多余字段立即抛错。 - `buildRouteUrl`、`consumeNavigationResult`、`resolveBackAction` 与 `handleBackPress` 同步返回;`openPage`、`goBack`、`returnTo`、`finishPage`、`goRoot` 与 `runBackGuard` 始终返回 `Promise`,并通过同一个转场锁保证一次只调用一个 Uni 导航 API,校验失败也只使用 Promise 拒绝这一种错误通道。 - [x] **步骤 1:先写伪栈运行时测试** `tests/navigation-gateway-runtime-smoke.js` 以独立 ESM 实例注入伪 `uni` 与 `getCurrentPages`,至少验证: ```js assert.equal(buildRouteUrl("T03", { genealogyId: "9007199254740993", personId: "p/1" }, "T01"), "/pages/tree/t03-member-profile?genealogyId=9007199254740993&personId=p%2F1&sourceKey=T01"); assert.throws(() => buildRouteUrl("T03", { genealogyId: "1", personId: 2 }, "T01"), /personId/); assert.throws(() => buildRouteUrl("T03", { genealogyId: "1", personId: "2", targetUrl: "/x" }, "T01"), /targetUrl/); const firstOpen = openPage("T03", { genealogyId: "1", personId: "2" }, "T01"); const repeatedOpen = openPage("T03", { genealogyId: "1", personId: "2" }, "T01"); assert.equal(firstOpen, repeatedOpen); // 同一 tick 复用在途 Promise,只发生一次 navigateTo assert.deepEqual(consumeNavigationResult("TEST_RESULT_LIST"), { operation: "fixture-created", entityId: "a9", refresh: true }); assert.equal(consumeNavigationResult("TEST_RESULT_LIST"), null); // 只消费一次;该中性路由只存在于测试内存源码 ``` 同时构造目标在栈内、目标不在栈内、栈深 1、根页切换、非法来源、提交中和未保存确认六组场景,并补齐以下反例:当前真实页 G01 伪报 `sourceKey=T07` 必须失败;外部直接链接即使带合法 `sourceKey` 也只能按规范父页回退;同一 tick 两次打开同一目标只调用一次 `navigateTo`;`operation="anything"`、空 `entityId`、非布尔 `refresh` 和额外字段都失败。 - [x] **步骤 2:运行并确认红灯** 首次运行记录:`node tests/navigation-gateway-runtime-smoke.js` 因 `utils/navigation.js` 不存在而按预期失败;当前 owner 已实现,严格运行时反例已转绿。 - [x] **步骤 3:实现 URL 和参数校验** 参数与结果校验只由 `utils/navigation.js` 的同一数据记录快照器拥有,计划不复制可漂移实现。运行时合同固定为: - 只接受原型为 `Object.prototype/null` 的普通对象;只读取 own enumerable data properties,拒绝 Symbol、访问器、不可枚举字段和继承字段;校验、编码、比较与结果写入始终消费同一冻结快照。 - 注册表声明的必填与可选参数只要实际提供,就必须是非空字符串;额外字段立即失败。栈内目标实例也只投影注册表字段后按完整必填合同校验,不能信任畸形历史页面。 - `sourceKey=""` 是构造纯目标 URL 时唯一的省略形态;`openPage` 必须提供非空字符串来源。`null/false/0/NaN` 不能借 truthy 判断绕过验证。 - 查询串只按注册表 `requiredParams` 后 `optionalParams` 的声明顺序逐项 `encodeURIComponent`;调用方插入顺序、字母序和运行环境是否提供 `URLSearchParams` 都不得改变结果。 - `navigationResults` 和唯一 `navigationInFlight` 锁只存在于网关模块;所有校验错误必须在调用 Uni API 前发生。 `buildRouteUrl` 只负责纯字符串构造;真正执行压栈导航前,`openPage` 必须读取当前栈顶路径,并断言 `sourceKey === getRouteKeyByPath(当前真实页面)`。页面不得通过省略来源或传入另一个合法来源绕过注册表。栈深为 1 时一律忽略当前 URL 查询中的 `sourceKey`,只使用注册表 `parent/parentParamMap`;`sourceKey` 不得成为外部深链的可信合同。 查询串固定按注册表字段顺序逐键 `encodeURIComponent`,不依赖 Uni 运行环境可能缺失的 `URLSearchParams`,不引入依赖、不复制到页面。等价参数对象即使插入顺序不同,也必须生成同一 URL 和同一转场锁键。 转场锁固定为“同一语义键复用同一个 Promise、不同语义在转场结束前以 Promise 返回 `false`”,不能用页面各自的布尔锁。取得新锁、写入结果、调用 Uni、成功目标实例校验、同步异常回滚和异步失败回滚必须属于同一个原子协议;`success/fail` 在 Promise settle 前按独立 flight 身份释放自己的锁,迟到的 `complete` 不得清掉新 flight。成功回调发现实际栈顶不是预定实例/参数时也必须拒绝并先回滚,不能只处理 Uni 的 `fail`。 - [x] **步骤 4:实现五种语义导航** 实现顺序固定为:先把调用方目标参数规范化为快照并完成来源校验,再读取当前真实栈,最后决定唯一 Uni 调用。`openPage` 对普通页只允许压栈,对 single 页先检查完整既有实例;`goRoot` 只接受四根并在路由与规范参数均相同时返回 Promise 化的 `false`。具体实现只由 `utils/navigation.js` 拥有,计划不复制第二份函数源码。 `goBack()` 在栈深大于 1 时只执行受转场锁保护的 `navigateBack({ delta: 1 })`;栈深为 1 且当前是普通页时忽略 URL 中的 `sourceKey`,只按规范父页、同名参数和 `parentParamMap` 构造父页参数。某级父页缺必填参数时继续沿注册表父链向上,直到第一个可合法构造的目标;例如缺 `genealogyId` 的异常 T03 不能伪造 T01/G05,最终回 G01。当前已是 A01/G01/F01/M01 根语义时返回 `false`,由 Android 系统处理退出,不伪造跨 Tab 历史。 当前路由合同没有任何“同流程临时步骤”替换边,原 14 处 `redirectTo` 也都归入根切换、规范返回或父链回退,因此网关不公开无消费者的 `replaceStep`。`redirectTo` 只作为 `goBack/returnTo/finishPage` 的内部实现细节;未来若出现真实替换流程,必须先在唯一注册表增加专用替换边和失败合同,再同轮新增公开语义。 `returnTo(routeKey, targetParams = {})` 从当前页下方按路由键反向寻找最近实例;找到后不再按参数搜索另一个实例,但调用方显式提供的每个字段必须与这个最近实例一致,冲突立即拒绝,空对象仍允许普通返回。只有目标不在栈内时才要求参数包含构造目标所需的全部必填项,并据此调用 `goRoot` 或内部 replace;普通返回不允许携带流程结果。`finishPage(routeKey, targetParams, result)` 是完成并回传结果的唯一公开入口,调用方即使返回栈内目标也必须显式提供目标的全部必填参数;先验证结果字段和目标页 `resultOperations`,再与最近实例核对。若当前来源也是注册路由,来源与目标共同声明且实际存在的上下文字段(如 `genealogyId/albumId/personId`)必须相等,不能由调用方伪造目标参数跨上下文写结果;外部未知来源不冒充业务上下文。 结果写入与转场锁的 started/reused/busy 判定必须原子完成:只有真正取得锁的首个动作写入,目标原生 `onShow` 时已经可见;复用和忙碌调用不得覆盖。页面实例身份只由模块内 `WeakMap` 生成原始值令牌,结果 envelope 不得强引用页面/Vue 实例。栈内结果绑定被反向搜索选中的最近页面令牌和完整规范参数,缺栈结果绑定发起页令牌与待构造目标参数;只有当前真实栈顶命中该目标实例/新建目标且参数一致时才可消费,错页、同路由更远实例和其他业务上下文返回 `null` 且不删除。新完成流程取得锁后淘汰已经错过生命周期的旧结果;Uni 调用失败或同步抛错只删除当前结果,不得复活陈旧结果。 `kind="single"` 的 T03 已在同一 `genealogyId` 栈下方时,`openPage` 不再压栈:网关写入 T03 允许的 `{ operation: "member-open-requested", entityId: personId, refresh: false }`,精确返回已有 T03,由其读取成功后推进页内轨迹;导航失败立即删除结果。测试必须构造 `[T01,T03,其他页]` 后再次打开 T03,断言原生栈中始终只有一个 T03。若既有 T03 的 `genealogyId` 不同,返回明确 `T03_CONTEXT_CONFLICT` 并拒绝压栈;若迁移前遗留栈已包含多个 T03,则返回 `T03_STACK_CONFLICT`,不得选择其中一个后伪称栈已唯一。调用方必须先返回或用根语义清理冲突上下文。 - [x] **步骤 5:实现统一返回优先级** 返回优先级固定为“关闭瞬态层 → 回退页内轨迹 → 阻止提交中离开 → 确认放弃未保存内容 → 普通返回”。该顺序只由 `resolveBackAction` 的运行时实现拥有,计划不复制第二份函数源码;同步公开入口默认接受空对象,并与异步 `runBackGuard` 共用严格普通对象快照和布尔字段校验。 `runBackGuard(context)` 的四个状态字段只要提供就必须是布尔值;只调用上下文中与返回动作同名的一个回调。关闭瞬态层、回退内部轨迹和阻止提交中返回完成后稳定返回 `true`;放弃确认返回 `false` 时必须留在当前页,返回 `true` 才调用 `goBack()`。页面不得另写不同优先级。 需要弹层、内部轨迹或未保存守卫的页面只保留一个 `requestBack` 函数。页头 `@back` 调用它,Android 返回固定委托给同步适配器;网关自己的 `navigateBack` 再次触发钩子时返回 `false` 放行,不能递归消费: ```js onBackPress((event) => handleBackPress(event, requestBack)); ``` 根页没有浮层时不注册拦截;有浮层的根页只在浮层可见时返回 `true` 并关闭最上层。 - [x] **步骤 6:验证网关** 运行 Node 冒烟、导航路由合同、全局响应式合同和编译审计。预期全部通过,且网关以外暂时仍有直接调用;任务 3 才启用零调用扫描。 ### 任务 3:迁移共享页头、底栏并退役通用母版 **文件:** - 修改:`components/AppTabbar.vue` - 修改:`components/PageHeader.vue` - 修改:`pages/genealogy/g01-my-genealogies.vue` - 修改:`pages/family/f01-family-feed.vue` - 修改:`pages/profile/m01-profile-home.vue` - 修改:`tests/shared-interaction-accessibility-contract.ps1` - 修改:`tests/shared-component-document-flow-contract.ps1` - 修改:`tests/root-pages-visual-contract.ps1` - 修改:`tests/runtime-assets-contract.ps1` - 新建:`tests/navigation-source-scan-contract.ps1` - 新建:`tests/retired-module-page-contract.ps1` - 删除:退役通用页面组件、临时页面目录及其两个正向死合同 - 删除:仅由退役通用页面虚构消费的两张 notification frame 资产及其清单项 **接口:** - `AppTabbar`:保留现有展示键 `genealogy/family/profile`,另给三项加入 `routeKey: G01/F01/M01`;只调用 `goRoot(item.routeKey)`,活动项无操作,删除每项的 `path`。 - `PageHeader`:普通返回调用 `goBack()`;`customBack` 仍只发出 `back` 事件给需要守卫的页面;删除 `fallbackUrl`。 - 52 个活动页面均已拥有自己的业务结构;退役通用页面与临时目录必须删除,不保留第二套状态、返回或视觉合同。 - [x] **步骤 1:先改失败断言** 把旧合同中对 `uni.navigateBack()`、`uni.reLaunch()` 和 `fallbackUrl` 的正向断言改成反向断言,要求页头与底栏导入导航网关,并先用失败合同证明无消费者的通用页面旧入口必须整体退役。 - [x] **步骤 2:迁移共享组件并收紧根页消费者** 共享组件只允许出现: ```js import { goBack, goRoot } from "@/utils/navigation.js"; ``` `PageHeader` 的返回函数固定为: ```js const handleBack = () => { if (props.customBack) { emit("back"); return; } return goBack(); }; ``` 删除所有 `fallbackUrl` 属性、默认值、模板传递和相关测试;不保留兼容属性。 审查同时证明原通用页面和临时页面目录没有活动消费者;本轮已删除旧入口、专属 mixin、正向死合同与两张孤立通知资产。`ModulePageBackground` 仍由活动页面消费,family/profile/records 的同名 frame 仍由专项 mixin 消费,均不得误删。 - [x] **步骤 3:建立最终零调用扫描与精确迁移债务账本** `tests/navigation-source-scan-contract.ps1` 覆盖根文件与全部生产 `.vue/.js`,按 SFC 的 template/script/style 分段剥离 HTML、JavaScript 与 SCSS 注释,保留字符串以捕获方括号和 Reflect 取值。五种 Uni 导航方法逐项计数,`getCurrentPages` 同步计数;声明式 `navigator`、动态 Uni 属性、Uni 对象逃逸和 `switchTab` 全局禁止。页面和组件的业务 `/pages/...` 路径字符串也进入账本,资产 `/static/...` 不受影响。任务 3 完成时精确剩余 39 个文件,任务 4—9 每迁移一页必须同轮收紧,任务 10 清空账本。 - [ ] **步骤 4:验证共享批次并在 MuMu 复核** 共享聚焦合同、退役入口合同、运行时资产合同、全局响应式合同和编译审计均已通过;MuMu 复核待执行。受“不启动、关闭或调整 MuMu”约束,本轮不以静态绿灯冒充原生视觉通过,后续只在用户现有 MuMu 环境可用时验证 G01/F01/M01 三个自定义 Tab、普通页头返回和带 `customBack` 的弹层返回。 ### 任务 4:迁移认证导航 **文件:** - 修改:`pages/auth/a01-entry.vue` - 修改:`pages/auth/a04-register.vue` - 修改:`pages/auth/a05-reset-password.vue` - 修改:`pages/auth/a06-auth-status.vue` - 修改:`tests/a04-registration-contract.ps1` - 修改:`tests/a05-reset-password-contract.ps1` - 修改:`tests/a06-auth-status-contract.ps1` - 修改:`tests/a01-a02-ui-contract.ps1` - 新增并作为后续导航批次唯一流程合同:`tests/navigation-flow-contract.ps1` - 修改:`tests/navigation-source-scan-contract.ps1` **流程合同:** | 操作 | 唯一语义 | | --- | --- | | A01 打开注册 | `openPage("A04", {}, "A01")` | | A01 打开重设密码 | `openPage("A05", {}, "A01")` | | A01 Android 返回 | 行为验证浮层打开时 `requestBack → runBackGuard` 只关闭浮层;无浮层时同步返回 `false`,交还 Android 退出根页 | | A04 页头、Android 返回或“已有账号” | 同一 `requestBack → runBackGuard → goBack()`,正常栈弹回 A01,深链按规范父页建立 A01 | | A05 未成功时的页头、Android 返回、“想起密码”或取消 | 同一 `requestBack → runBackGuard → goBack()`,不写结果 | | A05 本地视觉成功终态的按钮或 Android 返回 | 共用唯一 `leaveResetSuccess → returnTo("A01", {})`,不关闭终态后重新暴露已提交表单,不写结果 | | A05 未来真实重设接口成功 | `finishPage("A01", {}, { operation: "password-reset", refresh: false })` | | A01 真实登录成功 | `goRoot("G01")` | | A06 封存页返回 | 恢复浮层打开时只关闭浮层,否则 `goRoot("A01")` | 当前 A01/A04 的行为验证仍是明确的接口占位,导航批次不得把“知道了”伪装成登录或注册成功。2026-07-22 新线上 OpenAPI 已证明 `/genealogy/app/auth/register` 的 `200` 响应为 `RAppLoginVo → AppLoginVo.access_token`;真实注册接入时必须只读取当前字段、保存有效会话后调用 `goRoot("G01")`,不能先回 A01 再让用户重复登录,也不保留旧响应字段兼容分支。 - [x] **步骤 1:让认证导航合同先失败** 已先建立 `tests/navigation-flow-contract.ps1` 并移除旧直接导航正向断言;第一次执行明确失败在 A01 的 `uni.navigateTo`,证明红灯来自旧实现而非测试语法。合同锁定四页无原生导航/路径旁路、Android 同步适配、完整返回 context、A04/A05 单例延迟确认和 A05 本地成功终态不得生成 `password-reset` 结果。 - [x] **步骤 2:只迁移现有真实导航动作** 页面已统一导入所需语义函数;A01 的安全验证占位、A04 的接口占位和 A05 的本地成功弹窗保持当前事实。A05 本地成功终态的按钮与 Android 返回共用唯一无结果 `returnTo`,只有后续真实重设接口成功才允许生成并消费一次性结果;不新增假接口、不写未调用完成函数。A01 只在行为验证浮层打开时消费系统返回;A04/A05 的脏表单用项目 `AppDialog` 和单例延迟 Promise 确认,重复返回先关闭确认框,卸载必定释放等待者。当前没有真实异步提交,未虚构 `submitting` 状态。A06 只从 `onLoad(options)` 解析状态,已删除浏览器地址与 `getCurrentPages` 旁路。 - [x] **步骤 3:完成认证批次静态验证** 认证导航流程、四页聚焦合同、导航源扫描、响应式合同和编译审计均已通过;源扫描债务由 39 个文件收紧到 35 个文件。任务 4 完成当时的导航网关 Node 冒烟与 PowerShell `121/121` 是该批历史快照;项目当前全量证据以总览登记的任务 5 收口结果为准。 - [ ] **步骤 4:在 MuMu 复核认证流程** 在 MuMu 验证 A01→A04→页头返回、A01→A04→已有账号、A01→A05→取消、A05 本地成功→返回登录,各流程连续执行 3 次;返回后只能有一个 A01,表单取消不得显示成功。受“不启动、关闭或调整 MuMu”约束,本轮保持待验,不能把静态绿灯写成原生交互或视觉已通过。 ### 任务 5:迁移 G 系列导航 **文件:** - 修改:`pages/genealogy/g01-my-genealogies.vue` - 修改:`pages/genealogy/g03-create-genealogy.vue` - 修改:`pages/genealogy/g05-genealogy-overview.vue` - 修改:`pages/genealogy/g06-search-genealogies.vue` - 修改:`pages/genealogy/g08-join-application.vue` - 修改:`pages/genealogy/g09-my-applications.vue` - 修改:`pages/genealogy/g10-application-review.vue` - 修改:`pages/genealogy/g11-genealogy-settings.vue` - 修改:`pages/genealogy/g12-generation-poems.vue` - 修改:`data/mock.js` - 修改:`utils/api.js` - 修改:`utils/genealogy-contracts.js` - 新增:`utils/generation-poem.js` - 修改:`utils/navigation-routes.js` - 修改:G01、G03、G05、G06、G08—G12 的聚焦合同与运行时冒烟 - 修改:`tests/navigation-flow-contract.ps1` - 修改:`tests/navigation-gateway-runtime-smoke.js` - 修改:`tests/navigation-source-scan-contract.ps1` - 新增:`tests/genealogy-fixture-runtime-smoke.js` - 新增:`tests/generation-poem-runtime-smoke.js` - 新增:`tests/g-series-openapi-contract.ps1` **流程合同:** - G01 卡片和快捷入口只使用 `openPage`;路径对象改为路由键对象,不能继续保存 `/pages/...` 字符串。 - G03 内部从创建步骤切换到始祖步骤使用本页状态,不接收 `step` 或 `genealogyId`;当前只建立可撤销的本地预览,完成后用 `returnTo("G05", { genealogyId })` 进入明确预览态,不生成 `genealogy-created` 业务结果。真实接口形成创建与始祖闭环后,才能按服务端事实新增完成结果。 - G05 返回 `returnTo("G01", {})`,进入 F01 使用 `goRoot("F01", { genealogyId })`,其余入口使用 `openPage`。G05/G08 不再接收 `genealogyName`,页面按 `genealogyId` 从现有 fixture 或领域数据取名称。 - G06 已加入或我创建的结果 `goRoot("G01", { genealogyId })`;公开预览进入 G05;搜索申请进入 G08;审核中或被拒绝进入 G09。 - G08 当前搜索与邀请码都只完成本地表单校验:搜索来源无结果进入 G09 预览,邀请码来源无结果回 G01,均不得新增审核记录、选中家谱或声称加入成功。产品终点仍是“邀请码直接加入且不生成审核记录”,但必须等后端提供邀请码解析与直接加入合同后一次性替换本地预览;M08 的审核旧文案已在任务 9 删除,不写双分支兼容。 - G09 通过记录进入 G05,被拒绝记录进入 G08,搜索入口进入 G06;返回保留列表现场。 - G10/G11/G12 只更新当前页面的本地审核、设置或字辈预览,不向实际来源回传成功结果;取消按统一返回守卫处理。只有真实接口成功且来源页确实需要失效刷新时,才允许在同一合同变更中注册并消费一次性结果。 - [x] **步骤 1:逐页写失败断言** `tests/navigation-flow-contract.ps1` 已对上表每类流程检查路由键、`sourceKey`、无伪成功结果和终点;G 系列聚焦合同已移除直接 Uni API 正向断言。失败基线明确来自旧导航调用、伪结果和页面私有合同,而不是测试语法。 - [x] **步骤 2:迁移 G01、G03、G05** 根页、创建流程和总览已迁移。G03 在同一原生页面维护两步状态,并通过唯一临时预览 owner 生成词法字符串 ID;放弃删除本轮临时预览,完成只进入 G05 本地预览。G05 的名称、访问规则、来源、权限入口和公开字段由共享 fixture 与访问解析器投影,不信任路由伪造角色。 - [x] **步骤 3:迁移 G06、G08、G09** 三页已只传字符串 ID 与业务枚举;`previous` 原始来源参数已经删除,来源只由真实栈与受注册表约束的 `sourceKey` 表达。共享可见性与申请资格失败关闭;邀请码与搜索申请保留不同本地终点,G09 撤回只使用 `LOCAL_WITHDRAWN`,不冒充服务端状态。 - [x] **步骤 4:迁移 G10、G11、G12** 三页已删除伪后端成功结果并明确标注本地预览;G10 保留拒绝原因聚焦与无障碍关系,G11 的访问预设由 `utils/genealogy-contracts.js` 唯一拥有且未知值失败关闭。G12 按 OpenAPI 离线导出的 26000 字符、单代 50 字符、最多 500 代、状态 `0/1` 与历史保留约束实现 Unicode 安全解析、完整 ACTIVE 序列检查和每批 50 行渲染;服务端未定义批次首代前不发送本地合并结果。G01 弹层、G09 撤回确认及 G03/G08/G10/G11/G12 表单均已接入同一返回守卫。 - [ ] **步骤 5:完成 G 系列 MuMu 矩阵** 静态合同、纯 Node 冒烟、响应式合同和编译审计已经通过,源码导航债务收紧到 29 个文件。仍须在用户现有 MuMu 中验证 G01→G03→取消/完成、G01→G06→G08→G09、G05→G10/G11/G12→取消/本地完成、直接进入 G11/G12 后返回,以及每条流程重复进入;检查旧页面不残留、当前家谱 ID 不串、取消不刷新、本地预览不伪造服务端成功。受当前约束不得启动、关闭或调整模拟器,本步骤保持未勾选。 ### 任务 6:迁移 T 系列并实现 T03 单实例轨迹 **文件:** - 修改:`pages/tree/t01-tree-overview.vue` - 修改:`pages/tree/t03-member-profile.vue` - 修改:`pages/tree/t04-add-relative.vue` - 修改:`pages/tree/t05-edit-member.vue` - 修改:`pages/tree/t06-edit-relationship.vue` - 修改:`pages/tree/t07-member-directory.vue` - 修改:`pages/tree/t08-member-states.vue` - 修改:`data/mock.js` - 修改:`utils/api.js` - 修改:`tests/t03-t08-member-flow-contract.ps1` - 修改:`tests/t03-t08-member-flow-runtime-smoke.js` - 修改:`tests/t03-t08-business-specialization-contract.ps1` - 修改:`tests/t03-t08-business-specialization-runtime-smoke.js` - 修改:`tests/t07-module-baseline-contract.ps1` - 修改:`tests/t07-module-baseline-runtime-smoke.js` - 修改:`tests/navigation-flow-contract.ps1` - 新建:`tests/tree-member-fixture-runtime-smoke.js` **接口:** - T03 新增页内状态:`memberTrail: string[]`、`trailIndex: number`、`loadMember(personId): Promise`、`initializeMemberTrail(initialPersonId)`、`openRelative(personId)`、`popMemberTrail()`。 - 原生页面仍只有一个 T03;亲属点击只在 `loadMember` 成功后追加字符串 `personId`,失败不改轨迹、不清空当前成员。 - T03 初始 URL 的 `personId` 是不可变宿主页路由身份,页内活动成员随轨迹变化但不改 URL;当前 T03 只消费“打开成员”请求,不消费任何写成功结果。 - T04/T05/T06 当前都只生成明确写着“尚未提交服务器”的本地预览,提交动作留在本页且不产生导航结果;真实写接口、版本冲突和完成回流统一留到任务 18 原子启用。 - `data/mock.js::treeMembers` 是当前唯一可变成员夹具 owner,`utils/api.js` 是唯一写入口;页面只使用 `listTreeMemberFixtures(genealogyId)`、`findTreeMemberFixture(genealogyId, personId)` 的深拷贝快照。错误家谱、未知成员与缺失必填路由身份全部失败关闭,只有 T04 明确首位成员模式允许没有 `personId`。 - [x] **步骤 1:先锁定重复 T03 缺陷** 合同先断言初始读取失败时轨迹仍为空、初始成功后轨迹恰为 `[A]`,再构造 T01→T03(A)→亲属 B→亲属 C→返回→返回→来源,锁定 URL 始终为宿主 A、页内活动成员依次 A/B/C/B/A、最终才离开 T03;历史 B 失效时删除 B 后继续回 A。导航网关 Node 冒烟另构造伪栈 `[T01,T03,其他页]` 再打开同一家谱 T03,已证明回到既有实例且原生栈始终只有一个 T03;T03 浏览器运行时脚本已同步这些断言并通过语法检查,但因没有 `127.0.0.1:9222` 调试页而未执行。 - [x] **步骤 2:实现 T03 页内轨迹** 初始成员成功读取后必须先执行: ```js memberTrail.splice(0, memberTrail.length, initialPersonId); trailIndex.value = 0; ``` 随后亲属切换按以下顺序实现,并写明中文步骤注释: ```js const openRelative = async (nextPersonId) => { if (nextPersonId === personId.value) return; const loaded = await loadMember(nextPersonId); if (!loaded) return; memberTrail.splice(trailIndex.value + 1); memberTrail.push(nextPersonId); trailIndex.value = memberTrail.length - 1; }; ``` `popMemberTrail()` 先递减索引再加载历史成员;历史成员失效时移除该项并继续向前,不能生成新 T03 页面。页头与 `onBackPress` 必须构造同一 context,通过 `runBackGuard` 先消费内部轨迹;T04/T05/T06 等表单也以提交中→脏表单的统一优先级接线。 - [x] **步骤 3:迁移 T01、T04—T08 的终点** T01 打开 T03/T04/T06/T07 已全部使用路由键;T07 以路由键打开当前单实例 T03;T08 家谱失效使用根语义回 G01,普通状态使用统一返回。六位共享成员 101—106 已能从 T01/T07 一致进入 T03,104 能进入 T05/T08,106 进入 T06 时仍保持 106,不再回退首位成员。T04/T06 的本地预览由用户确认放弃后无结果回 T01;T05 的活动成员可能不同于宿主页路由身份,所以确认放弃预览后必须使用 `goBack()`:有历史时精确退回原 T03(A) 并保留活动成员 B 与轨迹,深链单页时才按当前参数构造规范父页。不得以活动成员 B 强行返回 T03(B),也不得把本地预览伪造成服务端完成。 - [ ] **步骤 4:完成 T 系列 MuMu 矩阵** T 聚焦合同、导航网关与成员夹具纯 Node 冒烟、全部 Node 语法检查、响应式合同和编译审计已经通过;最新全量结果为 PowerShell `122/122`、Node 语法 `35/35`、纯 Node `7/7`,源码导航债务收紧到 22 个页面/表单组件文件。成员夹具运行时已验证复合身份、跨谱隔离、父链与状态枚举、列表/单项深拷贝,以及 `createPerson` 写入完整 shape 后的同谱可见与跨谱不可见。依赖 `127.0.0.1:9222` 的浏览器脚本本轮只完成语法检查;仍须在用户现有 MuMu 中验证 T03 A→B→C→B→A→来源、T04/T05/T06 的取消与本地预览放弃、T07 选择、T08 返回和同一流程 20 次重复进入;原生栈不得随亲属浏览增长,三个预览均不得表现为服务端已保存。受当前约束不得启动、关闭或调整模拟器,本步骤保持未勾选。 ### 任务 7:迁移 F 系列导航 **文件:** - 修改:`utils/navigation-routes.js` - 修改:`data/mock.js` - 修改:`utils/api.js` - 修改:`pages/family/f01-family-feed.vue` - 修改:`pages/family/f02-publish-feed.vue` - 修改:`pages/family/f03-feed-detail.vue` - 修改:`pages/family/f04-article-list.vue` - 修改:`pages/family/f05-article-detail.vue` - 修改:`pages/family/f06-article-editor.vue` - 修改:`pages/family/f07-album-list.vue` - 修改:`pages/family/f08-album-detail.vue` - 修改:`pages/family/f09-media-upload.vue` - 修改:`pages/family/f10-video-list.vue` - 修改:`tests/f-business-flow-contract.ps1` - 修改:`tests/f05-expired-state-contract.ps1` - 修改:`tests/f06-editor-context-contract.ps1` - 修改:`tests/f08-album-detail-contract.ps1` - 修改:`tests/f09-media-upload-contract.ps1` - 修改:`tests/f10-video-status-contract.ps1` - 新增:`tests/family-fixture-runtime-smoke.js` - 修改:`tests/navigation-routes-contract.ps1` - 修改:`tests/navigation-source-scan-contract.ps1` - 修改:`tests/navigation-flow-contract.ps1` **流程合同:** | 流程 | 页面身份 | 取消/返回 | 当前阶段完成语义 | | --- | --- | --- | --- | | F01→F02 | `genealogyId` | F01 `{ genealogyId }` | 仅生成“尚未提交服务器”的本地预览,不发布、不产出结果 | | F01→F03 | `genealogyId + feedId` | F01 `{ genealogyId }` 且恢复现场 | 评论草稿保留在本页,不插入列表、不增加计数、不宣称发送成功 | | F01→F04→F05 | F04 为 `genealogyId`;F05 再加 `articleId` | F05 回 F04,F04 回 F01 | 收藏禁用并明确暂未开放 | | F04/F05→F06 | `genealogyId + mode`;编辑再加 `articleId` | 新建回 F04,编辑回精确 F05 | 仅生成未保存预览,不产出文章结果 | | F01→F07→F08→F09 | F07 为 `genealogyId`;F08/F09 再加 `albumId` | 逐级回实际来源 | 新相册与选图只做独立本地预览,不加入正式列表、不上传、不产出结果 | | F01→F10 | `genealogyId` | F01 `{ genealogyId }` | 当前未开放,无伪成功 | - [x] **步骤 1:先建立身份与伪成功失败合同** `tests/navigation-routes-contract.ps1` 已把 F02—F10 的 `genealogyId`、F03/F05/F08/F09 的复合实体身份、F06 的模式参数及 F01/F04/F05/F08 的零结果能力写成精确合同;`tests/family-fixture-runtime-smoke.js` 已把跨谱拒绝、缺参拒绝、词法字符串 ID 和列表/详情深拷贝写成运行时合同。旧实现分别因 `feed-created` 预注册和缺少共享查询 owner 正确红灯,证明失败来自待迁合同而非测试环境。 - [x] **步骤 2:收口唯一身份与只读内容 owner** F01 可从全局上下文解析家谱,但在开放任何内容和入口前必须用 `goRoot("F01", { genealogyId })` 把根页 URL 规范化;无有效成员家谱时失败关闭。F02—F10 全部必填 `genealogyId`,实体页按复合身份精确查询,缺参、未知实体和跨谱 ID 均不得回退首条数据。F01 的 R01/R03/R05/R10/R11 入口同轮把 `genealogyId` 交给注册表,R 页面内部迁移仍留在任务 8。 `data/mock.js` 是 F 系列只读 feed/article/album 夹具的唯一 owner,只公开按家谱列表和按复合身份详情的深拷贝查询函数;页面与 `utils/api.js` 不得读取或写入裸数组。`createFeed` 只允许真实远端配置下 POST;mock 模式必须以 `WRITE_UNAVAILABLE` 失败关闭。 - [x] **步骤 3:迁移动态与谱文流程** F01—F06 全部只使用导航网关。F02、F03、F06 的页头、Android 返回和显式取消统一处理脏草稿及校验中状态;F03 失效回 F01,F05 失效回 F04,F06 按 `mode=create/edit` 精确回 F04/F05。删除“动态已发布”“评论已发送”“谱文已保存”和收藏二态,任何页面不得调用 `finishPage`。 - [x] **步骤 4:迁移相册与未开放视频** F07 新建相册只形成独立、明确标注不会保存的本地预览,不插入正式相册列表;弹层与草稿分别按优先级处理。F08 页头和 Android 返回都先关闭照片预览,再回 F07;F09 的选择、批量说明与逐图说明均计入脏状态,校验中阻止离开,本地预览后无结果回同一 `genealogyId + albumId` 的 F08。F10 使用 `returnTo("F01", { genealogyId })`。旧 `/genealogy/app/files/upload` 不得进入运行时;真实上传必须在后续接口批次按线上 resumable init/chunk/complete 取得 `ossId` 后再创建相册照片记录。 - [x] **步骤 5:验证 F 系列静态与纯运行时合同** F 聚焦合同、共享导航合同、家族夹具、响应式合同和编译审计均已通过。生产 F01—F10 的 `resultOperations` 精确为空,网关运行时逐项证明旧 `feed-created/article-created/article-updated/media-uploaded` 在任何导航发生前被拒绝且不残留结果;泛型完成结果算法改用只存在于测试内存模块的 `TEST_*` 中性路由,不把旧 F 语义伪装成夹具。`data/mock.js` 的列表/详情深拷贝、词法字符串 ID、未知实体与跨谱隔离已由 `tests/family-fixture-runtime-smoke.js` 验证,mock 写动态以 `WRITE_UNAVAILABLE` 失败关闭。 最新文件 fresh 全量结果为 PowerShell `122/122`、Node 语法 `36/36`、纯 Node `8/8`;源码扫描为 `MIGRATION-DEBT=13`,剩余 `13 个页面/表单组件文件`,计数为 `navigateTo 17`、`navigateBack 1`、`redirectTo 3`、`reLaunch 1`、`getCurrentPages 1`、业务页面路径字面量 `27`。依赖 `127.0.0.1:9222` 的浏览器脚本没有可连接调试页,本批只完成 `36/36` 语法检查,未冒充浏览器或原生通过。 - [ ] **步骤 6:完成 F 系列 MuMu 矩阵** 在 MuMu 逐条验证表中流程的进入、返回、取消、本地预览和重复进入,重点检查 F01/F04/F07 不重复、F08 预览优先关闭、F09 始终不显示上传成功。受当前约束不得启动、关闭或调整 MuMu,静态与纯运行时绿灯不能冒充原生视觉通过。 ### 任务 8:迁移 R 系列导航 **文件:** - 修改:`utils/navigation-routes.js` - 修改:`data/mock.js` - 修改:`utils/api.js` - 修改:`pages/tree/t03-member-profile.vue` - 修改:`pages/records/r01-people-list.vue` - 修改:`pages/records/r02-person-detail.vue` - 修改:`pages/records/r03-gift-list.vue` - 修改:`pages/records/r04-gift-editor.vue` - 修改:`pages/records/r05-ritual-list.vue` - 修改:`pages/records/r06-ritual-detail.vue` - 修改:`pages/records/r07-ritual-editor.vue` - 修改:`pages/records/r08-growth-journal.vue` - 修改:`pages/records/r09-life-events.vue` - 修改:`pages/records/r10-memo-list.vue` - 修改:`pages/records/r11-merit-records.vue` - 修改:`tests/r-business-flow-contract.ps1` - 修改:`tests/r01-r02-person-flow-contract.ps1` - 修改:`tests/r02-person-detail-contract.ps1` - 修改:`tests/r02-person-detail-runtime-smoke.js` - 新建:`tests/records-fixture-runtime-smoke.js` - 修改:`tests/navigation-routes-contract.ps1` - 修改:`tests/navigation-gateway-runtime-smoke.js` - 修改:`tests/navigation-flow-contract.ps1` - 修改:`tests/navigation-source-scan-contract.ps1` - 修改:`docs/接口与页面映射总表.md` - 修改:`docs/项目当前总览.md` **三方审查结论与流程合同:** - R01/R02 归属线上 `lineage/persons`;R03/R04 归属 `relative-records`,不是依赖礼仪 ID 的 ceremony gifts,`giftId` 全量退役为 `relativeId`;R05—R07 归属 `ceremonies`,`ritualId` 全量退役为 `ceremonyId`。R08 只可在同一家谱内按 `lineagePersonId` 过滤 `growth-records`;R10/R11 分别归属 `memos` 与 `merit-records`。 - R09 必须保持硬关闭:线上没有独立人生事件接口,`growth-records.recordType` 也没有可引用枚举或类型说明;后端给出独立端点或正式字典前,不得自行发明 `LIFE_EVENT`、不得读取或写入成长记录冒充人生事。 - R01/R03/R05/R10/R11 必填 `genealogyId`;R02 必填 `genealogyId + mode`、可选 `personId`,create 禁止 ID、view 必须有精确 ID;R04 同理使用 `relativeId`;R06 必填 `genealogyId + ceremonyId`;R07 必填 `genealogyId + mode`、可选 `ceremonyId`,create/edit 条件互斥;R08/R09 必填 `genealogyId + personId`。所有 ID 保持词法字符串,未知实体、缺参和跨谱都失败关闭。 - 当前只允许只读正式列表与独立草稿;所有保存动作只能形成明确的“本地预览,尚未提交服务器”,不得插入正式列表、修改完成状态、增加汇总、生成实体 ID 或宣称删除。生产 R01—R11 的 `resultOperations` 精确为空,真实 API 成功接入前不得调用 `finishPage`。 - 权限模型只说明需要登录,没有 `canCreate/canEdit/canDelete`;本地成员角色、`sponsorUserId` 和页面 fixture 都不得冒充服务端权限。无效上下文必须隐藏预览入口;本地预览入口不能写成“已保存”或“已删除”。 - R02/R04/R07/R08/R10/R11 的页头、弹层取消按钮与 `onBackPress` 共享返回守卫;浮层优先关闭、提交中阻止离开、脏草稿确认放弃。R09 只有不可用说明与安全返回。R07 create 预览回 R05,edit 预览回原 R06;R08 返回实际 R02 或 T03 来源。 - [x] **步骤 1:先写旧参数、复合身份、零结果与假成功失败合同** 反转当前保护 `saveResult`、`personName`、`giftId`、`ritualId`、直接 Uni 路径、本地 `unshift`、完成状态切换和成功文案的旧测试;网关运行时必须证明旧 person/gift/ritual 结果在导航前被拒绝且无残留。源码扫描的 R01—R07 债务同轮清零。 - [x] **步骤 2:收紧注册表与精确身份** 先只改注册表和导航消费者:所有子页携带 `genealogyId`,实体页携带精确实体 ID,create/view/edit 条件由页面失败关闭;删除 R 生产结果能力和全部原始 Uni 导航。此步不接真实写接口。 - [x] **步骤 3:建立只读复合身份 owner** R01/R02 复用树成员深拷贝选择器;relative、ceremony、growth、memo、merit 建立唯一只读复合身份 owner,并由 `tests/records-fixture-runtime-smoke.js` 锁定深拷贝、词法 ID、未知实体和跨谱隔离。`utils/api.js` 的 ceremony/growth mock 只能消费该 owner,不能继续返回未隔离的 `familyContent`。 - [x] **步骤 4:按领域独立迁移页面** 依次完成 R01/R02 人物、R03/R04 人情往来、R05—R07 礼仪,再完成 R08/R10/R11 的同页预览;每一域分别运行聚焦合同后才进入下一域。字段使用真实 Body 语义:人物 `name`,往来 `relativeName`,礼仪 `ceremonyTitle/ceremonyType`,成长 `recordTitle`,备忘 `memoTitle`,功德 `donorName/meritTitle`。不明确的枚举不制作选择器。 - [x] **步骤 5:关闭 R09 并同步接口缺口** R09 不显示 fixture 时间轴、不开放新增,只说明后端缺少独立接口或正式 `recordType` 字典并安全返回;将接口问题写入总表,不能以页面隐藏替代后端问题单。 - [x] **步骤 6:执行静态与纯运行时验证** 运行 R 聚焦合同、导航注册表/网关/源码扫描、记录夹具 Node 冒烟、编译审计、全部 PowerShell 合同和全部 Node 语法检查;同步最新物理计数、迁移债务与明确未执行的环境验证。 R01—R11 已按复合家谱身份、受控成员展示投影、只读正式记录和独立本地预览完成静态迁移;R09 因线上没有独立端点或正式 `recordType` 字典保持硬关闭。三方复核未发现 P0/P1,受限成员的私密字段在数据 owner 即被删除,礼仪邀请只以 `inviteeUserId` 与同谱成员受控联接;编辑和弃稿弹层均禁止遮罩误关。最新文件 fresh 全量结果为 PowerShell `122/122`、Node 语法 `37/37`、纯 Node `9/9`;源码扫描为 `MIGRATION-DEBT=6`,剩余 `6 个页面/表单组件文件`,计数为 `navigateTo 8`、`navigateBack 1`、`redirectTo 0`、`reLaunch 0`、`getCurrentPages 1`、业务页面路径字面量 `14`。依赖 `127.0.0.1:9222` 的浏览器脚本只完成语法检查;受当前约束未触碰 MuMu,不能把静态结果冒充原生视觉验收。 - [ ] **步骤 7:完成 R 系列 MuMu 矩阵** MuMu 分别验证复合身份、跨谱失败、列表滚动、搜索现场、预览不入正式列表、弹层优先关闭、脏草稿确认、连续重复进入与 R09 硬关闭。当前明确禁止启动、关闭或调整 MuMu,因此此项保持未完成,静态绿灯不得冒充原生视觉通过。 ### 任务 9:迁移 N/M 系列与安全通知目标 **文件:** - 修改:`utils/navigation-routes.js` - 修改:`utils/navigation.js` - 修改:`pages/notification/n01-message-center.vue` - 修改:`pages/notification/n02-message-detail.vue` - 修改:`pages/profile/m01-profile-home.vue` - 修改:`pages/profile/m02-edit-profile.vue` - 修改:`pages/profile/m03-security-settings.vue` - 修改:`pages/profile/m04-change-password.vue` - 修改:`pages/profile/m05-change-phone.vue` - 修改:`pages/profile/m06-help-center.vue` - 修改:`pages/profile/m07-feedback.vue` - 修改:`pages/profile/m08-promotion.vue` - 修改:`pages/profile/m09-vip-orders.vue` - 修改:`pages/profile/m10-about-settings.vue` - 修改:`tests/nm-page-business-contract.ps1` - 修改:`tests/nm-business-runtime-smoke.js` - 修改:`tests/root-pages-runtime-smoke.js` - 修改:`tests/navigation-flow-contract.ps1` **接口:** `utils/navigation-routes.js` 同轮增加唯一通知目标表: ```js export const NOTICE_TARGETS = Object.freeze({ GENEALOGY_REVIEW: Object.freeze({ routeKey: "G10", params: ["genealogyId"] }), GENEALOGY_HOME: Object.freeze({ routeKey: "G01", params: ["genealogyId"] }), }); ``` `utils/navigation.js` 新增 `openNoticeTarget(targetType, params, sourceKey = "N02")`:未知类型、缺字段、多字段、非字符串 ID 和原始 URL 一律拒绝;G01 使用 `goRoot`,G10 使用 `openPage`。 - [x] **步骤 1:先锁定原始 URL 漏洞** 合同要求 N01/N02 数据只含 `targetType` 与 `targetParams`,源码不得出现 `noticeDetail.value.target` 或 `/pages/...` 目标字符串。旧 N02 因持有 `target` 并直接 `navigateTo` 而失败。 - [x] **步骤 2:迁移 N01/N02** N01 打开 N02 固定使用 `openPage("N02", { id }, "N01")`;N02 默认回 N01。目标无权限、过期、字段不足或类型未知时留在 N02 明确说明,不猜测、不回落空页面。 - [x] **步骤 3:迁移 M 页面、邀请语义与退出** M01 菜单由路由字符串改成路由键;M03/M06 使用 `openPage`;M08 删除“邀请码加入后需要管理员审核”及其并行分支,只保留已经定案的“邀请码直接加入且不生成审核记录”,聚焦合同同时断言旧文案不存在。M10 确认退出时按顺序执行 `session.clear()`、关闭确认层、`goRoot("A01")`,退出取消只关闭弹窗。M02/M04/M05/M07 的表单、M08/M09 的分享或说明弹层、M10 的协议和退出确认都让页头与 `onBackPress` 共用同一 `requestBack/runBackGuard`;提交中优先于脏表单,弹层优先于页面返回。 - [x] **步骤 4:完成 N/M 静态验证** N01/N02 现在只消费 `data/mock.js` 的词法消息 ID、`targetType/targetParams` 与 `unread` 字段;安全目标由 `NOTICE_TARGETS` 白名单映射,未知、缺参、多参、原始 URL 和越权目标均失败关闭。M02/M03/M05 统一消费当前用户 owner,M02/M04/M05 仅做明确未提交服务器的本地校验;M07 已在后续独立任务 25 接入真实反馈客户端。M08/M09 在缺少可实施合同时硬关闭伪邀请码和伪订单;M10 从 `manifest.json` 读取版本,并按 `session.clear()`、关闭确认层、`goRoot("A01")` 的顺序退出,账号令牌和当前家谱上下文同时清理。 Task9 最新静态结果为 PowerShell `122/122`、Node 语法 `39/39`、纯 Node `11/11`;导航扫描为 `MIGRATION-DEBT=1`,唯一剩余项是零生产消费者 `components/tree/TreeMemberForm.vue`,交由任务 10 原子删除。两份浏览器运行时合同已更新但因 `5173/9222` 均无可用页面只完成语法检查,未冒充浏览器通过。 - [ ] **步骤 5:完成 N/M 系列 MuMu 矩阵** MuMu 验证 G01/M01→N01→N02→业务目标、目标失效、返回、重复进入,以及 M01→M10→取消/确认退出。确认退出后原栈被清理且只有 A01。当前明确禁止启动、关闭或调整 MuMu,因此本项保持未完成。 ### 任务 10:关闭导航阶段 **文件:** - 修改:`tests/navigation-source-scan-contract.ps1` - 修改:`tests/navigation-flow-contract.ps1` - 删除:`components/tree/TreeMemberForm.vue` - 删除:`tests/tree-member-form-document-flow-contract.ps1` - 修改:`tests/t03-t06-all-states-visual-contract.ps1` - 修改:`tests/responsive-layout-coverage.json` - 修改:`docs/家谱项目全量治理设计.md` - 修改:`docs/家谱项目全量治理实施计划.md` - 修改:`docs/项目当前总览.md` - 修改:`docs/接口与页面映射总表.md` - [x] **步骤 1:执行零调用与闭合合同** `TreeMemberForm.vue` 已经三人确认零生产消费者并删除;旧专属合同同步删除,T04/T05/T06 活动页面接替字体、布局与文档流覆盖,响应式清单和导航债务账本同轮收紧。`tests/navigation-source-scan-contract.ps1` 证明 pages、components、utils 中网关外的五种 Uni 导航调用和 `getCurrentPages` 数量均为 `0`,唯一允许位置是 `utils/navigation.js`,`switchTab` 全项目为 `0`;路由注册表与 `pages.json` 为 `52/52`,结果为 `MIGRATION-DEBT=0`。 - [x] **步骤 2:运行全量合同** 运行本计划“每个代码批次的固定验证”中的全量 PowerShell 循环,再运行: ```powershell node tests/navigation-gateway-runtime-smoke.js node tests/password-policy-runtime-smoke.js ``` 最新文件 fresh 结果为 PowerShell `121/121`、Node 语法 `39/39`、纯 Node `11/11`,编译审计和响应式合同包含在全量绿灯中。合同数量从 122 变为 121 的唯一原因是删除了只保护已退役孤儿组件的专属测试,其有效视觉断言已经迁到 T04/T05/T06 现页合同;没有删除或放宽任何活动业务失败条件。 - [ ] **步骤 3:执行 NAV-MUMU-01 至 NAV-MUMU-08** 1. 认证注册与重设返回; 2. G03 创建取消/完成; 3. G06/G08/G09 加入流程; 4. T03 A→B→C→B→A→来源; 5. F02/F03/F04/F05/F06 回流; 6. F07/F08/F09 与预览优先级; 7. R01—R07 列表/详情/编辑; 8. N01/N02 业务目标与 M10 退出。 每组都验证进入、返回、取消、完成、直接进入和重复进入。记录实际行为与栈结果;忽略“3D研究室”等系统水印,不调整 MuMu。 - [ ] **步骤 4:三人终审并更新状态** 三人分别检查业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性;任何一人提出有证据的问题即继续修复。只有所有导航门禁均通过,才把总览改为“导航栈语义统一已完成”,并开启第二阶段接口门禁。 --- ## 第二阶段:T01 大规模世系树 ### 任务 11:向后端提交规范图合同并建立接口门禁 **文件:** - 新建:`tests/lineage-openapi-contract.ps1` - 修改:`docs/接口与页面映射总表.md` - 修改:`docs/家谱项目全量治理设计.md` **当前已证实缺口:** - 现有 `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` 只有 `genealogyId`,响应是递归 `LineagePersonTreeView[]`。 - 当前接口没有 `/lineage/tree/overview` 和 `/lineage/persons/{personId}/locator`。 - 当前世系 ID 大量使用 `integer/int64`,JavaScript 无法保证超过安全整数后的精确性。 - 当前递归 `children/spouses` 无法精确表达多个家庭联合点、收养/继亲/监护、主入边、未加载边界和图版本。 - T06 所需的关系修改接口缺失;现有新增父母、子女、兄弟姐妹和配偶接口没有统一的 `If-Match` 树版本并发合同。 **新读合同:** ```http GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId} ``` `/lineage/tree` 查询参数固定为 `mode,focusPersonId,ancestorDepth,descendantDepth,boundaryId,cursor,limit,treeVersion`,不接受 `branchId/generation`。两个模式严格互斥: - `mode=FOCUS`:`focusPersonId` 可选且只能是调用者可见的稳定人物 ID,REDACTED opaque ID 不得作为焦点;缺省时服务端按当前账号绑定成员、主始祖的顺序确定,并返回实际焦点。不可见、过期或不存在的焦点统一返回 HTTP `404 LINEAGE_FOCUS_NOT_AVAILABLE`,不泄漏成员是否存在。`ancestorDepth/descendantDepth` 是 0—20 的整数、可选且默认 2;`boundaryId/cursor/treeVersion` 必须缺席。 - `mode=BOUNDARY`:`boundaryId/cursor/treeVersion` 三项必填;`focusPersonId/ancestorDepth/descendantDepth` 必须缺席。 - `limit` 最小 1、默认 200、最大 500;混合、缺项、多余或越界组合返回 HTTP `422` 和稳定业务码 `LINEAGE_QUERY_INVALID`。OpenAPI 同时声明 `400/401/403/404/422/429/5xx`。 响应 `data` 必须是: ```json { "version": { "schemaVersion": "2.0", "treeVersion": "t-20260722-1", "generatedAt": "2026-07-22T10:00:00+08:00" }, "state": "EMPTY", "genealogyId": "1001", "nodes": [], "familyUnits": [], "edges": [], "window": { "focusPersonId": null, "entryPersonIds": [], "scope": { "ancestorDepth": 2, "descendantDepth": 2 }, "generationRange": null, "returnedNodeCount": 0, "boundaries": [] } } ``` 这是合法的 `state=EMPTY` 空谱,不是合同错误。下面是 `state=POPULATED` 响应中的可见人物、匿名人物、家庭和边片段;完整 POPULATED 根必须有非空 nodes、有效焦点、至少一个窗口入口以及非空代际范围。 节点、家庭和边的精确形状: ```json { "node": { "id": "p103", "generation": 18, "displayName": "某某某", "sex": "MALE", "avatarOssId": null, "branchId": "b-main", "branchPath": ["b-root", "b-main"], "order": 1, "visibility": "VISIBLE", "entryReason": null }, "redactedNode": { "id": "redacted:t-20260722-1:17", "generation": 18, "displayName": "隐私成员", "order": 2, "visibility": "REDACTED", "entryReason": null }, "familyUnit": { "id": "fu-103-1", "anchorPersonId": "p103", "partnerRelationship": { "relationshipId": "rel-partner-103-104", "relationshipKind": "PARTNER", "relationType": "MARRIAGE", "status": "ACTIVE" }, "partners": [ { "personId": "p103", "partnerRole": "ANCHOR", "order": 1 }, { "personId": "p104", "partnerRole": "PARTNER", "order": 2 } ], "order": 1 }, "edge": { "id": "e-103-106", "familyUnitId": "fu-103-1", "childId": "p106", "lineageParentId": "p103", "parentRelations": [ { "relationshipId": "rel-parent-103-106", "relationshipKind": "PARENT_CHILD", "personId": "p103", "parentRole": "FATHER", "relationType": "BIOLOGICAL" }, { "relationshipId": "rel-parent-104-106", "relationshipKind": "PARENT_CHILD", "personId": "p104", "parentRole": "MOTHER", "relationType": "BIOLOGICAL" } ], "primary": true, "order": 1 } } ``` `LineageGraphWindow` 以根字段 `state` 为 discriminator 使用 `oneOf`,枚举只允许 `EMPTY/POPULATED`。EMPTY 分支精确要求 `nodes/familyUnits/edges=[]`,且窗口内 `focusPersonId=null、entryPersonIds=[]、generationRange=null、returnedNodeCount=0、boundaries=[]`;POPULATED 分支要求 nodes 非空、`focusPersonId` 引用其中一个 VISIBLE 节点、`entryPersonIds` 非空且全部引用当前 nodes、`generationRange` 非空,并满足 `returnedNodeCount === nodes.length`。 非空 `generationRange` 固定为 `{ minGeneration, maxGeneration }`,两项均为大于等于 1 的整数且关闭额外字段;大小顺序由运行时 validator 验证。四条 v2 操作统一声明 `200/400/401/403/404/422/429/5XX`,tree、overview、relationship PATCH 另声明 `409`。错误响应根层必填字符串字段固定为 `businessCode`;每个稳定业务码必须落在对应响应 `oneOf` 分支的单值 enum(OpenAPI 3.1 可用 `const`)中,不能从 description、example 或无关 metadata 搜索得出。 `entryPersonIds` 精确等于当前返回窗口中没有 primary 入边的节点集合,不代表全谱始祖;secondary 入边不取消入口身份。入口节点 `entryReason` 只允许 `GENEALOGY_ROOT/WINDOW_CUT/DISCONNECTED_COMPONENT`,非入口固定为 `null`。每个入口在当前窗口恰有零条 primary 入边,每个非入口恰有一条 primary 入边;`GENEALOGY_ROOT` 表示全谱主森林根,`WINDOW_CUT` 表示 canonical primary 父边在窗口外,`DISCONNECTED_COMPONENT` 表示没有可达全谱根的 canonical primary 链。全谱根只由 overview 的可见根/隐私根计数和 locator 的根可见性分支表达。 人物以 `visibility` 为 discriminator 使用 `oneOf`,枚举只允许 `VISIBLE/REDACTED`。`VisibleLineagePerson` 精确包含 `id,generation,displayName,sex,avatarOssId,branchId,branchPath,order,visibility=VISIBLE,entryReason`,其 ID 是可用于 FOCUS、locator、搜索和写接口且不得以 `redacted:` 开头的稳定人物 ID,`sex` 只允许 `MALE/FEMALE/UNKNOWN`;`RedactedLineagePerson` 只允许 `id,generation,displayName,order,visibility=REDACTED,entryReason`,`displayName` 固定为“隐私成员”,opaque ID 精确使用 `redacted:{treeVersion}:{token}` 并仅能在该 `treeVersion` 内作当前图内部引用,不得用于 FOCUS、locator、搜索或写接口,也不得进入任何 `focusPersonId/targetPersonId`,并且不得返回 `sex/avatarOssId/branchId/branchPath` 或其他可推断身份字段。两类人物的 `generation` 都是大于等于 1 的整数,`order` 是非负整数;可见人物的 `avatarOssId` 只允许非空字符串或 `null`,其他实体、关系和引用 ID 必须是非空字符串。 双人家庭的 `partnerRelationship` 必含稳定 `relationshipId、relationshipKind=PARTNER、relationType、status`;`relationType` 为 `MARRIAGE/PARTNERSHIP/UNKNOWN`,`status` 为 `ACTIVE/ENDED/UNKNOWN`。单亲家庭该对象固定为 `null` 且 `partners` 只有一个 ANCHOR;`partnerRole` 只允许 `ANCHOR/PARTNER`,多配偶为每段伴侣关系建立不同 FamilyUnit。父子关系固定 `relationshipKind=PARENT_CHILD`。`lineageParentId` 必须同时出现在该 edge 的 `parentRelations` 和对应家庭成员中,子女不得同时属于该家庭成员;primary 入边与 `entryPersonIds` 必须满足上一段的零条/恰一条森林不变量,所有父子关系都检查自环和有向循环。 边界固定形状为 `{ id, anchorType, anchorId, direction, reason, hiddenCount, cursor }`。PERSON/FAMILY_UNIT 的 `anchorId` 引用对应实体,WINDOW 的 `anchorId=null`;`hiddenCount` 只能是非负整数或 `null`;cursor 仅在 `reason=UNLOADED` 时为非空字符串,MISSING/REDACTED 时固定为 `null`。窗口内匿名人物使用 `Node.visibility=REDACTED`,窗口外隐藏拓扑使用 `boundary.reason=REDACTED`,同一隐藏对象不得在一个响应中重复表达。 `/lineage/tree/overview` 只接受必填查询参数 `treeVersion` 并返回同一快照;版本已变化时返回 `409 TREE_VERSION_CHANGED`。`LineageOverview` 精确包含同一 `{ schemaVersion, treeVersion, generatedAt }` 以及 `state、genealogyId、genealogyPersonCount、genealogyRootPersonIds、redactedGenealogyRootCount、generationRange、buckets`,以 `state=EMPTY/POPULATED` 使用 `oneOf`。EMPTY 精确要求 `genealogyPersonCount=0、genealogyRootPersonIds=[]、redactedGenealogyRootCount=0、generationRange=null、buckets=[]`。POPULATED 要求 `genealogyPersonCount>0`、非空 generationRange 和 buckets,且 `genealogyRootPersonIds.length + redactedGenealogyRootCount >= 1`;每个 bucket 固定包含 `id、branchId、generation、visibleCount、redactedCount、unloadedCount、focusPersonId`,三种 count 都是非负整数且全部 buckets 的三类计数总和等于 `genealogyPersonCount`。 `genealogyRootPersonIds` 只包含调用者可见、可用于 FOCUS 的稳定人物 ID,隐私根只计入 `redactedGenealogyRootCount`,绝不返回 opaque ID。bucket 的 `focusPersonId` 只能是该 bucket 内可见稳定人物 ID,没有可见目标时固定为 `null`。 `/locator` 返回 `{ treeVersion, genealogyId, personId, rootVisibility, rootPersonId, pathCompleteness, ancestorPathSegments, generation, branchId }`。根可见性与路径完整性独立校验:`rootVisibility=VISIBLE` 要求可见稳定 `rootPersonId`,`rootVisibility=REDACTED` 要求 `rootPersonId=null`;`pathCompleteness=COMPLETE` 表示没有隐私缺口,`pathCompleteness=REDACTED_GAPS` 表示存在一个或多个隐私段,四种组合中除 `REDACTED + COMPLETE` 因根本身隐私而非法外,其余三种均可出现。 `ancestorPathSegments` 以 `kind` 使用 `oneOf`。VISIBLE 段精确为 `{ kind: "VISIBLE", personIds: [...] }`,personIds 非空且只含可见稳定 ID;REDACTED 段精确为 `{ kind: "REDACTED", hiddenCount }`,hiddenCount 为正整数或不披露时为 `null`,不得含人物 ID。相邻段必须交替,末个 VISIBLE 段以目标结束;可见根时首个 VISIBLE 段从 rootPersonId 开始,隐私根时首段为 REDACTED;COMPLETE 恰好没有 REDACTED 段,REDACTED_GAPS 至少一段。不得返回客户端路由或任何隐私 opaque ID。 所有世系写接口携带 `If-Match: `;成功返回 `{ treeVersion, affectedPersonIds, affectedFamilyUnitIds, affectedRelationshipIds }`。关系 PATCH 由 `relationshipId` 唯一寻址,并以不可变 `relationshipKind` 为 discriminator 使用 `oneOf`:PARTNER 分支只允许 `relationType/status`,PARENT_CHILD 分支只允许 `relationType/parentRole`;每个分支除 `relationshipKind` 外至少提交一个可修改字段,未提交的可修改字段保持原值,空更新返回 HTTP `422` 和稳定业务码 `RELATIONSHIP_PATCH_EMPTY`。kind 必须与服务端既有关系一致,参与人不可偷换。版本变化返回 HTTP `409` 和稳定业务码 `TREE_VERSION_CHANGED`。旧 v1 树接口保持原合同;当前 App 只消费固定的四条 `/genealogy/app/v2/...` 新路径,不做双读或运行时探测。 ```json { "oneOf": [ { "relationshipKind": "PARTNER", "relationType": "MARRIAGE", "status": "ACTIVE" }, { "relationshipKind": "PARENT_CHILD", "relationType": "BIOLOGICAL", "parentRole": "FATHER" } ] } ``` 两个请求分支都设置 `additionalProperties: false`,并以 `minProperties` 或等价 `anyOf(required)` 约束至少一个可修改字段;`relationshipKind` 必填且只作判别与一致性校验,不能通过 PATCH 改值。测试必须覆盖单字段更新、双字段更新、未提交字段保持原值,以及只有 `relationshipKind` 的空更新稳定失败为 `422 RELATIONSHIP_PATCH_EMPTY`。 - [x] **步骤 1:先写严格接口合同并确认当前红灯** `tests/lineage-openapi-contract.ps1` 已读取受保护双导出并先聚合前置缺口,再在前置项齐全时逐项断言上述四条固定 `/genealogy/app/v2/...` 路径、参数全集、overview 必填 `treeVersion`、响应 `$ref` 闭包、两个 EMPTY/POPULATED schema、locator 根可见性 `oneOf`、`additionalProperties: false`、关系 `oneOf/discriminator`、完整枚举、稳定关系 ID、字符串 ID、nullable 头像、1/200/500 限制、统一状态矩阵、根层 `businessCode`、409 和 `If-Match`;同时递归证明新 schema 不挂到 v1 树响应,并在限定的 YAML paths/components 块中对齐 operationId 和完整 component 引用闭包。当前结果为预期 `LINEAGE-OPENAPI-CONTRACT BLOCKED`,精确列出 JSON/YAML 各缺四条操作和三个固定根模型。平铺 query parameters 无法由 OpenAPI 静态证明 FOCUS/BOUNDARY 跨字段互斥,因此静态层只锁参数全集、枚举和单字段边界;互斥、条件必填、跨 bucket 计数、根隐私、请求/响应版本一致性、409/422 行为和 PATCH 字段保持由运行时 validator 与部署集成测试负责,不能用 description、example、关键词或 schema 存在冒充。YAML 门禁只证明路径、operationId 和引用闭包一致;双导出到位后仍逐字段人工复核,不能把文本解析夸大为完整 YAML 语义解析。 - [x] **步骤 2:把问题单写入唯一映射总表并交给后端** 在 `docs/接口与页面映射总表.md` 的 T01 专节记录当前值、目标值、受影响页面、请求/响应、错误码、权限、验收步骤和上述 JSON 示例。不得新建第六份 Markdown。 - [ ] **步骤 3:等待用户提供新的双格式导出** 本步骤是硬门禁:只接受用户从后端更新后的 Apifox 重新导出的 `APP.openapi.json` 与 `APP.openapi.yaml`。不手改本地导出、不用 mock 假装接口已完成、不提前实施任务 12。 - [ ] **步骤 4:验证新合同绿灯** 运行 `tests/lineage-openapi-contract.ps1`。预期 `LINEAGE-OPENAPI-CONTRACT PASS`;再核对 JSON/YAML 的接口路径、operationId、参数、响应引用和模型字段一致,三人复核后才开启客户端实现。 ### 任务 12:实现新图的规范化与严格校验 **文件:** - 新建:`utils/lineage/normalize.js` - 新建:`utils/lineage/validate.js` - 新建:`tests/lineage-graph-contract-runtime-smoke.js` - 新建:`tests/lineage-overview-locator-runtime-smoke.js` **接口:** - `normalizeLineageGraphWindow(raw): LineageGraphWindow` - `normalizeLineageOverview(raw): LineageOverview` - `normalizeLineageLocator(raw): LineageLocator` - `validateLineageGraph(graph): { valid: boolean, issues: Array<{ code, path, message }> }` - `validateLineageOverview(overview, { expectedTreeVersion }): { valid, issues }` - `validateLineageLocator(locator, { expectedTreeVersion, expectedPersonId }): { valid, issues }` - `assertLineageGraph(graph): LineageGraphWindow`;非法时抛出 `LineageGraphError`,错误对象保留全部 issues。 - `assertLineageOverview` 与 `assertLineageLocator` 使用同一错误结构,且分别在概览展示与定位回流前强制调用。 - [ ] **步骤 1:先覆盖有效和无效图** 测试至少构造:合法 EMPTY、合法 POPULATED、单亲、多配偶、收养次入边、带 secondary 入边但无 primary 入边的 DISCONNECTED_COMPONENT 入口、WINDOW_CUT 入口、窗口内匿名节点、窗口外隐私边界、未加载边界、WINDOW 空锚点和字符串大 ID。 无效图必须覆盖:EMPTY 仍带焦点或节点、POPULATED 无焦点或无入口;`entryPersonIds` 引用不存在人物、列表内人物 `entryReason=null`、列表外人物 `entryReason!=null`、入口仍有 primary 入边、非入口没有或具有多条 primary 入边;重复 ID、缺引用、自环、任意父子关系循环、反向代际、家庭锚点不等于唯一 ANCHOR、双人家庭缺关系对象或稳定 ID、relationshipKind 与容器不符、非法 state/sex/visibility/entryReason/partnerRole/relationType/status、REDACTED 泄漏身份字段或进入 `focusPersonId/targetPersonId`、父母不在家庭、子女同时在家庭、UNLOADED 无 cursor、非 UNLOADED 带 cursor、版本不匹配、数字 ID、旧 `children/spouses`、旧 `parentId`、旧 `partnerIds`、旧 `rootPersonIds/rootReason` 和旧 `version.schema/version.tree`。 概览运行时冒烟必须覆盖合法 EMPTY、可见根 POPULATED、全隐私根 POPULATED;并拒绝 EMPTY 带根/范围/bucket 或非零总量、POPULATED 零总量/空范围/空 bucket/可见根数加隐私根数为零、bucket 三类计数与 `genealogyPersonCount` 不等、重复 bucket ID、`genealogyRootPersonIds` 或 bucket `focusPersonId` 出现 `redacted:` opaque ID、响应 `treeVersion` 与请求不一致。locator 必须覆盖“可见根+COMPLETE”“可见根+REDACTED_GAPS”“隐私根+REDACTED_GAPS”三种合法组合,并拒绝“隐私根+COMPLETE”、COMPLETE 含隐私段、REDACTED_GAPS 无隐私段、相邻同类段、首段与根可见性不符、可见根路径不从 `rootPersonId` 开始、末段不以目标结束、VISIBLE 段含 `redacted:` ID、REDACTED 段含人物 ID、响应版本或目标人物不匹配。 旧结构必须明确失败: ```js assert.throws( () => normalizeLineageGraphWindow([{ id: 1, children: [] }]), /LINEAGE_GRAPH_SHAPE_UNSUPPORTED/ ); ``` - [ ] **步骤 2:运行并确认红灯** 运行两个 lineage 合同冒烟。预期因规范化与校验模块尚不存在而失败,且失败分别指向图窗口、概览或 locator,不得只报笼统 shape 错误。 - [ ] **步骤 3:实现无猜测规范化** 规范化只复制新合同字段、统一数组顺序和 `null`,不得出现以下兼容读法: ```text person.id || person.personId generationNo || generation children / spouses 递归展开 fatherId / motherId 推断 parentId 缺失 branch 时默认“主支” 缺失姓名时伪造“族人” ``` 规范化阶段统一复制字段并按 `order`、字符串 `id` 作确定性排序;因此规范化最坏复杂度明确为 `O(N log N + E)`,不冒充线性。相同无序输入经过规范化后必须得到字节级一致的规范图。 - [ ] **步骤 4:实现 O(N+E) 校验** 图窗口先建立人员、家庭、关系 ID、边和 boundary 的 Map,再检查引用;全部父子关系使用颜色 DFS 或 Kahn 拓扑检查有向循环,不允许只检查 primary 或让每个节点重新扫描全图。概览校验器单次遍历 buckets 完成计数、唯一性、根隐私和目标 ID 检查;locator 校验器按 discriminator 验证根、路径完整性、请求版本和目标人物。错误一次收集完整,页面可以显示诊断状态;生成投影前必须调用 `assertLineageGraph`,显示概览或应用定位前必须调用各自 assert。 - [ ] **步骤 5:验证新旧边界** 运行图窗口、概览/locator、OpenAPI、响应式合同和编译审计。预期新合法响应通过,旧递归、数字 ID、隐私 ID 泄漏、跨字段计数错误与版本错配均稳定失败。 ### 任务 13:生成可见图投影与聚合节点 **文件:** - 新建:`utils/lineage/project.js` - 新建:`tests/lineage-projection-runtime-smoke.js` **接口:** - `projectLineageGraph(graph, { focusPersonId }): LineageProjection` - 投影先通过唯一 primary 入边推导 `protectedPathPersonIds/protectedPathFamilyUnitIds`,它们是计算结果而不是第二份输入合同;不得写回规范图。 - 输出包含可见真实节点、家庭、父子边、边界和稳定虚拟聚合节点;布局与 Scene 只消费该投影,搜索和线性列表继续消费完整规范图。聚合节点固定包含 `direction、hiddenCount、focusable、targetPersonId`:有隐藏可见成员时 `focusable=true` 且 target 是稳定人物 ID,仅含 REDACTED 时 `focusable=false、targetPersonId=null`。 - [ ] **步骤 1:先写 200 子女失败测试** 构造祖父家庭 12 名子女、焦点父亲排第 9、焦点位于下一代的用例,先断言投影必须保留第 9 名父亲以及祖父→父亲→焦点的完整 primary 路径。再构造一个家庭 200 名有序子女,断言任何焦点下都最多保留 5 名真实子女,其余最多形成“前 N 人/后 N 人”两个聚合节点;前后计数之和加真实人数必须等于 200。补充混合可见/隐私区间与全隐私区间:前者 target 必须选择最近可见稳定 ID,后者必须 `focusable=false、targetPersonId=null`,任何 opaque ID 都不能成为跳转目标。聚合 ID 固定由家庭 ID、方向和被折叠 order 区间生成;20 组乱序原始输入必须先经过 `normalizeLineageGraphWindow`,再断言投影结果字节级一致。 - [ ] **步骤 2:实现不修改源图的 O(N+E) 投影** 投影只接受已经规范化且校验通过的图,按家庭一次分组并保持规范图既有顺序,不在投影内再次排序;沿 primary 入边从焦点回溯到窗口入口,得到唯一受保护路径。这样投影阶段保持 `O(N+E)`。路径上的每个家庭以通向焦点的子女为中心选最多 5 人,其他家庭稳定选前 5 人。前后聚合节点必须包含 `direction、hiddenCount、focusable、targetPersonId`;BACK/FRONT 在对应方向选择紧邻窗口的隐藏可见成员作为 target,区间仅含隐私成员时 target 固定为 `null` 且不可点击。点击可聚焦聚合节点后把稳定 `targetPersonId` 作为新焦点请求窗口,并把旧焦点写入最多 20 条的会话历史;不存在页内无限展开、折叠或 `expandedAggregateIds`,因此任意时刻每个家庭的真实子女上限始终为 5。 - [ ] **步骤 3:验证完整图与投影职责** 断言完整规范图仍含 200 名真实子女,线性列表可访问全部成员;投影只含当前可见真实节点和虚拟聚合节点;连续点击 BACK 聚合会按稳定目标推进焦点且每次仍最多 5 人,回到上一焦点恢复原投影;同一输入与焦点得到同一输出。运行投影、图、响应式合同与编译审计。 ### 任务 14:实现确定性家庭联合点布局 **文件:** - 新建:`utils/lineage/layout.js` - 新建:`tests/lineage-layout-runtime-smoke.js` - 修改:`tests/t01-relation-layout-contract.ps1` **接口:** - `layoutLineageProjection(projection, options): LineageLayout` - 输出:`nodeRects: Map`、`familyPoints: Map`、`generationBands`、`bounds`、`primaryForest`。 - 世界坐标常量唯一放在 `layout.js`:`NODE_WIDTH=168`、`NODE_HEIGHT=86`、`PARTNER_GAP=20`、`SIBLING_GAP=36`、`GENERATION_GAP=124`;相机负责适配视口,页面不得转写这些值。 - [ ] **步骤 1:先写三个已知缺陷和压力形状** 布局测试必须精确断言: ```js assert.equal(singleChildX("fu-103-1"), familyPointX("fu-103-1")); assert.deepEqual(partnerGroupMidpoint("fu-103-1"), familyPoint("fu-103-1")); ``` 并覆盖 6 人、10×12、20×20、5×100、100×3、300×1、单家庭 200 子女投影、缺 12—49 代、乱序输入、单链 500 代纯逻辑和极不平衡树。当前布局必须先失败 103→106 的家庭点错位和宽支系确定性断言;连接器端点与 4rpx 间隙放到下一任务的 Scene 几何合同验证。 - [ ] **步骤 2:实现线性预处理与主森林** 按 `generation` 一次分组;每个孩子只用唯一 `primary` 入边参加主布局,次要收养/继亲/监护关系只参加后续连线。`anchorPersonId` 决定多配偶家庭围绕哪个主世系人物展开,禁止根据数组第一项猜测。 - [ ] **步骤 3:实现自底向上的 tidy-tree** 自底向上计算每个主子树与家庭单元所需宽度,自顶向下分配世界坐标。每个家庭联合点位于实际 partner 节点组中点;单子女严格与联合点同轴,多子女跨度中心严格等于联合点。多家庭单元按 `order,id` 稳定排列,不能复制同一人物节点。 - [ ] **步骤 4:实现代际缺口与边界占位** 代际差大于 1 时生成明确 gap band,而不是把不相邻世代压在相邻行;MISSING/REDACTED/UNLOADED 端点都有实际 Rect 和可连接锚点。 - [ ] **步骤 5:验证复杂度与确定性** 乱序输入 20 次的 `nodeRects/familyPoints/bounds` 必须字节级一致;500 节点布局不得出现嵌套全图扫描。运行布局合同、图合同、响应式合同和编译审计。 ### 任务 15:实现 Scene、空间索引和相机 **文件:** - 新建:`utils/lineage/scene.js` - 新建:`utils/lineage/spatial-index.js` - 新建:`utils/lineage/camera.js` - 新建:`tests/lineage-scene-runtime-smoke.js` **接口:** - `buildLineageScene(projection, layout, { fontMetricsVersion }): Scene` - Scene 根对象精确为 `{ sceneVersion, treeVersion, focusPersonId, bounds, items }`,不接受缺字段或额外字段。 - `createSpatialIndex(scene, cellSize = 256)`,输出 `queryRect(rect)` 与 `hitTest(point)`。 - `createCamera(viewport, worldBounds)`、`panCamera`、`zoomCameraAt`、`fitCameraToIds`、`getLod(previousLod, scale)`。 - [ ] **步骤 1:先写十条几何不变量** 逐边检查路径首尾锚点、相邻折线端点完全相等、DPR 误差不超过 0.5 物理像素、单子女无孤立零长横梁、Scene edge 可反查传输 edge、主关系无穿节点、主关系交叉为零、视口外端点但路径穿视口仍被查询、四类边界有端点、Scene 原子替换不混版本。每个家庭成员连接器 ID 必须是 `family:{familyUnitId}:partner:{personId}`,每条父子边连接器 ID 必须是 `edge:{edgeId}`;`connectorStart(edge)` 必须等于家庭联合点,`connectorEnd(edge)` 必须等于实际子女顶部锚点。选中前后 Rect、布局与锚点完全不变。缺少 `sceneVersion` 必须失败;相同 `sceneVersion` 对应不同序列化 payload 也必须按版本碰撞失败,不能静默覆盖。 - [ ] **步骤 2:实现纯数据 Scene** `utils/lineage/scene.js` 是 `sceneVersion` 的唯一所有者,按 `treeVersion + projectionSignature + LINEAGE_LAYOUT_VERSION + fontMetricsVersion` 生成确定性摘要;任何其他层不得拼接或递增该版本。Scene 只含世界坐标、稳定 ID、绘制类型、样式令牌和可访问文本,并可由 `JSON.stringify/parse` 无损往返;不得包含 `Map`、函数、空间索引、Vue、DOM、rpx、Canvas context 或平台对象。瞬时 `selectedId` 不属于 Scene payload,也不参与 `sceneVersion`;节点、文字、家庭联合点、连接器、聚合节点和代际标尺属于 Scene,选中光晕由 renderjs 使用独立 `selectedId` 与 Scene 人物 Rect 动态绘制,不改变 Scene payload 或 `sceneVersion`。 - [ ] **步骤 3:实现连接器与空间索引** Scene 只消费任务 13 已生成的真实节点与聚合节点,不重新截断或重排子女。每个 partner 到联合点各有一条可追踪连接器;单亲联合点与锚点重合时可省略孤立零长度笔画,但保留关系反查。每条 ParentChildEdge 只有一条联合点到子女的连接器。统一网格索引按路径包围盒登记连接器,确保穿越视口的长线不会因端点在外而消失。 - [ ] **步骤 4:实现 LOD 与相机** 缩放范围 `0.30—2.00`;LOD 为 `0.30—0.44 / 0.45—0.74 / 0.75—1.34 / 1.35—2.00`,使用 `0.05` 迟滞。双指以手势中心缩放,搜索定位至少进入 L2;低于 12sp 的次要文字直接隐藏,不压缩到不可读。 - [ ] **步骤 5:验证纯逻辑** 运行 Scene 冒烟 20 次,断言命中、裁剪、LOD、相机锚点、聚合跳转目标和版本替换稳定,再运行固定验证。 ### 任务 16:实现视口大小的单 Canvas 组件 **文件:** - 新建:`components/LineageViewport.vue` - 新建:`tests/t01-large-lineage-contract.ps1` - 修改:`tests/t01-all-states-visual-contract.ps1` **接口:** ```js props: { scene: Object, initialCamera: Object, selectedId: String, bottomInset: Number, } emits: ["select", "focus", "camera-change", "request-boundary", "performance"] ``` `scene` 必须携带任务 15 生成的 `scene.sceneVersion`;组件没有第二个可独立传值的版本 prop,避免 payload 与版本漂移。 - [ ] **步骤 1:先写组件边界合同** 要求组件使用普通 Options API、包含 App-vue/H5 `renderjs`、Canvas 物理尺寸等于视口乘 DPR、同一绘制入口处理节点与边,并禁止 DOM 人物节点、DOM 拼线、全世界尺寸 Canvas、选中态改变节点宽高和逐帧跨层传输完整 Scene。合同还必须证明只改变 `selectedId` 时 Scene payload 与 `scene.sceneVersion` 不变,renderjs 仅在同一 Canvas 重绘光晕。 - [ ] **步骤 2:实现 App-vue/H5 renderjs** renderjs 持有 Scene、空间索引、相机、手势、LOD 和绘制循环;业务层只在完整 `scene.sceneVersion` 变化时传入可 JSON 序列化的新 Scene。视图层先校验版本与 payload,再重建索引,最后一次性交换 Scene、索引和版本;相同版本但不同 payload 必须拒绝并保留旧 Scene。平移、缩放及惯性期间零跨层通信;只在手势结束、焦点改变或确需边界请求时通过 `$ownerInstance.callMethod` 回业务层,不做连续节流快照。 - [ ] **步骤 3:实现绘制与原子替换** 视口、底部 inset 或 DPR 改变时,先 `setTransform(1,0,0,1,0,0)` 重设状态,再把 backing store 设为逻辑视口乘 DPR、CSS 尺寸保持逻辑视口。每帧顺序固定为清屏、设置 DPR、应用相机矩阵、查询可见对象、先画连接器、再画联合点和节点、最后画文字与选中外光晕。字体就绪或回退字体变化后重新测量,生成新的 `fontMetricsVersion` 并请求新 Scene;新 Scene 完整校验并建立新索引后一次替换旧引用,失败继续显示旧 Scene 并上报,不得半图混画。 - [ ] **步骤 4:实现 mp-weixin 适配和降级** mp-weixin 消费同一 Scene 和相机纯函数,通过平台 Canvas API 绘制;如果窗口超过平台实测能力或连续不达性能门槛,组件发出降级事件,由页面切换线性列表。不得为小程序另写关系规则。 - [ ] **步骤 5:验证组件** 运行大树组件合同、Scene 冒烟、响应式合同和编译审计;浏览器仅检查运行逻辑和 Canvas 尺寸,不作视觉通过结论。 ### 任务 17:重建 T01 页面产品结构与无障碍列表 **文件:** - 新建:`components/LineageAccessibleList.vue` - 修改:`pages/tree/t01-tree-overview.vue` - 修改:`tests/t01-tree-state-contract.ps1` - 修改:`tests/t01-tree-state-runtime-smoke.js` - 修改:`tests/t01-sheet-state-document-flow-contract.ps1` **页面状态:** - `loading`:保留页面骨架和明确加载反馈。 - `ready-graph`:Canvas 图模式。 - `ready-list`:同一规范图的线性列表模式。 - `empty`:仅由合法 `state=EMPTY` 进入,主操作进入 T04。 - `error`:合同、权限、网络、版本冲突分别说明;不得混成空态。 - 资料抽屉仅 `collapsed/half` 两档;完整资料进入 T03。 - [ ] **步骤 1:先把旧全量 DOM/Grid 断言改成新结构** 合同要求 `LineageViewport`、`LineageAccessibleList`、搜索、全谱概览、关系图例、两档抽屉、44dp 控件和四类边界;禁止 `tree-grid`、全量 CSS grid track、多 `` 拼线和页面内运行时 `members`。 - [ ] **步骤 2:实现初始焦点和窗口请求** 客户端提供焦点的顺序固定为路由 `selectedId`、当前账号绑定成员 ID;两者都没有时省略 `focusPersonId`,由后端按主始祖确定并在响应中返回实际焦点。初始 `mode=FOCUS`,`ancestorDepth=2`、`descendantDepth=2`。合法 `state=EMPTY` 直接显示空谱并允许进入 T04;只有 `state=POPULATED` 仍缺少或引用不到有效 `focusPersonId` 时才显示合同错误,客户端不从 nodes 数组猜成员。 - [ ] **步骤 3:实现搜索、聚合跳转和焦点历史** 单击 VISIBLE 人物节点只选择并打开半屏抽屉;双击、居中操作、搜索结果或可聚焦的“前 N 人/后 N 人”聚合节点才改变焦点并请求新窗口。REDACTED 节点只显示通用隐私说明,不允许双击、居中、进入详情或成为焦点;不可聚焦聚合节点也不发请求。`focusPersonId` 与聚合 `targetPersonId` 都只能使用稳定 VISIBLE 人物 ID,不在当前 Scene 追加更多真实子女。会话内保存最多 20 条 `{ focusPersonId, treeVersion, matrix }`,只用于“回到上一焦点”,不写本地存储。 - [ ] **步骤 4:实现独立全谱概览** 概览以当前窗口 `treeVersion` 请求 `/lineage/tree/overview` 的“世代×支系”聚合,不把全家谱所有人物缩成微型图。只有 bucket 提供 VISIBLE 稳定 `focusPersonId` 时才允许点击并请求局部窗口;隐私桶的目标为 `null`。收到 `409 TREE_VERSION_CHANGED` 时保留当前焦点,先刷新窗口,再用新版本重取概览,绝不混合两个快照。 - [ ] **步骤 5:实现抽屉、列表和返回优先级** 抽屉吸附完成后更新 `bottomInset` 并把选中节点移入剩余安全视口。Android 返回依次关闭搜索/概览/抽屉、回退焦点历史、最后 `goBack`。图和列表共享选中人物、焦点、树版本、已加载边界、焦点历史与搜索结果,不维护页内聚合展开状态。 - [ ] **步骤 6:验证页面状态** 运行三个 T01 聚焦合同、两个纯逻辑冒烟、响应式合同和编译审计;检查普通页面内容流、弹层 max-height、内部滚动和触控面积未回归。 ### 任务 18:接入新接口、mock 和跨页变更版本 **文件:** - 修改:`utils/api.js` - 修改:`data/mock.js` - 修改:`pages/tree/t01-tree-overview.vue` - 修改:`pages/tree/t03-member-profile.vue` - 修改:`pages/tree/t04-add-relative.vue` - 修改:`pages/tree/t05-edit-member.vue` - 修改:`pages/tree/t06-edit-relationship.vue` - 修改:`pages/tree/t07-member-directory.vue` - 修改:`utils/navigation-routes.js` - 修改:`tests/navigation-flow-contract.ps1` - 修改:`tests/t03-t08-member-flow-contract.ps1` - 修改:`tests/lineage-graph-contract-runtime-smoke.js` - 新建:`tests/t01-navigation-integration-contract.ps1` **接口:** ```js appApi.getLineageWindow(genealogyId, query) appApi.getLineageOverview(genealogyId, treeVersion) appApi.locateLineagePerson(genealogyId, personId, treeVersion) appApi.searchLineagePeople(genealogyId, keyword, cursor, limit) appApi.createRelative(genealogyId, personId, payload, treeVersion) appApi.updateLineagePerson(genealogyId, memberId, payload, treeVersion) appApi.updateRelationship(genealogyId, relationshipId, payload, treeVersion) ``` - [ ] **步骤 1:先改 API 合同并确认旧适配失败** 测试禁止 `toTreeNode`、`.map(toTreeNode)`、`treeMembers`、`id || personId`、客户端 `x/y` 和数字 ID;要求七个新方法、窗口与 overview 各自的查询参数白名单、overview 必填 `treeVersion`、所有三个写方法的 `If-Match` 和规范化/校验调用。REDACTED opaque ID 进入 FOCUS、locator、搜索、写接口或聚合 target 必须在客户端边界失败。成员更新还必须以权限能力和当前 `treeVersion` 为前置条件,旧的无版本人物更新接口不能作为兼容回退。 - [ ] **步骤 2:原子替换旧 API 与 mock** 删除 `getTree` 的递归数组返回和旧 `treeMembers`,mock 直接提供合法 `LineageGraphWindow`、overview、locator、人员详情和版本化 mutation result。所有现有消费者同轮迁移,不能保留双读。 - [ ] **步骤 3:实现边界增量与版本冲突** 展开 UNLOADED 只携带当前 boundary cursor 和 treeVersion;新窗口校验成功后原子合并或替换。收到 `409 TREE_VERSION_CHANGED` 时保留当前焦点,提示数据已更新并用 locator 重新定位,不能把新节点配旧边。 - [ ] **步骤 4:实现 T04/T05/T06 完成回流** 写成功结果带新 `treeVersion` 进入领域响应,但导航一次性结果仍只传 `operation/entityId/refresh`;T01 收到新增亲属或关系更新后通过 locator 或新窗口响应定位 `entityId`。取消、写失败、权限失败和版本冲突都不得生成成功导航结果。 T05 的真实更新必须在本步骤一次性拆分宿主页身份 A 与编辑实体 B,不提前留下兼容字段:T03 从自身初始查询保存不可变 `routePersonId=A`,页内活动成员为 `activePersonId=B`;T05 路由必填参数精确改为 `genealogyId/personId/memberId`,其中 `personId=A` 只表达宿主 T03 身份,`memberId=B` 只表达被编辑成员。T03 打开 T05 时传入两者,T05 源码立即别名为 `hostPersonId` 与 `editedMemberId`;API 只能以 `editedMemberId` 写入,完成目标只能以 `hostPersonId` 匹配 T03。删除旧的“T05 `personId` 同时代表编辑对象”合同,禁止 `memberId || personId` 双读,旧 URL 因缺少 `memberId` 必须失败。 真实写成功后,T05 以目标 `{ genealogyId, personId: hostPersonId }` 和一次性成员更新结果返回;结果的 `entityId` 必须是 `editedMemberId`。同轮给 T03 增加对应结果操作:仅当 `entityId` 等于当前活动成员时重新读取该成员,不调用 `openRelative`,不改变 URL、轨迹或索引;实体不匹配时失败关闭。测试必须覆盖 A/B 正常回流只刷新 B、来源宿主 C 到目标 A 被拒绝且零导航/零结果、目标误传 B 被拒绝、API 实参只能是 B、写失败或 409 零结果,以及深链 T05 要求 A 与 B 相同后才能构造 T03(A)。当前 `personId=A/memberId=B` 方案复用导航网关已有同名上下文门禁;`parentParamMap` 仍只负责无历史时构造父页,不能扩张成完成结果身份映射。 `tests/t01-navigation-integration-contract.ps1` 验证 T01 与导航网关之间的焦点回流、取消和版本冲突;`tests/t03-t08-member-flow-contract.ps1` 验证宿主页/编辑实体双身份与 T03 刷新不改轨迹。真实写结果会改变路由允许操作和页面消费者,因此必须在同一合同迁移中同步收紧 `tests/navigation-flow-contract.ps1` 与路由注册表,删除任务 6 的“本地预览无结果”旧断言;不能让运行时接受新合同而校验器仍只允许旧合同。 - [ ] **步骤 5:验证接口与回流** 运行 OpenAPI、图、布局、Scene、导航与 T01 聚焦合同,再运行响应式合同和编译审计。新有效路径通过,旧递归输入和旧字段明确失败。 ### 任务 19:完成 T01 MuMu、性能与三人终审 **文件:** - 修改:`docs/家谱项目全量治理设计.md` - 修改:`docs/家谱项目全量治理实施计划.md` - 修改:`docs/项目当前总览.md` - 修改:`docs/接口与页面映射总表.md` - [ ] **步骤 1:运行全部自动化验证** 运行全部 `tests/*.ps1`、全部 lineage Node 冒烟、导航 Node 冒烟和密码策略冒烟。预期全部退出码为 `0`,Vue 覆盖仍为 `64/64`,`100% 100% no-repeat` 与 Vue 直接 `border-image-slice` 扫描仍为 `0`。 - [ ] **步骤 2:执行 T01-MUMU-01 至 T01-MUMU-10** 1. 默认焦点上二代/下二代和 103→106 连线; 2. 单指平移、双指缩放、按钮缩放和四级 LOD; 3. 搜索或概览定位第 10、50、100、300 代;500 代单链只执行纯逻辑测试; 4. 200 子女聚合、前后人数、聚合跳转、返回原焦点和祖先主路径连续性; 5. 多配偶家庭联合点及对应子女归组; 6. MISSING、REDACTED、UNLOADED、FAILED 四类端点; 7. collapsed/half 抽屉、图/列表切换和全谱概览; 8. T03/T04/T06/T07 的进入、返回、取消、完成和重复进入; 9. 空谱、无权限、网络失败、版本冲突和异常图; 10. 60 秒连续操作、20 次聚合跳转与搜索、自动几何探针和同场景逐边视觉探针。 所有视觉证据只来自当前 MuMu;忽略系统水印,不调整设备。 - [ ] **步骤 3:验证性能门槛** - 单窗口不超过 500 人; - `dataReady`(规范图校验完成)到 `interactive`(首帧绘制完成且命中索引可用)不超过 800ms; - 输入到反馈 p95 不超过 100ms,从视图层触摸时间戳量到下一完成帧; - 持续手势帧耗时 p95 不超过 32ms,由 renderjs 的 `requestAnimationFrame` 记录; - 不连续出现两帧超过 100ms; - 内存先预热 3 轮,再在相同空闲点比较 20 次聚合跳转与返回后的驻留值,相对稳定基线增长不超过 15%。 每项 MuMu 时延至少采样 30 次,并使用 nearest-rank 计算 p95;T01 不单独承担全 App 冷启动门槛。自动几何探针与同一场景逐边视觉探针都必须为零违规。 不达标时优先缩小窗口、减少可见对象或降级到线性列表;不得提高门槛、压缩字号或改成全量巨大 Canvas。 - [ ] **步骤 4:三人独立终审** 三人分别从真实数据、几何连续性、交互、视觉完整性、接口严谨性、无障碍、性能和维护成本反向质询。只有十条几何不变量、全部性能门槛、MuMu 十组矩阵和自动化合同同时通过,才把 T01 标记完成。 --- ## 第三阶段:认证、TAC 与短信闭环 ### 任务 20:建立认证 OpenAPI 硬门禁 - [x] **步骤 1:先写失败合同并汇总后端问题单** `tests/auth-tac-openapi-contract.ps1` 已对受保护 JSON/YAML 同时检查三个公开认证场景、统一 `SmsCodeSecret`、`validToken`、密码登录票据,以及验证请求的必填字段、关闭额外字段和 provider discriminator。当前精确输出为 `AUTH-TAC-OPENAPI-CONTRACT BLOCKED`:密码登录无票据;`VerificationCheckBody` 缺 provider 分支闭包;TianAi 与系统图片 payload 均未关闭额外字段;生产短信码尚未原子升级为 6 位,公共发码仍错误包含 `APP_PHONE_CHANGE`。真实 `APP_REGISTER` challenge 还返回 HTTP 500 空响应。对应问题固定为 `API-AUTH-TAC-001`—`004` 与 `API-PHONE-001/002`,不得通过放宽测试或修改受保护导出变绿。 ### 任务 21:接入受保护 TAC 资产与严格适配器 - [x] **步骤 1:建立唯一场景与票据 owner** `utils/auth-verification.js` 精确持有 `APP_SMS_LOGIN/APP_REGISTER/APP_FORGOT_PASSWORD`、4 位短信码、require 和 verify 响应边界;`static/tac/js/jiapu-tac-adapter.js` 是 TianAi challenge/proof/verify 的唯一映射层。未知 provider/type、`required=false` 却无票据、空 token、scene 或 subject 漂移全部失败关闭。 - [x] **步骤 2:建立真实 renderjs 浮层** `components/TacVerification.vue` 加载后端提供且哈希锁定的 CSS/JS/图片,不修改四个供应商文件。浮层以严格 2xx JSON XHR 请求 challenge/verify,覆盖空体、非对象、HTTP 错误、超时、主动 abort、重复成功/取消和迟到 generation 回调;只有服务端 `passed=true + validToken` 才向页面完成。 - [x] **步骤 3:建立壳层无障碍预检** 浮层已补齐 dialog 命名与说明关联、初始聚焦、Tab 圈定、Escape/Android 返回、焦点恢复、原生刷新/关闭、48px 目标和小视口内部滚动。`tests/tac-shell-accessibility-contract.ps1` 与 `tests/auth-accessibility-static-contract.ps1` 只证明静态壳层,不得冒充 TianAi 拖动本身支持 TalkBack。 ### 任务 22:接通 A01/A04/A05 的认证客户端 - [x] **步骤 1:短信登录、注册和找回密码测试先行接线** A01 默认短信登录并以 `APP_SMS_LOGIN` 发码;A04/A05 分别使用 `APP_REGISTER/APP_FORGOT_PASSWORD`。三页当前均按“本地校验→require→TAC challenge/verify→携 `validToken` 发短信→4 位码提交”执行,手机号改变作废旧验证上下文,注册和重设提交不重复 TAC;任务 34 已把生产目标收紧为统一 6 位 `SmsCodeSecret`,后端、生成器、模板与全部消费者必须同版原子替换,当前代码不提前双读。A01 密码登录因 `API-AUTH-TAC-001` 保持可见但不可用;A04 成功保存 `AppLoginVo.access_token` 后直接 `goRoot("G01")`,不能先回 A01 再让用户重复登录;A05 成功使用同一 `AppDialog` 返回 A01。 - [x] **步骤 2:收紧认证传输生命周期** `utils/api.js` 的 `createRequestController/isRequestCancelled` 是认证与已审查业务请求共用的唯一生命周期入口;严格请求只接受 HTTP 200 和带整数 `code` 的 envelope,超时统一为 15 秒。登录、注册和 challenge 等有实体响应要求 `data`;短信发送与找回密码的 `RVoid` 没有声明 `data` 必填,`requestAuthVoid` 把省略 `data` 与 `data:null` 都归一为 `null`。返回或卸载先拒绝等待 Promise 再 abort RequestTask,迟到 native fail 不得覆盖 `REQUEST_CANCELLED`;旧认证专用公开入口已原子删除,按钮在进行中锁定并显示明确文案,不存在伪成功或本地验证码。 - [x] **步骤 3:完成客户端自动化验证** 认证/TAC 聚焦合同、renderjs/adapter/API/场景纯 Node 冒烟、响应式、文档流和编译审计已通过;后续任务 24—35 纳入后,最新项目总证据为 PowerShell `126/140`、Node 语法 `47/47`、纯 Node `19/19`、活动 Vue 脚本模块 `64/64`。十四项红灯全部是显式发布门禁,不是可删除测试:T01、认证 OpenAPI、家谱工作区 OpenAPI、G03 原子创建 OpenAPI、G03 客户端激活、M06 帮助 OpenAPI、个人资料读取 OpenAPI、个人资料写入 OpenAPI、通知读取 OpenAPI、通知已读写入 OpenAPI、M10 服务端退出 OpenAPI、M04 密码凭证 OpenAPI、M05 手机号换绑 OpenAPI 与 Android 认证无障碍证据。 ### 任务 23:关闭认证外部门禁并完成原生发布验收 - [ ] **步骤 1:后端关闭 `API-AUTH-TAC-001`—`004`** 验证码中心成为 provider-neutral 唯一 owner:session 与 challenge 绑定 tenant/client/scene/canonical subject;provider evidence 只由服务端核验;票据绑定 method/assurance/audience。短信端在同一事务中完成 `ISSUED → CONSUMED` 和唯一 outbox,相同幂等键返回原结果,并发或重放最多产生一个发送任务。修复线上空 500,发布同版本 JSON/YAML 后重新运行 OpenAPI 门禁。 - [ ] **步骤 2:实现可访问且不降风控的替代路径** P0 是同一验证中心和可恢复的文字/中继 `MANUAL_REVIEW`,不是免验证。各 scene 定义独立身份或号码控制证据;案件不可改写原 subject/scene,具备去重、RBAC、限额、审计、服务时段、容量和 SLA,高风险找回/换号双人复核。坐席只提交决定;用户重新进入原流程时,由验证中心激活同一短时单次票据。中国大陆非交互风控供应商限时 POC 达标后才可成为默认自动路径;音频另做可懂度、听障覆盖和 ASR POC,不能单独上线。设备断言只有在同一 subject 的已认证会话绑定私钥、服务端 nonce、RP/App 绑定、`userVerification=required`、检查撤销且防重放时才可独立放行;设备指纹和完整性只能加权。 - [ ] **步骤 3:切换远端并完成 H5/Android 验收** 后端门禁全绿后才把 `runtimeConfig.mode` 从 `mock` 切为 `remote`。验证正式 H5 域名 CORS、真实正常/过期/重放/限流/供应商故障、前后台倒计时恢复和并发幂等;在用户现有 MuMu 中由三人共同完成 A01/A04/A05 默认、错误、取消、重复进入、Android 返回、TalkBack、外接键盘与非拖动替代路径。证据写入 `tests/manual/tac-android-accessibility-evidence.json` 后运行发布门禁;浏览器截图不得替代。 ## 第四阶段:领域上下文与独立真实接口 ### 任务 24:建立会话与当前家谱上下文基础 - [x] **步骤 1:测试先行收紧词法身份与账号隔离** `utils/genealogy-context.js` 只接受无边界空白的非空字符串 ID,拒绝可能已经失真的 JavaScript number、重复列表、对象和控制字符;损坏存储立即清理。`utils/session.js` 在损坏令牌、退出和令牌切换时同步清理家谱上下文,同一令牌保存不误清。 - [x] **步骤 2:撤权与显式无权目标失败关闭** 首次没有历史选择和失效标记时才允许建立首个可用上下文。历史选择从调用方给定的新列表消失或显式请求的家谱不可用时,`reconcileCurrentGenealogyId` 清空 ID 并持久写入 tombstone,后续重载继续失败关闭;只有用户显式选择、退出或账号切换才清除标记。G01 显示“当前家谱已不可用”并要求用户明确选择,页面不得用 `currentGenealogy || list[0]` 掩盖失效。真实权限变化的 onShow/事件刷新仍属于后续 workspace,不把纯函数闭环夸大为服务端撤权已实时生效。 ### 任务 25:接通 M07 真实反馈客户端 - [x] **步骤 1:锁定 OpenAPI 与 wire payload** `tests/feedback-openapi-contract.ps1` 同时保护 JSON/YAML 中的 `POST /genealogy/app/feedback`、SaToken 与唯一 `FeedbackBody`。`appApi.submitFeedback` 只接受三个自有普通数据字段;`feedbackContent` trim 后非空,`feedbackType/contactInfo` 为无枚举可选字符串,空值不发送。调用方请求选项只允许 `requestController`,不能关闭认证头。remote 精确要求 HTTP 200 与整数成功 `code`;响应 schema 没有声明 `data` 必填且页面不消费返回实体,因此反馈请求不复制认证接口的 data 必需规则。mock 抛 `WRITE_UNAVAILABLE` 且不发请求。 - [x] **步骤 2:完成防重复、异常与可访问状态机** M07 以提交前规范快照锁定请求,提交中禁用类型、内容、联系方式和按钮;成功后保留已提交内容并把该快照提升为 baseline,原样禁止重提,编辑后恢复 ready,改回快照恢复成功态。成功与 uncertain 回流都再次比较当前快照,迟到输入不冒充已提交。超时、断网、意外 2xx/3xx、HTTP 408/5xx 和响应无效进入 `uncertain`,锁定同一快照且不提供原样重试;只有确定拒绝进入可重试 error。反馈类型明确选填,原生按钮有 group 名称、`aria-pressed`、44dp 目标和完整 reset,必填失败聚焦内容框;页面状态机已由纯 Node 直接执行验证,不再只有静态 token 断言。unknown 锁不跨重启持久化,避免保存反馈隐私或形成无期限锁;真正跨重启防重交给未来后端幂等合同。 - [ ] **步骤 3:真实服务与 MuMu 验收** 该步骤依赖任务 23 关闭认证远端门禁。切换 remote 后验证真实登录态、成功 envelope、401、业务拒绝、超时未知结果和卸载取消;在 MuMu 检查键盘、TalkBack、长文本、错误回执和系统返回。浏览器或静态合同不得替代。 ### 任务 26:建立家谱工作区远端硬门禁 - [x] **步骤 1:三人只读核对 G01/G05、线上 OpenAPI 与部署行为** 唯一读取 owner 固定为 `/genealogy/app/genealogies/mine` 和 `/genealogy/app/genealogies/{genealogyId}/overview`;首批不同时请求语义重复的 `/{genealogyId}`。线上 `RListAppGenealogyVo/RAppGenealogyVo/AppGenealogyVo` 虽已类型化,但均无 `required`;响应 `genealogyId` 为 `integer/int64`,`roleType/status/memberStatus` 无枚举,且没有 `canView`。无令牌只读实测三条接口均为 HTTP 200、JSON `{code:401,msg,data:null}`,与文档声明的 401 string 不一致。缺少 security 注解和媒体声明 `*/*` 作为发布文档质量问题记录,不把它们单独虚报为已发生泄漏。 - [x] **步骤 2:先写失败合同并固定最小闭包** `tests/genealogy-workspace-openapi-contract.ps1` 同时读取受保护 JSON/YAML,要求 `/mine → RListAppGenealogyVo`、`/overview → RAppGenealogyVo`,两层 envelope 的 `code/data` 必填,`data` 精确指向 `AppGenealogyVo`。首批消费字段固定为必填 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`:ID 和名称为非空字符串,三项 capability 为 boolean,角色是至少两个稳定非空值的 enum。测试当前输出 `GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED`;不得删除测试或手改双导出变绿。 - [ ] **步骤 3:后端关闭 `API-GENEALOGY-WORKSPACE-001`—`003`** `001` 关闭 JSON int64 身份失真并补 typed required 闭包;`002` 让 `/mine` 以 required `canView` 或可自动证明的等价 active-only 投影成为当前账号可访问集合真相,并提供稳定角色/能力;`003` 让认证失效、对象无权/撤权、删除和服务异常在文档与部署中可稳定区分。错误可以使用规范 HTTP 状态,也可以继续 HTTP 200+业务码,但 JSON/YAML、运行时 validator 与有效 token 集成反例必须一致。后端必须从同一版本重新导出两份受保护源文件;本地不手工补 schema。 - [ ] **步骤 4:门禁通过后测试先行接入 G01/G05** 新 adapter 只 pick 已声明字段并允许未知额外字段;任何 unsafe number、空 ID/名称、重复 ID、错误 data 形状、未知角色或 capability 缺失都失败关闭。G01 在 `onShow` 或统一失效事件中取消旧请求、严格读取 `/mine` 后才 reconcile;网络/5xx 保留当前现场,明确撤权才写 tombstone。G05 加载 `/overview` 前清空上一家谱数据并取消迟到响应,远端失败不回退 fixture。地点、堂号、人数和简介可选且诚实降级;若产品坚持展示线上未提供的来源、管理者、认证、始祖、支系、更新时间和激活人数,则另立合同,不从 mock 猜值。 - [ ] **步骤 5:真实账号与 MuMu 验收** 用至少两个有效账号验证本人列表、跨账号 ID、撤权、删除、401、403/404 或等价业务码、5xx、超时、畸形 JSON、快速切谱与离页取消;再在用户现有 MuMu 中验证加载/空/失败/失效/重选、G01 列表与切换弹层、G05 权限入口和 Android 返回。浏览器和静态合同均不能替代。 ### 任务 27:建立 M06 帮助内容远端硬门禁 - [x] **步骤 1:三人只读核对 M06、线上 OpenAPI 与部署行为** 线上列表 `GET /genealogy/app/help-articles` 返回 `RListHelpArticleVo`,每项 `HelpArticleVo` 已同时包含分类、标题与完整正文;详情端点因此不是当前单页手风琴闭环的必要依赖。受保护双导出仍返回通用 `ListResult/RList`,线上 wrapper 与 VO 又全部没有 `required`,正文没有纯文本/富文本语义,也没有只返回已发布内容和响应顺序的保证。匿名实测列表、带分类列表和详情均返回 HTTP 200+业务 `code=401`,与线上文档的 HTTP 401 string 不一致。源码还确认 M06 只有本地问题、筛选空态和 ``,没有加载、错误、取消、重试或完整无障碍语义。 - [x] **步骤 2:反向质询并选定 list-only 最小闭环** 一位评审最初要求字符串 `helpId` 加列表/详情二次读取,另一方案指出列表已带正文,M06 没有深链、写操作或跨刷新实体身份。三人最终一致选择列表唯一 owner:adapter 只投影 `helpCategory/helpTitle/helpContent`,不读取或暴露 `helpId`,因此当前 JSON int64 只作为未来详情债务。分类从列表标签按首次出现顺序派生,“全部”由客户端拥有;每次响应先分配 generation+ordinal 展示键,再做搜索/筛选,变更筛选或刷新前清空展开项,测试即可关闭 index 错绑反例。 - [x] **步骤 3:先写失败合同并固定最小 schema** `tests/help-center-openapi-contract.ps1` 同时读取受保护 JSON/YAML,要求列表 200 精确引用 `RListHelpArticleVo`、SaToken、required `code/data` 和 `HelpArticleVo[]`;每项 required `helpCategory/helpTitle/helpContent`,三项为非空字符串,正文声明 plain-text。当前输出 `HELP-CENTER-OPENAPI-CONTRACT BLOCKED`;不得手改双导出或用 live-only 字段写 adapter。 - [ ] **步骤 4:后端关闭 `API-M06-001`—`003`** `001` 提供专用列表响应与 required 最小闭包;`002` 明确分类为可直接展示标签、正文首版为纯文本、用户列表只含已发布文章且数组顺序就是展示顺序;`003` 统一 SaToken、JSON 媒体、HTTP 401 或业务 401 的文档与部署行为。后端从同一 Apifox 版本重新导出 JSON/YAML 并通过门禁;详情 `helpId` 改为字符串仅在未来消费详情时要求,不阻塞当前 M06。 - [ ] **步骤 5:门禁通过后测试先行接入页面** 先写 adapter 和页面状态失败测试:allowlist 投影必须丢弃即使已失真的 `helpId`;过滤不重编号,搜索/分类/刷新先清展开,旧 generation 迟到不替换。随后实现唯一可取消列表请求、加载、服务端空、搜索无结果、失败重试、认证失效、动态分类和纯文本展开;失败不得偷偷回退本地 FAQ。分类与问题使用原生按钮,补齐 `aria-pressed/aria-expanded/aria-controls`、状态播报和 44dp 目标,不接详情、分页、封面、浏览量或富文本。 - [ ] **步骤 6:真实账号与 MuMu 验收** 任务 23 关闭认证门禁后,验证有效/失效令牌、只发布与顺序、空列表、畸形行、5xx、超时、重试、快速刷新和卸载取消;再在 MuMu 检查搜索、分类、长正文、系统字号、TalkBack、焦点、Android 返回和 M06→M07。浏览器与静态合同不能替代。 ### 任务 28:建立个人资料读取远端硬门禁 - [x] **步骤 1:三人只读核对 M01/M02/M03、线上 OpenAPI 与部署行为** 唯一读取路径为 `GET /genealogy/app/auth/profile`。受保护双导出仍返回通用 `ObjectResult/RObject`;线上已改为 `RAppProfileVo → AppProfileVo`,但 envelope 与实体没有 required,手机号无 pattern,姓名/邮箱没有长度和格式边界,operation 也缺有效 security/clientid。匿名实测返回 HTTP 200+业务 `code=401`,与文档 HTTP 401 string 冲突。页面仍直接消费 `{name,phone,role}` fixture:M01 把资料错误连带整页入口隐藏并用 query 制造假错误,M02 把昵称冒充真实姓名,M03 在 setup 时固化手机号。 - [x] **步骤 2:反向质询并固定最小隐私投影** 三人先比较“四项 wire 字段全 required”和“仅 phone required”。最终选择后者:合法旧账号可能没有昵称、实名或邮箱,三项未设置时唯一省略;出现则必须是非空规范值。未来 PUT 只提交 dirty fields 和省略保持原值必须由写批次另证,不能用未知写语义反向扩大 GET 门禁。首批完全丢弃 `userId/avatar/status` 等未消费字段,因此 int64 与状态字典不阻塞;M01 家谱级“创建者”删除。原始手机号只在 adapter 局部校验并立即变成掩码与读屏尾号,不能进入页面、缓存、错误、日志或路由。 - [x] **步骤 3:先写失败合同并固定最小 schema** `tests/profile-openapi-contract.ps1` 同时读取受保护 JSON/YAML,要求 GET 200 精确引用 `RAppProfileVo`、SaToken、required `code/data` 与 `AppProfileVo`;实体只 required canonical 11 位 `phone`,可选 `nickName/realName/email` 出现时分别满足 1—30、1—30、email 且 1—100。当前输出 `PROFILE-OPENAPI-CONTRACT BLOCKED`,不手改双导出或写 live-only adapter。 - [ ] **步骤 4:后端关闭 `API-PROFILE-READ-001`—`003`** `001` 提供专用响应与最小 required/type/length/format;`002` 把“属性省略”固定为三项可选资料唯一未设置形态,并确认返回 canonical 原始手机号供客户端立即掩码;`003` 统一 required clientid、SaToken/security、JSON 媒体、HTTP 401 或业务 401 的文档与部署行为。后端从同一 Apifox 版本重导 JSON/YAML,门禁通过后才开始代码批次。 - [ ] **步骤 5:门禁通过后测试先行接入 M01/M02/M03** 先写 normalizer 测试,证明 optional 省略变内部空串、出现 null/空白/超长/非法邮箱失败,超大 `userId/avatar` 被丢弃,明文手机号不在输出或异常中;mock/remote 走同一模型,错误 runtime config 失败关闭。再实现严格、可取消 GET:M01 只替换身份卡且移除假 query 状态和 fixture 角色;M02 填表后才设 baseline,保存仍为本地校验;M03 只局部加载手机号。通知数量不得与真实资料混装,M05 的当前号码在 remote 发布前也迁移同一掩码 owner 或隐藏。原子更新禁止 API 的旧测试断言,并补局部错误、重试、取消、迟到响应和原生按钮无障碍。 - [ ] **步骤 6:真实账号与 MuMu 验收** 任务 23 关闭认证门禁后,用有/无可选资料账号验证加载、掩码、认证失效、畸形数据、5xx、超时、重试、账号切换和取消;MuMu 检查长昵称、系统字号、TalkBack 尾号播报、焦点、M02 键盘/错误、Android 返回和 M03→M04/M05。静态审查不能替代。 ### 任务 29:建立通知读取远端硬门禁 - [x] **步骤 1:三人只读核对 N01/N02/M01/G01、线上 OpenAPI 与部署行为** 线上提供 `GET /genealogy/app/notifications` 与 `GET /genealogy/app/notifications/unread-count`;受保护双导出只有通用通知列表且缺 unread-count,线上 `RListNotificationVo/NotificationVo/RLong` 又无 required、枚举、长度、完整正文、顺序或容量保证。四个页面当前分别克隆 fixture:N01/N02 的本地已读不能同步,M01/G01 的非响应式未读数也可能长期不一致。匿名 list/count 实测都是 HTTP 200+业务 `code=401`,与线上文档声明的 HTTP 401 string 不一致;operation 只有文字鉴权,没有有效 security/clientid。 - [x] **步骤 2:反向质询并固定无 ID 的完整活动集合** “只取最新 200 条”和“无分页完整列表”不能并存,否则角标可能指向用户永远看不到的未读项。三人最终固定首版活动集合本身最多 200 条,列表无筛选完整返回且最新优先,未读数精确统计同一集合的 `UNREAD`;跨请求并发变化允许瞬时不等。读取 adapter 只公开完整标题/正文、时间、读状态和 generation+ordinal `snapshotKey`,所有服务端 ID、发送者和业务目标字段全部丢弃。N02 只读当前内存快照;无快照时提示返回消息中心,不虚构服务端过期原因。 - [x] **步骤 3:先写失败合同并固定最小 schema** `tests/notification-read-openapi-contract.ps1` 要求两条 GET 的 SaToken、required clientid、专用 `RListNotificationVo/RNotificationUnreadCount`,两层 `code/data` required。列表允许空、`maxItems` 为 1—200 之间的明确上限并在语义上保证完整活动集合最多 200;每项 required `noticeTitle/noticeContent/publishTime/readStatus`,标题 1—50、完整纯文本正文 1—1000、带时区 RFC3339、状态枚举 `READ/UNREAD`;计数为 0—200 int32 且与同一活动集合一致。当前输出 `NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED`。 - [ ] **步骤 4:后端关闭 `API-NOTIFICATION-READ-001`—`003`** `001` 提供专用响应、最小 required/type/length/enum;`002` 明确活动集合最多 200、完整返回、最新优先、完整纯文本正文和 count/list 同域;`003` 统一 SaToken、required clientid、JSON 媒体、HTTP 401 或业务 401 的文档与部署行为。后端必须从同一 Apifox 版本重新导出 JSON/YAML,不能由客户端手改受保护文件。 - [ ] **步骤 5:门禁通过后测试先行实现读取** 先写 normalizer、内存快照和页面状态负例,证明 unsafe/重复服务端 ID、sender 与 biz 字段不进入页面,筛选不改变 key,旧 generation、重启、退出和账号切换不能别名。再实现严格可取消列表/计数请求:N01 摘要最多 160 字素并支持长词换行,N02 展示完整正文;M01/G01 文案改为“未读消息”,视觉角标为 `99+` 但读屏播报真实数。删除通用 G10/审核 CTA、目标按钮、查询参数假失败、fixture 未读数和所有本地伪已读,不接写端点。 - [ ] **步骤 6:真实账号与 MuMu 验收** 任务 23 关闭认证后验证 0/1/99/100/200 条、最新优先、并发新消息、畸形正文/时间/状态、401、5xx、超时、取消、刷新和账号切换;MuMu 检查系统字号、TalkBack、键盘/焦点、长标题正文、错误重试、N01→N02、Android 返回和 M01/G01 角标。浏览器与源码审查不能替代。 ### 任务 30:建立通知已读写入远端硬门禁 - [x] **步骤 1:三人独立核对单条与全部已读的身份和并发语义** 线上两个 POST 都存在,但 `notificationId` 在列表和 path 中仍为 int64,JavaScript 无法保证无损;`RVoid` 没有 required,文档也未定义重复调用、超时结果未知、跨账号、不存在、read-all 截止点和并发新消息。读批次因此不能先留本地 mutation 充数,写批次也不能直接把页面 `snapshotKey` 当服务端 ID。 - [x] **步骤 2:反向质询并固定私有身份迁移** 写入通过后,唯一 notification controller 才从同一列表响应私有保留 `notificationId`,页面、路由、日志和持久缓存仍只接触 `snapshotKey`。ID 固定为 1—128 位 URL-safe opaque string,列表字段与 path 使用同一个 pattern;禁止解析 int64 后 `String()`。单条和全部已读对当前账号幂等;不存在与跨账号单条 ID 统一为 404 `NOTIFICATION_NOT_AVAILABLE`,read-all 只覆盖服务端接收请求时已经存在的活动通知,之后新消息保持未读。 - [x] **步骤 3:先写独立失败合同** `tests/notification-read-state-openapi-contract.ps1` 要求两个 POST 的 SaToken、required clientid、无 request body、200 精确 `RVoid` 且整数 `code` required;`NotificationVo.notificationId` 与 path ID 同为 required 的 1—128 位 URL-safe string。当前输出 `NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED`,与读取红灯互不冒充。 - [ ] **步骤 4:后端关闭 `API-NOTIFICATION-STATE-001`—`003`** `001` 原子迁移无损 notification ID 与 required `RVoid`;`002` 固定幂等、重复/重试、read-all 截止点、并发/多端收敛;`003` 固定 401、404 `NOTIFICATION_NOT_AVAILABLE`、5xx、超时和 count/list 刷新规则。后端提供同版本双导出并以两个账号证明对象级隔离。 - [ ] **步骤 5:读取批次完成后单独实现写入** 先写 controller 映射、重复点击、迟到响应、超时未知和跨 generation 负例;随后原子删除首版内部丢弃 ID 的实现及 N01/N02 所有 clone mutation。N02 打开后由 controller 标记、N01 提供全部已读;成功或结果未知都重新取得列表与计数收敛,不做本地 `count - 1`。离页取消只停止等待,不宣称服务端未执行;同一 snapshot 防重复提交。 - [ ] **步骤 6:真实账号与 MuMu 验收** 验证单条重复调用、全部重复调用、超时后重读、并发新消息、多端读状态、跨账号 ID、被删除消息、401 和服务异常;MuMu 检查提交中禁用、播报、失败/未知回执、N01/N02/M01/G01 收敛与 Android 返回。没有有效账号和原生证据时保持发布红灯。 ### 任务 31:建立 M02 个人资料写入远端硬门禁 - [x] **步骤 1:三人只读核对页面、本地双导出与线上 PUT** M02 目前把同一个 `currentUser.name` 同时写入 nickName/realName,email 固定空,保存只跑 500ms 定时器;没有真实加载、错误、认证失效、结果未知或并发冲突。受保护 PUT 使用旧 `ProfileUpdateBody`,缺 realName/email 且示例含 schema 外字段,成功为 generic RObject。线上改为 `AppProfileUpdateBody → RAppProfileVo`,却没有 required/minProperties/关闭额外字段/merge/version,200 媒体为 `*/*`,operation 无 security/clientid,只列 200/401。GET profile 门禁仍红,因此 baseline owner 也尚未可用;未调用真实 PUT。 - [x] **步骤 2:交叉反驳并选择唯一 merge PUT** 三人否决“三字段全量 PUT”,因为它会覆盖其他端的未触碰字段,也无法代表 avatar/sex/birthday。PATCH 语义更正统,但在后端能够以 presence-aware DTO 和集成测试证明现有 PUT 为原子字段 merge 时,新增路由是无必要扩张;因此保留 PUT 且不发布 PATCH。省略字段保持,nickName 只允许脏的非空值,realName/email 以请求精确空串清空;null/空白非法,响应把清空字段省略。并发沿用 T01:body 返回唯一 opaque profileVersion,PUT 用 required If-Match,409 稳定码;不在 body 重复 version。 - [x] **步骤 3:先写失败合同锁定最小闭包** `tests/profile-update-openapi-contract.ps1` 要求唯一 PUT、SaToken、required clientid/If-Match、`application/json` 请求、关闭额外字段且 1—3 脏属性的 `AppProfileMergeUpdateBody`;昵称非空 1—30,实名/邮箱各以 oneOf 表达精确空串 clear 与非空规范值。200 精确 `RAppProfileVo` 并含 required profileVersion,409 精确 `RProfileVersionChanged/PROFILE_VERSION_CHANGED`,同时列 400/401/422/429/500。当前输出 `PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED`,证明失败来自合同漂移而非测试语法。 - [ ] **步骤 4:后端关闭 `API-PROFILE-UPDATE-001`—`004`** `001` 固定唯一 merge owner、dirty/omit/clear 和关闭额外字段;`002` 固定 profileVersion、If-Match、当前账号/租户原子 CAS 与 409;`003` 固定 typed canonical response、结构化字段错误、SaToken/clientid、JSON、`Cache-Control: private, no-store` 和正式 H5 CORS 允许 If-Match;`004` 固定重复设置无额外副作用、超时对账与账号隔离语义。后端从同一版本重导 JSON/YAML,不手改保护文件。 - [ ] **步骤 5:GET/PUT 双门禁通过后测试先行实现 M02** 先建立 profile update normalizer、API 和状态机纯运行时负例:clean 不请求,dirty allowlist,clear 命令,非法 nickname/email/null/额外字段,严格 envelope,双击、迟到响应、session generation、canonical 回填、409、确定失败和三分支 outcome reconciliation。随后迁移页面到 loading、ready/dirty、saving、success、error、uncertain、conflict、auth-expired;保存中锁输入与返回,成功更新 baseline,失败保留草稿。原子删除 fixture 双姓名、假 timer、API 禁用旧断言、假头像动作和邮箱送达承诺,不接头像/性别/生日。 - [ ] **步骤 6:真实账号、CORS 与 MuMu 验收** 用两个账号和两个会话验证单字段、多字段、清空、重复、旧 version、同字段冲突、不同字段更新、超时后 GET 对账、401/422/429/500、账号切换和迟到响应;正式 H5 origin 验证 If-Match 预检。MuMu 检查 loading、软键盘、系统字号、TalkBack、字段错误关联/聚焦、saving 返回、unknown/conflict 对话和长姓名邮箱。浏览器与静态合同不能替代。 ### 任务 32:建立 M10 当前设备退出远端硬门禁 - [x] **步骤 1:三人只读核对本机退出、双导出和线上 DELETE** M10 现有 `session.clear → close → goRoot(A01)` 保证本机 token 与家谱上下文被清,取消无副作用,但没有远端撤销、重复保护、epoch 或状态提示。受保护双导出有 DELETE、required clientid/SaToken 和 200 RVoid,却无 scope/幂等/required/错误;线上只有 200 RVoid 与 401 string、媒体 `*/*`,且 operation 无 security/clientid。两份都不能证明当前设备还是全账号退出、旧 token 是否真正失效;未发送真实 DELETE。 - [x] **步骤 2:反向质询并固定本机必退、远端分级** 三人同时否决“网络失败就不退出”和“await 后 finally 清会话并宣称远端成功”。前者把本机隐私交给网络,后者会延迟退出且 A 的迟到 finally 可能清掉新登录 B。唯一结论是 logoutCoordinator 同步捕获 A token/clientid/epoch,立即经 session owner 清理并 bump epoch,再用显式 A 创建不随 M10 卸载取消的请求并立即进入 A01;异步结果永不再 clear。远端 200 才 confirmed,网络/超时/畸形/5xx 为 unconfirmed,拒绝为 not-revoked,全部不恢复 token。 - [x] **步骤 3:先写失败合同锁定唯一服务端语义** `tests/logout-openapi-contract.ps1` 要求 DELETE 无 body、required SaToken/clientid、200 application/json RVoid 且 integer code required、401 typed `RLogoutRejected`,两者 `private, no-store`,并列 400/429/500。operation 明确只撤销当前 credential family、其他设备不受影响、active/revoked/expired 重复调用同一 200、成功后旧 token 被拒绝。当前输出 `LOGOUT-OPENAPI-CONTRACT BLOCKED`。 - [ ] **步骤 4:后端关闭 `API-LOGOUT-001`—`003`** `001` 固定当前凭证族、refresh 同步撤销、其他设备隔离和跨鉴权节点传播;`002` 固定 active/revoked/expired 同一幂等 200,非法/client mismatch typed 401;`003` 固定 SaToken/clientid 绑定、JSON、no-store、错误矩阵、正式 H5 CORS 和 bearer 日志脱敏。后端同版本重导 JSON/YAML并用两设备 token A/B 证明 A 失效、重复 A 200、B 仍可用。 - [ ] **步骤 5:门禁通过后测试先行实现 coordinator** 先扩展 session owner 的单调 epoch 与所有已登记账号态清理,再写 logoutCoordinator 纯运行时测试:同步 capture/clear/request/root 顺序、无 token、request 创建抛错、双击、pending/confirmed/unconfirmed/not-revoked、M10 卸载不 abort、A 迟到不清 B、一次性消费、不持久 token。实现严格 logout API 和 A01 非阻断可播报提示;原子替换 M10、导航、NM 旧“页面直接 clear”断言。Mock/错误配置也本机退出且远端未确认。 - [ ] **步骤 6:真实双设备与 MuMu 验收** 验证 active/revoked/expired/invalid/client mismatch、重复调用、跨节点传播、refresh、离线/超时/429/500、快速 B 登录、进程终止与根导航失败;MuMu 检查确认/取消、双击、系统返回、pending 与最终播报、A01 登录可用及不返回受保护页。视觉源码审查或浏览器不能替代原生流程。 ### 任务 33:建立 M04 与统一密码凭证远端硬门禁 - [x] **步骤 1:三人只读核对 M03/M04、四条密码 wire 与线上 PUT** M04 当前只有 500ms 本地校验预览,诚实显示“不提交服务器”;没有 API、MD5 或 session 写入。受保护双导出与线上文档的登录、注册、找回、改密都把 32 个十六进制字符 MD5 当静态密码等价物。线上 M04 只有 200 RVoid/401 string、`*/*`,operation 无 security/clientid;本地虽有 SaToken/clientid,却只列 200、RVoid 无 required,双方都缺策略、错误、会话撤销、缓存和并发语义。未发送真实 PUT、未操作 MuMu。 - [x] **步骤 2:交叉反驳并固定唯一安全方案** 三人否决保留 MD5 fallback、返回新 token 的方案 B 和继续保留旧 bearer 的方案 C。密码 wire 必须由登录、注册、找回、改密同批迁移到 HTTPS raw writeOnly;新密码目标为 NFC 后 15—64 Unicode code point、允许空格和 Unicode、无组成规则,由服务端执行 blocklist、限速和带盐慢哈希。M04 以当前密码重新认证,TAC 不能代替身份。唯一会话方案 A 是 200 前提升 credentialEpoch 并撤销所有设备/所有 client 的旧 access/refresh session,客户端清本机回 A01。 三人同时否决为了本批新增 operation-status 和持久重试 body:strict 200 与网络结果未知都会进入 A01,重新登录已是最小对账路径。但 PUT 发出后进程被杀会留下本机旧 token,因此采用最窄 marker:session owner 在 dispatch 前仅持久化 `{sessionEpoch,startedAt}`;不含 token、密码、摘要或 body,冷启动同 epoch 时在渲染账号缓存前清会话。客户端不自动重试,迟到 A 不能影响 B。 - [x] **步骤 3:测试先行建立共享 wire 与 M04 失败合同** 新增 `tests/password-change-openapi-contract.ps1`。合同要求 `CurrentPasswordSecret/NewPasswordSecret` 分别成为登录/当前值与注册/找回/新值的唯一 schema owner,旧 MD5 不能继续被任何入口接受;M04 body 只含 required oldPassword/newPassword 且关闭额外字段。PUT required SaToken/clientid;200/400/401/409/422/429/500 使用 JSON/no-store,429 有 Retry-After,409/422 只消费四个稳定业务码,200 RVoid 的 integer code required。当前得到预期 `PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED`,失败精确来自受保护后端合同缺口。 - [ ] **步骤 4:后端关闭 `API-PASSWORD-001`—`005`** `001` 原子删除 login/register/reset/change 的 MD5 wire,发布 raw writeOnly 与 15—64/NFC/blocklist/慢哈希合同;`002` 固定 credentialEpoch、ALL access/refresh 跨节点撤销和 200 语义;`003` 固定 typed 400/401/409/422/429/500、并发 CAS 与明确未写边界;`004` 固定 SaToken/clientid、HTTPS server、JSON/no-store、Retry-After、日志/APM 脱敏;`005` 明确无密码账号是进入独立设置流程还是不可进入 M04。后端只接受同一版本重新导出的 JSON/YAML,禁止手改受保护文件。 - [ ] **步骤 5:门禁通过后原子实施四条 wire、session 与 M04** 先用共享密码策略纯函数测试 14/15/64/65 code point、Unicode/NFC、空格、粘贴、无组成规则与 current/new 相同;再一次性移除 `calcMD5` 的四条认证消费者和 8—32 旧规则,收紧 API payload/响应。扩展 session epoch 与 credentialChangeInFlight 冷启动门禁,写 M04 ready/submitting/known-error/unknown/success 状态机;确定未写错误留页,200/401/409/unknown 清秘密和同 epoch 账号态回 A01。原子替换 NM 旧“禁止 API/尚未提交”断言,不能保留 preview/remote 双入口。 - [ ] **步骤 6:两设备、故障注入、无障碍与 MuMu 验收** 验证旧/新密码、同旧密码并发只有一笔成功、所有设备 access/refresh 跨节点失效、wrong-old/policy/rate 明确未写、请求创建失败、落密前后/撤销前后断网与 5xx、进程被杀 marker、快速 B 登录和秘密不进入日志。MuMu 固定环境检查 15—64 文案、密码管理器/粘贴、软键盘、系统字号、44dp 显隐按钮、aria-pressed、字段错误关联/首错聚焦、提交播报、Android 返回及 A01 成功/unknown 持久提示;静态审查不能替代。 ### 任务 34:建立 M05 手机号换绑与统一 OTP 远端硬门禁 - [x] **步骤 1:三人只读核对 M03/M05、全活动短信码、本地双导出与线上接口** M05 现有页面只消费脱敏 fixture,以 500ms 定时器校验新手机号和 4 位码,明确显示“当前未发送/尚未提交服务器”;没有 currentPassword、TAC consumer、API、session 或结果未知状态。本地 PUT body 为 `clientId/phone/smsCode`,线上为 `phone/smsCode`,两者均为 4 位码且缺 existing-factor、唯一约束、会话撤销和 typed 错误;线上返回完整 profile并把共享发码标为忽略权限。未发送 POST/PUT 或短信,未操作 MuMu。 - [x] **步骤 2:交叉反驳并固定身份、发码和会话唯一方案** 三人否决 bearer+新号 OTP:被盗 session 加攻击者自有号码即可接管。唯一身份闭环是活动 session+最终 PUT 内 raw currentPassword 再认证+新号 `APP_PHONE_CHANGE` TAC/OTP;不强制旧号 OTP,成功事务改为持久旧号通知 outbox。无密码账号 `STEP_UP_UNAVAILABLE`,不得降级。最终 PUT 不带 challengeId:同一复合键只有一个 active OTP generation,重发废止旧码即可消歧。 三人先讨论复用公共 `/auth/sms/code` 加条件鉴权,随后否决:标准 OpenAPI 无法把 body scene 与 operation security 联动,optional security、文字或 vendor extension 仍让通用 validator 允许匿名,违反 validator/runtime 同步。最终采用专用 `POST /genealogy/app/auth/phone/sms/code`,SaToken required、scene 服务端固定、body 只含 phone/validToken;公共 scene enum 删除 APP_PHONE_CHANGE。底层 OTP 服务仍是单一 owner,不复制实现。 - [x] **步骤 3:原子固定全活动六位短信码目标** 四位码不可上线。新增唯一 `SmsCodeSecret`:CSPRNG 生成恰好 6 位 ASCII 数字、允许前导零、writeOnly、无示例;5 分钟 TTL、60 秒重发、最多 5 次失败、单次消费,重发废止旧 generation且不重置累计失败次数。登录、注册、找回、换绑、注销及同源服务端生成器/模板、双 OpenAPI、validator、页面和测试必须同版切换,禁止 4/6 兼容窗口。当前客户端继续保持四位诚实预览,不能在后端前单边改成无法登录的六位。 - [x] **步骤 4:测试先行建立专用发码和最终换绑失败合同** 新增 `tests/phone-change-openapi-contract.ps1` 并同步收紧 `tests/auth-tac-openapi-contract.ps1`。门禁要求专用 POST 与最终 PUT 都 required SaToken/clientid;`PhoneChangeSmsCodeBody` 只含 phone/validToken,`PhoneChangeBody` 只含 currentPassword/phone/smsCode;密码、新号和短信码分别引用唯一 schema。公共发码移除 APP_PHONE_CHANGE;POST/PUT 均有 JSON/no-store 错误矩阵、429 Retry-After,严格 200 RVoid,409/422/429 使用稳定 `RPhoneChangeRejected`。当前精确输出 `PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED`,且认证门禁继续标准化输出自身红灯。 - [ ] **步骤 5:后端关闭 `API-PHONE-001`—`005`** `001` 提供专用鉴权发码、固定 scene、公开/受保护反例与共享 OTP owner;`002` 原子升级全活动消费者为六位并固定 TTL/重发/尝试/单次消费;`003` 提供 raw currentPassword existing-factor、闭合 PUT 与无密码账号语义;`004` 把密码/OTP、号码唯一、CAS 换绑、credentialEpoch、ALL session 撤销和旧号 outbox 放进同一事务;`005` 固定 typed 业务码、HTTPS、JSON/no-store、Retry-After、CORS、PII/秘密日志脱敏与结果未知边界。只接受同一版本重导的双文件,不手改受保护源。 - [ ] **步骤 6:前置门禁通过后原子实施客户端与 MuMu/两设备验收** 先同步迁移共享六位 validator、认证页面与测试,再新增带 bearer 的 phone-change TAC/发码 API、最终 PUT strict adapter和共享 credential marker,最后实现 M05 ready/tac/sending/code-sent/submitting/known-error/unknown 状态机;同轮删除四位、preview 和禁止 API 的旧断言,不保留双入口。验证匿名专用发码、错 scene/TAC 重放、前导零、过期/重发/限流、号码枚举与唯一、两设备并发、全部 access/refresh 撤销、outbox、请求各事务点 fault injection 和进程终止。MuMu 检查软键盘、自动填充/粘贴、系统字号、TalkBack 非拖动 TAC、原生 44dp 按钮、错误关联/首错聚焦、倒计时、Android 返回及跨根 unknown 提示。 ### 任务 35:建立 G03 家谱与始祖原子创建远端硬门禁 - [x] **步骤 1:三人只读核对 G03、上下文、现有测试、本地双导出与线上接口** G03 当前同页两步只写内存预览,第一步缺可信 regionCode,始祖默认男性、日期限制 1800 年以后且把“一世”放进 generationName;最终 `local-created-*` 进入 fixture G05,不安装真实 context。受保护双导出和线上 `/genealogies` 都只建谱,通用 `/lineage/persons` 另建人物;创建响应、地区项和线上 App 模型均缺 required/词法 ID/错误闭包,当前 `appApi.createGenealogy` 又会在 mock 中直接篡改 fixture。审查只读获取线上 `/v3/api-docs`,未发 POST/PUT、未触碰 MuMu、Git 或受保护 OpenAPI。 - [x] **步骤 2:交叉反驳并固定 atomic bootstrap、访问预设和地区唯一方案** 三人先完整设计 `ROOT_REQUIRED` 两写,再质询其必要性。当前没有跨库证据或“保存空谱以后补根”的产品需求;第一步本来也应零写,因此两写只会新增半成品 quota/ACL/搜索隐藏/过期/取消、G01 pending 卡、版本并发和第二次结果未知。最终选定现有 POST 原子创建家谱、OWNER、唯一一世始祖、READY 和幂等回执,任一失败全回滚;通用人物 POST 只服务 READY 后普通人物。 `GenealogyAccessPreset=MEMBER_ONLY/PUBLIC_APPLY` 是 APP 读、建、改唯一 wire,而非 G03 私有第三字段;目标版本删除 visibility/joinMode 和旧 create/update DTO。所在地只消费 required SaToken/clientid 的 `GET /genealogy/app/region/search` 所返回的 required `regionCode/label/selectable`,同版删除旧公共 `/genealogy/region/search`;不从自由文本反推 code,也不把 leaf 猜成可选或强制某行政层级。根人物 sex 使用 MALE/FEMALE/UNKNOWN且默认 UNKNOWN,生日为 date,无 1800 下限;generation=1 由服务端固定。G11 在本任务中只迁移共享字段形状,其 If-Match、版本/CAS 与结果未知另立设置写入门禁,不能借 G03 宣称可上线。 - [x] **步骤 3:选择无 PII 崩溃恢复并测试先行建立失败合同** 三人比较“本地持久化 exact body 同 key 重放”和“authenticated operation-status”。前者端点更少,却会把始祖姓名、生日、生平放进普通 UniApp storage,扩大退出、换号、备份和崩溃清理面;最终选择后者。客户端只持久 `{sessionEpoch,operationKey,startedAt}`;进程内可用相同 key+冻结 snapshot 重放,冷启动查询 `PENDING/SUCCEEDED/FAILED_NO_COMMIT`,不从 `/mine` 猜同名记录。key 固定为 13 位 issuedAt+22—43 位 base64url CSPRNG,`acceptUntil=issuedAt+10 分钟`,以 server time 判定且未来超过 5 分钟为 400 `OPERATION_KEY_INVALID`;上述公式以 600/300 秒 extension 机器锁定。首次 POST 先以短控制事务唯一认领 PENDING/digest/fencing/resolveBy,`resolveBy<=claimedAt+2 分钟`并以 120 秒 extension 锁定,再由业务事务原子写 quota、谱、OWNER、唯一根、READY 和 SUCCEEDED。业务回滚后或 watchdog 超时只能用 fencing CAS 终结 FAILED_NO_COMMIT,旧 worker 不得提交;GET 只允许 operationKey/clientid、无 body且始终纯读,不能靠查询制造墓碑。 新增后端双导出门禁 `tests/g03-bootstrap-openapi-contract.ps1`,当前精确输出 `G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED`;其前置 `tests/openapi-yaml-json-parity-runtime-smoke.js` 不依赖第三方包,以无损任意精度数字、合法 mapping 分隔符和完整对象结构深比较 JSON/YAML,JSON 检查因此等价覆盖 YAML 而不依赖缩进、引号或 JavaScript Number 舍入。另建客户端激活门禁 `tests/g03-bootstrap-client-release-gate.ps1`,当前精确输出 `G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED`;它只保留公共 owner/旧入口删除的静态断言,并实际执行依赖注入状态机测试,以 exact marker 和 request/storage/cache/context/navigation spies 验证分支及成功顺序,不再靠 helper 的 IndexOf 或测试标题误判。已有 fixture/G11 等聚焦守卫由全量测试共同证明,不冒充为该脚本单独覆盖。后端合同转绿不能自动开放页面,客户端绿灯也不能绕过后端;G03 两门禁、家谱工作区读取门禁和 MuMu 发布验收必须共同通过。同轮把 `tests/g-series-openapi-contract.ps1` 中会永久锁死旧 `GenealogyCreateBody/GenealogyUpdateBody/visibility/joinMode` 的断言移交给新唯一 owner,既有 G-series 合同仍独立通过。 - [ ] **步骤 4:后端关闭 `API-G03-001`—`005`** `001` 以 `AppGenealogyBootstrapBody` 原子替换旧 create,在业务事务内处理 quota、家谱、OWNER、根、READY 和 SUCCEEDED 回执;`002` 提供 issuedAt+至少 128 位随机量的 Idempotency-Key、server-time 10 分钟接受窗口、5 分钟未来偏差、同 key/body 同回执、不同 digest 409、过期空 key 409 与长期成功防重记录;`003` 以短控制事务/fencing/watchdog 发布受鉴权 operation-status,GET 参数/body/响应集闭合且纯读,PENDING 为 `resolveBy/retryAfterSeconds` 并最迟 claimedAt 后 2 分钟收敛,结构化转换只允许 `ABSENT→PENDING→SUCCEEDED/FAILED_NO_COMMIT` 且两个终态不可变,FAILED 机器保证零领域写与零 quota,截止前 404 带 acceptUntil/Retry-After,截止后无记录按 key 计算 FAILED 而不写墓碑;`004` 让 APP 读/建/改统一 accessPreset并递归拒绝组合 schema 中的旧 pair/DTO;`005` 将地区 owner 固定为 `/genealogy/app/region/search` 并删除公共旧路由,固定词法 ID、SaToken/clientid、状态专属 error code/businessCode、private no-store、对象隔离和事务 fault injection。通用人物 collection POST、人物 PUT/DELETE 和 parents mutation 同版以数据库 bootstrap-root 标记保护根身份:根 PUT 可编辑白名单精确只有 `name/sex/birthDate/biography`,其余字段全部 typed 422,根不可删,typed 409/422覆盖全部绕过入口。只接受同一版本重导 JSON/YAML,禁止手改当前源文件。 - [ ] **步骤 5:门禁通过后测试先行实现唯一 coordinator 与页面闭环** 先写 canonicalizer、严格响应 normalizer、operation marker/status 状态机和 context 安装的纯 Node 测试,再实现唯一 G03 coordinator。第一步 CTA 改“下一步:录入首代”且零请求;最终按钮冻结全表单后才写 marker/POST。本地校验或可证明零发出的 request-build 失败不留 marker;服务端在 claim 前返回的 400/401/403 清 marker,401 同时清会话。IDEMPOTENCY_KEY_REUSED 进入 fatal/quarantined,禁止查装 status或自动换 key,只有用户看到警告后显式放弃才清;确定未提交的 limit/expired/422 可清。429 保持同 key/body并先查 status;500/network/timeout/408/发出后取消/意外 2xx/3xx/畸形成功均按 unknown 保持。status 截止前 404 保持,400 清损坏 marker,401 走会话失效,429/500/network/cancel/unexpected/malformed 保持退避,FAILED_NO_COMMIT 才允许新 key。SUCCEEDED 唯一次序是 committed receipt → 失效或定点更新 `/mine` → 安装词法 context → 进入远端 G05;context/导航失败只重试本地闭环,不重发创建。原子删除 `createLocalGenealogyPreview`、会修改 fixture 的 mock create、禁止 appApi 的旧断言及 `local-created-*` 成功路径,不保留 preview/remote 双 owner;G01 onShow 和 G05 overview 必须与工作区门禁同一真实 ID 收敛。 - [ ] **步骤 6:表单无障碍、事务反例和 MuMu 发布验收** 地区选择覆盖加载/搜索/无结果/失败/迟到/失效;输入具有 label、aria-invalid/describedby、首错聚焦,访问预设使用 radio,所有动作至少 44dp,弹层使用 AppDialog 焦点圈定/恢复,提交中冻结输入与返回并播报 PENDING/unknown/fatal/committed。后端在 control claim、quota、谱、OWNER、根、receipt、终态 CAS 每个写点做 fault injection,验证零残留、同 key 并发唯一、不同 body 409、跨账号/tenant/client status 非泄漏 404、截止前 404 不换 key、截止后 GET 纯计算 FAILED 且数据库零写、迟到 POST 永久拒绝、watchdog 后旧 worker 因 fencing 无法提交;另覆盖 collection/PUT/DELETE/parents 的根身份绕过,并证明根 PUT 仅 `name/sex/birthDate/biography` 四项可编辑、其余字段一律 422。MuMu 固定覆盖 320/360/412 宽度、系统字号、TalkBack、软键盘、日期、双击、慢网/断网/杀进程、Android 返回、context/导航失败;浏览器或源码检查不能代替原生结论。 ## 后续独立阶段边界 T01、认证、家谱工作区、M06、个人资料读写、通知、服务端退出和密码凭证当前都被外部合同红灯阻塞,不在本地空等。其余阶段仍逐个重新执行三人只读审查、方案比较、失败测试和最小实施;不得把两个业务域混入同一批: 1. 家谱工作区:任务 26 已完成三人审查和 OpenAPI 红灯;线上 JSON `int64` 响应身份、模型 required、可访问能力与错误语义未关闭前禁止猜测实现。门禁通过后才处理远端列表现场、权限刷新和账号隔离;写后失效属于后续写接口批次。 2. M06:任务 27 已完成 list-only 三人审查和 OpenAPI 红灯;门禁通过后才写严格 adapter、异步状态和无障碍手风琴,不接详情 ID。 3. 个人资料读取:任务 28 已完成 M01/M02/M03 三人审查和 OpenAPI 红灯;门禁通过后才接掩码 adapter,PUT 保存、头像和换绑仍各自独立。 4. 通知:任务 29/30 已把读取和已读写入拆开建立红灯;读取通过后先实现无 ID 内存快照,写入再私有迁移字符串 ID,未知业务目标始终内容态。 5. M02 写入:任务 31 已建立唯一 merge PUT 红灯;GET/PUT 双门禁通过后才实现 dirty command、版本 CAS、结果未知对账与账号隔离,头像保持独立。 6. M10 退出:任务 32 已建立当前凭证族与幂等撤销红灯;通过后才实现 session epoch、logoutCoordinator 和 A01 提示,本机清理始终优先。 7. M04 密码凭证:任务 33 已建立 raw wire、15—64 策略、ALL 会话撤销与崩溃 marker 红灯;必须与登录/注册/找回同批迁移,M04 不单页双轨。 8. M05 手机号换绑:任务 34 已建立专用受保护发码、全活动六位 OTP、currentPassword、ALL 会话撤销与旧号 outbox 红灯;必须等认证/M04/profile 前置合同共同通过后原子实施。 9. G03 原子创建:任务 35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、可信地区和词法 ID 红灯;必须等本门禁与工作区读取门禁通过后再实现 context/G05/G01 完整回流。 10. G、F、R、N/M 其余真实接口:逐页核对页面、业务闭环与线上/双导出三方,缺失合同先形成后端问题单;当前从 G/F/R 选择不依赖既有红灯的下一域。 11. 全局文字层级与无障碍第二轮:字号层级、系统字号放大、焦点顺序、读屏语义、对比度和 44dp 触控目标。 12. 发布收口:真实环境配置、隐私与权限声明、错误观测、构建签名、升级/回滚、MuMu 全流程回归和三人终审。 不得在同一实现批次混合以上阶段,也不得借后续阶段返工已经通过且无回归证据的响应式页面。 ## 当前计划完成条件 - 阶段 0 的五份权威中文文档仍是唯一长期入口,没有新建旧版、备份或平行计划。 - 导航、T01、认证/TAC、家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑的所有者、旧路径删除条件、测试、MuMu 矩阵和后端接口缺口均已精确写明。 - 导航任务 1—10 已按测试先行完成两个共享所有者、共享组件、退役入口、认证、G、T、F、R、N/M 系列静态迁移与零债务门禁;没有手工改写 OpenAPI 导出,MuMu 原生矩阵仍按明确边界待执行。 - 三人对业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性形成一致结论。 - T01、认证、家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑 OpenAPI 门禁已经建立并取得预期红灯;TAC 客户端任务 20—22 已完成,任务 23 等待后端合同与 MuMu 原生证据。领域上下文任务 24、M07 客户端任务 25、工作区任务 26、M06 任务 27、profile 读取任务 28、通知任务 29/30、profile 写入任务 31、logout 任务 32、password 任务 33、phone 任务 34 与 G03 任务 35 已完成各自可本地完成的部分;等待后端期间转向 G/F/R 下一域,不把两个阶段混在同一变更中。