This commit is contained in:
2026-08-25 12:27:47 +08:00
parent 0e0b6f8264
commit 4b5e5a1e4e
13 changed files with 183 additions and 33 deletions
+10
View File
@@ -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
@@ -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
@@ -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}
@@ -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}
@@ -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
@@ -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: 人工确认装柜完成的兼容入口
@@ -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.}
@@ -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<String, Object> api = map(new Yaml().load(source));
Map<String, Object> paths = map(api.get("paths"));
Map<String, Object> get = map(map(paths.get(
"/api/platform/v1/fba-pricing/customer-rules")).get("get"));
Map<String, Object> success = map(map(get.get("responses")).get("200"));
Map<String, Object> responseSchema = map(map(map(success.get("content"))
.get("application/json")).get("schema"));
assertThat(responseSchema).containsEntry(
"$ref", "#/components/schemas/CustomerRuleVersionListApiResponse");
Map<String, Object> schemas = map(map(api.get("components")).get("schemas"));
Map<String, Object> envelope = map(schemas.get("CustomerRuleVersionListApiResponse"));
assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp");
Map<String, Object> envelopeProperties = map(envelope.get("properties"));
assertThat(envelopeProperties).containsOnlyKeys(
"code", "message", "data", "requestId", "traceId", "timestamp");
Map<String, Object> data = map(envelopeProperties.get("data"));
assertThat(data).containsEntry("type", "array");
assertThat(map(data.get("items"))).containsEntry(
"$ref", "#/components/schemas/CustomerRuleVersionResource");
Map<String, Object> 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<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<Object> list(Object value) {
return (List<Object>) value;
}
}
+1 -1
View File
@@ -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'}}}}
@@ -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]
@@ -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<String, Object> createNotification = map(schemas.get("CreateNotificationRequest"));
Map<String, Object> selectorProperties = map(createNotification.get("properties"));
assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1);
assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1);
List<Object> selectorBranches = list(createNotification.get("oneOf"));
assertThat(selectorBranches).hasSize(3);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> paths, String path, String method) {
@@ -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]
+16
View File
@@ -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)