Files
jiapuapp/docs/家谱项目全量治理设计.md
T
2026-07-23 08:24:07 +08:00

1040 lines
118 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 家谱项目全量治理设计
> 日期:2026-07-22
> 状态:阶段 0 已完成;导航任务 1—10 的静态实施和零债务门禁已经完成;A01/A04/A05 TAC 客户端与 M07 反馈客户端已经完成;MuMu 原生矩阵待执行;T01、认证、家谱工作区、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑后端合同均处于硬门禁红灯
> 适用范围:当前 UniApp 家谱项目、根目录 OpenAPI 文档、全部活动页面、共享组件、测试、正式资产与后续真实接口接入
## 一、背景
项目第一轮国风视觉与响应式适配已经完成,并形成了可继续工作的页面基线。但此前验收包含用户抽查和代理逐页审核,不能据此认定所有页面、所有真实数据和所有跨页流程都已经最终正确。
当前根目录已经取得由 Apifox 导出的两份接口文档:
- `APP.openapi.json`
- `APP.openapi.yaml`
两份文件来自同一个 Apifox 项目,只是表示格式不同。接口由后端根据此前思维导图设计,并非根据当前页面逐项编写,因此可能混入 PC 接口,也可能存在 App 接口缺失、字段不足、状态不完整或页面与接口目标不一致等问题。
后续工作不能简单地让页面迁就现有接口,也不能默认现有页面天然正确。必须从真实业务目标出发,对页面、接口、功能、交互、导航、视觉和数据状态进行联合治理。
## 二、治理目标
本轮治理需要同时达到以下目标:
1. 清理失效、重复、冲突或确实无用的文档、测试、资产和生成物,使工作区只保留当前有效内容。
2. 区分 App 接口、PC 接口、可共用接口和缺失接口,严格分析 OpenAPI 合同。
3. 建立页面、用户操作、业务状态、接口和数据结果之间的精确映射。
4. 重新评估全部活动页面的视觉完整性与美观性,不把“没有溢出”当成视觉完成。
5. 检查功能、权限、交互、导航、失败处理、重复操作和跨页数据一致性。
6. 按业务域向后端持续提交可直接修改 Apifox 的完整接口问题单。
7. 采用测试先行和小批次实施,每批均在 MuMu Android 中完成真实流程和视觉验收。
8. 建立中文文档、中文进度和详细中文代码注释规范,使后续接手者能够明确知道当前做到哪一步、每一步在做什么。
## 三、明确不做的事情
- 不重新推翻已经建立的国风设计方向。
- 不因为旧页面曾经通过就忽略新发现的可复现问题。
- 不因为现有接口已经写好就强迫页面接受错误业务。
- 不因为现有页面已经完成就无条件要求后端照搬。
- 不一次混合实施多个业务域。
- 不用浏览器截图代替 MuMu 视觉验收。
- 不制造临时假接口、双读取兼容路径或伪持久化成功。
- 不通过删除失败测试来制造全量通过。
- 不执行 Git add、commit、push、restore、checkout 或 reset 等 Git 变更操作。
## 四、权威顺序与唯一所有者
发生冲突时,采用以下权威顺序:
1. 用户确认的真实业务目标。
2. 三人共同推演并验证的完整用户流程。
3. 后端在 Apifox 中确认的接口合同。
4. 页面、共享组件和运行时代码。
5. 自动化测试与 MuMu 当前轮验收证据。
6. 当前 OpenAPI 导出、现有 mock 和历史资料。
关键合同的唯一所有者如下:
- 接口源头:后端维护的 Apifox 项目。
- 自动分析格式:`APP.openapi.json`
- 人工阅读与跨工具格式:`APP.openapi.yaml`
- 九宫格切片与可变高度装饰面:`styles/adaptive-frame-profiles.scss`
- 密码策略:`utils/validation.js`
- 响应式覆盖范围:`tests/responsive-layout-coverage.json`
- 固定尺寸例外:`tests/responsive-layout-allowlist.json`
- 项目当前状态入口:`docs/项目当前总览.md`
- 页面与接口关系:`docs/接口与页面映射总表.md`
`APP.openapi.json``APP.openapi.yaml` 不是两个独立接口所有者,而是同一次 Apifox 导出的两个产物。两份文件不得分别手工维护,必须通过自动合同验证语义一致。
## 五、三人联合治理机制
### 5.1 人员组成
总共三人:主代理和两位评审者。三个人都必须完整审查同一个业务域,不能把页面、接口和交互割裂分工,也不能出现“该问题不属于我”的责任边界。
三个人均需覆盖:
- 页面美观与样式完整性;
- 功能和业务严谨性;
- App、PC 接口分类与合同完整性;
- 交互、导航、页面状态和跨页数据;
- 真实数据进入后的布局、反馈与业务结果。
### 5.2 四轮互补流程
第一轮为独立首审。三个人在不查看另外两人初步结论的前提下,分别阅读页面源码、测试、OpenAPI 和业务说明,并审查同一组 MuMu 证据,独立形成完整判断,避免首个意见造成锚定。
第二轮为交叉补漏。三份结果公开后,每个人逐条检查另外两份结论,判断是否漏掉页面状态、接口边界、视觉问题、业务风险或连锁影响。
第三轮为反向质询。合并后的结论重新交给三个人,每个人主动尝试证明结论可能错误,包括检查接口是否真的缺失、页面问题是否由测试数据造成、视觉方案在边界状态下是否仍成立,以及导航和数据刷新是否在重复进入后仍然正确。
第四轮为共同收敛。每个问题必须带有页面、状态、接口、代码位置、MuMu 证据、影响等级和处理建议。不能只记录“感觉不好看”“接口可能有问题”等不可验证意见。
三人意见不能简单少数服从多数。任意一人提出有证据的疑点时,必须继续调查;只有证据充分后才能形成共同结论。
### 5.3 共享工作区规则
- 审查阶段三个人只读,不边看边改。
- MuMu 由主代理统一导航、切换状态和采集截图,避免多人同时操作造成状态干扰。
- 两位评审者审查同一份已经打开确认的 MuMu 截图。
- 评审者可以要求补拍状态,主代理补充证据后三人重新判断。
- 实施阶段由主代理统一修改工作区,两位评审者负责复核方案、变更、测试与 MuMu 证据。
## 六、联合评审模型
每个页面使用统一中文评审结构,至少记录:
- 页面编号、页面路由和业务目标;
- 用户身份、角色、权限和前置条件;
- 进入来源、返回目标和流程终点;
- 页面全部适用状态;
- 页面全部用户操作;
- 每个操作成功、失败、取消后的页面和数据结果;
- 对应 App 接口和关联 PC 接口;
- 缺失、冲突或不完整的接口;
- 样式、美观、功能、交互和导航问题;
- 真实数据和边界数据风险;
- MuMu、源码和 OpenAPI 证据;
- 三人初审、补漏、质询和最终结论。
每个用户功能都按以下顺序推演:
```text
进入
→ 输入或选择
→ 本地校验
→ 提交锁定
→ 发起请求
→ 成功或失败
→ 页面反馈
→ 数据刷新
→ 返回或流程结束
→ 再次进入
```
同时检查取消、Android 返回键、重复点击、网络失败、权限变化、目标数据过期和跨页状态一致性。
## 七、视觉与美观验收标准
第一轮样式审核是继续工作的基线,不是永远不能重开的封印。包括 G01—G10 在内,只要三人通过页面、接口、流程或 MuMu 证据发现明确且可复现的问题,就允许重新打开处理。
视觉验收不能只检查溢出、遮挡和响应式,还必须检查:
- 页面整体是否有协调的视觉中心和信息层级;
- 标题、正文、辅助信息、错误提示和操作区是否层级清楚;
- 字号、字重、颜色、间距、对齐、留白和视觉重心是否协调;
- 卡片、输入框、按钮、弹窗、列表项、图标、背景、边框和装饰是否完整;
- 默认、按下、选中、禁用、加载、空、错误和成功状态是否都有正式设计;
- 素材是否缺失、模糊、变形、错误裁切或风格不一致;
- 页面是否过空、过密、装饰不足或装饰堆叠;
- 同一模块与跨模块是否保持统一国风语言;
- 真实长短数据、键盘、弹窗和滚动状态下是否仍然美观。
缺少皮肤、页面明显失衡、视觉层级混乱、素材错误或半成品表现不能统一降级为低优先级体验问题。
现有国风设计体系是新增或修复样式的基础:
1. 优先复用现有共享组件和正式资产。
2. 能通过共享规则解决时,不在单页复制样式或素材。
3. 需要扩展时,优先扩展现有视觉体系。
4. 确实没有合适素材时,按真实槽位尺寸、色彩、纹理和透明要求制作正式资产。
5. 如果现有页面结构经真实流程与 MuMu 证据证明明显不合理,可以重新设计该页面,但必须保持产品信息与交互合同、复用现有设计语言,并先更新设计和测试,不能写单设备补丁。
6. 不用 emoji、文字符号、临时 SVG、CSS 拼图或占位框冒充正式资产。
7. 新资产必须在 MuMu 中验证清晰度、比例、裁切和状态完整性。
## 八、接口治理与后端协作
### 8.1 接口分类
每个接口标记为以下一种类型:
- App 可直接使用;
- PC 专用,不进入 App
- PC 与 App 可能共用,需要后端确认;
- App 接口存在但合同不完整;
- 页面需要但 App 接口缺失;
- 接口存在但当前页面没有合理用途;
- 接口与页面都需要重新定义。
接口审查至少覆盖鉴权、请求参数、响应字段、字段可空性、枚举、分页、错误码、权限、幂等、上传、删除、审核和状态转换。
### 8.2 接口问题单
每条接口问题必须包含:
- 问题编号、业务域、优先级和状态;
- 页面、路由、触发操作、用户身份和前置条件;
- 业务目标和完整使用场景;
- 现有接口路径、方法、请求、响应与当前问题;
- 建议新增或修改的接口能力;
- 鉴权、参数、字段类型、必填性、约束和成功响应;
- 分页、错误码、权限、幂等和数据副作用;
- 页面成功、失败、取消、返回和数据刷新表现;
- 验收步骤和三人共同结论。
不能只向后端提交“缺接口”或“字段不够”等模糊描述。
### 8.3 后端更新闭环
```text
三人形成接口问题单
→ 用户确认少数业务决策
→ 后端修改 Apifox
→ 用户重新导出 JSON 和 YAML
→ 验证双格式语义一致
→ 检查本次接口差异
→ 运行接口合同测试
→ 页面接入真实接口
→ MuMu 验证完整状态
→ 三人共同复核
→ 问题关闭
```
后端提出替代方案时,三个人重新评估它是否满足业务和页面,不因接口已经完成就自动接受。
## 九、问题等级与暂停条件
### 9.1 问题等级
- 阻断级:权限越权、隐私泄露、数据丢失、重复写入、关键流程无法完成或接口无法支撑业务。立即暂停当前批次。
- 高优先级:业务流程错误、返回目标错误、状态结果不一致、关键错误无反馈、主要页面缺少样式或呈现半成品。当前业务域不得带问题完成。
- 中优先级:局部状态样式、交互反馈、文案层级或边界数据存在问题。必须在该业务域闭环前解决。
- 体验优化:不影响理解和任务完成的细节打磨,可在本批后段处理。
### 9.2 必须暂停的情况
- 真实业务目的无法确定;
- 页面和接口表达不同状态模型;
- 缺少关键 App 接口;
- 后端字段、权限或错误码不足以安全实现;
- 三人对业务正确性仍有实质分歧;
- 共享合同的唯一所有者尚未确定;
- 没有有效 MuMu 证据;
- 测试无法先复现问题;
- 同一问题连续三个实现假设失败。
等待后端时,将当前批次标记为“待后端处理”,转向下一个没有依赖关系的业务域,不制作临时兼容路径。
## 十、阶段 0:项目基线清理
### 10.1 清理原则
旧内容不归档。确认已失效、已替代或确实无用后,直接从工作目录删除。Git 历史承担追溯作用,但本轮不执行 Git 变更命令。
清理候选无需用户逐项确认,但必须由三个人共同复核:
- 三人一致确认无用:由主代理统一删除。
- 任意一人提出有效疑点:继续调查,暂不删除。
- 审查人员不同时修改或删除文件。
### 10.2 文档清理
文档只有在内容被当前权威文档完整覆盖、没有独有有效决策、且不会继续作为执行入口时,才能删除。删除旧文档时必须同时清理所有指向旧入口的引用。
### 10.3 测试清理
每个测试需要记录保护的页面或功能、具体合同、当前有效性、重复关系和失败价值,并归类为保留、合并、重写或删除。
- 测试失败不是删除理由。
- 对应功能删除或合同被新唯一测试完整覆盖时,才能删除旧测试。
- 只断言旧文档文字、旧素材路径或无业务价值内部实现的测试应重写或删除。
- 截图采集工具不计入合同测试总数。
- 浏览器测试可以保留独立逻辑价值,但不能作为视觉验收。
- 生成物依赖先判断生成流程是否仍为正式能力。
### 10.4 资产清理
资产引用扫描必须覆盖 Vue、JavaScript、SCSS、JSON、配置、设计清单、构建脚本、CSS 变量和动态路径。
资产分为正式运行时资产、共享视觉资产、设计母版、构建输入、可再生成产物、临时 MuMu 证据、重复资产、被替代资产和已删除功能专用资产。
重复素材需要比较文件哈希、像素尺寸、透明区域、压缩质量和交互状态用途。确认合并后只保留一个公共所有者,更新全部引用后删除副本。
### 10.5 清理顺序与验证
```text
确认当前权威资料
→ 删除失效文档
→ 清理重复或失效测试
→ 建立完整资产引用关系
→ 清理无用资产和生成物
→ 删除残留引用
→ 运行聚焦与全量验证
→ MuMu 检查代表流程
```
阶段 0 只清理已经能证明无效的内容,不顺便重构业务代码,也不开始接口接入。
## 十一、文档体系与生命周期
### 11.1 核心当前文档
- `docs/项目当前总览.md`:项目唯一入口和当前进度。
- `docs/家谱项目全量治理设计.md`:长期有效的治理规则。
- `docs/家谱项目全量治理实施计划.md`:当前执行批次与验证要求;整体完成后将长期内容合并到总览并删除。
- `docs/接口与页面映射总表.md`:页面、功能、状态与接口关系的唯一总表。
### 11.2 临时文档
- `docs/项目基线清理清单.md`:阶段 0 使用,清理完成并合并有效信息后删除。
- `docs/评审/<业务域>联合评审.md`:业务域审查期间使用,完成并合并长期结论后删除。
- `docs/待后端处理接口问题.md`:只保留尚未解决的问题;后端合同通过验证后删除对应条目。
### 11.3 生命周期
```text
草稿
→ 三人评审
→ 当前有效
→ 内容进入唯一所有者
→ 过程文档删除
```
不保留“旧版”“最终版2”“备份”或历史归档。任何规则只能有一个当前所有者。
## 十二、测试策略
测试体系按以下层级组织:
1. 项目基础合同:页面清单、路由、共享组件、唯一所有者、响应式、编译和 OpenAPI 双格式一致性。
2. 接口合同测试:路径、方法、参数、鉴权、响应字段、错误码、分页和状态枚举。
3. 业务状态测试:校验、权限、状态转换、防重复提交、失败重试、过期和数据刷新。
4. 导航流程测试:进入、返回、取消、完成和重复进入后的栈与数据状态。
5. 纯函数运行测试:密码、验证码、字段归一化和状态机等纯逻辑。
6. MuMu 验收:视觉、美观、键盘、安全区、手势和 Android 返回键。
每个确认问题必须测试先行:
```text
第一步:写出能够复现问题的失败测试
第二步:运行并确认测试因目标问题失败
第三步:实施最小修改
第四步:运行聚焦测试
第五步:运行全局响应式合同和编译审计
第六步:运行全量合同
第七步:在 MuMu 验证完整流程和视觉状态
```
全量测试数量可以因清理和新增真实合同发生变化,不以保持原有数量为目标。
## 十三、中文文档与代码注释规范
- 新建规格、计划、报告和问题单使用中文文件名和中文内容。
- 接口路径、字段名、函数名等技术标识保留原文,并提供中文解释。
- Vue、JavaScript、SCSS 和测试脚本继续使用英文技术文件名,避免工具兼容问题。
- 测试代码同样使用中文注释说明准备条件、执行步骤、预期结果和合同目的。
- 复杂业务函数必须使用详细中文注释说明每一步的业务目的、执行顺序、状态变化、失败处理和设计原因。
- 简单赋值不堆叠无意义注释,但不能让复杂流程只能依赖阅读实现细节才能理解。
复杂流程注释采用明确步骤形式,例如:
```js
// 第一步:校验页面必填字段,阻止无效请求进入后端。
// 第二步:锁定提交状态,防止连续点击产生重复记录。
// 第三步:调用业务接口,并根据明确的后端状态更新页面。
// 第四步:成功后刷新来源数据,再结束当前流程。
// 第五步:失败时保留用户输入,并提供可理解的重试入口。
```
## 十四、精准代码与复杂度控制
所有新增或修改代码都必须以最小、精准、可验证为标准。代码行数少不是唯一目标,但每一行都必须能够追溯到当前确认的问题、业务合同或验证要求。
- 一个模块、函数或状态对象只承担一个清晰职责。
- 业务规则必须有唯一所有者,页面、组件、工具和测试不得复制同一判断。
- 改变合同后同步删除旧字段、旧入口、旧分支和旧注释,不保留“以防万一”的兼容读取。
- 不为单次使用创建抽象层,也不为了未来可能需求增加配置、参数或扩展点。
- 不把多个互不相关的业务流程塞进同一个大函数;复杂流程按明确业务步骤拆分,并保持输入、输出和副作用可见。
- 优先使用清晰命名、早返回和直接数据流,避免深层嵌套、隐式全局状态和难以追踪的副作用。
- 只有两个以上真实消费者需要同一规则时才提取共享能力,不能为了“看起来高级”制造工具层。
- 不新增没有必要的依赖,不用大范围重构解决局部问题。
- 中文注释解释“为什么、当前步骤和业务边界”,不能逐字翻译语法,也不能用大量注释掩盖混乱实现。
- 测试验证对外业务合同和关键状态,不把内部实现细节固化成脆弱断言。
- 每次实施只处理一个可独立验证的批次;相邻但无关的问题只记录,不顺手修改。
每个代码批次完成前,三个人必须共同检查:
1. 是否存在重复规则、重复请求或重复状态。
2. 是否引入不必要的抽象、依赖、参数或兼容分支。
3. 函数和文件是否具有清晰单一职责。
4. 旧路径、孤立变量、失效注释和无用导入是否由本批产生。
5. 是否可以用更直接、更少副作用的方式表达同一业务。
6. 每个修改是否都有对应失败测试、业务证据和 MuMu 验证。
任意评审者能够用证据指出代码复杂、重复或所有权不清时,该批次不能进入完成状态。
## 十五、项目阶段顺序
### 阶段 0:项目基线清理
清理失效文档、测试、资产和生成物,建立可信验证基线。
### 阶段 1:全项目共享合同
建立 OpenAPI 一致性、App/PC 分类、登录态、权限、统一错误、分页、上传、防重复提交和导航栈语义。
导航栈先形成统一设计和测试合同,具体修改随业务域逐页验证,不机械批量替换。
### 阶段 2:认证与账户
覆盖 A 系列,并同时审查 M03—M05 依赖的账户安全合同,包括注册、登录、短信验证码、找回密码、登录态失效、修改密码、换绑手机号和注销。
### 阶段 3:家谱主体与申请
覆盖 G 系列,包括列表、创建、概览、搜索、加入申请、审核、角色、权限、设置、字辈谱和家谱上下文。
### 阶段 4:世系与成员
覆盖 T 系列,包括树图、成员选择、详情、添加、编辑、关系、目录、隐私、权限和保存后的跨页一致性。
### 阶段 5:家族圈与内容媒体
覆盖 F 系列,包括动态、文章、评论、相册、照片、视频、上传、分页、草稿、发布、编辑和删除。
### 阶段 6:人物与族务记录
覆盖 R 系列,包括人物档案、礼物、祭祀、成长、人生事件、备忘、功德、时间线和汇总。
### 阶段 7:消息通知
覆盖 N 系列,包括未读、已读、全部已读、消息目标、过期、权限失效和业务结果一致性。
### 阶段 8:个人中心与设置
覆盖剩余 M 系列,包括资料、帮助、反馈、推广、会员订单、协议、退出登录和账户状态。
### 阶段 9:跨模块最终闭环
使用真实接口完整验证认证、创建或加入家谱、管理成员、发布内容、新增记录、接收消息、返回个人中心、退出和重新进入。
## 十六、每个业务域的固定执行流程
```text
三人独立首审
→ 交叉补漏
→ 反向质询
→ 共同结论
→ 后端接口问题单
→ Apifox 修改与双格式导出
→ 五方案比较与批次设计
→ 失败测试
→ 最小实施
→ 聚焦、全局和编译验证
→ MuMu 完整流程与视觉验收
→ 删除旧合同和无用内容
→ 业务域完成
```
每个确认问题提出五个可行方案,从业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性选择最优方案。
## 十七、进度与用户沟通
每个业务域只使用以下中文状态:
```text
未开始
基线清理中
独立首审中
交叉复核中
待业务确认
待后端修改
待实施
实施中
MuMu 验证中
回归验证中
已完成
```
开始批次时报告当前阶段、业务域、目标、页面、接口、状态、后端依赖和明确不处理范围。
执行过程中只报告有实际价值的进展,包括已完成内容、三人发现、当前检查、阻断问题和下一步,不向用户倾倒无关工具日志。
只有三个人无法根据证据安全决定的真实业务问题才打断用户,例如角色权限、永久删除、重新申请、费用权益、隐私范围或会改变产品含义的流程分歧。
提问时一次只问一个业务问题,并说明发生位置、当前冲突、已排除方案、推荐方案、影响和阻塞范围。
## 十八、业务域完成定义
一个业务域只有同时满足以下条件才能标记完成:
- 三人完成独立首审、交叉补漏和反向质询;
- 每个页面的全部适用状态都有本轮 MuMu 证据;
- 页面视觉达到完整、统一、美观的正式成品标准;
- 所有页面操作都有明确业务结果;
- 进入、返回、取消、完成、失败和重复进入均通过验证;
- 每个页面数据需求都映射到明确 App 接口;
- 若当前批次存在接口缺口,后端已在 Apifox 完成相应修复;
- JSON 与 YAML 来自同次导出且语义一致;
- 聚焦测试、全局响应式合同、编译审计和全量合同通过;
- 真实接口数据下再次完成 MuMu 验收;
- 没有遗留旧路径、旧字段、废弃测试或无用资产;
- 阻断级和高优先级问题为零;
- 其他未解决事项有明确负责人和状态。
- 本批代码通过三人复杂度审查,不存在新增重复规则、无意义抽象、旧兼容路径或职责混乱的大函数。
## 十九、当前实施前置条件
阶段 0 已经完成。后续实施必须满足:
1. 当前业务域已经由三个人完成独立首审、交叉补漏、反向质询和最终收敛。
2. 设计结论已经进入本文件,实施步骤已经进入唯一实施计划。
3. 需要后端变更时,先形成可直接修改 Apifox 的问题单;新导出通过双格式和接口差异合同后才能接入。
4. 每个批次先写失败测试,再实施最小代码;不得同时混合导航、短信、持久化和全局无障碍等多个阶段。
5. 业务代码变更后必须运行聚焦合同、全局响应式合同、编译审计和全量合同,并在当前 MuMu 复核完整流程。
用户已经授权三位审查者自行裁决有充分项目证据的专业方案。只有角色权限、隐私范围、永久删除、收费权益、法律责任或确实无法从现有业务判断的产品含义才再次询问用户。
## 二十、导航栈语义统一设计
### 20.1 当前证据
任务 3 实施前的只读扫描得到以下统一口径:
- 52 个活动页面中共有 `navigateTo 59``navigateBack 11``redirectTo 14``reLaunch 7`,合计 91 次直接调用。
- 活动共享组件另有 `navigateBack 2``reLaunch 2`,活动页面与组件共 95 次。
- 封存的 A06 另有 2 次,完整页面与组件源码共 97 次;项目没有 `switchTab` 调用。
- 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 八类问题的五方案终选
以下评分依次为“业务正确性/全局一致性/回归安全/维护成本/MuMu 可验证性”,5 分最好。每类只保留最终选中方案,其他方案不得作为兼容路径残留。
| 问题类型 | 方案一 | 方案二 | 方案三 | 方案四 | 方案五与终选 |
| --- | --- | --- | --- | --- | --- |
| 导航所有权 | 各页继续直接调用 `2/1/5/1/2` | 只封装页头 `2/2/4/3/3` | 无注册表的字符串工具 `3/3/4/3/4` | 只有注册表、页面仍直调 `4/4/3/3/4` | **路由注册表+唯一导航网关 `5/5/3/4/5`** |
| 三个根页 | 改原生 TabBar `4/2/2/2/4` | `navigateTo` `1/1/4/3/3` | `redirectTo` `2/2/3/3/4` | 页面各自混用 `2/1/4/2/2` | **保留自定义 Tabbar,统一 `goRoot` `5/5/4/5/5`** |
| 返回与直接进入 | 固定 `navigateBack` `1/1/4/4/2` | 页头保存 fallback URL `2/2/4/2/3` | 一律回根页 `2/3/3/4/4` | 查询参数传原始 return URL `3/2/2/2/3` | **栈感知返回+规范父页+类型化来源 `5/5/3/4/5`** |
| 列表/详情/编辑完成 | `redirectTo` 列表 `2/2/3/3/4` | `reLaunch` 列表 `1/2/2/4/4` | 固定 `navigateBack(delta)` `3/2/2/2/3` | 页面全局存整对象 `3/2/2/1/2` | **`returnTo/finishPage`+一次性结果 `5/5/3/4/5`** |
| T03 连续看亲属 | 重复 `navigateTo` `3/2/4/3/3` | 每次 `redirectTo` `2/2/3/3/4` | 每次 `reLaunch` `1/2/2/4/4` | 原生栈设上限 `3/2/3/2/3` | **单个 T03+页内成员轨迹 `5/5/3/4/5`** |
| 认证流程终点 | 保持混用 `2/1/4/2/3` | 全部 `navigateBack` `2/2/3/4/3` | 全部 `redirectTo` `3/2/3/3/4` | 全部 `reLaunch` `2/3/2/4/4` | **按进入、取消、完成和会话终止分别处理 `5/5/4/4/5`** |
| 通知与外部目标 | 后端下发原始 URL `1/1/2/3/2` | N02 自己匹配字符串 `2/2/3/2/3` | 任意 URL 白名单正则 `3/3/2/2/3` | 每个消息类型写独立跳转 `4/3/3/2/4` | **`targetType+业务 ID` 本地注册表映射 `5/5/4/4/5`** |
| Android 返回与未保存 | 只处理页头 `2/2/4/3/2` | 只处理 Android 键 `2/2/4/3/2` | 每页自由实现 `3/2/3/2/3` | 所有页面总是确认 `2/3/2/3/4` | **统一优先级状态机 `5/5/3/4/5`** |
### 20.3 唯一所有者
- `utils/navigation-routes.js` 是 52 个活动路由的唯一语义注册表,拥有路由键、路径、页面类型、规范父页、父页参数映射、根页、必填参数、可选参数、允许来源和目标页允许消费的结果操作枚举。
- `utils/navigation.js` 是项目唯一允许调用 `uni.navigateTo``uni.redirectTo``uni.reLaunch``uni.navigateBack` 的业务模块;项目没有原生 tabBar,`uni.switchTab` 在该模块内外都禁止。
- 页面和组件只调用语义方法,不拼接页面路径,不保存 fallback URL,不接收后端原始跳转 URL。
- 注册表中的路径集合必须与 `pages.json` 的 52 个活动路由精确相等;缺失、重复和陈旧条目均使合同失败。
路由参数只能使用注册表声明的标量值。ID 统一按字符串处理并逐项编码,不允许把对象、完整来源 URL、页面快照或领域数据塞进查询参数。
### 20.4 语义方法
```text
openPage(routeKey, params, sourceKey)
打开普通子页;sourceKey 必须等于当前真实页面。当前已经是同一路由且关键参数相同则不重复入栈。
goBack()
栈内有上一页时 navigateBack;没有时按当前路由的规范父页逐级回退。
returnTo(routeKey, targetParams = {})
按路由键寻找最近实例并精确返回;显式 targetParams 必须与最近实例一致,目标不在栈内时才用于构造合法回退 URL;普通返回不产生流程结果。
finishPage(routeKey, targetParams, result)
是完成并回传结果的唯一公开入口;调用方必须显式提供目标全部必填参数,当前来源页与目标页共同声明且实际存在的上下文字段必须相等;校验参数和类型化结果后精确返回,结果只能包含 operation、entityId 和 refresh。
goRoot(routeKey, params)
只接受 A01、G01、F01、M01 四个根语义;使用 reLaunch 清理旧流程。
handleBackPress(event, requestBack)
同步适配 UniApp 的 onBackPress:网关自己的 navigateBack 回调来源返回 false 放行,其余来源同步返回 true,并异步执行页面唯一 requestBack,避免递归拦截。
```
五个公开导航语义方法都返回 `Promise`,并共享一个在途转场锁:相同参数和相同结果语义的重复调用复用同一个 Promise,其他并发转场明确返回忙碌结果。一次性结果写入必须与“取得新锁”原子发生;复用或忙碌调用不得改写结果,导航失败必须回滚。每次转场用独立 flight 身份释放锁,`success/fail` 必须在 Promise settle 前释放自己的 flight;迟到的 `complete` 只能清理原 flight,不能清掉已经开始的新转场。所有调用方导航参数、流程结果和返回守卫上下文只接受普通对象的 own enumerable data propertiesSymbol、访问器、不可枚举字段和原型继承字段一律拒绝,校验后只使用同一次读取形成的冻结快照,禁止重复 getter 读取或校验后别名篡改。`redirectTo` 只允许由网关内部的规范父链回退和目标缺栈返回使用;当前没有可公开授权的“替换步骤”边,因此不暴露无消费者的替换方法。
一次性结果只存在于当前 JavaScript 进程。网关用模块内 `WeakMap` 为页面实例分配原始值身份令牌,结果 envelope 只保存令牌,不强引用或保存页面/Vue 实例。栈内返回时结果绑定反向搜索选中的最近目标令牌和该实例的规范业务参数;缺栈重建时绑定发起页令牌与完整目标参数。只有当前真实栈顶正是目标实例(或缺栈重建出的非发起页)且业务参数完全一致时才能消费,错页、同路由的更远实例和其他家谱上下文只能得到 `null`,也不得删除正确结果。结果必须在原生目标页 `onShow` 发生前随取得转场锁原子写入,消费一次后立即删除;新完成流程取得锁时淘汰已经错过目标生命周期的未消费旧结果,失败时不复活陈旧结果。字段键精确为 `operation、entityId(可选)、refresh``operation` 必须属于目标路由注册的 `resultOperations``entityId` 若存在必须为非空字符串,`refresh` 必须是布尔值。它不是领域数据持久化,不允许加入 `treeVersion、genealogyId、focusId、payload`,也不允许保存完整对象、表单内容、列表快照或接口响应。
`sourceKey` 只证明当前 JavaScript 进程内的一次真实导航:`openPage` 必须核对它与 `getCurrentPages()` 的当前真实路由一致。栈深为 1 或外部直接进入时,查询串里的 `sourceKey` 一律视为不可信并忽略,只按注册表的 `parent/parentParamMap` 建立回退目标;外部链接不能伪造来源合同。
### 20.5 根页与页头
- G01、F01、M01 继续使用自定义 `AppTabbar`;切换根页统一 `goRoot`,点击当前项不做任何跳转。
- A01 是认证根页;登录、注册完成取得有效会话后统一 `goRoot(G01)`
- G01、F01、M01 不显示普通返回箭头。左侧品牌只有绑定明确动作时才可点击,否则为非交互品牌标识。
- 根页右侧分别保留 G01 消息、F01 发布、M01 资料操作。
- 普通 `PageHeader` 删除 `fallbackUrl` 和直接路由判断;普通返回统一进入导航网关,未保存页面使用同一个返回守卫。
- 根页没有弹层时,Android 返回交给系统;有弹层时先关闭弹层。
### 20.6 返回、取消与完成
返回优先级固定为:
```text
关闭最上层预览、弹窗、搜索或抽屉
→ 消费 T03 等页面内部轨迹
→ 页面正在提交时阻止离开并给出状态
→ 有未保存输入时显示放弃确认
→ 执行普通 goBack
```
页头返回、页面取消按钮和 Android 返回键必须进入同一状态机,不能出现三个不同结果。
关键流程终点:
- A04 取消或“已有账号”返回 A01;注册并建立会话后 `goRoot(G01)`
- A05 取消返回 A01;只有未来真实重设接口成功才写入 `password-reset` 结果并回 A01。本地 `state=success`、计时器或视觉占位成功态只能普通返回,不得生成业务成功结果。
- A01 登录成功 `goRoot(G01)`;会话失效和退出成功也只允许 `goRoot(A01)`
- 当前 F02、F06、F07、F09 只形成明确标注“尚未提交服务器”的独立本地预览;F03 评论草稿不插入评论列表、不清空、不增加计数,F05 收藏明确禁用,F10 明确未开放。F01—F10 均不产生写成功结果;F02/F03 返回精确 F01,F06 新建回 F04、编辑回精确 F05,F09 无结果回同一 `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/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 的明确状态,不猜测页面。
2026-07-22 新线上 OpenAPI 已确认密码登录、短信登录和注册统一返回 `RAppLoginVo`,其 `data` 引用 `AppLoginVo`,唯一会话字段为 `access_token`。因此 A04 的正式终点不是“注册后再登录”,而是保存有效会话后直接 `goRoot(G01)`;受保护旧快照的响应形状不再作为兼容读取路径,在真实注册与行为验证尚未接入前也不得把当前占位反馈冒充注册完成。
### 20.7 T03 单实例成员轨迹
- T03 原生页面实例只保留一个。初始路由 `personId` 是不可变的宿主页路由身份,初始成员成功读取后先把轨迹初始化为恰好 `[initialPersonId]`;初始读取失败不建立轨迹。点击亲属只更新页面内活动成员 `personId` 和成员轨迹,不改 URL,也不再 `navigateTo` 同一路由。
- 新成员资料成功读取后才写入轨迹;读取失败保留原成员和原轨迹。
- 返回先逐项弹出成员轨迹,轨迹结束后才返回 T01、T07、R02 或实际来源。
- 当前 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 通知和外部进入
- 后端只返回 `targetType` 与该业务所需 ID,不返回客户端路由字符串。
- `targetType` 必须由本地固定映射转换为路由键,并先验证登录态、权限、目标存在性和必填参数。
- 外部进入先建立正确根页,再打开目标;目标失效时落入可理解的消息状态,不打开空白页面。
- 未知 `targetType`、多余参数和原始 URL 必须明确拒绝。
### 20.9 导航完成定义
- 页面和组件中直接调用五种 Uni 导航 API 的扫描结果为零。
- 路由注册表与 `pages.json` 精确闭合。
- 所有根页、普通页、直接进入、完成回流和异常回退满足上述不变量。
- MuMu 对每个受影响流程验证进入、返回、取消、完成和重复进入;T03 验证 A→B→C→B→A→来源且原生栈不增长。
- 每个迁移批次独立运行聚焦合同、全局响应式合同、编译审计和全量合同,不进行机械一次性替换。
## 二十一、T01 大规模世系树专项设计
### 21.1 已证实的 P0 与当前上限
当前 T01 不是只在“几百代”时才有风险,默认 6 人数据已经确定性断线:
- 布局得到 103 的横坐标为 550,唯一子女 106 的横坐标为 675。
- 父干线只画在 550,水平支线却只覆盖子女的 `minX..maxX`,唯一子女时只位于约 675—680,两段完全不相交。
- `NODE_HALF_HEIGHT=47`,普通节点实际半高为 43,只有选中节点半高为 47;普通节点端点天然留下约 4rpx 间隙。
- 现有 10 代×12 人压力数据中,约 45/54 个父分组会出现相同父干线断开;测试只断言线段数量大于 30,因此稳定漏报。
- 当前布局每一代都重新过滤全体成员,最坏为 `O(G×N)`,一人一代时接近 `O(N²)`;300 代单链约生成 13233 行 CSS Grid track。
- 页面、公共 mock 与 OpenAPI 分别使用 `parentId`、无关系字段、递归 `children/spousesfatherId/motherId`,直接接接口可能只显示根节点或完全没有连线。
### 21.2 五方案终选
评分依次为“业务正确性/全局一致性/回归安全/维护成本/MuMu 可验证性”。
| 方案 | 评分 | 结论 |
| --- | --- | --- |
| 修补现有 CSS Grid 与全量 DOM | `2/4/4/4/5` | 可修眼前断线,无法满足百代与宽支系,拒绝。 |
| 只改为可折叠线性目录 | `4/5/4/4/5` | 作为无障碍和降级模式保留,不能替代图谱。 |
| 全量 SVG 整洁树 | `3/3/3/3/4` | 几何易测,但数千图元和超大世界尺寸风险高,拒绝。 |
| Canvas 画线、DOM 叠节点 | `4/4/2/2/3` | App-vue 跟手手势中存在跨层不同帧错位风险,拒绝。 |
| **渐进图窗口+视口单 Canvas 同画节点和边** | **`5/5/3/3/5`** | **最终方案。** |
DCloud 官方文档明确说明 App-vue 的流畅 Canvas 动画应把绘制放在视图层并避免频繁跨层通信;renderjs 只支持 App-vue/H5Vue 3 项目不能直接在当前 `script setup` 页面内使用该写法。因此使用普通 Options API 的专用 Canvas 组件承载 renderjs,T01 页面继续负责低频业务编排:
- <https://uniapp.dcloud.net.cn/tutorial/renderjs>
- <https://uniapp.dcloud.net.cn/component/canvas.html>
- <https://uniapp.dcloud.net.cn/tutorial/performance.html>
### 21.3 产品与页面结构
T01 定位为“以焦点成员为中心的可探索世系窗口”,不是一次性展开整本族谱的手机长图。
- 焦点优先级固定为:路由 `selectedId` → 当前账号绑定成员 → 后端主始祖。合法 `state=EMPTY` 显示空谱并允许录入首位成员;只有 `state=POPULATED` 缺少有效焦点或窗口入口时才显示合同异常,客户端不猜测成员。
- 初始窗口固定为焦点上两代、下两代;这只是首次窗口,不是最大世代。
- 主世系是骨架;配偶并排,每段家庭关系具有独立联合点,子女从对应联合点向下。
- 同一家庭 Scene 在任何状态下最多直接显示 5 名真实子女。投影先沿 primary 入边从焦点反向推导受保护主路径;每个祖先家庭都必须保留通向焦点的那名子女,再用相邻 order 补足最多 5 人,不能把焦点祖先链聚合掉。其余成员形成“前 N 人”“后 N 人”聚合节点;存在隐藏可见成员时点击后以最近的稳定人物 ID 重建窗口,只有隐私成员时聚合节点不可聚焦。原焦点进入会话历史,聚合不在原图无限展开,也不截断后端数据。
- 顶部继续使用 `PageHeader`,下方为薄上下文工具条;一级操作是搜索和全谱,图谱/列表切换、关系图例、关系维护和阅读帮助进入更多。
- 左侧大面积代际栏改为 Canvas 内轻量标尺;缺代显示“中间缺 N 代”,不能把第 14 世和第 50 世误画成相邻世代。
- 右下控制组提供放大、缩小、适合当前支系和回到焦点,点击区域至少 44dp。
- 资料抽屉只有收起与半屏两档;完整资料进入既有 T03,禁止在 T01 再复制一套全屏人物详情。
- 抽屉吸附完成后才更新视口底部 inset,并把选中节点移入剩余安全区域;不得覆盖节点。
### 21.4 缩放、定位和概览
缩放范围为 0.30—2.00,默认按当前窗口适配且不自动放大超过 1.0:
| LOD | 缩放 | 内容 |
| --- | --- | --- |
| L0 | 0.30—0.44 | 当前已加载窗口的家庭、支系聚合与数量,不显示姓名。 |
| L1 | 0.45—0.74 | 姓名和隐私、缺失等状态。 |
| L2 | 0.75—1.34 | 标准人物节点、世代、关系和支系。 |
| L3 | 1.35—2.00 | 在标准节点上增加生卒摘要。 |
- 阈值使用 0.05 迟滞,避免临界缩放闪烁;Canvas 文字低于 12sp 时隐藏次要信息,不继续压小字号。
- 双指缩放以手势中心为基点;按钮每次按固定比例缩放;搜索定位后至少进入 L2。
- 单击只选中并打开抽屉;双击、点击居中或搜索结果才改变焦点并重建上二下二窗口。`REDACTED` 节点只允许显示通用隐私说明,不允许双击、居中、进入详情或成为焦点。
- 当前会话内保留最多 20 条定位记录;只保存 `focusPersonId、treeVersion、matrix`,不做最近浏览和离线持久化。
- 全谱是独立的“世代×支系”聚合视图,不是把全部人物缩小。它只展示权限过滤后的密度、根、支系覆盖和待归支统计,点击后再请求局部窗口。
- 图谱和线性列表消费同一规范图、焦点、树版本、已加载边界和搜索结果;不存在独立的页内聚合展开状态。Canvas 不可用或无障碍需要时,列表仍能搜索、选择可见成员并进入 T03/T04/T06,隐私成员只显示不可跳转的通用说明。
### 21.5 四类端点与异常数据
| 状态 | 后端或客户端事实 | 页面行为 |
| --- | --- | --- |
| 未录入 | 后端 boundary `MISSING` | 连到完整占位端点;有权限才显示补录。 |
| 隐私隐藏 | 后端 boundary `REDACTED` | 连到匿名端点,不返回可推断身份的信息。 |
| 尚未加载 | 后端 boundary `UNLOADED` | 显示数量和展开动作,必须携带版本绑定 cursor。 |
| 加载失败 | 客户端 boundary runtime `FAILED` | 保留原图与 cursor,只局部重试。 |
重复 ID、缺引用、自环、主图循环、多条主入边、反向代际和版本错配不能进入正常布局。部分合法数据仍可展示时显示诊断横幅;不允许通过不画线来隐藏错误。
### 21.6 传输合同
Apifox 是接口唯一源头,本地 JSON/YAML 只是同版本只读导出。旧递归 `LineagePersonTreeView` 必须被一个规范窗口原子替换,客户端不保留兼容读取。
```text
LineageGraphWindow
├─ version { schemaVersion, treeVersion, generatedAt }
├─ state EMPTY | POPULATED
├─ genealogyId
├─ nodes[]
├─ familyUnits[]
├─ edges[]
└─ window { focusPersonId, entryPersonIds, scope, generationRange, returnedNodeCount, boundaries[] }
```
关键字段合同:
- 所有人员、家庭、关系和引用 ID 都是非空字符串;非空 `avatarOssId` 也必须是字符串,未设置头像时只允许 `null`。后端 `int64` 不得作为 JSON Number 传给 JavaScript。稳定人物 ID 不得以 `redacted:` 开头;匿名人物 ID 精确使用 `redacted:{treeVersion}:{token}`,客户端可据此阻止它进入 FOCUS、locator、搜索、写接口和 overview 跳转目标。
- `LineageGraphWindow``state` 为 discriminator 使用 `oneOf`。EMPTY 只允许 `nodes/familyUnits/edges=[]`,且 `focusPersonId=null、entryPersonIds=[]、generationRange=null、returnedNodeCount=0、boundaries=[]`;这是合法空谱,不是合同错误。POPULATED 必须有非空 nodes、引用其中 VISIBLE 节点的非空 `focusPersonId`、至少一个引用当前 nodes 的 `entryPersonIds`、非空 generationRange,且 `returnedNodeCount === nodes.length`
- `entryPersonIds` 精确等于“当前返回窗口中没有 primary 入边的节点集合”,不是全谱始祖列表;secondary 入边不会取消窗口入口身份。每个入口节点的 `entryReason``GENEALOGY_ROOT/WINDOW_CUT/DISCONNECTED_COMPONENT`,非入口节点为 `null`;每个入口在当前窗口恰有零条 primary 入边,每个非入口恰有一条 primary 入边,因此从任意焦点沿 primary 必然回溯到某个入口。`GENEALOGY_ROOT` 表示全谱主森林根,`WINDOW_CUT` 表示其 canonical primary 父边在窗口外,`DISCONNECTED_COMPONENT` 表示没有可达全谱根的 canonical primary 链;因此第 50 代焦点的上二代窗口不会被伪装成全谱根。全谱根只由 overview 的可见根/隐私根计数和 locator 的根可见性分支表达。
- 人物节点以 `visibility` 为 discriminator 使用 `oneOf``VisibleLineagePerson` 固定包含 `id、generation、displayName、sex、avatarOssId、branchId、branchPath、order、visibility=VISIBLE、entryReason`;其 `id` 是可用于 FOCUS、locator、搜索和写接口的稳定人物 ID,`sex` 只允许 `MALE/FEMALE/UNKNOWN``RedactedLineagePerson` 只允许 `id、generation、displayName、order、visibility=REDACTED、entryReason`,其中 displayName 固定为通用“隐私成员”,id 是仅在当前 `treeVersion` 内可作当前图内部引用的不可解析 opaque ID,不得用于 FOCUS、locator、搜索或写接口,也不得进入任何 `focusPersonId/targetPersonId`;不得返回 sex、avatar、branchId、branchPath 或其他身份字段。两类节点的 `generation` 都是大于等于 1 的整数,`order` 是非负整数。
- `FamilyUnit` 包含稳定 `id、anchorPersonId、partnerRelationship、partners、order``partners` 只允许一个单亲成员,或同一伴侣关系中的两人;每项包含 `personId、partnerRole、order``partnerRole` 只允许 `ANCHOR/PARTNER`,且恰有一个 ANCHOR 与 `anchorPersonId` 相等。双人家庭的 `partnerRelationship` 必含 `relationshipId、relationshipKind=PARTNER、relationType、status`,其中 `relationType``MARRIAGE/PARTNERSHIP/UNKNOWN``status``ACTIVE/ENDED/UNKNOWN`;单亲家庭该字段为 `null`。同一人物的不同伴侣关系必须拆成不同家庭单元。
- `ParentChildEdge` 从家庭单元指向一个子女,包含 `id、familyUnitId、childId、lineageParentId、parentRelations、primary、order`。每个 `parentRelations` 项都包含稳定 `relationshipId、relationshipKind=PARENT_CHILD、personId、parentRole、relationType``parentRole` 使用 `FATHER/MOTHER/PARENT/GUARDIAN/UNKNOWN``relationType` 使用 `BIOLOGICAL/ADOPTIVE/STEP/GUARDIAN/UNKNOWN``lineageParentId` 必须同时出现在该边的父母关系和家庭成员中,子女不得同时是该家庭成员;primary 入边与 `entryPersonIds` 必须满足上一条的零条/恰一条森林不变量,布局不得根据数组顺序猜测主世系父母。
- 所有父子关系都参加缺引用、自环和有向循环校验,不能只检查 `primary` 主森林;`primary` 只决定布局主干,不降低次要收养、继亲或监护关系的数据正确性要求。
- boundary 锚点可以是 PERSON、FAMILY_UNIT 或 WINDOWPERSON/FAMILY_UNIT 的 `anchorId` 必须引用对应实体,WINDOW 的 `anchorId` 固定为 `null`。方向为 ANCESTORS、DESCENDANTS、LATERAL,原因只允许 MISSING、REDACTED、UNLOADED`hiddenCount` 只能是非负整数或 `null`cursor 只在 UNLOADED 时为非空字符串,其他原因固定为 `null`。网络 FAILED 是客户端状态。
- 当前窗口内返回但匿名化的人物使用 `Node.visibility=REDACTED`;窗口之外因权限隐藏的拓扑使用 `boundary.reason=REDACTED`。同一个隐藏对象在同一响应中不得既作为匿名节点又作为隐藏边界,避免重复计数和关系推断。
- 每个窗口对其返回的边保持引用闭包;服务端 `limit` 绝对上限 500。
- cursor 与 `genealogyId、treeVersion、boundaryId` 绑定;版本变化返回 `409 TREE_VERSION_CHANGED`
- 关系写操作携带 `If-Match`,成功返回新版本和受影响的人员、家庭及关系 ID。
`/lineage/tree` 使用两个互斥查询模式,不能由客户端拼出第三种组合:
- `mode=FOCUS``focusPersonId` 可选且只能是调用者可见的稳定人物 IDREDACTED opaque ID 不得作为焦点;缺省时服务端按“当前账号绑定成员→主始祖”确定并在响应中返回实际焦点。不可见、过期或不存在的焦点统一返回 `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`。接口同时明确 `400/401/403/404/422/429/5xx` 响应。
全谱概览不复用窗口的 `returnedNodeCount` 冒充全谱总数。`/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 的 `visibleCount/redactedCount/unloadedCount` 均为非负整数,三类计数在全部 buckets 的总和等于 `genealogyPersonCount`
`genealogyRootPersonIds` 只包含调用者可见、可用于 FOCUS 的稳定人物 ID;隐私根只计入 `redactedGenealogyRootCount`,绝不返回 opaque ID。bucket 的 `focusPersonId` 只能是该 bucket 内可见稳定人物 ID;没有可见目标时固定为 `null`
定位接口返回 `{ treeVersion, genealogyId, personId, rootVisibility, rootPersonId, pathCompleteness, ancestorPathSegments, generation, branchId }`。根可见性与路径完整性是两个独立 discriminator`rootVisibility=VISIBLE``rootPersonId` 是可见稳定 ID`rootVisibility=REDACTED``rootPersonId=null``pathCompleteness=COMPLETE` 表示路径没有隐私缺口,`pathCompleteness=REDACTED_GAPS` 表示含一个或多个隐私段,允许“可见根→隐私中间祖先→可见目标”。
`ancestorPathSegments``kind` 使用 `oneOf`VISIBLE 段精确为 `{ kind: "VISIBLE", personIds: [...] }`,数组非空且只含可见稳定 ID;REDACTED 段精确为 `{ kind: "REDACTED", hiddenCount }``hiddenCount` 为正整数或因权限不披露时为 `null`,不得包含任何人物 ID。相邻段 kind 必须交替,整条路径最后一个 VISIBLE 段必须以目标 `personId` 结束;可见根时第一个 VISIBLE 段必须从 `rootPersonId` 开始,隐私根时第一段必须是 REDACTED。COMPLETE 恰好没有 REDACTED 段,REDACTED_GAPS 至少有一段。任何分支都不得返回客户端路由或隐私 opaque ID。
### 21.7 客户端所有者与处理链
```text
OpenAPI 响应
→ normalize
→ validate
→ project
→ 确定性 family-unit tidy-tree layout
→ Scene 与空间索引
→ 平台 Canvas 渲染器
```
- `utils/lineage/` 是规范化、校验、投影、布局和空间索引的唯一客户端所有者。
- `normalize` 负责按 `order、id` 作一次确定性排序,最坏复杂度为 `O(N log N + E)``validate` 通过索引和一次图遍历保持 `O(N+E)``project` 只消费已规范化顺序并保持 `O(N+E)`,不得在投影阶段重复全量排序。
- `project` 只从已校验的完整窗口图生成当前可见投影:先由唯一 primary 入边推导焦点到窗口入口的受保护人员与家庭,再为每个家庭选出包含受保护子女的最多 5 名真实子女;无受保护子女的家庭稳定选择 order 最小的 5 人。其余连续区间生成带 `direction、hiddenCount、focusable、targetPersonId` 的“前 N 人/后 N 人”虚拟聚合节点;存在隐藏可见成员时 `focusable=true``targetPersonId` 指向最靠近当前可见窗口的稳定人物 ID,仅含隐私成员时 `focusable=false、targetPersonId=null`。点击可聚焦聚合节点后以该成员重新聚焦,不存在 `expandedAggregateIds` 或不断增长的页内展开状态。完整规范图继续供搜索和线性列表使用;布局只消费投影,Scene 不得再次决定聚合规则。
- 布局按 `order、id` 稳定排序,自底向上计算子树轮廓;家庭联合点必须位于可见子女跨度中心,单子女时严格同轴。
- Scene 根对象精确为 `{ sceneVersion, treeVersion, focusPersonId, bounds, items }``utils/lineage/scene.js``sceneVersion` 唯一所有者,按 `treeVersion + projectionSignature + LINEAGE_LAYOUT_VERSION + fontMetricsVersion` 生成确定性摘要;Scene 使用世界坐标并只由可 JSON 序列化的数组、对象、字符串、数字和布尔值组成,不得跨 renderjs 边界传递 `Map`、函数、Vue、DOM、rpx、Canvas context、空间索引或平台对象。瞬时 `selectedId` 不属于 Scene payload,也不参与 `sceneVersion`
- `components/LineageViewport.vue` 使用普通 Options APIApp-vue/H5 的 renderjs 持有 Scene、相机、手势、LOD、裁剪、命中和逐帧绘制。
- Canvas 物理尺寸始终等于视口,不等于几百代的世界尺寸;只绘制视口加 overscan 内对象。
- 节点、文字、联合点、关系线、聚合节点、端点、代际标尺和选中光晕全部在同一 Canvas、同一矩阵、同一帧绘制;选中光晕由 renderjs 使用独立 `selectedId` 和 Scene 内稳定人物 Rect 动态绘制,不修改 Scene、布局或版本。
- DOM 只保留 PageHeader、工具条、两档抽屉、搜索、全谱概览和线性列表。
- mp-weixin 复用同一 Scene 的 Canvas 适配器;窗口超过能力或持续不达性能门槛时进入线性列表,不另造关系规则。
- renderjs 读取 `scene.sceneVersion` 后先验证版本与 payload、在视图层重建空间索引,再原子交换 Scene、索引和版本;缺少版本或同一版本对应不同 payload 必须拒绝并保留旧 Scene。平移、缩放与惯性期间零跨层通信;只在手势结束、焦点变化或业务确需边界请求时回传相机状态。
- 视口、底部 inset 或 DPR 变化时先重设 transform,再把 backing store 设为视口乘 DPR、CSS 尺寸设为逻辑视口;字体就绪或回退字体变化后重新测量文字、生成新的 `fontMetricsVersion` 并生成新 Scene,不能沿用旧测量值。
### 21.8 “连线永不断”不变量
1. 每个家庭成员都恰有一个可追踪的 `family:{familyUnitId}:partner:{personId}` 连接器指向联合点;单亲联合点若与人物锚点重合,可以省略孤立的零长度笔画,但关系对象仍可反查。
2. 折线相邻段端点在世界坐标中完全相等,DPR 变换后误差不超过 0.5 个物理像素。
3. 单子女使用连续路径,不创建孤立零长度横梁。
4. 锚点从实际 Scene Rect 计算,不允许全局半高常量。
5. 选中态只画外部光晕,不改变节点宽高、布局或锚点。
6. 每条 `ParentChildEdge` 恰好对应一个 `edge:{edgeId}` 连接器,其起点必须是 `familyPoint(familyUnitId)`,终点必须是实际子女人物锚点;Scene connector 也能反查唯一传输 edge。
7. 主关系线不得穿过无关人物节点,纯主树的主关系交叉数必须为零。
8. 视口裁剪依据路径包围盒;即使两个端点都在视口外,只要路径穿过视口就必须绘制。
9. 未录入、隐私、未加载和聚合关系必须连接到明确端点,不允许悬空或静默消失。
10. 新窗口通过校验后原子替换旧 Scene;不得用新节点配旧边或新边配旧节点。
### 21.9 后端最小改动
为避免静默破坏既有 v1 消费者,后端新增并固定以下四条 v2 路径:
```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`,按 FOCUS/BOUNDARY 互斥规则校验;不再接受 `branchId、generation`
- `/lineage/tree/overview` 必须携带当前 `treeVersion`,只返回同版本的代际与支系聚合桶,并只为存在可见稳定人物 ID 的桶提供 `focusPersonId`;版本变化返回 `409 TREE_VERSION_CHANGED`
- `/locator` 返回目标人物、根可见性分支、`ancestorPathSegments` 隐私安全分段祖先路径、代数、支系和树版本;REDACTED 段不得返回人物 ID,任何分支都不得返回 opaque ID。
- 现有人物搜索只需稳定返回字符串 `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`
### 21.11 测试与 MuMu 门槛
测试先锁定当前 103→106、普通节点 4rpx 和 10×12 大量父干线断开,再依次覆盖:
- 合法单根、单亲、多配偶、多根、收养、隐私、字符串大 ID 和窗口边界。
- 旧递归结构、`parentId`、数字 ID、重复、缺引用、自环、循环、多主入边和版本错配必须明确失败。
- 6 人、10×12、20×20、5×100、100×3、300×1、单家庭 200 子女、缺第 2—49 代、乱序输入和极不平衡树;500 代单链只作纯逻辑校验,不作为 MuMu 手工搜索目标。
- 每条线的锚点、连续段、反查、穿节点、交叉、DPR、视口裁剪和选中前后几何。
- 搜索定位、聚合跳转与返回焦点、版本冲突、焦点保持、Canvas context 恢复和图谱/列表一致性。
当前 MuMu 不启动、不关闭,不调整模拟器窗口缩放比例、设备分辨率、方向或系统字体;App 内双指缩放属于 T01 功能验证。只在现有 `emulator-5554 / 720×1280 / 320dpi` 配置验证:
- 默认首屏和 103→106;单指平移、双指缩放、按钮缩放及四级 LOD。
- 10、50、100、300 代搜索或概览定位。
- 宽支系聚合、前后人数、聚合跳转、回到上一焦点,且受保护祖先链始终连续。
- 多配偶联合点及子女归组;未录入、隐私、未加载和局部失败端点。
- 两档抽屉、T03/T04/T06/T07 返回定位、长姓名、同名搜索、空谱、无权限和异常关系。
- 60 秒连续操作、20 次聚合跳转与搜索,以及自动几何探针和同场景逐边视觉探针;两者都为零违规才通过。
性能门槛:单窗口不超过 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. 导航栈语义统一:静态任务 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 一起完成;认证浮层的壳层无障碍不扩张为全项目已通过。外部门禁阻塞时继续无依赖批次,但任何真实成功、接口兼容或视觉通过都必须有对应证据。