From 4b5e5a1e4e706891ca9987876f3b69a0966a635a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:27:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 10 ++++ .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 48 +++++++++++++++-- .../main/resources/openapi/firstleg-v1.yaml | 1 - .../openapi/phase3-loading-delivery-v1.yaml | 2 +- .../openapi/phase3-pda-convergence-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 16 +++--- .../controller/FbaPricingContractTest.java | 51 +++++++++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../resources/openapi/notification-v1.yaml | 28 ++++++---- .../NotificationOpenApiContractTest.java | 27 ++++++++++ .../src/main/resources/openapi/order-v1.yaml | 11 ++-- scripts/java-topology-contract.test.mjs | 16 ++++++ 13 files changed, 183 insertions(+), 33 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4c67d8bc7..f05dade2c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,15 @@ Java拓扑合同: script: - node --test scripts/java-topology-contract.test.mjs +OpenAPI有效性: + stage: verify + image: node:22-alpine + cache: [] + script: + - npm ci --ignore-scripts --prefix scripts/openapi-validation + - npm test --prefix scripts/openapi-validation + - npm run validate --prefix scripts/openapi-validation + 校验与测试: stage: verify script: @@ -39,6 +48,7 @@ Java拓扑合同: stage: package needs: - Java拓扑合同 + - OpenAPI有效性 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 5cf380ab9..4f6dd8f73 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -9,7 +9,7 @@ paths: summary: List official carrier sources and compliant access modes x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} + '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c7a74e31f..aa91a88be 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -18,7 +18,7 @@ paths: description: 成功或业务失败 content: application/json: - schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'} + schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'} '403': {description: 无权限} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -231,6 +231,44 @@ components: VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + CustomerRuleVersionListApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/CustomerRuleVersionResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CustomerRuleVersionResource: + type: object + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + ruleCode: {type: string} + ruleVersion: {type: integer} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + businessType: {type: string} + chargeMode: {type: string} + taxMode: {type: string} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + currency: {type: string} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + status: {type: string} + remark: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -415,7 +453,7 @@ components: required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: expectedFeeVersion: {type: integer, minimum: 1} - chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} @@ -432,7 +470,7 @@ components: businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} - conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167} roundingScale: {type: integer, minimum: 0, maximum: 4} roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} currency: {type: string, pattern: '^[A-Z]{3}$'} @@ -454,7 +492,7 @@ components: destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} destinationName: {type: string, maxLength: 128, nullable: true} chargeUnit: {type: string, enum: [KG, CBM]} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} additionalFee: {type: number, minimum: 0} effectiveFrom: {type: string, format: date-time} @@ -578,7 +616,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} chargeUnit: {type: string, enum: [KG, CBM]} weightFromKg: {type: number, minimum: 0, nullable: true} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} sourceSheetName: {type: string, maxLength: 128} sourceRowNumber: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f38dfe72d..652cd8003 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1112,7 +1112,6 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} - requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 60e832d45..3b70a9548 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -34,7 +34,7 @@ paths: content: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: - '200': {description: Business envelope; ownership, type and version failures use business error codes} + '200': {description: 'Business envelope; ownership, type and version failures use business error codes'} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: get: summary: Download one loading evidence photo for the active PDA worker diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index eef1c0492..165d36799 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -93,7 +93,7 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} responses: - '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} + '200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: post: summary: 人工确认装柜完成的兼容入口 diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4a953b89d..0d2355f8f 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -8,7 +8,7 @@ paths: get: summary: Query truck appointments parameters: - - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -29,13 +29,13 @@ paths: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} + '200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -84,20 +84,20 @@ paths: get: summary: Query immutable appointment status history responses: - '200': {description: History entries including action, from/to status, operator, reason and time} + '200': {description: 'History entries including action, from/to status, operator, reason and time'} /api/platform/v1/truck-appointments/{id}/attachments: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} + '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} + '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: @@ -181,8 +181,8 @@ components: required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} - oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} - containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 956e14f4a..a3362b6cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.web.bind.annotation.RequestParam; +import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -82,9 +85,57 @@ class FbaPricingContractTest { .doesNotContain("format: int64"); } + @Test + @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") + void freezesCustomerRuleVersionListResponse() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map api = map(new Yaml().load(source)); + Map paths = map(api.get("paths")); + Map get = map(map(paths.get( + "/api/platform/v1/fba-pricing/customer-rules")).get("get")); + Map success = map(map(get.get("responses")).get("200")); + Map responseSchema = map(map(map(success.get("content")) + .get("application/json")).get("schema")); + assertThat(responseSchema).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); + + Map schemas = map(map(api.get("components")).get("schemas")); + Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); + assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); + Map envelopeProperties = map(envelope.get("properties")); + assertThat(envelopeProperties).containsOnlyKeys( + "code", "message", "data", "requestId", "traceId", "timestamp"); + Map data = map(envelopeProperties.get("data")); + assertThat(data).containsEntry("type", "array"); + assertThat(map(data.get("items"))).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionResource"); + + Map resource = map(schemas.get("CustomerRuleVersionResource")); + assertThat(map(resource.get("properties"))).containsOnlyKeys( + "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", + "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", + "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", + "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); + for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { + assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); + } + assertThat(source).doesNotContain("CustomerRuleVersionSummary"); + } + private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return (List) value; + } } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index fe88bdc06..4fa22a7e6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -886,7 +886,7 @@ components: TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} - Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: 'Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配', content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 99f5f992f..e184472d0 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -60,7 +60,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: templateCode, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match template name or title template', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} responses: {'200': {$ref: '#/components/responses/TemplatePage'}} post: @@ -92,7 +92,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: taskNo, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match rendered title', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} /platform/v1/notification-tasks/{id}: @@ -145,17 +145,27 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} - roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} idempotencyKey: {type: string, minLength: 8, maxLength: 128} sourceEventId: {type: string, maxLength: 128, nullable: true} - allOf: - - if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}} - then: {required: [recipientIds]} - - if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}} - then: {required: [roleCode]} + oneOf: + - required: [recipientIds] + properties: + recipientType: {type: string, enum: [USER, MEMBERSHIP]} + not: {required: [roleCode]} + - required: [roleCode] + properties: + recipientType: {type: string, enum: [TENANT_ROLE, PLATFORM_ROLE]} + not: {required: [recipientIds]} + - properties: + recipientType: {type: string, enum: [TENANT_ALL]} + not: + anyOf: + - required: [recipientIds] + - required: [roleCode] CreateTemplateRequest: type: object required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist] diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index af6a9a172..d514112cd 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -69,6 +69,33 @@ class NotificationOpenApiContractTest { assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) .containsEntry("minimum", 0).containsEntry("maximum", 3); assertThat(document).contains("scheduler delivery starts after the retry delay"); + + Map createNotification = map(schemas.get("CreateNotificationRequest")); + Map selectorProperties = map(createNotification.get("properties")); + assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1); + assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1); + List selectorBranches = list(createNotification.get("oneOf")); + assertThat(selectorBranches).hasSize(3); + + Map directRecipients = map(selectorBranches.get(0)); + assertThat(list(directRecipients.get("required"))).containsExactly("recipientIds"); + assertThat(list(map(directRecipients.get("not")).get("required"))).containsExactly("roleCode"); + assertThat(list(map(map(directRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("USER", "MEMBERSHIP"); + + Map roleRecipients = map(selectorBranches.get(1)); + assertThat(list(roleRecipients.get("required"))).containsExactly("roleCode"); + assertThat(list(map(roleRecipients.get("not")).get("required"))).containsExactly("recipientIds"); + assertThat(list(map(map(roleRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ROLE", "PLATFORM_ROLE"); + + Map allRecipients = map(selectorBranches.get(2)); + assertThat(list(map(map(allRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ALL"); + assertThat(list(map(allRecipients.get("not")).get("anyOf"))) + .extracting(value -> list(map(value).get("required"))) + .containsExactly(List.of("recipientIds"), List.of("roleCode")); + assertThat(document).doesNotContain("if:").doesNotContain("then:"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b015fdb8b..114b1e06e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -379,6 +379,11 @@ paths: '403': {description: 需要 merchant:order:view 权限} components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT schemas: TenantOrderStatisticsResponse: type: object @@ -391,12 +396,6 @@ components: nextMonthStart: {type: string, format: date-time} zoneId: {type: string, example: Asia/Shanghai} asOf: {type: string, format: date-time} - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - schemas: UnifiedMainStatus: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 736e5e8af..371e7053f 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -111,6 +111,22 @@ test('GitLab runs the topology contract in a dedicated Node job', async () => { assert.match(packageJob, /^ - Java拓扑合同$/m) }) +test('GitLab validates every static OpenAPI document before packaging', async () => { + const gitlab = await read('.gitlab-ci.yml') + const openapiJob = gitlab.match(/^OpenAPI有效性:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(openapiJob, /^ stage: verify$/m) + assert.match(openapiJob, /^ image: node:[^\s]+$/m) + assert.match(openapiJob, /^ - npm ci --ignore-scripts --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm test --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm run validate --prefix scripts\/openapi-validation$/m) + assert.doesNotMatch(openapiJob, /\bnpx\b|@latest/) + assert.match(packageJob, /^ - Java拓扑合同$/m) + assert.match(packageJob, /^ - OpenAPI有效性$/m) + assert.match(packageJob, /^ - 校验与测试$/m) +}) + test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { const pipeline = await read(path)