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
@@ -7,13 +7,17 @@ import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
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;
/**
* I7:平台投递入库 + 异步拉取发送。
@@ -90,6 +94,55 @@ public class PlatformDeliveryService {
}
}
/**
* I8:将 {@code DEAD} 行按 {@code receipt_id} 重新入队,尝试次数清零。
*/
public void replayDeadDeliveryByReceiptId(long receiptId) {
WebhookPlatformDelivery d =
deliveryMapper.selectOne(
Wrappers.lambdaQuery(WebhookPlatformDelivery.class)
.eq(WebhookPlatformDelivery::getReceiptId, receiptId));
if (d == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "platform delivery not found for receipt " + receiptId);
}
if (!STATUS_DEAD.equals(d.getStatus())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"platform delivery status is "
+ d.getStatus()
+ ", only DEAD can be replayed");
}
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
d.setStatus(STATUS_PENDING);
d.setAttempts(0);
d.setLastError(null);
d.setNextRetryAt(null);
d.setUpdatedAt(now);
deliveryMapper.updateById(d);
log.info("platform delivery replay re-queued id={} receiptId={}", d.getId(), receiptId);
}
/** I9:按 receipt_id 返回投递行摘要(供运维只读)。 */
public Map<String, Object> getStatusByReceiptId(long receiptId) {
WebhookPlatformDelivery d =
deliveryMapper.selectOne(
Wrappers.lambdaQuery(WebhookPlatformDelivery.class)
.eq(WebhookPlatformDelivery::getReceiptId, receiptId));
if (d == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "platform delivery not found for receipt " + receiptId);
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("receiptId", receiptId);
m.put("status", d.getStatus());
m.put("attempts", d.getAttempts() != null ? d.getAttempts() : 0);
m.put("lastError", d.getLastError());
m.put("nextRetryAt", d.getNextRetryAt());
m.put("updatedAt", d.getUpdatedAt());
return m;
}
private void processOne(WebhookPlatformDelivery d) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
try {
@@ -0,0 +1,48 @@
package cn.craftlabs.platform.webhook;
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.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* I8:为 {@code /internal/**} 校验运维 Token;无配置时拒绝(503),避免误暴露。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class WebhookOpsTokenFilter extends OncePerRequestFilter {
public static final String HEADER_OPS_TOKEN = "X-Webhook-Ops-Token";
@Value("${craftlabs.webhook.ops-token:}")
private String opsToken;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (path == null || !path.startsWith("/internal/")) {
filterChain.doFilter(request, response);
return;
}
if (!StringUtils.hasText(opsToken)) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "webhook ops token not configured");
return;
}
String presented = request.getHeader(HEADER_OPS_TOKEN);
if (!opsToken.equals(presented)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid or missing ops token");
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,41 @@
package cn.craftlabs.platform.webhook;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* I8{@code DEAD} 重放入队;I9:按收据 ID 只读查询投递状态(供 {@code delivery-platform-api} 代调)。
*/
@RestController
public class WebhookPlatformDeliveryOpsController {
private final PlatformDeliveryService platformDeliveryService;
public WebhookPlatformDeliveryOpsController(PlatformDeliveryService platformDeliveryService) {
this.platformDeliveryService = platformDeliveryService;
}
@GetMapping(
value = "/internal/v1/platform-deliveries/by-receipt/{receiptId}",
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> getByReceipt(@PathVariable("receiptId") long receiptId) {
return platformDeliveryService.getStatusByReceiptId(receiptId);
}
@PostMapping(
value = "/internal/v1/platform-deliveries/by-receipt/{receiptId}/replay",
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> replayByReceipt(@PathVariable("receiptId") long receiptId) {
platformDeliveryService.replayDeadDeliveryByReceiptId(receiptId);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", "REQUEUED");
body.put("receiptId", receiptId);
return body;
}
}
@@ -30,6 +30,8 @@ management:
craftlabs:
webhook:
expected-token: ${CRAFTLABS_WEBHOOK_EXPECTED_TOKEN:}
# I8:保护 /internal/** 运维接口(与平台代理共用 LICENSE_WEBHOOK_OPS_TOKEN);空则返回 503
ops-token: ${LICENSE_WEBHOOK_OPS_TOKEN:}
platform:
internal:
base-url: ${PLATFORM_INTERNAL_BASE_URL:}
@@ -0,0 +1,115 @@
package cn.craftlabs.platform.webhook;
import org.junit.jupiter.api.BeforeEach;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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
class WebhookPlatformDeliveryReplayTest {
private static final String OPS = WebhookOpsTokenFilter.HEADER_OPS_TOKEN;
private static final String TOKEN = "unit-test-webhook-ops-token";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanDeliveries() {
jdbcTemplate.update("DELETE FROM webhook_platform_delivery");
}
@Test
void replayDeadResetsToPending() throws Exception {
jdbcTemplate.update(
"""
INSERT INTO webhook_platform_delivery
(receipt_id, idempotency_key, request_body, status, attempts, last_error, created_at, updated_at)
VALUES (901, 'idem901', '{}', 'DEAD', 8, 'boom', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""");
mockMvc.perform(
post("/internal/v1/platform-deliveries/by-receipt/901/replay")
.header(OPS, TOKEN)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("REQUEUED"))
.andExpect(jsonPath("$.receiptId").value(901));
String st =
jdbcTemplate.queryForObject(
"SELECT status FROM webhook_platform_delivery WHERE receipt_id = 901",
String.class);
assertThat(st).isEqualTo("PENDING");
Integer attempts =
jdbcTemplate.queryForObject(
"SELECT attempts FROM webhook_platform_delivery WHERE receipt_id = 901",
Integer.class);
assertThat(attempts).isZero();
}
@Test
void replayNonDeadReturns409() throws Exception {
jdbcTemplate.update(
"""
INSERT INTO webhook_platform_delivery
(receipt_id, idempotency_key, request_body, status, attempts, created_at, updated_at)
VALUES (902, 'idem902', '{}', 'SENT', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""");
mockMvc.perform(
post("/internal/v1/platform-deliveries/by-receipt/902/replay")
.header(OPS, TOKEN)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isConflict());
}
@Test
void getDeliveryStatusByReceiptReturnsSummary() throws Exception {
jdbcTemplate.update(
"""
INSERT INTO webhook_platform_delivery
(receipt_id, idempotency_key, request_body, status, attempts, last_error, created_at, updated_at)
VALUES (905, 'idem905', '{}', 'PENDING', 2, 'err-x', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""");
mockMvc.perform(
get("/internal/v1/platform-deliveries/by-receipt/905")
.header(OPS, TOKEN)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.receiptId").value(905))
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.attempts").value(2))
.andExpect(jsonPath("$.lastError").value("err-x"));
}
@Test
void missingOpsTokenReturns401() throws Exception {
jdbcTemplate.update(
"""
INSERT INTO webhook_platform_delivery
(receipt_id, idempotency_key, request_body, status, attempts, created_at, updated_at)
VALUES (903, 'idem903', '{}', 'DEAD', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""");
mockMvc.perform(
post("/internal/v1/platform-deliveries/by-receipt/903/replay")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized());
}
}
@@ -18,6 +18,7 @@ mybatis-plus:
craftlabs:
webhook:
expected-token: test-secret
ops-token: unit-test-webhook-ops-token
platform:
internal:
base-url: http://127.0.0.1:65509