feat: 建立 Sense 可靠投递状态机 (#78)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listOutbox(query) {
|
||||
return request({ url: '/api/v1/outbox', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getOutbox(id) {
|
||||
return request({
|
||||
url: `/api/v1/outbox/${encodeURIComponent(id)}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function requeueOutbox(id, expectedVersion, reason) {
|
||||
return request({
|
||||
url: `/api/v1/outbox/${encodeURIComponent(id)}/requeue`,
|
||||
method: 'post',
|
||||
data: { expectedVersion, reason }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>可靠投递</h3>
|
||||
<p>查看 Sense 内部待投递记录、重试进度和人工恢复。</p>
|
||||
</div>
|
||||
<el-button
|
||||
:icon="Refresh"
|
||||
:loading="loading"
|
||||
@click="load"
|
||||
>刷新状态</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="boundary-alert"
|
||||
>
|
||||
<template #title>未配置外部投递出口不影响 Sense 核心功能</template>
|
||||
内部记录会安全保留,正式 connector 由后续协调工单提供;测试接收器在
|
||||
production 模式不可启用。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="可靠投递队列概览">
|
||||
<el-col
|
||||
v-for="card in summaryCards"
|
||||
:key="card.key"
|
||||
:xs="12"
|
||||
:sm="6"
|
||||
><div class="summary-item">
|
||||
<span>{{ card.label }}</span><strong :class="card.className">{{ summary[card.key] }}</strong>
|
||||
</div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form
|
||||
:model="query"
|
||||
label-width="76px"
|
||||
class="filter-form"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<el-form-item label="关键词"><el-input
|
||||
v-model="query.keyword"
|
||||
clearable
|
||||
placeholder="内部记录、业务引用或幂等键"
|
||||
@keyup.enter="search"
|
||||
/></el-form-item>
|
||||
<el-form-item label="状态"><el-select
|
||||
v-model="query.state"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
><el-option
|
||||
v-for="(label, value) in stateLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/></el-select></el-form-item>
|
||||
<el-form-item label="内部类型"><el-select
|
||||
v-model="query.internalType"
|
||||
clearable
|
||||
placeholder="全部类型"
|
||||
><el-option
|
||||
v-for="(label, value) in typeLabels"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/></el-select></el-form-item>
|
||||
<el-form-item class="filter-actions"><el-button
|
||||
type="primary"
|
||||
:icon="Search"
|
||||
native-type="submit"
|
||||
>查询</el-button><el-button
|
||||
:icon="RefreshLeft"
|
||||
@click="reset"
|
||||
>重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="items"
|
||||
border
|
||||
stripe
|
||||
empty-text="当前筛选条件下没有可靠投递记录"
|
||||
>
|
||||
<el-table-column
|
||||
label="内部记录"
|
||||
min-width="255"
|
||||
><template #default="scope"><strong>{{ typeLabel(scope.row.internalType) }}</strong>
|
||||
<div class="code-text">{{ scope.row.id }}</div>
|
||||
<small>幂等键:{{ scope.row.idempotencyKey }}</small></template></el-table-column>
|
||||
<el-table-column
|
||||
label="状态"
|
||||
width="112"
|
||||
align="center"
|
||||
><template #default="scope"><el-tag :type="stateType(scope.row.state)">{{
|
||||
stateLabel(scope.row.state)
|
||||
}}</el-tag></template></el-table-column>
|
||||
<el-table-column
|
||||
label="尝试"
|
||||
width="74"
|
||||
align="center"
|
||||
><template #default="scope">{{ scope.row.attemptCount }} 次</template></el-table-column>
|
||||
<el-table-column
|
||||
label="下次动作"
|
||||
min-width="180"
|
||||
><template #default="scope">{{
|
||||
nextAction(scope.row)
|
||||
}}</template></el-table-column>
|
||||
<el-table-column
|
||||
label="租约"
|
||||
min-width="142"
|
||||
><template #default="scope"><div>{{ scope.row.leaseOwner || "未领取" }}</div>
|
||||
<small v-if="scope.row.leaseUntil">{{
|
||||
formatTime(scope.row.leaseUntil)
|
||||
}}</small></template></el-table-column>
|
||||
<el-table-column
|
||||
label="最近结果"
|
||||
min-width="210"
|
||||
><template #default="scope">{{
|
||||
scope.row.lastError ||
|
||||
(scope.row.state === "delivered" ? "投递成功" : "尚未失败")
|
||||
}}</template></el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="150"
|
||||
fixed="right"
|
||||
><template #default="scope"><el-button
|
||||
v-permisaction="['sense:outbox:detail']"
|
||||
type="primary"
|
||||
link
|
||||
@click="openDetail(scope.row.id)"
|
||||
>详情</el-button><el-button
|
||||
v-if="scope.row.state === 'dead'"
|
||||
v-permisaction="['sense:outbox:requeue']"
|
||||
type="warning"
|
||||
link
|
||||
@click="openRequeue(scope.row)"
|
||||
>重新排队</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:current-page="query.pageIndex"
|
||||
v-model:page-size="query.pageSize"
|
||||
:total="total"
|
||||
@pagination="load"
|
||||
/>
|
||||
<p class="boundary-text">
|
||||
页面不展示内部
|
||||
payload、外部凭据或机器身份;人工恢复保留原业务记录、幂等键和失败历史。
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailOpen"
|
||||
title="投递记录详情"
|
||||
width="min(780px, calc(100vw - 24px))"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-descriptions v-if="selected" :column="2" border>
|
||||
<el-descriptions-item label="内部记录">{{
|
||||
selected.message.id
|
||||
}}</el-descriptions-item><el-descriptions-item label="内部类型">{{
|
||||
typeLabel(selected.message.internalType)
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="幂等键"
|
||||
:span="2"
|
||||
><span class="code-text">{{
|
||||
selected.message.idempotencyKey
|
||||
}}</span></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag :type="stateType(selected.message.state)">{{
|
||||
stateLabel(selected.message.state)
|
||||
}}</el-tag></el-descriptions-item><el-descriptions-item label="业务引用">{{
|
||||
selected.message.businessRef
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近结果" :span="2">{{
|
||||
selected.message.lastError || "尚未失败"
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 class="timeline-title">处理时间线</h4>
|
||||
<el-timeline v-if="selected"><el-timeline-item
|
||||
v-for="attempt in selected.attempts"
|
||||
:key="attempt.id"
|
||||
:timestamp="formatTime(attempt.createdAt)"
|
||||
placement="top"
|
||||
><strong>第 {{ attempt.number }} 次 · {{ attempt.outcome }}</strong>
|
||||
<div>{{ attempt.detail }}</div>
|
||||
<small v-if="attempt.actorUserId">操作人编号:{{ attempt.actorUserId }}</small></el-timeline-item><el-timeline-item
|
||||
v-if="!selected.attempts.length"
|
||||
:timestamp="formatTime(selected.message.createdAt)"
|
||||
>与业务记录在同一事务中创建</el-timeline-item></el-timeline>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="requeueOpen"
|
||||
title="将死信重新排队"
|
||||
width="min(560px, calc(100vw - 24px))"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetRequeue"
|
||||
>
|
||||
<el-alert
|
||||
title="这是受控恢复操作"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>只创建新的投递尝试,不修改原业务记录或删除失败历史。执行前请先排除失败原因。</el-alert>
|
||||
<el-form
|
||||
ref="requeueFormRef"
|
||||
:model="requeueForm"
|
||||
:rules="requeueRules"
|
||||
label-position="top"
|
||||
class="requeue-form"
|
||||
><el-form-item
|
||||
label="恢复原因"
|
||||
prop="reason"
|
||||
><el-input
|
||||
v-model="requeueForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="256"
|
||||
show-word-limit
|
||||
placeholder="例如:出口配置已恢复,已核对幂等键和目标状态"
|
||||
/></el-form-item></el-form>
|
||||
<p class="boundary-text">
|
||||
原因将与操作人、对象版本和时间一起写入脱敏审计。
|
||||
</p>
|
||||
<template #footer><el-button @click="requeueOpen = false">取消</el-button><el-button
|
||||
type="warning"
|
||||
:loading="requeueLoading"
|
||||
@click="confirmRequeue"
|
||||
>确认重新排队</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getOutbox, listOutbox, requeueOutbox } from '@/api/sense/outbox'
|
||||
import {
|
||||
buildOutboxQuery,
|
||||
formatTime,
|
||||
nextAction,
|
||||
stateLabel,
|
||||
stateLabels,
|
||||
stateType,
|
||||
typeLabel,
|
||||
typeLabels
|
||||
} from './outboxState'
|
||||
|
||||
defineOptions({ name: 'SenseOutbox' })
|
||||
const loading = ref(false)
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const requeueOpen = ref(false)
|
||||
const requeueLoading = ref(false)
|
||||
const requeueTarget = ref(null)
|
||||
const requeueFormRef = ref(null)
|
||||
const summary = reactive({ pending: 0, retry: 0, processing: 0, dead: 0 })
|
||||
const query = reactive({
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
state: '',
|
||||
internalType: '',
|
||||
keyword: ''
|
||||
})
|
||||
const requeueForm = reactive({ reason: '' })
|
||||
const requeueRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请填写恢复原因', trigger: 'blur' },
|
||||
{ min: 6, max: 256, message: '请填写 6 至 256 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
const summaryCards = computed(() => [
|
||||
{ key: 'pending', label: '等待投递' },
|
||||
{ key: 'retry', label: '重试等待', className: 'warning-number' },
|
||||
{ key: 'processing', label: '处理中 / 租约' },
|
||||
{ key: 'dead', label: '死信待处理', className: 'danger-number' }
|
||||
])
|
||||
function unwrap(response) {
|
||||
return response?.data?.data ?? response?.data ?? response
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listOutbox(buildOutboxQuery(query))) || {}
|
||||
items.value = payload.list || []
|
||||
total.value = payload.count || 0
|
||||
Object.assign(
|
||||
summary,
|
||||
payload.summary || { pending: 0, retry: 0, processing: 0, dead: 0 }
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '可靠投递状态加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function search() {
|
||||
query.pageIndex = 1
|
||||
load()
|
||||
}
|
||||
function reset() {
|
||||
Object.assign(query, {
|
||||
pageIndex: 1,
|
||||
state: '',
|
||||
internalType: '',
|
||||
keyword: ''
|
||||
})
|
||||
load()
|
||||
}
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getOutbox(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '投递详情加载失败')
|
||||
}
|
||||
}
|
||||
function openRequeue(item) {
|
||||
requeueTarget.value = item
|
||||
requeueOpen.value = true
|
||||
}
|
||||
function resetRequeue() {
|
||||
requeueForm.reason = ''
|
||||
requeueTarget.value = null
|
||||
requeueFormRef.value?.clearValidate()
|
||||
}
|
||||
async function confirmRequeue() {
|
||||
if (!requeueFormRef.value || !requeueTarget.value) return
|
||||
const valid = await requeueFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
requeueLoading.value = true
|
||||
try {
|
||||
await requeueOutbox(
|
||||
requeueTarget.value.id,
|
||||
requeueTarget.value.version,
|
||||
requeueForm.reason.trim()
|
||||
)
|
||||
ElMessage.success('死信已重新排队,原失败历史已保留')
|
||||
requeueOpen.value = false
|
||||
detailOpen.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message || '重新排队失败,请刷新状态后重试')
|
||||
} finally {
|
||||
requeueLoading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header h3 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
}
|
||||
.boundary-alert {
|
||||
margin: 16px 0;
|
||||
}
|
||||
.summary-row {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 68px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.summary-item span {
|
||||
color: #606266;
|
||||
}
|
||||
.summary-item strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
.warning-number {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.danger-number {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.filter-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.filter-form .el-form-item {
|
||||
width: 260px;
|
||||
}
|
||||
.filter-form .filter-actions {
|
||||
width: auto;
|
||||
}
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
.code-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.boundary-text,
|
||||
small {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.boundary-text {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.timeline-title {
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
.requeue-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.filter-form .el-form-item {
|
||||
width: 100%;
|
||||
}
|
||||
.summary-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
export const stateLabels = {
|
||||
pending: '等待投递',
|
||||
processing: '处理中',
|
||||
retry: '重试等待',
|
||||
dead: '死信',
|
||||
delivered: '已投递'
|
||||
}
|
||||
export const typeLabels = {
|
||||
local_event_candidate: '本地事件候选',
|
||||
audit_projection: '审计投影'
|
||||
}
|
||||
|
||||
export function stateLabel(value) {
|
||||
return stateLabels[value] || value || '—'
|
||||
}
|
||||
export function stateType(value) {
|
||||
return (
|
||||
{
|
||||
pending: 'info',
|
||||
processing: 'primary',
|
||||
retry: 'warning',
|
||||
dead: 'danger',
|
||||
delivered: 'success'
|
||||
}[value] || 'info'
|
||||
)
|
||||
}
|
||||
export function typeLabel(value) {
|
||||
return typeLabels[value] || value || '—'
|
||||
}
|
||||
export function buildOutboxQuery(query) {
|
||||
return {
|
||||
pageIndex: query.pageIndex,
|
||||
pageSize: query.pageSize,
|
||||
state: query.state || undefined,
|
||||
internalType: query.internalType || undefined,
|
||||
keyword: String(query.keyword || '').trim() || undefined
|
||||
}
|
||||
}
|
||||
export function nextAction(item, now = Date.now()) {
|
||||
if (item.state === 'dead') return '等待人工处理'
|
||||
if (item.state === 'processing') { return item.leaseUntil ? `租约至 ${formatTime(item.leaseUntil)}` : '处理中' }
|
||||
if (item.state === 'retry') {
|
||||
return item.availableAt && new Date(item.availableAt).getTime() > now
|
||||
? `自动重试 ${formatTime(item.availableAt)}`
|
||||
: '等待重试领取'
|
||||
}
|
||||
if (item.state === 'delivered') return '已完成'
|
||||
return '等待 worker 领取'
|
||||
}
|
||||
export function formatTime(value) {
|
||||
return value
|
||||
? new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
: '—'
|
||||
}
|
||||
Reference in New Issue
Block a user