diff --git a/contracts/openapi/delivery-platform-api.json b/contracts/openapi/delivery-platform-api.json index d7307af..e9b49ae 100644 --- a/contracts/openapi/delivery-platform-api.json +++ b/contracts/openapi/delivery-platform-api.json @@ -1184,6 +1184,80 @@ } } }, + "/api/v1/callback-inbox/{id}/status" : { + "patch" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "patchStatus_3", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackInboxStatusPatchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackInboxResponse" + } + } + } + } + } + } + }, + "/api/v1/callback-inbox/{id}/link" : { + "patch" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "patchLink", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackInboxLinkPatchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackInboxResponse" + } + } + } + } + } + } + }, "/api/v1/ping" : { "get" : { "tags" : [ "ping-controller" ], @@ -1205,6 +1279,140 @@ } } }, + "/api/v1/integration/product-lines" : { + "get" : { + "tags" : [ "integration-catalog-controller" ], + "operationId" : "listProductLines", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 50, + "maximum" : 200, + "minimum" : 1 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseProductLineResponse" + } + } + } + } + } + } + }, + "/api/v1/integration/product-lines/{id}" : { + "get" : { + "tags" : [ "integration-catalog-controller" ], + "operationId" : "getProductLine", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ProductLineResponse" + } + } + } + } + } + } + }, + "/api/v1/integration/environments" : { + "get" : { + "tags" : [ "integration-catalog-controller" ], + "operationId" : "listEnvironments", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 50, + "maximum" : 200, + "minimum" : 1 + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseIntegrationEnvironmentResponse" + } + } + } + } + } + } + }, + "/api/v1/integration/environments/{id}" : { + "get" : { + "tags" : [ "integration-catalog-controller" ], + "operationId" : "getEnvironment", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/IntegrationEnvironmentResponse" + } + } + } + } + } + } + }, "/api/v1/dictionaries/{type}" : { "get" : { "tags" : [ "dictionary-controller" ], @@ -1234,10 +1442,138 @@ } } }, + "/api/v1/callback-inbox" : { + "get" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "list_5", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 20, + "maximum" : 200, + "minimum" : 1 + } + }, { + "name" : "status", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "eventType", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "snCode", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "projectId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "productLineId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "environmentId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "receivedFrom", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string", + "format" : "date-time" + } + }, { + "name" : "receivedTo", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string", + "format" : "date-time" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseCallbackInboxResponse" + } + } + } + } + } + } + }, + "/api/v1/callback-inbox/{id}" : { + "get" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "get_5", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackInboxResponse" + } + } + } + } + } + } + }, "/api/v1/audit-events" : { "get" : { "tags" : [ "audit-controller" ], - "operationId" : "list_5", + "operationId" : "list_6", "parameters" : [ { "name" : "entityType", "in" : "query", @@ -1790,6 +2126,114 @@ }, "required" : [ "status" ] }, + "CallbackInboxStatusPatchRequest" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "status" ] + }, + "CallbackInboxResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "sourceSystem" : { + "type" : "string" + }, + "externalMessageId" : { + "type" : "string" + }, + "schemaVersion" : { + "type" : "string" + }, + "eventType" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "rawPayload" : { + "type" : "string" + }, + "idempotencyKey" : { + "type" : "string" + }, + "licenseSnId" : { + "type" : "integer", + "format" : "int64" + }, + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractId" : { + "type" : "integer", + "format" : "int64" + }, + "snCode" : { + "type" : "string" + }, + "productLineId" : { + "type" : "integer", + "format" : "int64" + }, + "integrationEnvironmentId" : { + "type" : "integer", + "format" : "int64" + }, + "receivedAt" : { + "type" : "string", + "format" : "date-time" + }, + "processedAt" : { + "type" : "string", + "format" : "date-time" + }, + "processedByUserId" : { + "type" : "string" + }, + "failureReason" : { + "type" : "string" + }, + "operatorNote" : { + "type" : "string" + }, + "webhookReceiptId" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "CallbackInboxLinkPatchRequest" : { + "type" : "object", + "properties" : { + "licenseSnId" : { + "type" : "integer", + "format" : "int64" + }, + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractId" : { + "type" : "integer", + "format" : "int64" + } + } + }, "PageResponseProjectResponse" : { "type" : "object", "properties" : { @@ -1836,6 +2280,114 @@ } } }, + "PageResponseProductLineResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ProductLineResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "ProductLineResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "code" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "description" : { + "type" : "string" + }, + "enabled" : { + "type" : "boolean" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "IntegrationEnvironmentResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "code" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "bitanswerBaseUrl" : { + "type" : "string" + }, + "kind" : { + "type" : "string" + }, + "productLineId" : { + "type" : "integer", + "format" : "int64" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "PageResponseIntegrationEnvironmentResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/IntegrationEnvironmentResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, "DictionaryItemResponse" : { "type" : "object", "properties" : { @@ -1920,6 +2472,29 @@ } } }, + "PageResponseCallbackInboxResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CallbackInboxResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, "AuditEventResponse" : { "type" : "object", "properties" : { diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java index 2573a92..1836e42 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java @@ -21,5 +21,8 @@ public final class AuditActions { public static final String LICENSE_SN_UPDATED = "LICENSE_SN_UPDATED"; public static final String LICENSE_SN_STATUS_CHANGED = "LICENSE_SN_STATUS_CHANGED"; + public static final String CALLBACK_INBOX_STATUS_CHANGED = "CALLBACK_INBOX_STATUS_CHANGED"; + public static final String CALLBACK_INBOX_LINK_UPDATED = "CALLBACK_INBOX_LINK_UPDATED"; + private AuditActions() {} } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java index ac6790b..1db8ee9 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java @@ -5,6 +5,7 @@ public final class AuditEntityTypes { public static final String CONTRACT = "CONTRACT"; public static final String DELIVERY_BATCH = "DELIVERY_BATCH"; public static final String LICENSE_SN = "LICENSE_SN"; + public static final String CALLBACK_INBOX = "CALLBACK_INBOX"; private AuditEntityTypes() {} } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java new file mode 100644 index 0000000..2232a4a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java @@ -0,0 +1,74 @@ +package cn.craftlabs.platform.api.callback; + +import cn.craftlabs.platform.api.service.CallbackInboxService; +import cn.craftlabs.platform.api.web.dto.CallbackInboxLinkPatchRequest; +import cn.craftlabs.platform.api.web.dto.CallbackInboxResponse; +import cn.craftlabs.platform.api.web.dto.CallbackInboxStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.OffsetDateTime; + +@RestController +@RequestMapping("/api/v1/callback-inbox") +@Validated +public class CallbackInboxController { + + private final CallbackInboxService callbackInboxService; + + public CallbackInboxController(CallbackInboxService callbackInboxService) { + this.callbackInboxService = callbackInboxService; + } + + @GetMapping + public PageResponse list( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "eventType", required = false) String eventType, + @RequestParam(value = "snCode", required = false) String snCode, + @RequestParam(value = "projectId", required = false) Long projectId, + @RequestParam(value = "productLineId", required = false) Long productLineId, + @RequestParam(value = "environmentId", required = false) Long environmentId, + @RequestParam(value = "receivedFrom", required = false) OffsetDateTime receivedFrom, + @RequestParam(value = "receivedTo", required = false) OffsetDateTime receivedTo) { + return callbackInboxService.page( + page, + size, + status, + eventType, + snCode, + projectId, + productLineId, + environmentId, + receivedFrom, + receivedTo); + } + + @GetMapping("/{id}") + public CallbackInboxResponse get(@PathVariable("id") long id) { + return callbackInboxService.getById(id); + } + + @PatchMapping("/{id}/status") + public CallbackInboxResponse patchStatus( + @PathVariable("id") long id, @Valid @RequestBody CallbackInboxStatusPatchRequest request) { + return callbackInboxService.patchStatus(id, request); + } + + @PatchMapping("/{id}/link") + public CallbackInboxResponse patchLink( + @PathVariable("id") long id, @Valid @RequestBody CallbackInboxLinkPatchRequest request) { + return callbackInboxService.patchLink(id, request); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java index 7ab3fd6..5e2e52a 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java @@ -1,8 +1,10 @@ package cn.craftlabs.platform.api.config; +import cn.craftlabs.platform.api.security.InternalTokenAuthenticationFilter; import cn.craftlabs.platform.api.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -12,14 +14,33 @@ import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** - * I1:JWT(Bearer)保护业务 API;登录与健康检查、OpenAPI 文档放行。 + * I1:JWT(Bearer)保护业务 API;I5:{@code /internal/**} 使用内部共享 Token,与 JWT 分离。 */ @Configuration @EnableWebSecurity public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) + @Order(1) + public SecurityFilterChain internalFilterChain( + HttpSecurity http, InternalTokenAuthenticationFilter internalTokenFilter) throws Exception { + http.securityMatcher("/internal/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement( + sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(b -> b.disable()) + .exceptionHandling( + ex -> + ex.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .addFilterBefore(internalTokenFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + @Order(2) + public SecurityFilterChain jwtFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement( diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CallbackInboxStatus.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CallbackInboxStatus.java new file mode 100644 index 0000000..359dac9 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CallbackInboxStatus.java @@ -0,0 +1,8 @@ +package cn.craftlabs.platform.api.domain; + +public enum CallbackInboxStatus { + PENDING, + PROCESSED, + FAILED, + IGNORED +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java new file mode 100644 index 0000000..81268df --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java @@ -0,0 +1,50 @@ +package cn.craftlabs.platform.api.integration; + +import cn.craftlabs.platform.api.service.IntegrationCatalogService; +import cn.craftlabs.platform.api.web.dto.IntegrationEnvironmentResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import cn.craftlabs.platform.api.web.dto.ProductLineResponse; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/integration") +@Validated +public class IntegrationCatalogController { + + private final IntegrationCatalogService integrationCatalogService; + + public IntegrationCatalogController(IntegrationCatalogService integrationCatalogService) { + this.integrationCatalogService = integrationCatalogService; + } + + @GetMapping("/product-lines") + public PageResponse listProductLines( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "50") @Min(1) @Max(200) int size) { + return integrationCatalogService.pageProductLines(page, size); + } + + @GetMapping("/product-lines/{id}") + public ProductLineResponse getProductLine(@PathVariable("id") long id) { + return integrationCatalogService.getProductLine(id); + } + + @GetMapping("/environments") + public PageResponse listEnvironments( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "50") @Min(1) @Max(200) int size) { + return integrationCatalogService.pageEnvironments(page, size); + } + + @GetMapping("/environments/{id}") + public IntegrationEnvironmentResponse getEnvironment(@PathVariable("id") long id) { + return integrationCatalogService.getEnvironment(id); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/internal/CallbackInternalController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/internal/CallbackInternalController.java new file mode 100644 index 0000000..881315d --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/internal/CallbackInternalController.java @@ -0,0 +1,31 @@ +package cn.craftlabs.platform.api.internal; + +import cn.craftlabs.platform.api.service.CallbackEventIngestService; +import cn.craftlabs.platform.api.web.dto.CallbackEventIngestRequest; +import cn.craftlabs.platform.api.web.dto.CallbackEventIngestResponse; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/internal/v1") +public class CallbackInternalController { + + private final CallbackEventIngestService ingestService; + + public CallbackInternalController(CallbackEventIngestService ingestService) { + this.ingestService = ingestService; + } + + @PostMapping("/callback-events") + public CallbackEventIngestResponse ingest( + @Valid @RequestBody CallbackEventIngestRequest body, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) { + return ingestService.ingest(body, idempotencyKey); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInbox.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInbox.java new file mode 100644 index 0000000..9d449c6 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInbox.java @@ -0,0 +1,253 @@ +package cn.craftlabs.platform.api.persistence.callback; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_callback_inbox") +public class PlatformCallbackInbox { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("source_system") + private String sourceSystem; + + @TableField("external_message_id") + private String externalMessageId; + + @TableField("schema_version") + private String schemaVersion; + + @TableField("event_type") + private String eventType; + + private String status; + + @TableField("raw_payload") + private String rawPayload; + + @TableField("idempotency_key") + private String idempotencyKey; + + @TableField("license_sn_id") + private Long licenseSnId; + + @TableField("project_id") + private Long projectId; + + @TableField("contract_id") + private Long contractId; + + @TableField("sn_code") + private String snCode; + + @TableField("product_line_id") + private Long productLineId; + + @TableField("integration_environment_id") + private Long integrationEnvironmentId; + + @TableField("received_at") + private OffsetDateTime receivedAt; + + @TableField("processed_at") + private OffsetDateTime processedAt; + + @TableField("processed_by_user_id") + private String processedByUserId; + + @TableField("failure_reason") + private String failureReason; + + @TableField("operator_note") + private String operatorNote; + + @TableField("webhook_receipt_id") + private String webhookReceiptId; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSourceSystem() { + return sourceSystem; + } + + public void setSourceSystem(String sourceSystem) { + this.sourceSystem = sourceSystem; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRawPayload() { + return rawPayload; + } + + public void setRawPayload(String rawPayload) { + this.rawPayload = rawPayload; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getLicenseSnId() { + return licenseSnId; + } + + public void setLicenseSnId(Long licenseSnId) { + this.licenseSnId = licenseSnId; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } + + public String getSnCode() { + return snCode; + } + + public void setSnCode(String snCode) { + this.snCode = snCode; + } + + public Long getProductLineId() { + return productLineId; + } + + public void setProductLineId(Long productLineId) { + this.productLineId = productLineId; + } + + public Long getIntegrationEnvironmentId() { + return integrationEnvironmentId; + } + + public void setIntegrationEnvironmentId(Long integrationEnvironmentId) { + this.integrationEnvironmentId = integrationEnvironmentId; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public OffsetDateTime getProcessedAt() { + return processedAt; + } + + public void setProcessedAt(OffsetDateTime processedAt) { + this.processedAt = processedAt; + } + + public String getProcessedByUserId() { + return processedByUserId; + } + + public void setProcessedByUserId(String processedByUserId) { + this.processedByUserId = processedByUserId; + } + + public String getFailureReason() { + return failureReason; + } + + public void setFailureReason(String failureReason) { + this.failureReason = failureReason; + } + + public String getOperatorNote() { + return operatorNote; + } + + public void setOperatorNote(String operatorNote) { + this.operatorNote = operatorNote; + } + + public String getWebhookReceiptId() { + return webhookReceiptId; + } + + public void setWebhookReceiptId(String webhookReceiptId) { + this.webhookReceiptId = webhookReceiptId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInboxMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInboxMapper.java new file mode 100644 index 0000000..ce52434 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/callback/PlatformCallbackInboxMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.callback; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformCallbackInboxMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironment.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironment.java new file mode 100644 index 0000000..22ef5da --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironment.java @@ -0,0 +1,97 @@ +package cn.craftlabs.platform.api.persistence.integration; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_integration_environment") +public class PlatformIntegrationEnvironment { + + @TableId(type = IdType.AUTO) + private Long id; + + private String code; + + private String name; + + @TableField("bitanswer_base_url") + private String bitanswerBaseUrl; + + private String kind; + + @TableField("product_line_id") + private Long productLineId; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBitanswerBaseUrl() { + return bitanswerBaseUrl; + } + + public void setBitanswerBaseUrl(String bitanswerBaseUrl) { + this.bitanswerBaseUrl = bitanswerBaseUrl; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public Long getProductLineId() { + return productLineId; + } + + public void setProductLineId(Long productLineId) { + this.productLineId = productLineId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironmentMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironmentMapper.java new file mode 100644 index 0000000..434e1e7 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformIntegrationEnvironmentMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.integration; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformIntegrationEnvironmentMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLine.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLine.java new file mode 100644 index 0000000..805cedd --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLine.java @@ -0,0 +1,85 @@ +package cn.craftlabs.platform.api.persistence.integration; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_product_line") +public class PlatformProductLine { + + @TableId(type = IdType.AUTO) + private Long id; + + private String code; + + private String name; + + private String description; + + private Boolean enabled; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLineMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLineMapper.java new file mode 100644 index 0000000..dbf813a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/integration/PlatformProductLineMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.integration; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformProductLineMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/InternalTokenAuthenticationFilter.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/InternalTokenAuthenticationFilter.java new file mode 100644 index 0000000..24c4216 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/InternalTokenAuthenticationFilter.java @@ -0,0 +1,71 @@ +package cn.craftlabs.platform.api.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** + * 服务间内部路由:{@code X-Platform-Internal-Token},与 JWT 分离。 + */ +@Component +public class InternalTokenAuthenticationFilter extends OncePerRequestFilter { + + public static final String HEADER_NAME = "X-Platform-Internal-Token"; + + @Value("${platform.internal.token:}") + private String expectedToken; + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + String uri = request.getRequestURI(); + return uri == null || !uri.startsWith("/internal/"); + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + if (!StringUtils.hasText(expectedToken)) { + response.sendError(HttpStatus.UNAUTHORIZED.value()); + return; + } + String presented = request.getHeader(HEADER_NAME); + if (!constantTimeEquals(presented, expectedToken)) { + response.sendError(HttpStatus.UNAUTHORIZED.value()); + return; + } + var auth = + new UsernamePasswordAuthenticationToken( + "platform-internal", + null, + List.of(new SimpleGrantedAuthority("ROLE_INTERNAL"))); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + byte[] ba = a.getBytes(StandardCharsets.UTF_8); + byte[] bb = b.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(ba, bb); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackEventIngestService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackEventIngestService.java new file mode 100644 index 0000000..f7ba7c3 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackEventIngestService.java @@ -0,0 +1,189 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.domain.CallbackInboxStatus; +import cn.craftlabs.platform.api.persistence.callback.PlatformCallbackInbox; +import cn.craftlabs.platform.api.persistence.callback.PlatformCallbackInboxMapper; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLine; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLineMapper; +import cn.craftlabs.platform.api.persistence.license.PlatformLicenseSn; +import cn.craftlabs.platform.api.persistence.license.PlatformLicenseSnMapper; +import cn.craftlabs.platform.api.web.dto.CallbackEventIngestRequest; +import cn.craftlabs.platform.api.web.dto.CallbackEventIngestResponse; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +@Service +public class CallbackEventIngestService { + + private static final int SUPPORTED_SCHEMA_MAJOR = 1; + + private final PlatformCallbackInboxMapper inboxMapper; + private final PlatformLicenseSnMapper licenseSnMapper; + private final PlatformContractLineMapper contractLineMapper; + private final ObjectMapper objectMapper; + + public CallbackEventIngestService( + PlatformCallbackInboxMapper inboxMapper, + PlatformLicenseSnMapper licenseSnMapper, + PlatformContractLineMapper contractLineMapper, + ObjectMapper objectMapper) { + this.inboxMapper = inboxMapper; + this.licenseSnMapper = licenseSnMapper; + this.contractLineMapper = contractLineMapper; + this.objectMapper = objectMapper; + } + + @Transactional + public CallbackEventIngestResponse ingest(CallbackEventIngestRequest request, String idempotencyHeader) { + validateSchemaMajor(request.getSchemaVersion()); + String source = request.getSourceSystem().trim(); + String ext = request.getExternalMessageId().trim(); + if (!StringUtils.hasText(source) || !StringUtils.hasText(ext)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "sourceSystem and externalMessageId must not be blank"); + } + + PlatformCallbackInbox existing = + inboxMapper.selectOne( + Wrappers.lambdaQuery(PlatformCallbackInbox.class) + .eq(PlatformCallbackInbox::getSourceSystem, source) + .eq(PlatformCallbackInbox::getExternalMessageId, ext)); + if (existing != null) { + return new CallbackEventIngestResponse(existing.getId(), true); + } + + String rawJson; + try { + rawJson = objectMapper.writeValueAsString(request.getRawPayload()); + } catch (JsonProcessingException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "rawPayload must be JSON-serializable"); + } + + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + OffsetDateTime receivedAt = request.getReceivedAt() != null ? request.getReceivedAt() : now; + + String idempotency = + firstNonBlank( + blankToNull(request.getIdempotencyKey()), + blankToNull(idempotencyHeader)); + + PlatformCallbackInbox row = new PlatformCallbackInbox(); + row.setSourceSystem(source); + row.setExternalMessageId(ext); + row.setSchemaVersion(request.getSchemaVersion().trim()); + row.setEventType(request.getEventType().trim()); + row.setStatus(CallbackInboxStatus.PENDING.name()); + row.setRawPayload(rawJson); + row.setIdempotencyKey(idempotency); + row.setWebhookReceiptId(blankToNull(request.getWebhookReceiptId())); + row.setReceivedAt(receivedAt); + row.setCreatedAt(now); + row.setUpdatedAt(now); + + applySnResolution(row, request.getRawPayload()); + + try { + inboxMapper.insert(row); + } catch (DataIntegrityViolationException e) { + PlatformCallbackInbox again = + inboxMapper.selectOne( + Wrappers.lambdaQuery(PlatformCallbackInbox.class) + .eq(PlatformCallbackInbox::getSourceSystem, source) + .eq(PlatformCallbackInbox::getExternalMessageId, ext)); + if (again != null) { + return new CallbackEventIngestResponse(again.getId(), true); + } + throw e; + } + return new CallbackEventIngestResponse(row.getId(), false); + } + + private void validateSchemaMajor(String schemaVersion) { + if (!StringUtils.hasText(schemaVersion)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "schemaVersion is required"); + } + String trimmed = schemaVersion.trim(); + int dot = trimmed.indexOf('.'); + String majorPart = dot < 0 ? trimmed : trimmed.substring(0, dot); + if (!StringUtils.hasText(majorPart)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid schemaVersion"); + } + int major; + try { + major = Integer.parseInt(majorPart); + } catch (NumberFormatException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "unsupported schema major version: " + majorPart); + } + if (major != SUPPORTED_SCHEMA_MAJOR) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "unsupported schema major version: " + major); + } + } + + private void applySnResolution(PlatformCallbackInbox row, JsonNode payload) { + String sn = extractSnCode(payload); + if (!StringUtils.hasText(sn)) { + return; + } + row.setSnCode(sn); + PlatformLicenseSn license = + licenseSnMapper.selectOne( + Wrappers.lambdaQuery(PlatformLicenseSn.class) + .eq(PlatformLicenseSn::getSnCode, sn)); + if (license == null) { + return; + } + row.setLicenseSnId(license.getId()); + row.setProjectId(license.getProjectId()); + Long lineId = license.getContractLineId(); + if (lineId != null) { + PlatformContractLine line = contractLineMapper.selectById(lineId); + if (line != null) { + row.setContractId(line.getContractId()); + } + } + } + + private static String extractSnCode(JsonNode payload) { + if (payload == null || !payload.isObject()) { + return null; + } + for (String k : List.of("sn", "snCode", "sn_code")) { + JsonNode n = payload.get(k); + if (n != null && n.isTextual()) { + String t = n.asText(); + if (StringUtils.hasText(t)) { + return t.trim(); + } + } + } + return null; + } + + private static String blankToNull(String s) { + return StringUtils.hasText(s) ? s.trim() : null; + } + + private static String firstNonBlank(String a, String b) { + if (StringUtils.hasText(a)) { + return a; + } + if (StringUtils.hasText(b)) { + return b; + } + return null; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java new file mode 100644 index 0000000..b763dc4 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java @@ -0,0 +1,254 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.audit.AuditActions; +import cn.craftlabs.platform.api.audit.AuditEntityTypes; +import cn.craftlabs.platform.api.domain.CallbackInboxStatus; +import cn.craftlabs.platform.api.persistence.callback.PlatformCallbackInbox; +import cn.craftlabs.platform.api.persistence.callback.PlatformCallbackInboxMapper; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper; +import cn.craftlabs.platform.api.persistence.license.PlatformLicenseSnMapper; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper; +import cn.craftlabs.platform.api.web.dto.CallbackInboxLinkPatchRequest; +import cn.craftlabs.platform.api.web.dto.CallbackInboxResponse; +import cn.craftlabs.platform.api.web.dto.CallbackInboxStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class CallbackInboxService { + + private final PlatformCallbackInboxMapper inboxMapper; + private final PlatformLicenseSnMapper licenseSnMapper; + private final PlatformProjectMapper projectMapper; + private final PlatformContractMapper contractMapper; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public CallbackInboxService( + PlatformCallbackInboxMapper inboxMapper, + PlatformLicenseSnMapper licenseSnMapper, + PlatformProjectMapper projectMapper, + PlatformContractMapper contractMapper, + AuditService auditService, + ObjectMapper objectMapper) { + this.inboxMapper = inboxMapper; + this.licenseSnMapper = licenseSnMapper; + this.projectMapper = projectMapper; + this.contractMapper = contractMapper; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PageResponse page( + int page, + int size, + String status, + String eventType, + String snCode, + Long projectId, + Long productLineId, + Long environmentId, + OffsetDateTime receivedFrom, + OffsetDateTime receivedTo) { + String st = StringUtils.hasText(status) ? status.trim() : null; + String et = StringUtils.hasText(eventType) ? eventType.trim() : null; + String sn = StringUtils.hasText(snCode) ? snCode.trim() : null; + if (st != null) { + parseStatusOrBadRequest(st); + } + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformCallbackInbox.class) + .eq(st != null, PlatformCallbackInbox::getStatus, st) + .eq(et != null, PlatformCallbackInbox::getEventType, et) + .like(sn != null, PlatformCallbackInbox::getSnCode, sn) + .eq(projectId != null, PlatformCallbackInbox::getProjectId, projectId) + .eq( + productLineId != null, + PlatformCallbackInbox::getProductLineId, + productLineId) + .eq( + environmentId != null, + PlatformCallbackInbox::getIntegrationEnvironmentId, + environmentId) + .ge(receivedFrom != null, PlatformCallbackInbox::getReceivedAt, receivedFrom) + .le(receivedTo != null, PlatformCallbackInbox::getReceivedAt, receivedTo) + .orderByDesc(PlatformCallbackInbox::getId); + Page mpPage = new Page<>(page + 1L, size); + inboxMapper.selectPage(mpPage, q); + List content = + mpPage.getRecords().stream() + .map(r -> toResponse(r, false)) + .collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional(readOnly = true) + public CallbackInboxResponse getById(long id) { + return toResponse(requireInbox(id), true); + } + + @Transactional + public CallbackInboxResponse patchStatus(long id, CallbackInboxStatusPatchRequest request) { + PlatformCallbackInbox row = requireInbox(id); + CallbackInboxStatus from = CallbackInboxStatus.valueOf(row.getStatus()); + CallbackInboxStatus to = parseStatusOrBadRequest(request.getStatus()); + if (from == to) { + return toResponse(row, true); + } + if (from != CallbackInboxStatus.PENDING) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "illegal callback inbox status transition"); + } + if (to != CallbackInboxStatus.PROCESSED + && to != CallbackInboxStatus.FAILED + && to != CallbackInboxStatus.IGNORED) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "illegal callback inbox status transition"); + } + String oldJson = toJson(snapshot(row)); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + row.setStatus(to.name()); + row.setProcessedAt(now); + row.setProcessedByUserId(currentActorId()); + row.setUpdatedAt(now); + inboxMapper.updateById(row); + auditService.record( + AuditEntityTypes.CALLBACK_INBOX, + id, + AuditActions.CALLBACK_INBOX_STATUS_CHANGED, + "status", + oldJson, + toJson(snapshot(row))); + return toResponse(row, true); + } + + @Transactional + public CallbackInboxResponse patchLink(long id, CallbackInboxLinkPatchRequest request) { + PlatformCallbackInbox row = requireInbox(id); + if (request.getLicenseSnId() == null + && request.getProjectId() == null + && request.getContractId() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "at least one of licenseSnId, projectId, contractId must be provided"); + } + String oldJson = toJson(snapshot(row)); + if (request.getLicenseSnId() != null) { + if (licenseSnMapper.selectById(request.getLicenseSnId()) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "license SN not found"); + } + row.setLicenseSnId(request.getLicenseSnId()); + } + if (request.getProjectId() != null) { + if (projectMapper.selectById(request.getProjectId()) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + row.setProjectId(request.getProjectId()); + } + if (request.getContractId() != null) { + if (contractMapper.selectById(request.getContractId()) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found"); + } + row.setContractId(request.getContractId()); + } + row.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + inboxMapper.updateById(row); + auditService.record( + AuditEntityTypes.CALLBACK_INBOX, + id, + AuditActions.CALLBACK_INBOX_LINK_UPDATED, + null, + oldJson, + toJson(snapshot(row))); + return toResponse(row, true); + } + + private PlatformCallbackInbox requireInbox(long id) { + PlatformCallbackInbox row = inboxMapper.selectById(id); + if (row == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "callback inbox not found"); + } + return row; + } + + private static CallbackInboxStatus parseStatusOrBadRequest(String raw) { + try { + return CallbackInboxStatus.valueOf(raw.trim()); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unknown callback inbox status: " + raw); + } + } + + private Map snapshot(PlatformCallbackInbox row) { + Map m = new LinkedHashMap<>(); + m.put("id", row.getId()); + m.put("status", row.getStatus()); + m.put("licenseSnId", row.getLicenseSnId()); + m.put("projectId", row.getProjectId()); + m.put("contractId", row.getContractId()); + m.put("snCode", row.getSnCode()); + m.put("productLineId", row.getProductLineId()); + m.put("integrationEnvironmentId", row.getIntegrationEnvironmentId()); + return m; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private CallbackInboxResponse toResponse(PlatformCallbackInbox row, boolean includePayload) { + CallbackInboxResponse r = new CallbackInboxResponse(); + r.setId(row.getId()); + r.setSourceSystem(row.getSourceSystem()); + r.setExternalMessageId(row.getExternalMessageId()); + r.setSchemaVersion(row.getSchemaVersion()); + r.setEventType(row.getEventType()); + r.setStatus(row.getStatus()); + r.setRawPayload(includePayload ? row.getRawPayload() : null); + r.setIdempotencyKey(row.getIdempotencyKey()); + r.setLicenseSnId(row.getLicenseSnId()); + r.setProjectId(row.getProjectId()); + r.setContractId(row.getContractId()); + r.setSnCode(row.getSnCode()); + r.setProductLineId(row.getProductLineId()); + r.setIntegrationEnvironmentId(row.getIntegrationEnvironmentId()); + r.setReceivedAt(row.getReceivedAt()); + r.setProcessedAt(row.getProcessedAt()); + r.setProcessedByUserId(row.getProcessedByUserId()); + r.setFailureReason(row.getFailureReason()); + r.setOperatorNote(row.getOperatorNote()); + r.setWebhookReceiptId(row.getWebhookReceiptId()); + r.setCreatedAt(row.getCreatedAt()); + r.setUpdatedAt(row.getUpdatedAt()); + return r; + } + + private static String currentActorId() { + var a = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if (a == null || !a.isAuthenticated()) { + return null; + } + return a.getName(); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/IntegrationCatalogService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/IntegrationCatalogService.java new file mode 100644 index 0000000..7e6b598 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/IntegrationCatalogService.java @@ -0,0 +1,97 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.persistence.integration.PlatformIntegrationEnvironment; +import cn.craftlabs.platform.api.persistence.integration.PlatformIntegrationEnvironmentMapper; +import cn.craftlabs.platform.api.persistence.integration.PlatformProductLine; +import cn.craftlabs.platform.api.persistence.integration.PlatformProductLineMapper; +import cn.craftlabs.platform.api.web.dto.IntegrationEnvironmentResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import cn.craftlabs.platform.api.web.dto.ProductLineResponse; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class IntegrationCatalogService { + + private final PlatformProductLineMapper productLineMapper; + private final PlatformIntegrationEnvironmentMapper environmentMapper; + + public IntegrationCatalogService( + PlatformProductLineMapper productLineMapper, + PlatformIntegrationEnvironmentMapper environmentMapper) { + this.productLineMapper = productLineMapper; + this.environmentMapper = environmentMapper; + } + + @Transactional(readOnly = true) + public PageResponse pageProductLines(int page, int size) { + Page mpPage = new Page<>(page + 1L, size); + productLineMapper.selectPage( + mpPage, Wrappers.lambdaQuery(PlatformProductLine.class).orderByAsc(PlatformProductLine::getId)); + List content = + mpPage.getRecords().stream().map(this::toProductLine).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional(readOnly = true) + public ProductLineResponse getProductLine(long id) { + PlatformProductLine row = productLineMapper.selectById(id); + if (row == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "product line not found"); + } + return toProductLine(row); + } + + @Transactional(readOnly = true) + public PageResponse pageEnvironments(int page, int size) { + Page mpPage = new Page<>(page + 1L, size); + environmentMapper.selectPage( + mpPage, + Wrappers.lambdaQuery(PlatformIntegrationEnvironment.class) + .orderByAsc(PlatformIntegrationEnvironment::getId)); + List content = + mpPage.getRecords().stream().map(this::toEnvironment).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional(readOnly = true) + public IntegrationEnvironmentResponse getEnvironment(long id) { + PlatformIntegrationEnvironment row = environmentMapper.selectById(id); + if (row == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "integration environment not found"); + } + return toEnvironment(row); + } + + private ProductLineResponse toProductLine(PlatformProductLine row) { + ProductLineResponse r = new ProductLineResponse(); + r.setId(row.getId()); + r.setCode(row.getCode()); + r.setName(row.getName()); + r.setDescription(row.getDescription()); + r.setEnabled(row.getEnabled()); + r.setCreatedAt(row.getCreatedAt()); + r.setUpdatedAt(row.getUpdatedAt()); + return r; + } + + private IntegrationEnvironmentResponse toEnvironment(PlatformIntegrationEnvironment row) { + IntegrationEnvironmentResponse r = new IntegrationEnvironmentResponse(); + r.setId(row.getId()); + r.setCode(row.getCode()); + r.setName(row.getName()); + r.setBitanswerBaseUrl(row.getBitanswerBaseUrl()); + r.setKind(row.getKind()); + r.setProductLineId(row.getProductLineId()); + r.setCreatedAt(row.getCreatedAt()); + r.setUpdatedAt(row.getUpdatedAt()); + return r; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestRequest.java new file mode 100644 index 0000000..d4fc284 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestRequest.java @@ -0,0 +1,90 @@ +package cn.craftlabs.platform.api.web.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.OffsetDateTime; + +public class CallbackEventIngestRequest { + + @NotBlank private String schemaVersion; + + @NotBlank private String sourceSystem; + + @NotBlank private String externalMessageId; + + @NotBlank private String eventType; + + private OffsetDateTime receivedAt; + + @NotNull private JsonNode rawPayload; + + private String webhookReceiptId; + + private String idempotencyKey; + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public String getSourceSystem() { + return sourceSystem; + } + + public void setSourceSystem(String sourceSystem) { + this.sourceSystem = sourceSystem; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public JsonNode getRawPayload() { + return rawPayload; + } + + public void setRawPayload(JsonNode rawPayload) { + this.rawPayload = rawPayload; + } + + public String getWebhookReceiptId() { + return webhookReceiptId; + } + + public void setWebhookReceiptId(String webhookReceiptId) { + this.webhookReceiptId = webhookReceiptId; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestResponse.java new file mode 100644 index 0000000..4aee5ba --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackEventIngestResponse.java @@ -0,0 +1,30 @@ +package cn.craftlabs.platform.api.web.dto; + +public class CallbackEventIngestResponse { + + private long inboxId; + private boolean duplicate; + + public CallbackEventIngestResponse() {} + + public CallbackEventIngestResponse(long inboxId, boolean duplicate) { + this.inboxId = inboxId; + this.duplicate = duplicate; + } + + public long getInboxId() { + return inboxId; + } + + public void setInboxId(long inboxId) { + this.inboxId = inboxId; + } + + public boolean isDuplicate() { + return duplicate; + } + + public void setDuplicate(boolean duplicate) { + this.duplicate = duplicate; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxLinkPatchRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxLinkPatchRequest.java new file mode 100644 index 0000000..a03b36f --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxLinkPatchRequest.java @@ -0,0 +1,32 @@ +package cn.craftlabs.platform.api.web.dto; + +public class CallbackInboxLinkPatchRequest { + + private Long licenseSnId; + private Long projectId; + private Long contractId; + + public Long getLicenseSnId() { + return licenseSnId; + } + + public void setLicenseSnId(Long licenseSnId) { + this.licenseSnId = licenseSnId; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxResponse.java new file mode 100644 index 0000000..a882dd7 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxResponse.java @@ -0,0 +1,206 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class CallbackInboxResponse { + + private Long id; + private String sourceSystem; + private String externalMessageId; + private String schemaVersion; + private String eventType; + private String status; + /** 列表接口为 null,详情接口为 JSON 字符串 */ + private String rawPayload; + private String idempotencyKey; + private Long licenseSnId; + private Long projectId; + private Long contractId; + private String snCode; + private Long productLineId; + private Long integrationEnvironmentId; + private OffsetDateTime receivedAt; + private OffsetDateTime processedAt; + private String processedByUserId; + private String failureReason; + private String operatorNote; + private String webhookReceiptId; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSourceSystem() { + return sourceSystem; + } + + public void setSourceSystem(String sourceSystem) { + this.sourceSystem = sourceSystem; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(String schemaVersion) { + this.schemaVersion = schemaVersion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRawPayload() { + return rawPayload; + } + + public void setRawPayload(String rawPayload) { + this.rawPayload = rawPayload; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getLicenseSnId() { + return licenseSnId; + } + + public void setLicenseSnId(Long licenseSnId) { + this.licenseSnId = licenseSnId; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } + + public String getSnCode() { + return snCode; + } + + public void setSnCode(String snCode) { + this.snCode = snCode; + } + + public Long getProductLineId() { + return productLineId; + } + + public void setProductLineId(Long productLineId) { + this.productLineId = productLineId; + } + + public Long getIntegrationEnvironmentId() { + return integrationEnvironmentId; + } + + public void setIntegrationEnvironmentId(Long integrationEnvironmentId) { + this.integrationEnvironmentId = integrationEnvironmentId; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public OffsetDateTime getProcessedAt() { + return processedAt; + } + + public void setProcessedAt(OffsetDateTime processedAt) { + this.processedAt = processedAt; + } + + public String getProcessedByUserId() { + return processedByUserId; + } + + public void setProcessedByUserId(String processedByUserId) { + this.processedByUserId = processedByUserId; + } + + public String getFailureReason() { + return failureReason; + } + + public void setFailureReason(String failureReason) { + this.failureReason = failureReason; + } + + public String getOperatorNote() { + return operatorNote; + } + + public void setOperatorNote(String operatorNote) { + this.operatorNote = operatorNote; + } + + public String getWebhookReceiptId() { + return webhookReceiptId; + } + + public void setWebhookReceiptId(String webhookReceiptId) { + this.webhookReceiptId = webhookReceiptId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxStatusPatchRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxStatusPatchRequest.java new file mode 100644 index 0000000..eb24560 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackInboxStatusPatchRequest.java @@ -0,0 +1,16 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public class CallbackInboxStatusPatchRequest { + + @NotBlank private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/IntegrationEnvironmentResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/IntegrationEnvironmentResponse.java new file mode 100644 index 0000000..e028850 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/IntegrationEnvironmentResponse.java @@ -0,0 +1,79 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class IntegrationEnvironmentResponse { + + private Long id; + private String code; + private String name; + private String bitanswerBaseUrl; + private String kind; + private Long productLineId; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBitanswerBaseUrl() { + return bitanswerBaseUrl; + } + + public void setBitanswerBaseUrl(String bitanswerBaseUrl) { + this.bitanswerBaseUrl = bitanswerBaseUrl; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public Long getProductLineId() { + return productLineId; + } + + public void setProductLineId(Long productLineId) { + this.productLineId = productLineId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProductLineResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProductLineResponse.java new file mode 100644 index 0000000..06ba43d --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProductLineResponse.java @@ -0,0 +1,70 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class ProductLineResponse { + + private Long id; + private String code; + private String name; + private String description; + private Boolean enabled; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/resources/application.yml b/services/delivery-platform-api/src/main/resources/application.yml index c6f4fa3..32c9459 100644 --- a/services/delivery-platform-api/src/main/resources/application.yml +++ b/services/delivery-platform-api/src/main/resources/application.yml @@ -30,7 +30,11 @@ platform: jwt: secret: ${PLATFORM_JWT_SECRET:dev-only-unsafe-change-in-production-32chars!!} expiry-seconds: ${PLATFORM_JWT_EXPIRY_SECONDS:43200} + internal: + token: ${PLATFORM_INTERNAL_TOKEN:${CRAFTLABS_PLATFORM_INTERNAL_TOKEN:}} springdoc: swagger-ui: path: /swagger-ui.html + paths-to-exclude: + - /internal/** diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V5__callback_inbox_and_integration.sql b/services/delivery-platform-api/src/main/resources/db/migration/V5__callback_inbox_and_integration.sql new file mode 100644 index 0000000..45f49f0 --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V5__callback_inbox_and_integration.sql @@ -0,0 +1,69 @@ +-- I5:Callback Inbox(M5)+ 产品线/集成环境(M6 最小只读) +CREATE TABLE platform_product_line ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(256) NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_platform_product_line_code UNIQUE (code) +); + +CREATE TABLE platform_integration_environment ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(256) NOT NULL, + bitanswer_base_url VARCHAR(512) NOT NULL, + kind VARCHAR(32) NOT NULL, + product_line_id BIGINT REFERENCES platform_product_line (id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_platform_integration_environment_code UNIQUE (code) +); + +CREATE INDEX idx_platform_integration_environment_product_line + ON platform_integration_environment (product_line_id); + +CREATE TABLE platform_callback_inbox ( + id BIGSERIAL PRIMARY KEY, + source_system VARCHAR(64) NOT NULL, + external_message_id VARCHAR(512) NOT NULL, + schema_version VARCHAR(64) NOT NULL, + event_type VARCHAR(256) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + raw_payload TEXT NOT NULL, + idempotency_key VARCHAR(512), + license_sn_id BIGINT REFERENCES platform_license_sn (id), + project_id BIGINT REFERENCES platform_project (id), + contract_id BIGINT REFERENCES platform_contract (id), + sn_code VARCHAR(128), + product_line_id BIGINT REFERENCES platform_product_line (id), + integration_environment_id BIGINT REFERENCES platform_integration_environment (id), + received_at TIMESTAMP WITH TIME ZONE NOT NULL, + processed_at TIMESTAMP WITH TIME ZONE, + processed_by_user_id VARCHAR(256), + failure_reason TEXT, + operator_note TEXT, + webhook_receipt_id VARCHAR(256), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_platform_callback_inbox_source_message UNIQUE (source_system, external_message_id) +); + +CREATE INDEX idx_platform_callback_inbox_status ON platform_callback_inbox (status); +CREATE INDEX idx_platform_callback_inbox_event_type ON platform_callback_inbox (event_type); +CREATE INDEX idx_platform_callback_inbox_sn_code ON platform_callback_inbox (sn_code); +CREATE INDEX idx_platform_callback_inbox_project ON platform_callback_inbox (project_id); +CREATE INDEX idx_platform_callback_inbox_product_line ON platform_callback_inbox (product_line_id); +CREATE INDEX idx_platform_callback_inbox_environment ON platform_callback_inbox (integration_environment_id); +CREATE INDEX idx_platform_callback_inbox_received_at ON platform_callback_inbox (received_at); + +-- 种子:本地/联调列表筛选(单测库亦执行,数据量极小) +INSERT INTO platform_product_line (code, name, description, enabled) +VALUES ('default', '默认产品线', 'I5 MVP 种子', TRUE); + +INSERT INTO platform_integration_environment (code, name, bitanswer_base_url, kind, product_line_id) +VALUES + ('dev', '开发环境', 'https://dev.bitanswer.example', 'DEV', 1), + ('prod', '生产环境', 'https://api.bitanswer.example', 'PROD', 1); diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java new file mode 100644 index 0000000..95df590 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java @@ -0,0 +1,120 @@ +package cn.craftlabs.platform.api.callback; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CallbackInboxControllerTest { + + private static final String INTERNAL_TOKEN = "unit-test-internal-token-for-callback-ingest"; + private static final String INTERNAL_HEADER = "X-Platform-Internal-Token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void listDetailStatusLinkAndIntegrationCatalog() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + String auth = "Bearer " + token; + + mockMvc.perform( + get("/api/v1/integration/product-lines") + .header("Authorization", auth) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].code").value("default")); + + mockMvc.perform( + get("/api/v1/integration/environments") + .header("Authorization", auth) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)); + + String ingestBody = minimalIngestJson("msg-inbox-flow-1"); + String ingestResp = + mockMvc.perform( + post("/internal/v1/callback-events") + .header(INTERNAL_HEADER, INTERNAL_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(ingestBody)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + long inboxId = objectMapper.readTree(ingestResp).get("inboxId").asLong(); + + mockMvc.perform( + get("/api/v1/callback-inbox") + .header("Authorization", auth) + .param("page", "0") + .param("size", "20") + .param("eventType", "sn:test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].id").value(inboxId)) + .andExpect(jsonPath("$.content[0].rawPayload").value(nullValue())); + + mockMvc.perform(get("/api/v1/callback-inbox/" + inboxId).header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rawPayload").exists()) + .andExpect(jsonPath("$.status").value("PENDING")); + + mockMvc.perform( + patch("/api/v1/callback-inbox/" + inboxId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"PROCESSED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PROCESSED")); + + mockMvc.perform( + patch("/api/v1/callback-inbox/" + inboxId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"FAILED\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value(containsString("illegal"))); + + mockMvc.perform( + patch("/api/v1/callback-inbox/" + inboxId + "/link") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"licenseSnId\":999999}")) + .andExpect(status().isNotFound()); + } + + private String minimalIngestJson(String externalMessageId) throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.put("schemaVersion", "1.0"); + root.put("sourceSystem", "BITANSWER"); + root.put("externalMessageId", externalMessageId); + root.put("eventType", "sn:test"); + root.set("rawPayload", objectMapper.createObjectNode().put("sn", "SN-X")); + return objectMapper.writeValueAsString(root); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/internal/CallbackInternalControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/internal/CallbackInternalControllerTest.java new file mode 100644 index 0000000..11a9a28 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/internal/CallbackInternalControllerTest.java @@ -0,0 +1,95 @@ +package cn.craftlabs.platform.api.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CallbackInternalControllerTest { + + private static final String INTERNAL_TOKEN = "unit-test-internal-token-for-callback-ingest"; + private static final String INTERNAL_HEADER = "X-Platform-Internal-Token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void unauthorizedWithoutToken() throws Exception { + mockMvc.perform( + post("/internal/v1/callback-events") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void ingestIdempotentReturnsSameInboxId() throws Exception { + String body = minimalIngestJson("msg-idempotent-1"); + String first = + mockMvc.perform( + post("/internal/v1/callback-events") + .header(INTERNAL_HEADER, INTERNAL_TOKEN) + .header("Idempotency-Key", "idem-1") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.duplicate").value(false)) + .andReturn() + .getResponse() + .getContentAsString(); + long inboxId = objectMapper.readTree(first).get("inboxId").asLong(); + + mockMvc.perform( + post("/internal/v1/callback-events") + .header(INTERNAL_HEADER, INTERNAL_TOKEN) + .header("Idempotency-Key", "idem-replay") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.duplicate").value(true)) + .andExpect(jsonPath("$.inboxId").value(inboxId)); + } + + @Test + void rejectsUnsupportedSchemaMajor() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.put("schemaVersion", "2.0"); + root.put("sourceSystem", "BITANSWER"); + root.put("externalMessageId", "msg-major"); + root.put("eventType", "t"); + root.set("rawPayload", objectMapper.createObjectNode()); + mockMvc.perform( + post("/internal/v1/callback-events") + .header(INTERNAL_HEADER, INTERNAL_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(root))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("unsupported schema major"))); + } + + private String minimalIngestJson(String externalMessageId) throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.put("schemaVersion", "1.0"); + root.put("sourceSystem", "BITANSWER"); + root.put("externalMessageId", externalMessageId); + root.put("eventType", "sn:test"); + root.set("rawPayload", objectMapper.createObjectNode().put("sn", "SN-X")); + return objectMapper.writeValueAsString(root); + } +} diff --git a/services/delivery-platform-api/src/test/resources/application.yml b/services/delivery-platform-api/src/test/resources/application.yml index ab7183f..16b12a2 100644 --- a/services/delivery-platform-api/src/test/resources/application.yml +++ b/services/delivery-platform-api/src/test/resources/application.yml @@ -12,3 +12,5 @@ spring: platform: jwt: secret: unit-test-jwt-secret-at-least-32-chars-ok + internal: + token: unit-test-internal-token-for-callback-ingest