Files
jiapuapp/tests/interface-page-mapping-contract.ps1
T
2026-07-23 17:21:33 +08:00

321 lines
15 KiB
PowerShell

$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*(?<code>[AGTFRNM]\d{2})\s*\|\s*[^|]+\|\s*`(?<route>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 '/(?<routeCode>[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)"