完成40%
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,301 @@
|
||||
# 今晚全量联调与明早测试执行计划
|
||||
|
||||
## 一、计划地位
|
||||
|
||||
本文是今晚到明早的唯一执行顺序与验收入口。既有治理文档继续提供历史背景和长期发布约束,但与今晚的批次顺序、时间分配或“可测试”口径冲突时,以本文为准。`APP.openapi.yaml` 和 `APP.openapi.json` 只读且受保护,不得修改、格式化、覆盖或删除。
|
||||
|
||||
当前处于“规划重整”阶段。在用户明确确认本版规划前,冻结业务代码、页面样式、接口接线、构建和 MuMu 操作;只允许只读核对与中文规划文档写入。规划确认后再按本文批次测试先行实施,不以已存在的半成品或局部截图代替用户确认。
|
||||
|
||||
## 二、明早交付目标
|
||||
|
||||
明早交付一套连接 `https://backend-api.ddxcjp.cn/`、能够在 MuMu 中从认证入口开始遍历的 UniApp 测试版本。`pages.json` 中全部 52 条活动路由都必须可进入、可返回、无白屏和死路;后端已有接口的页面必须调用真实接口,后端确实没有接口的页面必须显示“服务暂未开放”或同义的明确状态,不得用 mock、fixture、timer 或假成功伪装闭环。
|
||||
|
||||
测试版本不等同于正式发布版本。发布签名、正式域名 CORS、后端未实现合同和真实支付等外部条件可以保留为发布阻塞,但必须提供失败门禁、复现证据、解除条件和继续步骤。
|
||||
|
||||
“全量”分三层报告,禁止混为一个结论:
|
||||
|
||||
1. 已接真实接口的链路执行 live E2E,并据证据判定通过或失败。
|
||||
2. 尚无后端 operation 的页面执行路由、交互、状态和视觉回归,并明确标为服务未开放,不能记作接口通过。
|
||||
3. OpenAPI、签名、正式域名和其他发布门禁按预期发布阻塞单列,不能把预期红灯吞成全绿,也不能让它掩盖本机可修复失败。
|
||||
|
||||
今晚的可靠最低交付是“52/52 路由可进入返回 + 当前 7 个 remote consumer 的主链可信联调 + 其余页面诚实标记数据模式”;在此基础上按完成定义继续扩大 LIVE 页面数量。不得为了追求数字把只完成路径拼接、宽 DTO 直传或单一成功样例的页面提前标记为 LIVE。
|
||||
|
||||
## 三、已确认口径
|
||||
|
||||
- 唯一后端为 `https://backend-api.ddxcjp.cn/`,今晚联调使用 remote 模式。
|
||||
- 已提供有效测试账号;账号凭据只用于本机联调,不写入源码、测试、文档、日志或 Git。
|
||||
- 已用标准 JSON 请求确认密码登录返回 HTTP 200,`/captcha/challenge` 返回 HTTP 200、`TIANAI/SLIDER` 和完整挑战数据。此前空体 500 是 PowerShell 调用原生 `curl` 时 JSON 引号被破坏造成的错误诊断,不再作为后端阻塞。
|
||||
- 密码登录、短信登录、注册、忘记密码都必须显示并完成 TAC。密码登录接口不接收 `validToken`,滑动成功只作为客户端强制前置;短信发送接口必须消费对应场景的 `validToken`。
|
||||
- 允许操作 MuMu,并把实机画面、返回路径和交互状态纳入验收。
|
||||
- 沿用当前 `static` 国风视觉资产和现有页面结构;今晚修复白块、截断、错位、默认控件、触控尺寸、长文本和不同宽度适配,不重做整套视觉。
|
||||
- 允许测试账号创建带“联调测试”标识的家谱、成员、申请、动态、文章、相册、礼仪、备忘、功德和反馈等测试数据。
|
||||
- 不删除已有真实数据;无人值守期间不执行支付、换绑手机号、注销账号或不可逆操作。修改密码若经人工窗口测试,结束后恢复约定密码。
|
||||
- 不执行 `git add`、`commit`、`push`、`restore`、`checkout` 或 `reset`,除非用户另行明确授权。
|
||||
|
||||
## 四、单页完成定义
|
||||
|
||||
一个页面只有同时满足以下五项才标记为完成:
|
||||
|
||||
1. 从真实业务入口可进入,系统返回键、页面返回按钮和根页面切换行为正确。
|
||||
2. 后端已有操作使用真实接口,路径、方法、请求字段、响应字段和身份上下文与 Apifox、线上文档及真实响应一致。
|
||||
3. 正常、加载、空数据、失败、无权限和提交中状态完整;写操作另有校验、重复点击保护、成功、明确失败和结果未知处理。
|
||||
4. MuMu 实际画面没有白块、文字截断、控件错位、默认原生底色或不可点击区域;普通内容、长内容和状态页均可阅读。
|
||||
5. 页面对应的 mapper/runtime 测试、静态合同、编译检查和实机检查均通过。
|
||||
|
||||
自动脚本通过不能替代实机视觉合格,单张静态截图也不能替代接口与交互合格。
|
||||
|
||||
所有报告项只使用以下五种状态:`PASS`、`FAIL`、`EXPECTED_BLOCKED`、`NOT_RUN`、`INFRA_ERROR`。每项必须同时记录首次结果;如果进行了允许的只读重试,另记重试结果,不覆盖首次失败。
|
||||
|
||||
## 五、接口判定和实现规则
|
||||
|
||||
1. Apifox 用于判断接口业务用途;线上 `/v3/api-docs` 和真实请求用于确认当前部署路径、字段及故障表现;受保护 OpenAPI 只作离线补充。
|
||||
2. 先列出当前批次的“页面 → 动作 → 方法 → 路径 → 输入 → 页面字段 → 成功/失败表现”,再写该批次失败测试,随后实现。
|
||||
3. 接口存在就接入;4xx、5xx、空体、缺字段或身份错配必须展示真实失败,不回退 mock。
|
||||
4. 后端没有对应 operation 时,页面保留完整视觉和返回路径,动作显示服务未开放,不伪造保存、发送、上传、支付、邀请或审核成功。
|
||||
5. 单个接口排查超过 15 分钟仍无本机解决路径时,立即记录复现证据并继续其他页面,避免阻塞整夜。
|
||||
6. 每个合同只有一个 owner;新合同落地时同步移除同类 fixture、旧入口、兼容读取和过时测试期望。
|
||||
7. 真实写操作仅限测试账号和本轮新建、带“联调测试”标识且已登记 ID 的对象。GET 可按退避规则重试;写操作只有接口明确提供幂等键且结果可判定时才允许重试,超时、5xx、断线或进程终止造成结果未知时立即停止该动作并查只读结果。
|
||||
|
||||
每条路由的执行记录至少包含:`route`、真实入口、必填参数、测试角色/种子、`dataMode(LIVE/LOCAL_PREVIEW/CLOSED)`、主动作、返回结果、页面状态证据、LIVE 请求响应证据、MuMu 结果和 blocker。
|
||||
|
||||
## 六、执行批次与相对时间
|
||||
|
||||
### 第 0—0.5 小时:硬预检与全量映射
|
||||
|
||||
- 核对当前 HEAD、工作区、52 条路由、构建入口和已有测试。
|
||||
- 按页面盘点真实接口、现有 fixture、入口、写操作和视觉风险。
|
||||
- 只维护一张执行表和必要阻塞证据,不扩写与今晚交付无关的治理长文。
|
||||
- 夜跑前确认源码 HEAD 与 dirty diff、remote 配置、后端版本、网络、磁盘、MuMu 在线/解锁、测试包安装、测试账号会话、测试家谱 ID 和账号角色。缺少第二角色账号时,跨账号申请/审核不得伪造通过,单列测试数据阻塞。
|
||||
- 建立唯一夜跑入口:按固定清单发现测试,单项有超时,失败后继续下一项,记录检查点并支持从最后完成项续跑;结束时回收子进程。连续认证失败、429、同域 5xx、设备离线、磁盘不足或写结果未知触发对应域熔断。
|
||||
|
||||
### 第 0.5—1.5 小时:A 认证与 TAC
|
||||
|
||||
- 页面:A01、A04、A05及认证状态、协议和返回相关入口。
|
||||
- 链路:密码登录、短信登录、注册、忘记密码、TAC challenge/verify、短信发送、会话保存和 G01 跳转。
|
||||
- 重点视觉:验证码倒计时、禁用按钮、输入行、协议、Toast、TAC 弹层、短屏滚动和 360/412 宽度。
|
||||
- 用户宣布准备休息时,暂停其他工作并进入 45—60 分钟人工窗口。密码登录 TAC、A01 短信登录、A04 注册和 A05 找回是四条独立流程;若同时具备已注册收码号码和未注册号码则逐条验证,否则把缺少号码的流程标记测试数据阻塞。人工完成后验证会话在页面重进和应用重启后仍有效。
|
||||
- 记录会话有效性但不记录令牌;无人值守阶段若会话失效,停止需要身份的实机动作并标记 `EXPECTED_BLOCKED`,不尝试绕过认证。
|
||||
- 无人值守阶段不循环发送短信,不自动破解或绕过 TAC。自动接口测试取得的临时令牌只验证登录后接口,不能冒充认证页面验收。
|
||||
|
||||
### 第 1.5—3.5 小时:remote 主链与 B 高风险页面
|
||||
|
||||
- G01、G03、G05、G06、G08、G09、G10、G11、G12。
|
||||
- T01、T03—T08及所有活动世系路由。
|
||||
- 优先完成 A01 → G01 → G05 → T01 和 M07 已接线主链的 P0/P1,再按测试角色和后端合同推进我的家谱、原子建谱、公开搜索、普通申请、我的申请/撤回、待审/审核、设置、字辈、成员详情、编辑和关系操作。
|
||||
- T01 不按当前文字节点和简单底部操作条直接验收;必须先按第十一节完成头像人物卡、选中态、人物操作面板、关系快捷动作和长世代布局的测试门禁,再进入 T03—T06 的真实读写接线。
|
||||
- 后端未提供的邀请码、详情或冲突控制不混入其他普通申请链路;缺失功能显示明确不可用状态。
|
||||
|
||||
### 第 3.5—5.5 小时:52 路由 MuMu 遍历
|
||||
|
||||
- 按 A/G/T/F/R/N/M 顺序记录每页真实入口、必填参数、默认状态、Android 返回和根切换。
|
||||
- 这一阶段不以匆忙接接口为目标;先发现白屏、死路、缺参数、上下文串谱和明显视觉破损,并为每页标记 LIVE、LOCAL_PREVIEW 或 CLOSED。
|
||||
|
||||
### 第 5.5—7 小时:C/D/E 接口扩展与代表性状态视觉
|
||||
|
||||
- F01—F10:动态、评论、文章、相册、照片、媒体、视频和上传相关页面。
|
||||
- R01—R11:人物、礼簿、礼仪、成长、人生事件、备忘和功德。
|
||||
- 已有读取和写入接口接真实服务;上传链不完整时不得显示假上传成功;人生事件无接口时显示服务未开放。
|
||||
- N01、N02和 M01—M10同步按风险扩展通知、资料、安全、帮助、反馈、推广、VIP、关于和退出。
|
||||
- 换绑、支付、注销等敏感或缺少完整后端闭环的动作在无人值守阶段只验证页面、校验和真实不可用状态。
|
||||
- 视觉按共享形态覆盖认证表单、列表、详情、编辑器、弹窗、上传和关闭页;共享控件缺陷一次修全,高风险页补长文、空、错和无权限。320/412 用响应式运行检查补充,MuMu 当前设备证据不冒充其他宽度原生证据。
|
||||
|
||||
### 第 7—8.25 小时:全量测试、重编译和候选包
|
||||
|
||||
- 运行 PowerShell、Node、语法、纯 Node、活动 Vue 脚本和构建检查。
|
||||
- 构建测试版本并在 MuMu 中从认证根页遍历四个主入口和全部活动页面。
|
||||
- 检查重新进入、切换家谱、凭证失效、弱网、重复点击、请求取消、空数据、4xx/5xx和返回行为。
|
||||
- 安全覆盖 HTTP 200 业务错误、400/401/403/404/409/429/500、超时、畸形 JSON、重复或失真 ID、撤权、快速切页、离页取消、前后台切换和迟到响应;不适合对真实服务触发的故障使用确定性本地传输注入,不能攻击线上服务。
|
||||
- 输出已通过、后端阻塞、缺测试数据和需人工复验四类结果,不用含糊的“基本完成”。
|
||||
|
||||
### 第 8.25—9 小时:候选包回归与证据归档
|
||||
|
||||
- 对候选测试版本快速重走认证根页、四个主入口、家谱上下文和本轮失败项。
|
||||
- 汇总五种状态、首次结果、熔断、人工项和精确继续步骤,保留至少 45 分钟缓冲,不在最后时刻追加未验证接口。
|
||||
|
||||
## 七、三人交叉复核
|
||||
|
||||
主代理是唯一写入者。两位评审者只读检查每一批的接口、业务、交互、异常和视觉;发现分歧时给出证据并互相反驳,统一结论后由主代理修改。评审在批次边界进行,不并发写文件,也不把开放式讨论拖入下一批。
|
||||
|
||||
## 八、无人值守边界
|
||||
|
||||
- 可以继续读取、创建带测试标识的数据、运行测试、构建和操作 MuMu。
|
||||
- 不发送外部消息,不循环请求短信,不破解验证码,不使用生产数据做破坏性试验。
|
||||
- 不支付、不换绑、不注销、不删除已有家谱或用户内容。
|
||||
- 后端、测试账号、发布签名或 MuMu 人工验证码成为硬阻塞时,记录:失败门禁、请求与响应、复现步骤、解除条件、恢复后的第一步。
|
||||
- 用户有新消息时优先处理;用户说“准备睡了”即进入认证人工窗口。
|
||||
- Authorization、手机号、OTP、TAC proof、邀请码和个人资料不得进入报告、截图文件名或仓库日志。原始运行日志和截图放在本机临时验收目录,限制容量并在报告中只引用脱敏摘要。
|
||||
- TalkBack、系统大字号和无法可靠自动化的原生辅助功能只做可重复的早晨人工项,自动截图不得冒充无障碍通过。
|
||||
|
||||
## 九、明早交付物
|
||||
|
||||
1. MuMu 中可启动和遍历的测试版本;能够产出测试 APK 时同时给出 APK 路径,缺发布签名不阻塞开发测试包。
|
||||
2. 52 条路由的完成状态与入口清单。
|
||||
3. 页面—接口映射和真实请求结果。
|
||||
4. 自动测试、构建检查和 MuMu 实机验收结果。
|
||||
5. 后端阻塞、测试数据阻塞和需要人工复验的精确清单。
|
||||
6. 明早从登录开始的最短人工回归步骤。
|
||||
7. 运行元数据:源码 HEAD/dirty 状态、开始结束时间、后端地址与可识别版本、MuMu 分辨率/density、账号角色、各项耗时和熔断记录;不包含任何凭据。
|
||||
|
||||
## 十、执行记录
|
||||
|
||||
| 批次 | 状态 | 已通过 | 阻塞或人工项 |
|
||||
| --- | --- | --- | --- |
|
||||
| 断点与映射 | PASS | HEAD、dirty 工作区、OpenAPI 哈希、52 条路由、remote 配置、构建产物、MuMu 设备和当前接口 owner 已核对 | 无 APK;开发测试包可由 HBuilderX 继续运行 |
|
||||
| A 认证与 TAC | 进行中 | 后端密码登录与 challenge 均只读确认 200;认证专项自动门禁已通过;A01 短信默认态 MuMu 视觉已通过 | 四条 TAC/短信流程和倒计时态留待睡前人工窗口 |
|
||||
| B 家谱与世系 | 规划重整中 | G01/G05/T01 已有部分真实接口改动 | 实施冻结;先确认第十一节,再测试先行重做 T01 并复核 T03—T06 |
|
||||
| C/D 内容与族务 | 待执行 | 无 | 待接口盘点 |
|
||||
| E 消息与我的 | 待执行 | M07 已有真实反馈接口基础 | 待接口盘点 |
|
||||
| 全量验收 | 待执行 | 无 | 构建、MuMu 和整体验收待执行 |
|
||||
|
||||
### 第 0 批硬预检证据
|
||||
|
||||
- 源码基线:`main`,HEAD `f1edc6b53320755a8638c99d492250153a8dcb17`;工作区包含续作改动,未执行暂存、提交、还原或重置。
|
||||
- 受保护 OpenAPI:
|
||||
- `APP.openapi.yaml`:`8964CD583CE172425B63BBFD802F7EB587EB3641EADFD6F9D3B264FAA8090C6C`
|
||||
- `APP.openapi.json`:`87DB1DC148C5E6E877815AFF7B3A7FC7C7ECA95A2CEC50A88B42F3908961F31A`
|
||||
- 活动路由共 52 条:A 3、G 9、T 7、F 10、R 11、N 2、M 10;导航路由与导航流程门禁均通过。
|
||||
- 当前真实接口 consumer 共 7 页:A01、A04、A05、G01、G05、T01、M07。其余 45 页在逐页接线前均按 `LOCAL_PREVIEW` 或 `CLOSED` 报告,不冒充 LIVE。
|
||||
- MuMu 设备在线:Android 12,720×1280,density 320,HBuilder 基座处于前台;现有 `app-plus` 开发构建可运行,尚无 APK。
|
||||
- 测试账号密码登录成功;只读查询“我的家谱”返回空列表,可作为 G01 空态种子。后续通过 G03 创建带“联调测试”标识的本轮家谱后,登记返回 ID 再继续 owner 链路。
|
||||
- 普通申请、撤回、待审和审核需要第二独立账号形成申请人/管理员双角色;当前缺少第二角色时标为测试数据阻塞,不伪造跨账号通过。
|
||||
- 注册需要一个未注册且能接收短信的手机号;未提供前只完成页面、TAC 前置和接口合同,真实注册标为测试数据阻塞。
|
||||
|
||||
## 十一、产品参考功能基线与 T01 世系树重做计划
|
||||
|
||||
### 11.1 参考资料的归档与换路径办法
|
||||
|
||||
1. 原参考目录 `C:\Users\Administrator\Desktop\job\app设计` 只视为一次性导入源,不是项目运行依赖,也不是长期权威路径。业务代码、测试、文档合同和构建脚本均不得硬编码该绝对路径。
|
||||
2. 当前已取得的参考资料保存在项目相对目录 `docs/design/references/产品参考原稿`。该目录作为换电脑、原目录改名或移动后的本地参考副本;是否采用某个功能仍以用户确认和后续中文映射表为准,不能因为图片已归档就默认全部照搬。
|
||||
3. 当前电脑性能较弱,本阶段不批量解码图片、不做 OCR、不生成缩略图、不跑图像相似度或全量构建。用户回家并提供新的参考目录后再执行完整核对。
|
||||
4. 新目录核对只读进行:先枚举相对文件名、类型、大小和 SHA-256,再与项目内参考副本比较;只归档新增或内容变化的文件。旧副本不因源目录缺文件而自动删除,发现冲突先列清单给用户确认。
|
||||
5. 完整核对后建立 `docs/产品参考页面功能映射表.md`。每张参考页面必须映射到:参考文件、项目路由、保留功能、调整后的交互、沿用的国风视觉、明确舍弃的旧行为、接口 owner、数据模式、测试门禁和 MuMu 验收项。没有映射的参考图不得直接驱动代码。
|
||||
6. 参考 App 是功能和交互来源,不是整套视觉照抄目标。项目继续使用当前 `static` 中已确定的国风资产、色彩和组件语言;旧稿的亮红导航、卡片比例等只在用户明确点名时采用。
|
||||
7. 用户回家后的恢复步骤固定为:提供新目录 → 只读清单与哈希差异 → 补充归档 → 完成中文页面映射 → 用户确认映射 → 才把对应功能纳入实现批次。目录再次变化时重复相同步骤,不改业务代码中的路径。
|
||||
8. 用户后续提供的“已经完成的项目”作为第二参考源,价值高于单张截图对交互细节的表达,但仍不是当前项目的需求、接口或代码权威源。即使页面名称相同,也不得默认业务含义、角色权限、字段、接口和异常处理相同。
|
||||
9. 完成项目在用户给出新绝对路径后先只读盘点;第一轮不安装依赖、不运行构建、不修改参考仓库。检查范围包括路由、页面、组件、状态流、API 封装、数据模型、权限判断、视觉资产和异常分支,同时排除凭据、令牌、环境地址、签名、用户数据和机器专属配置。
|
||||
10. 禁止按目录、页面文件或组件整包迁移。甄别和采用的最小单位是“页面中的一个用户动作及其完整状态”,逐项映射后才能进入当前项目的测试先行批次。
|
||||
|
||||
### 11.2 T01 目标画面与交互
|
||||
|
||||
T01 的验收目标不是“能显示一棵文字树”,而是能够识别人、关系、代际并对选中人物执行明确动作的长世代世系树:
|
||||
|
||||
1. 人物节点使用头像卡片,至少呈现头像、姓名、关系标签和代际信息;配偶位于同一代并保持成组关系,父母、子女、兄弟姐妹的连接线不能断裂或指向错误人物。
|
||||
2. 有真实头像时通过统一文件投影显示;无头像、文件缺失或加载失败时使用确定性的国风默认头像,不使用随机网络图片或伪造真人照片。不能把 `avatarOssId` 直接拼成 URL。
|
||||
3. 支持横向与纵向浏览长世代内容;选中人物需有清晰高亮。长姓名、大字号、大量成员和数十代数据下,卡片、代际标签和连接线仍可辨认。
|
||||
4. 点击人物卡后打开唯一的人物操作面板,取代互相竞争的旧底部操作条。面板中央放大展示当前人物卡,头像区域提示并支持“点击头像查看资料”。
|
||||
5. 操作面板必须包含以下功能入口,并始终携带当前 `genealogyId` 和 `personId`:
|
||||
- 点击头像查看资料;
|
||||
- 添加父亲;
|
||||
- 添加母亲;
|
||||
- 添加配偶;
|
||||
- 添加兄弟姐妹;
|
||||
- 调整排行;
|
||||
- 添加儿子;
|
||||
- 添加女儿;
|
||||
- 邀请绑定;
|
||||
- 编辑信息。
|
||||
6. 面板遮罩点击、关闭按钮、Android 系统返回键、页面返回键、重复点击和焦点恢复行为必须一致。提交中的动作禁用重复触发;页面离开或响应迟到时不得把结果写入错误人物。
|
||||
7. 操作入口是否可见和可用由服务端权限或明确能力字段决定。不能因为当前账号从管理入口进入,就在前端猜测其拥有编辑、邀请或调整关系权限。
|
||||
|
||||
### 11.3 页面职责与唯一 owner
|
||||
|
||||
| 页面 | 唯一职责 | 不得承担 |
|
||||
| --- | --- | --- |
|
||||
| T01 世系树 | 读取树、布局、浏览、选择人物、展示操作面板并导航到对应流程 | 不直接伪造任何写操作成功,不保存另一份成员详情 |
|
||||
| T03 成员详情 | 读取并展示真实人物资料;承接“点击头像查看资料” | 不以本地 fixture 冒充服务端人物 |
|
||||
| T04 添加亲属 | 统一承接父亲、母亲、配偶、兄弟姐妹、儿子、女儿六类新增关系 | 不为六个入口复制六套提交合同,不猜测缺失 ID |
|
||||
| T05 编辑成员 | 修改头像和允许编辑的基础人物资料 | 不承担排行或关系拓扑修改 |
|
||||
| T06 关系维护 | 调整排行及后端明确支持的关系维护 | 不退化为另一份通用人物编辑页 |
|
||||
| 邀请绑定流程 | 在独立合同下邀请用户并绑定人物 | 不与普通加入家谱申请或邀请码直接加入混为一条链 |
|
||||
| T07/T08 | 成员目录、成员状态等辅助读取 | 不成为 T01 写操作的旁路 owner |
|
||||
|
||||
T04 的六个入口只传递稳定的词法 ID、`relationType` 和必要的意图提示;页面先展示当前人物和待添加关系,最终字段及性别等约束由后端合同校验。不得复制人物来模拟配偶,也不得仅凭“儿子/女儿”等按钮文案在本地推导超出请求意图的数据。
|
||||
|
||||
### 11.4 接口映射与启用条件
|
||||
|
||||
| 用户动作 | 页面 owner | 计划接口 | 启用条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 展示世系树 | T01 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | 树 DTO、稳定 ID、代际、配偶与父子连接关系已收紧并通过运行时测试 |
|
||||
| 查看人物资料 | T03 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | 路由参数与响应投影一致,不回退 fixture |
|
||||
| 添加儿子/女儿 | T04 | `POST .../lineage/persons/{personId}/children` | 请求 DTO、写入结果、重复提交与结果未知规则明确 |
|
||||
| 添加父亲/母亲 | T04 | `POST .../lineage/persons/{personId}/parents` | 同上,并由服务端校验关系冲突 |
|
||||
| 添加兄弟姐妹 | T04 | `POST .../lineage/persons/{personId}/siblings` | 同上,不通过复制已有节点伪造 |
|
||||
| 添加配偶 | T04 | `POST .../lineage/persons/{personId}/spouses` | 同上,并能读取提交后的真实关系 |
|
||||
| 编辑人物资料/头像引用 | T05 | `PUT /genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}` | 字段白名单、头像上传/引用/访问 URL 闭环和结果确认完整 |
|
||||
| 调整排行 | T06 | 后端明确支持的关系或 `sortOrder` 写操作 | 并发、权限、冲突和结果确认规则明确后才启用 |
|
||||
| 邀请绑定 | 独立流程 | 待 Apifox、线上合同和真实响应共同确认 | 必须有明确 operation、身份安全和幂等/结果查询规则;缺失时显示“服务暂未开放” |
|
||||
|
||||
接口路径中的 `...` 仅是规划表的共同前缀缩写,实际合同和测试必须写完整路径。`avatarOssId` 的 owner 是统一上传与文件访问链路;未确认上传、文件引用和可访问 URL 三段闭环前,T05 不显示假上传成功。邀请绑定没有独立后端 operation 时记为 `EXPECTED_BLOCKED`,不能借用普通家谱加入申请或邀请码接口。
|
||||
|
||||
所有写操作都必须定义提交中、成功、明确失败和结果未知四种结果。只有后端合同明确支持幂等或存在无副作用的结果查询时才允许安全恢复;超时、断网或 5xx 后不能用 timer 自动提示成功,也不能把本地草稿当成服务端已提交。
|
||||
|
||||
### 11.5 数据与状态约束
|
||||
|
||||
- `genealogyId`、`personId`、父母/配偶/子女引用均按稳定词法 ID 处理,不转数字、不截断、不以姓名定位。
|
||||
- 树投影至少明确头像访问结果、姓名、关系类型、代际、父母引用、配偶引用、同辈排行 `sortOrder` 和权限/能力;字段缺失必须进入可解释失败或降级状态,不能静默拼错树。
|
||||
- 关系类型采用单一枚举 owner,页面、API adapter、validator、测试和文档共同消费,禁止各页面维护中文字符串分支。
|
||||
- 排行调整的并发和最终顺序由服务端结果负责;前端可以预览但不能在服务失败后保留假顺序。
|
||||
- 恢复信息不得持久化手机号、头像原图、身份证明、邀请凭据等 PII;只保留完成恢复所需的最小非敏感标识和状态。
|
||||
|
||||
### 11.6 测试先行与实施顺序
|
||||
|
||||
规划经用户确认后,T01/T03—T06 按以下独立小批次执行,每批由主代理写入、两位评审者只读复核接口、业务、交互、异常和视觉,当前批通过后才进入下一批:
|
||||
|
||||
1. **T01-0 失败门禁**:先新增会失败的合同,锁定头像节点、姓名/关系/代际、唯一人物操作面板、十个动作入口,并禁止旧底部操作条与新面板并存。
|
||||
2. **T01-1 树 DTO 与布局**:收紧树 normalizer 和 validator,验证父母、配偶、子女、代际、排行、缺头像、失真 ID、重复节点、断链及循环关系;旧宽 DTO 必须失败。
|
||||
3. **T01-2 视觉与交互**:实现头像人物卡、连接线、选中态、横纵浏览和操作面板;覆盖遮罩、返回键、焦点恢复、重复点击、快速切换人物和迟到响应。
|
||||
4. **T01-3 页面导航合同**:逐项验证 T01 → T03/T04/T05/T06 的参数、返回后选中态和树刷新;邀请绑定若无合同,只验证诚实关闭态。
|
||||
5. **T03/T04/T05/T06 真实接线**:每个页面先写 mapper/runtime 失败测试,再接对应 GET/POST/PUT;六种亲属动作分别验证请求意图,但复用一份 T04 提交合同。
|
||||
6. **异常与恢复**:覆盖空树、加载失败、403、404、409、429、500、超时、断网、离页取消、结果未知、权限变化、头像加载失败和提交后重新读取不一致。
|
||||
7. **性能与视觉验收**:以小树、数十代、数百成员、长姓名、无头像和多配偶数据检查布局预算、滚动流畅度、连接线及内存;不以无限数据承诺替代明确上限。
|
||||
8. **MuMu 与无障碍**:先在 720×1280 实机检查,再补 320/412 响应式检查;验证触控尺寸、文本缩放、读屏名称、焦点顺序、弹层焦点约束、系统返回和低性能设备操作反馈。TalkBack 等无法稳定自动化的项保留可复现人工步骤。
|
||||
|
||||
### 11.7 T01 完成定义
|
||||
|
||||
T01 只有同时满足以下条件才能标记为 `PASS`:
|
||||
|
||||
1. 真实头像或确定性默认头像可见,姓名、关系、代际和连接线正确。
|
||||
2. 点击任一人物只打开一个完整操作面板,十个入口均能到达正确页面/流程,或在后端确实缺失时明确显示服务未开放。
|
||||
3. T03 展示真实详情;T04 六类关系写入、T05 编辑和 T06 排行/关系操作各自遵守唯一 owner,没有 fixture、timer、兼容旁路或假成功。
|
||||
4. 树刷新后能以服务端数据确认变更;写结果未知时停止重复写并提供恢复路径。
|
||||
5. 当前国风视觉一致,长世代滚动、卡片、连接线、遮罩和返回行为在目标尺寸上稳定,无白块、截断、误触和不可读状态。
|
||||
6. 接口、业务、交互、异常、视觉、性能和无障碍门禁通过三人交叉评审;自动测试、编译检查和 MuMu 实机证据齐全。
|
||||
|
||||
### 11.8 当前暂停项
|
||||
|
||||
- 已新增的 `tests/t03-member-api-runtime-smoke.js`、`tests/t03-member-remote-contract.ps1` 以及 `utils/api.js` 中 T03 normalizer 属于规划重整前留下的半成品。T03 页面尚未完成真实接线,因此整体状态是“暂停、未完成”,规划确认后从失败门禁重新核对,不能计入已完成页面。
|
||||
- 当前 T01 已有的文字节点、简单选中态、底部“查看资料/添加亲属”以及任何单张 MuMu 预览都不构成本节验收证据。
|
||||
- 在用户确认本节前不继续修改 T01/T03—T06,不批量处理参考图,不运行构建或 MuMu 验收。
|
||||
|
||||
### 11.9 已完成参考项目的逐项甄别门禁
|
||||
|
||||
参考项目与当前项目可能属于不同版本、不同产品边界、不同技术实现或不同后端合同,因此采用优先级固定为:
|
||||
|
||||
1. 用户对当前产品明确确认的需求和取舍;
|
||||
2. 当前真实后端、Apifox、线上接口文档及可复现响应;
|
||||
3. 当前 UniApp 的路由、架构、安全约束和已确定国风视觉基线;
|
||||
4. 参考截图与已完成参考项目。
|
||||
|
||||
参考项目中的每个候选功能必须逐项回答:
|
||||
|
||||
- 业务目标是否与当前产品一致;
|
||||
- 使用角色、权限来源和数据归属是否一致;
|
||||
- 当前后端是否有对应 operation,请求、响应、枚举和错误语义是否一致;
|
||||
- 是否适合当前 UniApp、Android、导航结构和低性能设备;
|
||||
- 正常、加载、空、失败、无权限、重复提交和结果未知状态是否完整;
|
||||
- 视觉是否能融入当前国风体系,无障碍和触控要求是否满足;
|
||||
- 是否包含旧接口、硬编码、兼容分支、fixture、PII、凭据或其他不可继承风险。
|
||||
|
||||
每个候选功能只能得出以下一种书面结论:
|
||||
|
||||
| 结论 | 含义 | 后续动作 |
|
||||
| --- | --- | --- |
|
||||
| 直接采用 | 业务、合同、交互和视觉均与当前项目一致 | 先写当前项目失败测试,再按本项目结构实现 |
|
||||
| 改造后采用 | 功能有价值,但接口、权限、状态或视觉需要调整 | 在映射表中列出差异和改造边界后实施 |
|
||||
| 仅参考交互 | 只能借鉴信息结构或操作方式 | 不复制其数据层、API 代码和环境配置 |
|
||||
| 后端缺失,暂时关闭 | 当前服务没有可靠 operation 或结果确认链 | 保留诚实关闭态并登记解除条件 |
|
||||
| 与当前产品冲突,明确舍弃 | 违反当前需求、架构、安全或视觉口径 | 记录舍弃原因,禁止后续人员再次误迁移 |
|
||||
|
||||
`docs/产品参考页面功能映射表.md` 对完成项目增加以下列:参考仓库相对路径、参考路由、候选动作、参考状态流、当前目标路由、当前接口证据、差异、甄别结论、改造边界、测试 owner 和用户待确认项。存在分歧或证据不足时标记“待确认”,不得由代理静默选择。
|
||||
|
||||
参考项目不得向当前仓库带入旧后端地址、账号、Token、密钥、签名、应用标识、用户数据、构建产物、依赖缓存或 Git 历史。采用其功能也必须重新通过当前项目的合同测试、运行时测试、编译、MuMu 视觉与三人交叉评审;“参考项目里能运行”不能作为当前项目完成证据。
|
||||
@@ -1,9 +1,9 @@
|
||||
# 家谱项目全量治理实施计划
|
||||
|
||||
> **执行要求:** 实施时使用 `superpowers:executing-plans` 按任务顺序执行,并在每个批次结束后由主代理、Lagrange、Bernoulli 三人复核;宣称批次或阶段完成前必须使用 `superpowers:verification-before-completion`。步骤使用复选框跟踪。用户已明确授权三人依据证据直接确定技术方案,不再为可从页面、接口和现有合同推出的细节反复请示。
|
||||
> **执行要求:** 实施时使用 `superpowers:executing-plans` 按任务顺序执行,并在每个批次结束后由主代理和两位只读评审者三人复核;宣称批次或阶段完成前必须使用 `superpowers:verification-before-completion`。步骤使用复选框跟踪。用户已明确授权三人依据证据直接确定技术方案,不再为可从页面、接口和现有合同推出的细节反复请示。
|
||||
>
|
||||
> 日期:2026-07-22
|
||||
> 状态:阶段 0、导航任务 1—10、TAC 认证客户端任务 20—22、领域上下文任务 24 与 M07 反馈客户端任务 25 已完成;家谱工作区任务 26、M06 帮助任务 27、个人资料读取任务 28、通知读取任务 29、通知已读写入任务 30、个人资料写入任务 31、M10 服务端退出任务 32、M04 密码凭证任务 33、M05 手机号换绑任务 34 和 G03 原子创建任务 35 已完成三人审查及 OpenAPI 红灯;T01、认证、工作区、G03、帮助、profile、通知、logout、password 与 phone-change 后端门禁明确阻塞,MuMu 原生矩阵待执行;当前继续无依赖业务域接口审查
|
||||
> 状态:阶段 0、导航任务 1—10、TAC 认证客户端任务 20—22、领域上下文任务 24 与 M07 反馈客户端任务 25 已完成;任务 26—39 已逐域完成工作区、帮助、资料、通知、退出、密码、换绑、G03、普通加入、邀请码直入、G11 设置与 G12 字辈集合的三人审查及 OpenAPI 红灯;任务 40 正在审查 F01/F03 家族动态列表、详情与一级评论读取边界。这些后端门禁和 MuMu 原生矩阵仍明确阻塞,继续后续无依赖业务域接口审查
|
||||
|
||||
**目标:** 以测试先行方式消除全项目导航栈歧义,完成可安全上线的认证闭环,并在新后端图窗口合同通过后,把 T01 建成可稳定阅读几十代、几百代且连接线连续的大规模世系图;随后逐域关闭真实接口、领域持久化、无障碍与发布门禁。
|
||||
|
||||
@@ -1247,7 +1247,7 @@ T05 的真实更新必须在本步骤一次性拆分宿主页身份 A 与编辑
|
||||
|
||||
- [x] **步骤 1:短信登录、注册和找回密码测试先行接线**
|
||||
|
||||
A01 默认短信登录并以 `APP_SMS_LOGIN` 发码;A04/A05 分别使用 `APP_REGISTER/APP_FORGOT_PASSWORD`。三页当前均按“本地校验→require→TAC challenge/verify→携 `validToken` 发短信→4 位码提交”执行,手机号改变作废旧验证上下文,注册和重设提交不重复 TAC;任务 34 已把生产目标收紧为统一 6 位 `SmsCodeSecret`,后端、生成器、模板与全部消费者必须同版原子替换,当前代码不提前双读。A01 密码登录因 `API-AUTH-TAC-001` 保持可见但不可用;A04 成功保存 `AppLoginVo.access_token` 后直接 `goRoot("G01")`,不能先回 A01 再让用户重复登录;A05 成功使用同一 `AppDialog` 返回 A01。
|
||||
A01 默认密码登录,开发/联调链执行“本地校验→static TAC→线上密码登录 wire→保存 `AppLoginVo.access_token`→`goRoot("G01")`”;默认 mode 保持 mock。当前登录端点不接收/消费 `validToken`,因此客户端 TAC 只是测试前置,`API-AUTH-TAC-001` 继续阻止生产发布。A01 短信发码仍使用 `APP_SMS_LOGIN`,A04/A05 分别使用 `APP_REGISTER/APP_FORGOT_PASSWORD`;三页短信链按“本地校验→require→TAC challenge/verify→携 `validToken` 发短信→4 位码提交”执行,手机号改变作废旧上下文,注册和重设提交不重复 TAC。任务 34 已把生产目标收紧为统一 6 位 `SmsCodeSecret`,后端、生成器、模板与全部消费者必须同版原子替换,当前代码不提前双读。
|
||||
|
||||
- [x] **步骤 2:收紧认证传输生命周期**
|
||||
|
||||
@@ -1305,11 +1305,11 @@ M07 以提交前规范快照锁定请求,提交中禁用类型、内容、联
|
||||
|
||||
- [x] **步骤 2:先写失败合同并固定最小闭包**
|
||||
|
||||
`tests/genealogy-workspace-openapi-contract.ps1` 同时读取受保护 JSON/YAML,要求 `/mine → RListAppGenealogyVo`、`/overview → RAppGenealogyVo`,两层 envelope 的 `code/data` 必填,`data` 精确指向 `AppGenealogyVo`。首批消费字段固定为必填 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`:ID 和名称为非空字符串,三项 capability 为 boolean,角色是至少两个稳定非空值的 enum。测试当前输出 `GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED`;不得删除测试或手改双导出变绿。
|
||||
`tests/genealogy-workspace-openapi-contract.ps1` 先执行受保护 JSON/YAML 深比较,再要求唯一 `appListMyGenealogies/appGetGenealogyOverview`、GET-only/no-body、required SaToken/clientid、词法 `GenealogyId` path、`/mine → RListAppGenealogyVo`、`/overview → RAppGenealogyVo`。两个成功 envelope 精确关闭为 `{code,data}` 且 code=200,data 指向 `AppGenealogyVo`;首批消费字段固定为必填 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`,ID/名称引用共享 owner,三项 capability 为 boolean,角色是至少两个稳定非空值的 enum。响应使用 exact local component ref、固定 private/no-store 和 typed HTTP;测试当前输出 `GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED`,不得删除测试或手改双导出变绿。
|
||||
|
||||
- [ ] **步骤 3:后端关闭 `API-GENEALOGY-WORKSPACE-001`—`003`**
|
||||
|
||||
`001` 关闭 JSON int64 身份失真并补 typed required 闭包;`002` 让 `/mine` 以 required `canView` 或可自动证明的等价 active-only 投影成为当前账号可访问集合真相,并提供稳定角色/能力;`003` 让认证失效、对象无权/撤权、删除和服务异常在文档与部署中可稳定区分。错误可以使用规范 HTTP 状态,也可以继续 HTTP 200+业务码,但 JSON/YAML、运行时 validator 与有效 token 集成反例必须一致。后端必须从同一版本重新导出两份受保护源文件;本地不手工补 schema。
|
||||
`001` 关闭 JSON int64 身份失真并补 typed required 闭包;`002` 让 `/mine` 用 `x-current-account-viewable-only=true` 与 `OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS` 成为当前账号可访问集合真相,并提供稳定角色/能力;`003` 收口为 typed HTTP,不再允许 HTTP 200+业务错误。`/mine` 精确 200/401/429/500,overview 精确 200/400/401/404/429/500;400 为 `GENEALOGY_ID_INVALID`,对象无权/撤权/不存在统一 `NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE`,每个响应只允许 JSON、固定 private/no-store,429 另带 Retry-After。后端必须从同一版本重新导出两份受保护源文件;本地不手工补 schema。
|
||||
|
||||
- [ ] **步骤 4:门禁通过后测试先行接入 G01/G05**
|
||||
|
||||
@@ -1317,7 +1317,7 @@ M07 以提交前规范快照锁定请求,提交中禁用类型、内容、联
|
||||
|
||||
- [ ] **步骤 5:真实账号与 MuMu 验收**
|
||||
|
||||
用至少两个有效账号验证本人列表、跨账号 ID、撤权、删除、401、403/404 或等价业务码、5xx、超时、畸形 JSON、快速切谱与离页取消;再在用户现有 MuMu 中验证加载/空/失败/失效/重选、G01 列表与切换弹层、G05 权限入口和 Android 返回。浏览器和静态合同均不能替代。
|
||||
用至少两个有效账号验证本人列表、跨账号 ID、撤权、删除、typed 400/401/404/429/500、超时、畸形 JSON、快速切谱与离页取消;再在用户现有 MuMu 中验证加载/空/失败/失效/重选、G01 列表与切换弹层、G05 权限入口和 Android 返回。浏览器和静态合同均不能替代。
|
||||
|
||||
### 任务 27:建立 M06 帮助内容远端硬门禁
|
||||
|
||||
@@ -1561,6 +1561,140 @@ G03 当前同页两步只写内存预览,第一步缺可信 regionCode,始
|
||||
|
||||
地区选择覆盖加载/搜索/无结果/失败/迟到/失效;输入具有 label、aria-invalid/describedby、首错聚焦,访问预设使用 radio,所有动作至少 44dp,弹层使用 AppDialog 焦点圈定/恢复,提交中冻结输入与返回并播报 PENDING/unknown/fatal/committed。后端在 control claim、quota、谱、OWNER、根、receipt、终态 CAS 每个写点做 fault injection,验证零残留、同 key 并发唯一、不同 body 409、跨账号/tenant/client status 非泄漏 404、截止前 404 不换 key、截止后 GET 纯计算 FAILED 且数据库零写、迟到 POST 永久拒绝、watchdog 后旧 worker 因 fencing 无法提交;另覆盖 collection/PUT/DELETE/parents 的根身份绕过,并证明根 PUT 仅 `name/sex/birthDate/biography` 四项可编辑、其余字段一律 422。MuMu 固定覆盖 320/360/412 宽度、系统字号、TalkBack、软键盘、日期、双击、慢网/断网/杀进程、Android 返回、context/导航失败;浏览器或源码检查不能代替原生结论。
|
||||
|
||||
### 任务 36:建立普通加入申请闭环远端硬门禁
|
||||
|
||||
- [x] **步骤 1:三人只读核对 G06/G08/G09/G10、双导出、线上部署与现有测试**
|
||||
|
||||
三人确认受保护快照和 2026-07-23 复核的当前线上部署都只有六条宽松 operation,缺少 request-status;身份仍为 int64,申请体含 phone/inviterUserId,mine/pending 共用泄漏 PII 的宽 VO,审核仍是数字 status/auditRemark,成功使用通用 List/Object/Void,匿名 GET 实际 HTTP 200+业务 401 又与文档 401 string 冲突。当前页面仍有 G10 手机号与三处消息中心承诺,但本批不改页面、不接宽松接口。
|
||||
|
||||
- [x] **步骤 2:三人交叉反驳并固定七个 operation、幂等与 CAS 方案**
|
||||
|
||||
七个稳定 operationId、三个专用 cursor page、词法 ID、mine 四状态 discriminator、pending 最小投影和审核两个闭合分支按设计 23.10 固定。普通申请与邀请码直入硬隔离;不引入 applicationVersion/If-Match/审核详情。requestKey 采用 `gja`+issuedAt+至少 128 位 CSPRNG,600/300/120 秒边界机器锁定;canonical scope 覆盖 method/path/genealogyId/tenant/account/client/body。申请+SUCCEEDED、成员+APPROVED 各自在同一业务事务提交,撤回/审核用 `status=PENDING` CAS;静态扩展只登记合同,数据库真实实现另由集成门禁证明。
|
||||
|
||||
- [x] **步骤 3:测试先行新增唯一失败门禁并移交旧 owner**
|
||||
|
||||
新增 `tests/join-application-openapi-contract.ps1`,先执行并取得精确 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`。门禁先运行完整 JSON/YAML 深比较,再检查七个 method/path/operationId、精确参数、required SaToken/clientid、词法 ID、请求体、三个 cursor page、mine/status/mutation discriminator、无 PII schema 闭包、精确成功/错误 response ref、private/no-store、Retry-After 及机器扩展。parameter/response 组件只按 exact local 分区解析,缺 owner 直接失败;幂等、唯一 scope、事务、重验、状态迁移与 cursor 集合必须是真实 JSON 数组,不能用逗号字符串伪装。`g-series-openapi-contract.ps1` 与 `core-flow-contract.ps1` 同轮删除对旧 phone/inviterUserId、数字 status/auditRemark 的正向锁定;专项门禁成为 APP 加入合同唯一 owner。受保护 OpenAPI 未修改。
|
||||
|
||||
- [x] **步骤 4:写入 API-JOIN-001—006 与文档合同**
|
||||
|
||||
`001` 拥有七个 operation、稳定 cursor、词法身份与专用投影;`002` 拥有闭合申请体、JOIN_APPLICATION_TEXT_V1 和旧 schema 删除;`003` 拥有 requestKey、canonical identity、活动 PENDING 唯一约束、三态恢复、同事务 receipt 与零写 FAILED;`004` 拥有撤回 CAS/幂等;`005` 拥有审核分支、事务内权限重验、唯一成员与竞态;`006` 拥有 typed JSON、安全头、部署/数据库反例、客户端状态机和 MuMu 发布门槛。四份中文权威文档与 `current-documentation-contract.ps1` 必须同步,否则本步骤不完成。
|
||||
|
||||
- [ ] **步骤 5:后端关闭 `API-JOIN-001`—`006` 并同版本重导双文件**
|
||||
|
||||
后端须发布七个稳定 owner,删除旧 APP DTO/通用响应/PII 投影,完成 cursor、数据库唯一约束、request control record、fencing/watchdog、PENDING CAS、批准事务和非泄漏错误;以真实账号执行跨账号/tenant/client、同 key 跨谱、并发申请、撤回/审核/反向决定竞态、权限撤销、PUBLIC_APPLY 关闭、超时/5xx/畸形响应、DB fault injection、GET 零写、真实 HTTP/CORS 反例。只接受同版本重导 `APP.openapi.json/yaml`,禁止手改受保护文件。
|
||||
|
||||
- [ ] **步骤 6:门禁通过后测试先行实现客户端状态机与 MuMu 发布验收**
|
||||
|
||||
先写 request marker/status normalizer、cursor、mine/pending、withdraw/review 收敛的纯 Node 失败测试,再实现唯一 adapter/coordinator。POST unknown 查专用 status;撤回 unknown 刷新 mine 并可重放同一 DELETE;审核 unknown 刷新 pending,行消失只称状态变化。账号 epoch 隔离旧 marker,invite source 硬拒普通 POST。原子删除遗留 adapter、G10 手机号、消息中心承诺和 LOCAL_WITHDRAWN;补原生按钮、44dp、提交冻结、aria-invalid/describedby、首错聚焦与状态播报。MuMu 覆盖 320/360/412、1.3 倍字号、TalkBack、软键盘、返回、双击、慢网、断网、杀进程和长拒绝理由。
|
||||
|
||||
### 任务 37:建立邀请码签发与直接加入远端硬门禁
|
||||
|
||||
- [x] **步骤 1:三人只读核对 G06/G08/M08、双导出、当前线上部署与测试**
|
||||
|
||||
受保护双导出和 2026-07-23 复核的线上 OpenAPI 都没有家谱邀请码签发、解析、撤销或直接加入 operation;通用 promotions 和礼仪邀请不属于本域。G06 仍以 `JP2026` 和首个 fixture 假校验,G08 仍信任 `source=invite` 并复用普通申请资格,M08 正确保持不可用。本批不把页面接到不存在的接口。
|
||||
|
||||
- [x] **步骤 2:三方反驳并统一六 operation、G06 直兑和秘密恢复方案**
|
||||
|
||||
三方最终否决邀请码进入 G08:G08 的唯一 owner 收紧为任务 36 普通申请,邀请只在 G06 完成 resolve、可信目标展示、明确确认、redeem 与 status 收敛。首版固定单次、24 小时、至少 128 位随机量;列表/日志不返原码,签发 unknown 只以 marker 中原 genealogyId 和同一 `gii` key 重放无 body POST。600 秒只限制不存在 key 的首次认领;已存在 key 始终定位同一票据,窗内用隔离 KMS 密文恢复原码,窗后返回 metadata+秘密不可恢复且绝不新建,不设并行 issue-status owner。resolve 允许写一个短时 grant 摘要但不消费票据或产生领域写。兑换无姓名/关系 body,以敏感 redemption token+`gir` key 直接创建账号 MEMBER 关系,绝不创建申请或人物关系。
|
||||
|
||||
- [x] **步骤 3:测试先行新增唯一 OpenAPI 失败门禁**
|
||||
|
||||
新增 `tests/invite-ticket-openapi-contract.ps1` 并先取得精确 `INVITE-TICKET-OPENAPI-CONTRACT BLOCKED`。门禁先执行双导出深比较,再锁定六个 method/path/operationId、精确参数与响应、SaToken/clientid、票据/请求 key、单次 24 小时、KMS 幂等重放、仅允许短时 grant 安全写的 resolve、敏感 token、兑换事务、PENDING/SUCCEEDED/FAILED_NO_COMMIT、普通申请字段隔离和 URL/query 零秘密。受保护 OpenAPI 未修改。
|
||||
|
||||
- [x] **步骤 4:写入 `API-INVITE-001`—`006` 与中文权威文档**
|
||||
|
||||
设计 23.11、接口映射 2.15/5.14、总览断点与本实施任务同步记录六 owner、生命周期、签发/撤销、兑换事务、unknown 恢复、隐私限流、页面原子迁移和 MuMu 门槛;文档合同必须锁定这些事实,不能只靠自然语言承诺。
|
||||
|
||||
- [ ] **步骤 5:后端关闭问题单并同版本重导受保护双文件**
|
||||
|
||||
后端发布六个专用 owner、词法 ID、专用 projection/envelope/error,完成 HMAC 查找、KMS 幂等回执、活动票据上限、authorization epoch、单次消费/成员唯一约束、control record、fencing/watchdog 与非枚举限流。真实集成必须覆盖猜码、跨主体、两人同码、同人两码、撤销/兑换、权限降级、活动普通申请、每个事务写点 fault injection、GET 零写、日志零秘密和正式 HTTPS/CORS。只接受同版本重导 JSON/YAML,禁止手改保护文件。
|
||||
|
||||
- [ ] **步骤 6:门禁通过后测试先行原子迁移 M08/G06/G08**
|
||||
|
||||
先写 issue/revoke/resolve/redeem/status normalizer 与 coordinator 纯 Node 失败测试,再接唯一 API adapter。M08 只从 workspace 选谱;G06 删除 `JP2026`、fixture target 与 invite→G08,增加可信确认和 unknown 恢复;G08 原子删除全部 invite source/文案/返回分支,只保留普通申请。SUCCEEDED 必须按 receipt→刷新 `/mine`→安装 context→G01/G05 收口;context/导航失败只重试本地步骤。补原生按钮、44dp、tab/label/aria、首错聚焦、状态播报和 MuMu 完整矩阵。
|
||||
|
||||
### 任务 38:建立 G11 家谱设置版本化写入远端硬门禁
|
||||
|
||||
- [x] **步骤 1:三人只读核对 G11、workspace/G03 共享 owner、双导出、线上部署与现有测试**
|
||||
|
||||
G11 当前只从 fixture 读取,保存仅重置本页 baseline 并用 1800ms timer 显示本地成功;访问预设、保存和结果动作仍是 clickable view。受保护 `PUT /genealogy/app/genealogies/{genealogyId}` 使用宽 `GenealogyUpdateBody`、int64 path、通用 RObject 且只列 200;2026-07-23 线上同路径虽改名 `AppGenealogyUpdateBody → RAppGenealogyVo`,仍有 10 个宽字段、旧 `visibility/joinMode`、int64、无 closed/minProperties/version/If-Match/security/clientid,只有 200/401 与 `*/*`。单谱读取已由任务 26 选定 `/overview`,不能复活通用 GET。后端门禁前不修改页面或接宽松接口。
|
||||
|
||||
- [x] **步骤 2:三人交叉反驳并固定 merge PUT、统一版本与待审迁移方案**
|
||||
|
||||
保留现有 PUT,不新增 PATCH;它只做出现字段的原子 dirty-only merge,不是整资源替换。三方先比较强 ETag+412 与项目既有版本模型,最终统一沿用 T01/M02:`AppGenealogyVo.settingsVersion+required If-Match+typed 409`,不用第二套并发语义。名称统一 G03 的 24 code point,简介 80 且精确空串清空;成功和版本冲突 current 均复用 `AppGenealogyVo`,不新建重复 snapshot。409 精确 three-way oneOf;实际 `PUBLIC_APPLY→MEMBER_ONLY` 遇活动 PENDING 整笔拒绝并引导 G10,检查与申请准入串行化。
|
||||
|
||||
- [x] **步骤 3:测试先行新增唯一 OpenAPI 失败门禁并移交 owner**
|
||||
|
||||
新增 `tests/g11-settings-openapi-contract.ps1`,修复门禁自身集合错误并吸收两位评审的伪绿反例后取得稳定 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`、`Issues: 48`。门禁先执行受保护 JSON/YAML 深比较并消费 workspace 唯一门禁结论,再递归锁全局唯一 PUT/operationId/设置 body owner:`/genealogy/app/genealogies...` 写 body 根层的 `intro` 被设置合同保留,任一单字段设置写、外部或错分区同名 `$ref`、generic GET/PATCH/旁路 settings 写都失败;workspace 对全部 APP GET 递归响应闭包并合并 allOf 属性,拒绝旁路详情。随后检查 exact local `$ref`、精确参数、SaToken/clientid、非 null `GenealogyName/GenealogyIntro/GenealogySettingsVersion`、名称控制字符与简介内部 LF、闭合 1—3 字段 body、If-Match/CAS、canonical 设置实际变化版本范围、冲突 precedence、纯 409 discriminator、typed envelope、required cache headers、固定 private/no-store 与 Retry-After。标量、引用、envelope、fieldErrors 与 role 枚举用 allowed-key 集拒绝 `nullable`、readOnly/writeOnly、冲突组合或额外验证关键字;附加 header 只允许 `traceparent/tracestate/x-request-id/x-correlation-id`,禁止 ETag 与其他语义 header;其 component 必须 exact local,内联 Header Object 必须 allowed-key 关闭并带非 null 的 string schema。任务 36 POST 同轮补 `ATOMIC_SINGLE_WINNER` 反向准入协调、纯 local ref/union 与真实 JSON 数组约束;任务 35 门禁只保留 bootstrap、纯 ref 的非 null 共享 accessPreset 与旧 DTO 删除,G-series 注释同步声明专项 owner。受保护双文件未修改。
|
||||
|
||||
- [x] **步骤 4:写入 `API-SETTINGS-001`—`006` 与机器化中文文档合同**
|
||||
|
||||
设计 23.12、映射 2.16/5.15、总览断点和本任务同步记录全局唯一 read/write、workspace typed HTTP 与旁路详情前置、共享文本/版本 owner、内部 LF/控制字符、dirty merge、canonical 设置实际变化、409 三分支、`ATOMIC_SINGLE_WINNER`、exact local ref、nullable/组合/readOnly/writeOnly 关闭、required 缓存 header、四种允许非语义 tracing header、禁止 ETag、unknown 对账、缓存/context、无障碍与 MuMu 激活条件。`current-documentation-contract.ps1` 和 `interface-page-mapping-contract.ps1` 必须锁定这些事实。三方同时统一:本批不新增无法执行生产 coordinator 的占位 client gate;后端绿后客户端第一项写操作才是实际执行生产模块的纯 Node 失败测试,禁止恒红或源码 token 假门禁。
|
||||
|
||||
- [ ] **步骤 5:后端关闭 `API-SETTINGS-001`—`006` 并同版本重导双文件**
|
||||
|
||||
后端发布全局唯一 `appUpdateGenealogySettings`,删除 generic GET、宽 update DTO、旧 pair 和任何旁路 owner,提供共享字段与 canonical `AppGenealogyVo`;实现当前主体/tenant/权限重验、只在三个 canonical 设置实际变化时更新的 settingsVersion CAS、READY 与待审迁移检查、原子 merge/no-op,以及设置 PUT/普通申请 POST 共同的 `ATOMIC_SINGLE_WINNER` 准入协调。真实集成覆盖单字段/多字段/清空/内部 LF/控制字符/no-op、旧版本、权限撤销、跨主体、活动 PENDING、设置与新申请竞态、每个事务写点 fault injection、500 unknown、正式 HTTP/CORS `If-Match`。只接受同版本重导 JSON/YAML,禁止手改保护文件。
|
||||
|
||||
- [ ] **步骤 6:门禁通过后先写可执行客户端失败测试,再原子迁移 G11**
|
||||
|
||||
第一项写操作必须新增直接执行生产 normalizer/coordinator 的纯 Node 失败测试,覆盖 24/80 Unicode、dirty/clear、If-Match、三个 409、三方比较、unknown 后 overview 对账、401/403/404、重复点击、取消/迟到响应、sessionEpoch、canonical 回填、缓存失效与 context 重验;不允许测试内嵌参考实现或只查函数名。随后实现唯一 adapter/coordinator,原子删除 fixture access、数字 pair、timer、本地成功分支和旧“禁止 appApi”断言。页面迁移到完整状态机、原生 button/radio、label/错误关联/首错聚焦、busy/live/44dp;最后通过 workspace/shared owner、聚焦/全量回归和 MuMu 原生矩阵。OpenAPI 绿只允许开始本步骤,不允许直接接线。
|
||||
|
||||
### 任务 39:建立 G12 字辈集合版本化保存远端硬门禁
|
||||
|
||||
- [x] **步骤 1:三人只读核对 G12、字辈共享 owner、受保护双导出与现有测试**
|
||||
|
||||
页面仍以 fixture、本地快照和 timer 表达未提交预览;受保护导出同时存在 collection POST、单行 PUT、batch preview/save 和 management 多入口,schema 又混用 `{poemText,disableMissing}`、状态 `0/1` 与宽响应,无法证明完整 ACTIVE 集合、稳定 ID、版本 CAS 或 unknown 恢复。三方确认后端门禁前不改页面、不接当前宽松接口,`runtimeConfig.mode` 保持 mock。
|
||||
|
||||
- [x] **步骤 2:交叉反驳并固定唯一 GET/PUT、完整候选与三方对账**
|
||||
|
||||
唯一 owner 固定为 `GET/PUT /genealogy/app/genealogies/{genealogyId}/generation-poems` 与 `appGetGenerationPoemSet/appUpdateGenerationPoemSet`;不引入 preview token/status。GET/PUT 共用 ACTIVE-only `GenerationPoemSetSnapshot`,PUT body 精确 `{items,disableMissing}`。generationNo 唯一排序、声明严格升序;false 保留遗漏 baseline、true 软停用,empty false no-op、empty true 清空 ACTIVE。服务端先校验声明严格升序,再构造声明目标与 merged candidate,随后验证唯一/slot/连续/最终容量、分配新 ID 并原子写;swap 显式声明全部受影响行。唯一并发为 `GenerationPoemSetVersion+If-Match+typed 409`,no-op 保持版本,语义变化版本永不复用;unknown 执行 effective target/old/current 三方比较且不自动 PUT。
|
||||
|
||||
- [x] **步骤 3:测试先行建立唯一 OpenAPI、对抗与 Unicode 门禁并完成 owner 移交**
|
||||
|
||||
新增 `tests/g12-generation-poem-openapi-contract.ps1`、`tests/g12-generation-poem-openapi-adversarial-contract.ps1` 与 `tests/g12-generation-poem-unicode-contract-runtime-smoke.js`。主门禁锁定唯一路径、全局 owner/ref 图、SaToken/clientid、typed HTTP、schema/版本/候选/unknown 与 `APP_GATEWAY_PREFLIGHT`;对抗合同以完整 zero issues seed 逐个验证多跳 ref、callback、HEAD/OPTIONS/TRACE、Reference Object、严格大小写 JSON Pointer、security 数组、schema type、候选顺序、CORS 与 annotation 非 owner;Unicode 冒烟覆盖 NFC、孤立 surrogate、控制/bidi/zero-width 和 50/51 个补充平面 code point。G-series 删除旧 G12 owner,只保留移交说明。已关闭评审复现的大小写假绿、非数组 security 和 example 误杀;当前主门禁保持 `BLOCKED Issues: 72`,对抗与 Unicode 均 PASS。
|
||||
|
||||
- [x] **步骤 4:写入 `API-POEM-001`—`006` 与机器化中文文档合同**
|
||||
|
||||
设计 23.13、映射 2.17/5.16、总览断点和本任务同步记录唯一 owner、ACTIVE-only 模型、complete candidate、disableMissing、软停用、版本 CAS、Unicode、typed HTTP、CORS owner、`FRESH_GET_THREE_WAY_NO_AUTO_PUT`、客户端原子迁移、无障碍与 MuMu 激活条件。`current-documentation-contract.ps1` 和 `interface-page-mapping-contract.ps1` 必须先红后绿,并锁定物理库存与当前发布红灯。
|
||||
|
||||
- [ ] **步骤 5:后端关闭 `API-POEM-001`—`006` 并同版本重导双文件**
|
||||
|
||||
后端删除旧五类旁路,发布唯一 GET/PUT、closed schemas、stable poemId、opaque version、软停用历史与真实权限/READY/CAS 事务;提供顶层 gateway policy 并在部署环境证明 OPTIONS/preflight。集成反例覆盖 0/1/500×50、重复文字、ID move/swap、遗漏策略、碰撞/非连续/超容量、no-op、旧版本、权限/READY 竞态、每个写点 fault injection、500 unknown、跨账号/tenant/client、缓存和日志。只接受同版本重导 JSON/YAML,不手改保护文件。
|
||||
|
||||
- [ ] **步骤 6:后端门禁绿后先写生产行为失败测试,再原子迁移 G12**
|
||||
|
||||
第一项客户端写操作必须直接执行生产 normalizer/coordinator,覆盖 Unicode、请求严格排序、effective target、If-Match、409/422、unknown 三方比较、sessionEpoch、取消/迟到响应、canonical 回填与缓存失效;禁止测试内参考实现或源码 token 假门禁。随后实现唯一 adapter/coordinator,并同轮删除 fixture/timer/本地成功、旧 preview/save 断言和兼容分支。页面补清空确认、saving/unknown/conflict、live region、焦点、44dp 与返回冻结;MuMu 完成宽度、字号、TalkBack、键盘、0/1/500×50、swap、慢网/断网/杀进程、权限撤销、READY 与账号切换矩阵。
|
||||
|
||||
### 任务 40:建立 F01/F03 家族动态读取远端硬门禁
|
||||
|
||||
- [x] **步骤 1:三人只读核对 F01/F03、fixture、受保护双导出、线上 OpenAPI 与现有测试**
|
||||
|
||||
F01 同步调用 `listFamilyFeedFixtures`,query.state 只切换视觉状态,失败重试只是再次读 fixture;F03 通过 `(genealogyId,feedId)` 深拷贝单条 fixture,comments 嵌在 feed 内,失败操作只恢复 ready,评论 timer 只生成明确未提交的本地预览。`utils/api.js` 虽有 dormant `getFeeds` 宽远端分支,但页面没有消费。受保护导出存在 feeds list/page 与 comments list/page 双 GET、无 operationId、int64、通用响应和开放评论模型;线上 3.1.0 仍无 required,评论 DTO 含手机号与内部审核字段。现有 fixture、F01/F03 业务/导航/视觉合同及 JSON/YAML parity 均通过,只证明当前本地预览安全,不证明生产接口可接。
|
||||
|
||||
- [x] **步骤 2:交叉反驳并固定谱内三 owner、最小投影与 cursor 窗口**
|
||||
|
||||
三方否决全局 feed 路由和 offset page,统一保留谱上下文 collection:`appListFamilyFeeds`、`appGetFamilyFeed`、`appListFamilyFeedRootComments`;删除两个 `/page` GET。Feed 最小 closed required 投影为 `{feedId,feedContent,authorDisplayName,publishedAt,hasMedia}`,评论为 `{commentId,commentContent,authorDisplayName,publishedAt}`;不凭 fixture 发明 title/tag,不返回 phone/user/status/audit。`hasMedia` 保证媒体动态不会被静默伪装成完整纯文字。两个列表用默认 20、1—50 的 opaque keyset cursor、无 total,排序分别为 publishedAt/feedId DESC 与 publishedAt/commentId ASC;窗口固定为 `UPPER_BOUND_KEYSET_LATEST_VISIBLE`,不冒充 MVCC snapshot。
|
||||
|
||||
- [x] **步骤 3:测试先行新增唯一 OpenAPI 失败门禁**
|
||||
|
||||
第一项本批写操作新增 `tests/family-feed-read-openapi-contract.ps1`,取得精确 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85`。门禁先验证受保护 JSON/YAML parity,再锁三个全局唯一 operationId、两个旧 `/page` GET 删除、精确参数、SaToken/clientid、词法 ID、closed projection、正文/作者/时间/媒体存在性、两个 cursor page、逐页鉴权、非泄露 404、typed 200/400/401/404/429/500、application/json、private/no-store、Retry-After、CORS owner 与成功 schema 无 PII/审核字段。受保护双文件、runtimeConfig 和页面均未修改。
|
||||
|
||||
- [x] **步骤 4:写入 `API-FEED-READ-001`—`006` 与机器化中文文档合同**
|
||||
|
||||
设计 23.14、映射 2.18/5.17、总览断点和本任务同步记录三 owner、词法身份、最小 projection、`hasMedia`、正常一级评论、cursor scope/order/window、每页 fresh 权限、non-disclosing 404、typed HTTP、部署 CORS、后端/客户端激活顺序、无障碍与 MuMu。`current-documentation-contract.ps1` 和 `interface-page-mapping-contract.ps1` 先红后绿;基线登记新增发布红灯,不能把旧双导出内部 parity 或线上宽 DTO 冒充接口通过。
|
||||
|
||||
- [ ] **步骤 5:用 zero issues 种子、对抗反例和两位评审关闭门禁自身风险**
|
||||
|
||||
`tests/family-feed-read-openapi-adversarial-contract.ps1` 已让完整合法种子返回 zero issues,并以 58 个独立变异拒绝旧 owner、外部/多跳 ref、大小写 Path Item/security/schema、HEAD/OPTIONS/callback 旁路、duplicate operationId、int64、开放 DTO、PII/审核字段、offset/total、cursor 跨作用域、403 资源分流、wildcard 和缺失 cache header,当前输出 `FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58`。两位评审仍须分别实跑主红灯与对抗套件,主代理只在两方都无 P0/P1 后标记完成。
|
||||
|
||||
- [ ] **步骤 6:后端关闭 `API-FEED-READ-001`—`006` 并提供同版本三份证据**
|
||||
|
||||
后端删除两个旧 `/page` GET 和通用/开放/PII 投影,发布三个稳定 operation、词法 ID、closed typed schemas 与 cursor。真实实现逐页重验 tenant/genealogy/membership/feed ownership/visibility,cursor 绑定 actor/session/client/scope/order/window 并验证完整性和过期;覆盖并发新增、删除、编辑、审核隐藏、权限撤销、错谱/跨账号/跨 feed 重放和 400/404 非泄露。只接受同一部署版本重导的 JSON/YAML 与 live `/v3/api-docs`,并以真实账号、正式 HTTP/CORS/OPTIONS 证明,不手改受保护文件。
|
||||
|
||||
- [ ] **步骤 7:门禁绿后先写生产读取失败测试,再原子迁移 F01/F03**
|
||||
|
||||
第一项客户端写操作必须新增直接执行生产 read normalizer/coordinator 的纯 Node 失败测试,覆盖 exact schema、词法 ID、cursor、loading/refresh/load-more/end、详情与评论局部失败、401/404/429/500、畸形 2xx、离页取消、sessionEpoch、切谱/切账号迟到响应、权限撤销、`hasMedia` 诚实占位和缓存键。随后一次性替换 F01/F03,删除 feed 内嵌 comments、fixture 读取锁、伪重试和旧“必须 fixture”断言;远端失败不得回退 fixture。补原生 button、44dp、live region、焦点/滚动恢复、长文/换行及 MuMu 320/360/412、字号、TalkBack、键盘、慢网/断网/返回矩阵。评论写 timer 仍只属于后续独立写批,不得混入读取成功。
|
||||
|
||||
## 后续独立阶段边界
|
||||
|
||||
T01、认证、家谱工作区、M06、个人资料读写、通知、服务端退出和密码凭证当前都被外部合同红灯阻塞,不在本地空等。其余阶段仍逐个重新执行三人只读审查、方案比较、失败测试和最小实施;不得把两个业务域混入同一批:
|
||||
@@ -1574,16 +1708,20 @@ T01、认证、家谱工作区、M06、个人资料读写、通知、服务端
|
||||
7. M04 密码凭证:任务 33 已建立 raw wire、15—64 策略、ALL 会话撤销与崩溃 marker 红灯;必须与登录/注册/找回同批迁移,M04 不单页双轨。
|
||||
8. M05 手机号换绑:任务 34 已建立专用受保护发码、全活动六位 OTP、currentPassword、ALL 会话撤销与旧号 outbox 红灯;必须等认证/M04/profile 前置合同共同通过后原子实施。
|
||||
9. G03 原子创建:任务 35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、可信地区和词法 ID 红灯;必须等本门禁与工作区读取门禁通过后再实现 context/G05/G01 完整回流。
|
||||
10. G、F、R、N/M 其余真实接口:逐页核对页面、业务闭环与线上/双导出三方,缺失合同先形成后端问题单;当前从 G/F/R 选择不依赖既有红灯的下一域。
|
||||
11. 全局文字层级与无障碍第二轮:字号层级、系统字号放大、焦点顺序、读屏语义、对比度和 44dp 触控目标。
|
||||
12. 发布收口:真实环境配置、隐私与权限声明、错误观测、构建签名、升级/回滚、MuMu 全流程回归和三人终审。
|
||||
10. 普通加入与邀请码直入:任务 36/37 已分别建立专项红灯并三人终审;后端关闭后仍须先写可执行客户端状态机测试,再原子迁移页面,两个域始终隔离。
|
||||
11. G11 设置:任务 38 已建立 merge PUT、版本 CAS、待审串行化与 unknown 红灯;后端绿后第一写才建立实际生产 coordinator 行为测试,不复制 G03 空壳门禁。
|
||||
12. G12 字辈集合:任务 39 已固定唯一 GET/PUT、完整候选、版本 CAS、unknown 与 CORS owner;完成门禁/文档/三人终审后等待 API-POEM,同样禁止提前接页。
|
||||
13. F01/F03 家族动态读取:任务 40 已固定谱内三个 cursor GET、无 PII 最小 projection、逐页鉴权与非泄露 404;完成对抗门禁和双评审后等待 API-FEED-READ,后端绿前不接 dormant 宽接口。
|
||||
14. G、F、R、N/M 其余真实接口:继续逐页核对页面、业务闭环与线上/双导出三方,缺失合同先形成后端问题单;从下一个独立业务域继续。
|
||||
15. 全局文字层级与无障碍第二轮:字号层级、系统字号放大、焦点顺序、读屏语义、对比度和 44dp 触控目标。
|
||||
16. 发布收口:真实环境配置、隐私与权限声明、错误观测、构建签名、升级/回滚、MuMu 全流程回归和三人终审。
|
||||
|
||||
不得在同一实现批次混合以上阶段,也不得借后续阶段返工已经通过且无回归证据的响应式页面。
|
||||
|
||||
## 当前计划完成条件
|
||||
|
||||
- 阶段 0 的五份权威中文文档仍是唯一长期入口,没有新建旧版、备份或平行计划。
|
||||
- 导航、T01、认证/TAC、家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑的所有者、旧路径删除条件、测试、MuMu 矩阵和后端接口缺口均已精确写明。
|
||||
- 导航、T01、认证/TAC、家谱工作区、G03 原子创建、普通加入、邀请码直入、G11 设置、G12 字辈集合、F01/F03 家族动态读取、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑的所有者、旧路径删除条件、测试、MuMu 矩阵和后端接口缺口均已精确写明。
|
||||
- 导航任务 1—10 已按测试先行完成两个共享所有者、共享组件、退役入口、认证、G、T、F、R、N/M 系列静态迁移与零债务门禁;没有手工改写 OpenAPI 导出,MuMu 原生矩阵仍按明确边界待执行。
|
||||
- 三人对业务正确性、全局一致性、回归风险、维护成本和 MuMu 可验证性形成一致结论。
|
||||
- T01、认证、家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑 OpenAPI 门禁已经建立并取得预期红灯;TAC 客户端任务 20—22 已完成,任务 23 等待后端合同与 MuMu 原生证据。领域上下文任务 24、M07 客户端任务 25、工作区任务 26、M06 任务 27、profile 读取任务 28、通知任务 29/30、profile 写入任务 31、logout 任务 32、password 任务 33、phone 任务 34 与 G03 任务 35 已完成各自可本地完成的部分;等待后端期间转向 G/F/R 下一域,不把两个阶段混在同一变更中。
|
||||
- T01、认证、家谱工作区、G03 原子创建、普通加入、邀请码直入、G11 设置、G12 字辈集合、F01/F03 家族动态读取、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑 OpenAPI 门禁均须取得预期红灯;TAC 客户端任务 20—22 已完成,任务 23 等待后端合同与 MuMu 原生证据。领域上下文任务 24、M07 客户端任务 25、工作区任务 26、M06 任务 27、profile 读取任务 28、通知任务 29/30、profile 写入任务 31、logout 任务 32、password 任务 33、phone 任务 34、G03 任务 35、加入任务 36、邀请任务 37、G11 任务 38 与 G12 任务 39 已完成各自可本地完成的部分;任务 40 完成对抗门禁和三人终审后立即转向下一个独立业务域,不把两个阶段混在同一变更中。
|
||||
|
||||
@@ -849,7 +849,7 @@ PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relatio
|
||||
|
||||
### 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`。
|
||||
A01 默认密码登录并已接入线上密码 wire、`access_token` 持久化和 G01 跳转;默认 mode 保持 mock。remote 联调先展示 static TAC,但登录端点尚未消费 `validToken`,所以这不是安全闭环,`API-AUTH-TAC-001` 仍阻止发布;微信登录也不得以视觉入口冒充已接通。A01/A04/A05 的短信发码顺序固定为:本地字段和协议校验 → `/captcha/require` → 严格 TAC challenge/verify → 取得服务端 `validToken` → `/genealogy/app/auth/sms/code`。验证码精确 4 位,注册或重设提交只消费短信码,不再重复 TAC。手机号或密码变化、刷新 challenge、切换验证方法、返回或卸载都使旧上下文失效;超时、空响应、非 JSON、重复回调和迟到回调失败关闭并保留表单。
|
||||
|
||||
短信状态覆盖可发送、验证中、发送中、60 秒倒计时、失败重试、到期、手机号变更和重复点击。客户端倒计时不是服务端限流证据;最终仍须用真实服务验证手机号/IP/设备/租户限流、前后台恢复、过期和并发重放。注册成功只读取 `RAppLoginVo → AppLoginVo.access_token`,保存会话后直接进入 G01;找回成功返回 A01,不自动登录。
|
||||
|
||||
@@ -879,17 +879,17 @@ M07 的唯一接口 owner 是 `appApi.submitFeedback`。请求只允许 `feedbac
|
||||
|
||||
### 23.1 家谱工作区只读合同
|
||||
|
||||
家谱工作区第一批唯一选择 `GET /genealogy/app/genealogies/mine` 持有当前账号可访问集合,选择 `GET /genealogy/app/genealogies/{genealogyId}/overview` 持有 G05 展示;语义重复的 `GET /{genealogyId}` 不同时接入。页面不把 fixture 与远端事实混用,也不在两个详情响应之间择优补字段。
|
||||
家谱工作区第一批唯一选择 `GET /genealogy/app/genealogies/mine`(`appListMyGenealogies`)持有当前账号可访问集合,选择 `GET /genealogy/app/genealogies/{genealogyId}/overview`(`appGetGenealogyOverview`)持有 G05 展示;两个 operationId 全局唯一,路径只开放 GET 且无 request body,语义重复的 `GET /{genealogyId}` 不同时接入。页面不把 fixture 与远端事实混用,也不在两个详情响应之间择优补字段。
|
||||
|
||||
线上 `AppGenealogyVo.genealogyId` 当前是 JSON `integer/int64`。最大合法 int64 经 JavaScript JSON 解析后会不可逆失真,事后 `String()` 无法恢复,因此响应身份必须改为非空词法字符串;URL path 在 wire 上本是文本,手写客户端可保持词法字符串,不机械把 path 声明本身当成同一阻塞。仅拒绝 unsafe number 可以失败关闭,却会使 OpenAPI 合法用户永久不可用,只能用于关闭功能开关的预研,不能作为正式兼容方案。
|
||||
线上 `AppGenealogyVo.genealogyId` 当前是 JSON `integer/int64`。最大合法 int64 经 JavaScript JSON 解析后会不可逆失真,事后 `String()` 无法恢复,因此响应身份与 overview path 都必须引用非空、1—128 位 URL-safe 词法 `GenealogyId`;仅拒绝 unsafe number 可以失败关闭,却会使 OpenAPI 合法用户永久不可用,只能用于关闭功能开关的预研,不能作为正式兼容方案。
|
||||
|
||||
首批最小实体只消费 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`。两层响应 envelope 的 `code/data` 与这些实体字段必须进入 `required`;ID、名称为非空字符串,三项 capability 为布尔值,`roleType` 提供至少两个稳定非空枚举供 G01 分组和标签。其他字段保持可选:地点、堂号、人数、简介等有值才展示;未知额外响应字段允许忽略。若产品坚持保留当前 fixture 的来源、管理者、认证、始祖、上级谱、支系、更新时间和激活人数,则后端必须另补合同;首批不为复刻 mock 强制 22 个字段全部必填。
|
||||
首批最小实体只消费 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`。`RListAppGenealogyVo/RAppGenealogyVo` 都是关闭额外字段且精确只有 required `code/data` 的成功 envelope,`code` 固定 200,data 分别为 `AppGenealogyVo[]` 与单个 `AppGenealogyVo`;实体上述字段进入 required,ID 与名称分别引用 `GenealogyId/GenealogyName`,三项 capability 为非 null 布尔值,`roleType` 提供至少两个稳定非空枚举供 G01 分组和标签。`AppGenealogyVo` 其他字段保持可选:地点、堂号、人数、简介等有值才展示,未知额外实体字段允许忽略。若产品坚持保留当前 fixture 的来源、管理者、认证、始祖、上级谱、支系、更新时间和激活人数,则后端必须另补合同;首批不为复刻 mock 强制 22 个字段全部必填。
|
||||
|
||||
`canView` 是进入 `availableIds` 的授权投影,不能从 `roleType/status/memberStatus` 猜值;后端也可以改为能够由自动化证明“`/mine` 只返回当前仍可查看 ACTIVE 成员”的等价合同。G01 仅在新列表成功校验后 reconcile;网络、超时、5xx 和畸形响应保留旧现场并显示错误,明确无权、撤权或不存在才写 tombstone。G05 每次加载先清空旧数据、取消迟到请求,只消费 `/overview`;能力缺失按最低权限处理,不回退本地角色。
|
||||
`canView` 是进入 `availableIds` 的授权投影,不能从 `roleType/status/memberStatus` 猜值;`/mine` 以 `x-current-account-viewable-only=true` 和 `x-revocation-policy=OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS` 机器声明只返回当前账号仍可查看的家谱。G01 仅在新列表成功校验后 reconcile;网络、超时、5xx 和畸形响应保留旧现场并显示错误,成功列表确认消失才写 tombstone。G05 每次加载先清空旧数据、取消迟到请求,只消费 `/overview`;对象无权、撤权与不存在统一 `NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE`,能力缺失按最低权限处理,不回退本地角色。
|
||||
|
||||
当前线上 OpenAPI 没有有效 security 声明且媒体写作 `*/*`,但无令牌实测三条读取均被拒绝、实际 Content-Type 为 JSON,因此两项是必须修复的发布文档质量问题,不单独声称已经公开泄漏。真正发布门禁还包括有效令牌下的无凭证、跨账号、撤权、删除、401/403/404/5xx、畸形 JSON 与取消反例。当前部署使用 HTTP 200+业务 `code=401`;后端可以保留业务码承载或改为规范 HTTP 状态,但文档、运行时 validator 与部署行为必须一致。
|
||||
当前线上 OpenAPI 没有有效 security 声明且媒体写作 `*/*`,但无令牌实测三条读取均被拒绝、实际 Content-Type 为 JSON,因此两项是必须修复的发布文档质量问题,不单独声称已经公开泄漏。生产合同不再允许 HTTP 200 包业务错误:两条读取都 required SaToken 与 1—128 位非空 clientid,只允许 typed `application/json`;`/mine` 精确 200/401/429/500,`/overview` 精确 200/400/401/404/429/500,分别固定 `GENEALOGY_ID_INVALID/AUTH_REQUIRED/GENEALOGY_NOT_AVAILABLE/RATE_LIMITED/GENEALOGY_WORKSPACE_UNAVAILABLE`。每个响应必须包含固定单值 enum `Cache-Control: private, no-store`,429 另有 1—300 秒 Retry-After;只允许 `traceparent/tracestate/x-request-id/x-correlation-id` 四种非语义 tracing header,禁止 ETag 与其他语义 header。component parameter/response/header/schema 解析只接受 exact local `#/components/<section>/...` 引用,拒绝外部或错分区 `$ref` 冒充 owner;四种 tracing header 的 component 引用也必须 exact local,内联时只接受 allowed-key 关闭且带非 null 的 string schema 的 Header Object。clientid、成功/错误 code/data/businessCode、实体字段与 role 枚举都用 allowed-key 集拒绝 `nullable`、readOnly/writeOnly、冲突组合或其他未拥有的验证关键字。真正发布门禁还包括有效令牌下的无凭证、跨账号、撤权、删除、typed HTTP、畸形 JSON 与取消反例。
|
||||
|
||||
`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` 错误语义及对象级授权行为。
|
||||
`tests/genealogy-workspace-openapi-contract.ps1` 以结构化 JSON 为判断对象并先要求受保护 JSON/YAML 语义深比较通过,唯一拥有上述 path/method/operationId、SaToken/clientid、词法参数、响应身份、typed envelope、required cache headers、required 与 capability/role 边界;它全局递归扫描 APP GET 的 200 schema 闭包,除 `/overview` 外任何返回 `RAppGenealogyVo/AppGenealogyVo` 或结构等价单谱实体的旁路详情都失败,`/mine` 的数组投影不误算成单谱读取。当前旧双导出与线上文档都不能通过。G11 门禁只消费该门禁的 PASS 结论,不复制 overview 合同。门禁关闭前不建立 G01/G05 专属字段 adapter,不让客户端成为第二份猜测合同。问题固定为 `API-GENEALOGY-WORKSPACE-001` 无损身份与最小 schema 闭包、`API-GENEALOGY-WORKSPACE-002` 可访问集合与能力投影、`API-GENEALOGY-WORKSPACE-003` typed HTTP、缓存与对象级授权行为。
|
||||
|
||||
### 23.2 M06 帮助内容只读合同
|
||||
|
||||
@@ -1017,6 +1017,96 @@ POST required SaToken、非空 clientid 和 `Idempotency-Key`。`GenealogyBootst
|
||||
|
||||
页面实施必须同步删除伪提交 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 原生交互/无障碍验收,任一红灯都不得开放真实创建。
|
||||
|
||||
### 23.10 G06/G08/G09/G10 普通加入申请合同
|
||||
|
||||
普通申请与邀请码直入是两个独立业务合同。任务 36 只覆盖七个 APP operation:`appSearchPublicGenealogies`、`appCreateGenealogyJoinApplication`、`appGetGenealogyJoinApplicationRequest`、`appListMyGenealogyJoinApplications`、`appWithdrawGenealogyJoinApplication`、`appListPendingGenealogyJoinApplications`、`appReviewGenealogyJoinApplication`。邀请码成功后直接建立成员关系且不生成审核记录,后续必须使用独立票据和直接加入接口;G08 的 `source=invite` 不得调用本批普通申请 POST。
|
||||
|
||||
`API-JOIN-001` 固定三个专用 cursor page 和词法身份。搜索、mine、pending 都使用 `limit=1..50`、opaque cursor、无 `total`;cursor 绑定 tenant/account/client 与过滤条件。搜索按 `updatedAt DESC, genealogyId DESC`,mine/pending 按 `submittedAt DESC, applyId DESC`,不得跨投影复用不存在的排序字段。`GenealogyId/JoinApplicationId` 均为 1—128 位 URL-safe 词法字符串,禁止 JSON int64。搜索项只含识别家谱所需的谱名、姓氏、地区、可选堂号/上级谱/支系/认证标签/成员数/更新时间与单一 `viewerState`;不同时返回可冲突的 `canApply`。mine 用 discriminator 精确区分 `PENDING/APPROVED/REJECTED/WITHDRAWN`,只有 REJECTED 必填申请人可见 `rejectionReason`;pending 只含 `applyId/applicantName/relationDesc/applyReason?/submittedAt`,递归禁止 phone、用户、邀请人和审核人身份。
|
||||
|
||||
`API-JOIN-002` 固定闭合申请体 `applicantName/relationDesc/applyReason?`,前两项必填,长度依次为 1—50、1—100、1—500。`JOIN_APPLICATION_TEXT_V1` 是本域唯一文本规范:Unicode NFC、去首尾边界空白、CRLF/CR 归一为 LF、不折叠内部空白并按 Unicode code point 计长。旧 `GenealogyJoinApplyBody/GenealogyJoinAuditBody`、`phone/inviterUserId` 与数字 `status/auditRemark` 不再是 APP owner;通用 G-series 与 core-flow 测试必须把所有权移交给专项门禁。
|
||||
|
||||
`API-JOIN-003` 固定无 PII 幂等与崩溃恢复。`GenealogyJoinApplicationRequestKey` 为 `gja.{13 位 issuedAt 毫秒}.{22—43 位 base64url CSPRNG}`,随机量至少 128 位;服务端按时间固定 10 分钟接受窗、5 分钟未来偏差与认领后最多 2 分钟收敛。canonical identity 覆盖 method、规范 path、genealogyId、tenant、account、client 与规范 body。同 scope/key/request 重放同一 `GenealogyJoinApplicationReceipt`,不同 digest 返回 `IDEMPOTENCY_KEY_REUSED`;同 tenant/account/genealogy 最多一个活动 PENDING,必须由数据库唯一约束保证。幂等 scope、活动唯一 scope、事务 effects、重验字段、状态迁移与 cursor 机器扩展都必须是真实 JSON 数组,逗号字符串不能冒充集合。POST 在领域事务内重验 genealogyState=READY、accessPreset=PUBLIC_APPLY 与 applicationEligibility,并与 G11 设置 PUT 共同声明 `x-public-apply-coordination=ATOMIC_SINGLE_WINNER`;关闭公开申请与新准入竞态只能一方提交。申请领域行和 SUCCEEDED 回执在同一业务事务提交,resolver 判定 FAILED 前不得把已提交申请误报为零写。
|
||||
|
||||
状态唯一 owner 是只读 `GET /genealogy/app/genealogies/join-apply-requests/{requestKey}`。响应以显式 mapping 的 oneOf 精确为 `PENDING{resolveBy,retryAfterSeconds}`、`SUCCEEDED{result}`、`FAILED_NO_COMMIT`;只允许 `ABSENT→PENDING→SUCCEEDED/FAILED_NO_COMMIT`,终态不可变。GET 不创建墓碑、不推进状态;接受窗内未知 key 返回带 acceptUntil/Retry-After 的非泄漏 404,窗后无记录按 key 时间零写计算 FAILED,迟到 POST 永久拒绝。跨 account/tenant/client 统一 404。客户端只持久 `{sessionEpoch,requestKey,startedAt}`;当前进程可用冻结 snapshot 重放,重启只查 status,不把姓名、关系、理由或完整 body 落盘。
|
||||
|
||||
`API-JOIN-004` 固定撤回 CAS:DELETE 只允许当前申请人撤回 PENDING;`WHERE status=PENDING` 决定唯一赢家,相同撤回只返回 WITHDRAWN typed receipt,审核已赢或其他终态返回 typed 409 与无 PII current state。`API-JOIN-005` 固定审核 CAS:body 只允许 `{decision:APPROVE}` 或 `{decision:REJECT,rejectionReason}` 两个闭合分支;相同决定只重放 APPROVED/REJECTED 原 200,规范化后不同拒绝理由或相反决定返回 409,审核成功绝不返回 WITHDRAWN。批准在一个事务内重验权限、READY 与 PUBLIC_APPLY,完成 PENDING CAS、唯一成员关系和 APPROVED;入口 capability 不是服务端授权证明。POST 的 409 只引用 `RJoinApplicationKeyConflict`,DELETE/PUT 的 409 只引用带 current 的 `RJoinApplicationStateConflict`;禁止用联合两类业务码的宽响应 owner 产生跨操作假绿。key 复用、过期或活动 PENDING 冲突不强制携带不存在的申请 current,只有申请状态/决定冲突分支必带 current。不引入 applicationVersion、If-Match 或审核详情端点。
|
||||
|
||||
`API-JOIN-006` 固定部署与客户端发布门槛。七个 operation required SaToken/非空 clientid,所有声明响应只允许 `application/json` 与 `Cache-Control: private, no-store`,429 带 Retry-After;禁止 `*/*`、default、int64 身份、通用 RList/RObject/RVoid、HTTP 200 包装认证错误。OpenAPI 静态扩展只能登记幂等、cursor、CAS 和事务意图,不能证明数据库实现;后端还须用数据库观测、并发与 fault injection 验证唯一约束、零写、事务、fencing、真实 HTTP/CORS。客户端门禁通过前 G06/G08/G09/G10 继续本地预览;接线时原子删除 G10 手机号、三处消息中心承诺、LOCAL_WITHDRAWN 和遗留 adapter,补齐原生按钮、44dp、提交冻结、状态播报、错误关联、首错聚焦、长文换行及 MuMu 的系统字号/TalkBack/键盘/返回/慢网/杀进程矩阵。
|
||||
|
||||
`tests/join-application-openapi-contract.ps1` 是上述七个 operation、schema 与响应闭包的唯一静态 owner,当前精确输出 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`。只有后端同版本重导 JSON/YAML、专项门禁、后端集成反例、客户端状态机、全量回归和 MuMu 发布矩阵全部通过,普通加入申请才可称为上线闭环;静态门禁单独转绿不开放页面。
|
||||
|
||||
### 23.11 G06/M08 邀请码签发与直接加入合同
|
||||
|
||||
任务 37 与普通申请完全隔离。唯一产品流程为:M08 管理自己有权签发的活动邀请;G06 在 JSON body 中安全解析邀请码(不消费票据、不建立成员,仅允许轮换短时 grant 摘要),展示可信的最小家谱目标和“确认后立即成为成员、不进入审核”,用户明确确认后直接兑换;成功刷新 workspace、安装家谱 context,再进入 G01/G05。邀请码流程不进入 G08,不提交 `applicantName/relationDesc/applyReason`,不创建 join-application、PENDING 或审核通知。真实接线时必须原子删除 G06→G08 的 invite 导航、G08 `source=invite` 分支、`JP2026`、首个 fixture 目标和普通 `canApply` 资格复用;成员资料或人物关联另立合同。
|
||||
|
||||
`API-INVITE-001` 固定六个 operation:`appListMyGenealogyInviteTickets`、`appIssueGenealogyInviteTicket`、`appRevokeGenealogyInviteTicket`、`appResolveGenealogyInviteTicket`、`appRedeemGenealogyInviteTicket`、`appGetGenealogyInviteRedemptionRequest`。所有 operation required SaToken、非空 clientid、typed JSON 和 private/no-store;429 带 Retry-After。邀请码只能置于 resolve 的 JSON body,绝不进入 URL、query、导航、持久化、日志、APM、分析、自动测试、崩溃报告或发布证据截图;用户主动显示/复制/系统分享属于受提示的显式动作。列表只返回当前活动票据元数据,不返回原码、领取人或剩余目标身份。
|
||||
|
||||
`API-INVITE-002` 固定首版为单次、24 小时、服务端时间判定的 opaque bearer ticket。原码使用版本前缀和 26 位 Crockford Base32 随机量,至少 128 位 CSPRNG 熵;展示可分组,canonical 校验不接受易混淆字符。数据库以 HMAC/摘要查找,仅为同 key 幂等重放使用隔离 KMS envelope encryption 保存原码 600 秒;恢复窗内重放同一 ticket 和原码,窗后只返回同一 ticket 的无秘密 metadata 与 `ISSUED_SECRET_UNAVAILABLE`,不得新建票据。列表永不恢复原码。状态唯一为 `ACTIVE→CONSUMED/REVOKED/EXPIRED`,终态不可逆。resolve 不消费票据或建立成员,但允许写入/轮换每个 tenant/account/client/ticketVersion 唯一的短时 grant 摘要;旧 grant 立即失效、TTL 最多五分钟并清理。redemption token 绑定 tenant/account/client/inviteTicketId/ticketVersion/genealogyId/authorizationEpoch;这里的 authorizationEpoch 是服务端邀请授权版本,签发者移除/降权、邀请功能关闭或票据版本变化时提升并使旧 grant 失效。编辑输入、切账号或 session epoch 变化立即销毁客户端内存 token。
|
||||
|
||||
`API-INVITE-003` 固定签发与撤销权限。M08 从 workspace `/mine` 取得当前明确家谱,不新增可信 URL genealogyId;workspace 投影字段 `canInviteMembers` 唯一映射服务端 capability `INVITE_MEMBER`,服务端仍重验 READY、邀请功能和当前成员关系。POST 无可配置 body,并用数据库约束控制每个 tenant/genealogy/issuer 的活动票据上限。签发 `Idempotency-Key` 为 `gii.{13 位毫秒}.{22—43 位 base64url CSPRNG}`;600 秒只限制不存在 key 的首次认领,已存在 key 永远定位同一 ticket,不同 digest 优先 409,恢复窗内重放原码、窗后只返 metadata,绝不签第二枚。客户端 marker 固定 `{sessionEpoch,genealogyId,requestKey,startedAt}`,不得从重启后的当前 workspace 猜 genealogyId。KMS 密文、ticket、HMAC lookup 和 request receipt 作为单一提交单元,任一步失败零票据。DELETE 只允许签发者本人管理并做 ACTIVE→REVOKED CAS,相同撤销重放原 200;CONSUMED/EXPIRED 返回 typed 409 current state,跨签发者统一 404。权限降级或邀请功能关闭必须让活动票据和旧 redemption token 立即失效。
|
||||
|
||||
`API-INVITE-004` 固定兑换事务。redeem 无 request body,只通过 required、敏感的 `Genealogy-Redemption-Token` header 和 `gir` Idempotency-Key 消费当前账号 grant。服务端在同一事务内重验 token 绑定/有效期、票据 ACTIVE/版本/未过期、签发者权限、家谱 READY/邀请功能、账号可加入与容量,执行 ACTIVE→CONSUMED CAS、唯一 `(tenant,genealogyId,account)` MEMBER 关系和 SUCCEEDED receipt;失败全部回滚。不得建立人物、亲属关系或姓名副本。同码两账号、同账号两码、撤销/兑换、权限撤销/兑换竞态都只能有一个事务赢家;失败事务不得消费票据。已有普通 PENDING 固定 `ACTIVE_PENDING_APPLICATION`,引导先在 G09 撤回,邀请流程不得暗改任务 36 状态。
|
||||
|
||||
`API-INVITE-005` 固定结果未知恢复。兑换 request key 采用 `gir.{issuedAt}.{CSPRNG}`,600 秒接受窗、300 秒未来偏差、认领后 120 秒内收敛;状态纯读 GET 只允许 `ABSENT→PENDING→SUCCEEDED/FAILED_NO_COMMIT`,终态不可变,已存在终态优先于 absent 计算,FAILED 保证票据未消费且成员未创建。成员、票据消耗和成功回执同事务提交,resolver/watchdog 以 fencing CAS 收敛,旧 worker 不得迟交。终态回执至少保留 30 天,客户端 marker TTL 为 30 天并先于回执清理;任何清理后也不得凭控制记录缺失否定已提交领域写。客户端 dispatch 前只持久 `{sessionEpoch,requestKey,startedAt}`,不持久邀请码、redemption token、目标或 body;SUCCEEDED 后刷新 `/mine` 并只重试本地 context/导航收口,FAILED 回 G06 重新输入,unknown 不换 key。
|
||||
|
||||
`API-INVITE-006` 固定安全、交互与发布反例。invalid/expired/revoked/consumed/跨 tenant 统一非枚举 `INVITE_TICKET_NOT_AVAILABLE`,resolve 需要账号、设备/IP、前缀和全局多维限流与时间侧信道控制。M08 覆盖加载、无权限、无票据、签发/unknown、原码显示/复制/分享、即将过期、撤销/竞态;G06 覆盖输入、格式错、解析、可信目标、明确确认、redeem PENDING/unknown、已加入、活动普通申请、限流、票据失效、账号切换和迟到响应。原生按钮、tablist/tab/aria-selected、label、aria-invalid/describedby、首错聚焦、44dp、状态播报、长码/长谱名换行和提交冻结均为发布门槛;MuMu 覆盖 320/360/412、1.3 倍字号、TalkBack、键盘、粘贴、双击、慢网/断网、杀进程、Android 返回与系统分享取消。后端还须用真实账号、数据库观测、并发、fault injection、日志审计和正式 HTTP/CORS 证明合同,OpenAPI 扩展不能替代实现证据。
|
||||
|
||||
### 23.12 G11 家谱设置版本化写入合同
|
||||
|
||||
任务 38 只治理 G11 的设置读取与写入,不混入 G12 字辈、成员管理、邀请码签发或普通申请审核。全局唯一写 owner 保留 `PUT /genealogy/app/genealogies/{genealogyId}`,operationId 固定为 `appUpdateGenealogySettings` 且全局只出现一次;不新增 PATCH、`/settings` 旁路或任何递归引用设置 body/包含任意一个 `genealogyName/intro/accessPreset` 单字段的第二写入口,外部或错分区同名 `$ref` 也按违规 owner 失败,只有精确 G03 建谱 POST 豁免。为消除裸 `intro` 歧义,`/genealogy/app/genealogies...` 写 body 根层的 `intro` 由设置合同保留,子域必须使用自身有界名称。单谱唯一读取 owner 继续是 `GET /genealogy/app/genealogies/{genealogyId}/overview`,同版删除重复的通用 GET,并对全部 `/genealogy/app/` GET 以响应 schema 闭包和 allOf 属性合成拒绝任何旁路详情;G11 只消费 workspace 唯一门禁的 PASS 结论,每次进入必须 fresh 读取 overview 后才建立可编辑 baseline,不从路由、`/mine` 卡片、G05 旧内存对象、`roleType` 或 fixture 推导当前设置与权限。
|
||||
|
||||
`API-SETTINGS-001` 固定共享字段与唯一模型。家谱 ID 继续引用词法 `GenealogyId`;新增共享 `GenealogyName`,统一 G03 创建、G11 更新和 `AppGenealogyVo` 为非 null、NFC、无边界空白、无换行/控制字符的 1—24 Unicode code point,删除 G11 旧 20 字双上限。共享 `GenealogyIntro` 精确允许非 null 空串,或 NFC、无边界空白、1—80 code point 的内容;正文只允许内部 LF,拒绝 CR、tab 和其他控制字符,空串就是清空,省略才是保持,null 与纯空白非法。`GenealogyAccessPreset` 仍由任务 35 作为 APP 读/建/改唯一枚举 owner,只有 `MEMBER_ONLY/PUBLIC_APPLY`。overview 与 PUT 200 都复用关闭额外字段、精确 `{code,data}` 的 `RAppGenealogyVo → AppGenealogyVo`;后者 required `genealogyId/genealogyName/intro/accessPreset/settingsVersion/canManage`,不新建会重复字段规则的 settings snapshot。
|
||||
|
||||
`API-SETTINGS-002` 固定原子 dirty-only merge。请求 body 唯一为关闭额外字段、`minProperties=1/maxProperties=3` 的 `AppGenealogySettingsUpdateBody`,属性精确为 `genealogyName/intro/accessPreset` 且都非 required;出现字段更新、省略字段保持,body 不携带 version。服务端在同一事务锁定家谱并重新校验权限后一次应用全部出现字段,不能按整资源 PUT 把其他字段覆盖。canonical 后实际无变化可返回 200,但必须保留原版本且除审计外不产生领域副作用;客户端 clean draft 永不发请求。
|
||||
|
||||
`API-SETTINGS-003` 固定唯一并发模型。新增非 null、1—128 位 URL-safe opaque `GenealogySettingsVersion`,只允许比较,不得解析、递增或经过 JavaScript Number;它以 `x-version-scope-fields=[genealogyName,intro,accessPreset]` 和 `x-version-change-policy=CANONICAL_SETTINGS_CHANGE_ONLY` 锁定只在这三个 canonical 设置实际变化时更换,其他领域写与 canonical no-op 都不得误增版本。`AppGenealogyVo.settingsVersion` required,PUT required `If-Match` 引用同一 owner,请求 body 不复制 version。项目统一采用 typed HTTP 409,而不是为 G11 单独引入强 ETag/412。409 `RGenealogySettingsConflict` 根层只允许 `oneOf/discriminator` 两个关键词,每个分支只能是 exact local schema ref;`GENEALOGY_SETTINGS_VERSION_CHANGED` 分支 required `current: AppGenealogyVo`,`GENEALOGY_NOT_READY` 与 `ACTIVE_PENDING_APPLICATIONS` 分支不得带 current 或审核数据。只有仍具管理权限的调用者可得到版本/current;服务端事务内重验权限。冲突优先级固定为 version → READY → active PENDING,旧 writer 不得用过期 baseline 触发业务判断。
|
||||
|
||||
`API-SETTINGS-004` 固定访问预设迁移与普通申请并发。活动 PENDING 只阻断实际的 `PUBLIC_APPLY→MEMBER_ONLY`:只改名称/简介、已经是 MEMBER_ONLY 的 no-op 或 `MEMBER_ONLY→PUBLIC_APPLY` 不得被误阻断;同一请求还改名称时,只要访问预设迁移被阻断,整笔请求零写。活动 PENDING 返回 `ACTIVE_PENDING_APPLICATIONS`,G11 引导去 G10 处理,不自动拒绝、撤回或迁移申请;邀请码合同不受影响。设置 PUT 和任务 36 新申请 POST 都必须在事务内重验 genealogyState/accessPreset(申请另重验 applicationEligibility),并共同声明 `x-public-apply-coordination=ATOMIC_SINGLE_WINNER`;PENDING 检查、preset 更新和新申请准入竞态只能一方提交,不把实现限制为特定数据库隔离级别。
|
||||
|
||||
`API-SETTINGS-005` 固定响应、结果未知与缓存收口。PUT 精确声明 200/400/401/403/404/409/422/429/500,全部只允许 typed `application/json`;每个 response 必须包含固定单值 enum `Cache-Control: private, no-store`,429 另有 1—300 秒 `Retry-After`,只允许 `traceparent/tracestate/x-request-id/x-correlation-id` 非语义 tracing header,禁止 ETag 与其他语义 header。parameter/response/header/schema 解析只接受 exact local component ref;四种 tracing header 若内联,必须是 allowed-key 关闭且带非 null 的 string schema 的 Header Object,不能以 external/wrong-section ref 或任意 inline schema 绕过。所有标量、引用字段、成功/错误 envelope 和 fieldErrors 用 allowed-key 集拒绝 `nullable`、readOnly/writeOnly、冲突组合与其他未拥有的验证关键字。禁止 default、3xx、`*/*`、通用 RObject/Void、可加 debug 字段的宽 envelope 和 HTTP 200 包业务错误。200 返回完整 canonical `RAppGenealogyVo`;400、401、403、404、422、429 为确定失败,500 `GENEALOGY_SETTINGS_OUTCOME_UNKNOWN`、网络、超时、408、取消和畸形 2xx 都是结果未知。客户端提交时冻结 `{baseline,dirtyPatch,settingsVersion,sessionEpoch}`,未知后不自动第二次 PUT,而 fresh 读取 overview:全部脏字段等于目标只可陈述“当前设置已与所选内容一致”,不能证明本次请求成功;仍为 baseline 才允许用户明确重试;部分匹配或第三值进入字段级三方比较。成功原子回填 canonical baseline,失效 `/mine`、overview、公开搜索和公开预览缓存并重新校验 context;权限/成员关系已变时清 context 回 G01,否则回 G05。
|
||||
|
||||
`API-SETTINGS-006` 固定客户端与发布激活条件。本批只建立 OpenAPI 门禁与权威文档,不创建因生产 coordinator 不存在而恒红、只扫描源码 token 或复制 OpenAPI 断言的占位 client gate。后端同版双导出、workspace 读取和共享 owner 门禁转绿后,客户端批次第一项写操作必须先新增实际执行生产 normalizer/coordinator 的纯 Node 失败测试,再实现 adapter/coordinator;覆盖 24/80 code point、dirty/clear、三个 409、三方比较、unknown 对账、权限撤销、双击/取消/迟到响应、sessionEpoch、canonical 回填、缓存/context 收口。随后才可原子删除 fixture 数字映射、本地成功 timer 和“禁止 appApi”旧断言。页面至少有 loading、ready/dirty、saving、saved、validation、uncertain/reconciling、conflict、no-permission、auth-expired、unavailable;用原生 button/radio、真实 label、aria-invalid/describedby、首错聚焦、busy/live 状态、44dp 与返回冻结。G11 OpenAPI、workspace/shared owner、可执行客户端行为门禁、聚焦/全量回归及 MuMu 320/360/412、1.3 倍字号、TalkBack、软键盘、双击、慢网/断网/杀进程、Android 返回与权限/版本竞态必须全部通过,OpenAPI 绿只表示可以开始客户端 TDD,不表示允许直接接线或上线。
|
||||
|
||||
`tests/g11-settings-openapi-contract.ps1` 是全局唯一设置写入口、body、settingsVersion/If-Match、409 oneOf、响应矩阵与设置字段 owner 的唯一静态门禁;任务 35 的 G03 门禁只继续拥有 bootstrap、共享 accessPreset/旧 DTO 删除及其读取传播,不再检查设置 operation、body 或响应,workspace 与 join 门禁分别唯一拥有读取基础和申请准入反向协调。当前专项门禁稳定输出 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED` 和 48 项缺口;受保护 OpenAPI 未修改。
|
||||
|
||||
### 23.13 G12 字辈集合版本化保存合同
|
||||
|
||||
任务 39 只治理 G12 字辈集合读取与原子保存,不混入逐条编辑、人物世代、T01 高亮、邀请码或 G11 设置。`API-POEM-001` 固定全局唯一 owner 为 `GET/PUT /genealogy/app/genealogies/{genealogyId}/generation-poems`,operationId 分别且全局唯一为 `appGetGenerationPoemSet`、`appUpdateGenerationPoemSet`。同版删除旧 collection POST、逐行 PUT、`/batch/preview`、`/batch/save`、`/management` 及其旧 schema/response;不引入 preview token、服务端 preview/status 或第二写入口。GET/PUT 200 共用关闭额外字段的 `RGenerationPoemSetSnapshot → GenerationPoemSetSnapshot`,快照精确为 `{genealogyId,poemSetVersion,items}`,只投影 ACTIVE、严格按 generationNo 升序;普通读只要求 canView,维护写在事务内重验 tenant、canEditContent 与 READY。
|
||||
|
||||
`API-POEM-002` 固定唯一请求与完整候选语义。`AppGenerationPoemSetUpdateBody` 精确 required `{items,disableMissing}`,items 为 0—500 个严格 generationNo 升序的 `AppGenerationPoemSetItem`。已有 ACTIVE 行必须提交当前词法 poemId,允许把稳定 ID 显式移动到新世代;新行省略 poemId,引用已停用/未知 ID 失败。generationNo 是唯一排序 owner,范围 1—2147483647;generationText 允许重复。`disableMissing=false` 保留全部未声明 baseline ACTIVE,`true` 软停用遗漏项;空 false 是 no-op,空 true 停用全部,历史绝不物理删除。服务端必须依次 `RESOLVE_BASELINE_ACTIVE_IDS`、`VALIDATE_DECLARED_STRICT_ORDER`、构造声明目标、应用遗漏策略、按 generationNo 排序 merged candidate,再验证 ID/世代唯一、`GENERATION_SLOT_CONFLICT`、连续性、`VALIDATE_FINAL_ACTIVE_CAPACITY`,随后 `ALLOCATE_UNIQUE_NEW_IDS` 并验证新 ID 非空、唯一、非 baseline,最后 `WRITE_ATOMICALLY`。swap 必须显式声明所有受影响行,全部成功或零领域写且外部不可见中间态。
|
||||
|
||||
`API-POEM-003` 固定唯一并发与文本模型。`GenerationPoemSetVersion` 是非空、词法、不可解析且永不复用的 ACTIVE 语义版本;PUT required `If-Match` 引用同一 owner,body 不重复版本,不发布 ETag/412。canonical no-op 返回当前 200 并保持版本,ACTIVE 语义变化必须生成新版本。字辈 ID 与版本均只能词法比较,不经 JavaScript Number。`GenerationPoemText` 必须为 well-formed UTF-16、NFC、1—50 Unicode code point、无边界空白,并拒绝 C0/C1、CR/LF/tab、bidi override/isolate、方向标记、zero-width、BOM、word joiner、行/段分隔符与孤立 surrogate;不以 UTF-16 code unit 截断。
|
||||
|
||||
`API-POEM-004` 固定 typed HTTP、授权和部署 owner。GET 精确声明 200/400/401/404/429/500,PUT 另含 403/409/422;成功、固定错误、409 union 和 422 fieldErrors 都是关闭额外字段的 typed `application/json`,禁止 default、3xx、`*/*`、200 包业务错误或通用 RObject。所有响应 required `Cache-Control: private, no-store`,429 required Retry-After;SaToken 必须是精确 header apiKey `Authorization` 且 security requirement 是 JSON 数组,clientid 非空。浏览器预检唯一声明 owner 为顶层 `x-app-gateway-policies.APP_GATEWAY_PREFLIGHT`,精确允许 Authorization/Content-Type/If-Match/clientid、GET/PUT/OPTIONS、显式部署 origin allowlist 和 600 秒;operation 只引用该 owner。正式发布仍须真实 gateway OPTIONS/preflight smoke,导出扩展不能替代部署证据。
|
||||
|
||||
`API-POEM-005` 固定冲突与结果未知恢复。409 只允许 `POEM_SET_VERSION_CONFLICT + current` 和 `GENEALOGY_NOT_READY`;422 精确包含请求顺序、重复 ID/世代、slot collision、非连续、容量、文本与行不可用,校验失败全程零写。网络、超时、408、取消、5xx 和畸形 2xx 都按 unknown。客户端冻结 baselinePoemSetVersion、baselineOrderedItems、declaredItems、disableMissing 与 sessionEpoch,按相同规则构造 effective target 后 fresh GET,执行 `FRESH_GET_THREE_WAY_NO_AUTO_PUT`:existing 按 poemId+generationNo+NFC 文本匹配,NEW 按 generationNo+文本匹配且返回 ID 必须非空、唯一、非 baseline;比较要求同基数、无额外 ACTIVE 行,忽略版本判断语义等价,但变化 target 必须有新且未复用版本。先判 current==target 并执行 `CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION`,只确认当前事实;再判 current==old 允许用户明确重试,否则进入 divergent,任何分支都不自动 PUT。
|
||||
|
||||
`API-POEM-006` 固定客户端、无障碍与激活条件。本批只建立受保护 OpenAPI 门禁、对抗合同、Unicode 运行时证据和中文权威文档;G12 在全部后端门禁转绿前保持明确本地预览。后端同版双导出通过后,客户端第一项写操作必须新增直接执行生产 normalizer/coordinator 的纯 Node 失败测试,再实现唯一 adapter/coordinator,并原子删除 fixture、timer、本地成功、旧 preview/save 假合同及“禁止 appApi”旧断言。页面覆盖 loading/empty/editing/validating/saving/saved/unknown/conflict/no-permission/auth-expired/unavailable、清空二次确认、草稿冻结、live region、首错聚焦、原生按钮、44dp 与返回保护;MuMu 覆盖 320/360/412、1.3 倍字号、TalkBack、软键盘、0/1/500×50、swap、双击、慢网/断网/杀进程、权限撤销、READY 变化和账号切换。未来 T01 高亮只消费正式 ACTIVE 快照,不能成为字辈写 owner。
|
||||
|
||||
唯一静态 owner 为 `tests/g12-generation-poem-openapi-contract.ps1`,当前输出 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`;合法 zero issues 种子、大小写严格 JSON Pointer、ref 图、HTTP 方法、callback、候选/unknown/CORS 和 annotation 非 owner 由 `tests/g12-generation-poem-openapi-adversarial-contract.ps1` 覆盖,字辈 Unicode 由 `tests/g12-generation-poem-unicode-contract-runtime-smoke.js` 覆盖。G-series 只保留 owner 转移说明,不再重复 G12 合同;受保护 OpenAPI 与页面保持未修改。
|
||||
|
||||
### 23.14 F01/F03 家族动态与一级评论读取合同
|
||||
|
||||
任务 40 只治理 F01 动态列表、F03 动态详情和正常一级评论读取,不混入 F02 发布、点赞、评论/回复写、回复读取或媒体文件读取。受保护双导出中的 APP 路径已有谱上下文,页面与 fixture 也以 `(genealogyId,feedId)` 为身份;因此三方否决全局 feed 路由,保留谱内解析和授权。2026-07-23 线上 OpenAPI 3.1.0 与受保护旧导出都不能直接接页:两者分别存在 `/feeds` 与 `/feeds/page`、`/comments` 与 `/comments/page` 双读取 owner,无稳定 operationId,身份仍为 int64,feed 使用通用或无 required 的响应,线上 `FamilyFeedCommentVo` 还暴露手机号、业务用户 ID、内部审核人/动作/原因、状态和持久化字段。双导出内部 parity 只能证明两份旧文件相同,不能证明它们与线上同版本或适合发布。
|
||||
|
||||
`API-FEED-READ-001` 固定三个唯一 owner:`GET /genealogy/app/genealogies/{genealogyId}/feeds` 的 `appListFamilyFeeds`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}` 的 `appGetFamilyFeed`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments` 的 `appListFamilyFeedRootComments`。同版本删除两个 `/page` GET;非分页 collection GET 成为唯一 cursor owner。写方法、likes、replies 和媒体读取不由本批声明或删除,不能借本批旁路接入。三个 operationId 在全局各出现一次,GET 无 request body。
|
||||
|
||||
`API-FEED-READ-002` 固定词法身份与无 PII 最小投影。`GenealogyId/FamilyFeedId/FamilyFeedCommentId` 均为 1—128 位 URL-safe opaque string,客户端只比较、不解析,不得进入 JavaScript Number。`AppFamilyFeedReadItem` 是 closed required `{feedId,feedContent,authorDisplayName,publishedAt,hasMedia}`:正文为 `FAMILY_FEED_TEXT_V1`、1—300 Unicode code point;作者名是经过授权的展示投影,原账号缺失时由服务端提供非空退化文案;时间是服务端生成且不可修改的 RFC3339。`hasMedia` 只证明存在附件,不泄露 `mediaOssIds`;在独立媒体读取合同通过前客户端必须展示诚实占位,不能把媒体动态静默伪装为完整纯文字内容。标题、标签、点赞、状态、排序值和总评论数不是当前页面的可靠服务端字段,不从 fixture 或正文猜值。
|
||||
|
||||
`AppFamilyFeedRootCommentReadItem` 是 closed required `{commentId,commentContent,authorDisplayName,publishedAt}`;正文为 `FAMILY_FEED_COMMENT_TEXT_V1`、1—1000 Unicode code point。comments collection 只投影当前正常可见的一级评论,排除回复、删除占位、parent/level、手机号、账号/业务用户 ID、tenant、状态、remark、审核字段和 createBy/updateBy。详情不嵌 comments,评论独立加载和独立失败;没有 total 时客户端只能显示“已加载 N 条”,不得把已加载数组长度冒充服务端总数。
|
||||
|
||||
`API-FEED-READ-003` 固定对象级可见性。三个 GET 每次请求都重新验证 tenant、genealogy 与 membership;详情和评论另验证 feed 属于 path 家谱且当前可见。未知 ID、错谱、动态删除/隐藏、无权、撤权或 cursor 跨主体/跨谱/跨 feed 重放统一 typed `404 FAMILY_FEED_NOT_AVAILABLE`,不以 403 或不同消息泄露资源存在性。未认证固定 401。后端只能从已通过可见性过滤的根评论集合生成响应和 cursor;前一页授权不延续为后一页授权。
|
||||
|
||||
`API-FEED-READ-004` 固定 opaque keyset cursor,不采用 pageNum/pageSize。首请求不带 cursor;`limit` 可省略且默认 20,范围 1—50;refresh 必须丢弃旧 cursor;`nextCursor` 缺席表示结束,响应不含 total、pageNum 或 hasMore 冗余真值。feed 按 `(publishedAt DESC, feedId DESC_ORDINAL)`,一级评论按 `(publishedAt ASC, commentId ASC_ORDINAL)`。cursor 绑定 tenant/account/authSession/client、genealogyId、评论的 feedId、projection/order/limit、windowUpperBound 与 lastTuple,并具有完整性和过期校验;篡改或过期返回 `400 FAMILY_FEED_CURSOR_INVALID`,不静默从首页重启。
|
||||
|
||||
读取窗口精确命名为 `UPPER_BOUND_KEYSET_LATEST_VISIBLE`,不能宣传成严格 MVCC snapshot:首次请求确定上界,新插入内容等 refresh 后进入;后续读取前删除、审核隐藏或撤权的内容省略;仍可见但已编辑的正文按该页读取时的最新可见版本返回。无 total,因此上述变化不会伪造恒定总量;keyset 与不可变 publishedAt/词法 ID tie-breaker 只保证在合同窗口内不因新增头部内容产生 offset 重复或漏项。
|
||||
|
||||
`API-FEED-READ-005` 固定 typed HTTP、安全与缓存。三个 GET 精确声明 200/400/401/404/429/500,只允许 closed typed `application/json`;禁止 default、3xx、`*/*`、通用 RList/RObject/PageResult、HTTP 200 包业务错和开放 DTO。所有 response 必须有单值 `Cache-Control: private, no-store`,429 另有 1—300 秒 Retry-After;operation security 必须是精确 SaToken 数组且空 scopes,required `clientid` 为非空 1—128 字符串。读取复用唯一 `APP_GATEWAY_PREFLIGHT`,OpenAPI 扩展只登记 owner;正式 H5 origin 的真实 OPTIONS/preflight、凭证与拒绝 origin/header/method 反例仍由部署门禁证明。
|
||||
|
||||
`API-FEED-READ-006` 固定激活顺序。`tests/family-feed-read-openapi-contract.ps1` 是三条 GET、词法 ID、closed projection、cursor、可见性、typed response 与 no-PII 闭包的唯一静态 owner,当前输出 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85`。`tests/family-feed-read-openapi-adversarial-contract.ps1` 用完整 zero issues 合法种子和 58 个独立变异覆盖旧 owner、外部/多跳 ref、大小写、security owner/数组、HEAD/OPTIONS/callback、PII/审核字段、int64、offset/total、错误 403 分流、wildcard 与缓存头,输出 `FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58`;它只证明静态门禁会正确转绿和拒绝反例,不解除后端红灯。后端还须以同版本 JSON/YAML/live `/v3/api-docs`、真实账号、权限撤销、跨谱 cursor、并发插入/删除/隐藏、数据库观测与正式 HTTP/CORS 关闭 P0。
|
||||
|
||||
后端绿前 F01/F03 保持按复合身份深拷贝且失败关闭的 fixture,`runtimeConfig.mode` 保持 mock,不把 dormant `appApi.getFeeds` 宽分支接页,也不在远端失败后静默回退 fixture。后端三份证据通过后,客户端第一项写操作必须是直接执行生产 read normalizer/coordinator 的纯 Node 失败测试;随后原子迁移 F01 列表和 F03 详情/评论,删除 fixture 锁、嵌入评论、伪重试和旧“必须 fixture”断言。状态必须覆盖首屏 loading/empty/error、refresh、load-more/end/局部失败、详情 404、评论独立失败、`hasMedia` 占位、离页取消、sessionEpoch/切谱/切账号迟到响应、权限撤销、滚动与焦点恢复;原生 button、44dp、live region、长文换行和 MuMu 320/360/412、1.3 倍字号、TalkBack、键盘、慢网/断网/返回矩阵是发布条件。
|
||||
|
||||
## 二十四、当前精确执行顺序
|
||||
|
||||
后续不再按页面样式迁移重做,而按以下独立阶段执行:
|
||||
@@ -1033,7 +1123,8 @@ POST required SaToken、非空 clientid 和 `Idempotency-Key`。`GenealogyBootst
|
||||
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 全流程终审。
|
||||
13. 任务 36 普通加入、任务 37 邀请码直入、任务 38 G11 设置、任务 39 G12 字辈集合和任务 40 F01/F03 家族动态读取的三人审查、专项 OpenAPI 红灯与 owner 移交已完成;等待后端分别关闭 API-JOIN/API-INVITE/API-SETTINGS/API-POEM/API-FEED-READ 后按各自客户端 TDD 顺序实施,任何单项 OpenAPI 转绿不开放页面。当前继续下一个独立业务域。
|
||||
14. 全局文字层级与无障碍第二轮。
|
||||
15. 生产配置、隐私权限、可观测性、构建发布、升级回滚与 MuMu 全流程终审。
|
||||
|
||||
T01 必需的线性目录和 44dp 控件随 T01 一起完成;认证浮层的壳层无障碍不扩张为全项目已通过。外部门禁阻塞时继续无依赖批次,但任何真实成功、接口兼容或视觉通过都必须有对应证据。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 接口与页面映射总表
|
||||
|
||||
> 更新日期:2026-07-22
|
||||
> 阶段状态:阶段 0、导航任务 1—10、A01/A04/A05 TAC 客户端、领域上下文基础与 M07 反馈客户端已完成;家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑三人审查及 OpenAPI 红灯已完成;T01、TAC、工作区、G03、帮助、profile、通知、logout、password 与 phone-change 后端接口门禁当前红灯;MuMu 原生矩阵待执行
|
||||
> 接口状态:已完成 A 系列认证/TAC、G 系列第一轮、G01/G05 工作区专项与 G03 原子创建专项、F 系列当前写接口边界、M06 帮助、M07 反馈、M01/M02/M03 个人资料读取、M02 资料写入、N01/N02/M01/G01 通知读写、M10 当前设备退出、M04/四条密码 wire、M05/全活动 OTP wire、T01 世系图及 2026-07-22 新线上 OpenAPI 差异核对;其余操作仍待逐项审查
|
||||
> 阶段状态:阶段 0、导航任务 1—10、A01/A04/A05 TAC 客户端、领域上下文基础与 M07 反馈客户端已完成;家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 密码凭证与 M05 手机号换绑三人审查及 OpenAPI 红灯已完成;普通加入、邀请码直入与 G11 设置三人审查及 OpenAPI 红灯也已完成;T01、TAC、工作区、G03、帮助、profile、通知、logout、password 与 phone-change 后端接口门禁当前红灯,加入、邀请与 settings 同样保持红灯;MuMu 原生矩阵待执行
|
||||
> 接口状态:已完成 A 系列认证/TAC、G 系列第一轮、G01/G05 工作区专项、G03 原子创建、Task36 普通加入、Task37 邀请码直入与 Task38 G11 设置专项、F 系列当前写接口边界、M06 帮助、M07 反馈、M01/M02/M03 个人资料读取、M02 资料写入、N01/N02/M01/G01 通知读写、M10 当前设备退出、M04/四条密码 wire、M05/全活动 OTP wire、T01 世系图及 2026-07-22 新线上 OpenAPI 差异核对;其余操作仍待逐项审查
|
||||
|
||||
## 一、权威边界
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
### 2.1 账户、启动与登录
|
||||
|
||||
- APP 不设游客模式。首次打开、无有效凭证或凭证过期时进入 A01;有效登录态进入 G01。
|
||||
- A01 是唯一活动登录页,承载短信登录、受后端合同阻塞的密码登录入口、注册、找回密码、协议入口和尚未接入的微信登录入口。当前默认短信登录;密码登录在后端能够强制消费 TAC 票据前保持可见但不可用。登录成功统一到 G01,不直接恢复上次浏览的深层页面。
|
||||
- A01 是唯一活动登录页,承载密码登录、短信登录、注册、找回密码、协议入口和尚未接入的微信登录入口。当前默认密码登录;开发/联调 wire 已按线上 `POST /genealogy/app/auth/login` 接通,成功统一保存 `AppLoginVo.access_token` 并进入 G01。默认 `runtimeConfig.mode=mock` 不会发真实请求;remote 测试入口先展示 static TAC,但线上登录接口尚未消费票据,因此只可用于联调,不能据此解除生产发布红灯。
|
||||
- A04 注册成功的目标是建立登录态后进入 G01。2026-07-22 新线上 OpenAPI 已确认 `POST /genealogy/app/auth/register` 与两种登录操作统一返回 `RAppLoginVo`,`data` 引用 `AppLoginVo`,会话字段为 `access_token`;`utils/api.js` 只读取这一当前字段。正式接入应保存有效会话后直接进入 G01,不让用户重复登录,也不为受保护旧快照保留并行兼容路径。行为验证与短信发送时机仍在后续短信状态机阶段核对。
|
||||
- A05 重设成功后不自动登录:返回 A01,保留合规的手机号信息,切换密码方式、聚焦密码框并让用户使用新密码登录。
|
||||
- A04、A05、M04 当前预览仍共用 `utils/validation.js` 的 `8–32+字母数字` 旧策略;Task33 已证明该规则只在客户端且无法作为生产 owner。门禁通过后必须与 A01/A04/A05/M04 的 MD5 wire 同批原子迁移为服务端权威的 15—64 Unicode code point、NFC、允许空格与 Unicode、无组成规则,并同步替换本地唯一 owner、页面文案和测试;不得只改 M04 或保留双轨。
|
||||
- A01 未勾选协议时在协议区域就近高亮并显示错误,不弹原生提示、不跳页;用户勾选后立即清除错误状态。
|
||||
- A01、A04、A05 已接入同一个 `TacVerification` 和 `static/tac/`,不能把本地滑动成功冒充服务端验证。A01 短信登录使用 `APP_SMS_LOGIN`,A04 使用 `APP_REGISTER`,A05 使用 `APP_FORGOT_PASSWORD`;三者都先从 `/captcha/require` 取得严格 provider/type 合同,再由 `/captcha/challenge` 和 `/captcha/verify` 换取 `validToken`,携票调用 `POST /genealogy/app/auth/sms/code`。验证只发生在发短信前,注册或重设提交不重复验证;手机号改变会作废旧上下文,关闭、失败或离页保留表单但不得继续发送。密码登录体尚无票据字段或其他强制绑定证据,因此密码登录入口保持不可用。
|
||||
- A01、A04、A05 已接入同一个 `TacVerification` 和 `static/tac/`,不能把本地滑动成功冒充服务端验证。A01 短信登录使用 `APP_SMS_LOGIN`,A04 使用 `APP_REGISTER`,A05 使用 `APP_FORGOT_PASSWORD`;三者都先从 `/captcha/require` 取得严格 provider/type 合同,再由 `/captcha/challenge` 和 `/captcha/verify` 换取 `validToken`,携票调用 `POST /genealogy/app/auth/sms/code`。A01 密码联调入口暂借现有 TAC 场景做客户端前置后调用线上密码登录 wire,但 `validToken` 未进入或被服务端消费;它不是安全闭环,`API-AUTH-TAC-001` 仍阻止 remote 配置成为生产发布配置。
|
||||
- A01/A04/A05 已实现可发送、发送中、60 秒倒计时、失败重试、手机号变更失效、验证码到期和重复发送保护;短信码精确为 4 位。认证 HTTP 只接受 HTTP 200 严格 envelope,统一 15 秒超时,Android 返回或页面卸载会中止 RequestTask,迟到回调不得改变已离开页面。真实限流、前后台剩余时间恢复和多设备重放仍需后端集成与 MuMu 证明。
|
||||
- 凭证过期先回 A01,再显示项目自定义的单按钮信息弹窗并聚焦登录表单;主动退出清除凭证,但可以保留用户上次选择的密码或短信登录方式,不保存密码。
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- G03 当前在同一页面实例内依次完成“创建家谱”和“录入始祖”,不接收 `step` 或 `genealogyId` 路由参数;门禁前本地成功无业务结果进入 G05 明确预览态。生产目标不允许创建中断成空谱:第一步零网络写,最终按钮一次原子创建家谱、OWNER 与唯一始祖;进程终止后按 operationKey 查询服务端操作状态,不恢复已删除的路由步骤合同,也不让 G01 承担半成品恢复卡。
|
||||
- G05 同一路由区分公开预览与成员视图。公开预览不得闪现成员隐私或管理入口;所有者和普通成员采用最小权限模型,最终权限以接口合同为准。
|
||||
- G05 首屏按身份确认、来源确认、可信度确认三层组织信息;世系是次级入口,不自动抢占首次进入流程。
|
||||
- G11 只维护当前可解释的名称、访问预设和家谱简介;门禁前本地 fixture 的“仅成员可见”“公开可申请”仍由 `utils/genealogy-contracts.js` 映射旧 `visibility/joinMode` 并对未知组合失败关闭。任务 35 已选定后端同版迁移为 APP 读、建、改唯一 `GenealogyAccessPreset`,落地时必须删除旧 pair 与客户端数字映射,不双读;不虚构“转让管理员”等能力。G12 本地批量预览固定按完整序列处理,支持最多 500 代、单代 50 字符和接口声明的分隔符;发现 ACTIVE 世代缺口时停止保存,不能静默错位。
|
||||
- G11 只维护当前可解释的名称、访问预设和家谱简介;门禁前本地 fixture 的“仅成员可见”“公开可申请”仍由 `utils/genealogy-contracts.js` 映射旧 `visibility/joinMode` 并对未知组合失败关闭。任务 38 已固定唯一 merge PUT、fresh overview baseline、`settingsVersion+If-Match+typed 409`、24/80 code point、活动 PENDING 迁移阻断和 unknown 对账;后端门禁通过前不删除诚实本地预览或接宽松接口,见 2.16/5.15。任务 39 已把 G12 收紧为唯一 GET/PUT、ACTIVE-only 完整集合、`poemSetVersion+If-Match`、完整候选校验、软停用与 unknown 三方对账;后端绿前仍只做本地预览,见 2.17/5.16。
|
||||
|
||||
### 2.3 页面状态与请求结果
|
||||
|
||||
@@ -161,8 +161,8 @@ T01/T03—T08 当前共用 `data/mock.js` 的唯一成员夹具 owner:列表
|
||||
- G06:新线上公开搜索已经返回类型化家谱对象,但仍没有显式 `canApply`、六类关系字典、邀请码校验、邀请码目标解析或直接加入端点;未来必须由服务端返回可信的 `canJoinByInvite` 或等价结果,搜索申请资格不能代替邀请码资格,也不能信任 `source=invite` 查询参数。
|
||||
- G08:`GenealogyJoinApplyBody` 提供 `applicantName/phone/relationDesc/applyReason/inviterUserId`,当前页面的 `realName/relation/message` 仍需显式映射、手机号来源和必填规则确认;接口没有邀请码票据字段。搜索来源本地完成仅进入 G09 预览,邀请码来源本地完成仅回 G01,二者都不建立成员关系。
|
||||
- G09/G10:新线上我的申请和待审核列表已返回类型化申请行,但申请状态仍缺正式 enum/字典;真实撤回和审核必须携带服务端词法 `applyId`。线上审核体为 `{status,auditRemark}`,`status` 必填且匹配 `[12]`(1 通过、2 拒绝),`auditRemark` 最长 500;`utils/api.js` 已同步严格校验,旧 `{approved}` 路径已删除。当前本地 `LOCAL_WITHDRAWN` 和审核预览仍不得当成服务端状态。
|
||||
- G11:受保护旧双导出的 `GenealogyUpdateBody` 与线上 `AppGenealogyUpdateBody` 都有 `genealogyName/intro/visibility/joinMode`,页面已删除不存在的 `accessNote`。`utils/genealogy-contracts.js` 当前只为 fixture 暂时映射 `2/0` 与 `1/1`;任务 35 已把远端 owner 收紧为闭合 `AppGenealogySettingsUpdateBody` 的 `genealogyName/intro/accessPreset`,并要求 `AppGenealogyVo` 同用枚举。新合同落地时原子删除旧 DTO、pair、映射和对应旧测试,不保留邀请码 mode 的暗中兼容,见 5.12。
|
||||
- G12:旧双导出的批量体为 `{poemText,disableMissing}`;输入总长上限 26000 字符、单代最多 50 字符、单批最多 500 代,停用遗漏后续记录时不得删除历史。新线上 `GenerationPoemBatchBody` 还暴露 `genealogyId`,说明为“由路径参数写入,客户端不得自行指定”,并丢失了旧导出中的单代/单批说明,必须由后端同版本导出确认哪组约束仍有效。客户端解析、状态 `0/1`、词法 `poemId` 保留、重复世代失败关闭、完整 ACTIVE 序列检查和 50 条分批渲染已由共享 owner 与 Node 冒烟锁定;页面运行时合同还覆盖 `disableMissing` 保留/停用/取消恢复及 500×50 个补充平面字符的最大合法包络。旧导出文字列“空格”等分隔符但示例含换行,当前解析器把 Unicode whitespace 视作分隔符;Tab/NBSP 是否属于正式合同仍须后端澄清。普通列表只应给可查看者返回正常状态,维护列表只给内容编辑者返回正常与停用记录。接口仍没有 `startGeneration`,且未明确批次首项对应哪一世;真实接入必须以服务端 preview items 为差异真相,在后端澄清批次起点前不得发送本地合并结果。
|
||||
- G11:受保护旧双导出的 `GenealogyUpdateBody` 与线上 `AppGenealogyUpdateBody` 都有 `genealogyName/intro/visibility/joinMode`,且缺版本、If-Match、closed merge 与 typed 错误。任务 38 保留现有 PUT 为唯一 dirty-only owner,删除 generic GET/PATCH,复用 overview 的 `RAppGenealogyVo`,新增 `GenealogyName/GenealogyIntro/GenealogySettingsVersion` 与 409 三分支;G03 门禁只继续拥有共享 accessPreset 和旧 DTO/pair 删除,见 2.16/5.15。
|
||||
- G12:任务 39 已否决旧 `{poemText,disableMissing}`、collection POST、逐行 PUT、batch preview/save 与 management 多 owner,固定唯一 GET/PUT aggregate。请求改为显式 0—500 行 `{poemId?,generationNo,generationText}`+disableMissing,generationNo 唯一排序且声明严格升序;新行省略 ID,已有 ACTIVE ID 可显式移动,重复文字允许。false 保留遗漏 baseline、true 软停用,empty false no-op、empty true 清空 ACTIVE;服务端先校验声明严格升序,再构造完整 merged candidate,随后验证 ID/世代/slot/连续/最终容量并原子写,历史不物理删除。Unicode 精确为 NFC、1—50 code point 且拒绝控制/bidi/zero-width/孤代理。后端门禁仍有 72 项红灯,真实接入不得再依赖服务端 preview items 或猜批次首代,见 2.17/5.16。
|
||||
|
||||
### 2.9 新线上 OpenAPI 差异与运行边界
|
||||
|
||||
@@ -177,7 +177,7 @@ T01/T03—T08 当前共用 `data/mock.js` 的唯一成员夹具 owner:列表
|
||||
|
||||
认证客户端批次已经完成,但 `runtimeConfig.mode` 继续固定为 `mock`;下列问题关闭、后端提供同版本双导出并完成真实联调前,客户端不得切换远端或宣称登录注册可上线:
|
||||
|
||||
- `API-AUTH-TAC-001`:密码登录体尚无票据字段。后端必须让 `POST /genealogy/app/auth/login` 强制消费与短信发送相同安全语义、绑定 `APP_PASSWORD_LOGIN + tenant + client + canonical phone` 的短时单次票据;在此之前 A01 密码登录入口保持不可用,不能仅由客户端先展示滑块。
|
||||
- `API-AUTH-TAC-001`:密码登录体尚无票据字段。后端必须让 `POST /genealogy/app/auth/login` 强制消费与短信发送相同安全语义、绑定 `APP_PASSWORD_LOGIN + tenant + client + canonical phone` 的短时单次票据。A01 已开放开发/联调 wire,但客户端先展示滑块不能充当服务端强制校验,故默认仍为 mock 且生产发布保持阻塞。
|
||||
- `API-AUTH-TAC-002`:2026-07-22 对真实 `APP_REGISTER` 请求只读联调时,`POST /captcha/challenge` 返回 HTTP 500 且响应体为空。后端必须修复并提供成功、无效场景、过期、限流和服务不可用的稳定错误 envelope;不得用客户端重试掩盖空 500。
|
||||
- `API-AUTH-TAC-003`:当前 `VerificationCheckBody` 未将 `providerCode/captchaType/payload` 全部声明为必填,根对象与 TianAi/SystemImage payload 也未关闭额外字段,且缺少以 evidence/provider 为 discriminator 的 `oneOf`。后端必须建立关闭额外字段的严格分支;客户端提交的 provider/type 不能替代 challenge 的服务端所有权。`tests/auth-tac-openapi-contract.ps1` 当前聚合六项结构缺口并输出 `AUTH-TAC-OPENAPI-CONTRACT BLOCKED`。
|
||||
- `API-AUTH-TAC-004`:验证码中心必须是唯一 owner。`/captcha/require` 返回服务端绑定 `tenant/client/scene/canonical subject/riskPolicyVersion` 的 `verificationSessionId`、允许方法和短时有效期;`required=false` 也必须直接返回可供短信端点原子消费的一次性 `validToken`。同一 session 只允许一个活动 challenge,刷新或切换方法立即作废旧题但不清零失败计数;`/captcha/verify` 只在服务端验证 evidence 并结合本地风险策略通过后签发票据,票据继续绑定 method/assurance/audience。`/sms/code` 必须在同一事务中完成 `ISSUED → CONSUMED` 与唯一短信 outbox 创建:相同幂等键返回原结果,并发或不同键重放不能创建第二个任务;跨手机号、场景、租户或客户端全部失败。
|
||||
@@ -215,29 +215,77 @@ T01/T03—T08 当前共用 `data/mock.js` 的唯一成员夹具 owner:列表
|
||||
- 个人资料:Task28 固定 `GET /genealogy/app/auth/profile` 为 M01/M02/M03 唯一读取 owner。首批 wire 只 required canonical `phone`;`nickName/realName/email` 未设置时省略,出现时分别满足 1—30、1—30、email 且 1—100。adapter 立即掩码手机号并丢弃 `userId/avatar/status` 等未消费字段,当前 `PROFILE-OPENAPI-CONTRACT BLOCKED`,见 5.5。Task31 保留 PUT 为唯一 dirty-only merge owner,只允许脏的三项资料;省略保持,realName/email 精确空串清空,`profileVersion+If-Match+409` 防并发覆盖,当前 `PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED`,见 5.8。头像与 M05 换绑继续各自独立。
|
||||
- 密码与手机:Task33 已判定 `PUT /genealogy/app/auth/password` 和登录/注册/找回共用的 32 位十六进制 MD5 不可上线;四条入口须原子迁移到 raw writeOnly、15—64 Unicode/NFC、blocklist/限速/慢哈希。M04 200 前撤销包括当前设备在内的 ALL access/refresh session;unknown 也清本机回 A01,崩溃窗口由无秘密的 sessionEpoch marker 关闭,当前 `PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED`,见 5.10。Task34 又固定 M05 为 currentPassword 再认证+新号 `APP_PHONE_CHANGE` TAC/6 位 OTP;换绑发码必须走专用 SaToken operation,最终 200 前换号、消费 OTP、提升 epoch、撤销 ALL session并持久化旧号通知,当前 `PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED`,见 5.11。M04/M05 都不伪提交。
|
||||
- 家谱创建:Task35 否决空谱+通用人物两写,固定 G03 最终按钮一次 atomic bootstrap;访问规则同版统一为 accessPreset,地区只提交 selectable 项的词法 code,结果未知按 operationKey 精确查询且本地不存始祖 PII。当前 `G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED`,见 5.12;通过前不接宽松 create API。
|
||||
- 家谱设置:Task38 保留唯一 PUT 但收紧为原子 dirty-only merge,`/overview` 是 fresh baseline 唯一读取 owner;`GenealogySettingsVersion+If-Match+409` 与 T01/M02 并发模型一致,PUBLIC_APPLY 关闭时与普通申请准入串行化。当前 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`,见 5.15;后端绿只允许开始客户端 TDD,不允许直接接页。
|
||||
- 字辈集合:Task39 固定唯一 `appGetGenerationPoemSet/appUpdateGenerationPoemSet`、`GenerationPoemSetVersion+If-Match`、完整候选与 `FRESH_GET_THREE_WAY_NO_AUTO_PUT`;浏览器预检由 `APP_GATEWAY_PREFLIGHT` 唯一声明。当前 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`,见 5.16;页面保持本地预览。
|
||||
- 帮助与反馈:M06 首批固定 `GET /genealogy/app/help-articles` 为完整列表唯一 owner,不调用详情、不消费 `helpId`;adapter 只允许投影分类、标题和纯文本正文,分类由当前列表动态派生。受保护双导出仍是通用 `ListResult/RList`,线上专用模型又缺 required、正文格式、仅发布和顺序语义,匿名行为也与文档 401 冲突,因此 `HELP-CENTER-OPENAPI-CONTRACT BLOCKED` 保持红灯,详见 5.4。`AppFeedbackBody.feedbackContent` 必填,`feedbackType/contactInfo` 可选且无 enum;M07 已由 `appApi.submitFeedback` 精确 POST `/genealogy/app/feedback`,调用方不能关闭认证头,只接受 HTTP 200 与整数成功 `code`。mock 模式不伪提交;成功、确定失败、结果未知和迟到输入已分离。两页真实服务与 MuMu 验收都等待认证远端门禁关闭。
|
||||
- 邀请:`GET /genealogy/app/promotions` 只返回推广内容与通用 `targetUrl`,没有家谱邀请码签发、校验、失效或直接加入端点,不能支撑产品邀请闭环。M08 已删除硬编码码值、剪贴板和海报伪能力,并显示不可用;未来只能接入“校验成功直接加入且不生成审核记录”的单一路径。
|
||||
- VIP 与订单:线上存在套餐和订单的查询/创建端点,但当前文档未闭合支付方式、价格精度、订单状态、重复下单、支付回调、退款与续费语义。M09 不读取查询参数、不生成演示订单并保持不可用,待独立支付合规审查后再开放。
|
||||
- 退出:Task32 固定 `DELETE /genealogy/app/auth/logout` 只撤销当前 bearer credential family,其他设备保持有效;同 client 的 active/revoked/expired 凭证重复调用都收敛为同一 200,非法/client 不匹配为 typed 401。客户端未来由唯一 logoutCoordinator 在同一同步段捕获 A、清本地并 bump epoch、用显式 A 启动不随 M10 卸载取消的请求,然后立即进入 A01;迟到结果不再 clear。当前本地/线上合同都缺 required、范围/幂等/no-store 与复用反例,`LOGOUT-OPENAPI-CONTRACT BLOCKED`,见 5.9。
|
||||
|
||||
### 2.14 任务 36 普通加入申请合同边界
|
||||
|
||||
普通申请唯一流程为 G06 鉴权搜索 → G08 普通申请 → G09 mine/撤回,以及 G10 pending/审核;邀请码成功后直接加入且不生成审核记录,另立后续批次。七个稳定 owner 为 `appSearchPublicGenealogies`、`appCreateGenealogyJoinApplication`、`appGetGenealogyJoinApplicationRequest`、`appListMyGenealogyJoinApplications`、`appWithdrawGenealogyJoinApplication`、`appListPendingGenealogyJoinApplications`、`appReviewGenealogyJoinApplication`。所有 genealogyId/applyId 使用词法字符串,三个列表使用绑定 actor/filter 的稳定 cursor、limit 1—50、无 total。
|
||||
|
||||
搜索投影只保留识别家谱所需字段与单一 viewerState;mine 以 PENDING/APPROVED/REJECTED/WITHDRAWN discriminator 表达,只有拒绝分支必带申请人可见 rejectionReason;pending 精确为 `applyId/applicantName/relationDesc/applyReason?/submittedAt`,递归禁止 phone、user、inviter、auditor。申请体只含 `applicantName/relationDesc/applyReason?`,审核只含 APPROVE 或 REJECT+rejectionReason;统一 `JOIN_APPLICATION_TEXT_V1` 规范 NFC、边界空白和换行。
|
||||
|
||||
Idempotency-Key/requestKey 共用 `GenealogyJoinApplicationRequestKey`:`gja.{13 位毫秒时间}.{22—43 位 base64url CSPRNG}`,600 秒接受窗、300 秒未来偏差、120 秒 PENDING 收敛;canonical scope 包含 method/path/genealogyId/tenant/account/client/body。状态 GET 纯读且只返回 PENDING/SUCCEEDED/FAILED_NO_COMMIT。撤回和审核都用 `WHERE status=PENDING` CAS;批准同事务创建唯一成员并写终态。静态 OpenAPI 只能锁定合同,真实唯一约束、零写、fencing、事务和 HTTP/CORS 仍须部署集成反例。
|
||||
|
||||
当前 `tests/join-application-openapi-contract.ps1` 精确输出 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`;旧 G-series/core-flow 不再拥有 APP 加入 schema。后端门禁前 G06/G08/G09/G10 不接远端。后续接线必须删除 G10 手机号、三处消息中心承诺、LOCAL_WITHDRAWN 和遗留 adapter,并补状态机、无障碍与 MuMu 矩阵。
|
||||
|
||||
### 2.15 任务 37 邀请码签发与直接加入合同边界
|
||||
|
||||
邀请与普通申请是两个独立 owner。六个稳定 operation 为活动票据列表、无 body 幂等签发、撤销、JSON body 安全解析(无票据消费/成员写,仅轮换短时 grant 摘要)、无 body 直接兑换和兑换状态查询。真实流程固定为 M08 管理票据,G06 输入原码→resolve 最小可信目标与短时 redemption token→明确确认“直接加入、不经审核”→redeem→status 收敛→刷新 `/mine`→安装 context→G01/G05;不再进入 G08,不提交姓名/关系/理由,不创建申请、审核记录、人物或亲属关系。
|
||||
|
||||
首版票据固定单次、24 小时和至少 128 位随机量;原码只允许在 resolve body 与签发成功/同 key 600 秒恢复窗中出现,查找使用 HMAC,短时恢复使用隔离 KMS 密文。列表、撤销、兑换、status、错误、URL/query、导航、storage、日志/APM/分析均不得出现原码或 redemption token。签发 unknown 使用 `{sessionEpoch,genealogyId,requestKey,startedAt}` 重放原路径/key,不自动换 key;600 秒只限制不存在 key 的首次认领,已存在 key 在窗内返同码、窗后返同 ticket metadata+秘密不可恢复,不签第二码。resolve 可轮换一个 HMAC 摘要 grant但不消费票据,token 最多五分钟并绑定 tenant/account/client/ticket/version/genealogy/authorizationEpoch。
|
||||
|
||||
兑换使用敏感 header 与 `gir` key,在一个事务中重验票据、grant、权限、READY、功能、账号资格与容量,完成 ACTIVE→CONSUMED CAS、唯一 MEMBER 和 SUCCEEDED receipt;FAILED_NO_COMMIT 保证票据未消费且成员未建。已有普通 PENDING 返回 `ACTIVE_PENDING_APPLICATION`,用户先去 G09 撤回,邀请流程不得暗改任务 36。当前 `tests/invite-ticket-openapi-contract.ps1` 精确输出 `INVITE-TICKET-OPENAPI-CONTRACT BLOCKED`;后端门禁前 M08 保持不可用、G06/G08 保持诚实预览,不接虚构接口。
|
||||
|
||||
### 2.16 任务 38 G11 家谱设置版本化写入合同边界
|
||||
|
||||
G11 全局唯一写 owner 保留 `PUT /genealogy/app/genealogies/{genealogyId}`,operationId 为全局唯一 `appUpdateGenealogySettings`;不新增 PATCH、`/settings` 旁路或递归引用设置 body/字段的第二写入口,并删除重复 generic GET。G11 只消费 workspace owner 门禁结论,每次进入 fresh 调用 `/{genealogyId}/overview` 建立 canonical baseline,不能信任路由、`/mine` 卡片、旧 G05 内存、roleType 或 fixture。overview 与 PUT 200 共用精确 `{code,data}` 的 `RAppGenealogyVo`,不建立第二套 settings snapshot。共享 `GenealogyName` 统一创建/更新/读取为非 null、NFC、无边界空白、无换行或控制字符的 1—24 Unicode code point;`GenealogyIntro` 为非 null 精确空串或规范 1—80,正文只允许内部 LF 并拒绝 CR/tab/其他控制字符,空串清空、省略保持;accessPreset 仍只引用 `GenealogyAccessPreset`。
|
||||
|
||||
`AppGenealogySettingsUpdateBody` 关闭额外字段并只允许 1—3 个 `genealogyName/intro/accessPreset` 脏属性,服务端事务内重验管理权限并原子 merge;canonical no-op 返回当前 200、版本不升且无领域副作用。并发唯一使用 `AppGenealogyVo.settingsVersion` 和 required `If-Match`,body 不重复版本;版本以 `x-version-scope-fields` 锁定只随三个 canonical 设置实际变化。旧版本返回 typed 409 `GENEALOGY_SETTINGS_VERSION_CHANGED`+current `AppGenealogyVo`,不用 ETag/412;409 wrapper 只允许 discriminator+三个 exact local ref 分支。另两条 409 为 `GENEALOGY_NOT_READY` 与只在实际 `PUBLIC_APPLY→MEMBER_ONLY` 且有活动待审时返回的 `ACTIVE_PENDING_APPLICATIONS`;后者引导 G10、不自动处理申请,设置 PUT 与普通申请 POST 共同声明 `ATOMIC_SINGLE_WINNER` 并在事务内重验准入。
|
||||
|
||||
unknown 不新增 status owner,也不持久含谱名/简介的 body。客户端冻结 baseline/dirty/version/sessionEpoch,网络、超时、408、取消、5xx 或畸形 2xx 后 fresh 读 overview 对账,不自动 PUT;目标值一致只说明当前事实,不能证明本次请求成功。成功后原子回填 canonical 模型,失效 mine/overview/公开搜索/预览缓存并重验 context。响应与 parameter/response/header/schema 解析只接受 exact local component ref,Cache-Control 由单值 enum 固定。当前 `tests/g11-settings-openapi-contract.ps1` 稳定输出 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`、48 项;本批不建占位 client gate,后端绿后的第一项写操作必须是实际执行生产 normalizer/coordinator 的纯 Node 失败测试。
|
||||
|
||||
### 2.17 任务 39 G12 字辈集合版本化保存合同边界
|
||||
|
||||
G12 唯一 aggregate owner 为 `GET/PUT /genealogy/app/genealogies/{genealogyId}/generation-poems`,operationId 为 `appGetGenerationPoemSet/appUpdateGenerationPoemSet`;旧 collection POST、逐行 PUT、batch preview/save、management 及其 schema/response 同版删除,不引入 preview token、服务端 preview/status。两条 200 共用 ACTIVE-only `GenerationPoemSetSnapshot {genealogyId,poemSetVersion,items}`,按 generationNo 严格升序;GET 重验 canView,PUT 事务内重验 tenant、canEditContent、READY 和版本。
|
||||
|
||||
`AppGenerationPoemSetUpdateBody` 精确 required `{items,disableMissing}`。声明行最多 500 且严格升序;已有行 poemId 必须来自 baseline ACTIVE,新行省略 ID,稳定 ID 可随显式 generationNo 移动。false 保留未声明 baseline ACTIVE,true 软停用遗漏项;空 false no-op,空 true 停用全部。服务端先执行 `VALIDATE_DECLARED_STRICT_ORDER`,再构造声明目标与 merged candidate,随后执行 ID/世代唯一、`GENERATION_SLOT_CONFLICT`、连续性、`VALIDATE_FINAL_ACTIVE_CAPACITY`、`ALLOCATE_UNIQUE_NEW_IDS` 及新 ID 非空/唯一/非 baseline 校验;swap 显式声明所有受影响行并原子成功或零写,历史不物理删除。generationText 可重复,但每项必须 NFC、1—50 Unicode code point、well-formed 且拒绝边界空白、控制/bidi/zero-width/孤代理。
|
||||
|
||||
唯一并发 owner 为 `GenerationPoemSetVersion+If-Match`,body 不复制版本,不用 ETag/412;no-op 保持版本,ACTIVE 语义变化生成永不复用的新版本。unknown 以 baseline/version、declaredItems 和 disableMissing 构造 effective target,按 `FRESH_GET_THREE_WAY_NO_AUTO_PUT` fresh GET:existing 比较 ID+generation+NFC 文本,NEW 比较 generation+文本并要求返回 ID 新鲜;同基数且无额外 ACTIVE 行才相等。先执行 `CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION`,只陈述当前事实;再判 old,否则 divergent,全部不自动 PUT。
|
||||
|
||||
响应只允许 typed JSON、private/no-store,429 带 Retry-After;SaToken security 必须是 JSON 数组,clientid 非空。顶层 `x-app-gateway-policies.APP_GATEWAY_PREFLIGHT` 是唯一 CORS 声明 owner,精确包含 Authorization/Content-Type/If-Match/clientid、GET/PUT/OPTIONS、显式 origin allowlist 与 600 秒;发布仍须真实 OPTIONS/preflight。当前主门禁输出 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`、`Issues: 72`;zero issues seed 与严格大小写/ref/security/annotation 反例输出 `G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS`,Unicode 输出 `G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS`。后端绿前 G12 保持本地预览;绿后第一项客户端写操作必须是可执行生产 normalizer/coordinator 失败测试,再原子删除 fixture/timer/本地成功和旧合同。
|
||||
|
||||
### 2.18 任务 40 F01/F03 家族动态读取合同边界
|
||||
|
||||
本批只拥有 F01 动态列表、F03 动态详情和正常一级评论读取。唯一 owner 为 `GET /genealogy/app/genealogies/{genealogyId}/feeds`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`,operationId 为 `appListFamilyFeeds/appGetFamilyFeed/appListFamilyFeedRootComments`;删除两个 `/page` GET。发布、点赞、评论/回复写、回复读与媒体文件读分别留给后续独立批次。受保护双导出和线上 3.1.0 当前仍有双 owner、int64、通用/开放 response;线上评论 DTO 还含手机号、业务用户 ID 和审核字段,故不得接页。
|
||||
|
||||
`AppFamilyFeedReadItem` closed required `{feedId,feedContent,authorDisplayName,publishedAt,hasMedia}`,`AppFamilyFeedRootCommentReadItem` closed required `{commentId,commentContent,authorDisplayName,publishedAt}`。ID 均为 1—128 位词法字符串;正文非 null 且有界;publishedAt 为服务端生成的不可变 RFC3339;作者只返回授权展示名和非空退化文案。评论只含正常可见一级评论,不含 reply/删除占位/parent/level/status/审核字段。标题、标签、点赞和总评论数不是本批合同。`hasMedia=true` 时客户端在媒体读取合同前展示诚实占位,不泄露或解析 mediaOssIds,不把附件静默丢失。
|
||||
|
||||
两个列表使用默认 20、范围 1—50 的 opaque cursor,无 total/pageNum。feed 排序为 `publishedAt:DESC,feedId:DESC_ORDINAL`,comment 为 `publishedAt:ASC,commentId:ASC_ORDINAL`;`UPPER_BOUND_KEYSET_LATEST_VISIBLE` 只固定上界并排除刷新前的新头部内容,后续删除/隐藏项省略、可见编辑返回该页读取时的最新内容,不宣称 MVCC snapshot。cursor 绑定 actor/session/client、谱/动态、projection/order/limit/windowUpperBound/lastTuple;每页重验 tenant、membership、feed 归属与可见性,篡改/过期 typed 400,错谱、跨主体、撤权、删除或隐藏统一 `404 FAMILY_FEED_NOT_AVAILABLE`。
|
||||
|
||||
三个 GET 精确 200/400/401/404/429/500 typed JSON,全部 private/no-store,429 带 Retry-After,required SaToken/clientid 并复用 `APP_GATEWAY_PREFLIGHT`。当前 `tests/family-feed-read-openapi-contract.ps1` 输出 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85`;zero issues 合法种子和 58 个 ref/大小写/PII/int64/offset/HTTP/cache 变异由 `tests/family-feed-read-openapi-adversarial-contract.ps1` 证明为 `FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58`。后端绿前保持复合身份 fixture,禁止连接 dormant 宽 `getFeeds` 或失败回退 fixture。后端同版本 JSON/YAML/live 通过后,客户端首写先建立直接执行生产 read normalizer/coordinator 的失败测试,再原子迁移 F01/F03 和无障碍/异常状态。
|
||||
|
||||
## 三、52 个活动页面映射
|
||||
|
||||
表中“返回或完成目标”描述业务意图,不表示现有导航 API 已经正确;导航栈阶段需要用源码扫描、测试和 MuMu 完整流程逐项验证。A04、G 系列第一轮、F 系列当前写边界、T01 和新线上差异已形成专项证据;其余接口列继续标记“待对应业务阶段 OpenAPI 审查”,避免把旧思维导图、页面 mock、旧离线快照或 PC 接口误当成当前 App 合同。
|
||||
|
||||
| 编号 | 页面 | 路由 | 当前业务目标 | 主要进入方式 | 返回或完成目标 | 必测状态 | 接口业务域 | 当前接口核对状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| A01 | 登录 | `pages/auth/a01-entry` | 完成短信认证并处理协议;密码与微信待合同关闭 | APP 启动、凭证失效、主动退出 | 成功进入 G01;取消或失败留在本页 | 短信、协议错误、TAC、发送中、倒计时、请求取消、登录失败、凭证过期 | 认证与账户 | `APP_SMS_LOGIN` 客户端链已落地;密码登录因 `API-AUTH-TAC-001` 硬关闭,后端与 MuMu 门禁见 2.10 |
|
||||
| A01 | 登录 | `pages/auth/a01-entry` | 完成密码/短信认证并处理协议;微信待合同关闭 | APP 启动、凭证失效、主动退出 | 成功进入 G01;取消或失败留在本页 | 密码、短信、协议错误、TAC、发送中、倒计时、请求取消、登录失败、凭证过期 | 认证与账户 | 密码登录线上 wire 与 `AppLoginVo.access_token` 已接入,默认 mock;`validToken` 未由登录接口消费,`API-AUTH-TAC-001` 仍阻止生产发布,见 2.10 |
|
||||
| A04 | 注册账号 | `pages/auth/a04-register` | 建立新账号并确认协议 | A01 注册入口 | 成功建立登录态并进入 G01;取消返回 A01 | 本地校验、TAC、短信、注册中、手机号占用、成功、失败、取消 | 认证与账户 | `APP_REGISTER`、4 位码、`RAppLoginVo → AppLoginVo.access_token` 客户端链已落地;真实 challenge 与 OpenAPI 仍红灯,见 2.10 |
|
||||
| A05 | 重设密码 | `pages/auth/a05-reset-password` | 验证手机号并设置新密码 | A01 忘记密码 | 成功返回 A01 的密码登录态;取消返回 A01 | 4 位码、TAC、密码策略、不一致、提交中、成功、失败、取消 | 认证与账户 | `APP_FORGOT_PASSWORD` 客户端链已落地;真实后端、Android 可访问替代与 MuMu 仍红灯,见 2.10 |
|
||||
| G01 | 我的家谱 | `pages/genealogy/g01-my-genealogies` | 选择全局家谱并完成加入或创建分流 | 登录成功、根 Tab、业务完成回流 | 进入 G03、G05、G06、G09、G10、G12、T01 或 N01 | 正常、空、加载、失败、审核中、被拒绝、退出或移除、切换弹层、未读消息 | 家谱、成员关系与通知计数 | Task26 工作区红灯已建立;`/mine` 尚未接远端,见 2.8/5.3;Task29 unread-count 红灯要求与 M01 共用唯一未读数 owner,见 2.13/5.6 |
|
||||
| G03 | 创建家谱 | `pages/genealogy/g03-create-genealogy` | 在同页收集家谱与始祖并最终原子创建 | G01 创建入口 | 门禁前进入 G05 本地预览;生产成功按 receipt→mine cache→context→G05 唯一次序收口,放弃零写 | 地区加载/失败、重复建议、始祖校验、提交中、PENDING、结果未知、fatal/quarantined、已提交待进入、放弃确认 | 家谱与成员关系 | Task35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、APP 可信地区与词法 ID 的后端/客户端双红灯;开放还依赖 Task26 workspace 与 MuMu,见 2.8/5.12 |
|
||||
| G05 | 家谱总览 | `pages/genealogy/g05-genealogy-overview` | 按公开预览、成员、所有者或本地预览浏览身份、来源和可信度 | G01、G06、G09、G03 本地预览 | 返回实际来源;按权限进入 T01、G08、G10、G11、G12 或 F01 | 公开预览、成员视图、所有者、本地预览、加载、空、失败、无权限 | 家谱与权限 | Task26 选定 `/overview` 为唯一 owner;最小 schema、对象级授权和错误语义未关闭,丰富首屏字段另待取舍,见 2.8/5.3 |
|
||||
| G06 | 加入家谱 | `pages/genealogy/g06-search-genealogies` | 通过搜索或邀请码本地校验定位目标家谱或支系 | G01 空态或添加家谱弹层 | 进入 G05、G08、G09;已加入或我创建时回 G01 | 初始、搜索中、结果、无结果、邀请码无效或过期、失败、六种用户关系 | 家谱搜索与邀请 | 第一轮已核对;邀请码验证与直入端点缺失,见 2.8 |
|
||||
| G08 | 关系确认与入谱 | `pages/genealogy/g08-join-application` | 校验真实姓名、关系和说明并预览两种加入流程 | G06、G05 或 G09 的共享资格入口 | 搜索来源本地完成进入 G09;邀请码来源本地完成回 G01;均不建立成员关系 | 双来源、不可申请、字段校验、提交中、本地成功、失败、重复提交、放弃填写 | 加入申请与邀请 | 第一轮已核对;申请字段适配与邀请码票据缺失,见 2.8 |
|
||||
| G09 | 我的申请 | `pages/genealogy/g09-my-applications` | 查看申请并预览撤回或重新申请 | G01、G08 本地流程、G06 审核中 | 已通过进入 G05;可重申记录进入 G08;本地撤回不改变服务器状态 | 列表、空、失败、待审、通过、拒绝、本地撤回、重新提交 | 加入申请 | 第一轮已核对;类型化申请行已存在,正式状态字典与词法 `applyId` 适配待补,见 2.8 |
|
||||
| G10 | 入谱审核 | `pages/genealogy/g10-application-review` | 所有者预览通过或拒绝申请 | G01、G05 或 N01 审核消息 | 本页只更新本地预览;取消或返回不产生导航结果 | 列表、空、失败、通过确认、拒绝原因、提交中、无权限;拒绝字段用 `aria-describedby` 保留错误关联和失败聚焦 | 加入审核与权限 | 第一轮已核对;类型化申请行与审核体已存在,正式状态字典和词法 int64 适配待补,见 2.8 |
|
||||
| G11 | 家谱设置 | `pages/genealogy/g11-genealogy-settings` | 本地维护名称、访问预设和家谱简介 | G05 所有者管理入口 | 保存只更新本页预览;取消恢复原值并返回 | 加载、字段校验、本地成功、失败、无权限、未保存返回 | 家谱设置与权限 | 第一轮已核对;Task35 只统一 accessPreset 字段,真实写入仍须独立 If-Match、版本/CAS、权限刷新与结果未知门禁,见 2.8/5.12 |
|
||||
| G12 | 字辈诗 | `pages/genealogy/g12-generation-poems` | 分批浏览并本地维护完整字辈序列 | G01 快捷入口或 G05 | 保存只更新本地列表;取消恢复编辑快照并返回来源 | 列表、空、编辑、校验、无权限、500 代分批渲染、停用但保留历史 | 字辈与权限 | 第一轮已核对;batch 首项世代未定义,真实保存须以服务端 preview 为准,见 2.8 |
|
||||
| G06 | 加入家谱 | `pages/genealogy/g06-search-genealogies` | 普通搜索,或解析邀请码后明确确认并直接加入 | G01 空态或添加家谱弹层 | 普通搜索按 viewerState 进入 G05/G08/G09;邀请成功刷新 workspace/context 后进入 G01/G05 | 搜索六关系;邀请码输入、格式错、解析、目标、确认、PENDING/unknown、已加入、普通申请冲突、失效、限流 | 普通搜索与独立邀请 | Task36 搜索见 2.14/5.13;Task37 六 operation 邀请红灯见 2.15/5.14,门禁前不接远端 |
|
||||
| G08 | 关系确认与入谱 | `pages/genealogy/g08-join-application` | 只提交普通加入申请;真实接线时删除全部 invite source | G06、G05 或 G09 的普通申请入口 | 普通申请成功刷新 G09 | 不可申请、字段校验、PENDING、unknown、成功、失败、放弃 | 普通加入申请 | Task36 后端红灯见 2.14/5.13;Task37 明确邀请不得进入 G08,现有 invite 预览待接线时原子删除 |
|
||||
| G09 | 我的申请 | `pages/genealogy/g09-my-applications` | 查看四状态申请并撤回或重新申请 | G01、G08、G06 审核中 | 已通过进入 G05;拒绝/撤回可重新申请;unknown 刷新 mine | 列表、空、失败、待审、通过、拒绝、撤回、重新提交 | 加入申请 | mine 四状态、拒绝原因和撤回 CAS 由 Task36 门禁拥有;消息中心承诺待接线时删除 |
|
||||
| G10 | 入谱审核 | `pages/genealogy/g10-application-review` | 审核不含手机号的 pending 最小投影 | G01 或 G05;首版不承诺通知目标 | 审核后刷新 pending;行消失只称状态变化 | 列表、空、失败、通过确认、拒绝原因、提交中、无权限、竞态冲突;拒绝字段保留 `aria-describedby`、错误关联与失败聚焦 | 加入审核与权限 | Task36 固定 APPROVE/REJECT、PENDING CAS 与事务权限重验;手机号节点待接线时原子删除 |
|
||||
| G11 | 家谱设置 | `pages/genealogy/g11-genealogy-settings` | 门禁前本地维护;生产目标原子更新名称、访问预设和简介 | G05 管理入口,进入后 fresh overview 鉴权 | 门禁前只更新本页预览;生产成功 canonical 回填、刷新缓存/context 后回 G05 或撤权回 G01 | loading、dirty、saving、validation、saved、unknown 对账、三类 409、无权限、认证失效、不可用、未保存返回 | 家谱设置与权限 | Task38 已建立唯一 merge PUT、settingsVersion/If-Match、409 three-way、待审串行化与 unknown 红灯;页面尚未接远端,见 2.16/5.15 |
|
||||
| G12 | 字辈诗 | `pages/genealogy/g12-generation-poems` | 分批浏览并本地维护完整字辈序列 | G01 快捷入口或 G05 | 门禁前保存只更新本地列表;生产成功 canonical 回填并刷新消费者 | loading、空、编辑、校验、saving、saved、unknown、版本/READY 冲突、无权限、0/1/500×50、swap、软停用历史 | 字辈与权限 | Task39 已建立唯一 GET/PUT、poemSetVersion/If-Match、完整候选集合、unknown 与 CORS owner 红灯;页面尚未接远端,见 2.17/5.16 |
|
||||
| T01 | 世系树 | `pages/tree/t01-tree-overview` | 以当前成员为焦点阅读可扩展世系窗口,并在图、概览和线性列表间定位成员 | G01 快捷入口或 G05 | 返回来源;进入单实例 T03、T04、T06、T07 | 上二代/下二代初始窗口、搜索、四级 LOD、多配偶联合点、宽支系聚合、代际缺口、四类边界、图/列表、空、失败、版本冲突、500 节点性能;节点与线由同一 Canvas/矩阵/帧绘制 | 世系与成员 | 专项已核对:现有递归 `LineagePersonTreeView` 不满足;待后端按规范图窗口问题单更新 Apifox |
|
||||
| T03 | 成员档案 | `pages/tree/t03-member-profile` | 在单个原生页面实例内查看成员资料、亲属与受控状态 | T01、T07、R02 的成员关联 | 页内成员轨迹优先返回;轨迹结束后回实际来源;进入 T05、T08 | A→B→C→B→A 页内轨迹、可编辑、隐私、无权限、成员缺失、加载失败、离世状态;读取成功后才推进轨迹 | 成员档案与权限 | 待对应业务阶段 OpenAPI 审查 |
|
||||
| T04 | 新增亲属 | `pages/tree/t04-add-relative` | 录入首位成员或为目标成员新增亲属 | T01 指定节点或空树入口 | 当前只生成“尚未提交服务器”的本地预览,确认放弃后无结果回 T01;真实写成功后才定位新成员 | 首位成员、普通亲属、关系选择、必填、长摘要、保存中、本地预览、失败、放弃确认 | 成员与亲属关系 | 待对应业务阶段 OpenAPI 审查 |
|
||||
@@ -245,9 +293,9 @@ T01/T03—T08 当前共用 `data/mock.js` 的唯一成员夹具 owner:列表
|
||||
| T06 | 编辑关系 | `pages/tree/t06-edit-relationship` | 校正两个现有成员之间的关系 | T01 关系操作 | 当前只生成“尚未提交服务器”的本地预览,确认放弃后无结果回 T01;真实写成功后才刷新关系 | 成员选择、校验、冲突、循环关系、冲突规则弹窗、保存中、本地预览、失败、无权限 | 亲属关系与权限 | 待对应业务阶段 OpenAPI 审查 |
|
||||
| T07 | 成员目录 | `pages/tree/t07-member-directory` | 搜索、筛选并选择家谱成员 | T01 成员目录入口 | 进入 T03;返回 T01 并恢复目录现场 | 完整列表、筛选、搜索无结果、明确空态、失败重试、加载、长列表、成员选择 | 成员查询 | 待对应业务阶段 OpenAPI 审查 |
|
||||
| T08 | 成员状态 | `pages/tree/t08-member-states` | 解释成员隐私、纪念或无权限状态 | T03 人物状态入口 | 返回 T03;家谱不可用时回 G01 | 隐私隐藏、离世纪念、无权限、无效成员、权限变化 | 成员状态与权限 | 待对应业务阶段 OpenAPI 审查 |
|
||||
| F01 | 家族动态 | `pages/family/f01-family-feed` | 按当前成员家谱展示动态并承载内容和档案入口 | 根 Tab 或 G05 | URL 已规范化后进入 F02—F04、F07、F10、R01、R03、R05、R10、R11 | 加载、列表、空、失败、无有效家谱、跨谱失败关闭 | 家族内容聚合 | 任务 7 已核对;线上有动态列表/写入能力,当前静态批次只读且按家谱隔离 |
|
||||
| F01 | 家族动态 | `pages/family/f01-family-feed` | 按当前成员家谱展示动态并承载内容和档案入口 | 根 Tab 或 G05 | URL 已规范化后进入 F02—F04、F07、F10、R01、R03、R05、R10、R11 | 加载、列表、空、失败、无有效家谱、跨谱失败关闭 | 家族内容聚合 | Task40 已建立唯一 cursor GET、无 PII 投影、逐页鉴权与非泄露 404 红灯;后端绿前保持 fixture,见 2.18/5.17 |
|
||||
| F02 | 发布动态 | `pages/family/f02-publish-feed` | 编辑文字或媒体动态的本地预览 | F01 发布入口 | 预览不发布、不产出结果;取消保留或确认放弃后回同一 F01 | 表单、空内容校验、长内容、媒体权限、本地预览、无权限、放弃确认 | 动态发布与上传 | 线上写请求要求 `feedContent` 且 minLength=1,另有 `feedType/mediaOssIds/sortOrder/status`;真实调用与媒体上传未启用,mock 以 `WRITE_UNAVAILABLE` 失败关闭,详见 2.11 |
|
||||
| F03 | 动态详情 | `pages/family/f03-feed-detail` | 按 `genealogyId + feedId` 阅读动态并编辑评论草稿 | F01 动态卡 | 评论只在本页预览,不插入、不计数、不清空;返回同一 F01 | 加载、正常、内容失效、失败、评论校验、本地预览、无权限 | 动态与评论 | 线上评论写请求要求 `commentContent`,文档边界为 minLength=0/maxLength=1000;当前未调用写接口,不宣称发送成功,详见 2.11 |
|
||||
| F03 | 动态详情 | `pages/family/f03-feed-detail` | 按 `genealogyId + feedId` 阅读动态并编辑评论草稿 | F01 动态卡 | 评论只在本页预览,不插入、不计数、不清空;返回同一 F01 | 加载、正常、内容失效、失败、评论校验、本地预览、无权限 | 动态与评论 | Task40 已拆分详情与一级评论 cursor owner;后端绿前保持 fixture,评论写仍只做未提交预览,见 2.18/5.17 |
|
||||
| F04 | 谱文列表 | `pages/family/f04-article-list` | 按家谱分类、搜索和浏览谱文 | F01 谱文入口 | 进入精确 F05 或 create 模式 F06;返回同一 F01 | 加载、列表、分类、搜索无结果并重置、空、失败、长列表、新建权限 | 谱文 | 任务 7 已核对线上列表及写接口;当前列表由唯一只读 owner 提供深拷贝 |
|
||||
| F05 | 谱文详情 | `pages/family/f05-article-detail` | 按 `genealogyId + articleId` 阅读并按权限进入编辑 | F04 谱文卡 | 返回同一 F04;有权限进入 edit 模式 F06 | 加载、正常、收藏暂未开放、编辑、失效、隐私、失败、无权限 | 谱文与权限 | 线上未证明收藏合同,当前禁用收藏;文章写请求要求 `articleTitle/articleContent` 且均为 minLength=1,详见 2.11 |
|
||||
| F06 | 编辑谱文 | `pages/family/f06-article-editor` | 新建或编辑谱文的本地预览 | F04 新建或 F05 编辑 | create 回 F04,edit 回精确 F05;预览不保存、不产出结果 | 新建、编辑、校验、加载、本地预览、失败保留、长正文、放弃确认 | 谱文编辑 | 线上 POST/PUT 已存在,`categoryId/coverOssId` 为 int64;真实调用未在导航批次启用,详见 2.11 |
|
||||
@@ -275,7 +323,7 @@ T01/T03—T08 当前共用 `data/mock.js` 的唯一成员夹具 owner:列表
|
||||
| M05 | 修改手机号 | `pages/profile/m05-change-phone` | 以当前密码重新认证,并通过受保护 TAC/6 位 OTP 验证新号码;门禁前保持本地预览 | M03 手机号入口 | 确定错误留页;最终 200/401/409/结果未知清本机回 A01;放弃确认回 M03 | 当前密码、新号、TAC、发送与倒计时、6 位码、占用、限流、提交中、并发、结果未知、进程终止 | 账号安全、短信与会话 | Task34 已建立专用 SaToken 发码、全活动六位码、ALL 会话撤销、outbox 与 credential marker 红灯;依赖 M04 raw wire,见 2.13/5.11 |
|
||||
| M06 | 帮助中心 | `pages/profile/m06-help-center` | 从完整帮助列表搜索、分类并展开纯文本正文,无法解决时进入反馈 | M01 帮助入口 | 返回 M01;无法解决时进入 M07 | 加载、服务端空、分类、搜索无结果、展开、失败重试、认证失效、取消 | 帮助内容 | Task27 选定列表唯一 owner 并建立红灯;专用 required、纯文本、仅发布、顺序和认证语义待后端关闭,见 2.13/5.4 |
|
||||
| M07 | 意见反馈 | `pages/profile/m07-feedback` | 通过唯一真实 owner 提交必填内容及可选类型、联系方式 | M06 联系入口 | 成功留页保留提交快照;编辑后可再提交;未提交修改放弃确认回 M06 | 必填、提交中、成功防重、失败、结果未知、mock 不可提交、未保存返回 | 用户反馈 | Task25 已接 `POST /genealogy/app/feedback` 严格客户端;remote 实测与 MuMu 待认证门禁关闭,见 2.13 |
|
||||
| M08 | 应用推广 | `pages/profile/m08-promotion` | 明确说明邀请码服务当前不可用 | M01 推广入口 | 查看不可用说明;返回 M01 | 无可用邀请码、服务未接入、说明弹层 | 邀请与系统分享 | 线上无邀请码签发/校验/直入合同,已删除码值、复制和海报伪能力,见 2.13 |
|
||||
| M08 | 邀请家人 | `pages/profile/m08-promotion` | 从 workspace 选择有正式邀请权限的家谱并管理单次活动票据 | M01 推广入口 | 签发、显示/复制/系统分享、撤销;返回 M01 | 加载、无权限、无票据、签发/unknown、原码恢复窗、即将过期、撤销/竞态、权限失效 | 家谱邀请与系统分享 | Task37 六 operation 邀请红灯见 2.15/5.14;通过前保持不可用且不展示假码 |
|
||||
| M09 | VIP 与订单 | `pages/profile/m09-vip-orders` | 展示基础说明并明确订单服务当前不可用 | M01 服务入口 | 查看关闭说明;返回 M01 | 服务未开放、无订单数据、说明弹层 | 服务权益、订单与支付 | 套餐/订单端点存在但支付闭环未定义,已删除查询参数演示订单,见 2.13 |
|
||||
| M10 | 关于家谱 | `pages/profile/m10-about-settings` | 从 manifest 展示版本和协议说明,并安全退出当前设备凭证族 | M01 设置入口 | 协议/退出取消留在本页;确认后立即清本机并回 A01,远端结果只更新一次性提示 | 版本、协议、隐私、退出确认、双击、pending、撤销确认/未确认/拒绝、账号竞态、根导航失败 | 配置、协议与认证 | Task9 已完成本机清理基线;Task32 已建立当前凭证族、幂等 200、logoutCoordinator、required RVoid 与部署复用红灯,见 2.13/5.9 |
|
||||
|
||||
@@ -355,15 +403,15 @@ PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relatio
|
||||
|
||||
**优先级:** P1;阻塞 G01/G05 切到 remote 和正式上线,不阻塞继续审查无依赖业务域。
|
||||
|
||||
**唯一 owner:** `GET /genealogy/app/genealogies/mine` 持有当前账号可访问集合;`GET /genealogy/app/genealogies/{genealogyId}/overview` 持有 G05 只读展示。首批不接语义重复的 `GET /{genealogyId}`,不让两个详情响应互相补字段。
|
||||
**唯一 owner:** `GET /genealogy/app/genealogies/mine`/`appListMyGenealogies` 持有当前账号可访问集合;`GET /genealogy/app/genealogies/{genealogyId}/overview`/`appGetGenealogyOverview` 持有 G05 只读展示。两个 operationId 全局唯一,路径只开放 GET、无 body;首批不接语义重复的 `GET /{genealogyId}`,不让两个详情响应互相补字段。
|
||||
|
||||
**当前线上证据:** `RListAppGenealogyVo/RAppGenealogyVo/AppGenealogyVo` 均无 `required`;`AppGenealogyVo.genealogyId` 为 `integer/int64`,`roleType/status/memberStatus` 无 enum,且没有 `canView`。最大合法 int64 经 JavaScript JSON 解析会失真,解析后再转字符串无法恢复。无令牌调用 `/mine`、`/1` 和 `/1/overview` 均实测返回 HTTP 200、`application/json;charset=UTF-8`、`{code:401,msg,data:null}`;线上文档却只列 200/401,401 schema 为 `*/* string`,也没有有效 security 声明。无令牌行为已经拒绝访问,因此不能仅凭注解缺失断言已发生公开泄漏;对象级授权仍没有有效账号反例证据。
|
||||
|
||||
**API-GENEALOGY-WORKSPACE-001:无损身份与最小 schema 闭包。** `/mine` 的 200 响应固定为 `RListAppGenealogyVo`,`/overview` 固定为 `RAppGenealogyVo`;两层 envelope 的 `code/data` 必填,列表 `data` 为 `AppGenealogyVo[]`,详情 `data` 为单个 `AppGenealogyVo`。首批实体必填字段固定为 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`:ID、名称为 `minLength >= 1` 的字符串,三项 capability 为 boolean,角色为至少两个稳定非空值的正式 enum。地点、堂号、人数、简介等展示字段可选,响应不强制 `additionalProperties:false`。URL path 本身以文本传输,不因 JSON 响应问题机械强制改类型;真正必须改的是响应身份。
|
||||
**API-GENEALOGY-WORKSPACE-001:无损身份与最小 schema 闭包。** `/mine` 的 200 响应固定为 `RListAppGenealogyVo`,`/overview` 固定为 `RAppGenealogyVo`;两个 envelope 都关闭额外字段且精确 required `{code,data}`、code=200,列表 data 为 `AppGenealogyVo[]`,详情 data 为单个 `AppGenealogyVo`。首批实体必填字段固定为 `genealogyId/genealogyName/canView/canManage/canEditContent/roleType`:响应字段和 overview path 都引用词法 `GenealogyId`,名称引用 `GenealogyName`,三项 capability 为 boolean,角色为至少两个稳定非空值的正式 enum。地点、堂号、人数、简介等实体字段可选,`AppGenealogyVo` 不强制关闭未知额外字段。
|
||||
|
||||
**API-GENEALOGY-WORKSPACE-002:可访问集合与能力投影。** `/mine` 中只有 `canView=true` 的行能进入 current context;也接受后端明确并由集成测试证明“只返回当前账号仍可查看 ACTIVE 家谱”的等价合同。G01 不能从未声明的 status 或 role 猜可访问性。`canManage/canEditContent` 是管理与内容入口的授权 UI 投影;真正写接口仍须后端逐次鉴权,capability 不能替代服务端授权。`roleType` 只拥有 G01 分组和角色标签,不替代 capability。
|
||||
**API-GENEALOGY-WORKSPACE-002:可访问集合与能力投影。** `/mine` 用 `x-current-account-viewable-only=true` 明确只返回当前账号仍可查看的家谱,并以 `OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS` 约束仅在成功确认失权时省略;G01 不能从未声明的 status 或 role 猜可访问性。`canManage/canEditContent` 是管理与内容入口的授权 UI 投影;真正写接口仍须后端逐次鉴权,capability 不能替代服务端授权。`roleType` 只拥有 G01 分组和角色标签,不替代 capability。
|
||||
|
||||
**API-GENEALOGY-WORKSPACE-003:错误语义与对象级授权。** 认证失效、对象无权/撤权、家谱不存在和服务故障必须在同版本文档、runtime validator 与部署行为中稳定一致。后端可使用规范 HTTP 401/403/404/5xx,也可继续 HTTP 200+稳定业务码;不能出现文档写 HTTP 错误、部署只回无字典业务码的双合同。至少用两个账号执行无凭证、跨账号、撤权、删除、服务异常和正常访问反例。G01 只在成功列表中确认 ID 消失或明确撤权时写 tombstone,网络/5xx 保留现场;G05 不把错误回退为 fixture 权限。
|
||||
**API-GENEALOGY-WORKSPACE-003:typed HTTP、缓存与对象级授权。** 两条读取都 required SaToken 和非空 clientid;`/mine` 精确 200/401/429/500,overview 精确 200/400/401/404/429/500,不再允许 HTTP 200+业务错误。400 固定 `GENEALOGY_ID_INVALID`,401 为 `AUTH_REQUIRED`,对象无权/撤权/不存在以 `NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE` 统一 404,429/500 分别为 `RATE_LIMITED/GENEALOGY_WORKSPACE_UNAVAILABLE`。全部只允许 JSON;每个响应必须含固定单值 private/no-store,429 另有 Retry-After;只允许非语义 tracing header:`traceparent/tracestate/x-request-id/x-correlation-id`,禁止 ETag 与其他语义 header。parameter/response/header/schema 只接受 exact local component ref;四种 tracing header 的引用也必须 exact local,内联 Header Object 必须 allowed-key 关闭并带非 null 的 string schema。clientid、code/data/businessCode、实体字段和 role 枚举以 allowed-key 集拒绝 `nullable`、readOnly/writeOnly、冲突组合和额外验证关键字;全局扫描全部 `/genealogy/app/` GET 的 200 schema 闭包并合并 allOf 属性,除 overview 外返回单谱实体的旁路详情一律失败,不把 `/mine` 数组误算为单谱读取。至少用两个账号执行无凭证、跨账号、撤权、删除、限流、服务异常和正常访问反例;G01 只在成功列表确认 ID 消失时写 tombstone,网络/5xx 保留现场,G05 不回退 fixture 权限。
|
||||
|
||||
**展示取舍:** 当前 G05 fixture 还显示 `source/manager/certification/ancestorName/parentName/branchName/updatedAt/activeCount`,线上没有等价字段。首批允许把这些区域隐藏或使用明确“待补充/待同步”的非业务降级;若产品要求继续把它们作为可信首屏信息,后端需另补字段或明确组合接口。不能为了复刻 mock 把 22 个字段全部列为当前硬门禁,也不能把 mock 值带进 remote。
|
||||
|
||||
@@ -530,3 +578,93 @@ PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relatio
|
||||
**页面与关闭条件:** 地区搜索、PENDING、unknown、fatal/quarantined、committed、context/导航失败必须可见;默认 UNKNOWN,删除 1800/UTC 日期错误;label、radio、aria-invalid/describedby、首错聚焦、至少 44dp 原生按钮、AppDialog 焦点与状态播报同批实现。后端同版本 JSON/YAML 必须通过 `tests/g03-bootstrap-openapi-contract.ps1`,客户端实现必须另行通过会实际执行状态机套件的 `tests/g03-bootstrap-client-release-gate.ps1`;它们只是 G03 自身两门禁,真实开放还要求 Task26 workspace 读取门禁、聚焦/全量回归和 MuMu 原生字号、TalkBack、键盘、慢网、双击、杀进程及 Android 返回矩阵全部通过。
|
||||
|
||||
当前其余已知但尚未核实的重点依赖包括:微信登录、公共行为验证、完整短信状态机、邀请码验证与直接加入、结构化亲属关系、上级家谱与支系权限、管理员授权与功能开关、上传与系统权限、消息业务目标、系统分享、订单支付与退款。它们只表示审查重点,不预判后端一定缺失。
|
||||
|
||||
### 5.13 后端问题单 API-JOIN-001—006
|
||||
|
||||
**优先级:** P0;阻塞 G06/G08/G09/G10 普通申请、撤回和审核的真实闭环。邀请码直入不属于本问题单。
|
||||
|
||||
**API-JOIN-001:七个稳定 owner、词法身份与 cursor。** 发布设计 23.10 的七个 operationId;三个列表分别返回专用 page/item,不返回 total,limit 1—50,cursor 绑定 tenant/account/client/filter;搜索按 updatedAt+genealogyId,mine/pending 按 submittedAt+applyId 稳定排序。genealogyId/applyId 均引用 1—128 位 URL-safe string owner;搜索 viewerState 只允许 NOT_JOINED/MEMBER/PENDING/REJECTED/FORMER_MEMBER/OWNER,不另发 canApply。
|
||||
|
||||
**API-JOIN-002:闭合投影与文本。** 申请体仅 `applicantName/relationDesc/applyReason?`,前两项 required;审核体仅 APPROVE 或 REJECT+required rejectionReason 两分支。三项申请文本和拒绝原因共用 `JOIN_APPLICATION_TEXT_V1`。mine 使用四状态 discriminator且拒绝分支必有可见原因;pending 精确五字段并递归禁止手机号、用户、邀请人和审核人。删除旧 APP join DTO 和通用响应引用。
|
||||
|
||||
**API-JOIN-003:幂等、恢复、零写与准入协调。** request key 为 `gja.{issuedAt}.{CSPRNG}`,600/300/120 秒边界;canonical identity 覆盖 method/path/genealogyId/tenant/account/client/body。parameter/response 只允许 exact local 分区引用;幂等 scope、活动唯一 scope、事务 effects、重验字段、状态迁移和 cursor 扩展必须是真实 JSON 数组,逗号字符串不能伪装集合。数据库唯一约束保证同账号同谱一个 PENDING;POST 在事务内重验 READY、PUBLIC_APPLY 与 applicationEligibility,并与 G11 设置 PUT 共享 `x-public-apply-coordination=ATOMIC_SINGLE_WINNER`,关闭公开申请与新准入竞态只允许一方提交。申请行与 SUCCEEDED receipt 同事务。新增纯读 status GET,三态转换和终态不可变;窗口内 404、窗口后零写 FAILED、跨主体非泄漏 404、迟到 POST 拒绝,客户端只持久无 PII marker。
|
||||
|
||||
**API-JOIN-004:撤回。** DELETE 以 PENDING CAS 决定唯一赢家;相同撤回只重放 WITHDRAWN typed 200,审核或其他终态获胜时返回 typed 409+最小 current state,不存在或越权统一 404。key 复用、key 过期与活动 PENDING 冲突不强制返回不存在的申请 current。
|
||||
|
||||
**API-JOIN-005:审核。** PUT 在事务内重验权限、READY、PUBLIC_APPLY,执行 PENDING CAS、唯一成员关系和终态;相同决定只重放 APPROVED/REJECTED 原 200,绝不返回 WITHDRAWN;相反决定或不同规范化拒绝理由返回 409。POST 的 409 精确引用 `RJoinApplicationKeyConflict`,DELETE/PUT 的 409 精确引用 `RJoinApplicationStateConflict`,不得共享会接纳跨操作业务码的宽联合 owner。无 applicationVersion、If-Match 或详情端点。
|
||||
|
||||
**API-JOIN-006:安全与发布。** 所有响应精确 typed JSON、private/no-store,429 和 status 窗口内 404 带 Retry-After;禁止 default、3xx、`*/*`、200 包认证错误、int64 和通用 RList/RObject/RVoid。后端以真实账号、数据库观测、并发与 fault injection 证明 cursor、唯一约束、fencing、零写、事务、跨主体不泄漏和正式 HTTP/CORS;OpenAPI 静态扩展不替代实现证据。
|
||||
|
||||
**关闭条件:** 同版本双导出通过 `tests/join-application-openapi-contract.ps1`;再完成后端运行时反例、客户端 marker/status/CAS 收敛测试、旧 adapter/手机号/消息承诺/LOCAL_WITHDRAWN 原子删除、全量回归和 MuMu 320/360/412、系统字号、TalkBack、键盘、返回、双击、慢网、断网、杀进程矩阵。当前门禁输出 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`,页面保持本地预览。
|
||||
|
||||
### 5.14 后端问题单 API-INVITE-001—006
|
||||
|
||||
**优先级:** P0;阻塞 M08 邀请签发、G06 邀请码解析与直接加入。普通申请继续由 API-JOIN 唯一拥有。
|
||||
|
||||
**API-INVITE-001:六个专用 owner 与流程隔离。** 发布 23.11 的 list/issue/revoke/resolve/redeem/redemption-status 六 operation,required SaToken/clientid、词法 genealogy/ticket/membership ID、operation-specific typed responses。G06 直接确认兑换,G08 只保留普通申请;普通申请 body 反向禁止 inviteCode/ticket/token。禁止平行旧入口、通用 promotions、礼仪邀请或普通申请 POST 承担本域。
|
||||
|
||||
**API-INVITE-002:单次秘密票据。** 原码固定版本前缀+26 位 Crockford Base32 随机量、至少 128 位熵、24 小时单次使用;HMAC 查找,KMS 密文仅保留 600 秒幂等恢复窗。列表只返回最多 20 个活动元数据;原码只允许 issue 200 指定位置,redemption token 只允许 resolve 200 指定位置,并对所有响应 `$ref/oneOf/allOf/items` 递归检查秘密与 PII 闭包。
|
||||
|
||||
**API-INVITE-003:签发、权限与撤销。** issue 无 body,`gii` key 按 method/path/genealogy/tenant/account/client 唯一;已存在 key 不受首次接受窗影响,不同 digest 优先冲突,600 秒内返同码、窗后返同 ticket metadata+`ISSUED_SECRET_UNAVAILABLE` 且不新建。ticket、HMAC、KMS 密文与 request receipt 原子提交。workspace `canInviteMembers` 唯一映射服务端 `INVITE_MEMBER`;list/issue/revoke 均重验并只管理 actor 自己签发的票据,跨 issuer 404;活动上限由数据库约束。DELETE 以 ACTIVE→REVOKED CAS,相同撤销重放 200,CONSUMED/EXPIRED typed 409 current;撤销与兑换只有一个赢家。
|
||||
|
||||
**API-INVITE-004:安全解析与原子兑换。** resolve 的原码只在 JSON body,不消费票据或建立成员但可轮换唯一短时 grant 摘要,并统一 `INVITE_TICKET_NOT_AVAILABLE`、多维限流与时间侧信道控制;返回最多五分钟且绑定 actor/client/ticketVersion/genealogy/authorizationEpoch 的敏感 token。redeem 无 body,事务谓词包含 token、版本、数据库时钟过期、unused、READY、功能、签发者权限、账号资格和容量;票据消费、唯一 MEMBER 与回执同事务。ALREADY_MEMBER、活动普通申请、账号禁用或票据失效均不得消费票据。
|
||||
|
||||
**API-INVITE-005:幂等与 unknown 恢复。** `gir` key 的 canonical identity 包含 token identity/ticket/genealogy/actor/client;同 key 不同 identity typed 409。600/300/120 秒边界、control claim、fencing/watchdog 和纯读 status 固定 PENDING/SUCCEEDED/FAILED_NO_COMMIT;窗口内 404 带 acceptUntil/Retry-After,窗口后零写计算 FAILED,跨主体统一 404。已存在终态优先,终态回执至少保留 30 天且晚于 30 天客户端 marker 清理;清理后不得据 absence 否定领域写。客户端只持久无秘密 marker,成功后只重试 workspace/context/导航本地收口。
|
||||
|
||||
**API-INVITE-006:安全、无障碍与发布。** 所有响应 JSON+private/no-store,429 带 Retry-After;URL/query/storage/log/APM/分析/崩溃报告/自动测试和发布证据截图零秘密,用户主动显示、复制或系统分享必须先提示风险且不得自动触发。后端以真实数据库并发、fault injection、权限降级、猜码、两人同码、同人两码、撤销竞态、日志审计与 HTTP/CORS 证明实现,静态扩展只登记意图。客户端原子删除 `JP2026`、fixture target、invite→G08 与 G08 invite 分支;补原生按钮、44dp、tab/label/aria、状态播报、长码换行、冻结和 MuMu 全矩阵。
|
||||
|
||||
**关闭条件:** 同版本双导出通过 `tests/invite-ticket-openapi-contract.ps1`,再通过后端运行时反例、客户端 coordinator 测试、Task36 普通申请隔离、workspace/context 收口、全量回归及 MuMu 320/360/412、1.3 倍字号、TalkBack、键盘/粘贴、双击、慢网/断网、杀进程、Android 返回和系统分享取消。当前输出 `INVITE-TICKET-OPENAPI-CONTRACT BLOCKED`,页面不得接假接口。
|
||||
|
||||
### 5.15 后端问题单 API-SETTINGS-001—006
|
||||
|
||||
**优先级:** P0;阻塞 G11 家谱设置真实读取、并发写入、访问规则切换与发布。
|
||||
|
||||
**API-SETTINGS-001:唯一 read/write 与共享字段 owner。** 保留全局唯一 `appUpdateGenealogySettings` PUT,递归拒绝旁路 settings 写、任一 `genealogyName/intro/accessPreset` 单字段第二 body owner以及外部/错分区同名 `$ref`;`/genealogy/app/genealogies...` 写 body 根层 `intro` 明确保留给设置,子域使用自身字段名。删除 generic GET 且不新增 PATCH;overview 是 G11 canonical baseline 唯一读取 owner,并由 workspace 门禁以 schema 闭包与 allOf 合成排除旁路详情。发布共享非 null `GenealogyName/GenealogyIntro/GenealogySettingsVersion`:名称拒绝换行/控制字符,简介只允许内部 LF并拒绝 CR/tab/其他控制字符,让 bootstrap、settings body 与 `AppGenealogyVo` 引用同一 24/80/版本合同;`GenealogyAccessPreset` 继续由共享 owner 唯一持有且禁止 nullable。200 复用关闭额外字段的 `RAppGenealogyVo`,不建重复 snapshot。
|
||||
|
||||
**API-SETTINGS-002:原子 dirty-only merge。** body 关闭额外字段、1—3 个出现字段,省略保持、intro 精确空串清空、null/空白/旧 pair/其他设置全部早失败。事务内锁定并重验 tenant、资源、权限,只应用出现字段;任一字段或迁移前置失败整笔零写。canonical no-op 保留版本且不产生额外领域副作用。
|
||||
|
||||
**API-SETTINGS-003:版本 CAS 与非泄漏权限。** `AppGenealogyVo.settingsVersion` 和 If-Match 共用 1—128 位 opaque owner,并以 `x-version-scope-fields`/`CANONICAL_SETTINGS_CHANGE_ONLY` 限定只在 canonical 设置实际变化时更换;旧版本精确 409+current,不发布 412/ETag 双轨。只有仍有权限者可得到版本/current;跨主体统一 404,权限撤销 403。冲突 precedence 为 version→READY→active pending,409 wrapper 只含 discriminator/oneOf 且分支均为 exact local ref,正式 H5 CORS 允许 If-Match。
|
||||
|
||||
**API-SETTINGS-004:访问迁移与申请串行化。** 活动 PENDING 只阻断真实 PUBLIC_APPLY→MEMBER_ONLY,返回无 PII 的 `ACTIVE_PENDING_APPLICATIONS` 并让客户端引导 G10;不阻断其他字段/no-op,不自动拒绝或撤回,不改变邀请合同。设置 PUT 与任务 36 新申请 POST 都在事务内重验 genealogyState/accessPreset,申请另验 eligibility,并共享 `x-public-apply-coordination=ATOMIC_SINGLE_WINNER`;竞态只允许一方提交,不把合同绑死为某种数据库隔离级别。
|
||||
|
||||
**API-SETTINGS-005:typed 响应、unknown 与缓存。** 精确 200/400/401/403/404/409/422/429/500,只允许 JSON;每个 response 必须含单值 enum private/no-store,429 另有 Retry-After;只允许非语义 tracing header:`traceparent/tracestate/x-request-id/x-correlation-id`,禁止 ETag 与其他语义 header,所有 component `$ref` 必须 exact local。四种 tracing header 若内联,只接受 allowed-key 关闭且带非 null 的 string schema 的 Header Object。标量、引用字段、成功/错误 envelope 与 fieldErrors 以 allowed-key 集关闭 `nullable`、readOnly/writeOnly、冲突组合和额外验证关键字;409 使用纯 businessCode discriminator 三分支,422 只返回三个字段错误,500 明示 outcome unknown。后端与客户端用真实账号、数据库观测、并发和 fault injection 验证零部分写、版本、权限与申请竞态;unknown 只以 fresh overview 对账,成功失效 mine/overview/search/preview 并重验 context。
|
||||
|
||||
**API-SETTINGS-006:客户端、无障碍与发布。** 同版双导出绿后,客户端首写先建可执行生产模块的纯 Node 失败测试,再实现 adapter/coordinator 并原子删除 fixture pair、timer、本地成功和 API 禁用旧断言;禁止恒红、token 扫描或测试内参考实现冒充行为证据。G11 覆盖原生 button/radio、label、aria-invalid/describedby、首错聚焦、busy/live、44dp、长文换行、返回冻结和冲突/unknown 草稿保留。OpenAPI、workspace/shared owner、客户端行为门禁、全量回归及 MuMu 原生矩阵任一未绿都不得接远端。
|
||||
|
||||
**关闭条件:** 后端从同一版本重导 JSON/YAML 并同时通过 workspace、join 与 `tests/g11-settings-openapi-contract.ps1`,再完成真实权限/版本/待审竞态/fault injection/HTTP/CORS;客户端随后按测试先行完成可执行 coordinator、页面/缓存/context 原子迁移,最后通过聚焦/全量和 MuMu 320/360/412、1.3 倍字号、TalkBack、软键盘、双击、慢网/断网/杀进程、Android 返回、权限撤销与版本竞态。当前输出 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`,48 项缺口;受保护 OpenAPI 未修改。
|
||||
|
||||
### 5.16 后端问题单 API-POEM-001—006
|
||||
|
||||
**优先级:** P0;阻塞 G12 字辈集合真实读取、原子保存、并发恢复、T01 正式消费与发布。
|
||||
|
||||
**API-POEM-001:唯一 owner 与 canonical 模型。** 发布唯一 `GET/PUT /genealogy/app/genealogies/{genealogyId}/generation-poems` 和 `appGetGenerationPoemSet/appUpdateGenerationPoemSet`,删除 collection POST、逐行 PUT、batch preview/save、management 及旧 DTO/response;不新增 preview token/status。GET/PUT 200 共用 closed `RGenerationPoemSetSnapshot → GenerationPoemSetSnapshot`,精确 required genealogyId/poemSetVersion/items,ACTIVE-only 且按 generationNo 升序;GET 重验 canView,PUT 事务内重验 tenant/canEditContent/READY。
|
||||
|
||||
**API-POEM-002:完整候选、软停用与原子写。** body 唯一为 closed required `AppGenerationPoemSetUpdateBody {items,disableMissing}`。items 0—500 严格 generationNo 升序;已有 ID 只允许 baseline ACTIVE 且可显式 move,新行省略 ID,disabled/unknown ID 失败,重复文本允许。false 保留遗漏 baseline,true 软停用;empty false no-op,empty true 停用全部。先校验声明严格升序,再构造声明目标与完整 merged candidate,随后按固定步骤验证唯一/slot/连续/最终容量、分配并校验新 ID,最后原子写;`GENERATION_SLOT_CONFLICT` 与所有校验失败零写,swap 必须显式声明全部受影响行,历史绝不物理删除。
|
||||
|
||||
**API-POEM-003:版本 CAS 与 Unicode。** `GenerationPoemSetVersion` 是 1—128 位 opaque、不可解析、永不复用的 ACTIVE 语义版本,PUT required If-Match,body 无版本,禁止 ETag/412;no-op 保持版本,实际变化生成新版本。poemId/version 均保持词法字符串。`GenerationPoemText` 为 NFC、1—50 Unicode code point、well-formed UTF-16、无边界空白,拒绝控制字符、CR/LF/tab、bidi override/isolate/mark、zero-width/BOM/word joiner、行段分隔符和孤代理;generationNo 是 1—2147483647 的唯一排序 owner。
|
||||
|
||||
**API-POEM-004:typed HTTP、security 与部署 CORS。** GET 精确 200/400/401/404/429/500,PUT 另有 403/409/422;全部 typed `application/json`、private/no-store,429 带 Retry-After,禁止 default/3xx/`*/*`/通用 envelope/200 包业务错。SaToken 精确为 Authorization header apiKey,operation security 是数组且 scopes 为空,clientid required 非空。顶层 closed `APP_GATEWAY_PREFLIGHT` policy 精确允许四个请求头、GET/PUT/OPTIONS、部署 allowlist 与 600 秒;GET/PUT 引用它。后端必须用正式 H5 origin 的真实 OPTIONS/preflight、凭证、拒绝 origin/header/method 反例证明 gateway 实现。
|
||||
|
||||
**API-POEM-005:冲突、unknown 与缓存收口。** 409 只含版本变化+current 与 READY 两分支,422 只含可达请求/候选错误。网络、超时、408、取消、5xx 和畸形 2xx 是 unknown;客户端冻结 baseline/version/declared/disableMissing/sessionEpoch,构造 effective target 后 fresh GET。existing/NEW、同基数、无额外行和版本策略必须按 `FRESH_GET_THREE_WAY_NO_AUTO_PUT` 执行;current==target 优先且不归因,current==old 才可由用户明确重试,其他进入 divergent,不自动 PUT。成功 canonical 回填并失效字辈消费者缓存;权限、READY 或账号变化失败关闭。
|
||||
|
||||
**API-POEM-006:客户端、无障碍与发布。** 同版双导出通过主门禁、zero issues 对抗合同和 Unicode Node 冒烟后,客户端首写先建直接执行生产 normalizer/coordinator 的失败测试,再实现唯一 adapter/coordinator,并原子删除 fixture、timer、本地成功、旧 preview/save 断言与兼容分支。页面覆盖清空确认、loading/empty/editing/saving/saved/unknown/conflict/no-permission、live region、首错聚焦、44dp 和返回冻结;MuMu 覆盖 320/360/412、字号、TalkBack、键盘、0/1/500×50、swap、双击、断网/杀进程、权限撤销、READY 与账号切换。
|
||||
|
||||
**关闭条件:** 后端同版本重导 JSON/YAML 并通过 `tests/g12-generation-poem-openapi-contract.ps1`、对抗合同、真实 DB/并发/fault injection/HTTP/CORS;客户端随后通过生产行为测试、页面原子迁移、聚焦/全量回归与 MuMu 矩阵。当前输出 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`、`Issues: 72`;`G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS` 和 `G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS` 只证明门禁质量,不解除后端红灯。受保护 OpenAPI 未修改。
|
||||
|
||||
### 5.17 后端问题单 API-FEED-READ-001—006
|
||||
|
||||
**优先级:** P0;阻塞 F01 动态列表和 F03 动态详情/一级评论的生产读取。发布、点赞、评论/回复写及媒体读取不属于本问题单。
|
||||
|
||||
**API-FEED-READ-001:三个稳定 owner 与旧入口删除。** 发布谱内嵌套的 `appListFamilyFeeds/appGetFamilyFeed/appListFamilyFeedRootComments`,全局各唯一;删除两个 `/page` GET,collection GET 使用 cursor。保留 `(genealogyId,feedId)` 复合资源边界,不新增可枚举的全局 feed 路由,不用写方法或 replies 冒充读取 owner。
|
||||
|
||||
**API-FEED-READ-002:词法身份与无 PII 最小 projection。** GenealogyId/FamilyFeedId/FamilyFeedCommentId 均为 opaque string。Feed 只含 `feedId/feedContent/authorDisplayName/publishedAt/hasMedia`,root comment 只含 `commentId/commentContent/authorDisplayName/publishedAt`;closed、required、非 null、有界。排除手机号、账号/业务用户 ID、tenant、status、remark、审核字段、parent/level、删除占位、内部媒体 ID 与持久化字段。`hasMedia` 只触发诚实占位;title/tag/like/count 不从 fixture 或正文猜测。
|
||||
|
||||
**API-FEED-READ-003:对象级授权与非泄露 404。** 每次/每页 fresh 验 tenant、genealogy、membership;详情和评论再验 feed 属于 path genealogy 且可见,评论只读正常 root。未知、错谱、删除/隐藏、撤权、跨主体或 cursor 跨 scope 统一 `FAMILY_FEED_NOT_AVAILABLE`,禁止 403 分流资源存在性;未认证才返回 401。
|
||||
|
||||
**API-FEED-READ-004:keyset cursor 与读取窗口。** `cursor? + limit?`,默认 20、1—50,无 total/pageNum;nextCursor 缺席即结束。feed 用 publishedAt/feedId 倒序,root comment 用 publishedAt/commentId 正序,时间不可变、ID ordinal tie-break。cursor 绑定 actor/session/client/scope/projection/order/limit/windowUpperBound/lastTuple,篡改或过期 typed 400。`UPPER_BOUND_KEYSET_LATEST_VISIBLE` 排除刷新前新增,后续删除/隐藏省略、编辑返回最新可见内容,不伪称严格 snapshot。
|
||||
|
||||
**API-FEED-READ-005:typed HTTP、安全与部署。** 三 GET 精确 200/400/401/404/429/500,只允许 closed typed application/json、private/no-store;429 Retry-After。SaToken 必须是数组且空 scopes,clientid required 非空;禁止 int64、通用 RList/RObject/PageResult、default/3xx/`*/*`/HTTP 200 业务错。复用 `APP_GATEWAY_PREFLIGHT`,正式 OPTIONS/preflight 与拒绝反例由部署证明。
|
||||
|
||||
**API-FEED-READ-006:证据与客户端激活。** 主门禁先以 zero issues 合法种子和 ref/大小写/HTTP 方法/PII/int64/offset/404 分流对抗变异证明自身,再要求后端同版本 JSON/YAML/live、真实权限撤销/跨谱 cursor/并发可见性/HTTP/CORS。后端绿后首写必须是执行生产 read normalizer/coordinator 的 Node 失败测试,再原子迁移 F01/F03,删除 fixture 锁、嵌入 comments、伪重试和旧 fixture 断言;远端失败不回退 mock。完成 sessionEpoch、局部错误、媒体占位、焦点/滚动、无障碍和 MuMu 矩阵后才可发布。
|
||||
|
||||
**关闭条件:** `tests/family-feed-read-openapi-contract.ps1`、对抗门禁、后端运行时反例、同版本三份接口证据、客户端生产行为测试、聚焦/全量回归与 MuMu 矩阵全部通过。当前输出 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85`;`FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58` 只证明门禁质量,不解除后端阻塞。受保护 OpenAPI 与 F01/F03 页面未修改。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 项目当前总览
|
||||
|
||||
> 当前阶段:导航任务 1—10、TAC 认证客户端、领域上下文基础与 M07 反馈客户端已经完成;T01、认证、家谱工作区、G03 原子创建、M06 帮助、个人资料读写、通知读写、M10 服务端退出、M04 登录态改密和 M05 手机号换绑后端接口门禁均为红灯,继续逐域关闭真实接口
|
||||
> 当前状态:A01/A04/A05 已接入统一 TAC、真实短信和认证请求代码,M07 已接真实反馈提交 owner;G01/G05、G03、M06、M01/M02/M03 个人资料读写、N01/N02/M01/G01 通知域、M10 退出域、M04 密码凭证域与 M05 换绑域已完成三人接口审查和失败门禁,均未猜测接线;`runtimeConfig.mode` 仍为 `mock`,MuMu 原生矩阵和真实环境联调待执行
|
||||
> 当前基准 HEAD:`9b0ad62df467f4e2c58b7689087683c77755e07b`(`main`,工作区有本轮未提交变更)
|
||||
> 当前阶段:导航任务 1—10、TAC 认证客户端、领域上下文基础与 M07 反馈客户端已经完成;任务 36 普通加入、任务 37 邀请码直入、任务 38 G11 设置和任务 39 G12 字辈集合合同已完成三人审查与失败门禁;任务 40 F01/F03 家族动态读取已完成三方接口定案并建立专项失败门禁,正在收口门禁对抗评审与文档;T01、认证、家谱工作区、G03、加入/邀请/G11/G12、F01/F03 读取、M06、个人资料读写、通知读写、M10、M04 和 M05 后端接口门禁均为红灯,继续逐域关闭真实接口
|
||||
> 当前状态:A01/A04/A05 已接入统一 TAC、真实短信和认证请求代码,M07 已接真实反馈提交 owner;G01/G05、G03、普通加入、邀请码直入、G11、G12、F01/F03 读取、M06、个人资料读写、通知域、退出、密码与换绑域均已完成接口审查并建立失败门禁,均未猜测接线;`runtimeConfig.mode` 仍为 `mock`,MuMu 原生矩阵和真实环境联调待执行
|
||||
> 当前基准 HEAD:`f1edc6b53320755a8638c99d492250153a8dcb17`(`main`,工作区有任务 36/37/38/39 未提交变更)
|
||||
> 更新日期:2026-07-23
|
||||
|
||||
## 2026-07-23 换机续作断点
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
本项目的总目标不可缩减为“只写规划”或“只让静态测试通过”:必须在 `main` 工作区内持续推进整个 UniApp 家谱项目,按测试先行和三人交叉评审完成导航、T01 长世代世系树、TAC/认证、家谱领域数据、G/T/F/R/N/M 全量页面与接口、异常恢复、无障碍、构建及上线验证,最终交付能够正常上线使用的项目。若本地可完成的工作全部完成后只剩后端合同重导、有效测试账号、应用签名、发布凭证或 MuMu 人工操作等外部状态,才允许停在明确硬阻塞;每项阻塞必须有失败门禁、复现证据、唯一所有者、解除条件和继续步骤,不能用 mock、fixture、定时器或宽松兼容伪装完成。
|
||||
|
||||
协作固定为当前主代理加两位评审者,共三人。三人都必须独立检查接口/字段、页面/业务闭环、交互/异常/视觉,再交叉质询并统一结论;主代理是唯一写入者,不再创建旧专家身份或增加并发评审者。普通技术取舍由三人自行收敛,不反复交给用户。系统保存的目标记录仍存在,但在 2026-07-23 读取时状态为 `paused`;换机后的执行者不得据此把任务视为取消,应以上述总目标和本节断点继续。只有项目真实达到完成定义时才能标记完成。
|
||||
协作固定为当前主代理加两位评审者,共三人。三人都必须独立检查接口/字段、页面/业务闭环、交互/异常/视觉,再交叉质询并统一结论;主代理是唯一写入者,不再创建旧专家身份或增加并发评审者。普通技术取舍由三人自行收敛,不反复交给用户。系统目标已经重新固定为整个项目的可上线闭环并处于 active;只有项目真实达到完成定义时才能标记完成。
|
||||
|
||||
### 仓库与保护基线
|
||||
|
||||
- 当前分支为 `main`,HEAD 为 `9b0ad62df467f4e2c58b7689087683c77755e07b`,上游显示 `main...origin/main`。工作区含大量本轮未提交的修改、删除和新增文件,均属于当前连续治理成果;换机后先运行 `git status -sb` 和 `git rev-parse HEAD` 核对,禁止用 `restore/checkout/reset` 清理,也禁止自行改分支或 worktree。
|
||||
- 当前分支为 `main`,HEAD 为 `f1edc6b53320755a8638c99d492250153a8dcb17`,上游显示 `main...origin/main`。工作区含任务 36/37/38/39 未提交修改和新增门禁,均属于当前连续治理成果;禁止用 `restore/checkout/reset` 清理,也禁止自行改分支或 worktree。
|
||||
- 本节仅记录断点,没有执行 `git add/commit/push`。由用户自行上传;换机后必须保留当前完整工作区,而不能只依赖旧远端基线。
|
||||
- 后端唯一目标地址是 `https://backend-api.ddxcjp.cn/`。受保护 `APP.openapi.yaml` 的 SHA-256 为 `8964CD583CE172425B63BBFD802F7EB587EB3641EADFD6F9D3B264FAA8090C6C`,`APP.openapi.json` 为 `87DB1DC148C5E6E877815AFF7B3A7FC7C7ECA95A2CEC50A88B42F3908961F31A`;两文件相对 HEAD 无差异。不得修改、格式化、覆盖或删除,只接受后端同一版本重新导出的双文件。
|
||||
- 不得启动、关闭或调整 MuMu。浏览器截图和源码检查不能冒充 Android 原生视觉、TalkBack、系统字号、软键盘或返回键验收。
|
||||
- 当前物理测试库存仍是 PowerShell `140`、Node `47`。最近一次完整记录为 PowerShell `126/140`,14 项均是预期发布红灯;Node 语法 `47/47`、纯 Node `19/19`、活动 Vue 脚本 `64/64` 通过。G03 最后一次布尔类型加固后又单独通过聚焦合同;换机后应先重跑完整套件,不能把这些历史数字冒充新机器证据。
|
||||
- 换机后 fresh 基线已重跑:任务 36 门禁前为 PowerShell `126/140`、14 项预期发布红灯;任务 36 后为 `126/141`。任务 37 新增 `tests/invite-ticket-openapi-contract.ps1` 后物理库存为 PowerShell `142`、Node `47`,全量 fresh 为 `126/142`,16 项均为明确发布红灯;任务 38 新增 `tests/g11-settings-openapi-contract.ps1` 后当前物理库存为 PowerShell `143`、Node `47`,全量 fresh 为 `126/143`,17 项均为明确发布红灯。任务 39 新增 G12 主门禁、对抗合同和 Unicode Node 冒烟后,物理库存为 PowerShell `145`、Node `48`,全量 fresh 为 `127/145`,18 项均为明确发布红灯。任务 40 新增家族动态读取主门禁与对抗合同后,当前物理库存为 PowerShell `147`、Node `48`,全量 fresh 为 `128/147`,19 项均为明确发布红灯;Node 语法 `48/48`、纯 Node `20/20`、活动 Vue 脚本 `64/64` 和编译审计均 fresh 通过。
|
||||
|
||||
### 已完成到哪里
|
||||
|
||||
@@ -33,16 +33,61 @@
|
||||
|
||||
- 搜索、我的申请和待审列表使用各自专用最小投影与稳定 cursor 分页,不返回 `total`,所有 `genealogyId/applyId` 都是有界词法字符串。搜索项只暴露识别家谱所需字段和当前查看者状态;我的申请完整表达 `PENDING/APPROVED/REJECTED/WITHDRAWN`;待审项只含 `applyId/applicantName/relationDesc/applyReason?/submittedAt`,不得泄漏手机号、用户 ID、邀请人或审核人内部字段。
|
||||
- 申请 body 闭合为 `applicantName/relationDesc/applyReason?`,前两项必填;删除 `phone/inviterUserId`。同账号、租户、家谱最多一个活动 `PENDING`,由数据库唯一约束而非先查后插保证。
|
||||
- 申请 POST 必须带无 PII 的 `Idempotency-Key`。同 key、同 canonical body 重放同一结果;同 key、不同 body 返回 409。客户端只持久 `{sessionEpoch,requestKey,startedAt}`,不持久姓名、关系和理由。
|
||||
- 申请 POST 必须带无 PII 的 `Idempotency-Key`,并与状态查询的 requestKey 共用唯一 `GenealogyJoinApplicationRequestKey`:`gja.{13 位毫秒时间}.{22—43 位 base64url CSPRNG}`,随机量至少 128 位;`acceptUntil=issuedAt+10 分钟`、未来偏差最多 5 分钟、`resolveBy<=claimedAt+2 分钟`。canonical digest 唯一覆盖 method、规范 path、genealogyId、tenant、account、client 与规范 body;正文统一执行 NFC、去边界空白、CRLF/CR→LF且不折叠内部空白。同 key、同 canonical request 重放同一结果;同 key、不同 digest 返回 409。客户端只持久 `{sessionEpoch,requestKey,startedAt}`,不持久姓名、关系和理由。
|
||||
- 冷启动恢复不复用 `mine` 查询,也不持久表单 PII;新增唯一只读 owner `GET /genealogy/app/genealogies/join-apply-requests/{requestKey}`,以显式 discriminator 返回 `PENDING {resolveBy,retryAfterSeconds}`、`SUCCEEDED {applyId/genealogyId/...无 PII 回执}` 或 `FAILED_NO_COMMIT`。只允许 `ABSENT→PENDING→SUCCEEDED/FAILED_NO_COMMIT`,两个终态不可变;GET 纯读,跨账号/租户/client 统一非泄漏 404,PENDING 必须有收敛期限,从未到达的 key 也必须在可计算时间边界后成为零写 `FAILED_NO_COMMIT`。
|
||||
- 不引入 `applicationVersion`、`If-Match` 或审核详情端点。申请在 PENDING 时不可编辑,撤回、通过和拒绝都以 `WHERE status=PENDING` 的数据库 CAS 决定唯一赢家。重复相同撤回返回同一 200;重复相同审核决定返回原 200,拒绝时只有规范化后理由相同才算相同动作;相反决定、不同拒绝理由或撤回/审核竞态败方返回 typed 409 和当前最小状态。终态不可改,重新申请创建新 `applyId`。
|
||||
- 审核 body 只允许 `APPROVE`,或 `REJECT+必填申请人可见 rejectionReason`。批准必须在同一事务完成唯一成员关系和申请终态;G05 的 `canReviewJoinApplications` 只是入口 capability,服务端在事务/CAS 时仍重新验证权限、家谱状态和 `PUBLIC_APPLY`。
|
||||
- 所有操作 required SaToken 和非空 `clientid`,仅使用 `application/json`、`Cache-Control: private, no-store`,429 带 `Retry-After`。线上三个匿名 GET 当前实测为 HTTP 200 加业务 `code=401`,却与文档 HTTP 401 string 冲突;发布合同必须统一为真实 HTTP 状态与 typed JSON,禁止 `*/*`、200 包装认证错误、`default` 响应和 int64 JSON 身份。
|
||||
- 客户端结果未知时禁止乐观改列表:申请查专用 operation;撤回刷新 mine 并可安全重放同一 DELETE;审核刷新 pending,行消失只能说“状态已变化/已被处理”,不能冒充本次审核成功。首版不承诺消息中心通知;G09 的 `onShow` 与手动刷新是当前业务真相。
|
||||
|
||||
**尚未实施的边界必须原样保留:** `tests/join-application-openapi-contract.ps1` 还没有创建,任务 36 尚未写入实施计划和接口映射,测试计数仍为 140/47;G06/G08/G09/G10 业务代码也没有在本任务中修改。换机后的第一项写操作应是用 `apply_patch` 新增该 OpenAPI 失败门禁,先运行并取得精确 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`,再把 `API-JOIN-001`—`006`、七个 operation、状态机、隐私投影、竞态和部署反例同步写入现有四份中文权威文档及文档合同。后端门禁通过前页面继续保持诚实本地预览,不写只适配当前宽松线上模型的临时代码。
|
||||
**当前实施边界:** `tests/join-application-openapi-contract.ps1` 已创建并取得精确 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`;它成为七个 operation 与 APP 加入 schema 的唯一 owner,旧 `g-series-openapi-contract.ps1` 和 `core-flow-contract.ps1` 不再锁定 `phone/inviterUserId`、数字状态和 `auditRemark`。任务 36 只建立后端合同、权威文档和发布反例,不修改 G06/G08/G09/G10 业务代码;后端门禁通过前页面继续保持诚实本地预览,不写只适配当前宽松线上模型的临时代码。后续客户端批次必须删除 G10 手机号、三处消息中心承诺、`LOCAL_WITHDRAWN` 与遗留 adapter,并以状态机测试和 MuMu 证据完成原子迁移。
|
||||
|
||||
完成任务 36 的本地门禁、全量回归和三人终审后,继续按独立批次推进邀请码直入、G11 设置写入、G12 字辈真实保存,以及其余 G/T/F/R/N/M 业务域;不得一次混合导航、验证码、领域持久化和无障碍多个阶段。最终仍须完成真实后端联调、构建、签名/隐私配置、MuMu 全流程矩阵和发布终审。
|
||||
任务 36 已完成本地门禁、`126/141` 全量回归和三人终审,后端缺口继续以发布红灯保留。当前已进入独立任务 37;完成后继续 G11 设置写入、G12 字辈真实保存,以及其余 G/T/F/R/N/M 业务域。不得一次混合导航、验证码、领域持久化和无障碍多个阶段;最终仍须完成真实后端联调、构建、签名/隐私配置、MuMu 全流程矩阵和发布终审。
|
||||
|
||||
### 当前精确断点:任务 37 邀请码签发与直接加入
|
||||
|
||||
三人已对 G06/G08/M08、受保护双导出和 2026-07-23 当前线上 OpenAPI 完成只读审查并交叉反驳。线上 722 条路径中只有通用 promotions 与礼仪邀请,没有家谱邀请码签发、解析、撤销或直接加入 owner。统一结论为六个专用 operation:活动票据列表、无 body 幂等签发、撤销、JSON body 安全解析、无 body 直接兑换、兑换 request-status。
|
||||
|
||||
- 真实流程只在 G06 完成“输入原码→resolve(不消费票据、不建立成员,仅允许轮换短时 grant 摘要)→展示无邀请人 PII 的可信目标→明确确认直接加入且不经审核→redeem→status 收敛→刷新 `/mine`→安装 context→G01/G05”。邀请不再进入 G08;真实接线时删除 G06→G08 invite 导航和 G08 全部 invite source,G08 只归普通申请所有。
|
||||
- 首版票据固定单次、24 小时和至少 128 位随机量;HMAC 查找,隔离 KMS 密文只保留 600 秒同 key 秘密恢复窗。签发 unknown 只重放原 `gii` POST/key,窗后返回 `ISSUED_SECRET_UNAVAILABLE` 且不新签;客户端不持久原码。
|
||||
- resolve 原码只在 JSON body,统一非枚举错误和多维限流;最多五分钟的 redemption token 绑定 tenant/account/client/ticket/version/genealogy/authorizationEpoch,只驻内存。列表、URL/query、导航、storage、日志/APM/分析、错误和除指定成功分支外的响应闭包禁止原码/token。
|
||||
- redeem 不提交姓名、关系或理由,不创建申请、审核记录、人物或亲属关系;事务内原子完成 ACTIVE→CONSUMED、唯一 MEMBER 和成功回执。已有普通 PENDING 引导先去 G09 撤回,不由邀请流程暗改任务 36。
|
||||
- 兑换 unknown 使用 `gir` key 和 PENDING/SUCCEEDED/FAILED_NO_COMMIT 纯读状态收敛;客户端只持久 `{sessionEpoch,requestKey,startedAt}`。FAILED 保证票据未消费、成员未建,成功后的 workspace/context/导航失败只重试本地收口。
|
||||
|
||||
`tests/invite-ticket-openapi-contract.ps1` 已测试先行创建,初次取得 `INVITE-TICKET-OPENAPI-CONTRACT BLOCKED`,经两位评审指出错误 envelope、撤销 CAS、秘密递归闭包、幂等时限、普通申请反向隔离等假绿后已加固到稳定 81 项后端缺口。文档合同、`126/142` 全量 PowerShell、Node `47/47`、纯 Node `19/19`、Vue 脚本 `64/64` 和编译审计均已 fresh 通过预期边界;当前门禁仍保持后端红灯,任务 37 已完成三人终审。后端门禁通过前不修改 M08/G06/G08 业务接线,不用 fixture、定时器或兼容分支伪装邀请码生产闭环。
|
||||
|
||||
### 当前精确断点:任务 38 G11 家谱设置版本化写入
|
||||
|
||||
三人已核对 G11、任务 26 workspace、任务 35 共享 accessPreset、受保护双导出与 2026-07-23 线上 OpenAPI。当前页面仍从 fixture 读取,保存只更新本页 baseline 并用 1800ms timer 显示“尚未提交服务器”;旧双导出 PUT 是宽 `GenealogyUpdateBody → RObject`,线上仍是含十个字段和 `visibility/joinMode` 的宽 `AppGenealogyUpdateBody`,两者都缺版本 CAS、typed 错误与严格权限/媒体合同。
|
||||
|
||||
- 全局唯一写 owner 保留 `PUT /genealogy/app/genealogies/{genealogyId}`,operationId 固定且全局唯一 `appUpdateGenealogySettings`;递归拒绝 PATCH、`/settings` 旁路、任一单字段设置 body/字段写入口,以及用外部或错分区同名 `$ref` 冒充 owner,并删除重复 generic GET。`/genealogy/app/genealogies...` 写 body 根层的 `intro` 明确保留给家谱设置,其他子域必须使用 `albumDesc/articleSummary` 等自身字段名,避免把设置第二写入口伪装成通用简介。workspace 同时递归扫描成功响应闭包,拒绝任何返回 `RAppGenealogyVo/AppGenealogyVo`、allOf 合成或等价实体的旁路详情 GET。G11 只消费 workspace 门禁的 PASS 结论,每次进入 fresh 读取唯一 `/overview` baseline,不信任路由、mine、旧 G05 内存、roleType 或 fixture。
|
||||
- 共享 `GenealogyName` 统一 G03/G11/读取为非 null、NFC、无边界空白、无换行/控制字符的 1—24 Unicode code point;`GenealogyIntro` 精确为空串或规范 1—80,正文只允许内部 LF,拒绝 CR/tab/其他控制字符,空串清空、省略保持。body 关闭额外字段且只允许 1—3 个 `genealogyName/intro/accessPreset` 脏属性,事务内原子 merge;canonical no-op 保留版本且无领域副作用。
|
||||
- 并发与 T01/M02 统一:`AppGenealogyVo.settingsVersion` 与 required `If-Match` 引用同一 opaque owner,以 `x-version-scope-fields` 锁定只随三个 canonical 设置实际变化,body 不重复版本,不另用强 ETag/412。409 根层只含 discriminator/oneOf 且分支为 exact local ref,精确分为版本变化+current、未 READY、活动待审;冲突优先级为 version→READY→active PENDING。
|
||||
- 活动 PENDING 只阻断实际 `PUBLIC_APPLY→MEMBER_ONLY`,整笔零写并引导 G10;不自动处理普通申请,不影响邀请码。设置 PUT 与任务 36 新申请 POST 都在事务内重验准入并共享 `ATOMIC_SINGLE_WINNER`,竞态只能一方提交。
|
||||
- 200 与 overview 共用关闭额外字段的 canonical `RAppGenealogyVo`;所有 component 解析只接受 exact local ref,标量、envelope、引用字段和错误字段以 allowed-key 集拒绝 `format/min/max/readOnly/writeOnly` 等额外约束、冲突组合关键字与 `nullable` 漂移。每个响应必须包含单值 enum 的 `Cache-Control: private, no-store`,429 另含 Retry-After;只允许 `traceparent/tracestate/x-request-id/x-correlation-id` 四种非语义 tracing header,禁止 ETag 与其他语义 header,避免产生第二套并发或缓存 owner。四种 tracing header 使用 component 时同样只能 exact local;内联时必须是 allowed-key 关闭、带非 null 的 string schema 的 Header Object。任务 36 的幂等、cursor、状态迁移、事务与重验扩展必须是真实 JSON 数组,不能用逗号字符串冒充。网络、超时、408、取消、5xx 和畸形 2xx 都是 outcome unknown;fresh overview 对账只陈述当前事实,不自动 PUT 或冒充本次成功。成功失效 mine/overview/search/preview 并重验 context。
|
||||
|
||||
`tests/g11-settings-openapi-contract.ps1` 已测试先行建立,吸收两位评审的全局 owner、workspace 前置、准入反向协调、字符、nullable、exact local ref、固定 header 与纯 discriminator 伪绿反例后稳定输出 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`、`Issues: 48`。它接管 PUT、body、settingsVersion/If-Match、409 与响应;workspace/join/G03 门禁分别只保留读取基础、申请准入协调、bootstrap/accessPreset/旧 DTO 删除。本批不创建无法执行生产 coordinator 的占位 client gate;后端同版双导出绿后,客户端第一项写操作必须先建立实际执行生产模块的纯 Node 失败测试,再实现 adapter/page,OpenAPI 绿不等于允许接线。任务 38 完成全量回归与三人终审后继续独立任务 39:G12 字辈真实保存。
|
||||
|
||||
### 当前精确断点:任务 39 G12 字辈集合版本化保存
|
||||
|
||||
三人已完成 G12 页面、共享字辈夹具/Node 冒烟、受保护双导出及现有 G-series 门禁的只读核对,并统一为唯一 `GET/PUT /genealogy/app/genealogies/{genealogyId}/generation-poems`,operationId 分别为 `appGetGenerationPoemSet`、`appUpdateGenerationPoemSet`。旧 collection POST、逐行 PUT、batch preview/save、management 旁路必须同版删除;不引入 preview token、preview/status 接口或第二写 owner。GET/PUT 200 共用 ACTIVE-only `GenerationPoemSetSnapshot {genealogyId,poemSetVersion,items}`,稳定词法 `poemId` 可随 `generationNo` 移动,重复字辈文字允许,排序只由 1—2147483647 的 generationNo 拥有。
|
||||
|
||||
- body 唯一为精确 `{items,disableMissing}` 的 `AppGenerationPoemSetUpdateBody`。声明 items 必须严格递增且最多 500;已有行的 poemId 必须引用 baseline ACTIVE,新行省略 ID。`disableMissing=false` 保留未声明 baseline,`true` 软停用遗漏项;空 false 是 no-op,空 true 停用全部。服务端先构造完整候选集合,再按 generationNo 排序并执行唯一 ID/世代、slot collision、连续性与最终 ACTIVE 容量检查;swap 必须显式提交所有受影响行并原子成功或零写,绝不物理删除历史。候选流程包含 `VALIDATE_FINAL_ACTIVE_CAPACITY`、`ALLOCATE_UNIQUE_NEW_IDS` 及新 ID 非空、唯一、非 baseline 校验,碰撞返回 `GENERATION_SLOT_CONFLICT`。
|
||||
- 并发唯一使用 opaque `GenerationPoemSetVersion` 与 required `If-Match`;body 不复制版本,不引入 ETag/412。canonical no-op 保留版本,语义变化生成永不复用的新版本。GET 重验 canView,PUT 在事务内重验 tenant、canEdit、版本与 READY;响应严格 typed JSON、private/no-store,429 带 Retry-After。正式 CORS 唯一声明 owner 为 `x-app-gateway-policies.APP_GATEWAY_PREFLIGHT`,精确允许 Authorization/Content-Type/If-Match/clientid 和 GET/PUT/OPTIONS;最终仍须用真实 gateway OPTIONS/preflight 证明部署 allowlist,导出声明不能冒充运行证据。
|
||||
- unknown 恢复采用 `FRESH_GET_THREE_WAY_NO_AUTO_PUT`:以 baseline/version、声明项与 disableMissing 构造 effective target;已有项按 ID+generation+NFC 文本匹配,新项按 generation+文本匹配且服务端 ID 必须新鲜。当前集合与 target 相等时执行 `CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION`,只确认事实而不归因本次请求;仍等于 old 才允许用户明确重试,其他情况进入分歧处理。比较要求同基数、无额外行,语义等价忽略版本,但已变化 target 必须得到新且未复用版本;任何 unknown 后都不自动 PUT。
|
||||
- 字辈文本按 NFC、1—50 Unicode code point、完整 UTF-16 且拒绝边界空白、控制字符、bidi/zero-width 类字符。客户端在后端门禁转绿前继续诚实本地预览,禁止把 fixture、timer 或本地成功接到当前宽松接口。转绿后的第一项客户端写操作必须是直接执行生产 normalizer/coordinator 的失败测试,再原子删除旧 fixture/timer/本地成功和旧合同;随后补 saving/unknown/conflict、清空确认、live region、焦点、44dp 与 MuMu 320/360/412、1.3 倍字号、TalkBack、软键盘、0/1/500×50、swap、断网/杀进程、权限撤销、READY 和账号切换矩阵。
|
||||
|
||||
测试先行证据为 `tests/g12-generation-poem-openapi-contract.ps1` 输出 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`、`Issues: 72`;72 项只指向受保护旧导出的真实路径/schema/header/CORS 缺口。完整 zero issues 合法种子及多跳 ref、callback、HTTP 方法、typed error、候选/unknown/CORS 变异由 `tests/g12-generation-poem-openapi-adversarial-contract.ps1` 证明为 `G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS`;ECMAScript Unicode 边界由 `tests/g12-generation-poem-unicode-contract-runtime-smoke.js` 证明为 `G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS`。G-series 已删除旧 G12 owner,三人第三轮终审无 P0/P1;受保护 OpenAPI 和页面均未修改。
|
||||
|
||||
### 当前精确断点:任务 40 F01/F03 家族动态读取
|
||||
|
||||
本批只治理 F01 动态列表、F03 动态详情和正常一级评论读取,明确排除 F02 发布、点赞、评论/回复写、回复读取和媒体文件读取。三方已统一唯一 owner 为 `GET /genealogy/app/genealogies/{genealogyId}/feeds`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}`、`GET /genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments`,operationId 分别为 `appListFamilyFeeds`、`appGetFamilyFeed`、`appListFamilyFeedRootComments`;同版本删除两个 `/page` GET,保留谱上下文嵌套身份,不引入全局 feed 路由。受保护双导出当前仍有 feed list/page 与 comments list/page 双 owner、无 operationId、int64 ID、通用 RList/RObject/PageResult 和开放评论 DTO;2026-07-23 线上 OpenAPI 3.1.0 还显示 `AppFamilyFeedVo/FamilyFeedCommentVo` 无 required,评论投影含手机号和内部审核字段,因此两者都不能接页面。
|
||||
|
||||
`API-FEED-READ-001`—`006` 固定词法 `GenealogyId/FamilyFeedId/FamilyFeedCommentId` 与 closed required 最小投影。`AppFamilyFeedReadItem` 只含 `feedId/feedContent/authorDisplayName/publishedAt/hasMedia`;`hasMedia=true` 时,在媒体读取合同落地前客户端必须诚实展示附件暂不可用占位,不能静默伪装为完整纯文字动态。`AppFamilyFeedRootCommentReadItem` 只含 `commentId/commentContent/authorDisplayName/publishedAt`,只返回正常可见一级评论,不返回删除占位、reply、手机号、账号/业务用户 ID、状态或审核字段。标题、标签、点赞数、总评论数和 fixture 展示时间都不是本批服务端合同,adapter 不得猜值。
|
||||
|
||||
两个 collection GET 使用 opaque cursor,`limit` 默认 20、范围 1—50,不返回 total/pageNum;feed 按 `publishedAt DESC + feedId DESC_ORDINAL`,评论按 `publishedAt ASC + commentId ASC_ORDINAL`。读取窗口精确为 `UPPER_BOUND_KEYSET_LATEST_VISIBLE`:新插入项等刷新后再出现,删除或变为不可见的项在后续页省略,编辑内容按该页读取时的最新可见版本返回,不能宣传为严格 MVCC snapshot。cursor 绑定 tenant/account/authSession/client、谱/动态、projection/order/limit、windowUpperBound 与 lastTuple;每页重新鉴权,篡改或过期为 typed 400,跨主体、错谱、feed 不属于 path 谱、删除/隐藏、撤权或不可见统一 `404 FAMILY_FEED_NOT_AVAILABLE`,不以 403 泄露存在性。
|
||||
|
||||
`tests/family-feed-read-openapi-contract.ps1` 已测试先行取得 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85`。85 项全部对应受保护旧导出的双 owner、参数、词法身份、closed projection、cursor、安全、typed JSON、private/no-store、Retry-After、严格 Reference Object 与无 PII 缺口;`tests/family-feed-read-openapi-adversarial-contract.ps1` 以完整 zero issues 合法种子和 58 个独立变异取得 `FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58`,覆盖旧 owner、大小写、security owner/数组、int64、PII/审核字段、offset/total、cursor scope/order、403 分流、wildcard、缓存头、外部/多跳 ref、HEAD/OPTIONS/callback 与 schema 关键字旁路。受保护 OpenAPI、F01/F03 和 dormant `appApi.getFeeds` 均未修改。后端绿前 F01/F03 继续使用按 `genealogyId + feedId` 失败关闭的 fixture,禁止接当前宽接口或在远端失败后回退 fixture。后端同版本 JSON/YAML/live 文档全部通过后,客户端第一项写操作必须先建立直接执行生产 read normalizer/coordinator 的纯 Node 失败测试,再原子迁移列表、详情和评论分页,删除 fixture 锁、伪重试与旧“必须 fixture”断言,并补 sessionEpoch、切谱/切账号迟到响应、局部评论错误、诚实媒体占位、焦点恢复和 MuMu/无障碍矩阵。
|
||||
|
||||
## 当前目标
|
||||
|
||||
@@ -72,11 +117,11 @@
|
||||
|
||||
后端在 2026-07-22 新提供 `https://backend-api.ddxcjp.cn/`。同日 21:52(Asia/Shanghai)只读获取其 `/v3/api-docs`,线上为 OpenAPI `3.1.0`、`722` 条路径、`858` 个操作和 `507` 个模型;本地 112 条路径中有 109 条仍在线,`/genealogy/app/files/reference`、`/genealogy/app/files/upload`、`/genealogy/pc/files/upload` 三条不在当前线上文档,线上另有 613 条路径。线上模型已把部分 `Genealogy*Body` 重命名为 `AppGenealogy*Body`,`GenerationPoemBatchBody` 也新增了由路径写入的 `genealogyId` 字段,因此本地双导出是明确的旧快照,不能再代表当前部署的完整合同。`utils/config.js` 的唯一 `baseUrl` 已更新为无尾斜杠的 HTTPS 地址;`mode` 仍保持 `mock`,在页面接口和安全合同逐批闭合前不得提前切换远端。
|
||||
|
||||
线上文档已经提供 `/captcha/require`、`/captcha/challenge`、`/captcha/verify`:挑战与校验绑定 `tenantId/clientId/sceneCode/subject`,校验成功响应可返回 `validToken`,而发送短信的 `AppSmsCodeBody` 已把 `validToken` 列为必填。客户端已按 `APP_SMS_LOGIN/APP_REGISTER/APP_FORGOT_PASSWORD` 三个精确场景实现“查询要求→取得挑战→供应商完成→服务端校验→携票发送短信”,注册和找回提交不重复执行 TAC;认证请求只接受 HTTP 200 的严格 JSON envelope,统一 15 秒超时,离页或返回会中止当前 RequestTask。密码登录体尚无 `validToken`,因此 A01 以短信登录为默认且密码登录入口保持不可用,绝不以客户端先滑动冒充服务端强制校验。
|
||||
线上文档已经提供 `/captcha/require`、`/captcha/challenge`、`/captcha/verify`:挑战与校验绑定 `tenantId/clientId/sceneCode/subject`,校验成功响应可返回 `validToken`,而发送短信的 `AppSmsCodeBody` 已把 `validToken` 列为必填。客户端已按 `APP_SMS_LOGIN/APP_REGISTER/APP_FORGOT_PASSWORD` 三个精确场景实现“查询要求→取得挑战→供应商完成→服务端校验→携票发送短信”,注册和找回提交不重复执行 TAC;认证请求只接受 HTTP 200 的严格 JSON envelope,统一 15 秒超时,离页或返回会中止当前 RequestTask。A01 现以密码登录为默认并接通线上 MD5 wire、`access_token` 持久化与 G01 跳转;默认 mode 仍为 mock。密码联调入口的 static TAC 票据尚未由登录接口消费,因此只是测试性前置,不能冒充服务端强制校验,`API-AUTH-TAC-001` 继续保持发布红灯。
|
||||
|
||||
认证后端门禁仍有四组问题:`API-AUTH-TAC-001` 要求密码登录加入服务端可消费的同语义票据;`API-AUTH-TAC-002` 记录线上 `/captcha/challenge` 在 `APP_REGISTER` 实测返回 HTTP 500 且空响应;`API-AUTH-TAC-003` 要求验证请求以 provider discriminator/`oneOf` 严格关闭根对象和各 payload 的额外字段,并补齐必填 `providerCode/captchaType/payload`;`API-AUTH-TAC-004` 要求同一验证中心返回服务端绑定的 verification session、可验证方法和同一类短时单次 `validToken`,`required=false` 也必须直接签发可供短信接口消费的票据,不得形成无障碍绕过。`tests/auth-tac-openapi-contract.ps1` 当前输出 `AUTH-TAC-OPENAPI-CONTRACT BLOCKED`。密码登录、短信登录和注册的线上成功响应已统一为 `RAppLoginVo → AppLoginVo.access_token`,客户端旧令牌字段读取已删除。线上文档自身仍发布 `http://backend-api.ddxcjp.cn` server URL,与已验证可用的 HTTPS 地址不一致;客户端只能使用显式 HTTPS,后端还需修正文档 server 声明。对 `http://localhost:5173` 的预检已返回允许 `content-type/clientid`,这不能替代正式 H5 域名的 CORS 验证。
|
||||
|
||||
家谱工作区只读审查已固定 `/genealogy/app/genealogies/mine` 为可访问集合 owner、`/genealogy/app/genealogies/{genealogyId}/overview` 为 G05 唯一详情 owner;首批不同时请求语义重复的 `/{genealogyId}`。线上 `AppGenealogyVo.genealogyId` 仍是 JSON `integer/int64`,最大合法值进入 JavaScript 后会不可逆失真;三个相关模型均无 `required`,也没有必填 `canView`,`roleType` 无枚举。无令牌实测三条读取均返回 HTTP 200、`application/json;charset=UTF-8` 与 `{code:401,msg,data:null}`,但文档只列 200/401 且 401 为字符串,尚不能稳定区分登录失效、对象无权、已删除和服务故障。问题单 `API-GENEALOGY-WORKSPACE-001`—`003` 与 `tests/genealogy-workspace-openapi-contract.ps1` 已建立;同版本双导出通过前不写 G01/G05 专属 adapter,不用本地字段猜测替代服务端 owner。
|
||||
家谱工作区只读审查已固定 `/mine`/`appListMyGenealogies` 为可访问集合 owner、`/{genealogyId}/overview`/`appGetGenealogyOverview` 为 G05 唯一详情 owner;首批不同时请求语义重复的 `/{genealogyId}`。门禁已收紧为 GET-only/no-body、required SaToken/clientid、词法 `GenealogyId`、关闭的成功 envelope、typed HTTP、exact local ref 和固定 private/no-store;`/mine` 以 `OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS` 约束失权省略,overview 以 `NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE` 统一对象无权/撤权/不存在,非法 ID 为 `GENEALOGY_ID_INVALID`。线上 `AppGenealogyVo.genealogyId` 仍是 JSON `integer/int64`,相关模型无 required/canView/角色 enum,无令牌部署又使用 HTTP 200+业务 401,均不能通过。问题单 `API-GENEALOGY-WORKSPACE-001`—`003` 与 `tests/genealogy-workspace-openapi-contract.ps1` 是唯一读取门禁;同版本双导出通过前不写 G01/G05 专属 adapter,不用本地字段猜测替代服务端 owner。
|
||||
|
||||
M06 三人审查选择 `GET /genealogy/app/help-articles` 的完整列表作为唯一远端 owner;线上列表模型已经包含 `helpContent`,因此页面不调用详情端点、不消费 `helpId`,也不让 JSON `int64` 进入页面模型。每次响应只允许显式投影 `helpCategory/helpTitle/helpContent`,分类从当前列表动态派生,展开键只在当前响应生命周期内使用并在搜索、分类、刷新前清空。当前受保护双导出仍返回通用 `ListResult/RList`,线上 `RListHelpArticleVo/HelpArticleVo` 又没有 `required`,正文格式、仅发布内容、展示顺序和认证失败承载也未形成一致合同;匿名实测列表与详情均为 HTTP 200+业务 `code=401`,而线上文档声明 HTTP 401 string。问题单 `API-M06-001`—`003` 与 `tests/help-center-openapi-contract.ps1` 已建立;门禁通过前保留明确本地 FAQ,不写 live-only adapter。
|
||||
|
||||
@@ -134,10 +179,20 @@ G03 创建链路三人反向质询后否决“先建空谱、再写始祖”的
|
||||
- 浏览器截图链已把 G03、G06、G08—G12、T06、F02、N01 的有效状态断言迁回各自现有运行时合同;G01、T03—T08、T07 三个有价值的浏览器测试只剥离截图写盘,继续验证状态、滚动、溢出与交互。
|
||||
- 已删除固定 Chrome 截图助手、联系表、8 个只保护截图助手的合同,以及迁移后重复的 N01/A02 合同;`tests/mumu-visual-acceptance-boundary-contract.ps1` 现在唯一约束“仓库不维护自动截图证据链、最终视觉通过只来自 MuMu”。
|
||||
|
||||
当前物理库存为 `140` 个 PowerShell 合同、`47` 个 Node 文件和 `4` 个 JSON 合同数据文件。最新 fresh 结果为 PowerShell `126/140` 通过;其余 14 项不是可删除的普通回归,而是保留真实外部或发布阻塞的 `tests/lineage-openapi-contract.ps1`、`tests/auth-tac-openapi-contract.ps1`、`tests/genealogy-workspace-openapi-contract.ps1`、`tests/g03-bootstrap-openapi-contract.ps1`、`tests/g03-bootstrap-client-release-gate.ps1`、`tests/help-center-openapi-contract.ps1`、`tests/profile-openapi-contract.ps1`、`tests/profile-update-openapi-contract.ps1`、`tests/notification-read-openapi-contract.ps1`、`tests/notification-read-state-openapi-contract.ps1`、`tests/logout-openapi-contract.ps1`、`tests/password-change-openapi-contract.ps1`、`tests/phone-change-openapi-contract.ps1` 和缺少 MuMu 人工证据时必须失败的 `tests/auth-android-accessibility-release-gate.ps1`。十四个输出分别为 `LINEAGE-OPENAPI-CONTRACT BLOCKED`、`AUTH-TAC-OPENAPI-CONTRACT BLOCKED`、`GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED`、`G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED`、`G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED`、`HELP-CENTER-OPENAPI-CONTRACT BLOCKED`、`PROFILE-OPENAPI-CONTRACT BLOCKED`、`PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED`、`NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED`、`NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED`、`LOGOUT-OPENAPI-CONTRACT BLOCKED`、`PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED`、`PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED` 和 `ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED`。Node 语法 `47/47` 通过,纯 Node 冒烟 `19/19` 通过,活动 Vue 脚本模块语法 `64/64` 通过;导航源码扫描保持 `MIGRATION-DEBT=0`。依赖 `127.0.0.1:9222` 调试页的浏览器运行时文件本轮只做语法检查,没有执行;这不能冒充浏览器行为或 MuMu 原生视觉通过。
|
||||
当前物理库存为 `147` 个 PowerShell 合同、`48` 个 Node 文件和 `4` 个 JSON 合同数据文件。最新 fresh 结果为 PowerShell `128/147` 通过;其余 19 项不是可删除的普通回归,而是保留真实外部或发布阻塞的 `tests/lineage-openapi-contract.ps1`、`tests/auth-tac-openapi-contract.ps1`、`tests/genealogy-workspace-openapi-contract.ps1`、`tests/g03-bootstrap-openapi-contract.ps1`、`tests/g03-bootstrap-client-release-gate.ps1`、`tests/help-center-openapi-contract.ps1`、`tests/profile-openapi-contract.ps1`、`tests/profile-update-openapi-contract.ps1`、`tests/notification-read-openapi-contract.ps1`、`tests/notification-read-state-openapi-contract.ps1`、`tests/logout-openapi-contract.ps1`、`tests/password-change-openapi-contract.ps1`、`tests/phone-change-openapi-contract.ps1`、`tests/join-application-openapi-contract.ps1`、`tests/invite-ticket-openapi-contract.ps1`、`tests/g11-settings-openapi-contract.ps1`、`tests/g12-generation-poem-openapi-contract.ps1`、`tests/family-feed-read-openapi-contract.ps1` 和缺少 MuMu 人工证据时必须失败的 `tests/auth-android-accessibility-release-gate.ps1`。十九个输出分别为 `LINEAGE-OPENAPI-CONTRACT BLOCKED`、`AUTH-TAC-OPENAPI-CONTRACT BLOCKED`、`GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED`、`G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED`、`G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED`、`HELP-CENTER-OPENAPI-CONTRACT BLOCKED`、`PROFILE-OPENAPI-CONTRACT BLOCKED`、`PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED`、`NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED`、`NOTIFICATION-READ-STATE-OPENAPI-CONTRACT BLOCKED`、`LOGOUT-OPENAPI-CONTRACT BLOCKED`、`PASSWORD-CHANGE-OPENAPI-CONTRACT BLOCKED`、`PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED`、`JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`、`INVITE-TICKET-OPENAPI-CONTRACT BLOCKED`、`G11-SETTINGS-OPENAPI-CONTRACT BLOCKED`、`G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`、`FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED` 和 `ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED`。Node 语法 `48/48` 通过,纯 Node 冒烟 `20/20` 通过,活动 Vue 脚本模块语法 `64/64` 通过;导航源码扫描保持 `MIGRATION-DEBT=0`。依赖 `127.0.0.1:9222` 调试页的浏览器运行时文件本轮只做语法检查,没有执行;这不能冒充浏览器行为或 MuMu 原生视觉通过。
|
||||
|
||||
阶段 0 清理结束时,`static/` 的正式基线为 `79` 个文件、共 `70,414,397` 字节。任务 3 退役两张孤立通知 frame 后,正式 `static/assets` 为 `77` 个文件、共 `69,093,742` 字节;`static/tac/` 当前 5 个文件、共 `68,190` 字节,因而当前 `static/` 物理库存为 `82` 个文件、共 `69,161,932` 字节。4 个供应商文件保持原字节并由哈希合同保护,新增适配器是唯一项目映射层;TAC 已进入 A01/A04/A05 业务运行时,但不属于可重建视觉资产注册表。`tmp/`、`unpackage/` 和 `design-pipeline/generated/` 均不存在;正式生成资产仍由清单和构建器恢复,质量报告不作为长期资料保留。
|
||||
|
||||
任务 36 新增门禁后,当前物理库存更新为 PowerShell `141`、Node `47`、JSON 合同数据 `4`;新增门禁已独立验证为 `JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED`。全量 fresh 复核为 `126/141`,新增加入申请红灯与原 14 项共同构成 15 项明确发布阻塞;上文 `140/47` 与 `126/140` 仅是新增门禁前的换机 fresh 基线,不能把两个时点混写为全部通过。
|
||||
|
||||
任务 37 新增邀请码门禁后,当前物理库存为 PowerShell `142`、Node `47`、JSON 合同数据 `4`;全量 fresh 为 `126/142`,新增 `INVITE-TICKET-OPENAPI-CONTRACT BLOCKED` 后共有 16 项明确发布阻塞。任务 36 的 `126/141` 是上一时点证据,不得冒充当前库存。
|
||||
|
||||
任务 38 新增设置门禁后,当前物理库存为 PowerShell `143`、Node `47`、JSON 合同数据 `4`;全量 fresh 为 `126/143`,新增 `G11-SETTINGS-OPENAPI-CONTRACT BLOCKED` 后共有 17 项明确发布阻塞,专项门禁稳定为 `Issues: 48`。
|
||||
|
||||
任务 39 新增 G12 主门禁、对抗合同和 Unicode 冒烟后,当前物理库存为 PowerShell `145`、Node `48`、JSON 合同数据 `4`;全量 fresh 为 `127/145`,对抗合同转绿而主门禁新增第 18 项发布阻塞。专项结果为 `G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED`、`Issues: 72`、`G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS` 和 `G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS`。
|
||||
|
||||
任务 40 新增家族动态读取主门禁与对抗合同后,当前物理库存为 PowerShell `147`、Node `48`、JSON 合同数据 `4`;全量 fresh 为 `128/147`,主门禁新增第 19 项发布阻塞,对抗合同本身通过。专项结果为 `FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED`、`Issues: 85` 和 `FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58`;它没有修改受保护 OpenAPI 或页面,也没有把线上宽松 DTO 当作可用合同。
|
||||
|
||||
## 当前规划结论
|
||||
|
||||
### 导航栈
|
||||
@@ -160,7 +215,7 @@ G03 创建链路三人反向质询后否决“先建空谱、再写始祖”的
|
||||
### TAC 认证与无障碍安全边界
|
||||
|
||||
- `utils/auth-verification.js` 是当前认证场景、4 位短信码和服务端票据形状的唯一客户端 owner;生产目标由后端 `SmsCodeSecret` 和 `tests/phone-change-openapi-contract.ps1` 锁定为严格 6 位,门禁通过时必须一次替换所有活动消费者并删除旧 owner 的四位规则。`components/TacVerification.vue` 是验证浮层与 renderjs 生命周期 owner;`static/tac/js/jiapu-tac-adapter.js` 是 TianAi challenge/proof/verify 映射 owner;`utils/api.js` 是严格 HTTP 200 envelope、15 秒超时、离页中止、认证会话写入和反馈 wire payload 的唯一 owner。旧认证专用请求控制器入口、占位验证、伪验证码、宽松令牌兼容和 M07 假提交均已删除。
|
||||
- A01 默认短信登录;密码登录因 `API-AUTH-TAC-001` 保持可见但不可用。A04/A05 都只在发送短信前验证一次,并在手机号改变后使旧验证码上下文失效;成功、失败、取消、重复回调、空响应、非 JSON、超时、离页和返回键均有静态或纯运行时合同。短信发送与重设密码的 `RVoid` 没有声明 `data` 必填,客户端因此只要求合法 HTTP 200 与整数成功 `code`,并把“省略 data”或 `data:null` 都精确归一为 `null`;登录、注册和 challenge 等有实体响应仍强制 `data`。
|
||||
- A01 默认密码登录,线上 wire、TAC 前置、token 保存和 G01 跳转已有可执行纯 Node 状态机证据;默认 mode 仍为 mock,且密码登录未消费 `validToken`,故 `API-AUTH-TAC-001` 仍是生产发布阻塞。A04/A05 都只在发送短信前验证一次,并在手机号改变后使旧验证码上下文失效;成功、失败、取消、重复回调、空响应、非 JSON、超时、离页和返回键均有静态或纯运行时合同。短信发送与重设密码的 `RVoid` 没有声明 `data` 必填,客户端因此只要求合法 HTTP 200 与整数成功 `code`,并把“省略 data”或 `data:null` 都精确归一为 `null`;登录、注册和 challenge 等有实体响应仍强制 `data`。
|
||||
- 当前客户端浮层已补齐对话框命名、说明关联、初始聚焦、Tab 圈定、Escape/Android 返回、焦点恢复、原生刷新与关闭按钮、48px 目标及小视口内部滚动;这只是客户端壳层预检,不代表第三方滑块本身可由 TalkBack 或键盘完成。`tests/auth-android-accessibility-release-gate.ps1` 要求 MuMu 原生证据与非拖动等价验证方式,证据缺失时固定输出 `ANDROID-AUTH-ACCESSIBILITY-RELEASE BLOCKED`。
|
||||
- 三人交叉质询后的唯一方向是“同一验证中心、同一短时单次 `validToken`、供应商无关的服务端决策”,永久禁止 `accessibility=true`、跳过 TAC 或无票据发短信。中国大陆非交互风控供应商只进入限时 POC,必须在真实 UniApp Android WebView 中证明 TalkBack、外接键盘和 Switch Access 无焦点陷阱且误杀、防刷、弱网与故障指标达标,不能预先写成无障碍完成。已安全绑定设备只能作为加权信号;供应商不确定或不可用时进入支持文字/中继的可审计人工兜底或稍后重试,绝不 fail-open;音频验证码仅为后续独立 POC 候选,不能作为 P0 或唯一替代。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "家谱",
|
||||
"appid": "__UNI__JIAPUAPP",
|
||||
"appid": "__UNI__1997FFD",
|
||||
"description": "家祠卷轴风格的家谱共建应用",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": 100,
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "家谱",
|
||||
"navigationBarBackgroundColor": "#F6F0E5",
|
||||
"backgroundColor": "#F6F0E5"
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
|
||||
<view class="login-tabs" role="tablist" aria-label="登录方式">
|
||||
<button
|
||||
class="auth-plain-button login-tab login-tab--unavailable"
|
||||
class="auth-plain-button login-tab"
|
||||
role="tab"
|
||||
:aria-selected="activeLoginMethod === 'password'"
|
||||
aria-disabled="true"
|
||||
:class="{ active: activeLoginMethod === 'password' }"
|
||||
:disabled="submitting || sendingCode || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="switchLoginMethod('password')"
|
||||
>密码登录</button
|
||||
@@ -26,6 +27,7 @@
|
||||
role="tab"
|
||||
:aria-selected="activeLoginMethod === 'sms'"
|
||||
:class="{ active: activeLoginMethod === 'sms' }"
|
||||
:disabled="submitting || sendingCode || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="switchLoginMethod('sms')"
|
||||
>验证码登录</button
|
||||
@@ -44,11 +46,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting || tacVisible || authenticationCommitted"
|
||||
placeholder="手机号"
|
||||
aria-label="手机号"
|
||||
placeholder-class="input-placeholder"
|
||||
confirm-type="next"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -63,7 +66,7 @@
|
||||
class="auth-input"
|
||||
:password="!passwordVisible"
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || tacVisible"
|
||||
placeholder="密码"
|
||||
aria-label="登录密码"
|
||||
placeholder-class="input-placeholder"
|
||||
@@ -74,6 +77,7 @@
|
||||
class="auth-plain-button password-toggle"
|
||||
:aria-label="passwordVisible ? '隐藏密码' : '显示密码'"
|
||||
:aria-pressed="passwordVisible"
|
||||
:disabled="submitting || tacVisible"
|
||||
hover-class="tap-fade"
|
||||
@click="togglePasswordVisibility"
|
||||
>
|
||||
@@ -140,7 +144,15 @@
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="login-submit__copy">{{ submitting ? "登录中…" : "登录" }}</text>
|
||||
<text class="login-submit__copy">{{
|
||||
submitting
|
||||
? authenticationCommitted
|
||||
? "正在进入…"
|
||||
: "登录中…"
|
||||
: authenticationCommitted
|
||||
? "进入家谱"
|
||||
: "登录"
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<view class="other-login-divider">
|
||||
@@ -249,7 +261,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import TacVerification from "@/components/TacVerification.vue";
|
||||
@@ -260,13 +272,15 @@ import {
|
||||
} from "@/utils/api.js";
|
||||
import {
|
||||
AUTH_TAC_SCENE,
|
||||
PASSWORD_TAC_BLOCKED_MESSAGE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
import {
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
@@ -274,7 +288,7 @@ import {
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const activeLoginMethod = ref("sms");
|
||||
const activeLoginMethod = ref("password");
|
||||
const passwordVisible = ref(false);
|
||||
const phone = ref("");
|
||||
const password = ref("");
|
||||
@@ -285,21 +299,32 @@ const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const authenticationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const feedbackVisible = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pendingTacAction = null;
|
||||
let pageActive = true;
|
||||
const authenticationNavigationFailure =
|
||||
"登录已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.SMS_LOGIN,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -311,6 +336,21 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const enterAuthenticatedRoot = async () => {
|
||||
if (!pageActive) return false;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const opened = await goRoot("G01");
|
||||
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
|
||||
return true;
|
||||
} catch {
|
||||
if (pageActive) showFeedback(authenticationNavigationFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const blockBusyAction = () => {
|
||||
if (!sendingCode.value && !submitting.value) return false;
|
||||
showFeedback("请求处理中,请稍候");
|
||||
@@ -319,17 +359,19 @@ const blockBusyAction = () => {
|
||||
|
||||
const switchLoginMethod = (method) => {
|
||||
if (blockBusyAction()) return;
|
||||
if (method === "password") {
|
||||
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (method === "sms") activeLoginMethod.value = method;
|
||||
if (method === "password" || method === "sms") activeLoginMethod.value = method;
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
passwordVisible.value = !passwordVisible.value;
|
||||
};
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const toggleAgreement = () => {
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
@@ -350,6 +392,7 @@ const requireAgreement = () => {
|
||||
const closeTac = () => {
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
pendingTacAction = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = () => {
|
||||
@@ -380,18 +423,6 @@ onBackPress((event) => {
|
||||
return handleBackPress(event, requestBack);
|
||||
});
|
||||
|
||||
const startCooldown = () => {
|
||||
cooldownSeconds.value = 60;
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
cooldownTimer = setInterval(() => {
|
||||
cooldownSeconds.value -= 1;
|
||||
if (cooldownSeconds.value <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
if (sendingCode.value || cooldownSeconds.value > 0) return;
|
||||
if (!validatePhone() || !requireAgreement()) return;
|
||||
@@ -416,6 +447,10 @@ const prepareGetCode = async () => {
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
pendingTacAction = {
|
||||
kind: "sms-code",
|
||||
phone: requestedPhone,
|
||||
};
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
@@ -426,12 +461,83 @@ const prepareGetCode = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const preparePasswordLogin = async () => {
|
||||
const requestedPhone = phone.value;
|
||||
const passwordHash = calcMD5(password.value);
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 产品规则:密码登录也必须先完成滑动验证。当前登录接口不消费
|
||||
// validToken,因此票据只作为本次前端验证成功的完成信号,不写入登录体。
|
||||
const sceneCode = AUTH_TAC_SCENE.SMS_LOGIN;
|
||||
const response = await appApi.getCaptchaRequirement(
|
||||
{ sceneCode, subject: requestedPhone },
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (phone.value !== requestedPhone) {
|
||||
throw new Error("手机号已变化,请重新登录");
|
||||
}
|
||||
const requirement = normalizeCaptchaRequirement(response, sceneCode);
|
||||
tacSequence += 1;
|
||||
tacContext.value = createTacRenderContext({
|
||||
requestId: `a01-password-${tacSequence}`,
|
||||
baseUrl: runtimeConfig.baseUrl,
|
||||
clientId: runtimeConfig.clientId,
|
||||
tenantId: runtimeConfig.tenantId,
|
||||
sceneCode,
|
||||
subject: requestedPhone,
|
||||
requirement,
|
||||
});
|
||||
pendingTacAction = {
|
||||
kind: "password-login",
|
||||
phone: requestedPhone,
|
||||
passwordHash,
|
||||
};
|
||||
tacVisible.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "登录失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeTac = async (result) => {
|
||||
const expectedContext = tacContext.value;
|
||||
if (!expectedContext) return;
|
||||
const action = pendingTacAction;
|
||||
if (!expectedContext || !action) return;
|
||||
try {
|
||||
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
|
||||
if (phone.value !== expectedContext.subject) throw new Error("手机号已变化,请重新验证");
|
||||
if (action.kind === "password-login") {
|
||||
if (
|
||||
phone.value !== action.phone ||
|
||||
calcMD5(password.value) !== action.passwordHash
|
||||
) {
|
||||
throw new Error("登录信息已变化,请重新验证");
|
||||
}
|
||||
tacVisible.value = false;
|
||||
tacContext.value = null;
|
||||
pendingTacAction = null;
|
||||
submitting.value = true;
|
||||
await appApi.loginWithPassword(
|
||||
{
|
||||
phone: action.phone,
|
||||
passwordHash: action.passwordHash,
|
||||
},
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
authenticationCommitted.value = true;
|
||||
await enterAuthenticatedRoot();
|
||||
return;
|
||||
}
|
||||
if (action.kind !== "sms-code") {
|
||||
const error = new Error("安全验证状态无效,请重新操作");
|
||||
error.code = "AUTH_TAC_ACTION_INVALID";
|
||||
throw error;
|
||||
}
|
||||
closeTac();
|
||||
sendingCode.value = true;
|
||||
await appApi.sendSmsCode(
|
||||
@@ -443,15 +549,33 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (
|
||||
action?.kind === "sms-code" &&
|
||||
isSmsDeliveryOutcomeUnknown(error)
|
||||
) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(
|
||||
error.message ||
|
||||
(action?.kind === "password-login"
|
||||
? "登录失败,请稍后重试"
|
||||
: "验证码发送失败"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
if (pageActive) {
|
||||
sendingCode.value = false;
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -465,11 +589,19 @@ const handleTacError = ({ message } = {}) => {
|
||||
|
||||
const submitLogin = async () => {
|
||||
if (submitting.value) return;
|
||||
if (authenticationCommitted.value) return enterAuthenticatedRoot();
|
||||
if (!validatePhone()) return;
|
||||
if (activeLoginMethod.value === "password" && !password.value) {
|
||||
showFeedback("请输入密码");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeLoginMethod.value === "sms" &&
|
||||
sentPhone.value !== phone.value
|
||||
) {
|
||||
showFeedback("请先获取当前手机号的验证码");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeLoginMethod.value === "sms" &&
|
||||
!/^\d{4}$/.test(verificationCode.value)
|
||||
@@ -479,10 +611,7 @@ const submitLogin = async () => {
|
||||
}
|
||||
if (!requireAgreement()) return;
|
||||
if (activeLoginMethod.value === "password") {
|
||||
// 受保护源合同明确禁止密码登录携带 TAC 票据。仅在客户端先滑动仍可被
|
||||
// 绕过,因此此入口必须失败关闭;短信登录已经具备 TAC→短信票据闭环。
|
||||
showFeedback(PASSWORD_TAC_BLOCKED_MESSAGE);
|
||||
return;
|
||||
return preparePasswordLogin();
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -494,7 +623,8 @@ const submitLogin = async () => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
await goRoot("G01");
|
||||
authenticationCommitted.value = true;
|
||||
await enterAuthenticatedRoot();
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "登录失败,请稍后重试");
|
||||
@@ -647,17 +777,16 @@ const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
|
||||
.get-code {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 142rpx;
|
||||
min-height: var(--app-touch-min);
|
||||
padding: 0 8rpx;
|
||||
background: transparent !important;
|
||||
color: #a9160d;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.login-tab--unavailable {
|
||||
color: #9f968d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.get-code--disabled {
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting || registrationCommitted"
|
||||
placeholder="请输入手机号"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="fieldErrors.phone ? 'a04-phone-error' : undefined"
|
||||
@input="clearFieldError('phone')"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.phone" id="a04-phone-error" class="field-error" role="alert">{{
|
||||
@@ -63,7 +63,7 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="4"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请输入验证码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||
@@ -73,7 +73,7 @@
|
||||
<button
|
||||
class="auth-plain-button get-code"
|
||||
:class="{ 'get-code--disabled': sendingCode || cooldownSeconds > 0 }"
|
||||
:disabled="sendingCode || submitting || cooldownSeconds > 0"
|
||||
:disabled="sendingCode || submitting || cooldownSeconds > 0 || registrationCommitted"
|
||||
hover-class="tap-fade"
|
||||
@click="prepareGetCode"
|
||||
>{{ sendingCode ? "请求中…" : cooldownSeconds > 0 ? `${cooldownSeconds}s 后重发` : "获取验证码" }}</button
|
||||
@@ -96,7 +96,7 @@
|
||||
class="auth-input"
|
||||
password
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请设置登录密码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.password)"
|
||||
@@ -121,7 +121,7 @@
|
||||
class="auth-input"
|
||||
password
|
||||
maxlength="32"
|
||||
:disabled="submitting"
|
||||
:disabled="submitting || registrationCommitted"
|
||||
placeholder="请再次输入密码"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||
@@ -147,7 +147,15 @@
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="register-submit__content">{{ submitting ? "注册中…" : "注册账号" }}</text>
|
||||
<text class="register-submit__content">{{
|
||||
submitting
|
||||
? registrationCommitted
|
||||
? "正在进入…"
|
||||
: "注册中…"
|
||||
: registrationCommitted
|
||||
? "进入家谱"
|
||||
: "注册账号"
|
||||
}}</text>
|
||||
</button>
|
||||
|
||||
<view
|
||||
@@ -160,7 +168,7 @@
|
||||
role="checkbox"
|
||||
:aria-checked="agreed"
|
||||
:aria-label="agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'"
|
||||
:disabled="sendingCode || submitting || tacVisible"
|
||||
:disabled="sendingCode || submitting || tacVisible || registrationCommitted"
|
||||
@click="toggleAgreement"
|
||||
>
|
||||
<image
|
||||
@@ -230,7 +238,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
@@ -244,9 +252,11 @@ import {
|
||||
AUTH_TAC_SCENE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
@@ -275,8 +285,11 @@ const tacVisible = ref(false);
|
||||
const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const registrationCommitted = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const isDirty = computed(() =>
|
||||
!registrationCommitted.value &&
|
||||
Boolean(
|
||||
phone.value ||
|
||||
verificationCode.value ||
|
||||
@@ -286,10 +299,17 @@ const isDirty = computed(() =>
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const registrationNavigationFailure =
|
||||
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.REGISTER,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -309,17 +329,19 @@ const cancelPendingRequest = () => {
|
||||
};
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: tacVisible.value || discardVisible.value,
|
||||
submitting: submitting.value || sendingCode.value,
|
||||
dirty: isDirty.value,
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
cancelPendingRequest();
|
||||
return requestBack();
|
||||
},
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
registrationCommitted.value
|
||||
? enterAuthenticatedRoot()
|
||||
: runBackGuard({
|
||||
transientOpen: tacVisible.value || discardVisible.value,
|
||||
submitting: submitting.value || sendingCode.value,
|
||||
dirty: isDirty.value,
|
||||
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||
"block-submitting": () => {
|
||||
cancelPendingRequest();
|
||||
return requestBack();
|
||||
},
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
@@ -327,9 +349,10 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -341,10 +364,26 @@ const showFeedback = (message) => {
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const enterAuthenticatedRoot = async () => {
|
||||
if (!pageActive) return false;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const opened = await goRoot("G01");
|
||||
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
|
||||
return true;
|
||||
} catch {
|
||||
if (pageActive) showFeedback(registrationNavigationFailure);
|
||||
return false;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const prepareAgreement = () => showFeedback("协议页面准备中");
|
||||
|
||||
const toggleAgreement = () => {
|
||||
if (sendingCode.value || submitting.value) return;
|
||||
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||
return;
|
||||
agreed.value = !agreed.value;
|
||||
if (agreed.value) agreementError.value = false;
|
||||
};
|
||||
@@ -353,16 +392,11 @@ const clearFieldError = (field) => {
|
||||
fieldErrors.value[field] = "";
|
||||
};
|
||||
|
||||
const startCooldown = () => {
|
||||
cooldownSeconds.value = 60;
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
cooldownTimer = setInterval(() => {
|
||||
cooldownSeconds.value -= 1;
|
||||
if (cooldownSeconds.value <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
const handlePhoneInput = () => {
|
||||
clearFieldError("phone");
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
@@ -424,12 +458,19 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
@@ -452,7 +493,9 @@ const validateForm = () => {
|
||||
confirmPassword: "",
|
||||
};
|
||||
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||
if (!/^\d{4}$/.test(verificationCode.value))
|
||||
if (sentPhone.value !== phone.value)
|
||||
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||
const passwordResult = validatePassword(password.value);
|
||||
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||
@@ -465,6 +508,7 @@ const validateForm = () => {
|
||||
|
||||
const submitRegister = async () => {
|
||||
if (submitting.value || sendingCode.value) return;
|
||||
if (registrationCommitted.value) return enterAuthenticatedRoot();
|
||||
const formValid = validateForm();
|
||||
if (!agreed.value) agreementError.value = true;
|
||||
if (!formValid || !agreed.value) return;
|
||||
@@ -479,14 +523,17 @@ const submitRegister = async () => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
await goRoot("G01");
|
||||
registrationCommitted.value = true;
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "注册失败,请稍后重试");
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (pageActive) submitting.value = false;
|
||||
}
|
||||
if (!pageActive) return;
|
||||
await enterAuthenticatedRoot();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -588,11 +635,17 @@ const submitRegister = async () => {
|
||||
}
|
||||
|
||||
.get-code {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 14rpx;
|
||||
display: flex;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8rpx;
|
||||
border-left: 1rpx solid #d7bd94;
|
||||
background: transparent !important;
|
||||
color: #a7160c;
|
||||
font-size: 26rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.get-code--disabled {
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
class="auth-input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
:disabled="sendingCode || cooldownSeconds > 0 || submitting"
|
||||
:disabled="sendingCode || submitting"
|
||||
placeholder="请输入手机号"
|
||||
placeholder-class="placeholder"
|
||||
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||
:aria-describedby="fieldErrors.phone ? 'a05-phone-error' : undefined"
|
||||
@input="clearFieldError('phone')"
|
||||
@input="handlePhoneInput"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.phone" id="a05-phone-error" class="field-error" role="alert">{{
|
||||
@@ -208,7 +208,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import AuthPageShell from "@/components/AuthPageShell.vue";
|
||||
@@ -222,9 +222,11 @@ import {
|
||||
AUTH_TAC_SCENE,
|
||||
createTacRenderContext,
|
||||
isAuthPhone,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
normalizeCaptchaRequirement,
|
||||
normalizeTacSuccess,
|
||||
} from "@/utils/auth-verification.js";
|
||||
import { createAuthSmsCooldown } from "@/utils/auth-sms-cooldown.js";
|
||||
import { runtimeConfig } from "@/utils/config.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { calcMD5 } from "@/utils/md5.js";
|
||||
@@ -251,6 +253,7 @@ const tacContext = ref(null);
|
||||
const sendingCode = ref(false);
|
||||
const submitting = ref(false);
|
||||
const cooldownSeconds = ref(0);
|
||||
const sentPhone = ref("");
|
||||
const fieldErrors = ref({
|
||||
phone: "",
|
||||
verificationCode: "",
|
||||
@@ -267,10 +270,15 @@ const isDirty = computed(() =>
|
||||
),
|
||||
);
|
||||
let feedbackTimer = null;
|
||||
let cooldownTimer = null;
|
||||
let tacSequence = 0;
|
||||
let pageActive = true;
|
||||
const authRequestController = createRequestController();
|
||||
const smsCooldown = createAuthSmsCooldown({
|
||||
sceneCode: AUTH_TAC_SCENE.FORGOT_PASSWORD,
|
||||
onChange: (seconds) => {
|
||||
cooldownSeconds.value = seconds;
|
||||
},
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -313,9 +321,10 @@ onUnload(() => {
|
||||
pageActive = false;
|
||||
authRequestController.abort();
|
||||
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
smsCooldown.dispose();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onShow(() => smsCooldown.sync());
|
||||
|
||||
const showFeedback = (message) => {
|
||||
feedbackMessage.value = message;
|
||||
@@ -331,16 +340,11 @@ const clearFieldError = (field) => {
|
||||
fieldErrors.value[field] = "";
|
||||
};
|
||||
|
||||
const startCooldown = () => {
|
||||
cooldownSeconds.value = 60;
|
||||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||||
cooldownTimer = setInterval(() => {
|
||||
cooldownSeconds.value -= 1;
|
||||
if (cooldownSeconds.value <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
const handlePhoneInput = () => {
|
||||
clearFieldError("phone");
|
||||
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||
verificationCode.value = "";
|
||||
sentPhone.value = "";
|
||||
};
|
||||
|
||||
const prepareGetCode = async () => {
|
||||
@@ -398,12 +402,19 @@ const completeTac = async (result) => {
|
||||
{ requestController: authRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
startCooldown();
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("验证码已发送");
|
||||
} catch (error) {
|
||||
closeTac();
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
if (pageActive) {
|
||||
if (isSmsDeliveryOutcomeUnknown(error)) {
|
||||
sentPhone.value = expectedContext.subject;
|
||||
smsCooldown.start();
|
||||
showFeedback("发送结果未知,如收到短信可直接填写;60 秒后可重试");
|
||||
} else if (!isRequestCancelled(error)) {
|
||||
showFeedback(error.message || "验证码发送失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) sendingCode.value = false;
|
||||
@@ -426,7 +437,9 @@ const validateForm = () => {
|
||||
confirmPassword: "",
|
||||
};
|
||||
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||
if (!/^\d{4}$/.test(verificationCode.value))
|
||||
if (sentPhone.value !== phone.value)
|
||||
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||
const passwordResult = validatePassword(password.value);
|
||||
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||
@@ -557,11 +570,17 @@ const submitReset = async () => {
|
||||
color: #9f968d;
|
||||
}
|
||||
.get-code {
|
||||
flex: 0 0 auto;
|
||||
padding-left: 14rpx;
|
||||
display: flex;
|
||||
flex: 0 0 176rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8rpx;
|
||||
border-left: 1rpx solid #d7bd94;
|
||||
background: transparent !important;
|
||||
color: #a7160c;
|
||||
font-size: 26rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.get-code--disabled {
|
||||
color: #9f968d;
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
>
|
||||
<view class="genealogy-lower">
|
||||
<view v-if="createdGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我创建的</text></view>
|
||||
<view class="section-heading"><text>我管理的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in createdGenealogies"
|
||||
:key="item.id"
|
||||
@@ -144,7 +144,10 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="list-section application-section">
|
||||
<view
|
||||
v-if="applicationRecords.length"
|
||||
class="list-section application-section"
|
||||
>
|
||||
<view class="section-heading"><text>加入申请</text></view>
|
||||
<view
|
||||
v-for="item in applicationRecords"
|
||||
@@ -350,7 +353,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
@@ -358,17 +361,16 @@ import GenealogyCard from "@/components/GenealogyCard.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
findGenealogyFixture,
|
||||
genealogies,
|
||||
getGenealogyFixtureAccess,
|
||||
listNotificationFixtures,
|
||||
} from "@/data/mock.js";
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
const list = ref(genealogies);
|
||||
const list = ref([]);
|
||||
const forceEmptyState = ref(false);
|
||||
const contextInvalidated = ref(false);
|
||||
const contextReconcileFailed = ref(false);
|
||||
@@ -378,9 +380,14 @@ const switcherVisible = ref(false);
|
||||
const selectedGenealogyId = ref(null);
|
||||
const listScrollCommand = ref(0);
|
||||
const currentListScrollTop = ref(0);
|
||||
const listRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let presentationState = "default";
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
const syncEmptyStateFromRoute = (query = {}) => {
|
||||
const presentationState = ["empty", "loading", "error"].includes(
|
||||
presentationState = ["empty", "loading", "error"].includes(
|
||||
query?.state,
|
||||
)
|
||||
? query.state
|
||||
@@ -419,15 +426,59 @@ const reconcilePageGenealogyContext = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadGenealogies = async () => {
|
||||
const generation = ++loadGeneration;
|
||||
listRequestController.abort();
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
try {
|
||||
const result = await appApi.getMyGenealogies({
|
||||
requestController: listRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
list.value = result;
|
||||
reconcilePageGenealogyContext();
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (pageActive && generation === loadGeneration) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
syncEmptyStateFromRoute(query);
|
||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||
reconcilePageGenealogyContext();
|
||||
if (presentationState === "default") {
|
||||
loadGenealogies();
|
||||
} else {
|
||||
reconcilePageGenealogyContext();
|
||||
}
|
||||
});
|
||||
|
||||
const unreadCount = computed(
|
||||
() => listNotificationFixtures().filter((item) => item.unread).length,
|
||||
);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (presentationState === "default") loadGenealogies();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
listRequestController.abort();
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => 0);
|
||||
const hasGenealogies = computed(
|
||||
() => !forceEmptyState.value && list.value.length > 0,
|
||||
);
|
||||
@@ -439,14 +490,10 @@ const isListLayout = computed(
|
||||
hasGenealogies.value,
|
||||
);
|
||||
const createdGenealogies = computed(() =>
|
||||
list.value.filter(
|
||||
(item) => getGenealogyFixtureAccess(item.id).accessRole === "owner",
|
||||
),
|
||||
list.value.filter((item) => item.accessRole === "owner"),
|
||||
);
|
||||
const joinedGenealogies = computed(() =>
|
||||
list.value.filter(
|
||||
(item) => getGenealogyFixtureAccess(item.id).accessRole === "member",
|
||||
),
|
||||
list.value.filter((item) => item.accessRole === "member"),
|
||||
);
|
||||
const availableGenealogies = computed(() => list.value);
|
||||
const currentGenealogy = computed(
|
||||
@@ -456,41 +503,14 @@ const currentGenealogy = computed(
|
||||
) || null,
|
||||
);
|
||||
const isCurrentGenealogyOwner = computed(
|
||||
() =>
|
||||
getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole ===
|
||||
"owner",
|
||||
() => currentGenealogy.value?.accessRole === "owner",
|
||||
);
|
||||
const currentRoleLabel = computed(() =>
|
||||
isCurrentGenealogyOwner.value ? "管理员" : "成员",
|
||||
);
|
||||
|
||||
// 仅用于页面样式阶段覆盖加入申请的关键状态,不作为接口数据。
|
||||
const applicationRecords = [
|
||||
{
|
||||
id: "pending",
|
||||
genealogyId: "2003",
|
||||
statusLabel: "审核中",
|
||||
tone: "pending",
|
||||
description: "申请已提交,等待管理员审核",
|
||||
},
|
||||
{
|
||||
id: "rejected",
|
||||
genealogyId: "2004",
|
||||
statusLabel: "被拒绝",
|
||||
tone: "rejected",
|
||||
description: "可修改关系说明后重新申请",
|
||||
},
|
||||
{
|
||||
id: "removed",
|
||||
genealogyId: "2005",
|
||||
statusLabel: "已退出",
|
||||
tone: "muted",
|
||||
description: "如需恢复成员身份,可重新申请加入",
|
||||
},
|
||||
].map((record) => ({
|
||||
...record,
|
||||
name: findGenealogyFixture(record.genealogyId)?.name || "未知家谱",
|
||||
}));
|
||||
// 普通加入申请批次接通前不展示本地伪记录。
|
||||
const applicationRecords = ref([]);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
@@ -603,13 +623,7 @@ const openApplication = (record) => {
|
||||
);
|
||||
};
|
||||
const retryLoad = () => {
|
||||
if (contextReconcileFailed.value) {
|
||||
hasError.value = false;
|
||||
reconcilePageGenealogyContext();
|
||||
return;
|
||||
}
|
||||
hasError.value = false;
|
||||
isLoading.value = false;
|
||||
loadGenealogies();
|
||||
};
|
||||
|
||||
const openShortcut = (key) => {
|
||||
|
||||
@@ -31,16 +31,19 @@
|
||||
>
|
||||
<text class="overview-hero__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-hero__motto">{{
|
||||
genealogy.motto || "敦亲睦族,敬祖传家。"
|
||||
genealogy.intro || "简介待补充"
|
||||
}}</text>
|
||||
<view class="overview-hero__stats">
|
||||
<text>共 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text>已激活 {{ genealogy.activeCount || 0 }} 人</text>
|
||||
<text v-if="genealogy.personCount !== null"
|
||||
>世系 {{ genealogy.personCount }} 人</text
|
||||
>
|
||||
<text>{{ getGenealogyAccessPresetLabel(genealogy.accessPreset) }}</text>
|
||||
</view>
|
||||
<view class="overview-hero__stats">
|
||||
<text>始祖 {{ genealogy.ancestorName }}</text>
|
||||
<text>更新于 {{ genealogy.updatedAt }}</text>
|
||||
<text v-if="genealogy.joinTime"
|
||||
>加入于 {{ formatGenealogyTime(genealogy.joinTime) }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -194,11 +197,15 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findGenealogyFixture, getGenealogyFixtureAccess } from "@/data/mock.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy-contracts.js";
|
||||
import {
|
||||
goBack,
|
||||
@@ -216,6 +223,10 @@ const viewMode = ref("member");
|
||||
const accessRole = ref("guest");
|
||||
const publicRelation = ref("unknown");
|
||||
const publicCanApply = ref(false);
|
||||
const overviewRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const publicActionLabel = computed(() => {
|
||||
if (publicRelation.value === "pending") return "查看申请进度";
|
||||
if (!publicCanApply.value) return "";
|
||||
@@ -249,7 +260,12 @@ const stateCopy = computed(
|
||||
"",
|
||||
);
|
||||
|
||||
const loadGenealogy = (query = {}) => {
|
||||
const formatGenealogyTime = (value) =>
|
||||
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
|
||||
|
||||
const loadGenealogy = async (query = {}) => {
|
||||
const generation = ++loadGeneration;
|
||||
overviewRequestController.abort();
|
||||
overviewState.value = "loading";
|
||||
loadError.value = "";
|
||||
genealogy.value = null;
|
||||
@@ -273,28 +289,47 @@ const loadGenealogy = (query = {}) => {
|
||||
}
|
||||
if (query.state === "loading") return;
|
||||
|
||||
const fixture = findGenealogyFixture(genealogyId.value);
|
||||
if (!fixture) {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
try {
|
||||
const result = await appApi.getOverview(genealogyId.value, {
|
||||
requestController: overviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
genealogy.value = result;
|
||||
viewMode.value = "member";
|
||||
accessRole.value = result.accessRole;
|
||||
overviewState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ([401, 403, 404].includes(error?.httpStatus)) {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
loadError.value = error?.message || "家谱概览读取失败,请稍后重试。";
|
||||
overviewState.value = "error";
|
||||
}
|
||||
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!access.canView) {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
genealogy.value = { ...fixture };
|
||||
viewMode.value = access.viewMode;
|
||||
accessRole.value = access.accessRole;
|
||||
publicRelation.value = access.relation;
|
||||
publicCanApply.value = access.canApply;
|
||||
overviewState.value = "ready";
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadGenealogy);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadGenealogy({ genealogyId: genealogyId.value });
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
overviewRequestController.abort();
|
||||
});
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () => returnTo("G01", {});
|
||||
const toTree = () =>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
<view class="tree-toolbar__title">
|
||||
<text>汤氏家谱</text>
|
||||
<text>主支 · 第 12—14 世</text>
|
||||
<text>世系成员</text>
|
||||
<text>{{ generationRangeLabel }}</text>
|
||||
</view>
|
||||
<view class="tree-toolbar__actions">
|
||||
<text
|
||||
@@ -177,12 +177,16 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { getGenealogyFixtureAccess, listTreeMemberFixtures } from "@/data/mock.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
|
||||
@@ -195,15 +199,14 @@ const centeredTreeScrollLeft = ref(90);
|
||||
const treeHasDrifted = ref(false);
|
||||
let ignoreTreeScroll = false;
|
||||
let recenterTimer = null;
|
||||
const treeRequestController = createRequestController();
|
||||
let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const GRID_UNIT = 5;
|
||||
const NODE_HALF_HEIGHT = 47;
|
||||
const MEMBER_GAP = 250;
|
||||
const GENERATION_GAP = 220;
|
||||
const generationMeta = {
|
||||
12: { label: "第十二世", summary: "始祖" },
|
||||
13: { label: "第十三世", summary: "两房" },
|
||||
14: { label: "第十四世", summary: "三支" },
|
||||
};
|
||||
const members = ref([]);
|
||||
|
||||
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
|
||||
@@ -218,8 +221,8 @@ const layoutMembers = computed(() => {
|
||||
.filter((member) => Number(member.generation) === generation)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.parentId || 0) - Number(right.parentId || 0) ||
|
||||
Number(left.id) - Number(right.id),
|
||||
String(left.parentId || "").localeCompare(String(right.parentId || "")) ||
|
||||
String(left.id).localeCompare(String(right.id)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
@@ -275,12 +278,19 @@ const generationRows = computed(() => {
|
||||
const y = Math.min(...group.map((member) => member.y));
|
||||
return {
|
||||
generation,
|
||||
label: generationMeta[generation]?.label || `第 ${generation} 世`,
|
||||
summary: generationMeta[generation]?.summary || `${group.length} 位成员`,
|
||||
label: `第 ${generation} 世`,
|
||||
summary: `${group.length} 位成员`,
|
||||
y,
|
||||
};
|
||||
});
|
||||
});
|
||||
const generationRangeLabel = computed(() => {
|
||||
const rows = generationRows.value;
|
||||
if (!rows.length) return "世系关系";
|
||||
const first = rows[0].generation;
|
||||
const last = rows[rows.length - 1].generation;
|
||||
return first === last ? `第 ${first} 世` : `第 ${first}—${last} 世`;
|
||||
});
|
||||
const generationBandStyle = (row) => ({
|
||||
gridRow: `${Math.max(1, Math.round((row.y - 88) / GRID_UNIT) + 1)} / span 12`,
|
||||
});
|
||||
@@ -351,7 +361,9 @@ const stateCopy = computed(
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
const loadTree = async (query = {}) => {
|
||||
const generation = ++treeLoadGeneration;
|
||||
treeRequestController.abort();
|
||||
if (genealogyContext.isCurrentGenealogyInvalidated()) {
|
||||
genealogyId.value = "";
|
||||
members.value = [];
|
||||
@@ -366,15 +378,6 @@ onLoad((query) => {
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
genealogyContext.invalidateCurrentGenealogyId();
|
||||
genealogyId.value = "";
|
||||
members.value = [];
|
||||
treeState.value = "error";
|
||||
return;
|
||||
}
|
||||
members.value = listTreeMemberFixtures(genealogyId.value);
|
||||
if (
|
||||
query.state === "loading" ||
|
||||
["landscape", "empty", "error"].includes(query.state)
|
||||
@@ -382,15 +385,45 @@ onLoad((query) => {
|
||||
treeState.value = query.state;
|
||||
return;
|
||||
}
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selected.value =
|
||||
layoutMembers.value.find(
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0];
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
try {
|
||||
const result = await appApi.getTree(genealogyId.value, {
|
||||
requestController: treeRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== treeLoadGeneration) return;
|
||||
members.value = result;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selected.value =
|
||||
layoutMembers.value.find(
|
||||
(item) => String(item.id) === String(query.selectedId),
|
||||
) || layoutMembers.value[0] || null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== treeLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
selected.value = null;
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadTree);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadTree({ genealogyId: genealogyId.value });
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
treeLoadGeneration += 1;
|
||||
treeRequestController.abort();
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
});
|
||||
|
||||
@@ -443,6 +476,9 @@ const handleStateAction = () => {
|
||||
"T01",
|
||||
);
|
||||
}
|
||||
if (treeState.value === "error") {
|
||||
return loadTree({ genealogyId: genealogyId.value });
|
||||
}
|
||||
selected.value = layoutMembers.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
|
||||
@@ -99,3 +99,10 @@ view:not(.page-header-slot):has(> .page-header-slot) {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
// App 端原生 button 在 disabled 时会自行注入浅色底,认证页的文字按钮不能
|
||||
// 继承这一默认控件外观。禁用态由各页面的文字颜色表达,不再出现白色方块。
|
||||
.auth-page.auth-page .auth-plain-button[disabled] {
|
||||
background: transparent !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -220,25 +220,26 @@ $smsCodeCopy = ConvertFrom-Utf8Base64 '55+t5L+h6aqM6K+B56CB'
|
||||
Assert-Contains -Content $entry -Expected $smsCodeCopy -Message 'A01 SMS state must use the full SMS code field label.'
|
||||
|
||||
foreach ($contract in @(
|
||||
'const activeLoginMethod = ref("sms")',
|
||||
'const activeLoginMethod = ref("password")',
|
||||
'const passwordVisible = ref(false)',
|
||||
'login-tab--unavailable',
|
||||
'aria-disabled="true"',
|
||||
'const switchLoginMethod = (method) =>',
|
||||
'const togglePasswordVisibility = () =>',
|
||||
'<AppToast :visible="feedbackVisible" :message="feedbackMessage" />',
|
||||
'<TacVerification',
|
||||
'AUTH_TAC_SCENE.SMS_LOGIN',
|
||||
'normalizeTacSuccess',
|
||||
'appApi.loginWithPassword',
|
||||
'appApi.loginWithSms',
|
||||
'PASSWORD_TAC_BLOCKED_MESSAGE',
|
||||
'calcMD5(password.value)',
|
||||
'const preparePasswordLogin = async () =>',
|
||||
'login-submit',
|
||||
'class="agreement-error"',
|
||||
'const agreementError = ref(false)'
|
||||
)) {
|
||||
Assert-Contains -Content $entry -Expected $contract -Message "A01 is missing state or interaction contract: $contract"
|
||||
}
|
||||
Assert-NotContains -Content $entry -Unexpected 'a01:last-login-method' -Message '密码登录后端未闭环前不得恢复为首屏或记忆入口。'
|
||||
Assert-NotContains -Content $entry -Unexpected 'login-tab--unavailable' -Message 'A01 密码登录页签不得继续显示为不可用。'
|
||||
Assert-NotContains -Content $entry -Unexpected 'PASSWORD_TAC_BLOCKED_MESSAGE' -Message 'A01 密码登录不得继续被旧硬关闭文案阻断。'
|
||||
|
||||
foreach ($nativeUi in @('uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
Assert-NotContains -Content $entry -Unexpected $nativeUi -Message "A01 must not use native UniApp feedback: $nativeUi"
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "../pages/auth/a01-entry.vue"),
|
||||
"utf8",
|
||||
);
|
||||
const match = source.match(/<script setup>([\s\S]*?)<\/script>/);
|
||||
assert(match, "A01 script setup is missing");
|
||||
|
||||
const pageScript = match[1].replace(
|
||||
/import[\s\S]*?from\s+["'][^"']+["'];\s*/g,
|
||||
"",
|
||||
);
|
||||
|
||||
const createHarnessFactory = (goRootResult = true, goRootError = null) => new Function(
|
||||
"goRootResult",
|
||||
"goRootError",
|
||||
`
|
||||
"use strict";
|
||||
const calls = [];
|
||||
const ref = (value) => ({ value });
|
||||
const onBackPress = () => {};
|
||||
const onShow = (callback) => callback();
|
||||
const onUnload = () => {};
|
||||
const AuthPageShell = {};
|
||||
const AppToast = {};
|
||||
const TacVerification = {};
|
||||
const createRequestController = () => ({
|
||||
abort() {},
|
||||
bind() {},
|
||||
release() {},
|
||||
});
|
||||
const isRequestCancelled = () => false;
|
||||
const isSmsDeliveryOutcomeUnknown = () => false;
|
||||
const createAuthSmsCooldown = ({ onChange }) => ({
|
||||
start() {
|
||||
onChange(60);
|
||||
},
|
||||
sync() {
|
||||
onChange(0);
|
||||
return 0;
|
||||
},
|
||||
dispose() {},
|
||||
});
|
||||
const appApi = {
|
||||
async getCaptchaRequirement(payload) {
|
||||
calls.push({ type: "require", payload: { ...payload } });
|
||||
return {
|
||||
required: true,
|
||||
providerCode: "TIANAI",
|
||||
captchaType: "SLIDER",
|
||||
sceneCode: payload.sceneCode,
|
||||
ttlSeconds: 300,
|
||||
};
|
||||
},
|
||||
async loginWithPassword(payload) {
|
||||
calls.push({ type: "password-login", payload: { ...payload } });
|
||||
return { access_token: "token-password" };
|
||||
},
|
||||
async sendSmsCode(payload) {
|
||||
calls.push({ type: "sms-code", payload: { ...payload } });
|
||||
return null;
|
||||
},
|
||||
async loginWithSms(payload) {
|
||||
calls.push({ type: "sms-login", payload: { ...payload } });
|
||||
return { access_token: "token-sms" };
|
||||
},
|
||||
};
|
||||
const AUTH_TAC_SCENE = Object.freeze({ SMS_LOGIN: "APP_SMS_LOGIN" });
|
||||
const createTacRenderContext = (value) => ({ ...value });
|
||||
const isAuthPhone = (value) => /^1[3-9]\\d{9}$/.test(value);
|
||||
const normalizeCaptchaRequirement = (value, expectedScene) => {
|
||||
if (value?.sceneCode !== expectedScene || value?.required !== true) {
|
||||
throw new Error("requirement invalid");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const normalizeTacSuccess = (value, expectedRequestId) => {
|
||||
if (
|
||||
!value ||
|
||||
value.requestId !== expectedRequestId ||
|
||||
typeof value.validToken !== "string" ||
|
||||
!value.validToken
|
||||
) {
|
||||
throw new Error("TAC result invalid");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const runtimeConfig = {
|
||||
baseUrl: "https://backend-api.ddxcjp.cn",
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
};
|
||||
const calcMD5 = (value) => "md5:" + value;
|
||||
const goRoot = async (pageId) => {
|
||||
calls.push({ type: "go-root", pageId });
|
||||
if (goRootError) throw goRootError;
|
||||
return goRootResult;
|
||||
};
|
||||
const handleBackPress = () => false;
|
||||
const openPage = () => {};
|
||||
const runBackGuard = () => false;
|
||||
const setTimeout = () => 1;
|
||||
const clearTimeout = () => {};
|
||||
const setInterval = () => 1;
|
||||
const clearInterval = () => {};
|
||||
|
||||
${pageScript}
|
||||
|
||||
return {
|
||||
calls,
|
||||
phone,
|
||||
password,
|
||||
agreed,
|
||||
tacVisible,
|
||||
tacContext,
|
||||
submitting,
|
||||
authenticationCommitted,
|
||||
feedbackMessage,
|
||||
submitLogin,
|
||||
completeTac,
|
||||
closeTac,
|
||||
setPendingTacAction(value) {
|
||||
pendingTacAction = value;
|
||||
},
|
||||
};
|
||||
`,
|
||||
)(goRootResult, goRootError);
|
||||
|
||||
const createHarness = (options = {}) =>
|
||||
createHarnessFactory(
|
||||
options.goRootResult ?? true,
|
||||
options.goRootError ?? null,
|
||||
);
|
||||
|
||||
const countCalls = (harness, type) =>
|
||||
harness.calls.filter((call) => call.type === type).length;
|
||||
|
||||
const preparePassword = async (harness, passwordValue = "secret-1") => {
|
||||
harness.phone.value = "13800138000";
|
||||
harness.password.value = passwordValue;
|
||||
harness.agreed.value = true;
|
||||
await harness.submitLogin();
|
||||
assert.deepStrictEqual(harness.calls.map((call) => call.type), ["require"]);
|
||||
assert.strictEqual(harness.submitting.value, false);
|
||||
assert.strictEqual(harness.tacVisible.value, true);
|
||||
assert(harness.tacContext.value?.requestId?.startsWith("a01-password-"));
|
||||
};
|
||||
|
||||
const completePasswordTac = (harness) =>
|
||||
harness.completeTac({
|
||||
requestId: harness.tacContext.value.requestId,
|
||||
validToken: "ticket-1",
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
const success = createHarness();
|
||||
await preparePassword(success);
|
||||
assert.strictEqual(countCalls(success, "password-login"), 0);
|
||||
await completePasswordTac(success);
|
||||
assert.deepStrictEqual(
|
||||
success.calls.map((call) => call.type),
|
||||
["require", "password-login", "go-root"],
|
||||
);
|
||||
assert.deepStrictEqual(success.calls[1].payload, {
|
||||
phone: "13800138000",
|
||||
passwordHash: "md5:secret-1",
|
||||
});
|
||||
assert.strictEqual(success.calls[2].pageId, "G01");
|
||||
assert.strictEqual(countCalls(success, "require"), 1);
|
||||
assert.strictEqual(countCalls(success, "sms-code"), 0);
|
||||
assert.strictEqual(success.tacVisible.value, false);
|
||||
assert.strictEqual(success.tacContext.value, null);
|
||||
|
||||
const navigationRejected = createHarness({ goRootResult: false });
|
||||
await preparePassword(navigationRejected);
|
||||
await completePasswordTac(navigationRejected);
|
||||
assert.strictEqual(navigationRejected.authenticationCommitted.value, true);
|
||||
assert.match(navigationRejected.feedbackMessage.value, /登录已完成/);
|
||||
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
|
||||
await navigationRejected.submitLogin();
|
||||
assert.strictEqual(countCalls(navigationRejected, "password-login"), 1);
|
||||
assert.strictEqual(countCalls(navigationRejected, "go-root"), 2);
|
||||
|
||||
const cancelled = createHarness();
|
||||
await preparePassword(cancelled);
|
||||
cancelled.closeTac();
|
||||
await cancelled.completeTac({
|
||||
requestId: "stale-request",
|
||||
validToken: "ticket-stale",
|
||||
expireSeconds: 300,
|
||||
});
|
||||
assert.strictEqual(countCalls(cancelled, "password-login"), 0);
|
||||
assert.strictEqual(countCalls(cancelled, "sms-code"), 0);
|
||||
|
||||
process.stdout.write("A01-PASSWORD-LOGIN-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -151,8 +151,8 @@ const run = async () => {
|
||||
assert(smsMetrics.agreementBottom <= smsMetrics.documentScrollHeight + 1, `A01 SMS agreement must remain reachable at ${size.width}x${size.height}`)
|
||||
}
|
||||
await valueOf(send, "document.querySelectorAll('.login-tab')[0].click()")
|
||||
await waitFor(send, "document.querySelector('.app-toast__copy')?.textContent === '密码登录的服务端安全验证尚未开放,请先使用验证码登录'", `A01 password unavailable reason was not announced at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')"), false, `A01 exposed the unsafe password form at ${size.width}x${size.height}`)
|
||||
await waitFor(send, "document.querySelectorAll('.login-tab')[0].classList.contains('active')", `A01 password tab did not activate at ${size.width}x${size.height}`)
|
||||
assert.strictEqual(await valueOf(send, "Boolean(document.querySelector('.password-toggle'))"), true, `A01 password form did not render at ${size.width}x${size.height}`)
|
||||
}
|
||||
|
||||
await valueOf(send, "document.querySelector('.login-submit').click()")
|
||||
|
||||
@@ -8,6 +8,9 @@ $remoteBusinessOwners = @(
|
||||
'pages/auth/a01-entry.vue',
|
||||
'pages/auth/a04-register.vue',
|
||||
'pages/auth/a05-reset-password.vue',
|
||||
'pages/genealogy/g01-my-genealogies.vue',
|
||||
'pages/genealogy/g05-genealogy-overview.vue',
|
||||
'pages/tree/t01-tree-overview.vue',
|
||||
'pages/profile/m07-feedback.vue'
|
||||
)
|
||||
|
||||
@@ -21,7 +24,7 @@ foreach ($relativePath in $activePaths) {
|
||||
throw "$relativePath must remain local-design only until its own tested interface batch"
|
||||
}
|
||||
if (-not $usesRemoteBusiness -and $relativePath -in $remoteBusinessOwners) {
|
||||
throw "$relativePath lost its owned authentication interface"
|
||||
throw "$relativePath lost its owned remote business interface"
|
||||
}
|
||||
if ($source -match 'uni\.(showToast|showModal|showLoading|showActionSheet)') { throw "$relativePath must use project feedback components" }
|
||||
}
|
||||
|
||||
@@ -133,6 +133,29 @@ const run = async () => {
|
||||
assert.strictEqual(login.access_token, "token-1");
|
||||
assert.deepStrictEqual(savedTokens, ["token-1"]);
|
||||
|
||||
respond({
|
||||
statusCode: 200,
|
||||
data: { code: 200, msg: "操作成功", data: { access_token: "token-password" } },
|
||||
});
|
||||
const passwordLogin = await appApi.loginWithPassword({
|
||||
phone: "13800138000",
|
||||
passwordHash: "a".repeat(32),
|
||||
});
|
||||
assert.strictEqual(passwordLogin.access_token, "token-password");
|
||||
const passwordRequest = requests.at(-1);
|
||||
assert.strictEqual(passwordRequest.url, "https://backend-api.ddxcjp.cn/genealogy/app/auth/login");
|
||||
assert.strictEqual(passwordRequest.method, "POST");
|
||||
assert.strictEqual(passwordRequest.header.clientid, "client-1");
|
||||
assert.strictEqual(passwordRequest.header.Authorization, undefined);
|
||||
assert.deepStrictEqual(passwordRequest.data, {
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
grantType: "password",
|
||||
phone: "13800138000",
|
||||
password: "a".repeat(32),
|
||||
});
|
||||
assert.deepStrictEqual(savedTokens, ["token-1", "token-password"]);
|
||||
|
||||
holdResponse = true;
|
||||
const requestController = createRequestController();
|
||||
const cancelled = appApi.sendSmsCode(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Contains {
|
||||
param([string]$Content, [string]$Expected, [string]$Message)
|
||||
if ($Content -notmatch [regex]::Escape($Expected)) { throw $Message }
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$global = Get-Content -LiteralPath (Join-Path $root 'styles/global.scss') -Raw -Encoding utf8
|
||||
|
||||
Assert-Contains $global '.auth-page.auth-page .auth-plain-button[disabled]' '认证页禁用按钮必须由全局认证样式覆盖原生默认底色'
|
||||
Assert-Contains $global 'background: transparent !important;' '认证页禁用按钮必须保持透明背景'
|
||||
|
||||
foreach ($relativePath in @(
|
||||
'pages/auth/a01-entry.vue',
|
||||
'pages/auth/a04-register.vue',
|
||||
'pages/auth/a05-reset-password.vue'
|
||||
)) {
|
||||
$page = Get-Content -LiteralPath (Join-Path $root $relativePath) -Raw -Encoding utf8
|
||||
foreach ($required in @(
|
||||
'class="auth-plain-button get-code"',
|
||||
'class="{ ''get-code--disabled'': sendingCode || cooldownSeconds > 0 }"',
|
||||
'createAuthSmsCooldown({',
|
||||
'const sentPhone = ref("");',
|
||||
'isSmsDeliveryOutcomeUnknown',
|
||||
'flex: 0 0 176rpx;',
|
||||
'white-space: nowrap;',
|
||||
'background: transparent !important;'
|
||||
)) {
|
||||
Assert-Contains $page $required "$relativePath 的验证码操作缺少视觉合同:$required"
|
||||
}
|
||||
if ($page.Contains('cooldownSeconds.value -= 1')) {
|
||||
throw "$relativePath still uses a decrementing cooldown that freezes in background"
|
||||
}
|
||||
if ($page -match '(?s)v-model\.trim="phone".{0,300}:disabled="[^"]*cooldownSeconds') {
|
||||
throw "$relativePath must not lock an empty phone field when a scene cooldown is restored"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output 'AUTH-CODE-ACTION-VISUAL-CONTRACT PASS'
|
||||
@@ -0,0 +1,68 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Read-RequiredFile([string]$relativePath) {
|
||||
$path = Join-Path $root $relativePath
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED`n- missing file: $relativePath"
|
||||
}
|
||||
return Get-Content -LiteralPath $path -Raw -Encoding UTF8
|
||||
}
|
||||
|
||||
$issues = [System.Collections.Generic.List[string]]::new()
|
||||
$a01 = Read-RequiredFile 'pages/auth/a01-entry.vue'
|
||||
$a04 = Read-RequiredFile 'pages/auth/a04-register.vue'
|
||||
$pages = Read-RequiredFile 'pages.json'
|
||||
|
||||
function Require-Text(
|
||||
[string]$content,
|
||||
[string]$expected,
|
||||
[string]$message
|
||||
) {
|
||||
if (-not $content.Contains($expected)) {
|
||||
$script:issues.Add($message)
|
||||
}
|
||||
}
|
||||
|
||||
function Require-Pattern(
|
||||
[string]$content,
|
||||
[string]$pattern,
|
||||
[string]$message
|
||||
) {
|
||||
if ($content -notmatch $pattern) {
|
||||
$script:issues.Add($message)
|
||||
}
|
||||
}
|
||||
|
||||
Require-Text -content $a01 -expected 'const authenticationCommitted = ref(false);' -message 'A01 must remember that remote authentication already committed'
|
||||
Require-Text -content $a01 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A01 must own a navigation-only retry after authentication commits'
|
||||
Require-Text -content $a01 -expected 'authenticationCommitted.value = true;' -message 'A01 must mark the remote authentication result before navigation'
|
||||
Require-Text -content $a01 -expected 'const authenticationNavigationFailure =' -message 'A01 must own a navigation-specific failure message'
|
||||
Require-Pattern -content $a01 -pattern '(?s)if \(authenticationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A01 repeated submit after commit must retry only the local navigation'
|
||||
Require-Text -content $a01 -expected 'const opened = await goRoot("G01");' -message 'A01 must inspect the navigation gateway boolean result'
|
||||
Require-Text -content $a01 -expected 'if (opened !== true)' -message 'A01 must treat a false navigation result as retryable failure'
|
||||
|
||||
Require-Text -content $a04 -expected 'const registrationCommitted = ref(false);' -message 'A04 must remember that the remote registration already committed'
|
||||
Require-Text -content $a04 -expected 'const enterAuthenticatedRoot = async () =>' -message 'A04 must own a navigation-only retry after registration commits'
|
||||
Require-Text -content $a04 -expected 'registrationCommitted.value = true;' -message 'A04 must mark the remote registration result before navigation'
|
||||
Require-Text -content $a04 -expected 'const registrationNavigationFailure =' -message 'A04 must own a navigation-specific failure message'
|
||||
Require-Pattern -content $a04 -pattern '(?s)if \(registrationCommitted\.value\)\s*return enterAuthenticatedRoot\(\);' -message 'A04 repeated submit after commit must retry only the local navigation'
|
||||
Require-Pattern -content $a04 -pattern '(?s)await appApi\.registerWithPassword\([\s\S]*?registrationCommitted\.value = true;\s*\}\s*catch' -message 'A04 must establish the registration commit before leaving the remote-write catch'
|
||||
Require-Pattern -content $a04 -pattern '(?s)\}\s*catch \(error\) \{[\s\S]*?\}\s*finally[\s\S]*?\}\s*if \(!pageActive\) return;\s*await enterAuthenticatedRoot\(\);' -message 'A04 navigation must execute after the registration failure boundary'
|
||||
Require-Text -content $a04 -expected 'const opened = await goRoot("G01");' -message 'A04 must inspect the navigation gateway boolean result'
|
||||
Require-Text -content $a04 -expected 'if (opened !== true)' -message 'A04 must treat a false navigation result as retryable failure'
|
||||
Require-Pattern -content $a04 -pattern '(?s)const requestBack = \(\) =>\s*registrationCommitted\.value\s*\?\s*enterAuthenticatedRoot\(\)' -message 'A04 committed registration must not enter the unsaved discard flow'
|
||||
|
||||
Require-Pattern -content $pages -pattern '"navigationBarTextStyle"\s*:\s*"white"' -message 'custom red headers require light Android status-bar content'
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = [System.Collections.Generic.List[string]]::new()
|
||||
$lines.Add('AUTH-POST-COMMIT-NAVIGATION-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) {
|
||||
$lines.Add("- $issue")
|
||||
}
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
}
|
||||
|
||||
Write-Output 'AUTH-POST-COMMIT-NAVIGATION-CONTRACT PASS'
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "../utils/auth-sms-cooldown.js"),
|
||||
"utf8",
|
||||
);
|
||||
const { createAuthSmsCooldown } = await import(toDataModuleUrl(source));
|
||||
|
||||
let now = 1_000_000;
|
||||
const timers = new Map();
|
||||
let nextTimerId = 1;
|
||||
const setIntervalFn = (callback) => {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
};
|
||||
const clearIntervalFn = (id) => timers.delete(id);
|
||||
|
||||
const firstValues = [];
|
||||
const first = createAuthSmsCooldown({
|
||||
sceneCode: "APP_SMS_LOGIN",
|
||||
onChange: (value) => firstValues.push(value),
|
||||
now: () => now,
|
||||
setIntervalFn,
|
||||
clearIntervalFn,
|
||||
});
|
||||
assert.strictEqual(first.sync(), 0);
|
||||
first.start();
|
||||
assert.strictEqual(firstValues.at(-1), 60);
|
||||
|
||||
now += 10_400;
|
||||
assert.strictEqual(first.sync(), 50);
|
||||
first.dispose();
|
||||
assert.strictEqual(timers.size, 0, "dispose must stop only the page timer");
|
||||
|
||||
const restoredValues = [];
|
||||
const restored = createAuthSmsCooldown({
|
||||
sceneCode: "APP_SMS_LOGIN",
|
||||
onChange: (value) => restoredValues.push(value),
|
||||
now: () => now,
|
||||
setIntervalFn,
|
||||
clearIntervalFn,
|
||||
});
|
||||
assert.strictEqual(
|
||||
restored.sync(),
|
||||
50,
|
||||
"a recreated page must restore the scene cooldown without storing a phone number",
|
||||
);
|
||||
|
||||
const other = createAuthSmsCooldown({
|
||||
sceneCode: "APP_REGISTER",
|
||||
onChange: () => {},
|
||||
now: () => now,
|
||||
setIntervalFn,
|
||||
clearIntervalFn,
|
||||
});
|
||||
assert.strictEqual(other.sync(), 0, "cooldowns must be isolated by TAC scene");
|
||||
|
||||
now += 50_000;
|
||||
assert.strictEqual(restored.sync(), 0);
|
||||
restored.dispose();
|
||||
other.dispose();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createAuthSmsCooldown({
|
||||
sceneCode: "",
|
||||
onChange: () => {},
|
||||
}),
|
||||
/sceneCode/,
|
||||
);
|
||||
|
||||
process.stdout.write("AUTH-SMS-COOLDOWN-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -88,7 +88,12 @@ foreach ($page in @($a01, $a04, $a05)) {
|
||||
}
|
||||
Require-Text -Content $page -Text 'const requestedPhone = phone.value' -Label '认证手机号请求快照'
|
||||
Require-Text -Content $page -Text 'subject: requestedPhone' -Label '认证手机号请求快照'
|
||||
Require-Text -Content $page -Text ':disabled="sendingCode || cooldownSeconds > 0 || submitting"' -Label '短信流程手机号锁定'
|
||||
$phoneLock = if ($page -eq $a01) {
|
||||
':disabled="sendingCode || cooldownSeconds > 0 || submitting || tacVisible"'
|
||||
} else {
|
||||
':disabled="sendingCode || cooldownSeconds > 0 || submitting"'
|
||||
}
|
||||
Require-Text -Content $page -Text $phoneLock -Label '短信流程手机号锁定'
|
||||
if ([regex]::Matches($page, [regex]::Escape('if (!pageActive) return;')).Count -lt 3) {
|
||||
throw '认证页面必须在策略、短信与最终提交的异步回流前拒绝卸载后的旧结果'
|
||||
}
|
||||
@@ -105,9 +110,10 @@ foreach ($token in @('const blockBusyAction = () =>', 'if (blockBusyAction()) re
|
||||
Require-Text -Content $a01 -Text $token -Label 'A01 忙碌动作门禁'
|
||||
}
|
||||
|
||||
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithSms', '/^\d{4}$/', 'PASSWORD_TAC_BLOCKED_MESSAGE')) {
|
||||
foreach ($token in @('AUTH_TAC_SCENE.SMS_LOGIN', 'appApi.loginWithPassword', 'appApi.loginWithSms', 'calcMD5(password.value)', 'const preparePasswordLogin = async () =>', '/^\d{4}$/')) {
|
||||
Require-Text -Content $a01 -Text $token -Label 'A01'
|
||||
}
|
||||
Reject-Text -Content $a01 -Text 'PASSWORD_TAC_BLOCKED_MESSAGE' -Label 'A01 旧密码登录硬关闭'
|
||||
Reject-Text -Content $a01 -Text '/^\d{6}$/' -Label 'A01 六位短信码'
|
||||
|
||||
foreach ($token in @('AUTH_TAC_SCENE.REGISTER', 'v-model.trim="verificationCode"', 'appApi.registerWithPassword', 'calcMD5(password.value)', '/^\d{4}$/', 'goRoot("G01")')) {
|
||||
|
||||
@@ -14,12 +14,12 @@ const run = async () => {
|
||||
);
|
||||
const {
|
||||
AUTH_TAC_SCENE,
|
||||
PASSWORD_TAC_BLOCKED_MESSAGE,
|
||||
isAuthPhone,
|
||||
assertSmsCode,
|
||||
normalizeCaptchaRequirement,
|
||||
createTacRenderContext,
|
||||
normalizeTacSuccess,
|
||||
isSmsDeliveryOutcomeUnknown,
|
||||
} = await import(toDataModuleUrl(source));
|
||||
|
||||
assert.deepStrictEqual(
|
||||
@@ -31,7 +31,6 @@ const run = async () => {
|
||||
},
|
||||
"认证 TAC 场景必须由受保护 OpenAPI 的唯一枚举拥有",
|
||||
);
|
||||
assert.match(PASSWORD_TAC_BLOCKED_MESSAGE, /服务端|安全验证|验证码登录/);
|
||||
|
||||
assert.strictEqual(isAuthPhone("13800138000"), true);
|
||||
for (const invalid of ["", "12800138000", "1380013800", "138001380000", 13800138000, null]) {
|
||||
@@ -123,6 +122,32 @@ const run = async () => {
|
||||
/票据/,
|
||||
);
|
||||
|
||||
for (const error of [
|
||||
{ code: "REQUEST_TIMEOUT" },
|
||||
{ code: "NETWORK_ERROR" },
|
||||
{ code: "RESPONSE_INVALID" },
|
||||
{ code: "REQUEST_CANCELLED" },
|
||||
{ code: "HTTP_ERROR", httpStatus: 201 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 204 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 302 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 408 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 500 },
|
||||
{ code: "BUSINESS_ERROR", businessCode: 408 },
|
||||
{ code: "BUSINESS_ERROR", businessCode: 500 },
|
||||
]) {
|
||||
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), true);
|
||||
}
|
||||
for (const error of [
|
||||
null,
|
||||
{ code: "HTTP_ERROR", httpStatus: 400 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 401 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 422 },
|
||||
{ code: "HTTP_ERROR", httpStatus: 429 },
|
||||
{ code: "BUSINESS_ERROR", businessCode: 429 },
|
||||
]) {
|
||||
assert.strictEqual(isSmsDeliveryOutcomeUnknown(error), false);
|
||||
}
|
||||
|
||||
process.stdout.write("AUTH-VERIFICATION-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ foreach ($token in @('currentStep.value = "ancestor"', 'submitAncestor', 'geneal
|
||||
throw "Missing G03 first-person flow: $token"
|
||||
}
|
||||
}
|
||||
if ($config -notmatch "mode:\s*'mock'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
|
||||
throw 'Runtime config must retain mock isolation while owning the new HTTPS backend base URL'
|
||||
if ($config -notmatch "mode:\s*'remote'" -or $config -notmatch "baseUrl:\s*'https://backend-api\.ddxcjp\.cn'") {
|
||||
throw 'Runtime config must enable the requested remote test backend over HTTPS'
|
||||
}
|
||||
if ($config -match '182\.61\.18\.23|http://backend-api\.ddxcjp\.cn|https://backend-api\.ddxcjp\.cn/') {
|
||||
throw 'Runtime config retains an obsolete, insecure or trailing-slash backend URL'
|
||||
@@ -78,20 +78,8 @@ if ($api -match 'if \(isMockMode\(\)\)') {
|
||||
throw 'Auth transports must not treat every non-mock mode as remote'
|
||||
}
|
||||
|
||||
foreach ($token in @(
|
||||
'async auditApplication(genealogyId, applicationId, { status, auditRemark = '''' })',
|
||||
'data: { status, auditRemark }',
|
||||
'payload.relationDesc',
|
||||
'payload.applyReason'
|
||||
)) {
|
||||
if (-not $api.Contains($token)) { throw "G-series API adapter contract missing: $token" }
|
||||
}
|
||||
if ($api.Contains('data: { approved }') -or $api.Contains('payload.message')) {
|
||||
throw 'G-series API adapter retains the deleted approved/message payload contract'
|
||||
}
|
||||
if ($api -notmatch '!/\^\[12\]\$/\.test\(status\)' -or $api -notmatch 'Array\.from\(auditRemark\)\.length > 500') {
|
||||
throw 'G-series audit adapter does not enforce the OpenAPI status pattern or remark length'
|
||||
}
|
||||
# 普通加入申请的旧数字审核体与宽松 adapter 已移交给专项 OpenAPI 门禁;
|
||||
# 页面在后端门禁通过前继续由 G08-G10 合同证明未接入这些遗留方法。
|
||||
if ($createFlow -match 'step=ancestor|query\?\.step') { throw 'G03 first-person flow must not restore the retired route step contract' }
|
||||
if ($createFlow -match 'createPerson') { throw 'G03 must not retain the removed createPerson entrypoint' }
|
||||
foreach ($route in @('pages/family/f04-article-list', 'pages/family/f02-publish-feed')) {
|
||||
|
||||
@@ -6,7 +6,8 @@ $currentDocuments = @(
|
||||
'docs/家谱项目全量治理设计.md',
|
||||
'docs/家谱项目全量治理实施计划.md',
|
||||
'docs/视觉资产与构建基线.md',
|
||||
'docs/接口与页面映射总表.md'
|
||||
'docs/接口与页面映射总表.md',
|
||||
'docs/今晚全量联调与明早测试执行计划.md'
|
||||
)
|
||||
|
||||
$combined = ''
|
||||
@@ -50,8 +51,11 @@ if ($designDocument -notmatch '导航任务 1—10 的静态实施和零债务
|
||||
if ($mappingDocument -notmatch '实际 `64/64` 个 Vue 文件') {
|
||||
throw '接口与页面映射总表没有同步退役通用页面后的响应式覆盖实数'
|
||||
}
|
||||
if ($overviewDocument -notmatch 'G01/G05、G03、M06、M01/M02/M03 个人资料读写、N01/N02/M01/G01 通知域、M10 退出域、M04 密码凭证域与 M05 换绑域已完成三人接口审查和失败门禁' -or
|
||||
$overviewDocument -notmatch '继续审查下一个不依赖现有红灯的业务域') {
|
||||
if ($overviewDocument -notmatch '任务 36 普通加入申请' -or
|
||||
$overviewDocument -notmatch '任务 37 邀请码签发与直接加入' -or
|
||||
$overviewDocument -notmatch '任务 38 G11 家谱设置版本化写入' -or
|
||||
$overviewDocument -notmatch '任务 39 G12 字辈集合版本化保存' -or
|
||||
$overviewDocument -notmatch '任务 40 F01/F03 家族动态读取') {
|
||||
throw '项目总览没有写明领域上下文与 M07 完成后的当前精确实施入口'
|
||||
}
|
||||
if ($overviewDocument -notmatch 'API-T01-001') {
|
||||
@@ -72,8 +76,9 @@ if ($visualDocument -notmatch '`static/tac/` 当前共 5 个文件' -or
|
||||
}
|
||||
if ($mappingDocument -notmatch 'A01、A04、A05 已接入同一个 `TacVerification`' -or
|
||||
$mappingDocument -notmatch '不能把本地滑动成功冒充服务端验证' -or
|
||||
$mappingDocument -notmatch '密码登录入口保持不可用') {
|
||||
throw '接口页面映射没有准确登记 A01/A04/A05 的 TAC 客户端状态与密码登录硬关闭'
|
||||
$mappingDocument -notmatch '开发/联调 wire 已按线上' -or
|
||||
$mappingDocument -notmatch '`validToken` 未进入或被服务端消费') {
|
||||
throw '接口页面映射没有准确登记 A01 密码联调接线、TAC 客户端状态与生产红灯'
|
||||
}
|
||||
if ($mappingDocument -notmatch '任务 5 实施前' -or $mappingDocument -notmatch '这只是历史视觉基线' -or $mappingDocument -notmatch '任务 4—6 的当前代码仍须.*MuMu 流程矩阵') {
|
||||
throw '接口页面映射把历史 MuMu 证据越界成了当前代码验收'
|
||||
@@ -133,8 +138,11 @@ $navigationFoundation = [regex]::Match(
|
||||
if ([string]::IsNullOrWhiteSpace($navigationFoundation) -or $navigationFoundation -match '- \[ \]') {
|
||||
throw '实施计划没有把已经验证的导航任务 1/2 精确标为完成'
|
||||
}
|
||||
if ($planDocument -notmatch 'M04 密码凭证任务 33、M05 手机号换绑任务 34 和 G03 原子创建任务 35 已完成三人审查及 OpenAPI 红灯' -or
|
||||
$planDocument -notmatch '等待后端期间转向 G/F/R 下一域') {
|
||||
if ($planDocument -notmatch '任务 36:建立普通加入申请闭环远端硬门禁' -or
|
||||
$planDocument -notmatch '任务 37:建立邀请码签发与直接加入远端硬门禁' -or
|
||||
$planDocument -notmatch '任务 38:建立 G11 家谱设置版本化写入远端硬门禁' -or
|
||||
$planDocument -notmatch '任务 39:建立 G12 字辈集合版本化保存远端硬门禁' -or
|
||||
$planDocument -notmatch '任务 40:建立 F01/F03 家族动态读取远端硬门禁') {
|
||||
throw '实施计划没有登记领域上下文与 M07 完成后的独立实施入口'
|
||||
}
|
||||
$taskThree = [regex]::Match(
|
||||
@@ -362,13 +370,43 @@ if ($navigationSource -match '(?m)^export\s+(const|function)\s+replaceStep\b' -o
|
||||
if ($mappingDocument -match '将在阶段 1.*确定' -or $mappingDocument -match '阶段 1 依赖') {
|
||||
throw '接口映射总表仍把已收口导航或未来业务错误绑定为阶段 1 待定项'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存为 `140` 个 PowerShell 合同、`47` 个 Node 文件' -or
|
||||
$overviewDocument -notmatch 'PowerShell `126/140` 通过' -or
|
||||
$overviewDocument -notmatch 'Node 语法 `47/47` 通过' -or
|
||||
$overviewDocument -notmatch '纯 Node 冒烟 `19/19` 通过' -or
|
||||
if ($overviewDocument -notmatch '当前物理库存为 `147` 个 PowerShell 合同、`48` 个 Node 文件' -or
|
||||
$overviewDocument -notmatch 'PowerShell `128/147` 通过' -or
|
||||
$overviewDocument -notmatch 'Node 语法 `48/48` 通过' -or
|
||||
$overviewDocument -notmatch '纯 Node 冒烟 `20/20` 通过' -or
|
||||
$overviewDocument -notmatch 'Vue 脚本模块语法 `64/64` 通过') {
|
||||
throw '项目总览没有登记当前测试库存与最新全量验证证据'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存更新为 PowerShell `141`、Node `47`' -or
|
||||
$overviewDocument -notmatch 'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED') {
|
||||
throw '项目总览没有登记任务 36 新增门禁后的物理库存和红灯'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `142`、Node `47`' -or
|
||||
$overviewDocument -notmatch '全量 fresh 为 `126/142`' -or
|
||||
$overviewDocument -notmatch 'INVITE-TICKET-OPENAPI-CONTRACT BLOCKED') {
|
||||
throw '项目总览没有登记任务 37 新增门禁后的物理库存、fresh 结果和红灯'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `143`、Node `47`' -or
|
||||
$overviewDocument -notmatch '全量 fresh 为 `126/143`' -or
|
||||
$overviewDocument -notmatch 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED' -or
|
||||
$overviewDocument -notmatch 'Issues: 48') {
|
||||
throw '项目总览没有登记任务 38 新增门禁后的物理库存、fresh 结果和红灯'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `145`、Node `48`' -or
|
||||
$overviewDocument -notmatch '全量 fresh 为 `127/145`' -or
|
||||
$overviewDocument -notmatch 'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED' -or
|
||||
$overviewDocument -notmatch 'Issues: 72' -or
|
||||
$overviewDocument -notmatch 'G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS' -or
|
||||
$overviewDocument -notmatch 'G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS') {
|
||||
throw '项目总览没有登记任务 39 门禁、对抗证据和 fresh 库存'
|
||||
}
|
||||
if ($overviewDocument -notmatch '当前物理库存为 PowerShell `147`、Node `48`' -or
|
||||
$overviewDocument -notmatch '全量 fresh 为 `128/147`' -or
|
||||
$overviewDocument -notmatch 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED' -or
|
||||
$overviewDocument -notmatch 'Issues: 85' -or
|
||||
$overviewDocument -notmatch 'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58') {
|
||||
throw '项目总览没有登记任务 40 家族动态读取门禁和 fresh 库存'
|
||||
}
|
||||
foreach ($completedDomainFact in @(
|
||||
'appApi.submitFeedback',
|
||||
'/genealogy/app/feedback',
|
||||
@@ -390,6 +428,19 @@ foreach ($workspaceGateFact in @(
|
||||
'RListAppGenealogyVo',
|
||||
'RAppGenealogyVo',
|
||||
'canView',
|
||||
'appListMyGenealogies',
|
||||
'appGetGenealogyOverview',
|
||||
'GENEALOGY_ID_INVALID',
|
||||
'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE',
|
||||
'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS',
|
||||
'旁路详情',
|
||||
'允许非语义 tracing header',
|
||||
'禁止 ETag',
|
||||
'traceparent/tracestate/x-request-id/x-correlation-id',
|
||||
'内联 Header Object',
|
||||
'非 null 的 string schema',
|
||||
'readOnly/writeOnly',
|
||||
'allOf',
|
||||
'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
|
||||
)) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($workspaceGateFact) -and
|
||||
@@ -537,6 +588,189 @@ foreach ($g03BootstrapGateFact in @(
|
||||
throw "当前文档没有登记 G03 原子创建门禁事实:$g03BootstrapGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($joinApplicationGateFact in @(
|
||||
'API-JOIN-001',
|
||||
'API-JOIN-006',
|
||||
'join-application-openapi-contract.ps1',
|
||||
'appSearchPublicGenealogies',
|
||||
'appCreateGenealogyJoinApplication',
|
||||
'appGetGenealogyJoinApplicationRequest',
|
||||
'appListMyGenealogyJoinApplications',
|
||||
'appWithdrawGenealogyJoinApplication',
|
||||
'appListPendingGenealogyJoinApplications',
|
||||
'appReviewGenealogyJoinApplication',
|
||||
'GenealogyJoinApplicationRequestKey',
|
||||
'JOIN_APPLICATION_TEXT_V1',
|
||||
'FAILED_NO_COMMIT',
|
||||
'WHERE status=PENDING',
|
||||
'JOIN-APPLICATION-OPENAPI-CONTRACT BLOCKED',
|
||||
'邀请码成功后直接加入且不生成审核记录',
|
||||
'G10 手机号',
|
||||
'消息中心承诺',
|
||||
'LOCAL_WITHDRAWN',
|
||||
'数据库观测',
|
||||
'静态门禁单独转绿不开放页面'
|
||||
)) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
|
||||
$designDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
|
||||
$mappingDocument -notmatch [regex]::Escape($joinApplicationGateFact) -and
|
||||
$planDocument -notmatch [regex]::Escape($joinApplicationGateFact)) {
|
||||
throw "当前文档没有登记普通加入申请门禁事实:$joinApplicationGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($inviteTicketGateFact in @(
|
||||
'API-INVITE-001',
|
||||
'API-INVITE-006',
|
||||
'invite-ticket-openapi-contract.ps1',
|
||||
'appListMyGenealogyInviteTickets',
|
||||
'appIssueGenealogyInviteTicket',
|
||||
'appRevokeGenealogyInviteTicket',
|
||||
'appResolveGenealogyInviteTicket',
|
||||
'appRedeemGenealogyInviteTicket',
|
||||
'appGetGenealogyInviteRedemptionRequest',
|
||||
'INVITE-TICKET-OPENAPI-CONTRACT BLOCKED',
|
||||
'G06',
|
||||
'不再进入 G08',
|
||||
'单次、24 小时',
|
||||
'600 秒',
|
||||
'HMAC',
|
||||
'KMS',
|
||||
'ACTIVE→CONSUMED',
|
||||
'ACTIVE_PENDING_APPLICATION',
|
||||
'PENDING/SUCCEEDED/FAILED_NO_COMMIT',
|
||||
'TalkBack'
|
||||
)) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
|
||||
$designDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
|
||||
$mappingDocument -notmatch [regex]::Escape($inviteTicketGateFact) -and
|
||||
$planDocument -notmatch [regex]::Escape($inviteTicketGateFact)) {
|
||||
throw "当前文档没有登记邀请码直接加入门禁事实:$inviteTicketGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($settingsGateFact in @(
|
||||
'API-SETTINGS-001',
|
||||
'API-SETTINGS-006',
|
||||
'g11-settings-openapi-contract.ps1',
|
||||
'appUpdateGenealogySettings',
|
||||
'GenealogyName',
|
||||
'GenealogyIntro',
|
||||
'GenealogySettingsVersion',
|
||||
'AppGenealogySettingsUpdateBody',
|
||||
'GENEALOGY_SETTINGS_VERSION_CHANGED',
|
||||
'GENEALOGY_NOT_READY',
|
||||
'ACTIVE_PENDING_APPLICATIONS',
|
||||
'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED',
|
||||
'Issues: 48',
|
||||
'dirty-only',
|
||||
'If-Match',
|
||||
'ATOMIC_SINGLE_WINNER',
|
||||
'canonical 设置实际变化',
|
||||
'内部 LF',
|
||||
'控制字符',
|
||||
'exact local',
|
||||
'单字段',
|
||||
'nullable',
|
||||
'真实 JSON 数组',
|
||||
'允许非语义 tracing header',
|
||||
'禁止 ETag',
|
||||
'traceparent/tracestate/x-request-id/x-correlation-id',
|
||||
'内联 Header Object',
|
||||
'非 null 的 string schema',
|
||||
'readOnly/writeOnly',
|
||||
'根层',
|
||||
'PUBLIC_APPLY→MEMBER_ONLY',
|
||||
'fresh overview',
|
||||
'不创建.*占位 client gate',
|
||||
'OpenAPI 绿.*不.*直接接线'
|
||||
)) {
|
||||
$pattern = if ($settingsGateFact -in @('不创建.*占位 client gate', 'OpenAPI 绿.*不.*直接接线')) {
|
||||
$settingsGateFact
|
||||
} else {
|
||||
[regex]::Escape($settingsGateFact)
|
||||
}
|
||||
if ($overviewDocument -notmatch $pattern -and
|
||||
$designDocument -notmatch $pattern -and
|
||||
$mappingDocument -notmatch $pattern -and
|
||||
$planDocument -notmatch $pattern) {
|
||||
throw "当前文档没有登记 G11 设置门禁事实:$settingsGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($generationPoemGateFact in @(
|
||||
'API-POEM-001',
|
||||
'API-POEM-006',
|
||||
'g12-generation-poem-openapi-contract.ps1',
|
||||
'g12-generation-poem-openapi-adversarial-contract.ps1',
|
||||
'g12-generation-poem-unicode-contract-runtime-smoke.js',
|
||||
'appGetGenerationPoemSet',
|
||||
'appUpdateGenerationPoemSet',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems',
|
||||
'AppGenerationPoemSetUpdateBody',
|
||||
'GenerationPoemSetSnapshot',
|
||||
'GenerationPoemSetVersion',
|
||||
'disableMissing',
|
||||
'VALIDATE_FINAL_ACTIVE_CAPACITY',
|
||||
'ALLOCATE_UNIQUE_NEW_IDS',
|
||||
'GENERATION_SLOT_CONFLICT',
|
||||
'POEM_SET_VERSION_CONFLICT',
|
||||
'FRESH_GET_THREE_WAY_NO_AUTO_PUT',
|
||||
'CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION',
|
||||
'APP_GATEWAY_PREFLIGHT',
|
||||
'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED',
|
||||
'Issues: 72',
|
||||
'G12-GENERATION-POEM-OPENAPI-ADVERSARIAL-CONTRACT PASS',
|
||||
'G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS',
|
||||
'zero issues',
|
||||
'ACTIVE-only',
|
||||
'If-Match',
|
||||
'不引入 preview token',
|
||||
'不自动 PUT',
|
||||
'本地预览',
|
||||
'生产 normalizer/coordinator'
|
||||
)) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
|
||||
$designDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
|
||||
$mappingDocument -notmatch [regex]::Escape($generationPoemGateFact) -and
|
||||
$planDocument -notmatch [regex]::Escape($generationPoemGateFact)) {
|
||||
throw "当前文档没有登记 G12 字辈集合门禁事实:$generationPoemGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($familyFeedReadGateFact in @(
|
||||
'API-FEED-READ-001',
|
||||
'API-FEED-READ-006',
|
||||
'family-feed-read-openapi-contract.ps1',
|
||||
'family-feed-read-openapi-adversarial-contract.ps1',
|
||||
'appListFamilyFeeds',
|
||||
'appGetFamilyFeed',
|
||||
'appListFamilyFeedRootComments',
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds',
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}',
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments',
|
||||
'FamilyFeedId',
|
||||
'FamilyFeedCommentId',
|
||||
'AppFamilyFeedReadItem',
|
||||
'AppFamilyFeedRootCommentReadItem',
|
||||
'UPPER_BOUND_KEYSET_LATEST_VISIBLE',
|
||||
'publishedAt:DESC',
|
||||
'commentId:ASC_ORDINAL',
|
||||
'hasMedia',
|
||||
'FAMILY_FEED_NOT_AVAILABLE',
|
||||
'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED',
|
||||
'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58',
|
||||
'Issues: 85',
|
||||
'zero issues',
|
||||
'手机号',
|
||||
'审核字段',
|
||||
'删除两个 `/page` GET',
|
||||
'后端绿前',
|
||||
'生产 read normalizer/coordinator'
|
||||
)) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
|
||||
$designDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
|
||||
$mappingDocument -notmatch [regex]::Escape($familyFeedReadGateFact) -and
|
||||
$planDocument -notmatch [regex]::Escape($familyFeedReadGateFact)) {
|
||||
throw "当前文档没有登记 F01/F03 家族动态读取门禁事实:$familyFeedReadGateFact"
|
||||
}
|
||||
}
|
||||
foreach ($liveOpenApiFact in @('https://backend-api.ddxcjp.cn/', '3.1.0', '722', '858', '507', '/captcha/challenge', 'validToken')) {
|
||||
if ($overviewDocument -notmatch [regex]::Escape($liveOpenApiFact) -or $mappingDocument -notmatch [regex]::Escape($liveOpenApiFact)) {
|
||||
throw "项目总览或接口映射缺少新线上 OpenAPI 事实:$liveOpenApiFact"
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$contractPath = Join-Path $PSScriptRoot 'family-feed-read-openapi-contract.ps1'
|
||||
$temporaryPath = [System.IO.Path]::GetTempFileName()
|
||||
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
||||
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
|
||||
|
||||
function New-Ref([string]$Ref) {
|
||||
return [ordered]@{ '$ref' = $Ref }
|
||||
}
|
||||
|
||||
function New-StringOwner(
|
||||
[int]$MinLength,
|
||||
[int]$MaxLength,
|
||||
[string]$Pattern = ''
|
||||
) {
|
||||
$schema = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = $MinLength
|
||||
maxLength = $MaxLength
|
||||
nullable = $false
|
||||
}
|
||||
if ($Pattern) { $schema.pattern = $Pattern }
|
||||
return $schema
|
||||
}
|
||||
|
||||
function New-ClosedObject(
|
||||
[System.Collections.Specialized.OrderedDictionary]$Properties,
|
||||
[string[]]$Required
|
||||
) {
|
||||
return [ordered]@{
|
||||
type = 'object'
|
||||
properties = $Properties
|
||||
required = $Required
|
||||
additionalProperties = $false
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-SuccessEnvelope([string]$DataRef) {
|
||||
return New-ClosedObject ([ordered]@{
|
||||
code = [ordered]@{
|
||||
type = 'integer'
|
||||
enum = @(200)
|
||||
nullable = $false
|
||||
}
|
||||
data = New-Ref $DataRef
|
||||
}) @('code', 'data')
|
||||
}
|
||||
|
||||
function New-ErrorEnvelope([int]$Status, [string[]]$BusinessCodes) {
|
||||
return New-ClosedObject ([ordered]@{
|
||||
businessCode = [ordered]@{
|
||||
type = 'string'
|
||||
enum = $BusinessCodes
|
||||
nullable = $false
|
||||
}
|
||||
code = [ordered]@{
|
||||
type = 'integer'
|
||||
enum = @($Status)
|
||||
nullable = $false
|
||||
}
|
||||
message = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = 1
|
||||
maxLength = 200
|
||||
nullable = $false
|
||||
}
|
||||
}) @('businessCode', 'code', 'message')
|
||||
}
|
||||
|
||||
function New-Response([string]$SchemaRef, [bool]$RateLimited = $false) {
|
||||
$headers = [ordered]@{
|
||||
'Cache-Control' = New-Ref '#/components/headers/PrivateNoStore'
|
||||
}
|
||||
if ($RateLimited) {
|
||||
$headers.'Retry-After' = New-Ref '#/components/headers/RetryAfter'
|
||||
}
|
||||
return [ordered]@{
|
||||
description = 'typed response'
|
||||
headers = $headers
|
||||
content = [ordered]@{
|
||||
'application/json' = [ordered]@{
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-Responses([string]$SuccessRef) {
|
||||
return [ordered]@{
|
||||
'200' = New-Response $SuccessRef
|
||||
'400' = New-Response '#/components/schemas/RFamilyFeedReadBadRequest'
|
||||
'401' = New-Response '#/components/schemas/RFamilyFeedReadUnauthorized'
|
||||
'404' = New-Response '#/components/schemas/RFamilyFeedReadNotFound'
|
||||
'429' = New-Response '#/components/schemas/RFamilyFeedReadRateLimited' $true
|
||||
'500' = New-Response '#/components/schemas/RFamilyFeedReadUnavailable'
|
||||
}
|
||||
}
|
||||
|
||||
function New-ClientIdParameter {
|
||||
return [ordered]@{
|
||||
name = 'clientid'
|
||||
in = 'header'
|
||||
required = $true
|
||||
schema = [ordered]@{
|
||||
type = 'string'
|
||||
minLength = 1
|
||||
maxLength = 128
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-IdParameter([string]$Name, [string]$SchemaRef) {
|
||||
return [ordered]@{
|
||||
name = $Name
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
|
||||
function New-CursorParameter([string]$SchemaRef) {
|
||||
return [ordered]@{
|
||||
name = 'cursor'
|
||||
in = 'query'
|
||||
required = $false
|
||||
schema = New-Ref $SchemaRef
|
||||
}
|
||||
}
|
||||
|
||||
function New-LimitParameter {
|
||||
return [ordered]@{
|
||||
name = 'limit'
|
||||
in = 'query'
|
||||
required = $false
|
||||
schema = [ordered]@{
|
||||
type = 'integer'
|
||||
minimum = 1
|
||||
maximum = 50
|
||||
default = 20
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function New-ReadOperation(
|
||||
[string]$OperationId,
|
||||
[object[]]$Parameters,
|
||||
[string]$SuccessRef,
|
||||
[string[]]$AuthorizationScope
|
||||
) {
|
||||
return [ordered]@{
|
||||
operationId = $OperationId
|
||||
parameters = $Parameters
|
||||
responses = New-Responses $SuccessRef
|
||||
security = @([ordered]@{ SaToken = @() })
|
||||
'x-read-only' = $true
|
||||
'x-non-disclosing-not-found' = $true
|
||||
'x-authorize-every-request' = $true
|
||||
'x-cors-policy-owner' = 'APP_GATEWAY_PREFLIGHT'
|
||||
'x-authorization-scope' = $AuthorizationScope
|
||||
}
|
||||
}
|
||||
|
||||
function Add-CursorContract(
|
||||
[System.Collections.Specialized.OrderedDictionary]$Operation,
|
||||
[string[]]$Scope,
|
||||
[string[]]$Order
|
||||
) {
|
||||
$Operation.'x-cursor-scope' = $Scope
|
||||
$Operation.'x-cursor-order' = $Order
|
||||
$Operation.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
|
||||
$Operation.'x-cursor-no-total' = $true
|
||||
$Operation.'x-refresh-discards-cursor' = $true
|
||||
$Operation.'x-authorize-every-page' = $true
|
||||
$Operation.'x-invalid-or-expired-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
|
||||
$Operation.'x-cross-scope-cursor' = '404_FAMILY_FEED_NOT_AVAILABLE'
|
||||
}
|
||||
|
||||
function New-CursorPage(
|
||||
[string]$ItemRef,
|
||||
[string]$CursorRef,
|
||||
[string[]]$Order
|
||||
) {
|
||||
$page = New-ClosedObject ([ordered]@{
|
||||
items = [ordered]@{
|
||||
type = 'array'
|
||||
items = New-Ref $ItemRef
|
||||
minItems = 0
|
||||
maxItems = 50
|
||||
nullable = $false
|
||||
}
|
||||
nextCursor = New-Ref $CursorRef
|
||||
}) @('items')
|
||||
$page.'x-no-total' = $true
|
||||
$page.'x-next-cursor-absent-at-end' = $true
|
||||
$page.'x-order' = $Order
|
||||
$page.'x-read-window' = 'UPPER_BOUND_KEYSET_LATEST_VISIBLE'
|
||||
$page.'x-new-items-after-window' = 'EXCLUDED_UNTIL_REFRESH'
|
||||
$page.'x-deletion-or-visibility-change' = 'OMIT_ON_LATER_PAGE'
|
||||
$page.'x-edit-policy' = 'LATEST_VISIBLE_AT_PAGE_READ'
|
||||
return $page
|
||||
}
|
||||
|
||||
function New-ValidDocument {
|
||||
$genealogyId = New-StringOwner 1 128 $identifierPattern
|
||||
$feedId = New-StringOwner 1 128 $identifierPattern
|
||||
$feedId.'x-opaque' = $true
|
||||
$feedId.'x-client-semantics' = 'COMPARE_ONLY'
|
||||
$commentId = New-StringOwner 1 128 $identifierPattern
|
||||
$commentId.'x-opaque' = $true
|
||||
$commentId.'x-client-semantics' = 'COMPARE_ONLY'
|
||||
|
||||
$feedCursor = New-StringOwner 1 512 $cursorPattern
|
||||
$feedCursor.'x-opaque' = $true
|
||||
$feedCursor.'x-purpose' = 'FAMILY_FEED_PAGE'
|
||||
$commentCursor = New-StringOwner 1 512 $cursorPattern
|
||||
$commentCursor.'x-opaque' = $true
|
||||
$commentCursor.'x-purpose' = 'FAMILY_FEED_ROOT_COMMENT_PAGE'
|
||||
|
||||
$feedContent = New-StringOwner 1 300
|
||||
$feedContent.'x-text-normalizer' = 'FAMILY_FEED_TEXT_V1'
|
||||
$feedContent.'x-length-unit' = 'UNICODE_CODE_POINT'
|
||||
$commentContent = New-StringOwner 1 1000
|
||||
$commentContent.'x-text-normalizer' = 'FAMILY_FEED_COMMENT_TEXT_V1'
|
||||
$commentContent.'x-length-unit' = 'UNICODE_CODE_POINT'
|
||||
$displayName = New-StringOwner 1 100
|
||||
$displayName.'x-projection' = 'AUTHORIZED_DISPLAY_NAME_ONLY'
|
||||
$displayName.'x-missing-author-policy' = 'NON_EMPTY_SERVER_FALLBACK'
|
||||
$publishedAt = [ordered]@{
|
||||
type = 'string'
|
||||
format = 'date-time'
|
||||
nullable = $false
|
||||
'x-server-generated' = $true
|
||||
'x-immutable' = $true
|
||||
}
|
||||
|
||||
$feedItem = New-ClosedObject ([ordered]@{
|
||||
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
|
||||
feedContent = New-Ref '#/components/schemas/FamilyFeedContent'
|
||||
feedId = New-Ref '#/components/schemas/FamilyFeedId'
|
||||
hasMedia = [ordered]@{
|
||||
type = 'boolean'
|
||||
nullable = $false
|
||||
}
|
||||
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
|
||||
}) @('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt')
|
||||
$feedItem.description = 'Annotations may mention phone or audit examples without becoming response fields.'
|
||||
$feedItem.example = [ordered]@{ annotationOnly = 'appUserPhone is not a schema property' }
|
||||
$feedItem.'x-projection' = 'VISIBLE_FEED_PRESENTATION_ONLY'
|
||||
$feedItem.'x-media-policy' = 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT'
|
||||
|
||||
$commentItem = New-ClosedObject ([ordered]@{
|
||||
authorDisplayName = New-Ref '#/components/schemas/FamilyFeedAuthorDisplayName'
|
||||
commentContent = New-Ref '#/components/schemas/FamilyFeedCommentContent'
|
||||
commentId = New-Ref '#/components/schemas/FamilyFeedCommentId'
|
||||
publishedAt = New-Ref '#/components/schemas/FamilyFeedPublishedAt'
|
||||
}) @('authorDisplayName', 'commentContent', 'commentId', 'publishedAt')
|
||||
$commentItem.'x-projection' = 'VISIBLE_COMMENT_PRESENTATION_ONLY'
|
||||
$commentItem.'x-comment-level' = 'ROOT_ONLY'
|
||||
$commentItem.'x-deleted-placeholder-policy' = 'EXCLUDE'
|
||||
|
||||
$feedOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
|
||||
$commentOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
|
||||
|
||||
$feedList = New-ReadOperation 'appListFamilyFeeds' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-CursorParameter '#/components/schemas/FamilyFeedCursor'),
|
||||
(New-LimitParameter)
|
||||
) '#/components/schemas/RAppFamilyFeedCursorPage' @('tenant', 'genealogy', 'membership')
|
||||
Add-CursorContract $feedList @(
|
||||
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection',
|
||||
'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
) $feedOrder
|
||||
|
||||
$feedDetail = New-ReadOperation 'appGetFamilyFeed' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId')
|
||||
) '#/components/schemas/RAppFamilyFeedReadItem' @(
|
||||
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
|
||||
)
|
||||
|
||||
$comments = New-ReadOperation 'appListFamilyFeedRootComments' @(
|
||||
(New-ClientIdParameter),
|
||||
(New-IdParameter 'genealogyId' '#/components/schemas/GenealogyId'),
|
||||
(New-IdParameter 'feedId' '#/components/schemas/FamilyFeedId'),
|
||||
(New-CursorParameter '#/components/schemas/FamilyFeedRootCommentCursor'),
|
||||
(New-LimitParameter)
|
||||
) '#/components/schemas/RAppFamilyFeedRootCommentCursorPage' @(
|
||||
'tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility'
|
||||
)
|
||||
Add-CursorContract $comments @(
|
||||
'tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId',
|
||||
'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
) $commentOrder
|
||||
|
||||
$schemas = [ordered]@{
|
||||
GenealogyId = $genealogyId
|
||||
FamilyFeedId = $feedId
|
||||
FamilyFeedCommentId = $commentId
|
||||
FamilyFeedCursor = $feedCursor
|
||||
FamilyFeedRootCommentCursor = $commentCursor
|
||||
FamilyFeedContent = $feedContent
|
||||
FamilyFeedCommentContent = $commentContent
|
||||
FamilyFeedAuthorDisplayName = $displayName
|
||||
FamilyFeedPublishedAt = $publishedAt
|
||||
AppFamilyFeedReadItem = $feedItem
|
||||
AppFamilyFeedRootCommentReadItem = $commentItem
|
||||
AppFamilyFeedCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' $feedOrder
|
||||
AppFamilyFeedRootCommentCursorPage = New-CursorPage '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' $commentOrder
|
||||
RAppFamilyFeedCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedCursorPage'
|
||||
RAppFamilyFeedReadItem = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedReadItem'
|
||||
RAppFamilyFeedRootCommentCursorPage = New-SuccessEnvelope '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
|
||||
RFamilyFeedReadBadRequest = New-ErrorEnvelope 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
|
||||
RFamilyFeedReadUnauthorized = New-ErrorEnvelope 401 @('AUTH_REQUIRED')
|
||||
RFamilyFeedReadNotFound = New-ErrorEnvelope 404 @('FAMILY_FEED_NOT_AVAILABLE')
|
||||
RFamilyFeedReadRateLimited = New-ErrorEnvelope 429 @('RATE_LIMITED')
|
||||
RFamilyFeedReadUnavailable = New-ErrorEnvelope 500 @('FAMILY_FEED_READ_UNAVAILABLE')
|
||||
}
|
||||
|
||||
$document = [ordered]@{
|
||||
openapi = '3.0.1'
|
||||
info = [ordered]@{
|
||||
title = 'family feed read adversarial fixture'
|
||||
version = '1'
|
||||
}
|
||||
paths = [ordered]@{
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds' = [ordered]@{
|
||||
get = $feedList
|
||||
}
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}' = [ordered]@{
|
||||
get = $feedDetail
|
||||
}
|
||||
'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments' = [ordered]@{
|
||||
get = $comments
|
||||
}
|
||||
}
|
||||
components = [ordered]@{
|
||||
schemas = $schemas
|
||||
headers = [ordered]@{
|
||||
PrivateNoStore = [ordered]@{
|
||||
schema = [ordered]@{
|
||||
type = 'string'
|
||||
enum = @('private, no-store')
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
RetryAfter = [ordered]@{
|
||||
schema = [ordered]@{
|
||||
type = 'integer'
|
||||
minimum = 1
|
||||
maximum = 300
|
||||
nullable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
securitySchemes = [ordered]@{
|
||||
SaToken = [ordered]@{
|
||||
type = 'apiKey'
|
||||
in = 'header'
|
||||
name = 'Authorization'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ($document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Copy-Document([object]$Document) {
|
||||
return ($Document | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Get-ContractIssues([object]$Document) {
|
||||
$json = $Document | ConvertTo-Json -Depth 100
|
||||
[System.IO.File]::WriteAllText($temporaryPath, $json, [System.Text.UTF8Encoding]::new($false))
|
||||
$output = @(
|
||||
& $contractPath -SkipProtectedParity -ReturnIssues -DocumentPath $temporaryPath
|
||||
)
|
||||
return @($output | Where-Object { $_ -is [string] -and $_.Length -gt 0 })
|
||||
}
|
||||
|
||||
function Rename-NoteProperty([object]$Owner, [string]$From, [string]$To) {
|
||||
$property = @($Owner.PSObject.Properties | Where-Object { $_.Name -ceq $From })[0]
|
||||
if (-not $property) { throw "mutation setup missing property: $From" }
|
||||
$value = $property.Value
|
||||
$Owner.PSObject.Properties.Remove($From)
|
||||
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($To, $value))
|
||||
}
|
||||
|
||||
function Add-NoteProperty([object]$Owner, [string]$Name, [object]$Value) {
|
||||
$Owner.PSObject.Properties.Add([System.Management.Automation.PSNoteProperty]::new($Name, $Value))
|
||||
}
|
||||
|
||||
function Assert-MutantRejected(
|
||||
[object]$Seed,
|
||||
[string]$Label,
|
||||
[scriptblock]$Mutate,
|
||||
[string]$ExpectedIssuePattern
|
||||
) {
|
||||
$mutant = Copy-Document $Seed
|
||||
& $Mutate $mutant
|
||||
$mutantIssues = @(Get-ContractIssues $mutant)
|
||||
if ($mutantIssues.Count -eq 0) {
|
||||
throw "adversarial mutant fake-greened: $Label"
|
||||
}
|
||||
if ($ExpectedIssuePattern -and -not (($mutantIssues -join "`n") -match $ExpectedIssuePattern)) {
|
||||
throw "adversarial mutant rejected for the wrong reason: $Label`n$($mutantIssues -join "`n")"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$seed = New-ValidDocument
|
||||
$seedIssues = @(Get-ContractIssues $seed)
|
||||
if ($seedIssues.Count -gt 0) {
|
||||
throw "valid zero-issue seed was rejected:`n$($seedIssues -join "`n")"
|
||||
}
|
||||
|
||||
$mutations = @(
|
||||
@{
|
||||
Label = 'legacy /feeds/page GET owner'
|
||||
Pattern = 'legacy duplicate GET owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/feeds/page' ([pscustomobject]@{
|
||||
get = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'operationId drift'
|
||||
Pattern = 'operationId must be appListFamilyFeeds'
|
||||
Apply = { param($doc) $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.operationId = 'list_17' }
|
||||
},
|
||||
@{
|
||||
Label = 'Path Item Get keyword casing'
|
||||
Pattern = 'Path Item keyword casing is invalid'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'get' 'Get'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'security object instead of array'
|
||||
Pattern = 'security must be a JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security = [pscustomobject]@{ SaToken = @() }
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'SaToken key casing'
|
||||
Pattern = 'must require only exact SaToken'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$requirement = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0]
|
||||
Rename-NoteProperty $requirement 'SaToken' 'satoken'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'non-empty SaToken scopes'
|
||||
Pattern = 'SaToken scopes must be an empty JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.security[0].SaToken = @('feed:read')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'int64 feed identity'
|
||||
Pattern = 'sole exact local ref #/components/schemas/FamilyFeedId'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId = [pscustomobject]@{ type = 'integer'; format = 'int64' }
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'open feed projection'
|
||||
Pattern = 'AppFamilyFeedReadItem must be closed'
|
||||
Apply = { param($doc) $doc.components.schemas.AppFamilyFeedReadItem.additionalProperties = $true }
|
||||
},
|
||||
@{
|
||||
Label = 'phone field leak'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedReadItem.properties 'appUserPhone' ([pscustomobject]@{
|
||||
type = 'string'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required += 'appUserPhone'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'moderation field leak'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'moderationReason' ([pscustomobject]@{
|
||||
type = 'string'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'moderationReason'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'offset total in cursor page'
|
||||
Pattern = 'AppFamilyFeedCursorPage must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedCursorPage.properties 'total' ([pscustomobject]@{
|
||||
type = 'integer'; nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'total'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'pageNum query bypass'
|
||||
Pattern = 'parameters must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$operation = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$operation.parameters += [pscustomobject]@{
|
||||
name = 'pageNum'; in = 'query'; required = $false
|
||||
schema = [pscustomobject]@{ type = 'integer'; minimum = 1; nullable = $false }
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cursor scope comma string'
|
||||
Pattern = 'x-cursor-scope must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = 'tenant,account,authSession'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cross-scope cursor returns 400'
|
||||
Pattern = 'cursor/refresh/per-page authorization semantics drifted'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cross-scope-cursor' = '400_FAMILY_FEED_CURSOR_INVALID'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = '403 resource existence split'
|
||||
Pattern = 'responses must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'.get.responses '403' (
|
||||
New-Response '#/components/schemas/RFamilyFeedReadNotFound'
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'wildcard response media type'
|
||||
Pattern = 'must expose only application/json'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$response = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
|
||||
Rename-NoteProperty $response.content 'application/json' '*/*'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'missing private cache header'
|
||||
Pattern = 'must define Cache-Control'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.PSObject.Properties.Remove('Cache-Control')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'unbounded Retry-After'
|
||||
Pattern = 'Retry-After must be a non-null integer in 1..300'
|
||||
Apply = { param($doc) $doc.components.headers.RetryAfter.schema.maximum = 301 }
|
||||
},
|
||||
@{
|
||||
Label = 'generic list response'
|
||||
Pattern = '200 schema ref must be #/components/schemas/RAppFamilyFeedCursorPage'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.content.'application/json'.schema.'$ref' = '#/components/schemas/RObject'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'external feedId ref'
|
||||
Pattern = 'must be the sole exact local ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.feedId.'$ref' = 'https://example.invalid/schemas.json#/FamilyFeedId'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'hasMedia removed'
|
||||
Pattern = 'AppFamilyFeedReadItem must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.properties.PSObject.Properties.Remove('hasMedia')
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required = @(
|
||||
$doc.components.schemas.AppFamilyFeedReadItem.required | Where-Object { $_ -cne 'hasMedia' }
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'feed cursor order drift'
|
||||
Pattern = 'x-cursor-order must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-order' = @('feedId:DESC_ORDINAL', 'publishedAt:DESC')
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'mutable publishedAt'
|
||||
Pattern = 'must be a non-null immutable server-generated RFC3339'
|
||||
Apply = { param($doc) $doc.components.schemas.FamilyFeedPublishedAt.'x-immutable' = $false }
|
||||
},
|
||||
@{
|
||||
Label = 'feed content length drift'
|
||||
Pattern = 'FamilyFeedContent must be a non-null string length 1..300'
|
||||
Apply = { param($doc) $doc.components.schemas.FamilyFeedContent.maxLength = 301 }
|
||||
},
|
||||
@{
|
||||
Label = 'comment level internal field'
|
||||
Pattern = 'success graph leaks forbidden/internal field'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas.AppFamilyFeedRootCommentReadItem.properties 'commentLevel' ([pscustomobject]@{
|
||||
type = 'string'; enum = @('root'); nullable = $false
|
||||
})
|
||||
$doc.components.schemas.AppFamilyFeedRootCommentReadItem.required += 'commentLevel'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'duplicate operationId'
|
||||
Pattern = 'operationId must be appListFamilyFeedRootComments|must have exactly one global operation owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'.get.operationId = 'appListFamilyFeeds'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'explicit HEAD read bypass'
|
||||
Pattern = 'must not expose an explicit HEAD read bypass'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds' 'head' ([pscustomobject]@{
|
||||
responses = [pscustomobject]@{}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'callback side channel'
|
||||
Pattern = 'must not define callbacks'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'callbacks' ([pscustomobject]@{
|
||||
leak = [pscustomobject]@{}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'schema Type keyword casing'
|
||||
Pattern = 'contains an unowned schema keyword: Type'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc.components.schemas.FamilyFeedContent 'type' 'Type' }
|
||||
},
|
||||
@{
|
||||
Label = 'nextCursor made required'
|
||||
Pattern = 'AppFamilyFeedCursorPage must be closed'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.schemas.AppFamilyFeedCursorPage.required += 'nextCursor'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate APP GET reuses feed read projection'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowFamilyFeedRead'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Response Object links side channel'
|
||||
Pattern = 'contains an unowned Response Object keyword: links'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' 'links' ([pscustomobject]@{
|
||||
next = [pscustomobject]@{ operationId = 'shadowFamilyFeedRead' }
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Header Object content side channel'
|
||||
Pattern = 'contains an unowned Header Object keyword: content'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.headers.PrivateNoStore 'content' ([pscustomobject]@{
|
||||
'application/json' = [pscustomobject]@{ schema = [pscustomobject]@{ type = 'string' } }
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI 3.1 dialect drift'
|
||||
Pattern = 'OpenAPI version must be exact 3.0.1'
|
||||
Apply = { param($doc) $doc.openapi = '3.1.0' }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI 3.1 webhooks keyword'
|
||||
Pattern = 'OpenAPI root contains an unowned keyword: webhooks'
|
||||
Apply = { param($doc) Add-NoteProperty $doc 'webhooks' ([pscustomobject]@{}) }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI Paths keyword casing'
|
||||
Pattern = 'OpenAPI root keyword casing is invalid: Paths'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc 'paths' 'Paths' }
|
||||
},
|
||||
@{
|
||||
Label = 'OpenAPI Components keyword casing'
|
||||
Pattern = 'OpenAPI root keyword casing is invalid: Components'
|
||||
Apply = { param($doc) Rename-NoteProperty $doc 'components' 'Components' }
|
||||
},
|
||||
@{
|
||||
Label = 'cursor extension keyword casing'
|
||||
Pattern = 'operation keyword casing is invalid: X-Cursor-Scope'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Rename-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-cursor-scope' 'X-Cursor-Scope'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'cursor scope comma join collision'
|
||||
Pattern = 'x-cursor-scope must be the exact JSON array'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.'x-cursor-scope' = @(
|
||||
'tenant,account', 'authSession', 'client', 'genealogyId', 'projection',
|
||||
'order', 'limit', 'windowUpperBound', 'lastTuple'
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter schema keyword casing'
|
||||
Pattern = 'Parameter Object keyword casing is invalid: Schema'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$parameter = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1]
|
||||
Rename-NoteProperty $parameter 'schema' 'Schema'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'path parameter case-shadow'
|
||||
Pattern = 'parameters must be exactly'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$pathItem = $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'
|
||||
Add-NoteProperty $pathItem 'parameters' @(
|
||||
[pscustomobject]@{
|
||||
name = 'GenealogyId'
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter Reference Object sibling'
|
||||
Pattern = 'parameter ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'parameters' ([pscustomobject]@{
|
||||
GenealogyIdParameter = [pscustomobject]@{
|
||||
name = 'genealogyId'
|
||||
in = 'path'
|
||||
required = $true
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
})
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] = [pscustomobject]@{
|
||||
'$ref' = '#/components/parameters/GenealogyIdParameter'
|
||||
description = 'forbidden sibling'
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Schema Reference Object sibling'
|
||||
Pattern = 'must be the sole exact local ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1].schema 'description' 'forbidden sibling'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'SaToken security scheme drift'
|
||||
Pattern = 'SaToken must be the exact apiKey/header/Authorization security owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$doc.components.securitySchemes.SaToken = [pscustomobject]@{
|
||||
type = 'oauth2'
|
||||
flows = [pscustomobject]@{}
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate 206 owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowFamilyFeed206'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
'206' = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-206' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'replies path reuses canonical projection'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowRepliesFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/genealogies/{genealogyId}/replies-shadow' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate HEAD projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowHeadFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-head' ([pscustomobject]@{ head = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'alternate OPTIONS projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowOptionsFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-options' ([pscustomobject]@{ options = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'callback GET projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowCallbackFamilyFeed'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/callback-carrier' ([pscustomobject]@{
|
||||
post = [pscustomobject]@{
|
||||
operationId = 'callbackCarrier'
|
||||
responses = [pscustomobject]@{
|
||||
'204' = [pscustomobject]@{ description = 'accepted' }
|
||||
}
|
||||
callbacks = [pscustomobject]@{
|
||||
leak = [pscustomobject]@{
|
||||
'{$request.body#/callbackUrl}' = [pscustomobject]@{ get = $shadow }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'inline alternate projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowInlineFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = Copy-Document $doc.components.schemas.RAppFamilyFeedCursorPage
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-inline' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'two-hop schema alias owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasTwo' (New-Ref '#/components/schemas/RAppFamilyFeedCursorPage')
|
||||
Add-NoteProperty $doc.components.schemas 'FamilyFeedAliasOne' (New-Ref '#/components/schemas/FamilyFeedAliasTwo')
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowSchemaAliasFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = New-Ref '#/components/schemas/FamilyFeedAliasOne'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-schema-alias' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'two-hop response alias owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
|
||||
FamilyFeedAliasOne = New-Ref '#/components/responses/FamilyFeedAliasTwo'
|
||||
FamilyFeedAliasTwo = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
})
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowResponseAliasFamilyFeed'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
'200' = New-Ref '#/components/responses/FamilyFeedAliasOne'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-response-alias' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'external alternate response schema'
|
||||
Pattern = 'schema ref is not an inspectable exact local component ref'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowExternalFamilyFeed'
|
||||
$shadow.responses.'200'.content.'application/json'.schema = New-Ref 'https://example.invalid/feed.json#/FeedPage'
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-external' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'default response projection owner'
|
||||
Pattern = 'alternate APP operation exposes the family-feed read projection'
|
||||
Apply = {
|
||||
param($doc)
|
||||
$shadow = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get
|
||||
$shadow.operationId = 'shadowDefaultFamilyFeed'
|
||||
$shadow.responses = [pscustomobject]@{
|
||||
default = New-Response '#/components/schemas/RAppFamilyFeedCursorPage'
|
||||
}
|
||||
Add-NoteProperty $doc.paths '/genealogy/app/family-feed-shadow-default' ([pscustomobject]@{ get = $shadow })
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Response Reference Object sibling'
|
||||
Pattern = 'response ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.components 'responses' ([pscustomobject]@{
|
||||
FeedListSuccess = Copy-Document $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'
|
||||
})
|
||||
$doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200' = [pscustomobject]@{
|
||||
'$ref' = '#/components/responses/FeedListSuccess'
|
||||
description = 'forbidden sibling'
|
||||
}
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Header Reference Object sibling'
|
||||
Pattern = 'header ref must contain only its exact local'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.responses.'200'.headers.'Cache-Control' 'description' 'forbidden sibling'
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'Parameter content side channel'
|
||||
Pattern = 'Parameter Object contains an unowned keyword: content'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get.parameters[1] 'content' ([pscustomobject]@{
|
||||
'application/json' = [pscustomobject]@{
|
||||
schema = New-Ref '#/components/schemas/GenealogyId'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
@{
|
||||
Label = 'unknown operation extension'
|
||||
Pattern = 'operation contains an unowned keyword: x-shadow-owner'
|
||||
Apply = {
|
||||
param($doc)
|
||||
Add-NoteProperty $doc.paths.'/genealogy/app/genealogies/{genealogyId}/feeds'.get 'x-shadow-owner' 'legacy'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($mutation in $mutations) {
|
||||
Assert-MutantRejected $seed $mutation.Label $mutation.Apply $mutation.Pattern
|
||||
}
|
||||
|
||||
Write-Output "FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=$($mutations.Count)"
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Force
|
||||
}
|
||||
}
|
||||
@@ -5,303 +5,24 @@ $jsonPath = Join-Path $root 'APP.openapi.json'
|
||||
$yamlPath = Join-Path $root 'APP.openapi.yaml'
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
|
||||
$helper = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'utils/generation-poem.js')
|
||||
|
||||
function Get-YamlBlock {
|
||||
param(
|
||||
[string]$Header,
|
||||
[string]$NextHeaderPattern
|
||||
)
|
||||
$lines = @($yaml -split "`r?`n")
|
||||
$start = [Array]::IndexOf($lines, $Header)
|
||||
if ($start -lt 0) { throw "YAML block missing: $Header" }
|
||||
$end = $lines.Count
|
||||
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
|
||||
if ($lines[$index] -match $NextHeaderPattern) {
|
||||
$end = $index
|
||||
break
|
||||
}
|
||||
}
|
||||
return ($lines[$start..($end - 1)] -join "`n")
|
||||
}
|
||||
|
||||
function Get-YamlNestedBlock {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$Header,
|
||||
[string]$NextHeaderPattern
|
||||
)
|
||||
$pattern = '(?ms)^' + [regex]::Escape($Header) + "`n(?<body>.*?)(?=$NextHeaderPattern|\z)"
|
||||
$match = [regex]::Match($Content, $pattern)
|
||||
if (-not $match.Success) { throw "YAML nested block missing: $Header" }
|
||||
return $match.Value
|
||||
}
|
||||
|
||||
foreach ($path in @(
|
||||
'/genealogy/app/genealogies',
|
||||
'/genealogy/app/genealogies/{genealogyId}',
|
||||
'/genealogy/app/genealogies/{genealogyId}/join-applies',
|
||||
'/genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'
|
||||
'/genealogy/app/genealogies/{genealogyId}'
|
||||
)) {
|
||||
if (-not $document.paths.PSObject.Properties[$path]) { throw "G-series OpenAPI path missing: $path" }
|
||||
if (-not $yaml.Contains(" $path`:")) { throw "G-series YAML path missing: $path" }
|
||||
}
|
||||
|
||||
# 创建、设置与访问预设从本轮起只由 g03-bootstrap-openapi-contract.ps1 管理.
|
||||
# 旧测试不得继续锁定 GenealogyCreateBody 或 visibility/joinMode,否则会把后端的新单一合同误判为回归.
|
||||
|
||||
$joinApply = $document.components.schemas.GenealogyJoinApplyBody
|
||||
foreach ($field in @('applicantName', 'phone', 'relationDesc', 'applyReason', 'inviterUserId')) {
|
||||
if (-not $joinApply.properties.PSObject.Properties[$field]) {
|
||||
throw "GenealogyJoinApplyBody field missing: $field"
|
||||
if (-not $document.paths.PSObject.Properties[$path]) {
|
||||
throw "G-series OpenAPI path missing: $path"
|
||||
}
|
||||
if (-not $yaml.Contains(" $path`:")) {
|
||||
throw "G-series YAML path missing: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$audit = $document.components.schemas.GenealogyJoinAuditBody
|
||||
if ('status' -notin @($audit.required)) { throw 'GenealogyJoinAuditBody.status must remain required' }
|
||||
foreach ($field in @('status', 'auditRemark')) {
|
||||
if (-not $audit.properties.PSObject.Properties[$field]) {
|
||||
throw "GenealogyJoinAuditBody field missing: $field"
|
||||
}
|
||||
}
|
||||
|
||||
$batch = $document.components.schemas.GenerationPoemBatchBody
|
||||
$batchProperties = @($batch.properties.PSObject.Properties.Name | Sort-Object)
|
||||
if (($batchProperties -join ',') -ne 'disableMissing,poemText') {
|
||||
throw "GenerationPoemBatchBody fields drifted: $($batchProperties -join ',')"
|
||||
}
|
||||
$batchRequired = @($batch.required | Sort-Object)
|
||||
if (($batchRequired -join ',') -ne 'poemText') {
|
||||
throw "GenerationPoemBatchBody required fields drifted: $($batchRequired -join ',')"
|
||||
}
|
||||
if ($batch.properties.poemText.type -ne 'string') { throw 'GenerationPoemBatchBody.poemText must remain a string' }
|
||||
if ($batch.properties.disableMissing.type -ne 'boolean') { throw 'GenerationPoemBatchBody.disableMissing must remain a boolean' }
|
||||
if ($batch.properties.poemText.maxLength -ne 26000) { throw 'GenerationPoemBatchBody.poemText maxLength must be 26000' }
|
||||
$poemDescription = [string]$batch.properties.poemText.description
|
||||
foreach ($fact in @(
|
||||
'\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26',
|
||||
'\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3',
|
||||
'\u7a7a\u683c\u3001\u9017\u53f7\u3001\u5206\u53f7\u3001\u987f\u53f7\u3001\u659c\u6760\u6216\u7ad6\u7ebf'
|
||||
)) {
|
||||
if ($poemDescription -notmatch $fact) { throw "GenerationPoemBatchBody description missing: $fact" }
|
||||
}
|
||||
if ([string]$batch.properties.disableMissing.description -notmatch '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55') {
|
||||
throw 'GenerationPoemBatchBody.disableMissing no longer guarantees history retention'
|
||||
}
|
||||
|
||||
foreach ($path in @(
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview',
|
||||
'/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save'
|
||||
)) {
|
||||
$bodyRef = $document.paths.$path.post.requestBody.content.'application/json'.schema.'$ref'
|
||||
if ($bodyRef -ne '#/components/schemas/GenerationPoemBatchBody') {
|
||||
throw "Batch endpoint request body drifted: $path -> $bodyRef"
|
||||
}
|
||||
}
|
||||
|
||||
$poemView = $document.components.schemas.GenerationPoemView
|
||||
$poemViewFields = @{
|
||||
poemId = @{ Type = 'integer'; Format = 'int64' }
|
||||
generationNo = @{ Type = 'integer'; Format = 'int64' }
|
||||
generationText = @{ Type = 'string'; Format = $null }
|
||||
status = @{ Type = 'string'; Format = $null }
|
||||
}
|
||||
foreach ($field in $poemViewFields.Keys) {
|
||||
$property = $poemView.properties.PSObject.Properties[$field].Value
|
||||
if (-not $property) { throw "GenerationPoemView field missing: $field" }
|
||||
if ($property.type -ne $poemViewFields[$field].Type -or $property.format -ne $poemViewFields[$field].Format) {
|
||||
throw "GenerationPoemView.$field type/format drifted"
|
||||
}
|
||||
}
|
||||
if ([string]$poemView.properties.status.description -notmatch '0\u6b63\u5e38\uff0c1\u505c\u7528') {
|
||||
throw 'GenerationPoemView status 0/1 contract drifted'
|
||||
}
|
||||
|
||||
$normalList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems'.get
|
||||
if ([string]$normalList.description -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or [string]$normalList.description -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001') {
|
||||
throw 'Normal generation-poem list capability or active-only projection drifted'
|
||||
}
|
||||
$managementList = $document.paths.'/genealogy/app/genealogies/{genealogyId}/generation-poems/management'.get
|
||||
if ([string]$managementList.description -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or [string]$managementList.description -notmatch '\u6b63\u5e38\u548c\u505c\u7528') {
|
||||
throw 'Management generation-poem list capability or status projection drifted'
|
||||
}
|
||||
foreach ($operation in @($normalList, $managementList)) {
|
||||
if ($operation.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemListResult') {
|
||||
throw 'Generation-poem list operation no longer returns GenerationPoemListResult'
|
||||
}
|
||||
}
|
||||
if ($document.components.responses.GenerationPoemListResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemList') {
|
||||
throw 'GenerationPoemListResult wrapper drifted'
|
||||
}
|
||||
$poemListData = $document.components.schemas.RGenerationPoemList.properties.data
|
||||
if ($poemListData.type -ne 'array' -or $poemListData.items.'$ref' -ne '#/components/schemas/GenerationPoemView') {
|
||||
throw 'RGenerationPoemList.data no longer exposes GenerationPoemView items'
|
||||
}
|
||||
|
||||
$previewPath = '/genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview'
|
||||
if ($document.paths.$previewPath.post.responses.'200'.'$ref' -ne '#/components/responses/GenerationPoemBatchPreviewResult') {
|
||||
throw 'Generation-poem preview response no longer uses GenerationPoemBatchPreviewResult'
|
||||
}
|
||||
if ($document.components.responses.GenerationPoemBatchPreviewResult.content.'application/json'.schema.'$ref' -ne '#/components/schemas/RGenerationPoemBatchPreview') {
|
||||
throw 'GenerationPoemBatchPreviewResult wrapper drifted'
|
||||
}
|
||||
if ($document.components.schemas.RGenerationPoemBatchPreview.properties.data.'$ref' -ne '#/components/schemas/GenerationPoemBatchPreviewView') {
|
||||
throw 'RGenerationPoemBatchPreview.data no longer exposes the preview view'
|
||||
}
|
||||
$previewView = $document.components.schemas.GenerationPoemBatchPreviewView
|
||||
if ($previewView.properties.items.type -ne 'array' -or $previewView.properties.items.items.'$ref' -ne '#/components/schemas/GenerationPoemBatchItemView') {
|
||||
throw 'GenerationPoemBatchPreviewView.items contract drifted'
|
||||
}
|
||||
$previewItem = $document.components.schemas.GenerationPoemBatchItemView
|
||||
$previewItemExpected = @{
|
||||
poemId = @{ Type = 'integer'; Format = 'int64' }
|
||||
generationNo = @{ Type = 'integer'; Format = 'int64' }
|
||||
oldGenerationText = @{ Type = 'string'; Format = $null }
|
||||
newGenerationText = @{ Type = 'string'; Format = $null }
|
||||
oldStatus = @{ Type = 'string'; Format = $null }
|
||||
newStatus = @{ Type = 'string'; Format = $null }
|
||||
action = @{ Type = 'string'; Format = $null }
|
||||
warning = @{ Type = 'string'; Format = $null }
|
||||
}
|
||||
$previewItemFields = @($previewItem.properties.PSObject.Properties.Name | Sort-Object)
|
||||
if (($previewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
|
||||
throw "GenerationPoemBatchItemView fields drifted: $($previewItemFields -join ',')"
|
||||
}
|
||||
foreach ($field in $previewItemExpected.Keys) {
|
||||
$property = $previewItem.properties.PSObject.Properties[$field].Value
|
||||
if ($property.type -ne $previewItemExpected[$field].Type -or $property.format -ne $previewItemExpected[$field].Format) {
|
||||
throw "GenerationPoemBatchItemView.$field type/format drifted"
|
||||
}
|
||||
}
|
||||
if ([string]$previewItem.properties.action.description -notmatch 'create.*update.*keep.*disable') {
|
||||
throw 'GenerationPoemBatchItemView.action dictionary drifted'
|
||||
}
|
||||
|
||||
$yamlBatch = Get-YamlBlock ' GenerationPoemBatchBody:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlBatch -notmatch '(?m)^ required:\n - poemText\n properties:$') {
|
||||
throw 'YAML GenerationPoemBatchBody required fields drifted'
|
||||
}
|
||||
$yamlBatchFields = @([regex]::Matches($yamlBatch, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
|
||||
if (($yamlBatchFields -join ',') -ne 'disableMissing,poemText') {
|
||||
throw "YAML GenerationPoemBatchBody fields drifted: $($yamlBatchFields -join ',')"
|
||||
}
|
||||
$yamlPoemText = Get-YamlNestedBlock $yamlBatch ' poemText:' '^ [A-Za-z0-9_]+:$'
|
||||
foreach ($pattern in @('(?m)^ type: string$', '(?m)^ maxLength: 26000$', '\u5355\u4e2a\u5b57\u8f88\u6700\u591a50\u4e2a\u5b57\u7b26', '\u4e00\u6b21\u6700\u591a500\u4e2a\u4e16\u4ee3')) {
|
||||
if ($yamlPoemText -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.poemText drifted: $pattern" }
|
||||
}
|
||||
$yamlDisableMissing = Get-YamlNestedBlock $yamlBatch ' disableMissing:' '^ [A-Za-z0-9_]+:$'
|
||||
foreach ($pattern in @('(?m)^ type: boolean$', '\u4e0d\u4f1a\u5220\u9664\u5386\u53f2\u8bb0\u5f55')) {
|
||||
if ($yamlDisableMissing -notmatch $pattern) { throw "YAML GenerationPoemBatchBody.disableMissing drifted: $pattern" }
|
||||
}
|
||||
|
||||
$yamlPoemView = Get-YamlBlock ' GenerationPoemView:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlPoemViewExpected = @{
|
||||
poemId = @{ Type = 'integer'; Format = 'int64' }
|
||||
generationNo = @{ Type = 'integer'; Format = 'int64' }
|
||||
generationText = @{ Type = 'string'; Format = $null }
|
||||
status = @{ Type = 'string'; Format = $null }
|
||||
}
|
||||
foreach ($field in $yamlPoemViewExpected.Keys) {
|
||||
$fieldBlock = Get-YamlNestedBlock $yamlPoemView " $field`:" '^ [A-Za-z0-9_]+:$'
|
||||
if ($fieldBlock -notmatch "(?m)^ type: $($yamlPoemViewExpected[$field].Type)$") {
|
||||
throw "YAML GenerationPoemView.$field type drifted"
|
||||
}
|
||||
$format = $yamlPoemViewExpected[$field].Format
|
||||
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
|
||||
throw "YAML GenerationPoemView.$field format drifted"
|
||||
}
|
||||
}
|
||||
$yamlStatus = Get-YamlNestedBlock $yamlPoemView ' status:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlStatus -notmatch 'description:.*0\u6b63\u5e38\uff0c1\u505c\u7528' -or
|
||||
$yamlStatus -notmatch '(?m)^ type: string$') {
|
||||
throw 'YAML GenerationPoemView.status contract drifted'
|
||||
}
|
||||
|
||||
$yamlPreviewPath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/preview:' '^ /.*:$'
|
||||
if ($yamlPreviewPath -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemBatchPreviewResult'")) {
|
||||
throw 'YAML generation-poem preview response drifted'
|
||||
}
|
||||
$yamlPreviewResponse = Get-YamlBlock ' GenerationPoemBatchPreviewResult:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPreviewResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemBatchPreview'")) {
|
||||
throw 'YAML GenerationPoemBatchPreviewResult wrapper drifted'
|
||||
}
|
||||
$yamlPreviewWrapper = Get-YamlBlock ' RGenerationPoemBatchPreview:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPreviewWrapper -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchPreviewView'")) {
|
||||
throw 'YAML RGenerationPoemBatchPreview.data drifted'
|
||||
}
|
||||
$yamlPreviewView = Get-YamlBlock ' GenerationPoemBatchPreviewView:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlPreviewItems = Get-YamlNestedBlock $yamlPreviewView ' items:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPreviewItems -notmatch '(?m)^ type: array$' -or $yamlPreviewItems -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchItemView'")) {
|
||||
throw 'YAML GenerationPoemBatchPreviewView.items drifted'
|
||||
}
|
||||
$yamlPreviewItem = Get-YamlBlock ' GenerationPoemBatchItemView:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlPreviewItemFields = @([regex]::Matches($yamlPreviewItem, '(?m)^ (?<field>[A-Za-z][A-Za-z0-9]*):$') | ForEach-Object { $_.Groups['field'].Value } | Sort-Object)
|
||||
if (($yamlPreviewItemFields -join ',') -ne (@($previewItemExpected.Keys | Sort-Object) -join ',')) {
|
||||
throw "YAML GenerationPoemBatchItemView fields drifted: $($yamlPreviewItemFields -join ',')"
|
||||
}
|
||||
foreach ($field in $previewItemExpected.Keys) {
|
||||
$fieldBlock = Get-YamlNestedBlock $yamlPreviewItem " $field`:" '^ [A-Za-z0-9_]+:$'
|
||||
if ($fieldBlock -notmatch "(?m)^ type: $($previewItemExpected[$field].Type)$") {
|
||||
throw "YAML GenerationPoemBatchItemView.$field type drifted"
|
||||
}
|
||||
$format = $previewItemExpected[$field].Format
|
||||
if (($format -and $fieldBlock -notmatch "(?m)^ format: $format$") -or (-not $format -and $fieldBlock -match '(?m)^ format:')) {
|
||||
throw "YAML GenerationPoemBatchItemView.$field format drifted"
|
||||
}
|
||||
}
|
||||
$yamlPreviewAction = Get-YamlNestedBlock $yamlPreviewItem ' action:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPreviewAction -notmatch 'create.*update.*keep.*disable') {
|
||||
throw 'YAML GenerationPoemBatchItemView.action dictionary drifted'
|
||||
}
|
||||
|
||||
$yamlNormalList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems:' '^ /.*:$'
|
||||
$yamlNormalGet = Get-YamlNestedBlock $yamlNormalList ' get:' '^ [a-z]+:$'
|
||||
if ($yamlNormalGet -notmatch '\u53ef\u67e5\u770b\u5bb6\u8c31' -or $yamlNormalGet -notmatch '\u4ec5\u8fd4\u56de\u6b63\u5e38\u72b6\u6001' -or
|
||||
$yamlNormalGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
|
||||
throw 'YAML normal generation-poem capability or projection drifted'
|
||||
}
|
||||
$yamlManagementList = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/management:' '^ /.*:$'
|
||||
$yamlManagementGet = Get-YamlNestedBlock $yamlManagementList ' get:' '^ [a-z]+:$'
|
||||
if ($yamlManagementGet -notmatch '\u5185\u5bb9\u7f16\u8f91\u8005' -or $yamlManagementGet -notmatch '\u6b63\u5e38\u548c\u505c\u7528' -or
|
||||
$yamlManagementGet -notmatch [regex]::Escape("`$ref: '#/components/responses/GenerationPoemListResult'")) {
|
||||
throw 'YAML management generation-poem capability or projection drifted'
|
||||
}
|
||||
$yamlPoemListResponse = Get-YamlBlock ' GenerationPoemListResult:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPoemListResponse -notmatch [regex]::Escape("`$ref: '#/components/schemas/RGenerationPoemList'")) {
|
||||
throw 'YAML GenerationPoemListResult wrapper drifted'
|
||||
}
|
||||
$yamlPoemListWrapper = Get-YamlBlock ' RGenerationPoemList:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlPoemListData = Get-YamlNestedBlock $yamlPoemListWrapper ' data:' '^ [A-Za-z0-9_]+:$'
|
||||
if ($yamlPoemListData -notmatch '(?m)^ type: array$' -or
|
||||
$yamlPoemListData -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemView'")) {
|
||||
throw 'YAML RGenerationPoemList.data drifted'
|
||||
}
|
||||
$yamlPreviewPost = Get-YamlNestedBlock $yamlPreviewPath ' post:' '^ [a-z]+:$'
|
||||
if ($yamlPreviewPost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
|
||||
throw 'YAML generation-poem preview request body drifted'
|
||||
}
|
||||
$yamlSavePath = Get-YamlBlock ' /genealogy/app/genealogies/{genealogyId}/generation-poems/batch/save:' '^ /.*:$'
|
||||
$yamlSavePost = Get-YamlNestedBlock $yamlSavePath ' post:' '^ [a-z]+:$'
|
||||
if ($yamlSavePost -notmatch [regex]::Escape("`$ref: '#/components/schemas/GenerationPoemBatchBody'")) {
|
||||
throw 'YAML generation-poem save request body drifted'
|
||||
}
|
||||
|
||||
foreach ($constant in @(
|
||||
'MAX_GENERATION_COUNT = 500',
|
||||
'MAX_GENERATION_TEXT_LENGTH = 50',
|
||||
'MAX_GENERATION_POEM_INPUT_LENGTH = 26000',
|
||||
"ACTIVE: '0'",
|
||||
"DISABLED: '1'"
|
||||
)) {
|
||||
if (-not $helper.Contains($constant)) { throw "Generation poem helper is not synchronized with OpenAPI: $constant" }
|
||||
}
|
||||
|
||||
foreach ($schemaName in @('GenealogyJoinApplyBody', 'GenealogyJoinAuditBody', 'GenerationPoemBatchBody', 'GenerationPoemView')) {
|
||||
if (-not $yaml.Contains(" $schemaName`:")) { throw "G-series YAML schema missing: $schemaName" }
|
||||
}
|
||||
# G03 原子创建、共享访问预设与旧 DTO 删除只由 g03-bootstrap-openapi-contract.ps1 管理。
|
||||
# G11 设置 PUT、dirty-only body、版本 CAS、响应与错误只由 g11-settings-openapi-contract.ps1 管理。
|
||||
# 普通加入申请只由 join-application-openapi-contract.ps1 管理;邀请码直入只由 invite-ticket-openapi-contract.ps1 管理。
|
||||
# G12 字辈聚合读写、词法行身份、版本 CAS、候选集合与旧批量入口删除只由
|
||||
# g12-generation-poem-openapi-contract.ps1 管理。本通用门禁不得重新锁定 poemText、0/1 状态、
|
||||
# integer/int64 poemId、preview、batch/save、management 或逐行写入口,避免形成相反合同 owner。
|
||||
|
||||
Write-Output 'G-SERIES-OPENAPI-CONTRACT PASS'
|
||||
|
||||
@@ -4,7 +4,7 @@ $page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g01-my-geneal
|
||||
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
|
||||
$api = Get-Content -LiteralPath (Join-Path $root 'utils/api.js') -Raw -Encoding UTF8
|
||||
$context = Get-Content -LiteralPath (Join-Path $root 'utils/genealogy-context.js') -Raw -Encoding UTF8
|
||||
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'getGenealogyFixtureAccess(item.id).accessRole === "owner"', 'getGenealogyFixtureAccess(item.id).accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=')) {
|
||||
foreach ($token in @('genealogyContext', 'setCurrentGenealogyId', 'reconcileCurrentGenealogyId', 'invalidateCurrentGenealogyId', 'isCurrentGenealogyInvalidated', 'reconcilePageGenealogyContext', 'item.accessRole === "owner"', 'item.accessRole === "member"', 'String(query?.genealogyId || "")', 'contextInvalidated', 'state-panel--context', ') || null,', '<button', ':aria-pressed=', 'appApi.getMyGenealogies', 'createRequestController', 'listRequestController.abort()', 'onUnload')) {
|
||||
if (-not $page.Contains($token)) { throw "G01 context contract missing: $token" }
|
||||
}
|
||||
foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId', 'getCurrentGenealogyId: readCurrentGenealogyId', 'invalidateCurrentGenealogyId:', 'isCurrentGenealogyInvalidated:', 'new Set(normalizedIds).size !== normalizedIds.length', 'normalizedIds.includes(normalizedPreferredId)', 'normalizedIds.includes(storedId)', 'if (normalizedPreferredId)', 'if (storedId)')) {
|
||||
@@ -12,7 +12,8 @@ foreach ($token in @('CURRENT_GENEALOGY_INVALIDATED_KEY', 'normalizeGenealogyId'
|
||||
}
|
||||
if ($page.Contains('Number(query?.genealogyId)')) { throw 'G01 must not coerce a genealogy ID to Number' }
|
||||
if ($page -match 'currentGenealogy\s*=\s*computed\([\s\S]*?\|\|\s*availableGenealogies\.value\[0\]') { throw 'G01 must not silently fall back to the first genealogy after permission loss' }
|
||||
if ($page -notmatch '(?s)const retryLoad = \(\) => \{.*?reconcilePageGenealogyContext\(\)') { throw 'G01 context failure retry must rerun reconciliation instead of exposing a dead button' }
|
||||
if ($page -notmatch '(?s)const retryLoad = \(\) => \{\s*loadGenealogies\(\)') { throw 'G01 retry must rerun the remote list owner instead of exposing a dead button' }
|
||||
if ($page -match '@/data/mock\.js|getGenealogyFixtureAccess|listNotificationFixtures') { throw 'G01 remote page must not retain fixture data owners' }
|
||||
foreach ($membership in @("membership: 'created'", "membership: 'joined'")) {
|
||||
if (-not $mock.Contains($membership)) { throw "Genealogy mock ownership missing: $membership" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = {
|
||||
mode: "remote",
|
||||
baseUrl: "https://backend-api.ddxcjp.cn",
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
};
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({ MEMBER_ONLY: "MEMBER_ONLY" });
|
||||
const fromApiGenealogyAccess = () => null;
|
||||
const session = {
|
||||
getToken: () => "session-1",
|
||||
saveToken() {},
|
||||
};
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
let nextResponse;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(nextResponse));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 200,
|
||||
msg: "操作成功",
|
||||
data: [
|
||||
{
|
||||
genealogyId: 900001001,
|
||||
genealogyName: "汤氏家谱",
|
||||
surname: "汤",
|
||||
ancestralHall: "敦睦堂",
|
||||
regionFullName: "河南省洛阳市",
|
||||
memberCount: 158,
|
||||
roleType: "OWNER",
|
||||
canManage: true,
|
||||
canEditContent: true,
|
||||
},
|
||||
{
|
||||
genealogyId: 900001002,
|
||||
genealogyName: "汤氏宗谱",
|
||||
regionName: "山东省济宁市",
|
||||
memberCount: 286,
|
||||
roleType: "MEMBER",
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appApi.getMyGenealogies();
|
||||
assert.deepStrictEqual(result, [
|
||||
{
|
||||
id: "900001001",
|
||||
name: "汤氏家谱",
|
||||
surname: "汤",
|
||||
hall: "敦睦堂",
|
||||
location: "河南省洛阳市",
|
||||
memberCount: 158,
|
||||
accessRole: "owner",
|
||||
canManage: true,
|
||||
canEditContent: true,
|
||||
},
|
||||
{
|
||||
id: "900001002",
|
||||
name: "汤氏宗谱",
|
||||
surname: "",
|
||||
hall: "",
|
||||
location: "山东省济宁市",
|
||||
memberCount: 286,
|
||||
accessRole: "member",
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
},
|
||||
]);
|
||||
assert.strictEqual(requests.length, 1);
|
||||
assert.strictEqual(requests[0].url, "https://backend-api.ddxcjp.cn/genealogy/app/genealogies/mine");
|
||||
assert.strictEqual(requests[0].method, "GET");
|
||||
assert.strictEqual(requests[0].header.clientid, "client-1");
|
||||
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
|
||||
assert.strictEqual(requests[0].timeout, 15000);
|
||||
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 200,
|
||||
data: [{
|
||||
genealogyId: 900001003,
|
||||
genealogyName: "只读家谱",
|
||||
memberCount: 1,
|
||||
roleType: "OWNER",
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const explicitCapabilities = await appApi.getMyGenealogies();
|
||||
assert.strictEqual(explicitCapabilities[0].accessRole, "member");
|
||||
assert.strictEqual(explicitCapabilities[0].canEditContent, false);
|
||||
|
||||
for (const invalidItem of [
|
||||
{
|
||||
genealogyId: 900001004,
|
||||
genealogyName: "缺少权限",
|
||||
memberCount: 1,
|
||||
canEditContent: false,
|
||||
},
|
||||
{
|
||||
genealogyId: 900001005,
|
||||
genealogyName: "错用人物数",
|
||||
personCount: 9,
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
},
|
||||
]) {
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: { code: 200, data: [invalidItem] },
|
||||
};
|
||||
await assert.rejects(
|
||||
appApi.getMyGenealogies(),
|
||||
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
|
||||
);
|
||||
}
|
||||
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 200,
|
||||
data: [
|
||||
{
|
||||
genealogyId: 900001006,
|
||||
genealogyName: "重复一",
|
||||
memberCount: 1,
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
},
|
||||
{
|
||||
genealogyId: 900001006,
|
||||
genealogyName: "重复二",
|
||||
memberCount: 2,
|
||||
canManage: false,
|
||||
canEditContent: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
appApi.getMyGenealogies(),
|
||||
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
|
||||
);
|
||||
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 200,
|
||||
data: [{ genealogyId: 9007199254740992, genealogyName: "失真家谱" }],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
appApi.getMyGenealogies(),
|
||||
(error) => error?.code === "GENEALOGY_RESPONSE_INVALID",
|
||||
);
|
||||
|
||||
process.stdout.write("G01-MY-GENEALOGIES-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ function Assert-Contains([string]$Content, [string]$Pattern, [string]$Message) {
|
||||
|
||||
Assert-Contains $page 'v-for="item in visibleShortcuts"' 'G01 must render the membership-filtered shortcut list'
|
||||
Assert-Contains $page '{{ currentRoleLabel }}' 'G01 must render the membership-derived role label'
|
||||
Assert-Contains $page 'getGenealogyFixtureAccess(currentGenealogy.value?.id).accessRole' 'G01 must consume the shared fixture access resolver'
|
||||
Assert-Contains $page 'currentGenealogy.value?.accessRole === "owner"' 'G01 must consume the normalized remote role'
|
||||
Assert-Contains $page 'item.key !== "applications"' 'G01 must remove application review for joined members'
|
||||
$selectedBinding = ':selected="item.id === currentGenealogy.id"'
|
||||
if (([regex]::Matches($page, [regex]::Escape($selectedBinding))).Count -ne 2) {
|
||||
|
||||
@@ -7,7 +7,6 @@ $issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$createPath = '/genealogy/app/genealogies'
|
||||
$statusPath = '/genealogy/app/genealogy-bootstrap-operations/{operationKey}'
|
||||
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
|
||||
$regionSearchPath = '/genealogy/app/region/search'
|
||||
$personPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons'
|
||||
$personDetailPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}'
|
||||
@@ -30,6 +29,35 @@ function Test-IsJsonBoolean {
|
||||
return $Value -is [System.Boolean] -and $Value -eq $Expected
|
||||
}
|
||||
|
||||
function Test-IsNonNullable {
|
||||
param([object]$Schema)
|
||||
if (-not $Schema) { return $false }
|
||||
$nullable = $Schema.PSObject.Properties['nullable']
|
||||
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
|
||||
}
|
||||
|
||||
function Assert-AllowedSchemaKeywords {
|
||||
param([object]$Schema, [string]$Label, [string[]]$Allowed)
|
||||
if (-not $Schema) { return }
|
||||
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
|
||||
foreach ($property in @($Schema.PSObject.Properties)) {
|
||||
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
|
||||
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsPureSchemaRef {
|
||||
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
|
||||
if (-not $Schema) { return $false }
|
||||
$properties = @($Schema.PSObject.Properties.Name)
|
||||
$actualRef = [string]$Schema.'$ref'
|
||||
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
||||
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
||||
$parityOutput = @(& node $parityScript 2>&1)
|
||||
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
|
||||
@@ -180,8 +208,8 @@ function Assert-RequestBodyRef {
|
||||
$media = $Operation.requestBody.content.PSObject.Properties['application/json']
|
||||
if (-not (Test-IsJsonBoolean $Operation.requestBody.required $true) -or -not $media) {
|
||||
Add-Issue "JSON $Label must require an application/json body"
|
||||
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
|
||||
Add-Issue "JSON $Label request body must use $ExpectedRef"
|
||||
} else {
|
||||
[void](Test-IsPureSchemaRef $media.Value.schema $ExpectedRef "$Label request body")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +233,8 @@ function Assert-ExactObject {
|
||||
} elseif ($requiredProperty) {
|
||||
Test-IsJsonArray $Schema.required
|
||||
} else { $true }
|
||||
if ($Schema.type -ne 'object' -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
||||
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
|
||||
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
||||
-not $requiredShapeValid -or
|
||||
($actualFields -join ',') -ne ($expectedFields -join ',') -or
|
||||
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
|
||||
@@ -217,16 +246,18 @@ function Assert-ExactObject {
|
||||
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
|
||||
Add-Issue "JSON $Label maxProperties must be $MaxProperties"
|
||||
}
|
||||
$allowedKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
||||
if ($MinProperties -ge 0) { $allowedKeywords += 'minProperties' }
|
||||
if ($MaxProperties -ge 0) { $allowedKeywords += 'maxProperties' }
|
||||
Assert-AllowedSchemaKeywords $Schema $Label $allowedKeywords
|
||||
}
|
||||
|
||||
function Assert-PropertyRef {
|
||||
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
||||
if (-not $Schema) { return }
|
||||
$property = $Schema.properties.PSObject.Properties[$Field]
|
||||
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
|
||||
if ($actual -ne $ExpectedRef) {
|
||||
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
|
||||
}
|
||||
if (-not $property) { Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef"; return }
|
||||
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
||||
}
|
||||
|
||||
function Assert-ErrorSchema {
|
||||
@@ -364,7 +395,6 @@ function Assert-AllDeclaredResponsesPrivateJson {
|
||||
|
||||
$createOperation = Get-Operation $createPath 'post'
|
||||
$statusOperation = Get-Operation $statusPath 'get'
|
||||
$settingsOperation = Get-Operation $settingsPath 'put'
|
||||
$regionOperation = Get-Operation $regionSearchPath 'get'
|
||||
$personOperation = Get-Operation $personPath 'post'
|
||||
$personUpdateOperation = Get-Operation $personDetailPath 'put'
|
||||
@@ -379,7 +409,6 @@ if ($document.paths.PSObject.Properties['/genealogy/region/search']) {
|
||||
foreach ($entry in @(
|
||||
[pscustomobject]@{ Operation = $createOperation; Label = "POST $createPath"; Path = $createPath },
|
||||
[pscustomobject]@{ Operation = $statusOperation; Label = "GET $statusPath"; Path = $statusPath },
|
||||
[pscustomobject]@{ Operation = $settingsOperation; Label = "PUT $settingsPath"; Path = $settingsPath },
|
||||
[pscustomobject]@{ Operation = $regionOperation; Label = "GET $regionSearchPath"; Path = $regionSearchPath },
|
||||
[pscustomobject]@{ Operation = $personOperation; Label = "POST $personPath"; Path = $personPath },
|
||||
[pscustomobject]@{ Operation = $personUpdateOperation; Label = "PUT $personDetailPath"; Path = $personDetailPath },
|
||||
@@ -391,7 +420,6 @@ foreach ($entry in @(
|
||||
}
|
||||
|
||||
Assert-RequestBodyRef $createOperation "POST $createPath" '#/components/schemas/AppGenealogyBootstrapBody'
|
||||
Assert-RequestBodyRef $settingsOperation "PUT $settingsPath" '#/components/schemas/AppGenealogySettingsUpdateBody'
|
||||
|
||||
$idempotencyHeader = Get-Parameter $createOperation 'Idempotency-Key' 'header' $createPath
|
||||
if ($idempotencyHeader) {
|
||||
@@ -556,7 +584,6 @@ $rootPersonBody = Get-Schema 'AppGenealogyRootPersonBody'
|
||||
$operationKey = Get-Schema 'GenealogyBootstrapOperationKey'
|
||||
$regionCode = Get-Schema 'GenealogyRegionCode'
|
||||
$accessPreset = Get-Schema 'GenealogyAccessPreset'
|
||||
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
|
||||
$bootstrapResult = Get-Schema 'GenealogyBootstrapResult'
|
||||
$operationStatus = Get-Schema 'GenealogyBootstrapOperationStatus'
|
||||
$pendingStatus = Get-Schema 'GenealogyBootstrapPendingStatus'
|
||||
@@ -573,7 +600,7 @@ Assert-ExactObject $bootstrapBody 'AppGenealogyBootstrapBody' @(
|
||||
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
||||
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'rootPerson' '#/components/schemas/AppGenealogyRootPersonBody'
|
||||
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'regionCode' '#/components/schemas/GenealogyRegionCode'
|
||||
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' 1 24
|
||||
Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' '#/components/schemas/GenealogyName'
|
||||
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'surname' 1 4
|
||||
Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'ancestralHall' 1 12
|
||||
|
||||
@@ -631,17 +658,20 @@ if ($regionCode) {
|
||||
if ($accessPreset) {
|
||||
$values = @($accessPreset.enum | Sort-Object)
|
||||
$expected = @('MEMBER_ONLY', 'PUBLIC_APPLY') | Sort-Object
|
||||
$nullable = $accessPreset.PSObject.Properties['nullable']
|
||||
if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or
|
||||
($values -join ',') -ne ($expected -join ',')) {
|
||||
Add-Issue 'JSON GenealogyAccessPreset must contain only MEMBER_ONLY/PUBLIC_APPLY'
|
||||
($values -join ',') -ne ($expected -join ',') -or
|
||||
($nullable -and -not (Test-IsJsonBoolean $nullable.Value $false))) {
|
||||
Add-Issue 'JSON GenealogyAccessPreset must be non-null and contain only MEMBER_ONLY/PUBLIC_APPLY'
|
||||
}
|
||||
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const')) {
|
||||
if ($accessPreset.PSObject.Properties[$keyword]) {
|
||||
Add-Issue "JSON GenealogyAccessPreset must not define conflicting schema keyword: $keyword"
|
||||
}
|
||||
}
|
||||
Assert-AllowedSchemaKeywords $accessPreset 'GenealogyAccessPreset' @('type', 'enum', 'nullable')
|
||||
}
|
||||
|
||||
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
|
||||
'genealogyName', 'intro', 'accessPreset'
|
||||
) @() 1 3
|
||||
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
||||
|
||||
foreach ($legacySchema in @('GenealogyCreateBody', 'AppGenealogyCreateBody', 'GenealogyUpdateBody', 'AppGenealogyUpdateBody')) {
|
||||
if ($document.components.schemas.PSObject.Properties[$legacySchema]) {
|
||||
Add-Issue "JSON legacy schema must be removed in the same migration: $legacySchema"
|
||||
@@ -673,9 +703,7 @@ foreach ($schemaProperty in $document.components.schemas.PSObject.Properties) {
|
||||
}
|
||||
}
|
||||
foreach ($presetDefinition in @(Get-ComposedPropertyDefinitions $schemaProperty.Value 'accessPreset' @{})) {
|
||||
if ([string]$presetDefinition.'$ref' -ne '#/components/schemas/GenealogyAccessPreset') {
|
||||
Add-Issue "JSON $($schemaProperty.Name).accessPreset must reference GenealogyAccessPreset"
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $presetDefinition '#/components/schemas/GenealogyAccessPreset' "$($schemaProperty.Name).accessPreset")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +995,6 @@ foreach ($status in $statusErrorRefs.Keys) {
|
||||
Assert-RetryAfter $statusResponses['404'] "GET $statusPath" '404'
|
||||
Assert-RetryAfter $statusResponses['429'] "GET $statusPath" '429'
|
||||
|
||||
Assert-AllDeclaredResponsesPrivateJson $settingsOperation "PUT $settingsPath"
|
||||
Assert-AllDeclaredResponsesPrivateJson $personOperation "POST $personPath"
|
||||
Assert-AllDeclaredResponsesPrivateJson $personUpdateOperation "PUT $personDetailPath"
|
||||
Assert-AllDeclaredResponsesPrivateJson $personDeleteOperation "DELETE $personDetailPath"
|
||||
|
||||
@@ -2,12 +2,9 @@ $ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$page = Get-Content -LiteralPath (Join-Path $root 'pages/genealogy/g05-genealogy-overview.vue') -Raw -Encoding UTF8
|
||||
$mock = Get-Content -LiteralPath (Join-Path $root 'data/mock.js') -Raw -Encoding UTF8
|
||||
foreach ($token in @('findGenealogyFixture', 'getGenealogyFixtureAccess', 'getGenealogyAccessPresetLabel', 'access.canView', 'ancestorName', 'updatedAt')) {
|
||||
foreach ($token in @('appApi.getOverview', 'createRequestController', 'isRequestCancelled', 'getGenealogyAccessPresetLabel', 'result.accessRole', 'overviewRequestController.abort()', 'onUnload')) {
|
||||
if (-not $page.Contains($token)) { throw "G05 data ownership contract missing: $token" }
|
||||
}
|
||||
if ($page -notmatch '(?s)const access = getGenealogyFixtureAccess\(genealogyId\.value\);\s*if \(!access\.canView\) \{\s*overviewState\.value = "no-permission";\s*return;\s*\}') {
|
||||
throw 'G05 must fail closed before exposing a fixture that the current role cannot view'
|
||||
}
|
||||
foreach ($token in @('export const publicGenealogies', 'export const createLocalGenealogyPreview', 'export const updateLocalGenealogyPreviewAncestor', 'export const findGenealogyFixture', 'export const getGenealogyFixtureAccess', 'fixture?.localPreview', "memberFixture?.membership === 'created'", "memberFixture?.membership === 'joined'")) {
|
||||
if (-not $mock.Contains($token)) { throw "Shared genealogy fixture ownership missing: $token" }
|
||||
}
|
||||
@@ -15,6 +12,7 @@ foreach ($forbidden in @('overviewFixtures', 'query.genealogyName', 'query.role'
|
||||
if ($page.Contains($forbidden)) { throw "G05 retains duplicate or untrusted data owner: $forbidden" }
|
||||
}
|
||||
if ($page.Contains('getGenealogyVisibilityLabel') -or $page.Contains('genealogy.visibility')) { throw 'G05 retains the deleted visibility-only contract' }
|
||||
if ($page -match '@/data/mock\.js|findGenealogyFixture|getGenealogyFixtureAccess') { throw 'G05 remote overview must not retain fixture owners' }
|
||||
if ($mock -match "(?s)const localCreatedGenealogy = \{.*?membership:\s*'created'") { throw 'Local-created URL fixture must never grant owner membership' }
|
||||
if ($page.Contains('class="overview-action-lock"')) { throw 'G05 ordinary members must not see inert management lock cards' }
|
||||
Write-Output 'G05-DATA-OWNERSHIP-CONTRACT PASS'
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const toDataModuleUrl = (source) =>
|
||||
`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
|
||||
const run = async () => {
|
||||
const rawSource = fs.readFileSync(path.join(__dirname, "../utils/api.js"), "utf8");
|
||||
const moduleBody = rawSource.slice(rawSource.indexOf("const successCodes"));
|
||||
const prelude = `
|
||||
const currentUser = {};
|
||||
const genealogies = [];
|
||||
const publicGenealogies = [];
|
||||
const treeMembers = [];
|
||||
const notifications = [];
|
||||
const joinApplications = [];
|
||||
const listFamilyFeedFixtures = () => [];
|
||||
const listFamilyArticleFixtures = () => [];
|
||||
const listFamilyAlbumFixtures = () => [];
|
||||
const listCeremonyFixtures = () => [];
|
||||
const listGrowthRecordFixtures = () => [];
|
||||
const listNotificationFixtures = () => [];
|
||||
const runtimeConfig = {
|
||||
mode: "remote",
|
||||
baseUrl: "https://backend-api.ddxcjp.cn",
|
||||
clientId: "client-1",
|
||||
tenantId: "000000",
|
||||
};
|
||||
const hasRemoteConfig = () => true;
|
||||
const resolveRuntimeMode = () => "remote";
|
||||
const AUTH_TAC_SCENE = Object.freeze({});
|
||||
const assertSmsCode = (value) => value;
|
||||
const GENEALOGY_ACCESS_PRESET = Object.freeze({
|
||||
MEMBER_ONLY: "MEMBER_ONLY",
|
||||
PUBLIC_APPLY: "PUBLIC_APPLY",
|
||||
});
|
||||
const fromApiGenealogyAccess = ({ visibility, joinMode } = {}) =>
|
||||
visibility === "1" && joinMode === "1"
|
||||
? "PUBLIC_APPLY"
|
||||
: visibility === "2" && joinMode === "0"
|
||||
? "MEMBER_ONLY"
|
||||
: null;
|
||||
const session = {
|
||||
getToken: () => "session-1",
|
||||
saveToken() {},
|
||||
};
|
||||
`;
|
||||
|
||||
const requests = [];
|
||||
let nextResponse;
|
||||
globalThis.uni = {
|
||||
request(options) {
|
||||
requests.push(options);
|
||||
queueMicrotask(() => options.success(nextResponse));
|
||||
return { abort() {} };
|
||||
},
|
||||
};
|
||||
const { appApi } = await import(toDataModuleUrl(`${prelude}\n${moduleBody}`));
|
||||
|
||||
nextResponse = {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
code: 200,
|
||||
data: {
|
||||
genealogyId: 900001001,
|
||||
genealogyName: "汤氏家谱",
|
||||
surname: "汤",
|
||||
ancestralHall: "敦睦堂",
|
||||
regionFullName: "河南省洛阳市",
|
||||
intro: "敦亲睦族,敬祖传家。",
|
||||
visibility: "1",
|
||||
joinMode: "1",
|
||||
memberCount: 158,
|
||||
personCount: 146,
|
||||
roleType: "OWNER",
|
||||
canManage: true,
|
||||
canEditContent: true,
|
||||
joinTime: "2026-07-23T08:00:00+08:00",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await appApi.getOverview("900001001");
|
||||
assert.deepStrictEqual(result, {
|
||||
id: "900001001",
|
||||
name: "汤氏家谱",
|
||||
surname: "汤",
|
||||
hall: "敦睦堂",
|
||||
location: "河南省洛阳市",
|
||||
memberCount: 158,
|
||||
personCount: 146,
|
||||
accessPreset: "PUBLIC_APPLY",
|
||||
accessRole: "owner",
|
||||
canManage: true,
|
||||
canEditContent: true,
|
||||
intro: "敦亲睦族,敬祖传家。",
|
||||
joinTime: "2026-07-23T08:00:00+08:00",
|
||||
});
|
||||
assert.strictEqual(
|
||||
requests[0].url,
|
||||
"https://backend-api.ddxcjp.cn/genealogy/app/genealogies/900001001/overview",
|
||||
);
|
||||
assert.strictEqual(requests[0].header.Authorization, "Bearer session-1");
|
||||
assert.strictEqual(requests[0].timeout, 15000);
|
||||
|
||||
await assert.rejects(
|
||||
appApi.getOverview("../other"),
|
||||
(error) => error?.code === "GENEALOGY_ID_INVALID",
|
||||
);
|
||||
|
||||
process.stdout.write("G05-OVERVIEW-API-RUNTIME-SMOKE PASS\n");
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(`${error.stack || error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -25,7 +25,9 @@ foreach ($required in @(
|
||||
"query.genealogyId",
|
||||
'const viewMode = ref("member")',
|
||||
'const accessRole = ref("guest")',
|
||||
'findGenealogyFixture',
|
||||
'appApi.getOverview',
|
||||
'createRequestController',
|
||||
'isRequestCancelled',
|
||||
'overview-ready',
|
||||
'overview-public',
|
||||
'overview-state--empty',
|
||||
@@ -36,7 +38,7 @@ foreach ($required in @(
|
||||
Assert-Contains $g05 $required "Missing G05 overview contract: $required"
|
||||
}
|
||||
Assert-Contains $profiles 'g05-overview-surface.png' 'Adaptive G05 surface profile must own the overview asset'
|
||||
foreach ($forbidden in @("from '@/utils/api.js'", 'appApi.', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
foreach ($forbidden in @("@/data/mock.js", 'findGenealogyFixture', 'getGenealogyFixtureAccess', 'uni.showToast', 'uni.showModal', 'uni.showLoading', 'uni.showActionSheet')) {
|
||||
if ($g05 -match [regex]::Escape($forbidden)) { throw "G05 retains forbidden implementation: $forbidden" }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$jsonPath = Join-Path $root 'APP.openapi.json'
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
|
||||
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
||||
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
||||
$genealogyNamePattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\n\u0000-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
|
||||
$genealogyIntroPattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
}
|
||||
|
||||
function Test-IsJsonArray {
|
||||
param([object]$Value)
|
||||
return $null -ne $Value -and $Value.GetType().IsArray
|
||||
}
|
||||
|
||||
function Test-IsJsonBoolean {
|
||||
param([object]$Value, [bool]$Expected)
|
||||
return $Value -is [System.Boolean] -and $Value -eq $Expected
|
||||
}
|
||||
|
||||
function Test-IsNonNullable {
|
||||
param([object]$Schema)
|
||||
if (-not $Schema) { return $false }
|
||||
$nullable = $Schema.PSObject.Properties['nullable']
|
||||
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
|
||||
}
|
||||
|
||||
function Assert-NoConflictingSchemaKeywords {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$Label,
|
||||
[string[]]$Allowed = @()
|
||||
)
|
||||
if (-not $Schema) { return }
|
||||
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
|
||||
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
|
||||
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-AllowedSchemaKeywords {
|
||||
param([object]$Schema, [string]$Label, [string[]]$Allowed)
|
||||
if (-not $Schema) { return }
|
||||
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
|
||||
foreach ($property in @($Schema.PSObject.Properties)) {
|
||||
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
|
||||
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsPureSchemaRef {
|
||||
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
|
||||
if (-not $Schema) { return $false }
|
||||
$properties = @($Schema.PSObject.Properties.Name)
|
||||
$actualRef = [string]$Schema.'$ref'
|
||||
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
||||
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
|
||||
return $false
|
||||
}
|
||||
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-LocalComponentName {
|
||||
param([string]$Ref, [string]$Section, [string]$Label)
|
||||
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
|
||||
$match = [regex]::Match($Ref, $pattern)
|
||||
if (-not $match.Success) {
|
||||
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
|
||||
return ''
|
||||
}
|
||||
return $match.Groups['name'].Value
|
||||
}
|
||||
|
||||
function Get-Schema {
|
||||
param([string]$Name)
|
||||
$property = $document.components.schemas.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON missing schema owner: $Name"
|
||||
return $null
|
||||
}
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Get-Operation {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if (-not $pathProperty) {
|
||||
Add-Issue "JSON missing path: $Path"
|
||||
return $null
|
||||
}
|
||||
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
|
||||
if (-not $operationProperty) {
|
||||
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
|
||||
return $null
|
||||
}
|
||||
return $operationProperty.Value
|
||||
}
|
||||
|
||||
function Resolve-Parameter {
|
||||
param([object]$Parameter, [string]$Label)
|
||||
if (-not $Parameter) { return $null }
|
||||
if (-not $Parameter.'$ref') { return $Parameter }
|
||||
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($parameterRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.parameters.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing parameter owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Resolve-Response {
|
||||
param([object]$Response, [string]$Label)
|
||||
if (-not $Response) { return $null }
|
||||
if (-not $Response.'$ref') { return $Response }
|
||||
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($responseRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.responses.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing response owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Resolve-Header {
|
||||
param([object]$Header, [string]$Label)
|
||||
if (-not $Header) { return $null }
|
||||
if (-not $Header.'$ref') { return $Header }
|
||||
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($headerRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.headers.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing header owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Get-Response {
|
||||
param([object]$Operation, [string]$Label, [string]$Status)
|
||||
if (-not $Operation) { return $null }
|
||||
$property = $Operation.responses.PSObject.Properties[$Status]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label missing response: $Status"
|
||||
return $null
|
||||
}
|
||||
return Resolve-Response $property.Value "$Label $Status"
|
||||
}
|
||||
|
||||
function Get-JsonResponseRef {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return '' }
|
||||
$media = @($Response.content.PSObject.Properties)
|
||||
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
|
||||
Add-Issue "JSON $Label $Status must expose only application/json"
|
||||
return ''
|
||||
}
|
||||
$schema = $media[0].Value.schema
|
||||
$ref = [string]$schema.'$ref'
|
||||
if (-not $ref) {
|
||||
Add-Issue "JSON $Label $Status must use a component schema ref"
|
||||
} else {
|
||||
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
|
||||
$schemaKeywords = @($schema.PSObject.Properties.Name)
|
||||
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
|
||||
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
|
||||
}
|
||||
}
|
||||
return $ref
|
||||
}
|
||||
|
||||
function Assert-ExactResponseSet {
|
||||
param([object]$Operation, [string]$Label, [string[]]$Expected)
|
||||
if (-not $Operation) { return }
|
||||
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
|
||||
$wanted = @($Expected | Sort-Object)
|
||||
if (($actual -join ',') -ne ($wanted -join ',')) {
|
||||
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PrivateNoStore {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
|
||||
return
|
||||
}
|
||||
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
|
||||
if (-not $header) { return }
|
||||
$values = @($header.schema.enum)
|
||||
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
|
||||
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
|
||||
[string]$values[0] -cne 'private, no-store') {
|
||||
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
|
||||
}
|
||||
|
||||
function Assert-RequiredResponseHeaders {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
|
||||
if ('Cache-Control' -notin $headerNames) {
|
||||
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
|
||||
}
|
||||
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
|
||||
Add-Issue "JSON $Label $Status response headers must include Retry-After"
|
||||
}
|
||||
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
|
||||
Add-Issue "JSON $Label $Status must not publish ETag; settings concurrency has one If-Match/settingsVersion owner"
|
||||
}
|
||||
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
|
||||
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
|
||||
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
|
||||
if ($unexpectedHeaders.Count -gt 0) {
|
||||
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
|
||||
}
|
||||
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
|
||||
if (-not $property) { continue }
|
||||
$header = Resolve-Header $property.Value "$Label $Status $traceName"
|
||||
if (-not $header) { continue }
|
||||
foreach ($headerProperty in @($header.PSObject.Properties)) {
|
||||
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
|
||||
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
|
||||
}
|
||||
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
|
||||
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
|
||||
continue
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RetryAfter {
|
||||
param([object]$Response, [string]$Label)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label must document Retry-After"
|
||||
return
|
||||
}
|
||||
$header = Resolve-Header $property.Value "$Label Retry-After"
|
||||
if (-not $header) { return }
|
||||
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
|
||||
[int]$header.schema.minimum -ne 1 -or
|
||||
[int]$header.schema.maximum -lt 1 -or [int]$header.schema.maximum -gt 300) {
|
||||
Add-Issue "JSON $Label Retry-After must be an integer in a bounded 1..300 second range"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
|
||||
}
|
||||
|
||||
function Assert-SaToken {
|
||||
param([object]$Operation, [string]$Label)
|
||||
if (-not $Operation) { return }
|
||||
$requirements = @($Operation.security)
|
||||
if ($requirements.Count -ne 1) {
|
||||
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
|
||||
return
|
||||
}
|
||||
$names = @($requirements[0].PSObject.Properties.Name)
|
||||
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
|
||||
Add-Issue "JSON $Label must require only SaToken"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OperationParameters {
|
||||
param([string]$Path, [object]$Operation, [string]$Label)
|
||||
$parameters = @()
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if ($pathProperty -and $pathProperty.Value.parameters) {
|
||||
foreach ($parameter in @($pathProperty.Value.parameters)) {
|
||||
$resolved = Resolve-Parameter $parameter "$Label path parameter"
|
||||
if ($resolved) { $parameters += $resolved }
|
||||
}
|
||||
}
|
||||
if ($Operation -and $Operation.parameters) {
|
||||
foreach ($parameter in @($Operation.parameters)) {
|
||||
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
|
||||
if ($resolved) { $parameters += $resolved }
|
||||
}
|
||||
}
|
||||
return $parameters
|
||||
}
|
||||
|
||||
function Get-Parameter {
|
||||
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
|
||||
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
|
||||
if ($matches.Count -ne 1) {
|
||||
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
|
||||
return $null
|
||||
}
|
||||
return $matches[0]
|
||||
}
|
||||
|
||||
function Assert-ExactParameters {
|
||||
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
|
||||
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
||||
$wanted = @($Expected | Sort-Object)
|
||||
if (($actual -join ',') -ne ($wanted -join ',')) {
|
||||
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExactObject {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$Name,
|
||||
[string[]]$Properties,
|
||||
[string[]]$Required,
|
||||
[int]$MinProperties = -1,
|
||||
[int]$MaxProperties = -1
|
||||
)
|
||||
if (-not $Schema) { return }
|
||||
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
|
||||
$expectedProperties = @($Properties | Sort-Object)
|
||||
$actualRequired = @($Schema.required | Sort-Object)
|
||||
$expectedRequired = @($Required | Sort-Object)
|
||||
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
|
||||
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
||||
($actualProperties -join ',') -ne ($expectedProperties -join ',') -or
|
||||
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
|
||||
Add-Issue "JSON $Name must be a closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $Schema $Name
|
||||
$allowedObjectKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
||||
if ($MinProperties -ge 0) { $allowedObjectKeywords += 'minProperties' }
|
||||
if ($MaxProperties -ge 0) { $allowedObjectKeywords += 'maxProperties' }
|
||||
Assert-AllowedSchemaKeywords $Schema $Name $allowedObjectKeywords
|
||||
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
|
||||
Add-Issue "JSON $Name.minProperties must be $MinProperties"
|
||||
}
|
||||
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
|
||||
Add-Issue "JSON $Name.maxProperties must be $MaxProperties"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PropertyRef {
|
||||
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
||||
if (-not $Schema) { return }
|
||||
$property = $Schema.properties.PSObject.Properties[$Field]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $SchemaName missing property: $Field"
|
||||
return
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
||||
}
|
||||
|
||||
function Assert-FixedError {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$Name,
|
||||
[int]$Status,
|
||||
[string]$BusinessCode,
|
||||
[switch]$Current
|
||||
)
|
||||
$properties = @('code', 'businessCode')
|
||||
if ($Current) { $properties += 'current' }
|
||||
Assert-ExactObject $Schema $Name $properties $properties
|
||||
if (-not $Schema) { return }
|
||||
if ($Schema.properties.code.type -ne 'integer' -or
|
||||
-not (Test-IsNonNullable $Schema.properties.code) -or
|
||||
-not (Test-IsJsonArray $Schema.properties.code.enum) -or
|
||||
@($Schema.properties.code.enum).Count -ne 1 -or
|
||||
@($Schema.properties.code.enum)[0] -ne $Status -or
|
||||
$Schema.properties.businessCode.type -ne 'string' -or
|
||||
-not (Test-IsNonNullable $Schema.properties.businessCode) -or
|
||||
-not (Test-IsJsonArray $Schema.properties.businessCode.enum) -or
|
||||
@($Schema.properties.businessCode.enum).Count -ne 1 -or
|
||||
@($Schema.properties.businessCode.enum)[0] -ne $BusinessCode) {
|
||||
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $Schema.properties.code "$Name.code" @('enum')
|
||||
Assert-NoConflictingSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('enum')
|
||||
Assert-AllowedSchemaKeywords $Schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
|
||||
Assert-AllowedSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
|
||||
if ($Current) { [void](Test-IsPureSchemaRef $Schema.properties.current '#/components/schemas/AppGenealogyVo' "$Name.current") }
|
||||
}
|
||||
|
||||
function Get-RequestBodySchemas {
|
||||
param([object]$Operation, [string]$Label)
|
||||
if (-not $Operation -or -not $Operation.requestBody) { return @() }
|
||||
$requestBody = $Operation.requestBody
|
||||
if ($requestBody.'$ref') {
|
||||
$name = Get-LocalComponentName ([string]$requestBody.'$ref') 'requestBodies' "$Label request body"
|
||||
if (-not $name) { return @() }
|
||||
$owner = $document.components.requestBodies.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing request body owner: $name"
|
||||
return @()
|
||||
}
|
||||
$requestBody = $owner.Value
|
||||
}
|
||||
if (-not $requestBody.content) { return @() }
|
||||
return @($requestBody.content.PSObject.Properties | ForEach-Object { $_.Value.schema } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
function Test-SchemaContainsSettingsContract {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[hashtable]$Seen,
|
||||
[string]$Label,
|
||||
[bool]$AllowInlineIntro,
|
||||
[bool]$AtRequestRoot = $true
|
||||
)
|
||||
if (-not $Schema) { return $false }
|
||||
|
||||
$ref = [string]$Schema.'$ref'
|
||||
if ($ref) {
|
||||
$leaf = @($ref -split '/')[-1]
|
||||
if ($leaf -ceq 'AppGenealogySettingsUpdateBody') {
|
||||
if ($ref -cne '#/components/schemas/AppGenealogySettingsUpdateBody') {
|
||||
[void](Get-LocalComponentName $ref 'schemas' $Label)
|
||||
}
|
||||
return $true
|
||||
}
|
||||
$name = Get-LocalComponentName $ref 'schemas' $Label
|
||||
if (-not $name) { return $false }
|
||||
if ($Seen.ContainsKey($name)) { return $false }
|
||||
$Seen[$name] = $true
|
||||
$owner = $document.components.schemas.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing schema owner: $name"
|
||||
return $false
|
||||
}
|
||||
return Test-SchemaContainsSettingsContract $owner.Value $Seen "$Label -> $name" $AllowInlineIntro $AtRequestRoot
|
||||
}
|
||||
|
||||
if ($Schema.properties) {
|
||||
$sharedOwners = @{
|
||||
genealogyName = '#/components/schemas/GenealogyName'
|
||||
intro = '#/components/schemas/GenealogyIntro'
|
||||
accessPreset = '#/components/schemas/GenealogyAccessPreset'
|
||||
}
|
||||
foreach ($field in $sharedOwners.Keys) {
|
||||
$property = $Schema.properties.PSObject.Properties[$field]
|
||||
if (-not $property) { continue }
|
||||
$propertyKeywords = @($property.Value.PSObject.Properties.Name)
|
||||
if ($propertyKeywords.Count -eq 1 -and $propertyKeywords[0] -ceq '$ref' -and
|
||||
[string]$property.Value.'$ref' -ceq $sharedOwners[$field]) {
|
||||
return $true
|
||||
}
|
||||
if ($AtRequestRoot -and ($field -ne 'intro' -or $AllowInlineIntro)) { return $true }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
|
||||
foreach ($branch in @($Schema.$keyword)) {
|
||||
if (Test-SchemaContainsSettingsContract $branch $Seen "$Label $keyword" $AllowInlineIntro $AtRequestRoot) { return $true }
|
||||
}
|
||||
}
|
||||
if ($Schema.items -and (Test-SchemaContainsSettingsContract $Schema.items $Seen "$Label items" $AllowInlineIntro $false)) { return $true }
|
||||
if ($Schema.properties) {
|
||||
foreach ($property in @($Schema.properties.PSObject.Properties)) {
|
||||
if (Test-SchemaContainsSettingsContract $property.Value $Seen "$Label.$($property.Name)" $AllowInlineIntro $false) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Assert-GlobalSettingsWriteOwner {
|
||||
$methods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
|
||||
$writeMethods = @('post', 'put', 'patch', 'delete')
|
||||
$settingsOwners = New-Object System.Collections.Generic.List[string]
|
||||
$operationIdOwners = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
|
||||
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $methods })) {
|
||||
$method = [string]$methodProperty.Name
|
||||
$operation = $methodProperty.Value
|
||||
$label = "$($method.ToUpperInvariant()) $($pathProperty.Name)"
|
||||
if ([string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
|
||||
$operationIdOwners.Add($label)
|
||||
}
|
||||
if ($method -notin $writeMethods) { continue }
|
||||
|
||||
$isSettingsOwner = $method -eq 'put' -and $pathProperty.Name -ceq $settingsPath
|
||||
$settingsSemanticPath = $pathProperty.Name -match '(?i)/genealogies(?:/\{[^}]+\})?/settings(?:[-_/]|$)'
|
||||
if ($settingsSemanticPath -or
|
||||
[string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
|
||||
$isSettingsOwner = $true
|
||||
}
|
||||
$isGenealogyCreate = $method -eq 'post' -and
|
||||
$pathProperty.Name -ceq '/genealogy/app/genealogies'
|
||||
if (-not $isGenealogyCreate) {
|
||||
if ($operation.requestBody -and $operation.requestBody.'$ref') {
|
||||
$requestBodyLeaf = @(([string]$operation.requestBody.'$ref') -split '/')[-1]
|
||||
if ($requestBodyLeaf -ceq 'AppGenealogySettingsUpdateBody') {
|
||||
$isSettingsOwner = $true
|
||||
}
|
||||
}
|
||||
$allowInlineIntro = $settingsSemanticPath -or
|
||||
$pathProperty.Name -match '^/genealogy/app/genealogies(?:/|$)'
|
||||
foreach ($schema in @(Get-RequestBodySchemas $operation $label)) {
|
||||
if (Test-SchemaContainsSettingsContract $schema @{} "$label request schema" $allowInlineIntro $true) {
|
||||
$isSettingsOwner = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($isSettingsOwner) { $settingsOwners.Add("$method $($pathProperty.Name)") }
|
||||
}
|
||||
}
|
||||
|
||||
$uniqueSettingsOwners = @($settingsOwners | Sort-Object -Unique)
|
||||
$expected = "put $settingsPath"
|
||||
if ($uniqueSettingsOwners.Count -ne 1 -or $uniqueSettingsOwners[0] -cne $expected) {
|
||||
Add-Issue "JSON settings write contract must have exactly one global owner ($expected); actual: $($uniqueSettingsOwners -join ',')"
|
||||
}
|
||||
if ($operationIdOwners.Count -ne 1 -or $operationIdOwners[0] -cne "PUT $settingsPath") {
|
||||
Add-Issue "JSON operationId appUpdateGenealogySettings must be globally unique on PUT $settingsPath; actual: $($operationIdOwners -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
||||
$parityOutput = @(& node $parityScript 2>&1)
|
||||
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
|
||||
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
|
||||
}
|
||||
|
||||
$workspaceScript = Join-Path $PSScriptRoot 'genealogy-workspace-openapi-contract.ps1'
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$workspaceOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $workspaceScript 2>&1)
|
||||
$workspaceExitCode = $LASTEXITCODE
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
$workspaceLines = @($workspaceOutput | ForEach-Object { [string]$_ })
|
||||
if ($workspaceExitCode -ne 0 -or 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS' -notin $workspaceLines) {
|
||||
Add-Issue 'canonical /mine and /overview workspace prerequisite must pass its sole owner gate before G11 can pass'
|
||||
}
|
||||
|
||||
Assert-GlobalSettingsWriteOwner
|
||||
|
||||
$settingsPathProperty = $document.paths.PSObject.Properties[$settingsPath]
|
||||
$settingsPathItem = if ($settingsPathProperty) { $settingsPathProperty.Value } else { $null }
|
||||
$settingsOperation = Get-Operation $settingsPath 'put'
|
||||
|
||||
if ($settingsPathItem -and $settingsPathItem.patch) {
|
||||
Add-Issue "JSON $settingsPath must not publish a second PATCH settings owner"
|
||||
}
|
||||
if ($settingsOperation) {
|
||||
if ($settingsOperation.operationId -ne 'appUpdateGenealogySettings') {
|
||||
Add-Issue "JSON PUT $settingsPath operationId must be appUpdateGenealogySettings"
|
||||
}
|
||||
Assert-SaToken $settingsOperation "PUT $settingsPath"
|
||||
$settingsParameters = @(Get-OperationParameters $settingsPath $settingsOperation "PUT $settingsPath")
|
||||
Assert-ExactParameters $settingsParameters "PUT $settingsPath" @(
|
||||
'path:genealogyId', 'header:clientid', 'header:If-Match'
|
||||
)
|
||||
$genealogyId = Get-Parameter $settingsParameters 'genealogyId' 'path' "PUT $settingsPath"
|
||||
if ($genealogyId) {
|
||||
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
|
||||
Add-Issue "JSON PUT $settingsPath genealogyId must be required"
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "PUT $settingsPath genealogyId")
|
||||
}
|
||||
$clientid = Get-Parameter $settingsParameters 'clientid' 'header' "PUT $settingsPath"
|
||||
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
|
||||
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
|
||||
[int]$clientid.schema.minLength -ne 1 -or
|
||||
[int]$clientid.schema.maxLength -ne 128)) {
|
||||
Add-Issue "JSON PUT $settingsPath clientid must be a required non-null bounded non-empty string"
|
||||
}
|
||||
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "PUT $settingsPath clientid" }
|
||||
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "PUT $settingsPath clientid" @('type', 'minLength', 'maxLength', 'nullable') }
|
||||
$ifMatch = Get-Parameter $settingsParameters 'If-Match' 'header' "PUT $settingsPath"
|
||||
if ($ifMatch) {
|
||||
if (-not (Test-IsJsonBoolean $ifMatch.required $true)) {
|
||||
Add-Issue "JSON PUT $settingsPath If-Match must be required"
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenealogySettingsVersion' "PUT $settingsPath If-Match")
|
||||
}
|
||||
|
||||
$requestContent = if ($settingsOperation.requestBody) { @($settingsOperation.requestBody.content.PSObject.Properties) } else { @() }
|
||||
if (-not $settingsOperation.requestBody -or -not (Test-IsJsonBoolean $settingsOperation.requestBody.required $true) -or
|
||||
$requestContent.Count -ne 1 -or $requestContent[0].Name -ne 'application/json') {
|
||||
Add-Issue "JSON PUT $settingsPath must require only application/json AppGenealogySettingsUpdateBody"
|
||||
} elseif ($requestContent.Count -eq 1) {
|
||||
[void](Test-IsPureSchemaRef $requestContent[0].Value.schema '#/components/schemas/AppGenealogySettingsUpdateBody' "PUT $settingsPath request schema")
|
||||
}
|
||||
|
||||
if ([string]$settingsOperation.'x-update-semantics' -ne 'ATOMIC_DIRTY_ONLY_MERGE' -or
|
||||
[string]$settingsOperation.'x-omitted-fields' -ne 'UNCHANGED' -or
|
||||
[string]$settingsOperation.'x-version-precondition' -ne 'IF_MATCH_SETTINGS_VERSION_CAS' -or
|
||||
[string]$settingsOperation.'x-canonical-noop-policy' -ne 'RETURN_200_KEEP_VERSION_NO_DOMAIN_SIDE_EFFECTS') {
|
||||
Add-Issue "JSON PUT $settingsPath must machine-bind dirty-only merge, omission, version CAS, and canonical no-op semantics"
|
||||
}
|
||||
$precedence = @($settingsOperation.'x-conflict-precedence')
|
||||
$expectedPrecedence = @(
|
||||
'GENEALOGY_SETTINGS_VERSION_CHANGED',
|
||||
'GENEALOGY_NOT_READY',
|
||||
'ACTIVE_PENDING_APPLICATIONS'
|
||||
)
|
||||
if (-not (Test-IsJsonArray $settingsOperation.'x-conflict-precedence') -or
|
||||
($precedence -join ',') -ne ($expectedPrecedence -join ',')) {
|
||||
Add-Issue "JSON PUT $settingsPath must order version, READY, then active-pending conflicts"
|
||||
}
|
||||
if ([string]$settingsOperation.'x-active-pending-policy' -ne 'BLOCK_ONLY_PUBLIC_APPLY_TO_MEMBER_ONLY' -or
|
||||
[string]$settingsOperation.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
|
||||
-not (Test-IsJsonBoolean $settingsOperation.'x-authorization-revalidated-in-transaction' $true) -or
|
||||
-not (Test-IsJsonBoolean $settingsOperation.'x-conflict-disclosure-requires-current-permission' $true)) {
|
||||
Add-Issue "JSON PUT $settingsPath must bind permission revalidation and atomic single-winner PUBLIC_APPLY-to-MEMBER_ONLY pending protection"
|
||||
}
|
||||
}
|
||||
|
||||
$statuses = @('200', '400', '401', '403', '404', '409', '422', '429', '500')
|
||||
Assert-ExactResponseSet $settingsOperation "PUT $settingsPath" $statuses
|
||||
$responseRefs = @{
|
||||
'200' = '#/components/schemas/RAppGenealogyVo'
|
||||
'400' = '#/components/schemas/RGenealogySettingsBadRequest'
|
||||
'401' = '#/components/schemas/RGenealogySettingsUnauthorized'
|
||||
'403' = '#/components/schemas/RGenealogySettingsForbidden'
|
||||
'404' = '#/components/schemas/RGenealogySettingsNotFound'
|
||||
'409' = '#/components/schemas/RGenealogySettingsConflict'
|
||||
'422' = '#/components/schemas/RGenealogySettingsValidationError'
|
||||
'429' = '#/components/schemas/RGenealogySettingsRateLimited'
|
||||
'500' = '#/components/schemas/RGenealogySettingsOutcomeUnknown'
|
||||
}
|
||||
$settingsResponses = @{}
|
||||
foreach ($status in $statuses) {
|
||||
$response = Get-Response $settingsOperation "PUT $settingsPath" $status
|
||||
$settingsResponses[$status] = $response
|
||||
$actualRef = Get-JsonResponseRef $response "PUT $settingsPath" $status
|
||||
if ($actualRef -ne $responseRefs[$status]) {
|
||||
Add-Issue "JSON PUT $settingsPath $status must return $($responseRefs[$status]); actual: $actualRef"
|
||||
}
|
||||
Assert-RequiredResponseHeaders $response "PUT $settingsPath" $status
|
||||
Assert-PrivateNoStore $response "PUT $settingsPath" $status
|
||||
}
|
||||
Assert-RetryAfter $settingsResponses['429'] "PUT $settingsPath 429"
|
||||
|
||||
$nameOwner = Get-Schema 'GenealogyName'
|
||||
if ($nameOwner) {
|
||||
if ($nameOwner.type -ne 'string' -or [int]$nameOwner.minLength -ne 1 -or
|
||||
[int]$nameOwner.maxLength -ne 24 -or [string]$nameOwner.pattern -cne $genealogyNamePattern -or
|
||||
-not (Test-IsNonNullable $nameOwner) -or
|
||||
[string]$nameOwner.'x-unicode-normalization' -ne 'NFC' -or
|
||||
[string]$nameOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT') {
|
||||
Add-Issue 'JSON GenealogyName must be non-null NFC, 1..24 Unicode code points, without boundary whitespace, line breaks, or control characters'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $nameOwner 'GenealogyName'
|
||||
Assert-AllowedSchemaKeywords $nameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
||||
}
|
||||
|
||||
$introOwner = Get-Schema 'GenealogyIntro'
|
||||
if ($introOwner) {
|
||||
$branches = @($introOwner.oneOf)
|
||||
$clear = @($branches | Where-Object {
|
||||
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and (Test-IsJsonArray $_.enum) -and
|
||||
@($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq ''
|
||||
})
|
||||
$value = @($branches | Where-Object {
|
||||
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and [int]$_.minLength -eq 1
|
||||
})
|
||||
if (-not (Test-IsJsonArray $introOwner.oneOf) -or $branches.Count -ne 2 -or
|
||||
$clear.Count -ne 1 -or $value.Count -ne 1 -or
|
||||
-not (Test-IsNonNullable $introOwner) -or
|
||||
[int]$value[0].maxLength -ne 80 -or [string]$value[0].pattern -cne $genealogyIntroPattern -or
|
||||
[string]$introOwner.'x-unicode-normalization' -ne 'NFC' -or
|
||||
[string]$introOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT' -or
|
||||
[string]$introOwner.'x-line-ending-normalization' -ne 'LF') {
|
||||
Add-Issue 'JSON GenealogyIntro must be non-null, allow exact empty clear or boundary-trimmed NFC 1..80 code points, permit internal LF only, and reject other controls'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf')
|
||||
Assert-AllowedSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf', 'nullable')
|
||||
foreach ($branch in $clear) {
|
||||
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro empty branch' @('enum')
|
||||
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro empty branch' @('type', 'enum', 'nullable')
|
||||
}
|
||||
foreach ($branch in $value) {
|
||||
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro value branch'
|
||||
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro value branch' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
||||
}
|
||||
}
|
||||
|
||||
$versionOwner = Get-Schema 'GenealogySettingsVersion'
|
||||
if ($versionOwner) {
|
||||
if ($versionOwner.type -ne 'string' -or [int]$versionOwner.minLength -ne 1 -or
|
||||
[int]$versionOwner.maxLength -ne 128 -or [string]$versionOwner.pattern -ne $identifierPattern -or
|
||||
-not (Test-IsNonNullable $versionOwner) -or
|
||||
[string]$versionOwner.'x-semantics' -ne 'OPAQUE_SETTINGS_CAS_VERSION' -or
|
||||
-not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or
|
||||
(@($versionOwner.'x-version-scope-fields') -join ',') -cne 'genealogyName,intro,accessPreset' -or
|
||||
[string]$versionOwner.'x-version-change-policy' -ne 'CANONICAL_SETTINGS_CHANGE_ONLY') {
|
||||
Add-Issue 'JSON GenealogySettingsVersion must be a non-null 1..128 URL-safe opaque CAS token that changes only when canonical genealogyName/intro/accessPreset changes'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $versionOwner 'GenealogySettingsVersion'
|
||||
Assert-AllowedSchemaKeywords $versionOwner 'GenealogySettingsVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
||||
}
|
||||
|
||||
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
|
||||
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
|
||||
'genealogyName', 'intro', 'accessPreset'
|
||||
) @() 1 3
|
||||
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'genealogyName' '#/components/schemas/GenealogyName'
|
||||
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'intro' '#/components/schemas/GenealogyIntro'
|
||||
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
||||
|
||||
$appGenealogy = Get-Schema 'AppGenealogyVo'
|
||||
if ($appGenealogy) {
|
||||
if (-not (Test-IsNonNullable $appGenealogy)) {
|
||||
Add-Issue 'JSON AppGenealogyVo must reject nullable=true'
|
||||
}
|
||||
foreach ($field in @('genealogyId', 'genealogyName', 'intro', 'accessPreset', 'settingsVersion', 'canManage')) {
|
||||
if ($field -notin @($appGenealogy.required)) {
|
||||
Add-Issue "JSON AppGenealogyVo.required missing settings baseline field: $field"
|
||||
}
|
||||
}
|
||||
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
|
||||
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
|
||||
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'intro' '#/components/schemas/GenealogyIntro'
|
||||
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
|
||||
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'settingsVersion' '#/components/schemas/GenealogySettingsVersion'
|
||||
if ($appGenealogy.properties.canManage.type -ne 'boolean' -or
|
||||
-not (Test-IsNonNullable $appGenealogy.properties.canManage)) {
|
||||
Add-Issue 'JSON AppGenealogyVo.canManage must be a non-null boolean and is only an entry capability, not write authorization proof'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage'
|
||||
Assert-AllowedSchemaKeywords $appGenealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
||||
Assert-AllowedSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' @('type', 'nullable')
|
||||
}
|
||||
|
||||
$successEnvelope = Get-Schema 'RAppGenealogyVo'
|
||||
if ($successEnvelope) {
|
||||
Assert-ExactObject $successEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
|
||||
if ($successEnvelope.properties.code.type -ne 'integer' -or
|
||||
-not (Test-IsNonNullable $successEnvelope.properties.code) -or
|
||||
-not (Test-IsJsonArray $successEnvelope.properties.code.enum) -or
|
||||
@($successEnvelope.properties.code.enum).Count -ne 1 -or
|
||||
@($successEnvelope.properties.code.enum)[0] -ne 200) {
|
||||
Add-Issue 'JSON RAppGenealogyVo must expose fixed code=200 and canonical AppGenealogyVo data'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('enum')
|
||||
Assert-AllowedSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('type', 'enum', 'nullable')
|
||||
[void](Test-IsPureSchemaRef $successEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
|
||||
}
|
||||
|
||||
$badRequest = Get-Schema 'RGenealogySettingsBadRequest'
|
||||
$unauthorized = Get-Schema 'RGenealogySettingsUnauthorized'
|
||||
$forbidden = Get-Schema 'RGenealogySettingsForbidden'
|
||||
$notFound = Get-Schema 'RGenealogySettingsNotFound'
|
||||
$versionChanged = Get-Schema 'RGenealogySettingsVersionChanged'
|
||||
$notReady = Get-Schema 'RGenealogySettingsNotReady'
|
||||
$pendingApplications = Get-Schema 'RGenealogySettingsPendingApplications'
|
||||
$conflict = Get-Schema 'RGenealogySettingsConflict'
|
||||
$validation = Get-Schema 'RGenealogySettingsValidationError'
|
||||
$rateLimited = Get-Schema 'RGenealogySettingsRateLimited'
|
||||
$outcomeUnknown = Get-Schema 'RGenealogySettingsOutcomeUnknown'
|
||||
|
||||
Assert-FixedError $badRequest 'RGenealogySettingsBadRequest' 400 'GENEALOGY_SETTINGS_REQUEST_INVALID'
|
||||
Assert-FixedError $unauthorized 'RGenealogySettingsUnauthorized' 401 'AUTH_REQUIRED'
|
||||
Assert-FixedError $forbidden 'RGenealogySettingsForbidden' 403 'GENEALOGY_SETTINGS_FORBIDDEN'
|
||||
Assert-FixedError $notFound 'RGenealogySettingsNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
|
||||
Assert-FixedError $versionChanged 'RGenealogySettingsVersionChanged' 409 'GENEALOGY_SETTINGS_VERSION_CHANGED' -Current
|
||||
Assert-FixedError $notReady 'RGenealogySettingsNotReady' 409 'GENEALOGY_NOT_READY'
|
||||
Assert-FixedError $pendingApplications 'RGenealogySettingsPendingApplications' 409 'ACTIVE_PENDING_APPLICATIONS'
|
||||
Assert-FixedError $rateLimited 'RGenealogySettingsRateLimited' 429 'RATE_LIMITED'
|
||||
Assert-FixedError $outcomeUnknown 'RGenealogySettingsOutcomeUnknown' 500 'GENEALOGY_SETTINGS_OUTCOME_UNKNOWN'
|
||||
|
||||
if ($conflict) {
|
||||
$actualRefs = @($conflict.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
|
||||
$expectedRefs = @(
|
||||
'#/components/schemas/RGenealogySettingsVersionChanged',
|
||||
'#/components/schemas/RGenealogySettingsNotReady',
|
||||
'#/components/schemas/RGenealogySettingsPendingApplications'
|
||||
) | Sort-Object
|
||||
$mapping = $conflict.discriminator.mapping
|
||||
$actualMapping = if ($mapping) {
|
||||
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
|
||||
} else { @() }
|
||||
$expectedMapping = @(
|
||||
'GENEALOGY_SETTINGS_VERSION_CHANGED=#/components/schemas/RGenealogySettingsVersionChanged',
|
||||
'GENEALOGY_NOT_READY=#/components/schemas/RGenealogySettingsNotReady',
|
||||
'ACTIVE_PENDING_APPLICATIONS=#/components/schemas/RGenealogySettingsPendingApplications'
|
||||
) | Sort-Object
|
||||
$conflictKeywords = @($conflict.PSObject.Properties.Name | Sort-Object)
|
||||
$discriminatorKeywords = if ($conflict.discriminator) {
|
||||
@($conflict.discriminator.PSObject.Properties.Name | Sort-Object)
|
||||
} else { @() }
|
||||
$pureBranches = @($conflict.oneOf | Where-Object {
|
||||
(@($_.PSObject.Properties.Name) -join ',') -ceq '$ref' -and
|
||||
[string]$_.'$ref' -match '^#/components/schemas/[^/]+$'
|
||||
})
|
||||
if (($conflictKeywords -join ',') -cne 'discriminator,oneOf' -or
|
||||
($discriminatorKeywords -join ',') -cne 'mapping,propertyName' -or
|
||||
-not (Test-IsJsonArray $conflict.oneOf) -or $pureBranches.Count -ne 3 -or
|
||||
($actualRefs -join ',') -ne ($expectedRefs -join ',') -or
|
||||
$conflict.discriminator.propertyName -ne 'businessCode' -or
|
||||
($actualMapping -join ',') -ne ($expectedMapping -join ',')) {
|
||||
Add-Issue 'JSON RGenealogySettingsConflict must contain only an exact businessCode discriminator and three local-ref oneOf branches for version, READY, and active-pending conflicts'
|
||||
}
|
||||
}
|
||||
|
||||
Assert-ExactObject $validation 'RGenealogySettingsValidationError' @(
|
||||
'code', 'businessCode', 'fieldErrors'
|
||||
) @('code', 'businessCode', 'fieldErrors')
|
||||
if ($validation) {
|
||||
if ($validation.properties.code.type -ne 'integer' -or
|
||||
-not (Test-IsNonNullable $validation.properties.code) -or
|
||||
-not (Test-IsJsonArray $validation.properties.code.enum) -or
|
||||
@($validation.properties.code.enum).Count -ne 1 -or @($validation.properties.code.enum)[0] -ne 422 -or
|
||||
$validation.properties.businessCode.type -ne 'string' -or
|
||||
-not (Test-IsNonNullable $validation.properties.businessCode) -or
|
||||
-not (Test-IsJsonArray $validation.properties.businessCode.enum) -or
|
||||
@($validation.properties.businessCode.enum).Count -ne 1 -or
|
||||
@($validation.properties.businessCode.enum)[0] -ne 'GENEALOGY_SETTINGS_INVALID') {
|
||||
Add-Issue 'JSON RGenealogySettingsValidationError must fix code=422 and businessCode=GENEALOGY_SETTINGS_INVALID'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('enum')
|
||||
Assert-NoConflictingSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('enum')
|
||||
Assert-AllowedSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('type', 'enum', 'nullable')
|
||||
Assert-AllowedSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('type', 'enum', 'nullable')
|
||||
$fieldErrors = $validation.properties.fieldErrors
|
||||
Assert-ExactObject $fieldErrors 'RGenealogySettingsValidationError.fieldErrors' @(
|
||||
'genealogyName', 'intro', 'accessPreset'
|
||||
) @() 1 3
|
||||
if ($fieldErrors) {
|
||||
foreach ($field in @('genealogyName', 'intro', 'accessPreset')) {
|
||||
$property = $fieldErrors.properties.PSObject.Properties[$field]
|
||||
if (-not $property -or $property.Value.type -ne 'string' -or
|
||||
-not (Test-IsNonNullable $property.Value) -or
|
||||
[int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 200) {
|
||||
Add-Issue "JSON RGenealogySettingsValidationError.fieldErrors.$field must be an optional non-null bounded non-empty string"
|
||||
}
|
||||
if ($property) {
|
||||
Assert-NoConflictingSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field"
|
||||
Assert-AllowedSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" @('type', 'minLength', 'maxLength', 'nullable')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED'
|
||||
Write-Output "Issues: $($issues.Count)"
|
||||
$issues | ForEach-Object { Write-Output "- $_" }
|
||||
Write-Output '- Keep PUT as the only settings owner and /overview as the only single-genealogy read owner; do not add PATCH or revive the generic GET.'
|
||||
Write-Output '- Use one atomic dirty-only merge with GenealogySettingsVersion in AppGenealogyVo, required If-Match, typed 409 conflicts, and no version field in the body.'
|
||||
Write-Output '- Block only PUBLIC_APPLY-to-MEMBER_ONLY while active PENDING applications exist, and serialize that transition with new application admission.'
|
||||
Write-Output '- Timeout, cancellation, 5xx, or malformed success is outcome-unknown; reconcile with a fresh /overview and never auto-repeat PUT or claim this request succeeded.'
|
||||
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT PASS'
|
||||
@@ -0,0 +1,73 @@
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
// This is an adversarial executable check for the OpenAPI text contract only.
|
||||
// It is not the production G12 normalizer or coordinator.
|
||||
const boundaryAndControlPattern = /^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$/u;
|
||||
|
||||
function hasWellFormedUtf16(value) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (unit >= 0xdc00 && unit <= 0xdfff) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validatesDeclaredGenerationText(value) {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
hasWellFormedUtf16(value) &&
|
||||
value === value.normalize("NFC") &&
|
||||
[...value].length >= 1 &&
|
||||
[...value].length <= 50 &&
|
||||
boundaryAndControlPattern.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
const hanDe = "\u5FB7";
|
||||
const hanCheng = "\u627F";
|
||||
const supplementary = String.fromCodePoint(0x20000);
|
||||
|
||||
for (const valid of [
|
||||
`${hanDe}${hanCheng}`,
|
||||
`${hanDe}${hanDe}`,
|
||||
`${supplementary}${hanCheng}`,
|
||||
supplementary.repeat(50),
|
||||
]) {
|
||||
assert.equal(validatesDeclaredGenerationText(valid), true, `valid sample rejected: ${JSON.stringify(valid)}`);
|
||||
}
|
||||
|
||||
for (const invalid of [
|
||||
"",
|
||||
supplementary.repeat(51),
|
||||
"e\u0301",
|
||||
"\ud800",
|
||||
"\udc00",
|
||||
` ${hanDe}`,
|
||||
`${hanDe} `,
|
||||
`\u00A0${hanDe}`,
|
||||
`${hanDe}\u00A0`,
|
||||
`${hanDe}\t${hanCheng}`,
|
||||
`${hanDe}\r${hanCheng}`,
|
||||
`${hanDe}\n${hanCheng}`,
|
||||
`${hanDe}\u0085${hanCheng}`,
|
||||
`${hanDe}\u061C${hanCheng}`,
|
||||
`${hanDe}\u200B${hanCheng}`,
|
||||
`${hanDe}\u200E${hanCheng}`,
|
||||
`${hanDe}\u200F${hanCheng}`,
|
||||
`${hanDe}\u2028${hanCheng}`,
|
||||
`${hanDe}\u2029${hanCheng}`,
|
||||
`${hanDe}\u202E${hanCheng}`,
|
||||
`${hanDe}\u2060${hanCheng}`,
|
||||
`${hanDe}\u2066${hanCheng}`,
|
||||
`${hanDe}\uFEFF${hanCheng}`,
|
||||
]) {
|
||||
assert.equal(validatesDeclaredGenerationText(invalid), false, `invalid sample accepted: ${JSON.stringify(invalid)}`);
|
||||
}
|
||||
|
||||
console.log("G12-GENERATION-POEM-UNICODE-RUNTIME-SMOKE PASS");
|
||||
@@ -2,58 +2,85 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$jsonPath = Join-Path $root 'APP.openapi.json'
|
||||
$yamlPath = Join-Path $root 'APP.openapi.yaml'
|
||||
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
||||
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$minePath = '/genealogy/app/genealogies/mine'
|
||||
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
||||
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
||||
$httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
|
||||
|
||||
function Add-Issue {
|
||||
param([string]$Message)
|
||||
$script:issues.Add($Message)
|
||||
}
|
||||
|
||||
function Get-JsonOperation {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if (-not $pathProperty) {
|
||||
Add-Issue "JSON missing path: $Path"
|
||||
return $null
|
||||
}
|
||||
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
|
||||
if (-not $operationProperty) {
|
||||
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
|
||||
return $null
|
||||
}
|
||||
return $operationProperty.Value
|
||||
function Test-IsJsonArray {
|
||||
param([object]$Value)
|
||||
return $null -ne $Value -and $Value.GetType().IsArray
|
||||
}
|
||||
|
||||
function Get-JsonResponseSchemaRef {
|
||||
param([object]$Operation, [string]$Label)
|
||||
if (-not $Operation) { return '' }
|
||||
$responseProperty = $Operation.responses.PSObject.Properties['200']
|
||||
if (-not $responseProperty) {
|
||||
Add-Issue "JSON $Label missing 200 response"
|
||||
return ''
|
||||
}
|
||||
$response = $responseProperty.Value
|
||||
if ($response.'$ref') {
|
||||
$responseName = ([string]$response.'$ref').Split('/')[-1]
|
||||
$responseOwner = $document.components.responses.PSObject.Properties[$responseName]
|
||||
if (-not $responseOwner) {
|
||||
Add-Issue "JSON $Label references missing response owner: $responseName"
|
||||
return ''
|
||||
function Test-IsJsonBoolean {
|
||||
param([object]$Value, [bool]$Expected)
|
||||
return $Value -is [System.Boolean] -and $Value -eq $Expected
|
||||
}
|
||||
|
||||
function Test-IsNonNullable {
|
||||
param([object]$Schema)
|
||||
if (-not $Schema) { return $false }
|
||||
$nullable = $Schema.PSObject.Properties['nullable']
|
||||
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
|
||||
}
|
||||
|
||||
function Assert-NoConflictingSchemaKeywords {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$Label,
|
||||
[string[]]$Allowed = @()
|
||||
)
|
||||
if (-not $Schema) { return }
|
||||
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
|
||||
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
|
||||
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
|
||||
}
|
||||
$response = $responseOwner.Value
|
||||
}
|
||||
$media = @($response.content.PSObject.Properties)
|
||||
if ($media.Count -eq 0) {
|
||||
Add-Issue "JSON $Label 200 response has no content schema"
|
||||
return ''
|
||||
}
|
||||
return [string]$media[0].Value.schema.'$ref'
|
||||
}
|
||||
|
||||
function Get-JsonSchema {
|
||||
function Assert-AllowedSchemaKeywords {
|
||||
param([object]$Schema, [string]$Label, [string[]]$Allowed)
|
||||
if (-not $Schema) { return }
|
||||
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
|
||||
foreach ($property in @($Schema.PSObject.Properties)) {
|
||||
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
|
||||
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsPureSchemaRef {
|
||||
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
|
||||
if (-not $Schema) { return $false }
|
||||
$properties = @($Schema.PSObject.Properties.Name)
|
||||
$actualRef = [string]$Schema.'$ref'
|
||||
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
||||
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
|
||||
return $false
|
||||
}
|
||||
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-LocalComponentName {
|
||||
param([string]$Ref, [string]$Section, [string]$Label)
|
||||
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
|
||||
$match = [regex]::Match($Ref, $pattern)
|
||||
if (-not $match.Success) {
|
||||
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
|
||||
return ''
|
||||
}
|
||||
return $match.Groups['name'].Value
|
||||
}
|
||||
|
||||
function Get-Schema {
|
||||
param([string]$Name)
|
||||
$property = $document.components.schemas.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
@@ -63,213 +90,630 @@ function Get-JsonSchema {
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Assert-JsonRequired {
|
||||
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
|
||||
if (-not $Schema) { return }
|
||||
$required = @($Schema.required)
|
||||
foreach ($field in $Fields) {
|
||||
if ($field -notin $required) {
|
||||
Add-Issue "JSON $SchemaName.required missing: $field"
|
||||
}
|
||||
function Get-Operation {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if (-not $pathProperty) {
|
||||
Add-Issue "JSON missing path: $Path"
|
||||
return $null
|
||||
}
|
||||
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
|
||||
if (-not $operationProperty) {
|
||||
Add-Issue "JSON missing operation: $($Method.ToUpperInvariant()) $Path"
|
||||
return $null
|
||||
}
|
||||
return $operationProperty.Value
|
||||
}
|
||||
|
||||
function Assert-OnlyMethod {
|
||||
param([string]$Path, [string]$Method)
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if (-not $pathProperty) { return }
|
||||
$actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { $_ -in $httpMethods } | Sort-Object)
|
||||
if ($actual.Count -ne 1 -or $actual[0] -ne $Method) {
|
||||
Add-Issue "JSON $Path must expose only $($Method.ToUpperInvariant()); actual: $($actual -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-JsonProperty {
|
||||
param(
|
||||
[object]$Schema,
|
||||
[string]$SchemaName,
|
||||
[string]$Field,
|
||||
[string]$Type,
|
||||
[switch]$NonEmpty
|
||||
)
|
||||
if (-not $Schema) { return $null }
|
||||
function Resolve-Parameter {
|
||||
param([object]$Parameter, [string]$Label)
|
||||
if (-not $Parameter) { return $null }
|
||||
if (-not $Parameter.'$ref') { return $Parameter }
|
||||
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($parameterRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.parameters.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing parameter owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Resolve-Response {
|
||||
param([object]$Response, [string]$Label, [bool]$StrictRefObject = $true)
|
||||
if (-not $Response) { return $null }
|
||||
if (-not $Response.'$ref') { return $Response }
|
||||
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($StrictRefObject -and $responseRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.responses.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing response owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Resolve-Header {
|
||||
param([object]$Header, [string]$Label)
|
||||
if (-not $Header) { return $null }
|
||||
if (-not $Header.'$ref') { return $Header }
|
||||
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
|
||||
if ($headerRefSiblings.Count -gt 0) {
|
||||
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
|
||||
}
|
||||
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
|
||||
if (-not $name) { return $null }
|
||||
$owner = $document.components.headers.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing header owner: $name"
|
||||
return $null
|
||||
}
|
||||
return $owner.Value
|
||||
}
|
||||
|
||||
function Get-OperationParameters {
|
||||
param([string]$Path, [object]$Operation, [string]$Label)
|
||||
$parameters = @()
|
||||
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
||||
if ($pathProperty -and $pathProperty.Value.parameters) {
|
||||
foreach ($parameter in @($pathProperty.Value.parameters)) {
|
||||
$resolved = Resolve-Parameter $parameter "$Label path parameter"
|
||||
if ($resolved) { $parameters += $resolved }
|
||||
}
|
||||
}
|
||||
if ($Operation -and $Operation.parameters) {
|
||||
foreach ($parameter in @($Operation.parameters)) {
|
||||
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
|
||||
if ($resolved) { $parameters += $resolved }
|
||||
}
|
||||
}
|
||||
return $parameters
|
||||
}
|
||||
|
||||
function Assert-ExactParameters {
|
||||
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
|
||||
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
||||
$wanted = @($Expected | Sort-Object)
|
||||
if (($actual -join ',') -cne ($wanted -join ',')) {
|
||||
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Parameter {
|
||||
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
|
||||
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
|
||||
if ($matches.Count -ne 1) {
|
||||
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
|
||||
return $null
|
||||
}
|
||||
return $matches[0]
|
||||
}
|
||||
|
||||
function Assert-SaToken {
|
||||
param([object]$Operation, [string]$Label)
|
||||
if (-not $Operation) { return }
|
||||
$requirements = @($Operation.security)
|
||||
if ($requirements.Count -ne 1) {
|
||||
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
|
||||
return
|
||||
}
|
||||
$names = @($requirements[0].PSObject.Properties.Name)
|
||||
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
|
||||
Add-Issue "JSON $Label must require only SaToken"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-GlobalOperationId {
|
||||
param([string]$OperationId, [string]$ExpectedLabel)
|
||||
$owners = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
|
||||
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $httpMethods })) {
|
||||
if ([string]$methodProperty.Value.operationId -ceq $OperationId) {
|
||||
$owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedLabel) {
|
||||
Add-Issue "JSON operationId $OperationId must be globally unique on $ExpectedLabel; actual: $($owners -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExactResponseSet {
|
||||
param([object]$Operation, [string]$Label, [string[]]$Expected)
|
||||
if (-not $Operation) { return }
|
||||
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
|
||||
$wanted = @($Expected | Sort-Object)
|
||||
if (($actual -join ',') -cne ($wanted -join ',')) {
|
||||
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Response {
|
||||
param([object]$Operation, [string]$Label, [string]$Status)
|
||||
if (-not $Operation) { return $null }
|
||||
$property = $Operation.responses.PSObject.Properties[$Status]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label missing response: $Status"
|
||||
return $null
|
||||
}
|
||||
return Resolve-Response $property.Value "$Label $Status"
|
||||
}
|
||||
|
||||
function Get-ResponseSchemaRef {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return '' }
|
||||
$media = @($Response.content.PSObject.Properties)
|
||||
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
|
||||
Add-Issue "JSON $Label $Status must expose only application/json"
|
||||
return ''
|
||||
}
|
||||
$schema = $media[0].Value.schema
|
||||
$ref = [string]$schema.'$ref'
|
||||
if (-not $ref) {
|
||||
Add-Issue "JSON $Label $Status must use a component schema ref"
|
||||
return ''
|
||||
}
|
||||
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
|
||||
$schemaKeywords = @($schema.PSObject.Properties.Name)
|
||||
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
|
||||
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
|
||||
}
|
||||
return $ref
|
||||
}
|
||||
|
||||
function Assert-RequiredResponseHeaders {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
|
||||
if ('Cache-Control' -notin $headerNames) {
|
||||
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
|
||||
}
|
||||
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
|
||||
Add-Issue "JSON $Label $Status response headers must include Retry-After"
|
||||
}
|
||||
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
|
||||
Add-Issue "JSON $Label $Status must not publish ETag; workspace reads use the shared settingsVersion owner instead"
|
||||
}
|
||||
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
|
||||
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
|
||||
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
|
||||
if ($unexpectedHeaders.Count -gt 0) {
|
||||
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
|
||||
}
|
||||
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
|
||||
if (-not $property) { continue }
|
||||
$header = Resolve-Header $property.Value "$Label $Status $traceName"
|
||||
if (-not $header) { continue }
|
||||
foreach ($headerProperty in @($header.PSObject.Properties)) {
|
||||
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
|
||||
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
|
||||
}
|
||||
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
|
||||
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
|
||||
continue
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PrivateNoStore {
|
||||
param([object]$Response, [string]$Label, [string]$Status)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
|
||||
return
|
||||
}
|
||||
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
|
||||
if (-not $header) { return }
|
||||
$values = @($header.schema.enum)
|
||||
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
|
||||
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
|
||||
[string]$values[0] -cne 'private, no-store') {
|
||||
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
|
||||
}
|
||||
|
||||
function Assert-RetryAfter {
|
||||
param([object]$Response, [string]$Label)
|
||||
if (-not $Response) { return }
|
||||
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $Label must document Retry-After"
|
||||
return
|
||||
}
|
||||
$header = Resolve-Header $property.Value "$Label Retry-After"
|
||||
if (-not $header) { return }
|
||||
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
|
||||
[int]$header.schema.minimum -ne 1 -or [int]$header.schema.maximum -lt 1 -or
|
||||
[int]$header.schema.maximum -gt 300) {
|
||||
Add-Issue "JSON $Label Retry-After must be a non-null integer in a bounded 1..300 second range"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
|
||||
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
|
||||
}
|
||||
|
||||
function Assert-ExactObject {
|
||||
param([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required)
|
||||
if (-not $Schema) { return }
|
||||
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
|
||||
$expectedProperties = @($Properties | Sort-Object)
|
||||
$actualRequired = @($Schema.required | Sort-Object)
|
||||
$expectedRequired = @($Required | Sort-Object)
|
||||
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
|
||||
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
||||
($actualProperties -join ',') -cne ($expectedProperties -join ',') -or
|
||||
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
|
||||
Add-Issue "JSON $Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $Schema $Name
|
||||
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
||||
}
|
||||
|
||||
function Assert-PropertyRef {
|
||||
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
|
||||
if (-not $Schema) { return }
|
||||
$property = $Schema.properties.PSObject.Properties[$Field]
|
||||
if (-not $property) {
|
||||
Add-Issue "JSON $SchemaName missing property: $Field"
|
||||
return $null
|
||||
return
|
||||
}
|
||||
if ($property.Value.type -ne $Type) {
|
||||
Add-Issue "JSON $SchemaName.$Field must be $Type"
|
||||
}
|
||||
if ($NonEmpty -and [int]$property.Value.minLength -lt 1) {
|
||||
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
|
||||
}
|
||||
return $property.Value
|
||||
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
||||
}
|
||||
|
||||
function Get-YamlBlock {
|
||||
param([string]$Header, [string]$NextHeaderPattern)
|
||||
$lines = @($yaml -split "`r?`n")
|
||||
$start = [Array]::IndexOf($lines, $Header)
|
||||
if ($start -lt 0) { return '' }
|
||||
$end = $lines.Count
|
||||
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
|
||||
if ($lines[$index] -match $NextHeaderPattern) {
|
||||
$end = $index
|
||||
break
|
||||
function Assert-FixedError {
|
||||
param([string]$Name, [int]$Status, [string]$BusinessCode)
|
||||
$schema = Get-Schema $Name
|
||||
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
|
||||
if (-not $schema) { return }
|
||||
$codes = @($schema.properties.code.enum)
|
||||
$businessCodes = @($schema.properties.businessCode.enum)
|
||||
if ($schema.properties.code.type -ne 'integer' -or
|
||||
-not (Test-IsNonNullable $schema.properties.code) -or
|
||||
-not (Test-IsJsonArray $schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[0] -ne $Status -or
|
||||
$schema.properties.businessCode.type -ne 'string' -or
|
||||
-not (Test-IsNonNullable $schema.properties.businessCode) -or
|
||||
-not (Test-IsJsonArray $schema.properties.businessCode.enum) -or
|
||||
$businessCodes.Count -ne 1 -or [string]$businessCodes[0] -cne $BusinessCode) {
|
||||
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
|
||||
Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum')
|
||||
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
|
||||
Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
|
||||
}
|
||||
|
||||
function Get-AllOfPropertyNames {
|
||||
param([object]$Schema, [hashtable]$Seen, [string]$Label)
|
||||
if (-not $Schema -or $Schema.type -eq 'array') { return @() }
|
||||
if ($Schema.'$ref') {
|
||||
$name = Get-LocalComponentName ([string]$Schema.'$ref') 'schemas' $Label
|
||||
if (-not $name -or $Seen.ContainsKey($name)) { return @() }
|
||||
$Seen[$name] = $true
|
||||
$owner = $document.components.schemas.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing schema owner: $name"
|
||||
return @()
|
||||
}
|
||||
return @(Get-AllOfPropertyNames $owner.Value $Seen "$Label -> $name")
|
||||
}
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
if ($Schema.properties) {
|
||||
foreach ($name in @($Schema.properties.PSObject.Properties.Name)) { $names.Add($name) }
|
||||
}
|
||||
foreach ($branch in @($Schema.allOf)) {
|
||||
foreach ($name in @(Get-AllOfPropertyNames $branch $Seen "$Label allOf")) { $names.Add($name) }
|
||||
}
|
||||
return @($names | Sort-Object -Unique)
|
||||
}
|
||||
|
||||
function Test-SchemaClosureExposesSingleGenealogy {
|
||||
param([object]$Schema, [hashtable]$Seen, [string]$Label)
|
||||
if (-not $Schema) { return $false }
|
||||
|
||||
$ref = [string]$Schema.'$ref'
|
||||
if ($ref) {
|
||||
$leaf = @($ref -split '/')[-1]
|
||||
if ($leaf -in @('RAppGenealogyVo', 'AppGenealogyVo')) {
|
||||
$expectedRef = "#/components/schemas/$leaf"
|
||||
if ($ref -cne $expectedRef) {
|
||||
[void](Get-LocalComponentName $ref 'schemas' $Label)
|
||||
}
|
||||
return $true
|
||||
}
|
||||
$name = Get-LocalComponentName $ref 'schemas' $Label
|
||||
if (-not $name -or $Seen.ContainsKey($name)) { return $false }
|
||||
$Seen[$name] = $true
|
||||
$owner = $document.components.schemas.PSObject.Properties[$name]
|
||||
if (-not $owner) {
|
||||
Add-Issue "JSON $Label references missing schema owner: $name"
|
||||
return $false
|
||||
}
|
||||
return Test-SchemaClosureExposesSingleGenealogy $owner.Value $Seen "$Label -> $name"
|
||||
}
|
||||
|
||||
if ($Schema.type -eq 'array') { return $false }
|
||||
$propertyNames = @(Get-AllOfPropertyNames $Schema @{} "$Label structural detail")
|
||||
$detailFields = @(@('genealogyId', 'genealogyName', 'canView') | Where-Object { $_ -in $propertyNames })
|
||||
if ($detailFields.Count -eq 3) {
|
||||
return $true
|
||||
}
|
||||
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
|
||||
foreach ($branch in @($Schema.$keyword)) {
|
||||
if (Test-SchemaClosureExposesSingleGenealogy $branch $Seen "$Label $keyword") { return $true }
|
||||
}
|
||||
}
|
||||
return ($lines[$start..($end - 1)] -join "`n")
|
||||
}
|
||||
|
||||
function Get-YamlRequiredFields {
|
||||
param([string]$Block)
|
||||
$match = [regex]::Match(
|
||||
$Block,
|
||||
'(?ms)^ required:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
|
||||
)
|
||||
if (-not $match.Success) { return @() }
|
||||
return @([regex]::Matches($match.Groups['rows'].Value, '(?m)^ - (?<name>[^\s]+)\s*$') |
|
||||
ForEach-Object { $_.Groups['name'].Value })
|
||||
}
|
||||
|
||||
function Get-YamlFieldBlock {
|
||||
param([string]$SchemaBlock, [string]$Field)
|
||||
$pattern = '(?ms)^ ' + [regex]::Escape($Field) + ':\s*\n(?<body>.*?)(?=^ [A-Za-z0-9_]+:\s*$|\z)'
|
||||
$match = [regex]::Match($SchemaBlock, $pattern)
|
||||
if (-not $match.Success) { return '' }
|
||||
return $match.Value
|
||||
}
|
||||
|
||||
function Assert-YamlRequired {
|
||||
param([string]$Block, [string]$SchemaName, [string[]]$Fields)
|
||||
if (-not $Block) { return }
|
||||
$required = @(Get-YamlRequiredFields $Block)
|
||||
foreach ($field in $Fields) {
|
||||
if ($field -notin $required) {
|
||||
Add-Issue "YAML $SchemaName.required missing: $field"
|
||||
if ($Schema.properties) {
|
||||
foreach ($property in @($Schema.properties.PSObject.Properties)) {
|
||||
if ($property.Value.type -eq 'array') { continue }
|
||||
if (Test-SchemaClosureExposesSingleGenealogy $property.Value $Seen "$Label.$($property.Name)") { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Assert-YamlField {
|
||||
param(
|
||||
[string]$Block,
|
||||
[string]$SchemaName,
|
||||
[string]$Field,
|
||||
[string]$Type,
|
||||
[switch]$NonEmpty
|
||||
)
|
||||
if (-not $Block) { return '' }
|
||||
$fieldBlock = Get-YamlFieldBlock $Block $Field
|
||||
if (-not $fieldBlock) {
|
||||
Add-Issue "YAML $SchemaName missing property: $Field"
|
||||
return ''
|
||||
function Test-OperationReturnsSingleGenealogy {
|
||||
param([object]$Operation, [string]$Label)
|
||||
if (-not $Operation -or -not $Operation.responses) { return $false }
|
||||
$responseProperty = $Operation.responses.PSObject.Properties['200']
|
||||
if (-not $responseProperty) { return $false }
|
||||
$response = $responseProperty.Value
|
||||
if ($response.'$ref') {
|
||||
$leaf = @(([string]$response.'$ref') -split '/')[-1]
|
||||
if ($leaf -eq 'RAppGenealogyVo') {
|
||||
if ([string]$response.'$ref' -cne '#/components/responses/RAppGenealogyVo') {
|
||||
[void](Get-LocalComponentName ([string]$response.'$ref') 'responses' "$Label 200 response")
|
||||
}
|
||||
return $true
|
||||
}
|
||||
$response = Resolve-Response $response "$Label 200 response" $true
|
||||
if (-not $response) { return $false }
|
||||
}
|
||||
if ($fieldBlock -notmatch "(?m)^ type: $([regex]::Escape($Type))\s*$") {
|
||||
Add-Issue "YAML $SchemaName.$Field must be $Type"
|
||||
if (-not $response.content) { return $false }
|
||||
foreach ($media in @($response.content.PSObject.Properties)) {
|
||||
if (Test-SchemaClosureExposesSingleGenealogy $media.Value.schema @{} "$Label 200 $($media.Name)") {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
if ($NonEmpty -and $fieldBlock -notmatch '(?m)^ minLength: [1-9][0-9]*\s*$') {
|
||||
Add-Issue "YAML $SchemaName.$Field must declare minLength >= 1"
|
||||
return $false
|
||||
}
|
||||
|
||||
function Assert-SoleSingleGenealogyReadOwner {
|
||||
$owners = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($pathProperty in @($document.paths.PSObject.Properties | Where-Object { $_.Name -match '^/genealogy/app(?:/|$)' })) {
|
||||
$getProperty = $pathProperty.Value.PSObject.Properties['get']
|
||||
if (-not $getProperty -or $pathProperty.Name -ceq $minePath) { continue }
|
||||
$label = "GET $($pathProperty.Name)"
|
||||
if (Test-OperationReturnsSingleGenealogy $getProperty.Value $label) {
|
||||
$owners.Add($label)
|
||||
}
|
||||
}
|
||||
$expected = "GET $overviewPath"
|
||||
$uniqueOwners = @($owners | Sort-Object -Unique)
|
||||
if ($uniqueOwners.Count -ne 1 -or $uniqueOwners[0] -cne $expected) {
|
||||
Add-Issue "JSON single-genealogy APP read must have exactly one global owner ($expected); actual: $($uniqueOwners -join ',')"
|
||||
}
|
||||
return $fieldBlock
|
||||
}
|
||||
|
||||
$minePath = '/genealogy/app/genealogies/mine'
|
||||
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
||||
$mine = Get-JsonOperation $minePath 'get'
|
||||
$overview = Get-JsonOperation $overviewPath 'get'
|
||||
|
||||
$mineResponseRef = Get-JsonResponseSchemaRef $mine 'GET /mine'
|
||||
if ($mineResponseRef -ne '#/components/schemas/RListAppGenealogyVo') {
|
||||
Add-Issue "JSON GET /mine must return RListAppGenealogyVo; actual: $mineResponseRef"
|
||||
}
|
||||
$overviewResponseRef = Get-JsonResponseSchemaRef $overview 'GET /overview'
|
||||
if ($overviewResponseRef -ne '#/components/schemas/RAppGenealogyVo') {
|
||||
Add-Issue "JSON GET /overview must return RAppGenealogyVo; actual: $overviewResponseRef"
|
||||
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
||||
$parityOutput = @(& node $parityScript 2>&1)
|
||||
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
|
||||
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
|
||||
}
|
||||
|
||||
$listEnvelope = Get-JsonSchema 'RListAppGenealogyVo'
|
||||
$objectEnvelope = Get-JsonSchema 'RAppGenealogyVo'
|
||||
$genealogy = Get-JsonSchema 'AppGenealogyVo'
|
||||
$mine = Get-Operation $minePath 'get'
|
||||
$overview = Get-Operation $overviewPath 'get'
|
||||
Assert-OnlyMethod $minePath 'get'
|
||||
Assert-OnlyMethod $overviewPath 'get'
|
||||
Assert-SoleSingleGenealogyReadOwner
|
||||
|
||||
Assert-JsonRequired $listEnvelope 'RListAppGenealogyVo' @('code', 'data')
|
||||
Assert-JsonRequired $objectEnvelope 'RAppGenealogyVo' @('code', 'data')
|
||||
Assert-JsonProperty $listEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
|
||||
Assert-JsonProperty $objectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
|
||||
$operationContracts = @(
|
||||
[pscustomobject]@{
|
||||
Path = $minePath
|
||||
Operation = $mine
|
||||
Label = "GET $minePath"
|
||||
OperationId = 'appListMyGenealogies'
|
||||
Parameters = @('header:clientid')
|
||||
Responses = [ordered]@{
|
||||
'200' = '#/components/schemas/RListAppGenealogyVo'
|
||||
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
|
||||
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
|
||||
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
|
||||
}
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Path = $overviewPath
|
||||
Operation = $overview
|
||||
Label = "GET $overviewPath"
|
||||
OperationId = 'appGetGenealogyOverview'
|
||||
Parameters = @('header:clientid', 'path:genealogyId')
|
||||
Responses = [ordered]@{
|
||||
'200' = '#/components/schemas/RAppGenealogyVo'
|
||||
'400' = '#/components/schemas/RGenealogyWorkspaceBadRequest'
|
||||
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
|
||||
'404' = '#/components/schemas/RGenealogyWorkspaceNotFound'
|
||||
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
|
||||
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($contract in $operationContracts) {
|
||||
$operation = $contract.Operation
|
||||
if (-not $operation) { continue }
|
||||
if ([string]$operation.operationId -cne $contract.OperationId) {
|
||||
Add-Issue "JSON $($contract.Label) operationId must be $($contract.OperationId)"
|
||||
}
|
||||
Assert-GlobalOperationId $contract.OperationId $contract.Label
|
||||
Assert-SaToken $operation $contract.Label
|
||||
if ($operation.PSObject.Properties['requestBody']) {
|
||||
Add-Issue "JSON $($contract.Label) must not define a request body"
|
||||
}
|
||||
$parameters = @(Get-OperationParameters $contract.Path $operation $contract.Label)
|
||||
Assert-ExactParameters $parameters $contract.Label $contract.Parameters
|
||||
$clientid = Get-Parameter $parameters 'clientid' 'header' $contract.Label
|
||||
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
|
||||
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
|
||||
[int]$clientid.schema.minLength -ne 1 -or [int]$clientid.schema.maxLength -ne 128)) {
|
||||
Add-Issue "JSON $($contract.Label) clientid must be a required non-null string bounded to 1..128"
|
||||
}
|
||||
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "$($contract.Label) clientid" }
|
||||
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "$($contract.Label) clientid" @('type', 'minLength', 'maxLength', 'nullable') }
|
||||
if ($contract.Path -eq $overviewPath) {
|
||||
$genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $contract.Label
|
||||
if ($genealogyId) {
|
||||
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
|
||||
Add-Issue "JSON $($contract.Label) genealogyId must be required"
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$($contract.Label) genealogyId")
|
||||
}
|
||||
}
|
||||
|
||||
$statuses = @($contract.Responses.Keys)
|
||||
Assert-ExactResponseSet $operation $contract.Label $statuses
|
||||
foreach ($status in $statuses) {
|
||||
$response = Get-Response $operation $contract.Label $status
|
||||
$actualRef = Get-ResponseSchemaRef $response $contract.Label $status
|
||||
if ($actualRef -cne $contract.Responses[$status]) {
|
||||
Add-Issue "JSON $($contract.Label) $status must return $($contract.Responses[$status]); actual: $actualRef"
|
||||
}
|
||||
Assert-RequiredResponseHeaders $response $contract.Label $status
|
||||
Assert-PrivateNoStore $response $contract.Label $status
|
||||
if ($status -eq '429') { Assert-RetryAfter $response "$($contract.Label) 429" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($mine -and (-not (Test-IsJsonBoolean $mine.'x-current-account-viewable-only' $true) -or
|
||||
[string]$mine.'x-revocation-policy' -cne 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS')) {
|
||||
Add-Issue 'JSON GET /mine must machine-bind current-account viewable-only scope and confirmed-revocation omission'
|
||||
}
|
||||
if ($overview -and [string]$overview.'x-object-authorization-failure' -cne 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE') {
|
||||
Add-Issue 'JSON GET /overview must machine-bind non-disclosing object authorization failure'
|
||||
}
|
||||
|
||||
$genealogyIdOwner = Get-Schema 'GenealogyId'
|
||||
if ($genealogyIdOwner -and ($genealogyIdOwner.type -ne 'string' -or
|
||||
-not (Test-IsNonNullable $genealogyIdOwner) -or [int]$genealogyIdOwner.minLength -ne 1 -or
|
||||
[int]$genealogyIdOwner.maxLength -ne 128 -or [string]$genealogyIdOwner.pattern -cne $identifierPattern)) {
|
||||
Add-Issue 'JSON GenealogyId must be a non-null 1..128 URL-safe lexical identifier'
|
||||
}
|
||||
if ($genealogyIdOwner) { Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId' }
|
||||
if ($genealogyIdOwner) { Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
|
||||
$genealogyNameOwner = Get-Schema 'GenealogyName'
|
||||
if ($genealogyNameOwner -and ($genealogyNameOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyNameOwner))) {
|
||||
Add-Issue 'JSON GenealogyName shared owner must be a non-null string; G11 owns its normalization and length semantics'
|
||||
}
|
||||
if ($genealogyNameOwner) { Assert-NoConflictingSchemaKeywords $genealogyNameOwner 'GenealogyName' }
|
||||
if ($genealogyNameOwner) { Assert-AllowedSchemaKeywords $genealogyNameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
|
||||
|
||||
$listEnvelope = Get-Schema 'RListAppGenealogyVo'
|
||||
$objectEnvelope = Get-Schema 'RAppGenealogyVo'
|
||||
$genealogy = Get-Schema 'AppGenealogyVo'
|
||||
|
||||
Assert-ExactObject $listEnvelope 'RListAppGenealogyVo' @('code', 'data') @('code', 'data')
|
||||
Assert-ExactObject $objectEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
|
||||
foreach ($envelope in @(
|
||||
[pscustomobject]@{ Name = 'RListAppGenealogyVo'; Schema = $listEnvelope },
|
||||
[pscustomobject]@{ Name = 'RAppGenealogyVo'; Schema = $objectEnvelope }
|
||||
)) {
|
||||
if (-not $envelope.Schema) { continue }
|
||||
$codes = @($envelope.Schema.properties.code.enum)
|
||||
if ($envelope.Schema.properties.code.type -ne 'integer' -or
|
||||
-not (Test-IsNonNullable $envelope.Schema.properties.code) -or
|
||||
-not (Test-IsJsonArray $envelope.Schema.properties.code.enum) -or
|
||||
$codes.Count -ne 1 -or $codes[0] -ne 200) {
|
||||
Add-Issue "JSON $($envelope.Name).code must be fixed to 200"
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('enum')
|
||||
Assert-AllowedSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('type', 'enum', 'nullable')
|
||||
}
|
||||
if ($listEnvelope) {
|
||||
$listData = $listEnvelope.properties.PSObject.Properties['data'].Value
|
||||
if (-not $listData -or $listData.type -ne 'array' -or $listData.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
||||
Add-Issue 'JSON RListAppGenealogyVo.data must be AppGenealogyVo[]'
|
||||
$listData = $listEnvelope.properties.data
|
||||
if ($listData.type -ne 'array' -or -not (Test-IsNonNullable $listData)) {
|
||||
Add-Issue 'JSON RListAppGenealogyVo.data must be a non-null AppGenealogyVo array'
|
||||
}
|
||||
Assert-NoConflictingSchemaKeywords $listData 'RListAppGenealogyVo.data'
|
||||
Assert-AllowedSchemaKeywords $listData 'RListAppGenealogyVo.data' @('type', 'items', 'nullable')
|
||||
[void](Test-IsPureSchemaRef $listData.items '#/components/schemas/AppGenealogyVo' 'RListAppGenealogyVo.data.items')
|
||||
}
|
||||
if ($objectEnvelope) {
|
||||
$objectData = $objectEnvelope.properties.PSObject.Properties['data'].Value
|
||||
if (-not $objectData -or $objectData.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
||||
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
|
||||
}
|
||||
[void](Test-IsPureSchemaRef $objectEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
|
||||
}
|
||||
|
||||
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
|
||||
Assert-JsonRequired $genealogy 'AppGenealogyVo' $consumedFields
|
||||
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
|
||||
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
|
||||
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
||||
Assert-JsonProperty $genealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
|
||||
}
|
||||
$roleType = Assert-JsonProperty $genealogy 'AppGenealogyVo' 'roleType' 'string'
|
||||
if ($roleType) {
|
||||
$roleValues = @($roleType.enum | Where-Object { $_ -is [string] -and $_.Trim() }) | Select-Object -Unique
|
||||
if ($roleValues.Count -lt 2) {
|
||||
Add-Issue 'JSON AppGenealogyVo.roleType must declare at least two non-empty enum values'
|
||||
if ($genealogy) {
|
||||
if ($genealogy.type -ne 'object' -or -not (Test-IsNonNullable $genealogy)) {
|
||||
Add-Issue 'JSON AppGenealogyVo must be a non-null object'
|
||||
}
|
||||
Assert-AllowedSchemaKeywords $genealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
|
||||
foreach ($field in $consumedFields) {
|
||||
if ($field -notin @($genealogy.required)) {
|
||||
Add-Issue "JSON AppGenealogyVo.required missing workspace field: $field"
|
||||
}
|
||||
}
|
||||
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
|
||||
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
|
||||
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
||||
$property = $genealogy.properties.PSObject.Properties[$field]
|
||||
if (-not $property -or $property.Value.type -ne 'boolean' -or -not (Test-IsNonNullable $property.Value)) {
|
||||
Add-Issue "JSON AppGenealogyVo.$field must be a non-null boolean"
|
||||
}
|
||||
if ($property) {
|
||||
Assert-NoConflictingSchemaKeywords $property.Value "AppGenealogyVo.$field"
|
||||
Assert-AllowedSchemaKeywords $property.Value "AppGenealogyVo.$field" @('type', 'nullable')
|
||||
}
|
||||
}
|
||||
$roleType = $genealogy.properties.PSObject.Properties['roleType']
|
||||
$rawRoleValues = if ($roleType) { @($roleType.Value.enum) } else { @() }
|
||||
$roleValues = @($rawRoleValues | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
|
||||
$uniqueRoleValues = @($roleValues | Sort-Object -CaseSensitive -Unique)
|
||||
if (-not $roleType -or $roleType.Value.type -ne 'string' -or -not (Test-IsNonNullable $roleType.Value) -or
|
||||
-not (Test-IsJsonArray $roleType.Value.enum) -or $roleValues.Count -ne $rawRoleValues.Count -or
|
||||
$uniqueRoleValues.Count -ne $rawRoleValues.Count -or $rawRoleValues.Count -lt 2) {
|
||||
Add-Issue 'JSON AppGenealogyVo.roleType must be a non-null string with at least two stable non-empty enum values'
|
||||
}
|
||||
if ($roleType) {
|
||||
Assert-NoConflictingSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('enum')
|
||||
Assert-AllowedSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('type', 'enum', 'nullable')
|
||||
}
|
||||
}
|
||||
|
||||
$yamlMine = Get-YamlBlock " $minePath`:" '^ /.*:$'
|
||||
$yamlOverview = Get-YamlBlock " $overviewPath`:" '^ /.*:$'
|
||||
if (-not $yamlMine) {
|
||||
Add-Issue "YAML missing path: $minePath"
|
||||
} elseif ($yamlMine -notmatch [regex]::Escape('#/components/schemas/RListAppGenealogyVo')) {
|
||||
Add-Issue 'YAML GET /mine must return RListAppGenealogyVo'
|
||||
}
|
||||
if (-not $yamlOverview) {
|
||||
Add-Issue "YAML missing path: $overviewPath"
|
||||
} elseif ($yamlOverview -notmatch [regex]::Escape('#/components/schemas/RAppGenealogyVo')) {
|
||||
Add-Issue 'YAML GET /overview must return RAppGenealogyVo'
|
||||
}
|
||||
|
||||
$yamlListEnvelope = Get-YamlBlock ' RListAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlObjectEnvelope = Get-YamlBlock ' RAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
||||
$yamlGenealogy = Get-YamlBlock ' AppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
||||
foreach ($schema in @(
|
||||
@{ Name = 'RListAppGenealogyVo'; Block = $yamlListEnvelope },
|
||||
@{ Name = 'RAppGenealogyVo'; Block = $yamlObjectEnvelope },
|
||||
@{ Name = 'AppGenealogyVo'; Block = $yamlGenealogy }
|
||||
)) {
|
||||
if (-not $schema.Block) { Add-Issue "YAML missing schema owner: $($schema.Name)" }
|
||||
}
|
||||
Assert-YamlRequired $yamlListEnvelope 'RListAppGenealogyVo' @('code', 'data')
|
||||
Assert-YamlRequired $yamlObjectEnvelope 'RAppGenealogyVo' @('code', 'data')
|
||||
Assert-YamlRequired $yamlGenealogy 'AppGenealogyVo' $consumedFields
|
||||
Assert-YamlField $yamlListEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
|
||||
Assert-YamlField $yamlObjectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
|
||||
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
|
||||
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
|
||||
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
||||
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
|
||||
}
|
||||
$yamlRoleType = Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'roleType' 'string'
|
||||
if ($yamlRoleType) {
|
||||
$enumMatch = [regex]::Match(
|
||||
$yamlRoleType,
|
||||
'(?ms)^ enum:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
|
||||
)
|
||||
$yamlRoleValues = if ($enumMatch.Success) {
|
||||
@([regex]::Matches($enumMatch.Groups['rows'].Value, '(?m)^ - (?<value>\S.*?)\s*$') |
|
||||
ForEach-Object { $_.Groups['value'].Value } | Select-Object -Unique)
|
||||
} else { @() }
|
||||
if ($yamlRoleValues.Count -lt 2) {
|
||||
Add-Issue 'YAML AppGenealogyVo.roleType must declare at least two enum values'
|
||||
}
|
||||
}
|
||||
Assert-FixedError 'RGenealogyWorkspaceBadRequest' 400 'GENEALOGY_ID_INVALID'
|
||||
Assert-FixedError 'RGenealogyWorkspaceUnauthorized' 401 'AUTH_REQUIRED'
|
||||
Assert-FixedError 'RGenealogyWorkspaceNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
|
||||
Assert-FixedError 'RGenealogyWorkspaceRateLimited' 429 'RATE_LIMITED'
|
||||
Assert-FixedError 'RGenealogyWorkspaceUnavailable' 500 'GENEALOGY_WORKSPACE_UNAVAILABLE'
|
||||
|
||||
if ($issues.Count -gt 0) {
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add('GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED')
|
||||
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
||||
$lines.Add('- Release behavior still requires authenticated tests for current-account filtering, revocation, cross-account access, 401/403/404/5xx, malformed JSON, and cancellation.')
|
||||
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
||||
throw ($lines -join [Environment]::NewLine)
|
||||
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
|
||||
Write-Output "Issues: $($issues.Count)"
|
||||
$issues | ForEach-Object { Write-Output "- $_" }
|
||||
Write-Output '- /mine and /overview are authenticated private reads; do not publish a generic duplicate detail read or HTTP-200-wrapped errors.'
|
||||
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
|
||||
Write-Output '- Release still requires two-account deployment tests for revocation, cross-account isolation, deletion, rate limits, malformed identifiers, and service failure.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'
|
||||
|
||||