$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $pagesPath = Join-Path $root 'pages.json' $mappingPath = Join-Path $root 'docs/接口与页面映射总表.md' if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf)) { throw '缺少页面与接口关系的唯一总表:docs/接口与页面映射总表.md' } $pagesDocument = Get-Content -Raw -Encoding UTF8 -LiteralPath $pagesPath | ConvertFrom-Json $registeredRoutes = @($pagesDocument.pages | ForEach-Object { $_.path }) $mappingLines = @(Get-Content -Encoding UTF8 -LiteralPath $mappingPath) $mappingText = $mappingLines -join "`n" # 第一步:只解析以正式页面编号开头的表格行,避免把正文中的示例路由误算成页面映射。 $routeRows = @( foreach ($line in $mappingLines) { if ($line -match '^\|\s*(?[AGTFRNM]\d{2})\s*\|\s*[^|]+\|\s*`(?pages/[^`]+)`\s*\|') { $columns = @($line.Trim('|').Split('|') | ForEach-Object { $_.Trim() }) [pscustomobject]@{ Code = $Matches.code Route = $Matches.route Line = $line Columns = $columns } } } ) if ($routeRows.Count -ne $registeredRoutes.Count) { throw "页面映射行数与 pages.json 不一致:映射=$($routeRows.Count),注册=$($registeredRoutes.Count)" } # 第二步:每个活动页面必须完整填写业务目标、入口、流程结果、状态和接口业务域;只列编号和路由不算完成迁移。 foreach ($row in $routeRows) { if ($row.Columns.Count -ne 9) { throw "页面映射列数错误:$($row.Code) 应为 9 列,实际为 $($row.Columns.Count)" } foreach ($columnIndex in 1..7) { if ([string]::IsNullOrWhiteSpace($row.Columns[$columnIndex])) { throw "页面映射存在空业务列:$($row.Code) 第 $($columnIndex + 1) 列" } } } # 第三步:页面编号和路由都必须唯一;重复行会制造两个互相冲突的当前合同。 $duplicateCodes = @($routeRows | Group-Object Code | Where-Object Count -gt 1) if ($duplicateCodes.Count -gt 0) { throw "页面映射存在重复编号:$($duplicateCodes.Name -join ', ')" } $duplicateRoutes = @($routeRows | Group-Object Route | Where-Object Count -gt 1) if ($duplicateRoutes.Count -gt 0) { throw "页面映射存在重复路由:$($duplicateRoutes.Name -join ', ')" } # 页面编号必须由实际文件名语义得出,不能把正确路径和另一个页面编号错误拼接。 foreach ($row in $routeRows) { if ($row.Route -notmatch '/(?[a-z]\d{2})-[^/]+$') { throw "页面映射路由缺少文件名语义编号:$($row.Route)" } if ($Matches.routeCode.ToUpperInvariant() -ne $row.Code) { throw "页面映射编号与路由不一致:编号=$($row.Code),路由=$($row.Route)" } } # 第四步:总表顺序和路由集合必须与 pages.json 精确一致,新增或删除页面时必须同轮更新唯一总表。 $mappedRoutes = @($routeRows | ForEach-Object Route) $missingRoutes = @($registeredRoutes | Where-Object { $_ -notin $mappedRoutes }) $staleRoutes = @($mappedRoutes | Where-Object { $_ -notin $registeredRoutes }) if ($missingRoutes.Count -gt 0 -or $staleRoutes.Count -gt 0) { throw "页面映射与 pages.json 不相等:缺失=[$($missingRoutes -join ', ')];陈旧=[$($staleRoutes -join ', ')]" } for ($index = 0; $index -lt $registeredRoutes.Count; $index += 1) { if ($mappedRoutes[$index] -ne $registeredRoutes[$index]) { throw "页面映射顺序错误:第 $($index + 1) 行应为 $($registeredRoutes[$index]),实际为 $($mappedRoutes[$index])" } } # 第五步:A04、G 系列第一轮、F 系列当前写边界和 T01 允许登记已有专项证据; # 其余页面不能提前冒充完成 OpenAPI 审查。F 系列每页必须同时写出已核对事实和仍未启用的边界。 $authSeriesReviewPatterns = @{ A01 = '密码登录线上 wire.*默认 mock.*`validToken` 未由登录接口消费.*`API-AUTH-TAC-001`.*见 2\.10' A04 = '`APP_REGISTER`、4 位码、`RAppLoginVo → AppLoginVo\.access_token` 客户端链已落地.*真实 challenge.*红灯.*见 2\.10' A05 = '`APP_FORGOT_PASSWORD` 客户端链已落地.*真实后端、Android 可访问替代与 MuMu 仍红灯.*见 2\.10' } $gSeriesWorkspacePatterns = @{ G01 = 'Task26 工作区红灯已建立.*`/mine`.*尚未接远端.*Task29 unread-count 红灯.*M01.*见 2\.13/5\.6' G03 = 'Task35 已建立 atomic bootstrap、无 PII operation-status、统一 accessPreset、APP 可信地区与词法 ID 的后端/客户端双红灯.*见 2\.8/5\.12' G05 = 'Task26 选定 `/overview` 为唯一 owner.*对象级授权和错误语义未关闭.*见 2\.8/5\.3' G11 = 'Task38 已建立唯一 merge PUT、settingsVersion/If-Match、409 three-way、待审串行化与 unknown 红灯.*见 2\.16/5\.15' G12 = 'Task39 已建立唯一 GET/PUT、poemSetVersion/If-Match、完整候选集合、unknown 与 CORS owner 红灯.*页面尚未接远端.*见 2\.17/5\.16' } $gSeriesJoinPatterns = @{ G06 = 'Task36 搜索.*见 2\.14/5\.13.*Task37 六 operation 邀请红灯.*见 2\.15/5\.14' G08 = 'Task36 后端红灯.*见 2\.14/5\.13.*Task37 明确邀请不得进入 G08' G09 = 'mine 四状态、拒绝原因和撤回 CAS.*消息中心承诺待接线时删除' G10 = 'Task36 固定 APPROVE/REJECT、PENDING CAS.*手机号节点待接线时原子删除' } $fSeriesReviewPatterns = @{ F01 = 'Task40 已建立唯一 cursor GET、无 PII 投影、逐页鉴权与非泄露 404 红灯.*后端绿前保持 fixture.*见 2\.18/5\.17' F02 = '线上写请求要求 `feedContent`.*minLength=1.*`feedType/mediaOssIds/sortOrder/status`.*`WRITE_UNAVAILABLE`' F03 = 'Task40 已拆分详情与一级评论 cursor owner.*评论写仍只做未提交预览.*见 2\.18/5\.17' F04 = '任务 7 已核对线上列表及写接口.*唯一只读 owner' F05 = '线上未证明收藏合同.*`articleTitle/articleContent`.*minLength=1' F06 = '线上 POST/PUT 已存在.*`categoryId/coverOssId` 为 int64.*真实调用未' F07 = '线上创建要求 `albumName`.*不伪增列表或计数' F08 = '任务 7 已核对.*复合身份.*跨谱' F09 = '旧 `/files/upload` 已下线.*resumable init/chunk/complete.*complete 返回.*`ossId` 为 string.*照片创建要求 int64.*后端消除类型冲突前不得转换或提交' F10 = '任务 7 未发现.*保持关闭' } $rSeriesReviewPatterns = @{ R01 = '任务 8 已核对.*lineage/persons/page.*词法字符串' R02 = '任务 8 已核对.*AppLineagePersonBody.*`name` 必填.*真实写入未启用' R03 = '任务 8 已核对.*relative-records.*不是.*ceremony gifts.*方向语义' R04 = '任务 8 已核对.*`relativeId`.*`relativeName` 必填.*本地预览' R05 = '任务 8 已核对.*ceremonies.*`ceremonyTitle/ceremonyType` 必填.*无正式枚举' R06 = '任务 8 已核对.*invitations.*成员选项.*参与者' R07 = '任务 8 已核对.*`ceremonyId`.*真实写入未启用' R08 = '任务 8 已核对.*growth-records.*`lineagePersonId`.*`recordType` 无枚举' R09 = '任务 8 已核对.*没有独立人生事件接口.*硬关闭' R10 = '任务 8 已核对.*memos.*`memoTitle` 必填.*`completed` 无枚举' R11 = '任务 8 已核对.*merit-records.*`donorName/meritTitle` 必填.*无枚举' } $nmSeriesReviewPatterns = @{ N01 = 'Task29 读取红灯已建立.*完整活动集合.*纯文本.*计数同域.*见 2\.13/5\.6.*Task30 前不得本地伪写' N02 = 'Task29 选定 list-owned snapshot.*删除目标 CTA.*Task30.*私有字符串 ID.*见 2\.13/5\.6/5\.7' M01 = 'Task28 profile GET 红灯.*身份失败不锁菜单.*见 2\.13/5\.5.*Task29 unread-count 红灯.*fixture 伪数.*见 2\.13/5\.6' M02 = 'Task28 读取红灯仍是前置.*Task31 已建立 PUT merge.*If-Match.*typed response.*409 红灯.*头像不混入.*见 2\.13/5\.5/5\.8' M03 = 'Task28 已建立 profile GET 红灯.*普通读取失败不得阻断密码入口.*设备状态仍无合同.*见 2\.13/5\.5' M04 = 'Task33 已建立 raw writeOnly、ALL 会话撤销、typed 错误与 session marker 红灯.*四条密码 wire 必须同批迁移.*见 2\.13/5\.10' M05 = 'Task34 已建立专用 SaToken 发码、全活动六位码、ALL 会话撤销、outbox 与 credential marker 红灯.*依赖 M04 raw wire.*见 2\.13/5\.11' M06 = 'Task27 选定列表唯一 owner 并建立红灯.*专用 required、纯文本、仅发布、顺序和认证语义待后端关闭.*见 2\.13/5\.4' M07 = 'Task25 已接 `POST /genealogy/app/feedback` 严格客户端.*remote 实测与 MuMu 待认证门禁关闭.*见 2\.13' M08 = 'Task37 六 operation 邀请红灯.*见 2\.15/5\.14.*通过前保持不可用且不展示假码' M09 = '套餐/订单端点存在但支付闭环未定义.*删除查询参数演示订单.*见 2\.13' M10 = 'Task9 已完成本机清理基线.*Task32 已建立当前凭证族、幂等 200、logoutCoordinator、required RVoid 与部署复用红灯.*见 2\.13/5\.9' } $prematureRows = @( $routeRows | Where-Object { if ($authSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $authSeriesReviewPatterns[$_.Code] } if ($_.Code -eq 'T01') { return $_.Line -notmatch '专项已核对:现有递归 `LineagePersonTreeView` 不满足' } if ($gSeriesWorkspacePatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $gSeriesWorkspacePatterns[$_.Code] } if ($gSeriesJoinPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $gSeriesJoinPatterns[$_.Code] } if ($fSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $fSeriesReviewPatterns[$_.Code] } if ($rSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $rSeriesReviewPatterns[$_.Code] } if ($nmSeriesReviewPatterns.ContainsKey($_.Code)) { return $_.Line -notmatch $nmSeriesReviewPatterns[$_.Code] } return $_.Line -notmatch '待对应业务阶段 OpenAPI 审查' } ) if ($prematureRows.Count -gt 0) { throw "存在没有准确表达接口审查状态的活动页面:$($prematureRows.Code -join ', ')" } foreach ($heading in @( '## 一、权威边界', '## 二、全局业务与交互合同', '## 三、52 个活动页面映射', '## 四、封存与已移除页面', '## 五、后续接口审查填充规则' )) { if (-not $mappingText.Contains($heading)) { throw "接口与页面映射总表缺少章节:$heading" } } # 第六步:锁定旧资料中仍然有效的关键业务事实,避免未来把表格保留却悄悄清空真正的产品合同。 foreach ($requiredFact in @( '15—64 Unicode code point', '无组成规则', 'SmsCodeSecret', '恰好 6 位 ASCII 数字', '/genealogy/app/auth/phone/sms/code', 'PHONE-CHANGE-OPENAPI-CONTRACT BLOCKED', 'API-PHONE-001', 'STEP_UP_UNAVAILABLE', 'AppGenealogyBootstrapBody', 'GenealogyBootstrapOperationKey', 'GenealogyAccessPreset', 'GenealogyRegionCode', 'FAILED_NO_COMMIT', 'G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED', 'G03-BOOTSTRAP-CLIENT-RELEASE BLOCKED', '/genealogy/app/region/search', 'GET 必须纯读', 'receipt→mine cache→context→G05', 'API-G03-001', 'API-G03-005', 'AppGenealogySettingsUpdateBody', 'GenealogySettingsVersion', 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED', 'API-SETTINGS-001', 'API-SETTINGS-006', 'ACTIVE_PENDING_APPLICATIONS', 'ATOMIC_SINGLE_WINNER', 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE', 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS', 'canonical 设置实际变化', '内部 LF', '控制字符', 'exact local', '旁路详情', 'nullable', '真实 JSON 数组', '允许非语义 tracing header', '禁止 ETag', 'traceparent/tracestate/x-request-id/x-correlation-id', '内联 Header Object', '非 null 的 string schema', 'readOnly/writeOnly', 'allOf', '根层', 'PUBLIC_APPLY→MEMBER_ONLY', 'AppGenerationPoemSetUpdateBody', 'GenerationPoemSetSnapshot', 'GenerationPoemSetVersion', 'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED', 'API-POEM-001', 'API-POEM-006', 'APP_GATEWAY_PREFLIGHT', 'VALIDATE_FINAL_ACTIVE_CAPACITY', 'FRESH_GET_THREE_WAY_NO_AUTO_PUT', 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED', 'FAMILY-FEED-READ-OPENAPI-ADVERSARIAL-CONTRACT PASS MUTANTS=58', 'API-FEED-READ-001', 'API-FEED-READ-006', 'appListFamilyFeeds', 'appGetFamilyFeed', 'appListFamilyFeedRootComments', 'AppFamilyFeedReadItem', 'AppFamilyFeedRootCommentReadItem', 'UPPER_BOUND_KEYSET_LATEST_VISIBLE', 'FAMILY_FEED_NOT_AVAILABLE', 'hasMedia', 'A01、A04、A05 已接入同一个 `TacVerification`', '`APP_SMS_LOGIN`', '`APP_REGISTER`', '`APP_FORGOT_PASSWORD`', '开发/联调 wire 已按线上', '`validToken` 未进入或被服务端消费', '不能把本地滑动成功冒充服务端验证', '聚焦密码框', '审核中记录只展示进度和“撤回申请”', '被拒绝记录展示原因和“修改后重新提交”', '仅成员可见', '公开可申请', 'LineageGraphWindow', 'EMPTY/POPULATED', 'entryPersonIds', 'genealogyRootPersonIds', 'redactedGenealogyRootCount', 'rootVisibility', 'pathCompleteness', 'ancestorPathSegments', 'anchorPersonId', 'schemaVersion', 'returnedNodeCount', 'partnerRelationship', 'relationshipKind', 'parentRole', 'LINEAGE_QUERY_INVALID', 'LINEAGE_FOCUS_NOT_AVAILABLE', 'RELATIONSHIP_PATCH_EMPTY', 'sceneVersion', '/genealogy/app/v2/', 'API-T01-001', '单个原生页面实例', '注册成功响应为 `RAppLoginVo`', '`AppLoginVo.access_token`', 'aria-describedby', '邀请码直接加入且不生成审核记录', 'appApi', '500', '20', '100ms', '44dp', '导航栈语义统一', '跨页面领域数据持久化', '全局文字层级和无障碍第二轮', 'A02 账号登录', 'A06 登录状态', 'G02', 'G04', 'G07', 'T02' )) { if (-not $mappingText.Contains($requiredFact)) { throw "接口与页面映射总表缺少关键业务事实:$requiredFact" } } if ($mappingText -match '汤正华堂侄') { throw '当前产品文档不得使用具体姓名示例,应改为“某某某堂侄”等通用占位表达' } $localRouteBoundary = [regex]::Match( $mappingText, '(?s)### 2\.6 当前本地数据与路由参数边界.*?(?=### 2\.7)' ).Value $localRouteTable = [regex]::Match($localRouteBoundary, '(?s)\| 页面 \|.*?\n\n').Value foreach ($retiredRParameter in @('`count`', '`saveResult`', '`giftId`', '`ritualId`', '`personName`')) { if ($localRouteTable.Contains($retiredRParameter)) { throw "R 系列本地路由边界仍登记已退役参数:$retiredRParameter" } } foreach ($currentRContract in @( '| R01 | `genealogyId`、`state` |', '| R04 | `genealogyId`、`mode`、`relativeId`、`state` |', '| R07 | `genealogyId`、`mode`、`ceremonyId`、`state` |', '| R08、R09 | `genealogyId`、`personId`、`state` |', '| R10、R11 | `genealogyId`、`state` |' )) { if (-not $localRouteTable.Contains($currentRContract)) { throw "R 系列本地路由边界缺少当前复合身份合同:$currentRContract" } } Write-Output "INTERFACE-PAGE-MAPPING-CONTRACT PASS ROUTES=$($routeRows.Count)"