feat(platform): I5 callback inbox, internal ingest, and M6 catalog APIs

Made-with: Cursor
This commit is contained in:
2026-04-06 22:40:21 +08:00
parent b6e110acaf
commit fc0c4b1930
30 changed files with 2646 additions and 3 deletions
@@ -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() {}
}
@@ -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() {}
}
@@ -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<CallbackInboxResponse> 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);
}
}
@@ -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;
/**
* I1JWTBearer)保护业务 API登录与健康检查、OpenAPI 文档放行
* I1JWTBearer)保护业务 APII5{@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(
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.domain;
public enum CallbackInboxStatus {
PENDING,
PROCESSED,
FAILED,
IGNORED
}
@@ -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<ProductLineResponse> 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<IntegrationEnvironmentResponse> 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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<PlatformCallbackInbox> {}
@@ -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;
}
}
@@ -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<PlatformIntegrationEnvironment> {}
@@ -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;
}
}
@@ -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<PlatformProductLine> {}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<CallbackInboxResponse> 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<PlatformCallbackInbox> 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<PlatformCallbackInbox> mpPage = new Page<>(page + 1L, size);
inboxMapper.selectPage(mpPage, q);
List<CallbackInboxResponse> 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<String, Object> snapshot(PlatformCallbackInbox row) {
Map<String, Object> 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();
}
}
@@ -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<ProductLineResponse> pageProductLines(int page, int size) {
Page<PlatformProductLine> mpPage = new Page<>(page + 1L, size);
productLineMapper.selectPage(
mpPage, Wrappers.lambdaQuery(PlatformProductLine.class).orderByAsc(PlatformProductLine::getId));
List<ProductLineResponse> 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<IntegrationEnvironmentResponse> pageEnvironments(int page, int size) {
Page<PlatformIntegrationEnvironment> mpPage = new Page<>(page + 1L, size);
environmentMapper.selectPage(
mpPage,
Wrappers.lambdaQuery(PlatformIntegrationEnvironment.class)
.orderByAsc(PlatformIntegrationEnvironment::getId));
List<IntegrationEnvironmentResponse> 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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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/**
@@ -0,0 +1,69 @@
-- I5Callback InboxM5+ 产品线/集成环境(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);
@@ -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);
}
}
@@ -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);
}
}
@@ -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