删除咸鱼旧链路

This commit is contained in:
yml
2026-05-25 21:46:39 +08:00
parent 9aafddd624
commit e7aa194dde
142 changed files with 539 additions and 17373 deletions
@@ -1,434 +0,0 @@
// @ts-check
import { createTaskClaimToken } from '../claim/claim-service.js'
import {
closeClaimSessionForAdminTask,
createClaimSessionForAdminTask,
getClaimDetailForAdminTask,
getClaimSessionSummaryForAdminTask,
reloadClaimSessionForAdminTask,
} from '../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { createHttpError } from '../../utils/http.js'
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js'
import type { TaskRow } from '../../types/repository-rows.js'
const MANUAL_PROVIDER = 'manual'
const MANUAL_PLATFORM = 'manual_redeem'
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
type JsonObject = Record<string, any>
export async function createAdminManualRedeemTask(
payload: AdminManualRedeemCreateInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const viewerContext = createAdminViewerContext(session)
const proofValue = normalizeManualProofValue(payload.proofValue)
const skuCode = String(payload.skuCode || '').trim()
const skuName = String(payload.skuName || '').trim() || skuCode
const remark = String(payload.remark || '').trim()
if (!proofValue) {
throw createHttpError('请先输入唯一凭据', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_proof',
})
}
if (!skuCode) {
throw createHttpError('请先选择内部履约 SKU', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_sku',
})
}
const existingOrder = await findOrderByPlatformOrderId({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
platformOrderId: proofValue,
})
if (existingOrder) {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
if (!profile) {
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
statusCode: 500,
errorCode: 'admin_manual_redeem_profile_missing',
})
}
const requirements = await listFulfillmentProfileRequirements(profile.id)
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
skuCode,
credentialType,
viewerContext.allowedInventoryGroupCodes,
)
if (!availableInventory) {
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_unavailable',
})
}
const now = nowIso()
const manualContext = {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
remark,
createdAt: now,
createdBy: session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null,
}
let order
try {
order = await createOrder({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
orderStatus: 'manual_created',
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
rawPayloadJson: JSON.stringify({
manualRedeem: manualContext,
}),
paidAt: now,
createdAt: now,
updatedAt: now,
})
} catch (error) {
if (String(error?.code || '') === '23505') {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
throw error
}
if (!order) {
throw createHttpError('人工兑换订单创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_create_failed',
})
}
const orderItems = await replaceOrderItems(order.id, [
{
skuCode,
skuName,
quantity: 1,
specJson: JSON.stringify({
title: skuName,
sourceType: MANUAL_SOURCE_TYPE,
}),
itemSnapshotJson: JSON.stringify({
manualRedeem: manualContext,
skuCode,
skuName,
}),
createdAt: now,
updatedAt: now,
},
])
const orderItem = orderItems[0]
if (!orderItem) {
throw createHttpError('人工兑换订单商品创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_item_create_failed',
})
}
const createdTask = await createTask({
orderId: order.id,
orderItemId: orderItem.id,
unitIndex: 1,
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
taskNo: randomId('MR'),
profileId: Number(profile.id),
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
taskStatus: 'paid',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
resultCode: '',
resultMessage: '',
claimToken: '',
claimExpiresAt: null,
automationMode: 'manual',
requiresClaim: true,
userActionStatus: 'pending_claim',
attemptCount: 0,
lastError: '',
contextJson: JSON.stringify({
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
profileName: String(profile.name || ''),
skuCode,
skuName,
inventorySkuCode: skuCode,
primaryRequirement: primaryRequirement
? {
roleKey,
credentialType,
}
: null,
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
manualRedeem: manualContext,
}),
createdAt: now,
updatedAt: now,
})
if (!createdTask) {
throw createHttpError('人工兑换任务创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_task_create_failed',
})
}
const reservedInventory = await reserveInventoryForTask({
skuCode,
taskId: createdTask.id,
credentialType,
roleKey,
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
if (!reservedInventory) {
await updateTask(createdTask.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '库存不足,无法为人工兑换任务预占库存',
updated_at: nowIso(),
})
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_race_lost',
})
}
const claimToken = await createTaskClaimToken(createdTask.id)
await updateTask(createdTask.id, {
task_status: 'link_generated',
inventory_status: 'reserved',
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: '',
context_json: JSON.stringify({
...parseTaskContext(createdTask),
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
}),
updated_at: nowIso(),
})
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
skuCode,
skuName,
inventoryItemId: Number(reservedInventory.id || 0) || null,
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
createdBy: manualContext.createdBy,
remark,
}, nowIso())
return getAdminManualRedeemDetail(createdTask.id, session)
}
export async function getAdminManualRedeemDetail(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimDetailForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function createAdminManualRedeemSession(
taskId: AdminEntityIdInput,
payload: { loginType?: string, forceRecreate?: boolean } = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await createClaimSessionForAdminTask(task.id, payload)
return decorateManualRedeemDetail(task, detail)
}
export async function getAdminManualRedeemSessionSummary(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimSessionSummaryForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function reloadAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await reloadClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await closeClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await closeAdminTask(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
export async function confirmAdminManualRedeemRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await confirmAdminTaskAssistedRole(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
export async function redeemAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await redeemAdminTaskAssisted(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
async function getRequiredManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<TaskRow> {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('人工兑换任务不存在', {
statusCode: 404,
errorCode: 'admin_manual_redeem_task_not_found',
})
}
const taskContext = parseTaskContext(task)
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
throw createHttpError('当前任务不是人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_task_invalid',
})
}
const viewerContext = createAdminViewerContext(session)
if (viewerContext.restrictInventoryGroups) {
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_denied',
})
}
}
return task
}
function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject {
const taskContext = parseTaskContext(task)
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
? taskContext.manualRedeem
: {}
return {
...detail,
claimUrl: '',
manualRequest: {
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
remark: String(manualRedeem.remark || '').trim(),
},
result: detail.result
? {
...detail.result,
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
}
: null,
}
}
function normalizeManualProofValue(value: unknown): string {
return String(value || '').replace(/\s+/g, ' ').trim()
}
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
import {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
? order.raw_payload_json
: JSON.parse(String(order.raw_payload_json || '{}')),
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
},
items: items.map((item) => ({
orderItemId: item.id,
@@ -1,86 +0,0 @@
export {
getRequiredInventoryItem,
mapAdminInventoryListItem,
} from './admin-inventory-read-helpers.js'
export {
mapAdminOrderListItem,
summarizeOrderItems,
} from './admin-order-read-helpers.js'
export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
export {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary,
createEmptyTaskBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
getTaskBindingSummary,
getTaskBindingSummaryMap,
mapAdminTaskEvent,
mapAdminTaskInventoryBinding,
mapAdminTaskListItem,
mapAdminTaskSummary,
mapTaskActionPayload,
} from './admin-task-read-helpers.js'
export {
canRegenerateClaimLinkForViewer,
canViewerConfirmAssistedRole,
canViewerRedeemAssistedTask,
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
mapManualDispatchContext,
mapRedeemResolutionContext,
parseTaskContext,
parseTaskState,
resolveOrderItemDeliveryMode,
resolveOrderItemTitle,
resolveAdminTaskScreenshotUrl,
resolveDisplayShopName,
} from './admin-read-shared-helpers.js'
export {
getAdminInventoryItems,
getAdminInventorySkuSuggestions,
getAdminOrderDetail,
getAdminOrders,
getAdminTaskDetail,
getAdminTasks,
getAdminTaskScreenshotPath,
getAdminWebhookEventDetail,
getAdminWebhookEvents,
} from './admin-read-service.js'
export {
closeAdminTask,
completeAdminTaskManualDispatch,
confirmAdminTaskAssistedRole,
createAdminInventoryItem,
dispatchAdminTaskKuaishouCloudFulfillment,
importAdminInventoryItems,
invalidateAdminInventoryItem,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
redeemAdminTaskAssisted,
regenerateAdminTaskClaimLink,
releaseAdminInventoryItem,
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
replayAdminWebhookEvent,
returnNumberAdminTaskKuaishouCloudFulfillment,
retryAdminTask,
} from './admin-write-service.js'
export {
getAdminAgisoShopConfigs,
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminAgisoShopConfigs,
updateAdminFulfillmentBindingConfigs,
} from './platform-config/service.js'
export { getAdminDashboardSummary } from './admin-dashboard-service.js'
export { getAdminMessageDeliveries } from './admin-message-delivery-service.js'
@@ -10,13 +10,11 @@ import {
} from './admin-read-shared-helpers.js'
import type {
AdminAgisoAutoDeliveryStatus,
AdminTaskBindingSummary,
AdminTaskListItem,
} from '../../types/admin-read-models.js'
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
import type {
OrderRow,
TaskEventRow,
TaskInventoryBindingRow,
TaskInventoryBindingSummaryRow,
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
userBindingStatus: string
}
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
totalTaskCount: number
deliveredTaskCount: number
}
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
}
export function mapAdminTaskSummary(
task: TaskRow,
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
): JsonRecord {
const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return {
taskId: task.id,
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
lastError: task.last_error,
retryCount: getTaskRetryCount(task),
bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
createdAt: task.created_at,
updatedAt: task.updated_at,
}
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
viewerContext: AdminViewerContext = createAdminViewerContext(),
): AdminTaskListItem {
const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return {
taskId: task.id,
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
redeemedAt: task.redeemed_at,
retryCount: getTaskRetryCount(task),
bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
lastError: task.last_error,
createdAt: task.created_at,
updatedAt: task.updated_at,
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
}
}
export function buildOrderAgisoAutoDeliverySummary(
order: Partial<OrderRow> | null | undefined,
tasks: TaskRow[] = [],
): OrderAgisoAutoDeliverySummary | null {
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
return null
}
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
const totalTaskCount = normalizedTasks.length
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
if (!autoDelivery) {
return best
}
const candidate = {
...autoDelivery,
sourceTaskId: Number(task.id || 0) || null,
sourceTaskNo: String(task.task_no || '').trim(),
}
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
const bestTime = Date.parse(String(best?.updatedAt || ''))
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
return candidate
}
return best
}, null)
if (latest) {
return {
...latest,
totalTaskCount,
deliveredTaskCount,
}
}
return {
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
trigger: '',
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: 0,
errorMessage: '',
requestId: '',
updatedAt: null,
sourceTaskId: null,
sourceTaskNo: '',
totalTaskCount,
deliveredTaskCount,
}
}
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
if (!task || !binding) {
return false
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
}
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
return {
status: String(record.status || '').trim(),
trigger: String(record.trigger || '').trim(),
reason: String(record.reason || '').trim(),
platformOrderId: String(record.platformOrderId || '').trim(),
responseStatus: Number(record.responseStatus || 0),
errorMessage: String(record.errorMessage || '').trim(),
requestId: String(record.requestId || '').trim(),
updatedAt: record.updatedAt || null,
}
}
function normalizeRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
@@ -1,28 +0,0 @@
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
import { createHttpError } from '../../utils/http.js'
import { getRequiredTask } from './admin-task-read-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
export async function getAdminTaskScreenshotPathWithTencentFallback(
taskId: AdminEntityIdInput,
_session: AdminViewerSessionInput | null = null,
): Promise<string> {
const task = await getRequiredTask(taskId)
if (task.screenshot_path) {
return task.screenshot_path
}
if (task.browser_session_id) {
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
}
throw createHttpError('当前任务还没有可查看截图', {
statusCode: 404,
errorCode: 'admin_task_screenshot_not_found',
})
}
@@ -1,6 +1,5 @@
import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
import { safeParseJson } from './admin-query-utils.js'
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
const mapped = {
eventId: item.id,
provider: item.provider || 'agiso',
provider: item.provider || '',
platform: item.platform,
platformRaw: pickFirstNonEmpty([
query.fromPlatform,
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
relatedOrderId: item.related_order_id,
createdAt: item.created_at,
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
buyerId: pickFirstNonEmpty([
payload.buyer_id,
payload.buyerId,
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
}
function extractWebhookPayload(body: JsonRecord): JsonRecord {
return extractAgisoTradePayload(body)
const candidates = [
body.data,
body.Data,
body.payload,
body.Payload,
body.message,
body.Message,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
return normalizeRecord(candidate)
}
}
return body
}
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
const fromPayload = pickFirstNonEmpty([
payload.biz_order_id,
payload.bizOrderId,
payload.BizOrderId,
payload.tid,
payload.Tid,
payload.order_id,
payload.orderId,
payload.OrderId,
payload.oid,
payload.Oid,
])
if (fromPayload) {
return fromPayload
}
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
}
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
@@ -1,105 +0,0 @@
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',
}),
}),
/已兑换任务不能关闭/,
)
})
@@ -1,30 +0,0 @@
export {
createAdminInventoryItem,
importAdminInventoryItems,
releaseAdminInventoryItem,
invalidateAdminInventoryItem,
} from './write/inventory.js'
export {
replayAdminWebhookEvent,
} from './write/webhook-events.js'
export {
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
regenerateAdminTaskClaimLink,
confirmAdminTaskAssistedRole,
redeemAdminTaskAssisted,
closeAdminTask,
closeAdminTaskWithDeps,
markAdminTaskManualReview,
retryAdminTask,
completeAdminTaskManualDispatch,
} from './write/task-actions.js'
export {
prepareAdminTaskKuaishouCloudFulfillment,
dispatchAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from './write/kuaishou-cloud-actions.js'
@@ -1,65 +0,0 @@
import { query } from '../../../db/client.js'
import {
getAgisoMessagingDefaults,
getAgisoShopConfigMap,
getAgisoShopsFilePath,
saveAgisoMessagingConfig,
} from '../../platforms/agiso/shop-config-service.js'
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
import {
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
} from './domain.js'
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
type JsonObject = Record<string, any>
export async function getAdminAgisoShopConfigs() {
const configMap = getAgisoShopConfigMap()
const defaults = getAgisoMessagingDefaults()
const rowsResult = await query(
`
SELECT
shop_id,
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
MAX(created_at) AS latest_seen_at,
COUNT(*)::int AS webhook_event_count
FROM webhook_events
WHERE provider = 'agiso' AND trim(shop_id) != ''
GROUP BY shop_id
ORDER BY latest_seen_at DESC, shop_id DESC
`,
)
const rows = rowsResult.rows
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(defaults),
shops: Object.entries(configMap)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
observedShops: rows.map((row) => ({
shopId: String(row.shop_id || '').trim(),
detectedShopName: String(row.detected_shop_name || '').trim(),
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
latestSeenAt: row.latest_seen_at || null,
webhookEventCount: Number(row.webhook_event_count || 0),
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
})),
}
}
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
currentDefaults: getAgisoMessagingDefaults(),
currentMap: getAgisoShopConfigMap(),
}))
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
shops: Object.entries(saved.shops)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
}
}
@@ -1,6 +0,0 @@
export function normalizeAgisoMessageTemplate(value: unknown): string {
return String(value || "")
.replaceAll("\\r\\n", "\n")
.replaceAll("\\n", "\n")
.replaceAll("\r\n", "\n");
}
@@ -2,101 +2,15 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
applyOptionalStringField,
findMatchingObservedBinding,
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
mapAdminFulfillmentBindingConfigItem,
mapAdminObservedProductItem,
matchesObservedProduct,
} from './domain.js'
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
assert.deepEqual(
mapAdminAgisoMessagingDefaults({
messageTemplate: ' hello\\nworld ',
autoDeliveryMessageTemplate: ' done\\nnow ',
}),
{
messageTemplate: 'hello\nworld',
autoDeliveryMessageTemplate: 'done\nnow',
},
)
})
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
assert.deepEqual(
mapAdminAgisoShopConfigItem('1001', {
shopName: ' 店铺A ',
accessToken: 'abcdef1234567890',
enabled: true,
messageTemplate: ' hi\\nall ',
autoDeliveryMessageTemplate: ' ok ',
appSecret: 'secret-1',
apiVersion: ' v2 ',
sendMessageEndpoint: ' /send ',
}),
{
shopId: '1001',
shopName: '店铺A',
accessToken: 'abcdef1234567890',
accessTokenMasked: 'abcdef****567890',
enabled: true,
messageTemplate: 'hi\nall',
autoDeliveryMessageTemplate: 'ok',
appSecretConfigured: true,
apiVersion: 'v2',
sendMessageEndpoint: '/send',
},
)
})
test('applyOptionalStringField updates and clears normalized template fields', () => {
const target = { messageTemplate: 'old' }
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
assert.deepEqual(target, { messageTemplate: 'next\nline' })
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
assert.deepEqual(target, {})
})
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
assert.deepEqual(
mapAdminFulfillmentBindingConfigItem({
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
skuCode: ' sku-1 ',
priority: '80',
match: {
externalSkuCode: ' ext-1 ',
},
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '',
skuCode: 'sku-1',
skuName: '',
profileKey: '',
enabled: true,
priority: 80,
config: {},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
},
)
})
test('matchesObservedProduct honors provider platform shop and external fields', () => {
const binding = {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
match: {
externalSkuCode: 'sku-ext-1',
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal(
matchesObservedProduct(binding, {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
externalSkuCode: 'sku-ext-1',
}),
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal(
matchesObservedProduct(binding, {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-2',
externalSkuCode: 'sku-ext-1',
}),
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
test('mapAdminObservedProductItem attaches matched binding summary', () => {
const bindings = [
{
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
skuCode: 'sku-1',
skuName: 'SKU 1',
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
},
]
const observed = {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
shopName: '店铺1',
externalItemId: '',
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
provider: 'agiso',
platform: 'xianyu',
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-1',
shopName: '店铺1',
externalItemId: '',
@@ -1,70 +1,5 @@
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
import { maskSecret } from './mappers.js'
type JsonObject = Record<string, any>
export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) {
return {
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(defaults.autoDeliveryMessageTemplate || '').trim(),
),
}
}
export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) {
return {
shopId,
shopName: String(config.shopName || '').trim(),
accessToken: String(config.accessToken || '').trim(),
accessTokenMasked: maskSecret(config.accessToken),
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(config.autoDeliveryMessageTemplate || '').trim(),
),
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
apiVersion: String(config.apiVersion || '').trim(),
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
}
}
export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) {
if (!source || typeof source[key] !== 'string') {
return
}
const value = String(source[key] || '').trim()
if (value) {
target[key] = normalizeAgisoMessageTemplate(value)
return
}
delete target[key]
}
export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) {
const match = item?.match || {}
return {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
skuCode: String(item?.skuCode || '').trim(),
skuName: String(item?.skuName || '').trim(),
profileKey: String(item?.profileKey || '').trim(),
enabled: item?.enabled !== false,
priority: Number(item?.priority || 100),
config: item?.config || {},
match: {
externalSkuCode: String(match.externalSkuCode || '').trim(),
externalItemId: String(match.externalItemId || '').trim(),
externalSkuName: String(match.externalSkuName || '').trim(),
config: match.config || {},
},
}
}
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
const provider = String(binding?.provider || '').trim()
const platform = String(binding?.platform || '').trim()
@@ -1,103 +0,0 @@
import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js'
import {
filterLegacyOrderFulfillmentBindings,
getOrderFulfillmentBindingsFilePath,
getLegacyOrderFulfillmentBindingConfigs,
saveOrderFulfillmentBindingConfigs,
} from '../../order/fulfillment-binding-config-service.js'
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
import { createHttpError } from '../../../utils/http.js'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
import { listAdminObservedProducts } from './observed-products.js'
import { validateAdminFulfillmentBindingConfigs } from './validation.js'
import { mapAdminFulfillmentBindingConfigItem } from './domain.js'
import type {
AdminFulfillmentBindingConfigSaveInput,
AdminFulfillmentBindingLookupInput,
} from '../../../types/admin-write-inputs.js'
type JsonObject = Record<string, any>
export async function getAdminFulfillmentBindingConfigs() {
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
observedProducts: await listAdminObservedProducts(bindings),
}
}
export async function lookupAdminFulfillmentBindingOrder(
payload: AdminFulfillmentBindingLookupInput = {},
) {
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
const detailResult = await enrichAgisoXianyuTradeOrder({
provider,
platform,
shopId,
shopName: '',
platformOrderId,
orderStatus: 'created',
payStatus: 'unpaid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
paidAt: null,
rawPayload: {},
items: [],
}, {
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
})
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return buildFulfillmentLookupResult({
provider,
platform,
shopId,
platformOrderId,
detail,
detailResult,
bindings,
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
})
}
export async function updateAdminFulfillmentBindingConfigs(
payload: AdminFulfillmentBindingConfigSaveInput = {},
) {
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, {
getFulfillmentProfileByKey,
})
assertLegacyBindingConfigsOnly(normalizedBindings)
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
await syncConfiguredFulfillmentBindings()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
}
}
function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) {
if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) {
return
}
throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved',
})
}
@@ -1,124 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
assert.deepEqual(
normalizeFulfillmentLookupPayload({
shopId: ' shop-1 ',
platformOrderId: ' order-1 ',
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
},
)
assert.throws(
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
/请先填写店铺 ID/,
)
})
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
assert.throws(
() =>
resolveFulfillmentLookupDetail(
{
reason: 'missing_config',
parsed: { items: [] },
},
'order-1',
),
/当前店铺缺少订单详情查询配置/,
)
})
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
const result = buildFulfillmentLookupResult({
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
detail: {
shopName: '店铺A',
buyerName: '买家A',
totalAmount: 12345,
paidAt: '2026-05-04T10:00:00.000Z',
items: [
{
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
skuName: '礼包A',
quantity: 2,
},
],
},
detailResult: {
enriched: true,
reason: 'ok',
errorMessage: '',
},
bindings: [
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
match: {
externalSkuCode: 'sku-1',
},
},
],
fallbackShopName: '备用店铺',
})
assert.deepEqual(result, {
order: {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
platformOrderId: 'order-1',
buyerName: '买家A',
totalAmountFen: 12345,
totalAmount: '123.45',
paidAt: '2026-05-04T10:00:00.000Z',
enriched: true,
enrichReason: 'ok',
errorMessage: '',
},
items: [
{
lineId: 'order-1:1:sku-1:item-1',
itemTitle: '礼包A',
quantity: 2,
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
latestSeenAt: null,
orderItemCount: 2,
configured: true,
matchedBinding: {
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
},
},
],
})
})
@@ -1,137 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { formatFenToAmount } from '../../../utils/money.js'
import { isPlainObject, pickFirstNonEmpty } from './context.js'
import { mapAdminObservedProductItem } from './domain.js'
type JsonObject = Record<string, any>
export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) {
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
const shopId = String(payload.shopId || '').trim()
const platformOrderId = String(payload.platformOrderId || '').trim()
if (!shopId) {
throw createHttpError('请先填写店铺 ID', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
})
}
if (!platformOrderId) {
throw createHttpError('请先填写平台订单号', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
})
}
if (provider !== 'agiso' || platform !== 'xianyu') {
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
})
}
return { provider, platform, shopId, platformOrderId }
}
export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) {
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
const items = Array.isArray(detail.items) ? detail.items : []
if (items.length > 0) {
return { detail, items }
}
const detailReason = String(detailResult?.reason || '').trim()
const detailMessage = String(detailResult?.errorMessage || '').trim()
let message = detailMessage
if (!message && detailReason === 'missing_config') {
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
}
if (!message) {
message = `未查询到订单 ${platformOrderId} 的商品明细`
}
throw createHttpError(message, {
statusCode: 404,
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
})
}
export function buildFulfillmentLookupResult({
provider = '',
platform = '',
shopId = '',
platformOrderId = '',
detail = {},
detailResult = {},
bindings = [],
fallbackShopName = '',
}: {
provider?: string
platform?: string
shopId?: string
platformOrderId?: string
detail?: JsonObject
detailResult?: JsonObject
bindings?: JsonObject[]
fallbackShopName?: string
} = {}) {
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
const items = Array.isArray(detail.items) ? detail.items : []
return {
order: {
provider,
platform,
shopId,
shopName: resolvedShopName,
platformOrderId,
buyerName: String(detail.buyerName || '').trim(),
totalAmountFen: Number(detail.totalAmount || 0),
totalAmount: formatFenToAmount(detail.totalAmount),
paidAt: detail.paidAt || null,
enriched: Boolean(detailResult?.enriched),
enrichReason: String(detailResult?.reason || '').trim(),
errorMessage: String(detailResult?.errorMessage || '').trim(),
},
items: items.map((item, index) => {
const observed = {
provider,
platform,
shopId,
shopName: resolvedShopName,
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
externalSkuCode: pickFirstNonEmpty([
item?.externalSkuCode,
item?.skuCode,
item?.externalItemId,
item?.itemId,
]),
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
latestSeenAt: null,
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
}
return {
lineId: [
platformOrderId,
index + 1,
observed.externalSkuCode || 'na',
observed.externalItemId || 'na',
].join(':'),
itemTitle: pickFirstNonEmpty([
item?.skuName,
item?.externalSkuName,
item?.externalSkuCode,
item?.externalItemId,
]),
quantity: observed.orderItemCount,
...mapAdminObservedProductItem(observed, bindings),
}
}),
}
}
@@ -1,61 +0,0 @@
export {
getAdminAgisoShopConfigs,
updateAdminAgisoShopConfigs,
} from "./agiso-service.js";
export {
getAdminNinetyoneOrders,
retryAdminNinetyoneOrder,
failAdminNinetyoneOrder,
} from "./ninetyone-service.js";
export {
getAdminKuaishouEticketSourceConfig,
updateAdminKuaishouEticketSourceConfig,
queryAdminKuaishouEticketDetail,
queryAdminKuaishouEticketShopInfo,
consumeAdminKuaishouEticket,
} from "./kuaishou-eticket-service.js";
export {
listAdminCloudtentaclesSources,
deleteAdminCloudtentaclesSource,
updateAdminCloudtentaclesSourceConfig,
sendAdminCloudtentaclesSmsCode,
testAdminCloudtentaclesLogin,
validateAdminCloudtentaclesSession,
getAdminCloudtentaclesAsset,
getAdminCloudtentaclesCategories,
getAdminCloudtentaclesSkuList,
buyAdminCloudtentaclesSku,
useAdminCloudtentaclesSku,
getAdminCloudtentaclesKnapsack,
listAdminCloudtentaclesVirtualNumbers,
appointAdminCloudtentaclesVirtualNumber,
generateAdminCloudtentaclesLoginCode,
fetchAdminCloudtentaclesVirtualNumberCode,
verifyAdminCloudtentaclesLoginCode,
getAdminCloudtentaclesBindUrl,
backAdminCloudtentaclesVirtualNumber,
runAdminCloudtentaclesFullFlow,
} from "./cloudtentacles-service.js";
export {
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminFulfillmentBindingConfigs,
} from "./fulfillment-bindings-service.js";
export {
getAdminKuaishouCloudFulfillmentConfig,
updateAdminKuaishouCloudFulfillmentConfig,
} from "./kuaishou-cloud-fulfillment-service.js";
export {
getAdminNotificationConfig,
updateAdminNotificationConfig,
testAdminNotification,
getAdminScheduledJobsConfig,
updateAdminScheduledJobsConfig,
runAdminScheduledJobNow,
} from "./notification-service.js";
@@ -1,175 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
assertAdminFulfillmentBindingsInput,
buildAdminFulfillmentBindingUniqueKey,
normalizeAdminFulfillmentBindingItem,
validateAdminFulfillmentBindingConfigs,
} from './validation.js'
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
})
test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => {
const result = normalizeAdminFulfillmentBindingItem(
{
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
shopName: ' 店铺 ',
skuCode: 'sku-1',
match: {
externalSkuCode: 'ext-1',
},
config: {
kuaishouShop: {
note: 'keep',
},
},
},
{ index: 0 },
)
assert.deepEqual(result, {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺',
skuCode: 'sku-1',
skuName: '',
profileKey: 'manual_review',
enabled: true,
priority: undefined,
config: {
kuaishouShop: {
note: 'keep',
},
},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
})
})
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
match: {
externalSkuCode: 'ext-1',
},
},
{ index: 1 },
),
/第 2 条规则缺少内部履约 SKU/,
)
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
skuCode: 'sku-1',
},
{ index: 2 },
),
/第 3 条规则至少需要一种外部匹配条件/,
)
})
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
assert.equal(
buildAdminFulfillmentBindingUniqueKey({
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-a',
skuCode: 'sku-1',
match: {
externalItemId: '',
externalSkuCode: '',
externalSkuName: ' 礼包 A ',
},
}),
'91kaquan::kuaishou::shop-a::::::礼包 a::sku-1',
)
})
test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'missing-profile',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return null
},
},
),
/第 1 条规则使用了不存在的履约方式: missing-profile/,
)
})
test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: ' ext-1 ',
},
},
{
provider: 'agiso',
platform: 'xianyu',
shopId: ' shop-1 ',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return { key: 'manual_review' }
},
},
),
/第 2 条规则与其它规则重复/,
)
})
@@ -1,125 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { normalizeProductName } from '../../order/product-match-service.js'
import { isPlainObject } from './context.js'
type JsonObject = Record<string, any>
export function assertAdminFulfillmentBindingsInput(bindings: unknown) {
if (Array.isArray(bindings)) {
return
}
throw createHttpError('履约配置格式不正确', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_payload',
})
}
export function normalizeAdminFulfillmentBindingItem(
rawBinding: unknown,
options: JsonObject = {},
) {
const index = Number(options.index || 0)
if (!isPlainObject(rawBinding)) {
throw createHttpError(`${index + 1} 条规则格式不正确`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_item',
})
}
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
const platform = String(rawBinding.platform || '').trim()
const shopId = String(rawBinding.shopId || '').trim()
const shopName = String(rawBinding.shopName || '').trim()
const skuCode = String(rawBinding.skuCode || '').trim()
const skuName = String(rawBinding.skuName || '').trim()
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
const externalSkuName = String(match.externalSkuName || '').trim()
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
if (!skuCode) {
throw createHttpError(`${index + 1} 条规则缺少内部履约 SKU`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
})
}
if (!externalItemId && !externalSkuCode && !externalSkuName) {
throw createHttpError(`${index + 1} 条规则至少需要一种外部匹配条件`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
})
}
return {
provider,
platform,
shopId,
shopName,
skuCode,
skuName,
profileKey,
enabled: rawBinding.enabled !== false,
priority: rawBinding.priority,
config,
match: {
externalSkuCode,
externalItemId,
externalSkuName,
config: isPlainObject(match.config) ? match.config : {},
},
}
}
export function buildAdminFulfillmentBindingUniqueKey(binding) {
return [
String(binding?.provider || '').trim(),
String(binding?.platform || '').trim(),
String(binding?.shopId || '').trim(),
String(binding?.match?.externalItemId || '').trim(),
String(binding?.match?.externalSkuCode || '').trim(),
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
String(binding?.skuCode || '').trim(),
].join('::')
}
export async function validateAdminFulfillmentBindingConfigs(
bindings: JsonObject[],
options: JsonObject = {},
) {
assertAdminFulfillmentBindingsInput(bindings)
const getFulfillmentProfileByKey = (
options.getFulfillmentProfileByKey || (async () => null)
) as (profileKey: string) => Promise<unknown>
const seenKeys = new Set()
const normalizedBindings: JsonObject[] = []
for (const [index, rawBinding] of bindings.entries()) {
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index })
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
if (!profile) {
throw createHttpError(`${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
})
}
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
if (seenKeys.has(uniqueKey)) {
throw createHttpError(`${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
statusCode: 409,
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
})
}
seenKeys.add(uniqueKey)
normalizedBindings.push(normalized)
}
return normalizedBindings
}
@@ -1,127 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAdminAgisoMessagingSavePayload,
buildAdminAgisoShopConfigUpdate,
normalizeAdminCloudtentaclesSourceConfigPayload,
} from './writes.js'
test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => {
assert.deepEqual(
buildAdminAgisoShopConfigUpdate(
{
shopId: ' shop-1 ',
shopName: ' ',
accessToken: ' new-token ',
messageTemplate: ' ',
autoDeliveryMessageTemplate: ' done ',
enabled: false,
},
{
shopName: '旧店铺',
accessToken: 'old-token',
messageTemplate: 'old-template',
autoDeliveryMessageTemplate: 'old-auto',
appSecret: 'keep-secret',
},
),
{
shopId: 'shop-1',
config: {
accessToken: 'new-token',
autoDeliveryMessageTemplate: 'done',
appSecret: 'keep-secret',
enabled: false,
},
},
)
})
test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => {
assert.deepEqual(
buildAdminAgisoMessagingSavePayload(
{
defaults: {
messageTemplate: ' next\\nline ',
autoDeliveryMessageTemplate: ' ',
},
shops: [
{
shopId: ' shop-1 ',
shopName: '店铺一',
accessToken: '',
enabled: true,
},
{
shopId: 'shop-2',
shopName: ' 店铺二 ',
accessToken: ' token-2 ',
messageTemplate: ' hi ',
apiVersion: ' v2 ',
},
{
shopId: '',
accessToken: 'skip',
},
],
},
{
currentDefaults: {
messageTemplate: 'old',
autoDeliveryMessageTemplate: 'old-auto',
},
currentMap: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
},
},
},
),
{
defaults: {
messageTemplate: 'next\nline',
},
shops: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
enabled: true,
shopName: '店铺一',
},
'shop-2': {
accessToken: 'token-2',
apiVersion: 'v2',
messageTemplate: 'hi',
shopName: '店铺二',
},
},
},
)
})
test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => {
assert.deepEqual(
normalizeAdminCloudtentaclesSourceConfigPayload({
enabled: false,
baseUrl: ' ',
username: ' user ',
password: ' pass ',
phone: ' 13800138000 ',
deviceId: ' ',
deviceType: '2',
}),
{
key: 'default',
label: '',
enabled: false,
baseUrl: 'https://123.207.217.176',
username: 'user',
password: 'pass',
phone: '13800138000',
deviceId: '-',
deviceType: 2,
},
)
})
@@ -1,100 +0,0 @@
import { applyOptionalStringField } from "./domain.js";
type JsonObject = Record<string, any>
export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
return null;
}
const next: JsonObject = { ...current };
const shopName = String(item?.shopName || "").trim();
const accessToken = String(item?.accessToken || "").trim();
const messageTemplate = String(item?.messageTemplate || "").trim();
const autoDeliveryMessageTemplate = String(
item?.autoDeliveryMessageTemplate || ""
).trim();
const appSecret = String(item?.appSecret || "").trim();
const apiVersion = String(item?.apiVersion || "").trim();
const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim();
if (shopName) {
next.shopName = shopName;
} else {
delete next.shopName;
}
if (accessToken) {
next.accessToken = accessToken;
}
if (messageTemplate) {
next.messageTemplate = messageTemplate;
} else if (typeof item?.messageTemplate === "string") {
delete next.messageTemplate;
}
if (autoDeliveryMessageTemplate) {
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate;
} else if (typeof item?.autoDeliveryMessageTemplate === "string") {
delete next.autoDeliveryMessageTemplate;
}
if (appSecret) {
next.appSecret = appSecret;
}
if (apiVersion) {
next.apiVersion = apiVersion;
}
if (sendMessageEndpoint) {
next.sendMessageEndpoint = sendMessageEndpoint;
}
if (typeof item?.enabled === "boolean") {
next.enabled = item.enabled;
}
if (!next.accessToken) {
return null;
}
return { shopId, config: next };
}
export function buildAdminAgisoMessagingSavePayload(
payload: JsonObject = {},
options: JsonObject = {}
) {
const rawItems = Array.isArray(payload.shops) ? payload.shops : [];
const currentDefaults: JsonObject = options.currentDefaults || {};
const currentMap: Record<string, JsonObject> = options.currentMap || {};
const nextDefaults: JsonObject = {
...currentDefaults,
};
const nextMap: Record<string, JsonObject> = {};
applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults);
applyOptionalStringField(
nextDefaults,
"autoDeliveryMessageTemplate",
payload.defaults
);
for (const item of rawItems) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
continue;
}
const updated = buildAdminAgisoShopConfigUpdate(
item,
currentMap[shopId] || {}
);
if (!updated) {
continue;
}
nextMap[updated.shopId] = updated.config;
}
return {
defaults: nextDefaults,
shops: nextMap,
};
}
@@ -1,520 +0,0 @@
import {
markInventoryItemDelivered,
releaseReservedInventoryItem,
} from '../../../repositories/inventory-repo.js'
import { updateClaimToken } from '../../../repositories/claim-token-repo.js'
import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import {
getTaskInventoryBindingById,
listTaskInventoryBindingsByTaskId,
} from '../../../repositories/task-inventory-binding-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createTaskClaimToken } from '../../claim/claim-service.js'
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../../order/inventory-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 {
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
parseTaskContext,
} from '../admin-read-shared-helpers.js'
import {
getRequiredTask,
mapTaskActionPayload,
} from '../admin-task-read-helpers.js'
import {
ensureViewerCanOperateAssistedTask,
getTaskClaimExpiresAt,
isRecoverableTaskSessionCloseError,
maskCode,
normalizeManualDispatchOutcome,
resolveTaskInventoryGroupCodes,
} from './shared.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../../types/admin-read-inputs.js'
import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js'
import type {
AdminTaskActionResponse,
AdminTaskBindingReleaseResponse,
AdminTaskManualDispatchResponse,
} from '../../../types/admin-write-models.js'
import type { TaskRow } from '../../../types/repository-rows.js'
import type { TaskUpdatePatch } from '../../../types/repository-inputs.js'
type CloseAdminTaskDeps = {
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
nowIso?: () => string
}
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
if (!primaryInventoryItemId) {
throw createHttpError('当前任务没有预占库存项', {
statusCode: 409,
errorCode: 'admin_task_no_reserved_inventory',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存项', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(primaryInventoryItemId, now)
const updatedTask = await updateTask(task.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '已手动释放预占库存项',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function releaseAdminTaskInventoryBinding(
taskId: AdminEntityIdInput,
bindingId: AdminEntityIdInput,
): Promise<AdminTaskBindingReleaseResponse> {
const task = await getRequiredTask(taskId)
const binding = await getTaskInventoryBindingById(Number(bindingId))
if (!binding || Number(binding.task_id) !== Number(task.id)) {
throw createHttpError('任务库存绑定不存在', {
statusCode: 404,
errorCode: 'admin_task_inventory_binding_not_found',
})
}
if (String(binding.binding_status || '').trim() !== 'reserved') {
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
statusCode: 409,
errorCode: 'admin_task_inventory_binding_release_not_allowed',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存绑定', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(binding.inventory_item_id, now)
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
? 'waiting_inventory'
: task.task_status
if (!hasReservedBindings) {
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
updated_at: now,
})
await createTaskEvent(task.id, 'inventory_binding_released', {
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
roleKey: String(binding.role_key || '').trim(),
}, now)
return {
task: mapTaskActionPayload(updatedTask),
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
}
}
export async function regenerateAdminTaskClaimLink(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
const viewerContext = createAdminViewerContext(session)
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重新生成领取链接', {
statusCode: 409,
errorCode: 'admin_task_regenerate_not_allowed',
})
}
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
statusCode: 403,
errorCode: 'admin_task_regenerate_permission_denied',
})
}
const now = nowIso()
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
const claimToken = await createTaskClaimToken(task.id)
const updatedTask = await updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
task_status: 'link_generated',
last_error: '',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimToken.claimUrl,
token: claimToken.token,
}
}
export async function confirmAdminTaskAssistedRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
await confirmClaimRoleForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function redeemAdminTaskAssisted(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
await redeemClaimTaskForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function closeAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
return closeAdminTaskWithDeps(taskId)
}
export async function closeAdminTaskWithDeps(
taskId: AdminEntityIdInput,
{
getRequiredTask: getTask = getRequiredTask,
listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId,
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
updateClaimToken: updateToken = updateClaimToken,
updateTask: updateTaskRecord = updateTask,
createTaskEvent: createEvent = createTaskEvent,
closeTencentBrowserSession: closeSession = closeTencentBrowserSession,
nowIso: getNowIso = nowIso,
}: CloseAdminTaskDeps = {},
): Promise<AdminTaskActionResponse> {
const task = await getTask(taskId)
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能关闭', {
statusCode: 409,
errorCode: 'admin_task_close_not_allowed',
})
}
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',
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),
}
}
export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const updatedTask = await updateTask(task.id, {
task_status: 'manual_review',
delivery_status: task.delivery_status || 'pending',
last_error: task.last_error || '已转人工处理',
updated_at: nowIso(),
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function retryAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const now = nowIso()
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
})
}
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重试', {
statusCode: 409,
errorCode: 'admin_task_retry_not_allowed',
})
}
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const orderItems = await listOrderItemsByOrderId(task.order_id)
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
let claimTokenId = getTaskPrimaryClaimTokenId(task)
let nextStatus = 'link_generated'
let lastError = ''
let claimExpiresAt = getTaskClaimExpiresAt(task)
let claimUrl = ''
let token = ''
if (!reservedInventoryItemId) {
const reserved = await reserveInventoryForTask({
skuCode: orderItem?.sku_code || '',
taskId: task.id,
credentialType: primaryRequirement?.credentialType || 'tencent_code',
roleKey: primaryRequirement?.roleKey || 'primary_code',
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
})
if (!reserved) {
nextStatus = 'waiting_inventory'
lastError = '库存不足,等待可用库存凭据'
} else {
reservedInventoryItemId = reserved.id
}
}
if (nextStatus === 'link_generated' && !claimTokenId) {
const claimToken = await createTaskClaimToken(task.id)
claimTokenId = claimToken.id
claimExpiresAt = claimToken.expired_at
claimUrl = claimToken.claimUrl
token = claimToken.token
}
const updatedTask = await updateTask(task.id, {
task_status: nextStatus,
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
claim_token: token || task.claim_token || '',
claim_expires_at: claimExpiresAt,
last_error: lastError,
updated_at: now,
})
const response: AdminTaskActionResponse = {
task: mapTaskActionPayload(updatedTask),
}
if (claimUrl) {
response.claimUrl = claimUrl
response.token = token
}
return response
}
export async function completeAdminTaskManualDispatch(
taskId: AdminEntityIdInput,
payload: AdminTaskManualDispatchInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskManualDispatchResponse> {
const task = await getRequiredTask(taskId)
if (!isManualDispatchTask(task)) {
throw createHttpError('当前任务不是人工履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_manual_dispatch',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_already_completed',
})
}
const now = nowIso()
const outcome = normalizeManualDispatchOutcome(payload.outcome)
const resultMessage = String(payload.resultMessage || '').trim()
const deliveryReference = String(payload.deliveryReference || '').trim()
const deliveredCredential = String(payload.deliveredCredential || '').trim()
const context = parseTaskContext(task)
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
? 'consumed'
: task.inventory_status || 'not_required'
const manualDispatch = {
outcome,
deliveryReference,
deliveredCredential,
resultMessage: resultMessage || fallbackMessage,
completedAt: now,
completedBy: session
? {
userId: Number(session.userId || 0),
username: String(session.username || ''),
role: String(session.role || ''),
}
: null,
}
if (outcome === 'delivered' && inventoryItemId) {
await markInventoryItemDelivered(inventoryItemId, now)
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
delivery_status: nextDeliveryStatus,
result_code: resultCode,
result_message: resultMessage || fallbackMessage,
user_action_status: 'not_required',
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
context_json: JSON.stringify({
...context,
manualDispatch,
}),
updated_at: now,
})
await createTaskEvent(task.id, 'manual_dispatch_completed', {
outcome,
resultCode,
resultMessage: resultMessage || fallbackMessage,
deliveryReference,
deliveredCredentialMasked: maskCode(deliveredCredential),
completedBy: manualDispatch.completedBy,
}, now)
const taskAfterAutoDelivery = outcome === 'delivered'
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order: await getOrderById(task.order_id),
task: updatedTask,
trigger: 'manual_dispatch_completed',
})).task || updatedTask
: updatedTask
return {
outcome,
task: mapTaskActionPayload(taskAfterAutoDelivery),
}
}
@@ -1,32 +0,0 @@
import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js'
import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js'
import { createHttpError } from '../../../utils/http.js'
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js'
export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise<AdminWebhookReplayResponse> {
const event = await getWebhookEventById(Number(eventId))
if (!event) {
throw createHttpError('Webhook 事件不存在', {
statusCode: 404,
errorCode: 'admin_webhook_not_found',
})
}
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
throw createHttpError('当前只支持重放 agiso webhook', {
statusCode: 409,
errorCode: 'admin_webhook_replay_not_supported',
})
}
const result = await replayAgisoTradeWebhookEvent(event)
return {
eventId: event.id,
replayed: true,
result,
}
}