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 }}
+ 已失效
-
|