feat(i8-i9): webhook DEAD replay, read-only delivery status, and callback UI

I8: platform proxies replay to webhook; webhook ops token filter and internal
replay endpoint; delivery service supports read/replay flows.

I9: platform GET callback webhook delivery status by inbox id; UI shows
read-only status block and handles load errors without blocking the page.

Also refresh OpenAPI, Runbook notes, test fixtures and YAML; fix Vite dev
axios baseURL so /api uses proxy; improve login error messaging.

Made-with: Cursor
This commit is contained in:
2026-04-07 21:26:44 +08:00
parent 5e051633ec
commit d53ddf32c8
20 changed files with 874 additions and 6 deletions
@@ -4,6 +4,8 @@ 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.CallbackWebhookDeliveryStatusResponse;
import cn.craftlabs.platform.api.web.dto.CallbackWebhookReplayResponse;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
@@ -12,6 +14,7 @@ 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -73,4 +76,16 @@ public class CallbackInboxController {
@PathVariable("id") long id, @Valid @RequestBody CallbackInboxLinkPatchRequest request) {
return callbackInboxService.patchLink(id, request);
}
/** I8:代理 OPS 调用 Webhook,将关联收据的 {@code DEAD} 出库重新入队。 */
@PostMapping("/{id}/replay-webhook-delivery")
public CallbackWebhookReplayResponse replayWebhookDelivery(@PathVariable("id") long id) {
return callbackInboxService.replayWebhookDelivery(id);
}
/** I9:只读查询与收件箱关联的 Webhook 平台投递状态。 */
@GetMapping("/{id}/webhook-delivery")
public CallbackWebhookDeliveryStatusResponse getWebhookDelivery(@PathVariable("id") long id) {
return callbackInboxService.getWebhookDeliveryStatus(id);
}
}
@@ -11,7 +11,10 @@ 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.CallbackWebhookDeliveryStatusResponse;
import cn.craftlabs.platform.api.web.dto.CallbackWebhookReplayResponse;
import cn.craftlabs.platform.api.web.dto.PageResponse;
import cn.craftlabs.platform.api.webhook.WebhookDeliveryReplayClient;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -39,6 +42,7 @@ public class CallbackInboxService {
private final PlatformContractMapper contractMapper;
private final AuditService auditService;
private final ObjectMapper objectMapper;
private final WebhookDeliveryReplayClient webhookDeliveryReplayClient;
public CallbackInboxService(
PlatformCallbackInboxMapper inboxMapper,
@@ -46,13 +50,15 @@ public class CallbackInboxService {
PlatformProjectMapper projectMapper,
PlatformContractMapper contractMapper,
AuditService auditService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
WebhookDeliveryReplayClient webhookDeliveryReplayClient) {
this.inboxMapper = inboxMapper;
this.licenseSnMapper = licenseSnMapper;
this.projectMapper = projectMapper;
this.contractMapper = contractMapper;
this.auditService = auditService;
this.objectMapper = objectMapper;
this.webhookDeliveryReplayClient = webhookDeliveryReplayClient;
}
@Transactional(readOnly = true)
@@ -180,6 +186,62 @@ public class CallbackInboxService {
return toResponse(row, true);
}
/** I8:按收件箱关联的 {@code webhook_receipt_id} 请求 Webhook 将 DEAD 出库重新入队。 */
public CallbackWebhookReplayResponse replayWebhookDelivery(long inboxId) {
PlatformCallbackInbox row = requireInbox(inboxId);
String receiptStr = row.getWebhookReceiptId();
if (!StringUtils.hasText(receiptStr)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"callback inbox has no webhookReceiptId; cannot replay platform delivery");
}
long receiptId;
try {
receiptId = Long.parseLong(receiptStr.trim());
} catch (NumberFormatException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid webhookReceiptId");
}
if (!webhookDeliveryReplayClient.isConfigured()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"webhook replay is not configured (set LICENSE_WEBHOOK_BASE_URL and LICENSE_WEBHOOK_OPS_TOKEN)");
}
try {
webhookDeliveryReplayClient.replay(receiptId);
} catch (IllegalStateException e) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, e.getMessage());
}
return new CallbackWebhookReplayResponse("REQUEUED", receiptStr.trim());
}
/** I9:按收件箱 {@code webhookReceiptId} 拉取 Webhook 出库行只读摘要。 */
@Transactional(readOnly = true)
public CallbackWebhookDeliveryStatusResponse getWebhookDeliveryStatus(long inboxId) {
PlatformCallbackInbox row = requireInbox(inboxId);
String receiptStr = row.getWebhookReceiptId();
if (!StringUtils.hasText(receiptStr)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"callback inbox has no webhookReceiptId; no platform delivery row linked");
}
long receiptId;
try {
receiptId = Long.parseLong(receiptStr.trim());
} catch (NumberFormatException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid webhookReceiptId");
}
if (!webhookDeliveryReplayClient.isConfigured()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"webhook ops is not configured (set LICENSE_WEBHOOK_BASE_URL and LICENSE_WEBHOOK_OPS_TOKEN)");
}
try {
return webhookDeliveryReplayClient.fetchDeliveryStatus(receiptId);
} catch (IllegalStateException e) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, e.getMessage());
}
}
private PlatformCallbackInbox requireInbox(long id) {
PlatformCallbackInbox row = inboxMapper.selectById(id);
if (row == null) {
@@ -0,0 +1,62 @@
package cn.craftlabs.platform.api.web.dto;
import java.time.OffsetDateTime;
/** I9:与 Webhook {@code GET .../platform-deliveries/by-receipt/{id}} 对齐的只读摘要。 */
public class CallbackWebhookDeliveryStatusResponse {
private Long receiptId;
private String status;
private Integer attempts;
private String lastError;
private OffsetDateTime nextRetryAt;
private OffsetDateTime updatedAt;
public Long getReceiptId() {
return receiptId;
}
public void setReceiptId(Long receiptId) {
this.receiptId = receiptId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Integer getAttempts() {
return attempts;
}
public void setAttempts(Integer attempts) {
this.attempts = attempts;
}
public String getLastError() {
return lastError;
}
public void setLastError(String lastError) {
this.lastError = lastError;
}
public OffsetDateTime getNextRetryAt() {
return nextRetryAt;
}
public void setNextRetryAt(OffsetDateTime nextRetryAt) {
this.nextRetryAt = nextRetryAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,33 @@
package cn.craftlabs.platform.api.web.dto;
/**
* I8:请求 Webhook 将 {@code DEAD} 出库任务重新入队后的响应。
*/
public class CallbackWebhookReplayResponse {
private String status;
private String receiptId;
public CallbackWebhookReplayResponse() {}
public CallbackWebhookReplayResponse(String status, String receiptId) {
this.status = status;
this.receiptId = receiptId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getReceiptId() {
return receiptId;
}
public void setReceiptId(String receiptId) {
this.receiptId = receiptId;
}
}
@@ -0,0 +1,119 @@
package cn.craftlabs.platform.api.webhook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import cn.craftlabs.platform.api.web.dto.CallbackWebhookDeliveryStatusResponse;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
/**
* I8 / I9:代 OPS 调用 {@code license-webhook-ingress}(重放与只读查询),须配置 base-url + ops-token。
*/
@Component
public class WebhookDeliveryReplayClient {
private static final Logger log = LoggerFactory.getLogger(WebhookDeliveryReplayClient.class);
/** 与 license-webhook-ingress {@code WebhookOpsTokenFilter} 请求头一致 */
private static final String HEADER_OPS_TOKEN = "X-Webhook-Ops-Token";
private final RestClient restClient = RestClient.create();
@Value("${craftlabs.webhook.base-url:}")
private String baseUrl;
@Value("${craftlabs.webhook.ops-token:}")
private String opsToken;
public boolean isConfigured() {
return StringUtils.hasText(baseUrl) && StringUtils.hasText(opsToken);
}
public void replay(long receiptId) {
if (!isConfigured()) {
throw new IllegalStateException("webhook replay is not configured");
}
String url =
baseUrl.replaceAll("/+$", "")
+ "/internal/v1/platform-deliveries/by-receipt/"
+ receiptId
+ "/replay";
try {
restClient
.post()
.uri(url)
.header(HEADER_OPS_TOKEN, opsToken)
.retrieve()
.toBodilessEntity();
} catch (RestClientResponseException e) {
throw mapException(e);
}
}
/** I9:查询 {@code webhook_platform_delivery} 行摘要。 */
public CallbackWebhookDeliveryStatusResponse fetchDeliveryStatus(long receiptId) {
if (!isConfigured()) {
throw new IllegalStateException("webhook ops client is not configured");
}
String url =
baseUrl.replaceAll("/+$", "")
+ "/internal/v1/platform-deliveries/by-receipt/"
+ receiptId;
try {
return restClient
.get()
.uri(url)
.header(HEADER_OPS_TOKEN, opsToken)
.retrieve()
.body(CallbackWebhookDeliveryStatusResponse.class);
} catch (RestClientResponseException e) {
throw mapException(e);
} catch (RestClientException e) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "webhook unreachable: " + e.getMessage());
}
}
private static ResponseStatusException mapException(RestClientResponseException e) {
int code = e.getStatusCode().value();
String body = e.getResponseBodyAsString(StandardCharsets.UTF_8);
String detail = shorten(body);
log.debug("webhook replay HTTP {} body {}", code, detail);
if (code == HttpStatus.NOT_FOUND.value()) {
return new ResponseStatusException(
HttpStatus.NOT_FOUND, "webhook platform delivery not found for receipt");
}
if (code == HttpStatus.CONFLICT.value()) {
return new ResponseStatusException(
HttpStatus.CONFLICT,
StringUtils.hasText(detail)
? detail
: "webhook rejected replay (delivery is not DEAD or conflict)");
}
if (code == HttpStatus.SERVICE_UNAVAILABLE.value()) {
return new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
StringUtils.hasText(detail) ? detail : "webhook ops endpoint unavailable");
}
if (code == HttpStatus.UNAUTHORIZED.value()) {
return new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "webhook rejected ops token (check LICENSE_WEBHOOK_OPS_TOKEN)");
}
return new ResponseStatusException(HttpStatus.BAD_GATEWAY, "webhook replay failed: HTTP " + code);
}
private static String shorten(String body) {
if (!StringUtils.hasText(body)) {
return "";
}
String t = body.trim();
return t.length() > 400 ? t.substring(0, 400) + "" : t;
}
}
@@ -33,6 +33,12 @@ platform:
internal:
token: ${PLATFORM_INTERNAL_TOKEN:${CRAFTLABS_PLATFORM_INTERNAL_TOKEN:}}
# I8:平台代调 Webhook 出库重放(OPS JWT → 平台 → Webhook 内部 API
craftlabs:
webhook:
base-url: ${LICENSE_WEBHOOK_BASE_URL:}
ops-token: ${LICENSE_WEBHOOK_OPS_TOKEN:}
springdoc:
swagger-ui:
path: /swagger-ui.html
@@ -1,16 +1,25 @@
package cn.craftlabs.platform.api.callback;
import cn.craftlabs.platform.api.support.JwtTestSupport;
import cn.craftlabs.platform.api.web.dto.CallbackWebhookDeliveryStatusResponse;
import cn.craftlabs.platform.api.webhook.WebhookDeliveryReplayClient;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -33,6 +42,9 @@ class CallbackInboxControllerTest {
@Autowired
private ObjectMapper objectMapper;
@MockBean
private WebhookDeliveryReplayClient webhookDeliveryReplayClient;
@Test
void listDetailStatusLinkAndIntegrationCatalog() throws Exception {
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
@@ -55,6 +67,8 @@ class CallbackInboxControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(2));
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(false);
String ingestBody = minimalIngestJson("msg-inbox-flow-1");
String ingestResp =
mockMvc.perform(
@@ -110,6 +124,7 @@ class CallbackInboxControllerTest {
@Test
void developerCannotAccessCallbackInbox() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(false);
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper, "dev", "dev");
mockMvc.perform(
get("/api/v1/callback-inbox")
@@ -119,13 +134,85 @@ class CallbackInboxControllerTest {
.andExpect(status().isForbidden());
}
@Test
void replayWebhookDeliveryDelegatesToWebhookClient() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(true);
doNothing().when(webhookDeliveryReplayClient).replay(anyLong());
String ingestBody = minimalIngestJson("msg-replay-flow", "4242");
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();
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
mockMvc.perform(
post("/api/v1/callback-inbox/" + inboxId + "/replay-webhook-delivery")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("REQUEUED"))
.andExpect(jsonPath("$.receiptId").value("4242"));
verify(webhookDeliveryReplayClient).replay(4242L);
}
@Test
void getWebhookDeliveryStatusDelegatesToWebhookClient() throws Exception {
when(webhookDeliveryReplayClient.isConfigured()).thenReturn(true);
CallbackWebhookDeliveryStatusResponse wh = new CallbackWebhookDeliveryStatusResponse();
wh.setReceiptId(777L);
wh.setStatus("PENDING");
wh.setAttempts(2);
wh.setLastError("probe");
when(webhookDeliveryReplayClient.fetchDeliveryStatus(eq(777L))).thenReturn(wh);
String ingestBody = minimalIngestJson("msg-webhook-delivery-status", "777");
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();
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
mockMvc.perform(
get("/api/v1/callback-inbox/" + inboxId + "/webhook-delivery")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.receiptId").value(777))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.attempts").value(2))
.andExpect(jsonPath("$.lastError").value("probe"));
verify(webhookDeliveryReplayClient).fetchDeliveryStatus(777L);
}
private String minimalIngestJson(String externalMessageId) throws Exception {
return minimalIngestJson(externalMessageId, null);
}
private String minimalIngestJson(String externalMessageId, String webhookReceiptId) 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"));
if (webhookReceiptId != null) {
root.put("webhookReceiptId", webhookReceiptId);
}
return objectMapper.writeValueAsString(root);
}
}
@@ -14,3 +14,8 @@ platform:
secret: unit-test-jwt-secret-at-least-32-chars-ok
internal:
token: unit-test-internal-token-for-callback-ingest
craftlabs:
webhook:
base-url: http://127.0.0.1:65520
ops-token: unit-test-webhook-ops-token