feat(m5): add simulated callback event delivery for testing

This commit is contained in:
2026-05-25 15:04:03 +08:00
parent ca1279162b
commit 1cef437fb3
3 changed files with 74 additions and 3 deletions
@@ -1,6 +1,9 @@
package cn.craftlabs.platform.api.callback; package cn.craftlabs.platform.api.callback;
import cn.craftlabs.platform.api.service.CallbackEventIngestService;
import cn.craftlabs.platform.api.service.CallbackInboxService; import cn.craftlabs.platform.api.service.CallbackInboxService;
import cn.craftlabs.platform.api.web.dto.CallbackEventIngestRequest;
import cn.craftlabs.platform.api.web.dto.CallbackEventIngestResponse;
import cn.craftlabs.platform.api.web.dto.CallbackInboxLinkPatchRequest; import cn.craftlabs.platform.api.web.dto.CallbackInboxLinkPatchRequest;
import cn.craftlabs.platform.api.web.dto.CallbackInboxResponse; import cn.craftlabs.platform.api.web.dto.CallbackInboxResponse;
import cn.craftlabs.platform.api.web.dto.CallbackInboxStatusPatchRequest; import cn.craftlabs.platform.api.web.dto.CallbackInboxStatusPatchRequest;
@@ -10,15 +13,17 @@ import cn.craftlabs.platform.api.web.dto.PageResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@@ -30,9 +35,11 @@ import java.time.OffsetDateTime;
public class CallbackInboxController { public class CallbackInboxController {
private final CallbackInboxService callbackInboxService; private final CallbackInboxService callbackInboxService;
private final CallbackEventIngestService callbackEventIngestService;
public CallbackInboxController(CallbackInboxService callbackInboxService) { public CallbackInboxController(CallbackInboxService callbackInboxService, CallbackEventIngestService callbackEventIngestService) {
this.callbackInboxService = callbackInboxService; this.callbackInboxService = callbackInboxService;
this.callbackEventIngestService = callbackEventIngestService;
} }
@GetMapping @GetMapping
@@ -77,6 +84,14 @@ public class CallbackInboxController {
return callbackInboxService.patchLink(id, request); return callbackInboxService.patchLink(id, request);
} }
/** M5-F10:模拟投递(仅测试环境),手动 POST 模拟 Callback 事件。 */
@PostMapping("/simulate")
public CallbackEventIngestResponse simulate(
@Valid @RequestBody CallbackEventIngestRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
return callbackEventIngestService.ingest(request, idempotencyKey);
}
/** I8:代理 OPS 调用 Webhook,将关联收据的 {@code DEAD} 出库重新入队。 */ /** I8:代理 OPS 调用 Webhook,将关联收据的 {@code DEAD} 出库重新入队。 */
@PostMapping("/{id}/replay-webhook-delivery") @PostMapping("/{id}/replay-webhook-delivery")
public CallbackWebhookReplayResponse replayWebhookDelivery(@PathVariable("id") long id) { public CallbackWebhookReplayResponse replayWebhookDelivery(@PathVariable("id") long id) {
@@ -280,6 +280,14 @@ export function patchCallbackInboxLink(id, body) {
return axios.patch(`/api/v1/callback-inbox/${id}/link`, body); return axios.patch(`/api/v1/callback-inbox/${id}/link`, body);
} }
/**
* M5-F10:模拟投递(仅测试环境)。
* @param {Record<string, unknown>} body
*/
export function simulateCallback(body) {
return axios.post('/api/v1/callback-inbox/simulate', body);
}
/** /**
* I8:将 Webhook 侧 DEAD 出库按收据 ID 重新入队(需平台配置 LICENSE_WEBHOOK_*)。 * I8:将 Webhook 侧 DEAD 出库按收据 ID 重新入队(需平台配置 LICENSE_WEBHOOK_*)。
* @param {string | number} id — callback inbox id * @param {string | number} id — callback inbox id
@@ -15,6 +15,7 @@
<el-input v-model="filterProjectId" clearable placeholder="项目 ID" class="filter" style="width: 120px" @keyup.enter="load" /> <el-input v-model="filterProjectId" clearable placeholder="项目 ID" class="filter" style="width: 120px" @keyup.enter="load" />
<el-button type="primary" :loading="loading" @click="load">查询</el-button> <el-button type="primary" :loading="loading" @click="load">查询</el-button>
<el-button @click="handleBatchRetry" :disabled="selectedCallbacks.length === 0">批量重试</el-button> <el-button @click="handleBatchRetry" :disabled="selectedCallbacks.length === 0">批量重试</el-button>
<el-button @click="simDialogVisible = true">模拟投递</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -52,6 +53,29 @@
@size-change="onSizeChange" @size-change="onSizeChange"
/> />
</div> </div>
<el-dialog v-model="simDialogVisible" title="模拟 Callback 投递" width="560px">
<el-form label-width="120px">
<el-form-item label="事件类型" required>
<el-select v-model="simForm.eventType" style="width:100%">
<el-option label="sn:post_activate" value="sn:post_activate" />
<el-option label="sn:pre_activate" value="sn:pre_activate" />
<el-option label="device:post_activate" value="device:post_activate" />
<el-option label="yunbaobao:session_logout" value="yunbaobao:session_logout" />
</el-select>
</el-form-item>
<el-form-item label="SN 编码">
<el-input v-model="simForm.snCode" placeholder="选填" />
</el-form-item>
<el-form-item label="Payload (JSON)">
<el-input v-model="simForm.rawPayload" type="textarea" :rows="6" placeholder='{"key": "value"}' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="simDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="simulating" @click="handleSimulate">投递</el-button>
</template>
</el-dialog>
</el-card> </el-card>
</template> </template>
@@ -60,7 +84,7 @@ import { ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { listCallbackInbox, replayCallbackWebhookDelivery } from "../api/platform"; import { listCallbackInbox, replayCallbackWebhookDelivery, simulateCallback } from "../api/platform";
import { apiErrorMessage } from "../utils/apiErrorMessage"; import { apiErrorMessage } from "../utils/apiErrorMessage";
const auth = useAuthStore(); const auth = useAuthStore();
@@ -76,6 +100,9 @@ const filterEventType = ref("");
const filterSnCode = ref(""); const filterSnCode = ref("");
const filterProjectId = ref(""); const filterProjectId = ref("");
const selectedCallbacks = ref([]); const selectedCallbacks = ref([]);
const simDialogVisible = ref(false);
const simulating = ref(false);
const simForm = ref({ eventType: 'sn:post_activate', snCode: '', rawPayload: '{}' });
function handleSelectionChange(val) { function handleSelectionChange(val) {
selectedCallbacks.value = val; selectedCallbacks.value = val;
@@ -150,6 +177,27 @@ async function load() {
} }
} }
async function handleSimulate() {
simulating.value = true;
try {
await simulateCallback({
sourceSystem: 'SIMULATOR',
externalMessageId: `sim-${Date.now()}`,
schemaVersion: '1.0',
eventType: simForm.value.eventType,
rawPayload: JSON.parse(simForm.value.rawPayload),
receivedAt: new Date().toISOString(),
});
ElMessage.success('模拟事件已投递');
simDialogVisible.value = false;
load();
} catch (e) {
ElMessage.error(apiErrorMessage(e, '模拟投递失败'));
} finally {
simulating.value = false;
}
}
function goDetail(id) { function goDetail(id) {
router.push({ name: "callback-inbox-detail", params: { id: String(id) } }); router.push({ name: "callback-inbox-detail", params: { id: String(id) } });
} }