feat(platform): I1 bootstrap, I2 M1 APIs, OpenAPI SSOT, and CI guards

Deliver dual Spring Boot services (platform API + webhook ingress), JWT
auth, Flyway with isolated history tables, customer/project/dictionary
endpoints, OpenAPI snapshot under contracts/, RUNBOOK, and CI that runs
on services/web/contracts paths plus enforcer + dependency tree ban on
craftlabs-auth-bitanswer.

Made-with: Cursor
This commit is contained in:
2026-04-06 21:04:56 +08:00
parent 76ff98db87
commit 3f577b34d5
57 changed files with 3170 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.craftlabs.platform</groupId>
<artifactId>craftlabs-platform-services-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>license-webhook-ingress</artifactId>
<name>License Webhook Ingress</name>
<description>比特规则 Callback 接入(I5 扩展持久化/MQ);默认端口 8081</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,59 @@
package cn.craftlabs.platform.webhook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.Map;
import java.util.Optional;
/**
* 比特规则 Callback 入口(M5 / BP-06)。
* I1:验 token + 打日志;I5:落库/MQ + 幂等键。
*/
@RestController
public class CallbackIngestController {
private static final Logger log = LoggerFactory.getLogger(CallbackIngestController.class);
public static final String HEADER_TOKEN = "x-bitanswer-token";
private final CallbackReceiptService receiptService;
@Value("${craftlabs.webhook.expected-token:}")
private String expectedToken;
public CallbackIngestController(CallbackReceiptService receiptService) {
this.receiptService = receiptService;
}
@PostMapping("/webhook/bitanswer/callback")
public ResponseEntity<Map<String, String>> ingest(
@RequestHeader(value = HEADER_TOKEN, required = false) String token,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
@RequestBody String rawBody) {
if (expectedToken != null && !expectedToken.isBlank()) {
if (token == null || !expectedToken.equals(token)) {
log.warn("callback rejected: bad or missing {}", HEADER_TOKEN);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
int bytes = rawBody != null ? rawBody.length() : 0;
receiptService.recordReceipt(idempotencyKey, bytes);
log.info(
"bitanswer callback accepted idempotencyKey={} bytes={}",
Optional.ofNullable(idempotencyKey).orElse("-"),
bytes);
return ResponseEntity.ok(Map.of("status", "accepted"));
}
}
@@ -0,0 +1,37 @@
package cn.craftlabs.platform.webhook;
import cn.craftlabs.platform.webhook.persistence.WebhookCallbackReceipt;
import cn.craftlabs.platform.webhook.persistence.WebhookCallbackReceiptMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@Service
public class CallbackReceiptService {
private static final Logger log = LoggerFactory.getLogger(CallbackReceiptService.class);
private final WebhookCallbackReceiptMapper mapper;
public CallbackReceiptService(WebhookCallbackReceiptMapper mapper) {
this.mapper = mapper;
}
/**
* 记录幂等键;重复键忽略(对比特仍返回 2xx)。
*/
public void recordReceipt(String idempotencyKey, int bodyBytes) {
if (idempotencyKey == null || idempotencyKey.isBlank()) {
return;
}
var row = new WebhookCallbackReceipt();
row.setIdempotencyKey(idempotencyKey.trim());
row.setBodyBytes(bodyBytes);
try {
mapper.insert(row);
} catch (DataIntegrityViolationException e) {
log.debug("callback idempotent replay key={}", idempotencyKey);
}
}
}
@@ -0,0 +1,14 @@
package cn.craftlabs.platform.webhook;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("cn.craftlabs.platform.webhook.persistence")
public class WebhookApplication {
public static void main(String[] args) {
SpringApplication.run(WebhookApplication.class, args);
}
}
@@ -0,0 +1,52 @@
package cn.craftlabs.platform.webhook.persistence;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.Instant;
@TableName("webhook_callback_receipt")
public class WebhookCallbackReceipt {
@TableId(type = IdType.AUTO)
private Long id;
private String idempotencyKey;
private Integer bodyBytes;
private Instant createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getIdempotencyKey() {
return idempotencyKey;
}
public void setIdempotencyKey(String idempotencyKey) {
this.idempotencyKey = idempotencyKey;
}
public Integer getBodyBytes() {
return bodyBytes;
}
public void setBodyBytes(Integer bodyBytes) {
this.bodyBytes = bodyBytes;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,7 @@
package cn.craftlabs.platform.webhook.persistence;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WebhookCallbackReceiptMapper extends BaseMapper<WebhookCallbackReceipt> {}
@@ -0,0 +1,32 @@
server:
port: 8081
spring:
application:
name: license-webhook-ingress
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/craftlabs_platform}
username: ${SPRING_DATASOURCE_USERNAME:craftlabs}
password: ${SPRING_DATASOURCE_PASSWORD:craftlabs}
driver-class-name: org.postgresql.Driver
flyway:
enabled: true
# 与 delivery-platform-api 的 flyway_platform_api 并存于同一 PostgreSQL 时互不覆盖迁移历史
table: flyway_webhook
codec:
max-in-memory-size: 512KB
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
management:
endpoints:
web:
exposure:
include: health,info
# 开发可设环境变量 CRAFTLABS_WEBHOOK_EXPECTED_TOKEN;空则不做 token 校验(仅本地)
craftlabs:
webhook:
expected-token: ${CRAFTLABS_WEBHOOK_EXPECTED_TOKEN:}
@@ -0,0 +1,8 @@
-- I1Callback 幂等键落库(PostgreSQL 15 / H2 PG 模式);NULL idempotency_key 允许多条
CREATE TABLE webhook_callback_receipt (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
idempotency_key VARCHAR(512),
body_bytes INT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key)
);
@@ -0,0 +1,53 @@
package cn.craftlabs.platform.webhook;
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class CallbackIngestControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void rejectsWithoutToken() throws Exception {
mockMvc.perform(
post("/webhook/bitanswer/callback")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void acceptsWithToken() throws Exception {
mockMvc.perform(
post("/webhook/bitanswer/callback")
.header(CallbackIngestController.HEADER_TOKEN, "test-secret")
.header("Idempotency-Key", "k1")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"event\":\"sn:post_activate\"}"))
.andExpect(status().isOk());
}
@Test
void duplicateIdempotencyKeyStillOk() throws Exception {
String body = "{\"event\":\"dup\"}";
for (int i = 0; i < 2; i++) {
mockMvc.perform(
post("/webhook/bitanswer/callback")
.header(CallbackIngestController.HEADER_TOKEN, "test-secret")
.header("Idempotency-Key", "stable-key")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
}
}
}
@@ -0,0 +1,20 @@
spring:
datasource:
url: jdbc:h2:mem:webhook_ingress;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
driver-class-name: org.h2.Driver
username: sa
password:
flyway:
enabled: false
sql:
init:
mode: always
schema-locations: classpath:schema-webhook-test.sql
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
craftlabs:
webhook:
expected-token: test-secret
@@ -0,0 +1,8 @@
-- 单测建表(与 db/migration/V1 语义一致);单测关闭 Flyway,由 spring.sql.init 执行
CREATE TABLE IF NOT EXISTS webhook_callback_receipt (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
idempotency_key VARCHAR(512),
body_bytes INT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key)
);