mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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:
+59
@@ -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"));
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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:}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- I1:Callback 幂等键落库(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)
|
||||
);
|
||||
+53
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user