mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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:
+53
@@ -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 {
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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:}
|
||||
|
||||
+115
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user