$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 $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(?.*?)(?=$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' )) { 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" } } $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)^ (?[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)^ (?[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" } } Write-Output 'G-SERIES-OPENAPI-CONTRACT PASS'