完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+221 -25
View File
@@ -1,7 +1,7 @@
# 家谱项目全量治理设计
> 日期:2026-07-22
> 状态:阶段 0 已完成;导航栈语义与 T01 大规模世系树设计已经三人终审,尚未实施业务代码
> 状态:阶段 0 已完成;导航任务 1—10 的静态实施和零债务门禁已经完成;A01/A04/A05 TAC 客户端与 M07 反馈客户端已经完成;MuMu 原生矩阵待执行;T01、认证、家谱工作区、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑后端合同均处于硬门禁红灯
> 适用范围:当前 UniApp 家谱项目、根目录 OpenAPI 文档、全部活动页面、共享组件、测试、正式资产与后续真实接口接入
## 一、背景
@@ -509,7 +509,7 @@ MuMu 验证中
### 20.1 当前证据
只读扫描得到以下统一口径:
任务 3 实施前的只读扫描得到以下统一口径:
- 52 个活动页面中共有 `navigateTo 59``navigateBack 11``redirectTo 14``reLaunch 7`,合计 91 次直接调用。
- 活动共享组件另有 `navigateBack 2``reLaunch 2`,活动页面与组件共 95 次。
@@ -517,6 +517,8 @@ MuMu 验证中
- MuMu 已复现 F01→F03→“返回家族圈”后留下两个 F01,A01→A04→“登录”后留下两个 A01,T03 可连续叠出三个同路由页面。
- 栈深为 1 时直接进入 F03,当前 `PageHeader` 会回到固定 G01,而不是业务父页 F01。
任务 3 已把共享页头和自定义底栏的直接调用清零,并删除无活动消费者的通用页面旧入口;任务 4—8 依次迁移认证、G、T、F、R,任务 9 完成 N/M、安全通知目标、账号表单返回守卫与退出会话清理,任务 10 删除最后一个零生产消费者的 `TreeMemberForm.vue` 及其旧专属合同。当前 SFC 分段词法扫描的迁移债务已经清零:pages/components 中 `navigateTo``navigateBack``redirectTo``reLaunch``getCurrentPages` 与业务页面路径字面量均为零,声明式 navigator、动态 Uni 属性、Uni 对象逃逸与 `switchTab` 也为零;验证结果为 `MIGRATION-DEBT=0`。路由与页面栈合同分别只由 `utils/navigation-routes.js``utils/navigation.js` 持有,后续业务批次不得恢复页面私有路径或栈判断。
因此问题不是某几个按钮写错,而是项目没有统一表达“打开页面、替换步骤、返回来源、完成流程、切换根页和直接进入回退”的语义合同。
### 20.2 八类问题的五方案终选
@@ -537,7 +539,7 @@ MuMu 验证中
### 20.3 唯一所有者
- `utils/navigation-routes.js` 是 52 个活动路由的唯一语义注册表,拥有路由键、路径、页面类型、规范父页、父页参数映射、根页、必填参数、可选参数、允许来源和目标页允许消费的结果操作枚举。
- `utils/navigation.js` 是项目唯一允许调用 `uni.navigateTo``uni.redirectTo``uni.reLaunch``uni.switchTab``uni.navigateBack` 的业务模块。
- `utils/navigation.js` 是项目唯一允许调用 `uni.navigateTo``uni.redirectTo``uni.reLaunch``uni.navigateBack` 的业务模块;项目没有原生 tabBar`uni.switchTab` 在该模块内外都禁止
- 页面和组件只调用语义方法,不拼接页面路径,不保存 fallback URL,不接收后端原始跳转 URL。
- 注册表中的路径集合必须与 `pages.json` 的 52 个活动路由精确相等;缺失、重复和陈旧条目均使合同失败。
@@ -549,27 +551,27 @@ MuMu 验证中
openPage(routeKey, params, sourceKey)
打开普通子页;sourceKey 必须等于当前真实页面。当前已经是同一路由且关键参数相同则不重复入栈。
replaceStep(routeKey, params, sourceKey)
只替换同一流程中的临时步骤;sourceKey 同样必须来自当前真实页面,禁止用于“返回列表”。
goBack()
栈内有上一页时 navigateBack;没有时按当前路由的规范父页逐级回退。
returnTo(routeKey, targetParams = {}, result = null)
按路由键寻找最近实例并精确返回;targetParams 只用于目标不在栈内时构造合法回退 URL。
returnTo(routeKey, targetParams = {})
按路由键寻找最近实例并精确返回;显式 targetParams 必须与最近实例一致,目标不在栈内时才用于构造合法回退 URL;普通返回不产生流程结果
finishPage(routeKey, targetParams, result)
校验目标参数和类型化结果后调用 returnTo;结果只能包含 operation、entityId 和 refresh。
是完成并回传结果的唯一公开入口;调用方必须显式提供目标全部必填参数,当前来源页与目标页共同声明且实际存在的上下文字段必须相等;校验参数和类型化结果后精确返回,结果只能包含 operation、entityId 和 refresh。
goRoot(routeKey, params)
只接受 A01、G01、F01、M01 四个根语义;使用 reLaunch 清理旧流程。
handleBackPress(event, requestBack)
同步适配 UniApp 的 onBackPress:网关自己的 navigateBack 回调来源返回 false 放行,其余来源同步返回 true,并异步执行页面唯一 requestBack,避免递归拦截。
```
个公开语义方法中,除纯判断外的导航动作都返回 `Promise`,并共享一个在途转场锁:同一目标的重复调用复用同一个 Promise,其他并发转场明确返回忙碌结果。导航失败或被锁拒绝时必须回滚刚写入的一次性结果。
个公开导航语义方法都返回 `Promise`,并共享一个在途转场锁:相同参数和相同结果语义的重复调用复用同一个 Promise,其他并发转场明确返回忙碌结果。一次性结果写入必须与“取得新锁”原子发生;复用或忙碌调用不得改写结果,导航失败必须回滚。每次转场用独立 flight 身份释放锁,`success/fail` 必须在 Promise settle 前释放自己的 flight;迟到的 `complete` 只能清理原 flight,不能清掉已经开始的新转场。所有调用方导航参数、流程结果和返回守卫上下文只接受普通对象的 own enumerable data propertiesSymbol、访问器、不可枚举字段和原型继承字段一律拒绝,校验后只使用同一次读取形成的冻结快照,禁止重复 getter 读取或校验后别名篡改。`redirectTo` 只允许由网关内部的规范父链回退和目标缺栈返回使用;当前没有可公开授权的“替换步骤”边,因此不暴露无消费者的替换方法
一次性结果只存在于当前 JavaScript 进程,目标页消费一次后立即删除。字段键精确为 `operation、entityId(可选)、refresh``operation` 必须属于目标路由注册的 `resultOperations``entityId` 若存在必须为非空字符串,`refresh` 必须是布尔值。它不是领域数据持久化,不允许加入 `treeVersion、genealogyId、focusId、payload`,也不允许保存完整对象、表单内容、列表快照或接口响应。
一次性结果只存在于当前 JavaScript 进程。网关用模块内 `WeakMap` 为页面实例分配原始值身份令牌,结果 envelope 只保存令牌,不强引用或保存页面/Vue 实例。栈内返回时结果绑定反向搜索选中的最近目标令牌和该实例的规范业务参数;缺栈重建时绑定发起页令牌与完整目标参数。只有当前真实栈顶正是目标实例(或缺栈重建出的非发起页)且业务参数完全一致时才能消费,错页、同路由的更远实例和其他家谱上下文只能得到 `null`,也不得删除正确结果。结果必须在原生目标页 `onShow` 发生前随取得转场锁原子写入,消费一次后立即删除;新完成流程取得锁时淘汰已经错过目标生命周期的未消费旧结果,失败时不复活陈旧结果。字段键精确为 `operation、entityId(可选)、refresh``operation` 必须属于目标路由注册的 `resultOperations``entityId` 若存在必须为非空字符串,`refresh` 必须是布尔值。它不是领域数据持久化,不允许加入 `treeVersion、genealogyId、focusId、payload`,也不允许保存完整对象、表单内容、列表快照或接口响应。
`sourceKey` 只证明当前 JavaScript 进程内的一次真实导航:`openPage/replaceStep` 必须核对它与 `getCurrentPages()` 的当前真实路由一致。栈深为 1 或外部直接进入时,查询串里的 `sourceKey` 一律视为不可信并忽略,只按注册表的 `parent/parentParamMap` 建立回退目标;外部链接不能伪造来源合同。
`sourceKey` 只证明当前 JavaScript 进程内的一次真实导航:`openPage` 必须核对它与 `getCurrentPages()` 的当前真实路由一致。栈深为 1 或外部直接进入时,查询串里的 `sourceKey` 一律视为不可信并忽略,只按注册表的 `parent/parentParamMap` 建立回退目标;外部链接不能伪造来源合同。
### 20.5 根页与页头
@@ -599,20 +601,22 @@ goRoot(routeKey, params)
- A04 取消或“已有账号”返回 A01;注册并建立会话后 `goRoot(G01)`
- A05 取消返回 A01;只有未来真实重设接口成功才写入 `password-reset` 结果并回 A01。本地 `state=success`、计时器或视觉占位成功态只能普通返回,不得生成业务成功结果。
- A01 登录成功 `goRoot(G01)`;会话失效和退出成功也只允许 `goRoot(A01)`
- F02、F03 完成或返回精确 F01F06 新建回 F04编辑回 F05F09 上传完成回 F08
- 当前 F02、F06、F07、F09 只形成明确标注“尚未提交服务器”的独立本地预览;F03 评论草稿不插入评论列表、不清空、不增加计数,F05 收藏明确禁用,F10 明确未开放。F01—F10 均不产生写成功结果;F02/F03 返回精确 F01F06 新建回 F04编辑回精确 F05F09 无结果回同一 `genealogyId + albumId` 的 F08。真实写接口返回服务器 ID 后才能同轮启用完成结果
- `data/mock.js` 是 F 系列 feed/article/album 只读夹具的唯一 owner,只公开按 `genealogyId` 列表和按复合身份详情的深拷贝查询;跨谱、未知实体或缺失身份失败关闭。`utils/api.js::createFeed` 在 mock 模式以 `WRITE_UNAVAILABLE` 拒绝,不得修改列表冒充发布成功。
- R02 回 R01R04 回 R03R06、R07 回 R05。
- T04 保存后回 T01 并定位新成员;T05、T08 回当前 T03;T06 保存后回 T01 并刷新原焦点;T07 选择成员后回 T01 定位或进入 T03
- 当前 T04/T05/T06 只生成“尚未提交服务器”的本地预览,不产生写成功结果;用户确认放弃预览后,T04/T06 无结果回 T01T05 使用 `goBack()` 精确回原 T03 实例,T08 普通返回 T03,T07 可进入 T03。新增亲属、编辑成员和编辑关系的完成定位只能在真实版本化写接口同轮启用
- 当前阶段 `data/mock.js::treeMembers` 是唯一可变成员夹具 owner`utils/api.js` 是唯一写入口;T01/T03—T08 只能通过 `listTreeMemberFixtures(genealogyId)``findTreeMemberFixture(genealogyId, personId)` 取得含亲属数组深拷贝的快照。成员身份必须由家谱与成员复合定位,缺失、未知或跨家谱身份失败关闭;只有 T04 的明确首位成员模式允许没有 `personId`。Task 18 接入真实新图合同后必须原子删除该临时 owner、选择器和旧 `id/parentId` 模型,不保留双读适配层。
- N02 默认回 N01;通知业务目标失效、无权限或字段不足时留在 N02 的明确状态,不猜测页面。
当前 OpenAPI 已确认 `POST /genealogy/app/auth/register` 的成功响应复用 `LoginResult`,其 `LoginVo` 可返回 `token/accessToken/tokenValue`。因此 A04 的正式终点不是“注册后再登录”,而是保存有效会话后直接 `goRoot(G01)`;在真实注册与行为验证尚未接入前不得把当前占位反馈冒充注册完成。
2026-07-22 新线上 OpenAPI 已确认密码登录、短信登录和注册统一返回 `RAppLoginVo`,其 `data` 引用 `AppLoginVo`,唯一会话字段为 `access_token`。因此 A04 的正式终点不是“注册后再登录”,而是保存有效会话后直接 `goRoot(G01)`受保护旧快照的响应形状不再作为兼容读取路径,在真实注册与行为验证尚未接入前不得把当前占位反馈冒充注册完成。
### 20.7 T03 单实例成员轨迹
- T03 原生页面实例只保留一个。初始成员成功读取后先把轨迹初始化为恰好 `[initialPersonId]`;初始读取失败不建立轨迹。点击亲属只更新页面内 `personId` 和成员轨迹,不再 `navigateTo` 同一路由。
- T03 原生页面实例只保留一个。初始路由 `personId` 是不可变的宿主页路由身份,初始成员成功读取后先把轨迹初始化为恰好 `[initialPersonId]`;初始读取失败不建立轨迹。点击亲属只更新页面内活动成员 `personId` 和成员轨迹,不改 URL,也不`navigateTo` 同一路由。
- 新成员资料成功读取后才写入轨迹;读取失败保留原成员和原轨迹。
- 返回先逐项弹出成员轨迹,轨迹结束后才返回 T01、T07、R02 或实际来源。
- T05、T08 返回时刷新当前轨迹项,不新增 T03
- 如果 T03 已位于原生栈下方,再次打开同一 `genealogyId` 的 T03 时必须回到该实例,并用目标页允许的类型化一次性操作请求加载目标成员;不得创建第二个 T03。若栈中 T03 属于不同 `genealogyId`,网关返回 `T03_CONTEXT_CONFLICT` 并拒绝压栈调用方必须先通过根语义切换家谱上下文。
- 当前 T05 本地预览以 `goBack()` 返回并保留原 T03 宿主页身份、活动成员和轨迹,T08 普通返回也不新增 T03。未来真实成员更新结果只允许刷新与 `entityId` 相同的当前活动成员,不得切换轨迹或改写宿主页身份
- 如果 T03 已位于原生栈下方,再次打开同一 `genealogyId` 的 T03 时必须回到该实例,并用目标页允许的类型化一次性操作请求加载目标成员;不得创建第二个 T03。若栈中 T03 属于不同 `genealogyId`,网关返回 `T03_CONTEXT_CONFLICT` 并拒绝压栈;若迁移前遗留栈已有多个 T03,则返回 `T03_STACK_CONFLICT`,不得只激活最近实例后宣称栈已唯一。调用方必须先返回或通过根语义清理冲突上下文。
- 从直接链接进入 T03 且没有来源页时,规范父页为带当前成员定位参数的 T01;缺少 `genealogyId` 时继续回退 G01。
### 20.8 通知和外部进入
@@ -805,6 +809,10 @@ PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relatio
- 现有人物搜索只需稳定返回字符串 `personId`;新增人物、编辑人物和编辑关系接口必须加入版本并发合同。当前 OpenAPI 没有 T06 所需的关系修改入口,因此新增上述关系 PATCH。不可变 `relationshipKind` 只允许 `PARTNER/PARENT_CHILD`,必须与服务端既有关系一致;请求使用以该字段为 discriminator 的 `oneOf`PARTNER 分支只更新 `relationType/status`PARENT_CHILD 分支只更新 `relationType/parentRole`。每个分支除 `relationshipKind` 外至少包含一个可修改字段,省略字段保持原值;只有 discriminator 的空更新返回 `422 RELATIONSHIP_PATCH_EMPTY`。参与人和 `relationshipKind` 不可在 PATCH 中偷换,也不以客户端删除再新增模拟修改。
- 旧 v1 `/genealogy/app/genealogies/{genealogyId}/lineage/tree` 保持原合同供既有消费者使用;当前 App 只接入上述四条明确的 `/genealogy/app/v2/...` 路径,不双读、不做运行时版本探测。
四条操作统一声明 `200/400/401/403/404/422/429/5XX`tree、overview 和 relationship PATCH 还必须声明 `409`。稳定业务码字段唯一固定为错误响应根层必填字符串 `businessCode`,对应 HTTP 响应使用 `oneOf` 分支中的单值 enumOpenAPI 3.1 也可使用 `const`。description、example 或无关 metadata 中出现同名文本都不构成合同。`generationRange` 固定为关闭额外字段的 `{ minGeneration, maxGeneration }`,两项均为大于等于 1 的整数;`minGeneration <= maxGeneration` 由运行时校验。
接口门禁分为两层,不能互相冒充:`tests/lineage-openapi-contract.ps1` 的静态层只验证 OpenAPI 能结构化表达的路径、参数全集与单字段边界、响应引用闭包、discriminator/oneOf、精确字段集、枚举、字符串 ID、错误响应、`If-Match`、JSON/YAML 引用一致性以及 v1 隔离。平铺 query Parameter Objects 即使在 OpenAPI 3.1 中也不能证明 FOCUS/BOUNDARY 的跨参数互斥;入口集合、引用、环、bucket 总和、隐私根、locator 分段首尾、cursor/version 绑定、409/422 真实行为及 PATCH 省略字段保持原值同样必须由运行时 validator 与部署集成测试验证,禁止用 description、example 或关键词命中制造假绿。支持 schema 的文件名不由客户端另造,固定根模型之外沿真实 `$ref` 闭包验证结构;YAML 静态层只证明限定块内的路径、operationId 和完整 component 引用闭包与 JSON 一致,字段语义仍必须在后端同版本导出后逐项复核。
### 21.10 明确删除的旧路径
同一迁移中删除:页面内置运行时 members、递归 `children/spouses`、单一 `parentId``fatherId/motherId``parentId` 双读、`person.id || person.personId`、API 或 fixture 的 `x/y`、数字 int64 ID、默认“族人/主支”语义兜底、顶层 `.map(toTreeNode)`、全量 CSS Grid track、多 `<view>` 拼线、Canvas 线叠 DOM 节点、选中态改尺寸和全局 `NODE_HALF_HEIGHT`
@@ -830,14 +838,202 @@ PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relatio
性能门槛:单窗口不超过 500 人;`dataReady` 定义为规范图完成校验的时间点,`interactive` 定义为首帧绘制结束且命中索引可用,二者间隔不超过 800ms;输入到反馈 p95 不超过 100ms;持续手势帧耗时 p95 不超过 32ms;不连续出现两帧超过 100ms;20 次聚合跳转与搜索后驻留内存相对稳定态增长不超过 15%。每项 MuMu 时延指标至少采样 30 次并按 nearest-rank 计算 p95;手势帧由 renderjs 的 `requestAnimationFrame` 记录,输入延迟从视图层触摸时间戳量到下一完成帧;内存先预热 3 轮,再在相同空闲点比较 20 轮。不把全 App 冷启动混进 T01 门槛。未达到门槛时缩小窗口或进入列表,不能提高上限掩盖问题。
## 二十二、当前精确执行顺序
## 二十二、认证、TAC 与可访问安全设计
### 22.1 唯一所有者
- `utils/auth-verification.js` 唯一拥有 `APP_SMS_LOGIN/APP_REGISTER/APP_FORGOT_PASSWORD` 三个认证场景、4 位短信码和 require/verify 响应边界。
- `components/TacVerification.vue` 唯一拥有 TAC 浮层、renderjs 加载、可见生命周期、焦点和返回行为;页面不得直接操作供应商全局对象。
- `static/tac/js/jiapu-tac-adapter.js` 唯一拥有 TianAi challenge、proof 与 verify payload 映射。后端提供的 `tac.css/tac.min.js/icon.png/dun.jpeg` 保持原字节,由专项哈希合同保护。
- `utils/api.js` 唯一拥有已审查请求的 HTTP 200 严格 envelope、15 秒超时、RequestTask 中止和错误分类,并拥有认证密码摘要、`validToken` 发码、`AppLoginVo.access_token` 会话写入及反馈 wire payload。页面不复制 header、地址、响应兼容、token 读取或请求控制器。
### 22.2 页面与状态机
A01 默认短信登录;密码登录因为服务端请求体无法消费 TAC 票据而保持可见但不可用,微信登录也不得以视觉入口冒充已接通。A01/A04/A05 的发码顺序固定为:本地字段和协议校验 → `/captcha/require` → 严格 TAC challenge/verify → 取得服务端 `validToken``/genealogy/app/auth/sms/code`。验证码精确 4 位,注册或重设提交只消费短信码,不再重复 TAC。手机号变化、刷新 challenge、切换验证方法、返回或卸载都使旧上下文失效;超时、空响应、非 JSON、重复回调和迟到回调失败关闭并保留表单。短信发送与找回密码返回 `RVoid`,其 schema 未把 `data` 列为必填;客户端仍强制 HTTP 200 和整数成功 `code`,但把省略 `data``data:null` 都归一为 `null`,不把这一合法空响应误判为失败。有实体的 challenge、登录和注册响应继续要求 `data`
短信状态覆盖可发送、验证中、发送中、60 秒倒计时、失败重试、到期、手机号变更和重复点击。客户端倒计时不是服务端限流证据;最终仍须用真实服务验证手机号/IP/设备/租户限流、前后台恢复、过期和并发重放。注册成功只读取 `RAppLoginVo → AppLoginVo.access_token`,保存会话后直接进入 G01;找回成功返回 A01,不自动登录。
### 22.3 后端验证中心合同
`VerificationCenter` 是唯一授权 owner,供应商只提供 evidence,不直接签发或消费短信票据。`/captcha/require` 建立绑定 tenant、client、scene、规范化 subject 和风险策略版本的 `verificationSessionId``required=false` 也直接签发供指定 audience 使用的一次性 grant,不能让短信端点出现无票据分支。同一 session 只允许一个活动 challenge;刷新或切换方法使旧 challenge 失效但不清零累计失败。
`/captcha/verify` 的请求以 evidence/provider 为 discriminator 使用 `oneOf`,根和每个 payload 都 `additionalProperties:false`。provider、type、scene 和 subject 以服务端 challenge 记录为真,客户端字段不能改变绑定。只有供应商 evidence 与本地风险策略共同通过才签发 opaque `validToken`;票据绑定 session、tenant、client、scene、subject、challenge、method、assurance、audience 和过期时间。`/sms/code` 在同一事务中完成 `ISSUED → CONSUMED` 与唯一短信 outbox 创建;同一幂等键返回原结果,不同键或并发重放不能产生第二条短信任务。
当前后端门禁固定为:`API-AUTH-TAC-001` 补齐密码登录票据;`API-AUTH-TAC-002` 修复真实 challenge 空 500 与错误 envelope`API-AUTH-TAC-003` 关闭验证 schema 并建立 discriminator`API-AUTH-TAC-004` 落实 session、多方法、`required=false` 票据和原子消费。`AUTH-TAC-OPENAPI-CONTRACT BLOCKED` 解除前,`runtimeConfig.mode` 必须保持 `mock`
### 22.4 不降风控的可访问路径
TianAi 指针滑块不能因外层 dialog 可聚焦就被宣称为 TalkBack 或键盘可完成;不得生成固定键盘轨迹,也不得检测辅助技术后免验证。不存在 `accessibility=true``skipCaptcha`、供应商故障放行或客服直接发短信等旁路。
三人交叉质询后的统一 P0 是 provider-neutral 验证中心与可恢复的文字/中继 `MANUAL_REVIEW`。文字渠道只是可访问通信媒介,各 scene 仍有独立身份或号码控制证据。案件继承且不可改写原 session 的 subject/scene,具备去重、RBAC、主体/IP/设备/审核员限额、完整审计、服务时段、容量和 SLA;高风险找回或换号双人复核。坐席只提交决定,验证中心才可签票;异步案件和批准授权在合理期限内可恢复,用户重新进入原流程时才激活短时 token,避免在通知前过期,也不得要求残障证明。
中国大陆非交互风控供应商只进入限时 POC;必须在真实 UniApp Android WebView 中证明 TalkBack、外接键盘、Switch Access、弱网、超时、异常/重放票据、误杀和攻击拦截指标,达标后才可成为默认自动路径,不能预先宣称符合无障碍标准。音频验证码只作为另一个 POC 候选,必须验证可懂度、听障覆盖与 ASR 对抗,不能单独上线或成为唯一替代。设备断言只有在同一 subject 的已认证会话绑定硬件保护私钥、服务端 nonce、RP/App 绑定、`userVerification=required`、短时单次、防重放并检查撤销时才可独立放行;普通设备指纹、完整性或仅 user-presence 只能作为风险信号,注册和未绑定设备不得使用。
客户端现有壳层只完成 dialog 命名、说明关联、初始聚焦、Tab 圈定、Escape/Android 返回、焦点恢复、原生刷新/关闭、48px 目标和小视口滚动。最终必须在 MuMu 用 TalkBack、外接键盘与非拖动路径完成 A01/A04/A05;证据缺失时 `ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED` 必须保持红灯。
## 二十三、领域上下文与反馈提交设计
`utils/genealogy-context.js` 是当前家谱词法 ID 与失效 tombstone 的唯一持久 owner`utils/session.js` 是账号令牌边界。只在首次且没有历史上下文或失效标记时自动选择首个可用家谱;调用方给定的新列表不再包含历史 ID,或显式目标不可用时,必须清理 ID、写入 tombstone 并让用户明确重选,后续重载不能用列表第一项继续渲染。令牌损坏、退出和账号切换都同步清理 ID 与标记;导航一次性结果不得承担领域持久化。实际后台撤权仍需 workspace 在 onShow 或失效事件中重新取得服务端列表,当前基础不能被表述为实时权限刷新已完成。
M07 的唯一接口 owner 是 `appApi.submitFeedback`。请求只允许 `feedbackContent/feedbackType/contactInfo` 三个普通字符串数据字段;内容必填,类型和联系方式可选且无客户端枚举映射;请求选项不能覆盖认证要求。remote 只接受 `POST /genealogy/app/feedback` 的 HTTP 200 与整数成功 `code`,反馈响应未声明 `data` 必填且页面不消费实体;mock 必须抛 `WRITE_UNAVAILABLE`。页面提交中锁定表单并捕获规范快照;成功和 uncertain 回流都再次核对当前快照,保留回执或未知结果并阻止原样重复写,迟到输入保持为尚未提交的新内容。超时、断网、意外 2xx/3xx、HTTP 408/5xx 或响应失真进入 `uncertain`,只有确定拒绝允许重试。本地不持久保存反馈原文或哈希 tombstone;跨重启 at-most-once 必须由未来服务端幂等键或状态查询合同解决,不能以隐私数据、无期限锁或伪保证替代。
### 23.1 家谱工作区只读合同
家谱工作区第一批唯一选择 `GET /genealogy/app/genealogies/mine` 持有当前账号可访问集合,选择 `GET /genealogy/app/genealogies/{genealogyId}/overview` 持有 G05 展示;语义重复的 `GET /{genealogyId}` 不同时接入。页面不把 fixture 与远端事实混用,也不在两个详情响应之间择优补字段。
线上 `AppGenealogyVo.genealogyId` 当前是 JSON `integer/int64`。最大合法 int64 经 JavaScript JSON 解析后会不可逆失真,事后 `String()` 无法恢复,因此响应身份必须改为非空词法字符串;URL path 在 wire 上本是文本,手写客户端可保持词法字符串,不机械把 path 声明本身当成同一阻塞。仅拒绝 unsafe number 可以失败关闭,却会使 OpenAPI 合法用户永久不可用,只能用于关闭功能开关的预研,不能作为正式兼容方案。
首批最小实体只消费 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`。两层响应 envelope 的 `code/data` 与这些实体字段必须进入 `required`;ID、名称为非空字符串,三项 capability 为布尔值,`roleType` 提供至少两个稳定非空枚举供 G01 分组和标签。其他字段保持可选:地点、堂号、人数、简介等有值才展示;未知额外响应字段允许忽略。若产品坚持保留当前 fixture 的来源、管理者、认证、始祖、上级谱、支系、更新时间和激活人数,则后端必须另补合同;首批不为复刻 mock 强制 22 个字段全部必填。
`canView` 是进入 `availableIds` 的授权投影,不能从 `roleType/status/memberStatus` 猜值;后端也可以改为能够由自动化证明“`/mine` 只返回当前仍可查看 ACTIVE 成员”的等价合同。G01 仅在新列表成功校验后 reconcile;网络、超时、5xx 和畸形响应保留旧现场并显示错误,明确无权、撤权或不存在才写 tombstone。G05 每次加载先清空旧数据、取消迟到请求,只消费 `/overview`;能力缺失按最低权限处理,不回退本地角色。
当前线上 OpenAPI 没有有效 security 声明且媒体写作 `*/*`,但无令牌实测三条读取均被拒绝、实际 Content-Type 为 JSON,因此两项是必须修复的发布文档质量问题,不单独声称已经公开泄漏。真正发布门禁还包括有效令牌下的无凭证、跨账号、撤权、删除、401/403/404/5xx、畸形 JSON 与取消反例。当前部署使用 HTTP 200+业务 `code=401`;后端可以保留业务码承载或改为规范 HTTP 状态,但文档、运行时 validator 与部署行为必须一致。
`tests/genealogy-workspace-openapi-contract.ps1` 只读取受保护的同版本 JSON/YAML,固定上述响应身份、typed envelope、required 与 capability/role 边界;当前旧双导出与线上文档都不能通过。门禁关闭前不建立 G01/G05 专属字段 adapter,不让客户端成为第二份猜测合同。问题固定为 `API-GENEALOGY-WORKSPACE-001` 无损身份与最小 schema 闭包、`API-GENEALOGY-WORKSPACE-002` 可访问集合与能力投影、`API-GENEALOGY-WORKSPACE-003` 错误语义及对象级授权行为。
### 23.2 M06 帮助内容只读合同
M06 第一批唯一选择 `GET /genealogy/app/help-articles` 持有完整 FAQ 列表。线上 `HelpArticleVo` 已同时包含 `helpCategory/helpTitle/helpContent`,所以单页手风琴不再调用 `/{helpId}`;详情端点只保留为未来文章深链或其他 surface 的候选,不进入当前页面。这样当前页面不读取、比较、缓存、序列化或回传 `helpId`,线上 JSON `integer/int64` 虽仍是未来详情债务,却不会参与 M06 身份或行为。
成功响应必须由 `RListHelpArticleVo` 唯一拥有,并把 `code/data` 设为 required`data` 是允许为空的 `HelpArticleVo[]`。每行只要求 required、非空的 `helpCategory/helpTitle/helpContent`:分类是可直接展示的标签,正文首版明确为纯文本,数组只含当前可发布文章且顺序就是展示顺序。分类 enum、详情 ID、封面、浏览量、状态和排序字段都不由客户端消费;“全部”是唯一客户端分类值。富文本、Markdown、图片和文章详情只有在另立格式与内容安全合同时才可进入。
adapter 必须使用 allowlist 投影为 `{category,title,content}`,不得 spread 原对象。每次合法响应先按原始顺序分配仅限当前 generation 的 ordinal 展示键,再进行搜索和分类;筛选后的 index 绝不能成为 key。搜索、分类、刷新或原子替换列表前清空展开项;唯一列表 controller 与 generation 共同拒绝取消后的迟到响应。重复标题或正文不会产生 key 冲突,也不能据此生成持久身份。
页面状态固定为加载、正常、服务端空列表、搜索/筛选无结果、失败可重试和认证失效;失败不得回退成本地五条内容并冒充线上成功。搜索只匹配标题与纯文本正文,分类从返回标签按首次出现顺序动态去重。分类与标题使用原生按钮语义并满足至少 44dp 触控目标;分类补 `aria-pressed`,问题补 `aria-expanded/aria-controls`,加载、计数、失败和展开状态提供适当播报。最终系统字号、TalkBack、焦点和视觉只能在 MuMu 验收。
当前受保护 JSON/YAML 仍把列表写成通用 `ListResult/RList`;线上虽已出现 `RListHelpArticleVo/HelpArticleVo`,两者仍无 `required`,正文格式、仅发布与排序保证也未定义。匿名实测列表、带分类列表和详情均为 HTTP 200+`{code:401,data:null}`,而线上文档声明 HTTP 401 string 且 operation 没有有效 security。后端可以选择规范 HTTP 401 或稳定业务 401,但文档、部署和 validator 必须一致。`tests/help-center-openapi-contract.ps1` 当前输出 `HELP-CENTER-OPENAPI-CONTRACT BLOCKED`;问题固定为 `API-M06-001` 专用响应与最小 required 闭包、`API-M06-002` 纯文本/发布范围/顺序语义、`API-M06-003` 认证与错误承载一致性。门禁通过前不写 live-only adapter。
### 23.3 个人资料只读合同
`GET /genealogy/app/auth/profile` 是 M01 身份卡、M02 表单初值和 M03 绑定手机号展示的唯一 wire owner。首批不消费 `userId/avatar/status/tenantId/userNo/sex/birthday/registerSource/loginIp/loginDate/clientKey/deviceType`;数字 ID 与状态字典因此不构成本批门禁。M01 当前显示的“创建者”是家谱级角色,不属于账号 profile,必须删除而不是向后端索要错误字段。
成功响应固定为 `RAppProfileVo → AppProfileVo`envelope 的 `code/data` required。wire 实体仅把 `phone` 设为 required,并要求 canonical `^1[3-9]\d{9}$``nickName/realName/email` 可选,省略是唯一“未设置”形态,出现则拒绝 null、空串和边界空白,姓名为 1—30 字符,邮箱为有效格式且 1—100 字符。把四项全部 required 会让没有实名或邮箱的合法旧账号拖垮 M01/M03;省略可选字段后由 adapter 统一为空串,不是旧字段兼容或双模型。GET 只定义读取;未来 PUT 必须另证省略字段是否保持原值并只发送 dirty fields,不能在本批预判。
唯一 profile normalizer 在 mock 与 remote 中输出固定 `{maskedPhone,phoneAccessibleLabel,nickName,realName,email}`。原始手机号只在函数局部完成格式校验和掩码,页面、缓存、错误详情、日志与路由不得出现明文;可选字段只有真正缺席才规范成空串,出现但非法时整份响应失败。所有其他响应字段使用 allowlist 丢弃。`appApi.getProfile` 必须经 `resolveRuntimeMode()` 区分 mock/remote;非法配置失败关闭,remote 走严格 envelope、15 秒超时和 request controller,不以 `hasRemoteConfig() ? remote : fixture` 把错误配置伪装成本地数据。三个页面可以各自读取同一 adapter,但不建立跨会话缓存。
M01 在 `onShow` 刷新并以 controllergeneration 拒绝迟到结果;资料 loading/error 只替换身份卡,服务菜单和底栏不因普通读取失败而消失。旧 `query.state=error` 与“重新查看即 ready”是假状态,接线时删除;通知未读数在通知域接通前不得与真实 profile 混装为线上事实。M02 首次加载成功后再填充三个字段并重设 baseline,加载不能制造脏表单;普通 GET 失败可重试,编辑期间不后台刷新覆盖输入,保存仍明确是本地校验,不能冒充 PUT。M03 只让手机号行局部加载/失败,密码入口不依赖 profile;M05 的当前手机号展示在真正 remote 前也必须消费同一掩码 owner 或隐藏,不能保留 fixture。
账号失效交给会话 owner,普通 network/timeout/5xx/畸形响应只产生可重试读取失败,没有写请求的 uncertain 状态。实施测试必须证明 optional 缺席、出现非法值、明文不泄漏、超大 `userId/avatar` 丢弃、错误配置、取消、迟到回调、M01/M03 局部失败和 M02 baseline。交互同时改为原生按钮,补加载播报、M02 字段错误关联与聚焦、44dp 目标、长昵称换行和装饰图隐藏;最终系统字号、TalkBack、纹理对比和返回流程仍只由 MuMu 验收。
当前受保护双导出仍引用通用 `ObjectResult/RObject`;线上 `RAppProfileVo/AppProfileVo` 没有 required、phone pattern 或姓名/邮箱边界,GET operation 也缺有效 security/clientid。匿名真实响应是 HTTP 200+业务 `code=401`,文档却列 HTTP 401 string。`tests/profile-openapi-contract.ps1` 当前输出 `PROFILE-OPENAPI-CONTRACT BLOCKED`;问题固定为 `API-PROFILE-READ-001` 专用响应与最小字段闭包、`API-PROFILE-READ-002` 可选字段与隐私投影、`API-PROFILE-READ-003` 认证/媒体/错误一致性。门禁通过前不写 live-only adapter。
### 23.4 通知读取与已读状态合同
通知必须分成读取批次和写入批次,不能为了让页面看起来完整而在同一个本地 clone 中伪造读写闭环。读取只由不带 `readStatus` 筛选的 `GET /genealogy/app/notifications``GET /genealogy/app/notifications/unread-count` 持有;前者返回当前账号完整活动通知集合,集合最多 200 条、按最新优先,后者精确统计同一集合中 `readStatus=UNREAD` 的条目。两个独立请求之间若有新消息或多端读状态变化,短暂不相等是合法并发结果,页面分别展示成功响应并在下一次刷新收敛。
读取成功的最小 wire 形状为 `RListNotificationVo → NotificationVo[]``RNotificationUnreadCount → int32`。两层 `code/data` required;计数范围为 0—200。通知首批只 required `noticeTitle/noticeContent/publishTime/readStatus`:标题 1—50,正文 1—1000、完整且为 plain text,时间为带时区的 RFC3339,状态精确枚举 `READ/UNREAD`。客户端不解释 HTML、Markdown、服务端 URL 或 `bizType`,也不从标题、类型或摘要猜业务目标。N01 只将摘要显示限制在最多 160 个 Unicode 字素,N02 必须持有同一响应中的未截断正文。
读取 adapter 首版公开模型固定为 `{snapshotKey,title,content,publishedAt,unread}``snapshotKey` 使用成功响应 generation 与映射前 ordinal 组成的词法 key;裸 ordinal、数组筛选后 index、服务端 int64 ID 和内容哈希都不能成为页面身份。唯一内存快照不可变且不落盘;成功刷新原子替换 generation,退出或账号切换立即清空。N02 在进入时解析并持有该 generation 的完整条目,应用重启、旧 generation、直接构造或未知 key 统一显示“请返回消息中心重新打开”,不能虚构“服务端已删除/已撤回”。
读取批次显式丢弃 `notificationId/genealogyId/senderUserId/senderPhone/bizId/bizType/noticeType` 等服务端字段;N01 的通用审核 CTA、空态 G10 按钮和 N02 目标按钮一并删除。只有后端以后提供闭合的 `bizType → route key+必填词法参数+权限/失效语义` 字典,并为每种目标给出越权、删除和跨谱反例,才能另立目标分流批次;任何原始 URL 都不得执行。M01 与 G01 共同消费未读数 owner,文案只称“未读消息”,可见数大于 99 时显示 `99+`,可访问名称仍包含真实数量;加载或错误只影响各自消息入口,不锁死根页其他功能。
写入批次由 `POST /genealogy/app/notifications/{notificationId}/read``POST /genealogy/app/notifications/read-all` 唯一持有。此时唯一 notification controller 可以从同一个严格列表响应私有保留 `notificationId`,但页面模型、路由、日志和持久缓存仍只见 `snapshotKey`;ID 必须在列表实体与 path 中同为 1—128 位 URL-safe opaque string,禁止 int64 经 JavaScript 解析后再转字符串。该迁移必须原子删除首版“完全丢弃 ID”的内部实现和 N01/N02/M01/G01 所有 fixture 计数与本地 clone 写入口,不保留双 owner。
单条已读和全部已读对当前账号必须幂等,重复调用成功且无重复副作用。认证失效返回稳定 401;不存在与跨账号单条 ID 统一为 404 `NOTIFICATION_NOT_AVAILABLE`,避免泄露实体存在性。read-all 的截止点是服务端接收该请求时当前账号已经存在的活动通知,截止点之后并发到达的消息保持未读;成功后客户端重取列表和未读数,不用本地递减猜结果。超时、断网、HTTP 408/5xx 或畸形响应属于结果未知,依靠幂等重试或重新读取收敛;明确 4xx 才是确定拒绝。
当前受保护双导出把列表返回写成通用 `ListResult/RList`,完全缺少 unread-count 路径与专用通知模型;线上虽出现专用 VO 和四条操作,但 wrapper/VO 无 required`readStatus` 无枚举、内容无长度/纯文本/完整性、列表无容量和顺序,ID 仍是 int64operation 也缺有效 security/clientid。匿名 list/count 实测均为 HTTP 200+业务 `code=401`,与文档 HTTP 401 string 冲突。`tests/notification-read-openapi-contract.ps1``tests/notification-read-state-openapi-contract.ps1` 当前分别输出 `NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED``NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED`;问题固定为 `API-NOTIFICATION-READ-001``003``API-NOTIFICATION-STATE-001``003`。双门禁通过前不写 live-only adapter,也不把本地已读行为称为成功。
### 23.5 M02 个人资料写入合同
M02 继续只使用 `PUT /genealogy/app/auth/profile`,不同时发布 PATCH 或第二写入口。由于页面只拥有 `nickName/realName/email`PUT 必须在 operation 与专用 `AppProfileMergeUpdateBody` 中明确是原子 dirty-only merge,而不是整个 profile replacement:只允许出现 1—3 个真正改变的可编辑字段,出现字段更新、省略字段保持,额外字段关闭;同一 payload 重复设置相同值不能产生重复通知或其他业务副作用。后端若无法证明 presence-aware merge,就必须先替换现有操作,客户端不能从“字段 optional”推断安全。
`nickName` 出现时必须是去边界空白的 1—30 字符,空串和 null 均非法;这允许没有昵称的旧账号只修改其他字段,但不允许把已有昵称删除。`realName/email` 的精确空串只在 request command 中表示清空,省略仍表示保持;非空值分别满足 1—30 和 email 格式/1—100。null、纯空白、边界空白都非法,服务端不以隐式 trim 把空白偷偷解释成 clear。GET 和成功响应中,清空后的可选字段继续以属性省略表达,不能把请求命令形状泄漏回读取模型。
并发只使用一个 opaque `profileVersion` owner。`AppProfileVo.profileVersion` required,值为 1—128 位 URL-safe stringPUT required `If-Match` 携带同一 token,请求 body 不重复版本。后端以当前 principaltenantversion 原子 compare-and-set,成功 200 返回完整 canonical `RAppProfileVo` 与新版本;旧版本返回 409,唯一稳定码固定为 `PROFILE_VERSION_CHANGED`。本项目 T01 已使用 body versionIf-Match409 模式,资料写入沿用同一并发语义;H5 CORS 必须允许 `If-Match`
客户端先依赖 23.3 的 GET 取得 canonical 初值和版本,完成异步回填后建立 baseline。`normalizeProfileUpdate` 只从普通自有数据属性提取脏字段,禁止 getter、symbol、prototype、avatar/sex/birthday 和额外属性;clean 不发请求。提交开始时冻结三个输入并捕获规范快照、版本与 session generation;成功只接受严格 HTTP 200 typed envelope,以返回模型原子回填表单和 baseline。确定的 400/401/409/422 保留草稿并进入对应状态,409 不静默覆盖。
超时、断网、408/5xx、取消或畸形成功响应都是 outcome unknown。客户端不得自动重复 PUT,而先重新 GET:若本次所有脏字段均等于提交值,视为已提交;仍等于旧 baseline 才可由用户重试;出现第三值或版本无法归因则进入 conflict,并保留当前草稿供用户明确选择重新载入或基于最新值继续。页面卸载、退出和账号切换中止等待并清空未持久草稿;RequestTask 取消不证明服务端没有落库,旧 session generation 的迟到响应永远不能污染新账号。
M02 当前把 `currentUser.name` 同时填入昵称和真实姓名,是必须删除的 PII 伪造;500ms 定时器只形成本地预览,不是接口状态。真正实施时状态至少包括 loading、ready/dirty、saving、success、error、uncertain、conflict、auth-expired;保存期间锁定输入和返回,成功重置 dirty,失败保留草稿。头像假按钮、相册权限和 int64 avatar 不混入本批;“邮箱用于接收通知”在验证/送达合同缺失时删除。原生输入和按钮补齐 `aria-invalid/aria-describedby`、错误关联、首错聚焦、busy/status 播报、email 键盘类型与 44dp 目标,最终只由 MuMu 验收。
受保护双导出仍使用旧 `ProfileUpdateBody`,缺 realName/email,示例还出现 schema 外 `regionCode/addressDetail`,成功为 generic `RObject`。线上改为 `AppProfileUpdateBody → RAppProfileVo`,但 body 无 required/minProperties/关闭额外字段/merge/version,响应无 requiredoperation 无 security/clientid,媒体还是 `*/*` 且只列 200/401。`tests/profile-update-openapi-contract.ps1` 当前输出 `PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED`;问题固定为 `API-PROFILE-UPDATE-001` 唯一 merge owner 与字段命令、`002` 版本并发和 409、`003` typed 响应/认证/错误/CORS、`004` 超时对账与账号隔离。读取和写入门禁都通过前不接 M02 真实保存。
### 23.6 M10 当前设备退出合同
`DELETE /genealogy/app/auth/logout` 唯一语义是撤销 Authorization bearer 所属的当前设备 credential family,包括同一登录会话的 refresh 能力(如果未来存在);同账号其他设备保持有效。“退出全部设备”必须是另一条接口、另一层确认和另一份权限合同。200 只在撤销已经传播到所有鉴权节点、旧 access token 不能再访问任何受保护接口且旧 refresh 不能换取新 token 时返回;已经在鉴权前通过的并发业务请求无法回滚,产品文案不得承诺取消所有进行中操作。
DELETE 没有 bodyrequired SaToken 和与 token client 绑定的非空 clientid。为形成唯一幂等成功出口,凡能验证为该 client 历史签发的 active、revoked 或 expired credential,重复调用都返回相同的 HTTP 200 `RVoid`,不产生额外副作用;伪造、格式非法和 client 不匹配才返回 401 `RLogoutRejected`,稳定码只允许 `TOKEN_INVALID/TOKEN_CLIENT_MISMATCH`,它们不是撤销成功。200/401 均为 application/json 且 `Cache-Control: private, no-store`RVoid 的 integer code required,同时声明 400/429/500。
客户端唯一 owner 是服务级 `logoutCoordinator`,而不是 M10、A01 和 session 各存一份。一次同步临界段按顺序:捕获 A 的 token/clientid 和当前 session epoch;通过 session owner 立即 bump epoch 并且只清一次 token、家谱上下文与所有已登记账号缓存;使用显式 A 快照创建不绑定页面 controller 的 DELETE RequestTask;发布非敏感 attempt 并立即 `goRoot(A01)`。该同步段没有 await,创建请求同步失败也不恢复 token。M10 页面永远拿不到 token,请求不会因 M10 卸载或 reLaunch 主动 abort。
异步 callback、catch 和 finally 只能按 attemptId 更新 coordinator,绝不能再次 `session.clear()`;否则 A 的迟到响应会抹掉随后登录的 B。coordinator 的公开内存态固定为 `{attemptId,logoutEpoch,status}`status 为 `pending/confirmed/unconfirmed/not-revoked`,不含 token、header、响应 payload 或服务端消息。最多允许用同一 A 快照做一次同进程短时有界重试;不写 storage、日志或持久队列,也不从 session 重新取 token。
A01 仅在 session 仍为空、epoch 与 logoutEpoch 相等时订阅并原子消费一次状态;B 登录或 epoch 改变后丢弃 A 的迟到提示。进程被杀允许丢失提示,但本地退出已经持久完成。唯一主句始终是“已从本机退出”:pending 补“正在结束服务器会话”,严格 200 为 confirmednetwork/timeout/408/429/5xx、畸形响应或 generic 401 为 unconfirmedtyped 401/400/403 为 not-revoked。所有分支都留在 A01、不恢复 token、不返回 M10、不阻塞重新登录;状态必须可播报且不能只靠短 toast 或颜色。
M10 确认前的取消和 Android 返回只关闭确认层,零请求、零清理;确认后立刻退出可关闭弹层态并防双击,根导航失败时显示“本机已退出”遮罩且禁止返回受保护页面。当前文案“本机保存的密码不会被保留”没有任何密码存储 owner 证据,实施时改为“仅退出此设备,其他设备不受影响”。Mock 或错误 runtime config 也必须完成本机退出,并准确标记远端未确认,不能为了发请求把用户困在本地会话。
当前 M10 只做 `session.clear → close → goRoot(A01)`,本机边界正确但没有远端撤销、重复保护、epoch 或跨根提示;旧静态测试也锁定这条本地序列。受保护双导出有 DELETE、SaToken、clientid 和 200 `RVoid`,但缺范围、幂等、required 和错误;线上只有 200/401 `*/*`,又缺 operation security/clientid。`tests/logout-openapi-contract.ps1` 当前输出 `LOGOUT-OPENAPI-CONTRACT BLOCKED`;问题固定为 `API-LOGOUT-001` 当前凭证族与撤销传播、`API-LOGOUT-002` 幂等成功/拒绝模型、`API-LOGOUT-003` 安全/媒体/no-store/部署反例。门禁通过前不新增 logout API 代码,也不把本机退出写成服务端注销成功。
### 23.7 M04 登录态改密与统一密码凭证合同
当前本地双导出要求 `oldPassword/newPassword` 为 32 个十六进制字符的 MD5,线上 `AppPasswordChangeBody` 也只把它放宽为大小写十六进制;登录、注册和找回同样接受静态摘要。该摘要不是一次性 proof,而是后端登录入口直接接受的密码等价物,一旦从日志、代理、调试记录或终端泄漏即可重放;同时服务端看不到原始长度与 blocklist 命中,无法成为生产策略 owner。新合同不得长期双读 raw/MD5,也不得只改 M04 留下其他入口。A01 登录、A04 注册、A05 找回和 M04 改密必须同一批删除全部 MD5 wire fallback;只在认证 HTTPS 通道提交 raw `writeOnly` 密码,服务端使用独立盐与 Argon2id,无法使用时才采用合规 scrypt/PBKDF2。
密码 schema 只有两个 owner`CurrentPasswordSecret` 原样、不 trim,允许 1—64 Unicode code point 以兼容已有账号;`NewPasswordSecret` 在明确 NFC 规则后为 15—64 Unicode code point,允许空格、Unicode、粘贴和密码管理器,不设置字母/数字/符号组成规则。注册、找回与改密新值共用后者;登录与改密当前值共用前者。服务端在写入前执行常见/泄露密码 blocklist、账号级限速、当前密码重新认证和新旧不同;客户端只镜像即时提示,不能成为可绕过的权威。TAC 是反机器人证据,不代替当前密码;用户要求的登录/注册/找回 TAC 继续由认证合同持有,正常 M04 不另建一套 TAC。
M04 的唯一会话方案是 ALL,而不是返回并安装新 token,也不是保留旧 bearer。严格 HTTP 200 `RVoid` 只能在新 verifier 已持久化、账号 `credentialEpoch` 已原子递增,并且包括请求者在内的所有设备、所有 client 的既有 access/refresh/renewal 会话已跨鉴权节点失效后返回。方案 B 会要求 typed 新 token 和响应丢失恢复协议,当前 RVoid 无法承载;方案 C 会让可能被盗的旧 token 在改密后继续有效,均被否决。两个使用同一旧密码的并发请求必须通过服务端 CAS 至多一笔成功,另一笔返回 typed 409 `CREDENTIAL_VERSION_CONFLICT`
PUT required SaToken、非空 clientid、`application/json` 和关闭额外字段的 `PasswordChangeBody`body 只含 oldPassword/newPasswordconfirm 永不出端。200/400/401/409/422/429/500 都必须是 JSON 且 `Cache-Control: private, no-store`429 带 `Retry-After`。409/422 的 `RPasswordChangeRejected.businessCode` 只允许 `CREDENTIAL_VERSION_CONFLICT/CURRENT_PASSWORD_INCORRECT/NEW_PASSWORD_SAME_AS_CURRENT/PASSWORD_POLICY_VIOLATION`;服务端和网关日志、APM、分析、崩溃记录及错误消息不得含密码、摘要或请求体。
客户端在 dispatch 前由 session owner 持久化唯一非敏感 marker `{sessionEpoch,startedAt}`,不保存 token、密码、摘要、body、重试键或 operation id。明确未写的 400/422/429 原子清 marker 并留页;严格 200、401、409 均清同 epoch 全部账号态并回 A01。network、timeout、取消、畸形 2xx 或 5xx 在发出后属于结果未知,同样清秘密与本机会话、回 A01 并只提示“请尝试使用新密码重新登录”,绝不自动重试或宣称失败。冷启动发现同 epoch marker 时,在任何受保护缓存渲染前先清会话;新登录 bump epoch,旧 marker 和 A 的迟到响应不得清掉 B。无需新增 operation-status,重新登录就是最小对账路径。
页面实施时状态至少是 ready/submitting/known-error/unknown/success:提交中冻结三个输入、显隐控制、按钮和返回;确定字段错误聚焦并关联 `aria-invalid/aria-describedby`,显隐改为可键盘操作的原生按钮与 `aria-pressed`,目标至少 44dp,持续状态可播报。成功与 unknown 的跨根提示不能只靠短 toast。M03“建议定期更新”改为风险触发建议;M04 允许自动填充、粘贴和密码管理器。`tests/password-change-openapi-contract.ps1` 当前输出 `PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED``API-PASSWORD-001``005`、密码登录 TAC、双设备/并发/故障注入和 MuMu 原生矩阵通过前,不修改 M04 的诚实本地预览。
### 23.8 M05 手机号换绑与统一短信码合同
M05 当前只展示脱敏 fixture,以 500ms 定时器验证新手机号和 4 位验证码并明确“不提交服务器”;没有当前密码、TAC、远端请求、会话迁移或结果未知状态。受保护双导出的 PUT body 是 `clientId/phone/smsCode`,线上则只有 `phone/smsCode` 并返回完整 profile;两者都缺 existing-factor 再认证、号码唯一性、会话撤销和 typed 错误。共享发码接口在线上还明确忽略权限。活动 bearer 加攻击者控制的新号验证码因此足以构成账号接管,不能接线。
三人比较了“共享发码 operation 按 scene 条件鉴权”和“专用受保护 operation”。标准 OpenAPI 3.x 无法把 operation-level security 与 body 中的 `sceneCode` 条件绑定;`security: [{}, {SaToken: []}]`、文字说明或 vendor extension 都不能让通用 validator、网关和 SDK 自动拒绝匿名 `APP_PHONE_CHANGE`,会产生 validator 允许而 runtime 拒绝的双合同。唯一方案是 `POST /genealogy/app/auth/phone/sms/code`required SaToken 与非空 clientid,闭合 body 只有 `phone/validToken`scene 在服务端固定,公共 `/auth/sms/code` 的 enum 删除 `APP_PHONE_CHANGE`。两个 operation 只分协议边界,底层仍调用同一 OTP 生成、存储和限流 owner,不复制策略。
最终 PUT 的身份闭环是活动 sessionraw `currentPassword`+新号 OTP。TAC 只防自动化,不能替代既有因子;新号 OTP 只证明新号码控制权。不强制旧号 OTP,因为旧号丢失是正常换绑原因且不会增加独立因子;成功事务改为持久化旧号安全通知 outbox。所有账号若不保证有密码,稳定返回 `STEP_UP_UNAVAILABLE` 并进入独立找回,M05 不得降级为 bearer+新号 OTP。最终 body 精确为 `currentPassword/phone/smsCode`,不含 currentPhone、clientId、validToken、challengeId 或供应商字段;它依赖 M04 先完成 raw-password/慢哈希迁移,当前 MD5 wire 不得复用。
短信码唯一 schema owner 是 `SmsCodeSecret`CSPRNG 生成恰好 6 位 ASCII 十进制字符串,保留前导零,TTL 5 分钟,60 秒重发冷却,最多 5 次失败,单次消费;重发原子废止旧码且不重置累计失败计数。同一 `(account,session/credentialEpoch,tenant,client,scene,newPhone)` 只有一个 active generation,服务端内部 generation 足以消歧,因此最终 PUT 不新增 challengeId/attemptId。A01/A04/A05/M05、注销等活动消费者、短信模板、生成器、OpenAPI、validator、页面和测试必须同版本从 4 位原子迁移为 6 位,禁止兼容双长度。
服务端在同一事务内验证当前密码与 active OTP、执行 `(tenant,canonicalPhone)` 唯一约束、消费 OTP、更新手机号、递增 credentialEpoch、撤销包括当前在内的全部 access/refresh session,并持久化旧号通知 outbox;严格 200 只返回 `RVoid`。两个并发换绑至多一笔成功,另一笔按 credential CAS 无写入。发码 POST 的结果未知不清登录态,但按服务端冷却避免立即轰炸;最终 PUT dispatch 前复用无秘密 `{sessionEpoch,startedAt}` credential marker200、401、409、network/timeout/5xx/畸形响应或进程终止均清同 epoch 本机会话回 A01且不自动重试。
M05 页面实施时状态至少为 ready/tac/sending/code-sent/submitting/known-error/unknown:新号变化作废 TAC 与 OTP,发码后锁定号码,提交中冻结全部字段和返回。当前密码支持粘贴、密码管理器和带 `aria-pressed` 的显隐按钮;手机号与 OTP 使用能保留前导零的文本/电话输入和 numeric inputmode,错误具备 `aria-invalid/aria-describedby` 与首错聚焦,发送按钮为至少 44dp 的原生按钮,倒计时和持续状态可播报但不每秒打断 TalkBack。`tests/phone-change-openapi-contract.ps1` 当前输出 `PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED``API-PHONE-001``005`、M04/认证前置门禁、两设备/OTP/故障注入和 MuMu 矩阵完成前不修改诚实预览。
### 23.9 G03 家谱与始祖原子创建合同
G03 当前在一个页面实例中先收集家谱资料,再录入始祖,两个 320ms 定时器只生成 `local-created-*` 预览;没有真实 API、可信 `regionCode`、上下文安装或 G01/G05 远端回流。受保护双导出的 `GenealogyCreateBody` 和线上 `AppGenealogyCreateBody` 都只创建家谱;通用 `/lineage/persons` 又允许客户端提交代数、父母和账号字段。若按旧接口先建谱再建根,会制造非产品需求的 `ROOT_REQUIRED` 半成品及两次结果未知。三人先讨论了两写恢复,再反向检查是否存在跨库或“保存空谱”的硬约束;当前没有任何证据,因此一致选择原子 bootstrap。只有后端以后证明事务边界不可跨越时才重新立 saga,而不是把补偿复杂度预埋客户端。
唯一写 owner 仍是 `POST /genealogy/app/genealogies`,但旧 `GenealogyCreateBody/AppGenealogyCreateBody` 必须被 `AppGenealogyBootstrapBody` 原子替换,不保留双收。根对象关闭额外字段,只允许 `genealogyName/surname/ancestralHall/regionCode/accessPreset/rootPerson`,前五项除堂号外均必填;`rootPerson` 关闭额外字段,只允许 `name/sex/birthDate/biography` 且 name/sex 必填。服务端固定根人物 `generation=1` 和唯一首根,不接收 `generationName/personNo/appUserId/fatherId/motherId/status``sex` 只允许 `MALE/FEMALE/UNKNOWN`,页面默认 UNKNOWN;生日是本地日 `format: date`,不保留页面武断的 1800 年下限。文本按 Unicode code point、NFC 和无边界空白统一验证,长度沿用页面现有 4/24/12/20/200 上限。
严格 200 前必须在一个事务内完成配额竞争检查、家谱、OWNER 成员关系、唯一一世始祖、READY 状态和幂等成功回执;任一步失败全部回滚,通用人物 POST 只服务 READY 家谱中的普通人物,不能承担 bootstrap。数据库 `bootstrap-root` 标记是根身份唯一权威,不能从 generation=1 猜测:普通人物 POST 即使提交一世也不能新建/替换根;人物 PUT 对根的可编辑白名单精确且只有 `name/sex/birthDate/biography`operation 以 `x-bootstrap-root-editable-fields` 登记这四项并以 `x-bootstrap-root-noneditable-policy=REJECT_422_BOOTSTRAP_ROOT_IMMUTABLE` 登记拒绝策略;任何 status/personStatus、账号绑定、世代、父母、根标记或其他字段即使出现在通用 body 中也必须以 typed 422 拒绝,不能用“值未变化”掩盖越权字段。人物 DELETE 不能删除根,parents mutation 不能给根新增或重挂父母。未 READY 固定 typed 409 `GENEALOGY_NOT_READY`,触碰白名单外根字段或根身份固定 typed 422 `BOOTSTRAP_ROOT_IMMUTABLE`。成功 `GenealogyBootstrapResult` 必填词法字符串 `genealogyId/rootPersonId``setupState=READY``roleType=OWNER``canView=true`;两个 ID 从 JSON 到 storage、context 和 URL 都不得经过 JavaScript Number。同姓、同名和同地区均合法,页面重复提醒只能是建议,后端不得把名称当唯一键。
访问规则的唯一 wire owner 是共享 `GenealogyAccessPreset`,枚举只有 `MEMBER_ONLY/PUBLIC_APPLY`。这不是 G03 私有别名:同一后端版本必须让 APP 家谱读取 `AppGenealogyVo`、bootstrap 创建和 G11 设置更新都引用它,并删除 `visibility/joinMode`、旧 create/update DTO 和 `utils/genealogy-contracts.js` 的远端数字映射;客户端不得长期同时接受新 enum 与旧 pair。设置请求同步收紧为闭合、至少一个脏字段的 `AppGenealogySettingsUpdateBody`,只含 `genealogyName/intro/accessPreset`。本任务只统一 G11 的字段合同,不代表设置写入已经可上线;G11 的 `If-Match`、版本/CAS、权限刷新和结果未知仍须在独立批次建立门禁。在后端合同尚未落地的当前本地预览阶段,现有 fixture 映射暂不改写,也不能冒充生产 wire。
所在地只能从 required SaToken、非空 clientid 的 `GET /genealogy/app/region/search` typed `RegionSelectVo` 选择;同版删除旧公共 `/genealogy/region/search`,不能让 APP 在两条 owner 间漂移。`regionCode/label/selectable` 必填,code 使用共享词法 `GenealogyRegionCode``leaf` 只表示树导航,不能被客户端猜成“可提交”。页面展示 label、只提交 code,最终 POST 在业务事务中再次验证仍可选;不强制县、乡或 leaf,避免无依据排除省市级、历史地域或要求过细隐私位置。搜索加载、无结果、失败、迟到响应和失效选项分别表达,自由文本不得反推 code。
POST required SaToken、非空 clientid 和 `Idempotency-Key``GenealogyBootstrapOperationKey` 精确为 `gcb.{13位毫秒时间}.{22—43位 base64url 随机量}`,随机量必须来自至少 128 位 CSPRNG;`issuedAt` 从 key 提取,`acceptUntil=issuedAt+10 分钟`,一律以服务端时间判定,issuedAt 晚于 serverNow 5 分钟以上固定 400 `OPERATION_KEY_INVALID`。schema 同时用 `x-issued-at-source=KEY_EPOCH_MILLISECONDS``x-accept-window-seconds=600``x-max-future-skew-seconds=300` 机器锁定公式。仍在窗口内的首次请求先用短控制事务按 `(account,tenant,client,path,key)` 唯一 CAS 认领 `PENDING`、canonical digest、fencing lease 和 `resolveBy``resolveBy<=claimedAt+2 分钟`,并以 `x-resolve-from=CLAIMED_AT/x-resolve-sla-seconds=120` 锁定。相同作用域、key 与 canonical body 的已存在操作即使超过 acceptUntil 也可重放同一结果,不同 digest 固定 409 `IDEMPOTENCY_KEY_REUSED`;截止后仍不存在的 key 固定 409 `OPERATION_KEY_EXPIRED`,不得启动工作。
控制事务认领后,业务事务才原子执行配额竞争检查、家谱、OWNER、唯一一世始祖、READY 和 `SUCCEEDED` 回执;任一写点失败全部回滚,再以 fencing token CAS 成不可变 `FAILED_NO_COMMIT`。超出 lease/resolveBy 的 watchdog 也只能用同一 CAS 终结,旧 worker 失去 fencing 后不得提交,保证 PENDING 最迟 2 分钟内收敛。严格 200 只表示业务事务与成功回执均已提交;SUCCEEDED 防重记录至少覆盖实体生命周期,FAILED_NO_COMMIT 记录至少保留 30 天。
为避免把始祖姓名、生日和生平写入 UniApp 普通持久存储,崩溃恢复选择受鉴权 `GET /genealogy/app/genealogy-bootstrap-operations/{operationKey}`,不持久化完整 body。状态响应必须用带显式 mapping 的 discriminator `oneOf` 精确区分 `PENDING{resolveBy,retryAfterSeconds}``SUCCEEDED{result}``FAILED_NO_COMMIT`,三个 status 均是单值 stringPENDING 的 `retryAfterSeconds` 为 1—30,避免给所有 200 错加 Retry-After。`x-state-transitions` 只登记 `ABSENT→PENDING``PENDING→SUCCEEDED/FAILED_NO_COMMIT`,两个终态无出边,`x-terminal-immutable=true`FAILED schema 同时固定 `x-domain-effects=NONE``x-quota-consumed=false`,机器保证家谱、OWNER、始祖和配额均未提交。SUCCEEDED 返回与 POST 相同严格回执。GET 只允许 operationKey path 与 clientid header、不得有 request body,是纯读且绝不创建墓碑或推进状态:acceptUntil 前无记录返回 typed 404 `BOOTSTRAP_OPERATION_NOT_AVAILABLE`,同时返回服务端 acceptUntil 与 Retry-After;截止后无记录则按 key 时间可计算地返回 FAILED_NO_COMMIT 而不写库,迟到 POST 仍永久拒绝。跨 account、tenant 或 client 查询统一 404且不泄漏存在性,禁止额外 403 分叉。操作记录不进 `/mine`、不占业务配额,也不返回请求 body 或人物 PII;GET 零写入和 FAILED 零领域提交最终仍须数据库观测测试证明,OpenAPI 描述与扩展不能冒充实现证据。
客户端唯一 coordinator 在最终按钮校验全部字段后才冻结 canonical snapshot、生成 key,并在 dispatch 前持久化 `{sessionEpoch,operationKey,startedAt}`;第一步 CTA 只写“下一步:录入首代”,绝不发网络请求。客户端本地校验失败不写 marker;请求对象创建失败且能证明零发出时清 marker。服务端在 claim 前返回的 400/401/403 均保证无 operation,清 marker,其中 401 还必须清会话并回登录,403 失败关闭。409 `IDEMPOTENCY_KEY_REUSED` 视为合同/篡改冲突,marker 转入 fatal/quarantined,不查询或安装该 key 的 status、不自动换 key;只有用户看到明确警告并显式放弃后才清 marker、重新开始。创建上限、过期 key 和 422 是确定未提交,可清 marker后修正。429 保持 marker、同 key/body,先查 status 再按 Retry-After 重试;500、网络、超时、408、发出后的取消、任意意外 2xx/3xx、畸形 200 或进程终止均是 unknown,保持 marker并查 status,绝不换 key。status 的截止前 404 保持 key400 表示本地 marker 已损坏并安全清除,401 执行会话失效,429/500、网络、取消、意外状态和畸形 200 均保持并退避;只有 FAILED_NO_COMMIT 或可证明未认领的确定失败才清 marker。
当前进程 unknown 可用相同 key 与内存 snapshot 重放;进程重启只查状态,不从 `/mine` 按名字猜测,也不自动生成新 key。SUCCEEDED 的唯一次序固定为:持久化 committed receipt → 失效或定点更新 `/mine` 缓存 → 安装 genealogy context → 进入 G05context 或导航失败只重试本地安装/导航,绝不重发创建。退出、换账号和 epoch 变化必须隔离旧操作;任何日志、路由、marker、遥测和崩溃记录都不得包含表单正文。
页面实施必须同步删除伪提交 owner 与 mock fixture mutation,覆盖地区加载、确定拒绝、fatal/quarantined、PENDING、结果未知、已提交待进入、上下文失败和导航失败。文本标签与 input 建立关系;访问预设使用真正 radio/`aria-checked`;按钮至少 44dp,提交中冻结字段和返回;错误具备 `aria-invalid/aria-describedby`、首错聚焦和持续状态播报;重复提醒与成功/放弃层复用具备焦点圈定和恢复的 `AppDialog`。当前源码仍有 clickable view、54rpx 目标、23rpx 选项、无关联错误和 UTC 日期上限,这些只登记为实施项;没有 MuMu 证据前不得宣称视觉或 TalkBack 上线。后端门禁先执行无第三方依赖的 `tests/openapi-yaml-json-parity-runtime-smoke.js`,以无损任意精度数字、严格 YAML mapping 分隔符和完整对象结构深比较 JSON/YAML,确保不安全整数差一也不能假绿,再由 `tests/g03-bootstrap-openapi-contract.ps1` 检查唯一 JSON 语义、组合 schema 内旧字段、同 scope 参数重复、根 PUT 精确白名单和结构化不可变终态;客户端门禁 `tests/g03-bootstrap-client-release-gate.ps1` 实际执行依赖注入的 marker/status 状态机测试,不再用 helper 名称顺序冒充行为证据。两项只是 G03 自身门禁;真实开放还必须同时通过家谱工作区读取门禁、聚焦测试全量回归与 MuMu 原生交互/无障碍验收,任一红灯都不得开放真实创建。
## 二十四、当前精确执行顺序
后续不再按页面样式迁移重做,而按以下独立阶段执行:
1. 导航栈语义统一:先测试和共享所有者,再按认证、G、T、F、R、N/M 小批迁移,每批 MuMu 闭环
2. T01 大规模世系树:先向后端提交新图合同,等待 Apifox 更新并验证,再按图合同、布局、Scene、Canvas页面交互分批实施。
3. 短信验证码完整状态机
4. 跨页面领域数据持久化
5. 全局文字层级与无障碍第二轮
1. 导航栈语义统一:静态任务 1—10 已完成,MuMu 流程矩阵待执行
2. T01 大规模世系树:设计与 OpenAPI 红灯已完成;等待后端新图合同后按规范化、布局、Scene、Canvas页面交互分批实施。
3. TAC 认证:客户端与静态/纯运行时门禁已完成;等待后端关闭 `API-AUTH-TAC-001``004`,随后执行真实环境与 Android 发布门禁
4. 领域上下文基础与 M07 真实反馈客户端已完成;家谱工作区三人审查与 OpenAPI 红灯已完成,等待后端关闭 `API-GENEALOGY-WORKSPACE-001``003` 后再接 G01/G05
5. G03 原子 bootstrap 三人审查与 OpenAPI 红灯已完成;等待后端关闭 `API-G03-001``005` 后,再实现严格 coordinator、无 PII 状态恢复、地区选择、context/G05/G01 闭环和独立 MuMu 矩阵
6. M06 帮助三人审查与 list-only OpenAPI 红灯已完成;等待后端关闭 `API-M06-001``003` 后再接严格 adapter。等待期间继续下一个无依赖只读域,不与验证码、T01 或支付混合。
7. M01/M02/M03 个人资料读取三人审查与 OpenAPI 红灯已完成;等待后端关闭 `API-PROFILE-READ-001``003` 后再接掩码 adapter,不与资料写入混合。
8. 通知读取和已读写入已分别完成三人审查与 OpenAPI 红灯;后端先关闭读取合同后实现无 ID 内存快照,再在独立写批次私有迁移字符串 ID、删除伪本地读状态并验证并发收敛。
9. M02 资料写入三人审查与 OpenAPI 红灯已完成;等待读取与 `API-PROFILE-UPDATE-001``004` 同时关闭后,再分 normalizer、API 和页面状态实现。
10. M10 当前设备退出三人审查与 OpenAPI 红灯已完成;等待后端关闭 `API-LOGOUT-001``003` 后,再实现 session epoch、logoutCoordinator 和 A01 一次性状态。
11. M04 登录态改密与四条密码 wire 三人审查、OpenAPI 红灯已完成;等待后端关闭 `API-PASSWORD-001``005` 后,原子迁移共享策略、MD5 消费者、session marker、页面状态机与全设备撤销。
12. M05 手机号换绑与全活动 OTP 三人审查、OpenAPI 红灯已完成;等待后端关闭 `API-PHONE-001``005` 且 M04/认证前置门禁通过后,再原子迁移六位码、专用受保护发码、最终 PUT 与 credential marker。等待期间转向 G/F/R,不混入本批。
13. 全局文字层级与无障碍第二轮。
14. 生产配置、隐私权限、可观测性、构建发布、升级回滚与 MuMu 全流程终审。
T01 必需的线性目录和 44dp 控件随 T01 一起完成;不借此提前改造全项目文字体系。任何阶段完成前都不开始下一阶段的业务代码
T01 必需的线性目录和 44dp 控件随 T01 一起完成;认证浮层的壳层无障碍不扩张为全项目已通过。外部门禁阻塞时继续无依赖批次,但任何真实成功、接口兼容或视觉通过都必须有对应证据