点“关闭任务”,就会达到你要的效果:链接失效,预占释放。

This commit is contained in:
yml
2026-04-14 18:29:25 +08:00
parent 813c733860
commit 215f8bb710
6 changed files with 351 additions and 6 deletions
@@ -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<AdminTaskActionResponse>} */
/** @param {AdminEntityIdInput} taskId */
export async function closeAdminTask(taskId) {
const task = await getRequiredTask(taskId)
return closeAdminTaskWithDeps(taskId)
}
/** @returns {Promise<AdminTaskActionResponse>} */
/** @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()
@@ -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',
}),
}),
/已兑换任务不能关闭/,
)
})
@@ -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
}
@@ -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')
})