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
@@ -20,6 +20,7 @@
<el-descriptions-item label="事件类型">{{ row.eventType ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="Schema 版本">{{ row.schemaVersion ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="幂等键">{{ row.idempotencyKey ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="Webhook 收据 ID">{{ row.webhookReceiptId ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="SN">{{ row.snCode ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="项目 ID">{{ row.projectId ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="合同 ID">{{ row.contractId ?? "—" }}</el-descriptions-item>
@@ -35,6 +36,28 @@
<h3 class="section-title">Payload脱敏预览</h3>
<pre class="payload-pre">{{ payloadDisplay }}</pre>
<h3 v-if="canReplayWebhook" class="section-title">Webhook 平台投递状态I9</h3>
<template v-if="canReplayWebhook">
<el-skeleton v-if="webhookDeliveryLoading" :rows="2" animated />
<p v-else-if="webhookDeliveryError" class="hint webhook-err">{{ webhookDeliveryError }}</p>
<el-descriptions v-else-if="webhookDeliveryStatus" :column="2" border class="block block-tight">
<el-descriptions-item label="出库状态">{{ webhookDeliveryStatus.status ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="尝试次数">{{ webhookDeliveryStatus.attempts ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="上次错误" :span="2">{{ webhookDeliveryStatus.lastError ?? "—" }}</el-descriptions-item>
<el-descriptions-item label="下次重试">{{ formatDateTime(webhookDeliveryStatus.nextRetryAt) }}</el-descriptions-item>
<el-descriptions-item label="出库更新时间">{{ formatDateTime(webhookDeliveryStatus.updatedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
<h3 v-if="canReplayWebhook" class="section-title">Webhook 出库I8</h3>
<p v-if="canReplayWebhook" class="hint">
Webhook 侧平台投递为 DEAD可将该收据对应任务重新入队需在平台配置 <code>LICENSE_WEBHOOK_BASE_URL</code>
<code>LICENSE_WEBHOOK_OPS_TOKEN</code>
</p>
<div v-if="canReplayWebhook" class="status-row">
<el-button type="warning" :loading="replaying" @click="replayWebhook">重新入队出库DEAD待投递</el-button>
</div>
<h3 v-if="isPending" class="section-title">状态处置</h3>
<div v-if="isPending" class="status-row">
<el-button type="success" :loading="patchingStatus" @click="setStatus('PROCESSED')">标为已处理</el-button>
@@ -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;
@@ -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;
}