$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml') $issues = New-Object System.Collections.Generic.List[string] function Add-Issue { param([string]$Message) $script:issues.Add($Message) } function Get-Operation { param([string]$Path) $pathProperty = $document.paths.PSObject.Properties[$Path] if (-not $pathProperty -or -not $pathProperty.Value.get) { Add-Issue "JSON missing GET $Path" return $null } return $pathProperty.Value.get } function Get-ResponseRef { param([object]$Operation, [string]$Label) if (-not $Operation) { return '' } $response = $Operation.responses.PSObject.Properties['200'].Value if (-not $response) { Add-Issue "JSON $Label missing 200 response" return '' } if ($response.'$ref') { $responseName = ([string]$response.'$ref').Split('/')[-1] $response = $document.components.responses.PSObject.Properties[$responseName].Value } $media = @($response.content.PSObject.Properties) if ($media.Count -eq 0) { Add-Issue "JSON $Label missing response content" return '' } return [string]$media[0].Value.schema.'$ref' } 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 Assert-SecurityAndClient { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $hasSaToken = $false foreach ($requirement in @($Operation.security)) { if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true } } if (-not $hasSaToken) { Add-Issue "JSON $Label must require SaToken" } $client = @($Operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($client.Count -ne 1 -or $client[0].required -ne $true -or $client[0].schema.type -ne 'string') { Add-Issue "JSON $Label must require one string clientid header" } } function Assert-Required { param([object]$Schema, [string]$SchemaName, [string[]]$Fields) if (-not $Schema) { return } foreach ($field in $Fields) { if ($field -notin @($Schema.required)) { Add-Issue "JSON $SchemaName.required missing: $field" } } } $listPath = '/genealogy/app/notifications' $countPath = '/genealogy/app/notifications/unread-count' $listOperation = Get-Operation $listPath $countOperation = Get-Operation $countPath Assert-SecurityAndClient $listOperation "GET $listPath" Assert-SecurityAndClient $countOperation "GET $countPath" $listRef = Get-ResponseRef $listOperation "GET $listPath" if ($listRef -ne '#/components/schemas/RListNotificationVo') { Add-Issue "JSON GET $listPath must return RListNotificationVo; actual: $listRef" } $countRef = Get-ResponseRef $countOperation "GET $countPath" if ($countRef -ne '#/components/schemas/RNotificationUnreadCount') { Add-Issue "JSON GET $countPath must return RNotificationUnreadCount; actual: $countRef" } $listEnvelope = Get-Schema 'RListNotificationVo' $notice = Get-Schema 'NotificationVo' $countEnvelope = Get-Schema 'RNotificationUnreadCount' Assert-Required $listEnvelope 'RListNotificationVo' @('code', 'data') Assert-Required $notice 'NotificationVo' @('noticeTitle', 'noticeContent', 'publishTime', 'readStatus') Assert-Required $countEnvelope 'RNotificationUnreadCount' @('code', 'data') $listSemantics = ([string]$listOperation.description) + ' ' + ([string]$listEnvelope.properties.data.description) if ($listSemantics -notmatch '(?i)newest[ -]?first' -or $listSemantics -notmatch '(?i)complete active notification set' -or $listSemantics -notmatch '(?i)at most 200') { Add-Issue 'JSON notification list must document newest-first, complete active-set, and at-most-200 semantics' } $countSemantics = ([string]$countOperation.description) + ' ' + ([string]$countEnvelope.properties.data.description) if ($countSemantics -notmatch '(?i)same active notification set' -or $countSemantics -notmatch '(?i)readStatus=UNREAD') { Add-Issue 'JSON unread count must document the same active-set and readStatus=UNREAD scope' } if ($listEnvelope) { if ($listEnvelope.properties.code.type -ne 'integer') { Add-Issue 'JSON RListNotificationVo.code must be integer' } $data = $listEnvelope.properties.data if ($data.type -ne 'array' -or $data.items.'$ref' -ne '#/components/schemas/NotificationVo') { Add-Issue 'JSON RListNotificationVo.data must be NotificationVo[]' } if ([int]$data.maxItems -lt 1 -or [int]$data.maxItems -gt 200) { Add-Issue 'JSON RListNotificationVo.data must declare maxItems between 1 and 200' } } if ($notice) { foreach ($field in @('noticeTitle', 'noticeContent')) { if ($notice.properties.$field.type -ne 'string') { Add-Issue "JSON NotificationVo.$field must be string" } if ([int]$notice.properties.$field.minLength -lt 1) { Add-Issue "JSON NotificationVo.$field must declare minLength >= 1" } } if ([int]$notice.properties.noticeTitle.maxLength -lt 1 -or [int]$notice.properties.noticeTitle.maxLength -gt 50) { Add-Issue 'JSON NotificationVo.noticeTitle must declare maxLength between 1 and 50' } if ([int]$notice.properties.noticeContent.maxLength -lt 1 -or [int]$notice.properties.noticeContent.maxLength -gt 1000) { Add-Issue 'JSON NotificationVo.noticeContent must declare maxLength between 1 and 1000' } $contentDescription = [string]$notice.properties.noticeContent.description if ($contentDescription -notmatch '(?i)plain[ -]?text' -or $contentDescription -notmatch '(?i)complete|untruncated') { Add-Issue 'JSON NotificationVo.noticeContent must declare complete, untruncated plain-text semantics' } $publishTime = $notice.properties.publishTime if ($publishTime.type -ne 'string' -or $publishTime.format -ne 'date-time') { Add-Issue 'JSON NotificationVo.publishTime must be date-time string' } $timePattern = [string]$publishTime.pattern try { $timeRegex = [regex]::new($timePattern) if (-not $timePattern -or -not $timeRegex.IsMatch('2026-07-22T21:00:00Z') -or -not $timeRegex.IsMatch('2026-07-22T21:00:00.123+08:00') -or $timeRegex.IsMatch('2026-07-22T21:00:00')) { Add-Issue 'JSON NotificationVo.publishTime pattern must require an RFC3339 timezone suffix' } } catch { Add-Issue 'JSON NotificationVo.publishTime pattern must be a valid regular expression' } $readStatus = $notice.properties.readStatus $readValues = @($readStatus.enum | Sort-Object) if ($readStatus.type -ne 'string' -or ($readValues -join ',') -ne 'READ,UNREAD') { Add-Issue 'JSON NotificationVo.readStatus must be enum READ/UNREAD' } } if ($countEnvelope) { if ($countEnvelope.properties.code.type -ne 'integer') { Add-Issue 'JSON RNotificationUnreadCount.code must be integer' } $countData = $countEnvelope.properties.data if ($countData.type -ne 'integer' -or $countData.format -ne 'int32' -or [int64]$countData.minimum -ne 0 -or [int64]$countData.maximum -ne 200) { Add-Issue 'JSON RNotificationUnreadCount.data must be int32 in range 0..200' } } foreach ($yamlFact in @( ' /genealogy/app/notifications:', ' /genealogy/app/notifications/unread-count:', '#/components/schemas/RListNotificationVo', '#/components/schemas/RNotificationUnreadCount', ' NotificationVo:', ' RListNotificationVo:', ' RNotificationUnreadCount:', ' - noticeTitle', ' - noticeContent', ' - publishTime', ' - readStatus' )) { if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" } } if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('NOTIFICATION-READ-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- The first adapter drops every server ID, senderPhone, and biz target field; N02 reads only the current immutable in-memory snapshot.') $lines.Add('- The service must document newest-first ordering, the active retention/window policy, and count/list scope consistency.') $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 'NOTIFICATION-READ-OPENAPI-CONTRACT PASS'