diff --git a/contracts/openapi/delivery-platform-api.json b/contracts/openapi/delivery-platform-api.json index e9b49ae..56cdced 100644 --- a/contracts/openapi/delivery-platform-api.json +++ b/contracts/openapi/delivery-platform-api.json @@ -1041,6 +1041,33 @@ } } }, + "/api/v1/callback-inbox/{id}/replay-webhook-delivery" : { + "post" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "replayWebhookDelivery", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackWebhookReplayResponse" + } + } + } + } + } + } + }, "/api/v1/auth/login" : { "post" : { "tags" : [ "auth-controller" ], @@ -1570,6 +1597,33 @@ } } }, + "/api/v1/callback-inbox/{id}/webhook-delivery" : { + "get" : { + "tags" : [ "callback-inbox-controller" ], + "operationId" : "getWebhookDelivery", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CallbackWebhookDeliveryStatusResponse" + } + } + } + } + } + } + }, "/api/v1/audit-events" : { "get" : { "tags" : [ "audit-controller" ], @@ -2096,6 +2150,17 @@ }, "required" : [ "customerId", "projectId" ] }, + "CallbackWebhookReplayResponse" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string" + }, + "receiptId" : { + "type" : "string" + } + } + }, "LicenseSnStatusPatchRequest" : { "type" : "object", "properties" : { @@ -2495,6 +2560,33 @@ } } }, + "CallbackWebhookDeliveryStatusResponse" : { + "type" : "object", + "properties" : { + "receiptId" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string" + }, + "attempts" : { + "type" : "integer", + "format" : "int32" + }, + "lastError" : { + "type" : "string" + }, + "nextRetryAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, "AuditEventResponse" : { "type" : "object", "properties" : { diff --git a/services/RUNBOOK.md b/services/RUNBOOK.md index 8209697..1bc4e3f 100644 --- a/services/RUNBOOK.md +++ b/services/RUNBOOK.md @@ -144,3 +144,7 @@ curl -sS -o /dev/null -w "%{http_code}\n" \ | `craftlabs.platform.delivery.batch-size` | 每 tick 最多拉取条数 | 比特 Callback **2xx** 在收据落库与 **出站行入队** 之后返回;真正 `POST` 平台由后台线程执行。`DEAD` 行需人工依据 `last_error` 与平台侧幂等处理。 + +**I8 — DEAD 重放入队**:在平台与 Webhook 配置 **`LICENSE_WEBHOOK_BASE_URL`**(Webhook 根 URL)与 **`LICENSE_WEBHOOK_OPS_TOKEN`**(两侧相同;保护 Webhook `POST /internal/v1/platform-deliveries/by-receipt/{receiptId}/replay`)。**OPS / SYS_ADMIN** 可在 UI **Callback 详情** 触发「重新入队出库」,平台会按收件箱的 `webhookReceiptId` 代调 Webhook;仅当出库行为 **`DEAD`** 时成功。亦可手工:`curl -X POST -H "X-Webhook-Ops-Token: …" "http:///internal/v1/platform-deliveries/by-receipt//replay"`。 + +**I9 — 出库状态只读**:平台 `GET /api/v1/callback-inbox/{id}/webhook-delivery` 代调 Webhook `GET …/by-receipt/{receiptId}`(同一 Ops Token);UI 详情展示 `status` / `attempts` / `lastError` 等。 diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java index d943c7b..d7cec99 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java @@ -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); + } } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java index b763dc4..8b5dd8c 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CallbackInboxService.java @@ -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) { diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookDeliveryStatusResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookDeliveryStatusResponse.java new file mode 100644 index 0000000..4fc5b67 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookDeliveryStatusResponse.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookReplayResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookReplayResponse.java new file mode 100644 index 0000000..1556c51 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CallbackWebhookReplayResponse.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/webhook/WebhookDeliveryReplayClient.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/webhook/WebhookDeliveryReplayClient.java new file mode 100644 index 0000000..c9c3feb --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/webhook/WebhookDeliveryReplayClient.java @@ -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; + } +} diff --git a/services/delivery-platform-api/src/main/resources/application.yml b/services/delivery-platform-api/src/main/resources/application.yml index 32c9459..2da3771 100644 --- a/services/delivery-platform-api/src/main/resources/application.yml +++ b/services/delivery-platform-api/src/main/resources/application.yml @@ -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 diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java index 69dea2d..1294412 100644 --- a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java @@ -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); } } diff --git a/services/delivery-platform-api/src/test/resources/application.yml b/services/delivery-platform-api/src/test/resources/application.yml index 16b12a2..dbd09e7 100644 --- a/services/delivery-platform-api/src/test/resources/application.yml +++ b/services/delivery-platform-api/src/test/resources/application.yml @@ -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 diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java index ba2517e..5353518 100644 --- a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java @@ -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 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 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 { diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookOpsTokenFilter.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookOpsTokenFilter.java new file mode 100644 index 0000000..0cbbb9c --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookOpsTokenFilter.java @@ -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); + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryOpsController.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryOpsController.java new file mode 100644 index 0000000..8148d8c --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryOpsController.java @@ -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 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 replayByReceipt(@PathVariable("receiptId") long receiptId) { + platformDeliveryService.replayDeadDeliveryByReceiptId(receiptId); + Map body = new LinkedHashMap<>(); + body.put("status", "REQUEUED"); + body.put("receiptId", receiptId); + return body; + } +} diff --git a/services/license-webhook-ingress/src/main/resources/application.yml b/services/license-webhook-ingress/src/main/resources/application.yml index ad784fa..4d62095 100644 --- a/services/license-webhook-ingress/src/main/resources/application.yml +++ b/services/license-webhook-ingress/src/main/resources/application.yml @@ -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:} diff --git a/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryReplayTest.java b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryReplayTest.java new file mode 100644 index 0000000..9958913 --- /dev/null +++ b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/WebhookPlatformDeliveryReplayTest.java @@ -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()); + } +} diff --git a/services/license-webhook-ingress/src/test/resources/application.yml b/services/license-webhook-ingress/src/test/resources/application.yml index 1409f52..c68c497 100644 --- a/services/license-webhook-ingress/src/test/resources/application.yml +++ b/services/license-webhook-ingress/src/test/resources/application.yml @@ -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 diff --git a/web/delivery-platform-ui/src/api/platform.js b/web/delivery-platform-ui/src/api/platform.js index 295b06a..6e98012 100644 --- a/web/delivery-platform-ui/src/api/platform.js +++ b/web/delivery-platform-ui/src/api/platform.js @@ -244,6 +244,22 @@ export function patchCallbackInboxLink(id, body) { return axios.patch(`/api/v1/callback-inbox/${id}/link`, body); } +/** + * I8:将 Webhook 侧 DEAD 出库按收据 ID 重新入队(需平台配置 LICENSE_WEBHOOK_*)。 + * @param {string | number} id — callback inbox id + */ +export function replayCallbackWebhookDelivery(id) { + return axios.post(`/api/v1/callback-inbox/${id}/replay-webhook-delivery`); +} + +/** + * I9:只读查询 Webhook 侧平台投递行状态(需 LICENSE_WEBHOOK_*)。 + * @param {string | number} id — callback inbox id + */ +export function getCallbackWebhookDelivery(id) { + return axios.get(`/api/v1/callback-inbox/${id}/webhook-delivery`); +} + /** * @param {{ page?: number, size?: number }} params */ diff --git a/web/delivery-platform-ui/src/main.js b/web/delivery-platform-ui/src/main.js index f5566a3..b327652 100644 --- a/web/delivery-platform-ui/src/main.js +++ b/web/delivery-platform-ui/src/main.js @@ -7,10 +7,11 @@ import App from "./App.vue"; import router from "./router"; import { useAuthStore } from "./stores/auth"; -const apiBase = +// 开发环境始终使用相对路径,以便 Vite 将 /api 代理到后端;误设 VITE_API_BASE 时否则会直连并常出现跨域或连错环境。 +const apiBaseRaw = typeof import.meta.env.VITE_API_BASE === "string" ? import.meta.env.VITE_API_BASE.trim() : ""; -if (apiBase) { - axios.defaults.baseURL = apiBase.replace(/\/+$/, ""); +if (!import.meta.env.DEV && apiBaseRaw) { + axios.defaults.baseURL = apiBaseRaw.replace(/\/+$/, ""); } const pinia = createPinia(); diff --git a/web/delivery-platform-ui/src/views/CallbackInboxDetailView.vue b/web/delivery-platform-ui/src/views/CallbackInboxDetailView.vue index fc20a43..06a00ad 100644 --- a/web/delivery-platform-ui/src/views/CallbackInboxDetailView.vue +++ b/web/delivery-platform-ui/src/views/CallbackInboxDetailView.vue @@ -20,6 +20,7 @@ {{ row.eventType ?? "—" }} {{ row.schemaVersion ?? "—" }} {{ row.idempotencyKey ?? "—" }} + {{ row.webhookReceiptId ?? "—" }} {{ row.snCode ?? "—" }} {{ row.projectId ?? "—" }} {{ row.contractId ?? "—" }} @@ -35,6 +36,28 @@

Payload(脱敏预览)

{{ payloadDisplay }}
+

Webhook 平台投递状态(I9)

+ + +

Webhook 出库(I8)

+

+ 若 Webhook 侧平台投递为 DEAD,可将该收据对应任务重新入队;需在平台配置 LICENSE_WEBHOOK_BASE_URL 与 + LICENSE_WEBHOOK_OPS_TOKEN。 +

+
+ 重新入队出库(DEAD→待投递) +
+

状态处置

标为已处理 @@ -68,7 +91,13 @@ import { ref, reactive, computed, watch, onMounted } from "vue"; import { useRoute, useRouter } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; import { useAuthStore } from "../stores/auth"; -import { getCallbackInbox, patchCallbackInboxStatus, patchCallbackInboxLink } from "../api/platform"; +import { + getCallbackInbox, + getCallbackWebhookDelivery, + patchCallbackInboxStatus, + patchCallbackInboxLink, + replayCallbackWebhookDelivery, +} from "../api/platform"; import { apiErrorMessage } from "../utils/apiErrorMessage"; import { formatRedactedPayloadJson } from "../utils/redactPayload"; @@ -78,8 +107,12 @@ const router = useRouter(); const loading = ref(false); const patchingStatus = ref(false); +const replaying = ref(false); const savingLink = ref(false); const row = ref(null); +const webhookDeliveryStatus = ref(null); +const webhookDeliveryLoading = ref(false); +const webhookDeliveryError = ref(null); const linkForm = reactive({ licenseSnId: "", @@ -91,6 +124,11 @@ const inboxId = computed(() => route.params.id); const isPending = computed(() => String(row.value?.status ?? "").toUpperCase() === "PENDING"); +const canReplayWebhook = computed(() => { + const id = row.value?.webhookReceiptId ?? row.value?.webhook_receipt_id; + return id != null && String(id).trim() !== ""; +}); + const payloadDisplay = computed(() => { const r = row.value; if (!r) return "—"; @@ -147,6 +185,27 @@ function goList() { router.push({ name: "callback-inbox" }); } +async function loadWebhookDelivery() { + const id = inboxId.value; + const rid = row.value?.webhookReceiptId ?? row.value?.webhook_receipt_id; + if (id == null || id === "" || rid == null || String(rid).trim() === "") { + webhookDeliveryStatus.value = null; + webhookDeliveryError.value = null; + return; + } + webhookDeliveryLoading.value = true; + webhookDeliveryError.value = null; + try { + const { data } = await getCallbackWebhookDelivery(id); + webhookDeliveryStatus.value = data && typeof data === "object" ? data : null; + } catch (e) { + webhookDeliveryStatus.value = null; + webhookDeliveryError.value = apiErrorMessage(e, "加载 Webhook 出库状态失败"); + } finally { + webhookDeliveryLoading.value = false; + } +} + async function load() { const id = inboxId.value; if (id == null || id === "") return; @@ -160,6 +219,12 @@ async function load() { } finally { loading.value = false; } + if (row.value) { + await loadWebhookDelivery(); + } else { + webhookDeliveryStatus.value = null; + webhookDeliveryError.value = null; + } } async function setStatus(status) { @@ -183,6 +248,30 @@ async function setStatus(status) { } } +async function replayWebhook() { + const id = inboxId.value; + if (id == null) return; + try { + await ElMessageBox.confirm( + "确认向 Webhook 请求将该收据的 DEAD 出库重新入队?(若平台未配置 Webhook 地址或出库非 DEAD 将失败)", + "重新入队", + { type: "warning" } + ); + } catch { + return; + } + replaying.value = true; + try { + await replayCallbackWebhookDelivery(id); + ElMessage.success("已请求重新入队,请稍后在 Webhook 库表或收件箱确认"); + await loadWebhookDelivery(); + } catch (e) { + ElMessage.error(apiErrorMessage(e, "重新入队失败")); + } finally { + replaying.value = false; + } +} + async function saveLink() { const id = inboxId.value; if (id == null) return; @@ -239,6 +328,18 @@ async function saveLink() { gap: 8px; margin-bottom: 16px; } +.hint { + margin: 0 0 8px; + font-size: 13px; + color: var(--el-text-color-secondary); + line-height: 1.5; +} +.webhook-err { + color: var(--el-color-danger); +} +.block-tight { + margin-bottom: 12px; +} .payload-pre { margin: 0; padding: 12px; diff --git a/web/delivery-platform-ui/src/views/LoginView.vue b/web/delivery-platform-ui/src/views/LoginView.vue index 5256a2e..7c6dc6c 100644 --- a/web/delivery-platform-ui/src/views/LoginView.vue +++ b/web/delivery-platform-ui/src/views/LoginView.vue @@ -21,6 +21,7 @@ import { ref, onMounted } from "vue"; import { useRouter, useRoute } from "vue-router"; import { ElMessage } from "element-plus"; import { useAuthStore } from "../stores/auth"; +import { apiErrorMessage } from "../utils/apiErrorMessage"; const username = ref("admin"); const password = ref("admin"); @@ -39,7 +40,11 @@ async function onSubmit() { const redirect = route.query.redirect || "/"; await router.replace(typeof redirect === "string" ? redirect : "/"); } catch (e) { - ElMessage.error(e.response?.data?.message || "登录失败"); + const hasResponse = e && typeof e === "object" && "response" in e && e.response; + const fallback = hasResponse + ? "登录失败" + : "无法连接登录接口,请确认 delivery-platform-api 已在本机 8080 运行(npm run dev 前启动后端,见 README)。"; + ElMessage.error(apiErrorMessage(e, fallback)); } finally { loading.value = false; }