From 215f8bb710661e497be82e3ab50ac7e190968fd9 Mon Sep 17 00:00:00 2001 From: yml Date: Tue, 14 Apr 2026 18:29:25 +0800 Subject: [PATCH] =?UTF-8?q?=E7=82=B9=E2=80=9C=E5=85=B3=E9=97=AD=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E2=80=9D=EF=BC=8C=E5=B0=B1=E4=BC=9A=E8=BE=BE=E5=88=B0?= =?UTF-8?q?=E4=BD=A0=E8=A6=81=E7=9A=84=E6=95=88=E6=9E=9C=EF=BC=9A=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=A4=B1=E6=95=88=EF=BC=8C=E9=A2=84=E5=8D=A0=E9=87=8A?= =?UTF-8?q?=E6=94=BE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.服务端 | 2 +- .../src/services/admin/admin-write-service.js | 82 +++++++++++++- .../admin/admin-write-service.test.js | 105 ++++++++++++++++++ .../src/services/order/order-service.js | 90 +++++++++++++++ .../src/services/order/order-service.test.js | 61 ++++++++++ .../src/views/admin/AdminTaskDetailView.vue | 17 ++- 6 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 apps/backend/src/services/admin/admin-write-service.test.js create mode 100644 apps/backend/src/services/order/order-service.test.js diff --git a/.env.服务端 b/.env.服务端 index 424fd96b..dcc719d2 100644 --- a/.env.服务端 +++ b/.env.服务端 @@ -20,5 +20,5 @@ TENCENT_SESSION_DEBUG=false ADMIN_SESSION_SECRET=xm4BDpbdHJvU4RIlpGqD0nvQTZtIidvQMk6LSP1d ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"Mzl5iG6pkdrzLs73COiB","role":"admin"},{"username":"operator","password":"d9tvmbPPMRdLySEhORzk","role":"operator"}] -AGISO_APP_SECRET=9fcb0f688aab713f9af3e85d29ba8b15 +AGISO_APP_SECRET=tccxk5c7ppy7xpr43rvastceyydskfha AGISO_MESSAGING_ENABLED=false diff --git a/apps/backend/src/services/admin/admin-write-service.js b/apps/backend/src/services/admin/admin-write-service.js index ec7ddc37..ca0ebb8b 100644 --- a/apps/backend/src/services/admin/admin-write-service.js +++ b/apps/backend/src/services/admin/admin-write-service.js @@ -22,6 +22,7 @@ import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../cl import { reserveInventoryForTask } from '../order/inventory-service.js' import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js' import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js' +import { closeTencentBrowserSession } from '../session/session.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { @@ -394,7 +395,25 @@ export async function redeemAdminTaskAssisted(taskId, session = null) { /** @returns {Promise} */ /** @param {AdminEntityIdInput} taskId */ export async function closeAdminTask(taskId) { - const task = await getRequiredTask(taskId) + return closeAdminTaskWithDeps(taskId) +} + +/** @returns {Promise} */ +/** @param {AdminEntityIdInput} taskId */ +export async function closeAdminTaskWithDeps( + taskId, + { + getRequiredTask: getTask = getRequiredTask, + listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId, + releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, + updateClaimToken: updateToken = updateClaimToken, + updateTask: updateTaskRecord = updateTask, + createTaskEvent: createEvent = createTaskEvent, + closeTencentBrowserSession: closeSession = closeTencentBrowserSession, + nowIso: getNowIso = nowIso, + } = {}, +) { + const task = await getTask(taskId) if (task.task_status === 'redeemed') { throw createHttpError('已兑换任务不能关闭', { @@ -403,13 +422,63 @@ export async function closeAdminTask(taskId) { }) } - const updatedTask = await updateTask(task.id, { + const now = getNowIso() + const bindings = await listBindings(task.id) + const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved') + const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0))) + const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed') + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + let browserSessionClosed = false + + if (primaryClaimTokenId) { + await updateToken(primaryClaimTokenId, { + status: 'revoked', + expired_at: now, + updated_at: now, + }) + } + + if (task.browser_session_id) { + try { + await closeSession(task.browser_session_id) + browserSessionClosed = true + } catch (error) { + if (!isRecoverableTaskSessionCloseError(error)) { + throw error + } + } + } + + for (const inventoryItemId of releasedInventoryItemIds) { + await releaseReserved(inventoryItemId, now) + } + + const closeReasonParts = ['已手动关闭任务'] + if (primaryClaimTokenId) { + closeReasonParts.push('领取链接已失效') + } + if (releasedInventoryItemIds.length > 0) { + closeReasonParts.push('预占库存已释放') + } + + const updatedTask = await updateTaskRecord(task.id, { task_status: 'closed', + inventory_status: hasConsumedBindings ? 'consumed' : 'pending', delivery_status: 'closed', - last_error: task.last_error || '已手动关闭任务', - updated_at: nowIso(), + user_action_status: 'closed', + claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at, + browser_session_id: '', + last_error: task.last_error || closeReasonParts.join(','), + updated_at: now, }) + await createEvent(task.id, 'task_closed', { + claimTokenRevoked: Boolean(primaryClaimTokenId), + releasedInventoryItemIds, + releasedInventoryCount: releasedInventoryItemIds.length, + browserSessionClosed, + }, now) + return { task: mapTaskActionPayload(updatedTask), } @@ -697,6 +766,11 @@ function ensureViewerCanOperateAssistedTask(task, viewerContext, action) { } } +function isRecoverableTaskSessionCloseError(error) { + const errorCode = String(error?.errorCode || error?.code || '').trim() + return errorCode === 'session_not_found' || errorCode === 'session_closed' +} + function normalizeManualDispatchOutcome(value) { const normalized = String(value || '').trim().toLowerCase() diff --git a/apps/backend/src/services/admin/admin-write-service.test.js b/apps/backend/src/services/admin/admin-write-service.test.js new file mode 100644 index 00000000..0ada829a --- /dev/null +++ b/apps/backend/src/services/admin/admin-write-service.test.js @@ -0,0 +1,105 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { closeAdminTaskWithDeps } from './admin-write-service.js' + +test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => { + const calls = { + updateToken: [], + releaseReserved: [], + closeSession: [], + updateTask: [], + createEvent: [], + } + const task = { + id: 30, + task_status: 'link_generated', + delivery_status: 'pending', + inventory_status: 'reserved', + user_action_status: 'pending_claim', + browser_session_id: 'browser-session-30', + primary_claim_token_id: 9, + claim_expires_at: '2026-04-15T10:00:00.000Z', + last_error: '', + updated_at: '2026-04-14T10:00:00.000Z', + } + const now = '2026-04-14T10:16:18.000Z' + + const result = await closeAdminTaskWithDeps(task.id, { + getRequiredTask: async () => task, + listTaskInventoryBindingsByTaskId: async () => ([ + { inventory_item_id: 17, binding_status: 'reserved' }, + { inventory_item_id: 18, binding_status: 'released' }, + { inventory_item_id: 17, binding_status: 'reserved' }, + ]), + releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => { + calls.releaseReserved.push({ inventoryItemId, updatedAt }) + return { id: inventoryItemId } + }, + updateClaimToken: async (tokenId, patch) => { + calls.updateToken.push({ tokenId, patch }) + return { id: tokenId, ...patch } + }, + closeTencentBrowserSession: async (sessionId) => { + calls.closeSession.push(sessionId) + return { sessionId, closed: true } + }, + updateTask: async (taskId, patch) => { + calls.updateTask.push({ taskId, patch }) + return { ...task, ...patch } + }, + createTaskEvent: async (taskId, eventType, payload, createdAt) => { + calls.createEvent.push({ taskId, eventType, payload, createdAt }) + return null + }, + nowIso: () => now, + }) + + assert.equal(calls.updateToken.length, 1) + assert.deepEqual(calls.updateToken[0], { + tokenId: 9, + patch: { + status: 'revoked', + expired_at: now, + updated_at: now, + }, + }) + assert.deepEqual(calls.releaseReserved, [ + { inventoryItemId: 17, updatedAt: now }, + ]) + assert.deepEqual(calls.closeSession, ['browser-session-30']) + assert.equal(calls.updateTask.length, 1) + assert.equal(calls.updateTask[0].patch.task_status, 'closed') + assert.equal(calls.updateTask[0].patch.delivery_status, 'closed') + assert.equal(calls.updateTask[0].patch.inventory_status, 'pending') + assert.equal(calls.updateTask[0].patch.user_action_status, 'closed') + assert.equal(calls.updateTask[0].patch.browser_session_id, '') + assert.equal(calls.updateTask[0].patch.claim_expires_at, now) + assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/) + assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/) + assert.equal(calls.createEvent.length, 1) + assert.deepEqual(calls.createEvent[0], { + taskId: 30, + eventType: 'task_closed', + payload: { + claimTokenRevoked: true, + releasedInventoryItemIds: [17], + releasedInventoryCount: 1, + browserSessionClosed: true, + }, + createdAt: now, + }) + assert.equal(result.task.status, 'closed') +}) + +test('closeAdminTaskWithDeps rejects redeemed tasks', async () => { + await assert.rejects( + () => closeAdminTaskWithDeps(99, { + getRequiredTask: async () => ({ + id: 99, + task_status: 'redeemed', + }), + }), + /已兑换任务不能关闭/, + ) +}) diff --git a/apps/backend/src/services/order/order-service.js b/apps/backend/src/services/order/order-service.js index 135dfff3..46877bb2 100644 --- a/apps/backend/src/services/order/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -73,14 +73,17 @@ export async function upsertOrderFromWebhook(event) { rawPayloadJson: JSON.stringify(event.rawPayload), paidAt: event.paidAt, } + const mergedPayload = mergeWebhookOrderState(existing, basePayload) const order = existing ? await updateOrder(existing.id, { ...basePayload, + ...mergedPayload, updatedAt: now, }) : await createOrder({ ...basePayload, + ...mergedPayload, createdAt: now, updatedAt: now, }) @@ -147,3 +150,90 @@ export async function upsertOrderFromWebhook(event) { messageDeliveries, } } + +const ORDER_STATUS_PRIORITY = { + created: 0, + paid: 1, + closed: 2, + refunded: 3, +} + +const PAY_STATUS_PRIORITY = { + unpaid: 0, + failed: 0, + paid: 1, + refunded: 2, +} + +export function mergeWebhookOrderState(existing, incoming) { + const orderStatus = chooseHigherPriorityStatus( + normalizeStateValue(existing?.order_status ?? existing?.orderStatus), + normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status), + ORDER_STATUS_PRIORITY, + ) || normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) || 'created' + + const payStatus = chooseHigherPriorityStatus( + normalizeStateValue(existing?.pay_status ?? existing?.payStatus), + normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status), + PAY_STATUS_PRIORITY, + ) || normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) || 'unpaid' + + return { + orderStatus, + payStatus, + paidAt: resolveMergedPaidAt({ + existingPaidAt: existing?.paid_at ?? existing?.paidAt ?? null, + incomingPaidAt: incoming?.paidAt ?? incoming?.paid_at ?? null, + payStatus, + }), + } +} + +function chooseHigherPriorityStatus(existingValue, incomingValue, priorityMap) { + const existingPriority = resolveStatusPriority(existingValue, priorityMap) + const incomingPriority = resolveStatusPriority(incomingValue, priorityMap) + + if (incomingPriority > existingPriority) { + return incomingValue + } + + if (existingPriority >= incomingPriority) { + return existingValue + } + + return incomingValue || existingValue +} + +function resolveStatusPriority(value, priorityMap) { + return Number(priorityMap[normalizeStateValue(value)] ?? -1) +} + +function normalizeStateValue(value) { + return String(value || '').trim().toLowerCase() +} + +function resolveMergedPaidAt({ existingPaidAt, incomingPaidAt, payStatus }) { + if (!['paid', 'refunded'].includes(normalizeStateValue(payStatus))) { + return null + } + + const existingTime = parseDateValue(existingPaidAt) + const incomingTime = parseDateValue(incomingPaidAt) + + if (existingTime && incomingTime) { + return existingTime <= incomingTime ? existingPaidAt : incomingPaidAt + } + + return existingPaidAt || incomingPaidAt || null +} + +function parseDateValue(value) { + const text = String(value || '').trim() + + if (!text) { + return null + } + + const timestamp = Date.parse(text) + return Number.isFinite(timestamp) ? timestamp : null +} diff --git a/apps/backend/src/services/order/order-service.test.js b/apps/backend/src/services/order/order-service.test.js new file mode 100644 index 00000000..50be87b9 --- /dev/null +++ b/apps/backend/src/services/order/order-service.test.js @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { mergeWebhookOrderState } from './order-service.js' + +test('mergeWebhookOrderState keeps paid state when a late trade_create webhook arrives', () => { + const merged = mergeWebhookOrderState( + { + order_status: 'paid', + pay_status: 'paid', + paid_at: '2026-04-14T10:06:10.000Z', + }, + { + orderStatus: 'created', + payStatus: 'unpaid', + paidAt: null, + }, + ) + + assert.equal(merged.orderStatus, 'paid') + assert.equal(merged.payStatus, 'paid') + assert.equal(merged.paidAt, '2026-04-14T10:06:10.000Z') +}) + +test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives later', () => { + const merged = mergeWebhookOrderState( + { + order_status: 'created', + pay_status: 'unpaid', + paid_at: null, + }, + { + orderStatus: 'paid', + payStatus: 'paid', + paidAt: '2026-04-14T10:06:12.000Z', + }, + ) + + assert.equal(merged.orderStatus, 'paid') + assert.equal(merged.payStatus, 'paid') + assert.equal(merged.paidAt, '2026-04-14T10:06:12.000Z') +}) + +test('mergeWebhookOrderState preserves refunded status over older paid webhook', () => { + const merged = mergeWebhookOrderState( + { + order_status: 'refunded', + pay_status: 'refunded', + paid_at: '2026-04-14T10:06:10.000Z', + }, + { + orderStatus: 'paid', + payStatus: 'paid', + paidAt: '2026-04-14T10:06:09.000Z', + }, + ) + + assert.equal(merged.orderStatus, 'refunded') + assert.equal(merged.payStatus, 'refunded') + assert.equal(merged.paidAt, '2026-04-14T10:06:09.000Z') +}) diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue index e4b62d1a..3a75c9d9 100644 --- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue @@ -35,7 +35,20 @@ const manualDispatchForm = reactive({ }) const canOperateTasks = computed(() => hasAdminRole('operator')) const canViewSensitiveTaskData = computed(() => Boolean(detail.value?.operations.canViewSensitiveTaskData)) -const claimUrl = computed(() => lastClaimUrl.value || detail.value?.claimToken?.claimUrl || '') +const claimUrl = computed(() => { + const tokenStatus = String(detail.value?.claimToken?.status || '').trim() + const taskStatus = String(detail.value?.task.status || '').trim() + + if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) { + return '' + } + + return lastClaimUrl.value || detail.value?.claimToken?.claimUrl || '' +}) +const claimLinkInvalid = computed(() => { + const tokenStatus = String(detail.value?.claimToken?.status || '').trim() + return Boolean(detail.value?.claimToken?.claimUrl) && tokenStatus && tokenStatus !== 'active' +}) const screenshotSectionTitle = computed(() => { if (detail.value?.task.status === 'redeemed') { return '结果截图' @@ -286,6 +299,7 @@ onBeforeUnmount(clearScreenshotPreview) 领取链接: {{ claimUrl }}

+

领取链接:已失效

@@ -394,6 +408,7 @@ onBeforeUnmount(clearScreenshotPreview) 领取链接 {{ claimUrl }} + 已失效 -