$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $json = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.json') -Raw -Encoding UTF8 | ConvertFrom-Json $yaml = Get-Content -LiteralPath (Join-Path $root 'APP.openapi.yaml') -Raw -Encoding UTF8 $blockers = [System.Collections.Generic.List[string]]::new() function Require-Operation { param([string]$Path, [string]$Method) $pathProperty = $json.paths.PSObject.Properties[$Path] if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$Method]) { throw "认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path" } if ($yaml -notmatch [regex]::Escape(" $Path`:") -or $yaml -notmatch "(?m)^ $Method`:\s*$") { throw "YAML 认证源合同缺少操作:$($Method.ToUpperInvariant()) $Path" } return $pathProperty.Value.PSObject.Properties[$Method].Value } function Require-Schema { param([string]$Name) $property = $json.components.schemas.PSObject.Properties[$Name] if ($null -eq $property) { throw "认证源合同缺少 schema:$Name" } if ($yaml -notmatch "(?m)^ $([regex]::Escape($Name)):\s*$") { throw "YAML 认证源合同缺少 schema:$Name" } return $property.Value } function Assert-ExactSet { param([object[]]$Actual, [object[]]$Expected, [string]$Label) $actualSet = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique) $expectedSet = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique) if (($actualSet -join ',') -ne ($expectedSet -join ',')) { throw "$Label 漂移:actual=[$($actualSet -join ',')] expected=[$($expectedSet -join ',')]" } } $operations = @( @('/captcha/require', 'get'), @('/captcha/challenge', 'post'), @('/captcha/verify', 'post'), @('/genealogy/app/auth/sms/code', 'post'), @('/genealogy/app/auth/login', 'post'), @('/genealogy/app/auth/login/sms', 'post'), @('/genealogy/app/auth/register', 'post'), @('/genealogy/app/auth/password/reset', 'put') ) foreach ($entry in $operations) { [void](Require-Operation -Path $entry[0] -Method $entry[1]) } $expectedVerificationScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_PHONE_CHANGE', 'APP_ACCOUNT_DEACTIVATE') foreach ($schemaName in @('VerificationChallengeBody', 'VerificationCheckBody')) { $schema = Require-Schema -Name $schemaName Assert-ExactSet -Actual @($schema.properties.sceneCode.enum) -Expected $expectedVerificationScenes -Label "$schemaName.sceneCode" } $check = Require-Schema -Name 'VerificationCheckBody' Assert-ExactSet -Actual @($check.properties.payload.oneOf.'$ref') -Expected @('#/components/schemas/TianaiVerificationPayload', '#/components/schemas/SystemImageVerificationPayload') -Label 'VerificationCheckBody.payload.oneOf' $requiredCheckFields = @('tenantId', 'clientId', 'sceneCode', 'subject', 'challengeId', 'providerCode', 'captchaType', 'payload') $missingCheckFields = @($requiredCheckFields | Where-Object { $_ -notin @($check.required) }) if ($missingCheckFields.Count -gt 0) { $blockers.Add("VerificationCheckBody 未强制字段:$($missingCheckFields -join '、')。") } if ($check.additionalProperties -ne $false) { $blockers.Add('VerificationCheckBody 未设置 additionalProperties=false,服务端校验边界仍可接受未声明字段。') } if (@($check.oneOf).Count -lt 2 -or $check.discriminator.propertyName -ne 'providerCode') { $blockers.Add('VerificationCheckBody 未用 providerCode 判别至少两个 oneOf 分支,providerCode、captchaType 与 payload 形态无法被原子约束。') } $tianaiPayload = Require-Schema -Name 'TianaiVerificationPayload' Assert-ExactSet -Actual @($tianaiPayload.required) -Expected @('track') -Label 'TianaiVerificationPayload.required' if ($tianaiPayload.properties.track.'$ref' -ne '#/components/schemas/TianaiCaptchaTrack') { throw '天爱校验载荷必须唯一包装为 payload.track' } if ($tianaiPayload.additionalProperties -ne $false) { $blockers.Add('TianaiVerificationPayload 未设置 additionalProperties=false,历史直传字段仍可能绕过 payload.track 约束。') } $systemImagePayload = Require-Schema -Name 'SystemImageVerificationPayload' if ($systemImagePayload.additionalProperties -ne $false) { $blockers.Add('SystemImageVerificationPayload 未设置 additionalProperties=false,系统图形验证码载荷边界未闭合。') } $track = Require-Schema -Name 'TianaiCaptchaTrack' Assert-ExactSet -Actual @($track.required) -Expected @('bgImageWidth', 'bgImageHeight', 'startTime', 'stopTime', 'trackList') -Label 'TianaiCaptchaTrack.required' if ($track.properties.trackList.minItems -ne 1) { throw '天爱行为轨迹不得为空' } $smsCode = Require-Schema -Name 'SmsCodeBody' Assert-ExactSet -Actual @($smsCode.required) -Expected @('clientId', 'grantType', 'tenantId', 'sceneCode', 'phone', 'validToken') -Label 'SmsCodeBody.required' if ($smsCode.additionalProperties -ne $false) { throw 'SmsCodeBody 必须拒绝历史供应商字段' } $expectedPublicSmsScenes = @('APP_SMS_LOGIN', 'APP_REGISTER', 'APP_FORGOT_PASSWORD', 'APP_ACCOUNT_DEACTIVATE') $actualPublicSmsScenes = @($smsCode.properties.sceneCode.enum | ForEach-Object { [string]$_ } | Sort-Object -Unique) $expectedPublicSmsScenes = @($expectedPublicSmsScenes | Sort-Object -Unique) if (($actualPublicSmsScenes -join ',') -ne ($expectedPublicSmsScenes -join ',')) { $blockers.Add('公共 SmsCodeBody.sceneCode 必须删除 APP_PHONE_CHANGE;换绑发码只能由需要 SaToken 的专用 /auth/phone/sms/code operation 持有。') } $smsSecretProperty = $json.components.schemas.PSObject.Properties['SmsCodeSecret'] if ($null -eq $smsSecretProperty) { $blockers.Add('缺少全认证场景共用的 SmsCodeSecret;当前四位码必须原子升级为严格六位 ASCII 数字,不能保留 4/6 双接受。') } else { $smsSecret = $smsSecretProperty.Value if ($smsSecret.type -ne 'string' -or $smsSecret.writeOnly -ne $true -or [int]$smsSecret.minLength -ne 6 -or [int]$smsSecret.maxLength -ne 6 -or [string]$smsSecret.pattern -ne '^[0-9]{6}$' -or $smsSecret.example) { $blockers.Add('SmsCodeSecret 必须是无示例、保留前导零的 writeOnly 六位 ASCII 数字字符串。') } } foreach ($schemaName in @('SmsLoginBody', 'PasswordRegisterBody', 'PasswordResetBody')) { $schema = Require-Schema -Name $schemaName $actualRef = [string]$schema.properties.smsCode.'$ref' if ($actualRef -ne '#/components/schemas/SmsCodeSecret') { $blockers.Add("$schemaName.smsCode 必须引用唯一 SmsCodeSecret,禁止继续内联四位码或接受双长度。") } } $passwordLogin = Require-Schema -Name 'PasswordLoginBody' $passwordFields = @($passwordLogin.properties.PSObject.Properties.Name) $passwordRequired = @($passwordLogin.required) if ('validToken' -notin $passwordFields -or 'validToken' -notin $passwordRequired) { $blockers.Add('PasswordLoginBody 未定义并强制消费 validToken,密码登录无法形成服务端 TAC 闭环,客户端先滑后登录仍可被绕过。') } if ($blockers.Count -gt 0) { $details = $blockers | ForEach-Object { "- $_" } throw (@( 'AUTH-TAC-OPENAPI-CONTRACT BLOCKED' $details '- 关闭条件:后端同步更新同版本 JSON/YAML;校验体按 providerCode 严格区分供应商并拒绝缺字段/多余字段;密码登录原子消费绑定租户、客户端、场景、手机号的一次性 TAC 票据;全活动短信码原子迁移为六位;APP_PHONE_CHANGE 改由专用受保护发码 operation;全部部署到 HTTPS 环境并通过反向用例。' ) -join [Environment]::NewLine) } Write-Output 'AUTH-TAC-OPENAPI-CONTRACT PASS'