diff --git a/apps/backend/src/repositories/message-delivery-repo.js b/apps/backend/src/repositories/message-delivery-repo.js index 0f7f70eb..1d419659 100644 --- a/apps/backend/src/repositories/message-delivery-repo.js +++ b/apps/backend/src/repositories/message-delivery-repo.js @@ -115,3 +115,92 @@ export async function findLatestSuccessfulMessageDeliveryByTask(taskId, channel) return result.rows[0] || null } + +export async function listMessageDeliveries({ + page = 1, + pageSize = 20, + provider = '', + platform = '', + status = '', + shopId = '', + platformOrderId = '', + taskNo = '', + dateFrom = '', + dateTo = '', +} = {}) { + const offset = (page - 1) * pageSize + const filters = [] + const params = [] + + if (provider) { + params.push(provider) + filters.push(`md.provider = $${params.length}`) + } + + if (platform) { + params.push(platform) + filters.push(`md.platform = $${params.length}`) + } + + if (status) { + params.push(status) + filters.push(`md.status = $${params.length}`) + } + + if (shopId) { + params.push(shopId) + filters.push(`md.shop_id = $${params.length}`) + } + + if (platformOrderId) { + params.push(`%${platformOrderId}%`) + filters.push(`md.platform_order_id ILIKE $${params.length}`) + } + + if (taskNo) { + params.push(`%${taskNo}%`) + filters.push(`ft.task_no ILIKE $${params.length}`) + } + + if (dateFrom) { + params.push(dateFrom) + filters.push(`md.created_at >= $${params.length}`) + } + + if (dateTo) { + params.push(dateTo) + filters.push(`md.created_at <= $${params.length}`) + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const fromClause = ` + FROM message_deliveries md + LEFT JOIN fulfillment_tasks ft ON ft.id = md.task_id + ` + + const totalResult = await query( + `SELECT COUNT(*)::int AS total ${fromClause} ${whereClause}`, + params, + ) + + params.push(pageSize) + params.push(offset) + const itemsResult = await query( + ` + SELECT + md.*, + ft.task_no, + ft.task_status + ${fromClause} + ${whereClause} + ORDER BY md.id DESC + LIMIT $${params.length - 1} OFFSET $${params.length} + `, + params, + ) + + return { + items: itemsResult.rows, + total: Number(totalResult.rows[0]?.total || 0), + } +} diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js index 6c1533de..8440306d 100644 --- a/apps/backend/src/routes/admin.js +++ b/apps/backend/src/routes/admin.js @@ -4,6 +4,7 @@ import authRouter from './admin/auth.js' import auditLogsRouter from './admin/audit-logs.js' import dashboardRouter from './admin/dashboard.js' import inventoryRouter from './admin/inventory.js' +import messageDeliveriesRouter from './admin/message-deliveries.js' import ordersRouter from './admin/orders.js' import platformConfigRouter from './admin/platform-config.js' import { requireAdminSession } from './admin/shared.js' @@ -23,6 +24,7 @@ router.use(platformConfigRouter) router.use(ordersRouter) router.use(tasksRouter) router.use(inventoryRouter) +router.use(messageDeliveriesRouter) router.use(webhookEventsRouter) router.use((req, res) => { diff --git a/apps/backend/src/routes/admin/message-deliveries.js b/apps/backend/src/routes/admin/message-deliveries.js new file mode 100644 index 00000000..1637d387 --- /dev/null +++ b/apps/backend/src/routes/admin/message-deliveries.js @@ -0,0 +1,17 @@ +import { Router } from 'express' + +import { getAdminMessageDeliveries } from '../../services/admin/admin-service.js' +import { createJsonHandler } from './shared.js' + +const router = Router() + +router.get('/message-deliveries', createJsonHandler( + (req) => getAdminMessageDeliveries(req.query), + { + successMessage: 'ok', + errorMessage: '读取消息发送记录失败', + scope: '[admin/message-deliveries]', + }, +)) + +export default router diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index 44c5bb09..d0d2f093 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -32,6 +32,7 @@ import { } from '../../repositories/task-inventory-binding-repo.js' import { createTaskEvent, listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' +import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen, parseAmountToFen } from '../../utils/money.js' import { nowIso } from '../../utils/time.js' @@ -197,6 +198,28 @@ export async function getAdminTasks(query = {}) { } } +export async function getAdminMessageDeliveries(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = await listMessageDeliveries({ + page, + pageSize, + provider: String(query.provider || '').trim(), + platform: String(query.platform || '').trim(), + status: String(query.status || '').trim(), + shopId: String(query.shopId || '').trim(), + platformOrderId: String(query.platformOrderId || '').trim(), + taskNo: String(query.taskNo || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map(mapAdminMessageDeliveryListItem), + pagination: { page, pageSize, total }, + } +} + export async function getAdminTaskDetail(taskId) { const task = await getTaskById(Number(taskId)) @@ -1103,6 +1126,33 @@ function mapAdminFulfillmentBindingConfigItem(item) { } } +function mapAdminMessageDeliveryListItem(item) { + return { + deliveryId: Number(item.id), + provider: String(item.provider || '').trim(), + platform: String(item.platform || '').trim(), + shopId: String(item.shop_id || '').trim(), + shopName: String(item.shop_name || '').trim(), + channel: String(item.channel || '').trim(), + orderId: item.order_id ? Number(item.order_id) : null, + taskId: item.task_id ? Number(item.task_id) : null, + taskNo: String(item.task_no || '').trim(), + taskStatus: String(item.task_status || '').trim(), + platformOrderId: String(item.platform_order_id || '').trim(), + recipientKey: String(item.recipient_key || '').trim(), + messageContent: String(item.message_content || '').trim(), + claimUrl: String(item.claim_url || '').trim(), + status: String(item.status || '').trim(), + requestUrl: String(item.request_url || '').trim(), + responseStatus: Number(item.response_status || 0), + response: safeParseJson(item.response_json), + errorMessage: String(item.error_message || '').trim(), + sentAt: item.sent_at || null, + createdAt: item.created_at, + updatedAt: item.updated_at, + } +} + function matchesObservedProduct(binding, observed) { const provider = String(binding?.provider || '').trim() const platform = String(binding?.platform || '').trim() diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.js b/apps/backend/src/services/platforms/agiso/xianyu/message-service.js index 6193be92..69b89c1e 100644 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.js +++ b/apps/backend/src/services/platforms/agiso/xianyu/message-service.js @@ -28,7 +28,7 @@ export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, tas return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' } } - const successful = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_XIANYU_MESSAGE_CHANNEL) + const successful = await findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_XIANYU_MESSAGE_CHANNEL) if (successful) { return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id } } @@ -52,7 +52,7 @@ export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, tas apiVersion: String(config.apiVersion || '1').trim() || '1', }) const createdAt = nowIso() - const delivery = createMessageDelivery({ + const delivery = await createMessageDelivery({ provider: 'agiso', platform: String(order.platform || '').trim() || 'unknown', shopId: String(order.shop_id || '').trim(), @@ -85,7 +85,7 @@ export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, tas const rawText = await response.text() const parsed = safeParseJson(rawText) const success = isAgisoSendSuccess(response.status, parsed) - const updated = updateMessageDelivery(delivery.id, { + const updated = await updateMessageDelivery(delivery.id, { status: success ? 'success' : 'failed', response_status: response.status, response_json: JSON.stringify(parsed ?? { rawText }), @@ -104,7 +104,7 @@ export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ order, tas } } catch (error) { const message = error instanceof Error ? error.message : String(error || '发送消息失败') - updateMessageDelivery(delivery.id, { + await updateMessageDelivery(delivery.id, { status: 'failed', response_status: 0, response_json: '{}', diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 8320b119..a83f64bc 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -68,6 +68,10 @@ const router = createRouter({ path: 'inventory', component: () => import('@/views/admin/AdminInventoryView.vue'), }, + { + path: 'message-deliveries', + component: () => import('@/views/admin/AdminMessageDeliveriesView.vue'), + }, { path: 'webhook-events', component: () => import('@/views/admin/AdminWebhookEventsView.vue'), diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index c740085f..4c5c83c6 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -5,6 +5,7 @@ import type { AdminAuditLogItem, AdminFulfillmentBindingConfigItem, AdminInventoryItemListItem, + AdminMessageDeliveryListItem, AdminObservedProductItem, AdminTaskActionResponse, AdminDashboardSummary, @@ -154,6 +155,13 @@ export function fetchAdminInventoryItems(params?: Record) { return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>('/api/v1/admin/inventory', params) } +export function fetchAdminMessageDeliveries(params?: Record) { + return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>( + '/api/v1/admin/message-deliveries', + params, + ) +} + export function importAdminInventoryItems(payload: Record) { return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/inventory/import', payload) } diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 46abd97e..cfa68f80 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -371,6 +371,31 @@ export interface AdminInventoryItemListItem { updatedAt: string } +export interface AdminMessageDeliveryListItem { + deliveryId: number + provider: string + platform: string + shopId: string + shopName: string + channel: string + orderId: number | null + taskId: number | null + taskNo: string + taskStatus: string + platformOrderId: string + recipientKey: string + messageContent: string + claimUrl: string + status: string + requestUrl: string + responseStatus: number + response: Record + errorMessage: string + sentAt: string | null + createdAt: string + updatedAt: string +} + export interface AdminWebhookEventListItem { eventId: number provider: string diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 6b098eb4..026fcbdf 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -40,6 +40,13 @@ export const adminInventoryCredentialTypeOptions = [ { label: '纯文本凭据', value: 'text_credential' }, ] +export const adminMessageDeliveryStatusOptions = [ + { label: '全部状态', value: '' }, + { label: '发送成功', value: 'success' }, + { label: '发送失败', value: 'failed' }, + { label: '等待发送', value: 'pending' }, +] + export const adminWebhookProcessedOptions = [ { label: '全部', value: '' }, { label: '处理成功', value: '1' }, diff --git a/apps/frontend/src/views/admin/AdminLayout.vue b/apps/frontend/src/views/admin/AdminLayout.vue index 51ef7444..8c746432 100644 --- a/apps/frontend/src/views/admin/AdminLayout.vue +++ b/apps/frontend/src/views/admin/AdminLayout.vue @@ -15,6 +15,7 @@ const navItems = computed(() => { { to: '/admin/orders', label: '订单' }, { to: '/admin/tasks', label: '任务' }, { to: '/admin/inventory', label: '库存' }, + { to: '/admin/message-deliveries', label: '消息发送' }, { to: '/admin/webhook-events', label: 'Webhook' }, ] diff --git a/apps/frontend/src/views/admin/AdminMessageDeliveriesView.vue b/apps/frontend/src/views/admin/AdminMessageDeliveriesView.vue new file mode 100644 index 00000000..cd9f006d --- /dev/null +++ b/apps/frontend/src/views/admin/AdminMessageDeliveriesView.vue @@ -0,0 +1,401 @@ + + + + +