删除咸鱼旧链路

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,9 +0,0 @@
## Agiso Platform Services
Keep Agiso-wide shared helpers in this directory.
Per-platform integrations live in subdirectories:
- `xianyu/`
- `pdd/`
- `taobao/`
@@ -1,3 +0,0 @@
## PDD
Put all Agiso PDD-specific service integrations here.
@@ -1,181 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate']
const AGISO_SHOP_CONFIG_KEYS = [
'shopName',
'accessToken',
'messageTemplate',
'autoDeliveryMessageTemplate',
'appSecret',
'apiVersion',
'sendMessageEndpoint',
'tradeDetailEndpoint',
'tradeDetailApiVersion',
'tradeDetailTimeoutMs',
]
type JsonObject = Record<string, unknown>
export type AgisoMessagingDefaults = {
messageTemplate?: string
autoDeliveryMessageTemplate?: string
}
export type AgisoShopConfig = {
enabled?: boolean
shopName?: string
accessToken?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
appSecret?: string
apiVersion?: string
sendMessageEndpoint?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string
}
export type AgisoMessagingConfigDocument = {
defaults: AgisoMessagingDefaults
shops: Record<string, AgisoShopConfig>
}
export function getAgisoShopsFilePath(): string {
return AGISO_SHOPS_FILE_PATH
}
export function getAgisoShopConfigMap(): Record<string, AgisoShopConfig> {
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
const fileConfig = loadAgisoMessagingConfigDocumentFromFile()
return {
...envConfig,
...fileConfig.shops,
}
}
export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return getAgisoShopConfigMap()[normalizedShopId] || null
}
export function getAgisoMessagingDefaults(): AgisoMessagingDefaults {
return loadAgisoMessagingConfigDocumentFromFile().defaults
}
export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument {
const normalized = normalizeAgisoMessagingConfigDocument(rawValue)
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument {
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
return {
defaults: {},
shops: {},
}
}
try {
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeAgisoMessagingConfigDocument(parsed)
} catch {
return {
defaults: {},
shops: {},
}
}
}
function normalizeAgisoShopConfigMap(rawValue: unknown): Record<string, AgisoShopConfig> {
const output: Record<string, AgisoShopConfig> = {}
const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : []
for (const [shopId, config] of entries) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next: AgisoShopConfig = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of AGISO_SHOP_CONFIG_KEYS) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults {
const output: AgisoMessagingDefaults = {}
if (!isPlainObject(rawValue)) {
return output
}
for (const key of AGISO_MESSAGING_DEFAULT_KEYS) {
const value = String(rawValue[key] || '').trim()
if (value) {
output[key] = value
}
}
return output
}
function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument {
const normalizedValue = isPlainObject(rawValue) ? rawValue : {}
const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults')
|| Object.prototype.hasOwnProperty.call(normalizedValue, 'shops')
return {
defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}),
shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue),
}
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,3 +0,0 @@
## Taobao
Put all Agiso Taobao-specific service integrations here.
@@ -1,3 +0,0 @@
## Xianyu
Put all Agiso Xianyu-specific service integrations here.
@@ -1,264 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
hasAgisoAutoDeliverySucceeded,
ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps,
isOrderReadyForAgisoAutoDelivery,
isAgisoAutoDeliverySuccess,
resolveAgisoAutoDeliveryEndpoint,
resolveAgisoAutoDeliveryErrorMessage,
} from './auto-delivery-service.js'
const agisoOrder = {
id: 101,
provider: 'agiso',
platform: 'xianyu',
shop_id: 'shop-auto-delivery-test',
shop_name: '自动发货测试店',
platform_order_id: 'P-AUTO-10001',
}
function createDeliveredTask(patch = {}) {
return {
id: 201,
order_id: agisoOrder.id,
task_status: 'completed',
delivery_status: 'delivered',
context_json: '{}',
...patch,
}
}
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
assert.equal(isOrderReadyForAgisoAutoDelivery([]), false)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'delivered' },
]), true)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'processing' },
]), false)
})
test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: {} },
{ context_json: { agisoAutoDelivery: { status: 'success' } } },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
]), false)
})
test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) },
]), false)
})
test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => {
assert.equal(isAgisoAutoDeliverySuccess(200, {}), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true)
assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false)
})
test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => {
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{ Error_Msg: '库存不足' },
'raw body',
400,
),
'库存不足',
)
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{},
'fallback raw body',
400,
),
'fallback raw body',
)
})
test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => {
assert.equal(
resolveAgisoAutoDeliveryEndpoint(''),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
assert.equal(
resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => {
const calls = {
update: 0,
fetch: 0,
}
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task: createDeliveredTask(),
}, {
listTasksByOrderId: async () => [
createDeliveredTask(),
createDeliveredTask({ id: 202, delivery_status: 'processing' }),
],
updateTask: async () => {
calls.update += 1
return null
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'waiting_other_tasks')
assert.equal(calls.update, 0)
assert.equal(calls.fetch, 0)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {}
try {
const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) })
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: {
...agisoOrder,
shop_id: 'missing-auto-delivery-config-shop',
},
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 1 }
},
fetch: async () => {
throw new Error('fetch should not be called without config')
},
nowIso: () => '2026-05-21T12:00:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'missing_config')
assert.equal(updates.length, 1)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.existing, true)
assert.equal(context.agisoAutoDelivery.status, 'skipped')
assert.equal(context.agisoAutoDelivery.reason, 'missing_config')
assert.deepEqual({
hasEndpoint: context.agisoAutoDelivery.hasEndpoint,
hasAccessToken: context.agisoAutoDelivery.hasAccessToken,
hasAppSecret: context.agisoAutoDelivery.hasAppSecret,
}, {
hasEndpoint: true,
hasAccessToken: false,
hasAppSecret: false,
})
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
const messages = []
runtimeConfig.platforms.agiso.appSecret = 'runtime-secret'
runtimeConfig.platforms.agiso.messaging.shops = {
[agisoOrder.shop_id]: {
accessToken: 'access-token',
appSecret: 'shop-secret',
apiVersion: '1',
},
}
try {
const task = createDeliveredTask()
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
fetch: async () => ({
status: 200,
text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }),
}),
confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({
shipped: false,
orderStatus: 2,
shipTime: 0,
reason: `not shipped: ${input.requestId}`,
}),
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 2 }
},
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => {
messages.push(payload)
return { sent: true }
},
nowIso: () => '2026-05-21T12:01:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, false)
assert.equal(result.reason, 'delivery_not_confirmed')
assert.equal(messages.length, 0)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.agisoAutoDelivery.status, 'failed')
assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed')
assert.equal(context.agisoAutoDelivery.requestId, 'req-1')
assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2)
assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/)
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
@@ -1,640 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../../repositories/task-event-repo.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { queryAgisoXianyuOrderDetail } from './order-detail-service.js'
import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js'
import { parseJsonObject } from '../../../../utils/json.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import type { TaskRow } from '../../../../types/repository-rows.js'
const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
type JsonObject = Record<string, unknown>
type AutoDeliveryOrder = {
id?: number | string
provider?: string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type SupportedAgisoOrder = AutoDeliveryOrder & {
id: number | string
provider: 'agiso'
platform: 'xianyu'
platform_order_id: string
}
type AutoDeliveryTask = Partial<TaskRow> & {
id?: number | string
delivery_status?: string
task_status?: string
context_json?: string | JsonObject | null
[key: string]: unknown
}
type AutoDeliveryTaskWithId = AutoDeliveryTask & {
id: number | string
}
type EnsureAgisoXianyuAutoDeliveryInput = {
order?: AutoDeliveryOrder | null
task?: AutoDeliveryTask | null
trigger?: string
}
type AutoDeliveryStatus = 'success' | 'failed' | 'skipped'
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type AgisoAutoDeliveryConfirmInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type AgisoAutoDeliveryConfirmResult = {
shipped: boolean
orderStatus: number
shipTime: number
reason: string
}
type AutoDeliveryMessageResult = {
sent: boolean
skipped?: boolean
reason?: string
[key: string]: unknown
}
type EnsureAgisoXianyuAutoDeliveryDeps = {
listTasksByOrderId?: (orderId: number | string) => Promise<AutoDeliveryTask[]>
fetch?: FetchLike
confirmAgisoXianyuAutoDeliveryShipped?: (
input: AgisoAutoDeliveryConfirmInput,
) => Promise<AgisoAutoDeliveryConfirmResult>
updateTask?: (
taskId: number | string,
patch: { context_json: string; updated_at: string },
) => Promise<AutoDeliveryTask | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
nowIso?: () => string
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: (
input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null },
) => Promise<AutoDeliveryMessageResult>
}
type PersistAgisoAutoDeliveryResultInput = {
status: AutoDeliveryStatus
trigger?: string
reason?: string
order?: AutoDeliveryOrder | null
responseStatus?: number
response?: unknown
errorMessage?: string
detail?: JsonObject
}
type AgisoAutoDeliveryConfig = {
enabled: boolean
endpoint: string
apiVersion: string
appSecret: string
accessToken: string
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}) {
return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger })
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId
const sendRequest = deps.fetch || (fetch as FetchLike)
const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped
if (!isAgisoXianyuOrder(order) || !task?.id) {
return { sent: false, skipped: true, reason: 'not_supported', task }
}
const currentTask = task as AutoDeliveryTaskWithId
if (String(currentTask.delivery_status || '').trim() !== 'delivered') {
return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask }
}
const orderTasks = await listOrderTasks(order.id)
if (hasAgisoAutoDeliverySucceeded(orderTasks)) {
return { sent: false, skipped: true, reason: 'already_sent', task: currentTask }
}
if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) {
logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', {
trigger,
orderId: order.id,
taskId: currentTask.id,
platformOrderId: order.platform_order_id,
taskStatuses: orderTasks.map((current) => ({
taskId: current.id,
taskStatus: current.task_status,
deliveryStatus: current.delivery_status,
})),
})
return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask }
}
const config = resolveAgisoXianyuAutoDeliveryConfig(order)
if (!config.enabled) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'auto_delivery_disabled',
order,
}, deps)
}
if (!config.endpoint || !config.accessToken || !config.appSecret) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'missing_config',
order,
detail: {
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
},
}, deps)
}
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
const requestBody = buildRequestBody({
platformOrderId: String(order.platform_order_id || '').trim(),
appSecret: config.appSecret,
})
logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
endpoint: config.endpoint,
})
try {
const response = await sendRequest(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = parseJsonObject(rawText) as JsonObject
const success = isAgisoAutoDeliverySuccess(response.status, parsed)
if (success) {
const requestId = String(parsed?.RequestId || '').trim()
// 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。
const confirmResult = await confirmShipped({
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
requestId,
})
if (!confirmResult.shipped) {
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
confirmReason: confirmResult.reason,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'delivery_not_confirmed',
order,
responseStatus: response.status,
response: parsed,
errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
}
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
})
const result = await persistAgisoAutoDeliveryResult(currentTask, {
status: 'success',
trigger,
reason: '',
order,
responseStatus: response.status,
response: parsed,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
// 发货确认成功后,发送自定义消息通知。
await sendAutoDeliveryMessage({ order, task: currentTask }, deps)
return result
}
const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status)
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
errorMessage,
response: parsed,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_failed',
order,
responseStatus: response.status,
response: parsed,
errorMessage,
}, deps)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败')
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
errorMessage,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_error',
order,
responseStatus: 0,
response: {},
errorMessage,
}, deps)
}
}
export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean {
return (Array.isArray(tasks) ? tasks : []).some((task) => {
const context = parseTaskContext(task)
const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {}
return String(autoDelivery.status || '').trim() === 'success'
})
}
export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean {
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
return normalizedTasks.length > 0
&& normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered')
}
function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig {
const baseConfig = runtimeConfig.platforms.agiso.autoDelivery
const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {}
return {
enabled: normalizeBooleanLike(baseConfig.enabled, true),
endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint),
apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1',
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
accessToken: String(shopConfig.accessToken || '').trim(),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: String(apiVersion || '1').trim() || '1',
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp: String(Math.floor(Date.now() / 1000)),
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = String(appSecret || '').trim()
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += String(appSecret || '').trim()
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
export function resolveAgisoAutoDeliveryErrorMessage(
payload: unknown,
rawText: string,
statusCode: number,
): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}`
}
export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string {
return AGISO_DUMMY_SEND_ENDPOINT
}
async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, {
status,
trigger,
reason,
order,
responseStatus = 0,
response = {},
errorMessage = '',
detail = {},
}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const getNowIso = deps.nowIso || nowIso
const patchTask = deps.updateTask || updateTask
const insertTaskEvent = deps.createTaskEvent || createTaskEvent
const now = getNowIso()
const currentContext = parseTaskContext(task)
const nextContext = {
...currentContext,
agisoAutoDelivery: {
status,
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
response: isPlainObject(response) ? response : {},
updatedAt: now,
...detail,
},
}
const updatedTask = await patchTask(task.id, {
context_json: JSON.stringify(nextContext),
updated_at: now,
})
if (status === 'success' || status === 'failed' || status === 'skipped') {
await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, {
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
...detail,
}, now)
}
return {
sent: status === 'success',
skipped: status === 'skipped',
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
response,
task: updatedTask || task,
}
}
function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject {
const value = task?.context_json
if (!value) {
return {}
}
if (isPlainObject(value)) {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
}
function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder {
return String(order?.provider || '').trim() === 'agiso'
&& String(order?.platform || '').trim() === 'xianyu'
&& Boolean(String(order?.platform_order_id || '').trim())
&& Number(order?.id || 0) > 0
}
function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'undefined' || value === null || value === '') {
return fallbackValue
}
const normalized = String(value || '').trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return fallbackValue
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。
* 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过
*/
async function confirmAgisoXianyuAutoDeliveryShipped({
shopId = '',
platformOrderId = '',
requestId = '',
}: AgisoAutoDeliveryConfirmInput = {}): Promise<AgisoAutoDeliveryConfirmResult> {
try {
const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId })
if (detailResult.success && detailResult.shipped) {
return {
shipped: true,
orderStatus: detailResult.orderStatus,
shipTime: detailResult.shipTime,
reason: '',
}
}
return {
shipped: false,
orderStatus: detailResult.orderStatus || 0,
shipTime: detailResult.shipTime || 0,
reason: detailResult.success ? 'order_not_shipped' : detailResult.reason,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', {
requestId,
shopId,
platformOrderId,
errorMessage: message,
}, { level: 'warn' })
return {
shipped: false,
orderStatus: 0,
shipTime: 0,
reason: 'confirm_query_failed',
}
}
}
/**
* 自动发货成功后发送消息通知。
*/
async function sendAutoDeliveryMessage(
{ order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {},
deps: EnsureAgisoXianyuAutoDeliveryDeps = {},
): Promise<AutoDeliveryMessageResult> {
try {
const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
|| ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
const result = await deliverMessage({ order, task })
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
sent: result.sent,
skipped: result.skipped,
reason: result.reason,
})
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
errorMessage: message,
}, { level: 'warn' })
return { sent: false, skipped: true, reason: 'message_send_error' }
}
}
@@ -1,103 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
deliverAgisoXianyuMessageForTaskWithDeps,
normalizeAgisoMessageTemplate,
renderAgisoAutoDeliveryMessage,
} from './message-service.js'
test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => {
const originalMessaging = runtimeConfig.platforms.agiso.messaging
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
runtimeConfig.platforms.agiso.messaging = {
...originalMessaging,
enabled: true,
sendMessageEndpoint: 'https://example.com/send',
apiVersion: '1',
accessToken: 'access-token',
}
runtimeConfig.platforms.agiso.appSecret = 'app-secret'
const calls = {
find: [],
create: 0,
fetch: 0,
}
try {
const result = await deliverAgisoXianyuMessageForTaskWithDeps({
order: {
id: 15,
platform: 'xianyu',
shop_id: '2209880145223',
platform_order_id: '4502280133178028841',
},
task: {
id: 36,
task_no: 'DT7af76e9b2438',
},
channel: 'agiso_im',
messageContent: '测试消息',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
}, {
findLatestSuccessfulMessageDelivery: async (input) => {
calls.find.push(input)
return { id: 16, task_id: null }
},
createMessageDelivery: async () => {
calls.create += 1
return { id: 999 }
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.deepEqual(calls.find, [
{
provider: 'agiso',
platform: 'xianyu',
shopId: '2209880145223',
platformOrderId: '4502280133178028841',
channel: 'agiso_im',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
},
])
assert.equal(calls.create, 0)
assert.equal(calls.fetch, 0)
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'already_sent')
assert.equal(result.deliveryId, 16)
} finally {
runtimeConfig.platforms.agiso.messaging = originalMessaging
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => {
assert.equal(
normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'),
'第一行\n第二行\n第三行',
)
})
test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => {
const message = renderAgisoAutoDeliveryMessage({
order: {
platform_order_id: '4502280133178028841',
shop_id: '2209880145223',
shop_name: '大锤号商',
},
task: {
task_no: 'DT7af76e9b2438',
},
template: '订单 {platformOrderId}\\n结果:{resultMessage}',
})
assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功')
})
@@ -1,539 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js'
import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js'
import {
createMessageDelivery,
findLatestSuccessfulMessageDelivery,
updateMessageDelivery,
} from '../../../../repositories/message-delivery-repo.js'
import { nowIso } from '../../../../utils/time.js'
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery'
type JsonObject = Record<string, unknown>
type MessageOrder = {
id?: number | string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type MessageTask = {
id?: number | string
task_no?: string
[key: string]: unknown
}
type AgisoMessagingConfig = {
enabled?: boolean
sendMessageEndpoint?: string
accessToken?: string
appSecret?: string
apiVersion?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
shopName?: string
[key: string]: unknown
}
type EnsureClaimMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
claimUrl?: string
expiredAt?: string
}
type EnsureAutoDeliveryMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
}
type DeliverMessageInput = EnsureAutoDeliveryMessageInput & {
channel?: string
messageContent?: string
claimUrl?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type MessageDeliveryLike = {
id: number | string
[key: string]: unknown
}
type MessageDeliveryCreateInput = {
provider: string
platform: string
shopId: string
shopName: string
channel: string
orderId?: number | string | null
taskId?: number | string | null
platformOrderId?: string
recipientKey?: string
messageContent: string
claimUrl: string
status: string
requestUrl: string
requestHeadersJson: string
requestBodyJson: string
responseStatus: number
responseJson: string
errorMessage: string
sentAt: string | null
createdAt: string
updatedAt: string
}
type MessageDeliveryPatch = {
status?: string
response_status?: number
response_json?: string
error_message?: string
sent_at?: string | null
updated_at?: string
}
type FindSuccessfulDeliveryInput = {
provider: string
platform: string
shopId: string
platformOrderId: string
channel: string
claimUrl: string
}
type DeliverMessageDeps = {
nowIso?: () => string
findLatestSuccessfulMessageDelivery?: (
input: FindSuccessfulDeliveryInput,
) => Promise<MessageDeliveryLike | null>
createMessageDelivery?: (
input: MessageDeliveryCreateInput,
) => Promise<MessageDeliveryLike | null>
updateMessageDelivery?: (
deliveryId: number | string,
patch: MessageDeliveryPatch,
) => Promise<MessageDeliveryLike | null>
fetch?: FetchLike
}
type RenderClaimMessageInput = EnsureClaimMessageInput & {
template?: string
shopName?: string
}
type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & {
template?: string
shopName?: string
}
type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & {
template?: string
claimUrl?: string
expiredAt?: string
resultMessage?: string
}
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({
order,
task,
claimUrl,
expiredAt,
}: EnsureClaimMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template: config.messageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
messageContent,
claimUrl,
})
}
export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({
order,
task,
}: EnsureAutoDeliveryMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoAutoDeliveryMessage({
order,
task,
template: config.autoDeliveryMessageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL,
messageContent,
})
}
async function deliverAgisoXianyuMessageForTask({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}) {
return deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl,
})
}
export async function deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) {
const now = deps.nowIso || nowIso
const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery
const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery
const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery
const sendRequest = deps.fetch || (fetch as FetchLike)
if (!order || !task || !messageContent) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = resolveAgisoXianyuMessagingConfig(order)
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
const endpoint = String(config.sendMessageEndpoint || '').trim()
const accessToken = String(config.accessToken || '').trim()
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
if (!endpoint || !accessToken || !appSecret) {
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = await findSuccessfulDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
platformOrderId: String(order.platform_order_id || '').trim(),
channel,
claimUrl: String(claimUrl || ''),
})
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
msg: messageContent,
appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken,
apiVersion: String(config.apiVersion || '1').trim() || '1',
})
const createdAt = now()
const delivery = await insertMessageDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
recipientKey: order.platform_order_id,
messageContent,
claimUrl,
status: 'pending',
requestUrl: url,
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
responseStatus: 0,
responseJson: '{}',
errorMessage: '',
sentAt: null,
createdAt,
updatedAt: createdAt,
})
if (!delivery) {
return { sent: false, skipped: false, reason: 'delivery_create_failed' }
}
try {
const response = await sendRequest(url, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = safeParseJson(rawText)
const success = isAgisoSendSuccess(response.status, parsed)
const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status)
const updated = await patchMessageDelivery(delivery.id, {
status: success ? 'success' : 'failed',
response_status: response.status,
response_json: JSON.stringify(parsed ?? { rawText }),
error_message: errorMessage,
sent_at: success ? now() : null,
updated_at: now(),
})
return {
sent: success,
skipped: false,
deliveryId: updated?.id || delivery.id,
responseStatus: response.status,
response: parsed ?? { rawText },
errorMessage,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
await patchMessageDelivery(delivery.id, {
status: 'failed',
response_status: 0,
response_json: '{}',
error_message: message,
sent_at: null,
updated_at: now(),
})
return {
sent: false,
skipped: false,
deliveryId: delivery.id,
errorMessage: message,
}
}
}
function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig {
const baseConfig = runtimeConfig.platforms.agiso.messaging
const fileDefaults = getAgisoMessagingDefaults()
const shopId = String(order?.shop_id || '').trim()
const shopConfigs = getAgisoShopConfigMap()
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
return {
...baseConfig,
...fileDefaults,
...shopConfig,
}
}
function buildRequestUrl(endpoint: string): string {
return new URL(endpoint).toString()
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
tid,
msg,
appSecret,
}: { tid: string; msg: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid,
msg,
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template,
shopName = '',
}: RenderClaimMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
claimUrl,
expiredAt: String(expiredAt || '尽快'),
})
}
export function renderAgisoAutoDeliveryMessage({
order,
task,
template,
shopName = '',
}: RenderAutoDeliveryMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已完成自动发货,请注意查收。'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
resultMessage: '自动发货成功',
})
}
function renderAgisoMessageTemplate({
order,
task,
template,
shopName = '',
claimUrl = '',
expiredAt = '',
resultMessage = '',
}: RenderMessageTemplateInput = {}): string {
const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim()
return normalizeAgisoMessageTemplate(template)
.replaceAll('{platformOrderId}', String(order?.platform_order_id || ''))
.replaceAll('{taskNo}', String(task?.task_no || ''))
.replaceAll('{shopName}', resolvedShopName)
.replaceAll('{shopId}', String(order?.shop_id || ''))
.replaceAll('{claimUrl}', String(claimUrl || ''))
.replaceAll('{expiredAt}', String(expiredAt || ''))
.replaceAll('{resultMessage}', String(resultMessage || ''))
}
function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object') {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText: string): JsonObject | null {
const normalized = String(rawText || '').trim()
if (!normalized) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function maskHeadersForStorage(headers: Record<string, string>): Record<string, string> {
const output = { ...headers }
if (output.Authorization) {
output.Authorization = '[masked]'
}
return output
}
function maskBodyForStorage(body: Record<string, string>): Record<string, string> {
const output = { ...body }
if (output.sign) {
output.sign = '[masked]'
}
return output
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,219 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
enrichAgisoXianyuTradeOrderWithDeps,
queryAgisoXianyuOrderDetailWithDeps,
resolveAgisoXianyuOrderDeliveryState,
} from './order-detail-service.js'
test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => {
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), {
shipped: true,
shipTime: 1713097840,
orderStatus: 0,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), {
shipped: true,
shipTime: 0,
orderStatus: 3,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), {
shipped: false,
shipTime: 0,
orderStatus: 2,
})
})
test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const calls = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
tradeDetailApiVersion: '2',
tradeDetailTimeoutMs: '3000',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10001',
requestId: 'req-detail',
}, {
fetchWithTimeout: async (url, options, timeoutMs) => {
calls.push({ url, options, timeoutMs })
return {
status: 200,
text: async () => JSON.stringify({
IsSuccess: true,
Data: {
total_fee: '25.50',
pay_time: '2026-04-14 20:30:40',
ship_time: 1713097840,
order_status: 3,
item: {
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
},
}),
}
},
})
assert.equal(result.success, true)
assert.equal(result.responseStatus, 200)
assert.equal(result.totalAmountFen, 2550)
assert.equal(result.shipped, true)
assert.equal(result.shipTime, 1713097840)
assert.equal(result.orderStatus, 3)
assert.equal(calls[0]?.url, 'https://example.com/detail')
assert.equal(calls[0]?.timeoutMs, 3000)
assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token')
assert.equal(calls[0]?.options.headers.ApiVersion, '2')
assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/)
assert.match(String(calls[0]?.options.body), /sign=/)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10002',
}, {
fetchWithTimeout: async () => ({
status: 200,
text: async () => JSON.stringify({
IsSuccess: false,
Error_Msg: '订单不存在',
}),
}),
})
assert.equal(result.success, false)
assert.equal(result.reason, 'business_error')
assert.equal(result.errorMessage, '订单不存在')
assert.equal(result.responseStatus, 200)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
let fetchCalled = false
runtimeConfig.platforms.agiso.messaging.shops = {}
runtimeConfig.platforms.agiso.appSecret = ''
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'missing-shop',
platformOrderId: 'P-DETAIL-10003',
}, {
fetchWithTimeout: async () => {
fetchCalled = true
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.success, false)
assert.equal(result.reason, 'missing_config')
assert.equal(fetchCalled, false)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => {
const parsed = {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-detail-test',
shopName: '',
platformOrderId: 'P-DETAIL-10004',
totalAmount: 0,
paidAt: null,
buyerId: '',
buyerName: '',
receiverContact: '',
rawPayload: {},
items: [],
}
const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, {
queryAgisoXianyuOrderDetail: async (input) => {
assert.deepEqual(input, {
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10004',
requestId: 'req-enrich',
})
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: 200,
payload: {},
detailPayload: {
total_fee: '19.90',
pay_time: '2026-04-14 20:30:40',
buyer_id: 'buyer-1',
buyer_name: '测试买家',
receiver_mobile: '13800138000',
shop_name: '详情店铺',
items: [
{
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
],
},
totalAmountFen: 1990,
shipped: false,
shipTime: 0,
orderStatus: 2,
}
},
})
assert.equal(result.enriched, true)
assert.equal(result.totalAmountFen, 1990)
assert.equal(result.parsed.totalAmount, 1990)
assert.equal(result.parsed.buyerId, 'buyer-1')
assert.equal(result.parsed.buyerName, '测试买家')
assert.equal(result.parsed.receiverContact, '13800138000')
assert.equal(result.parsed.shopName, '详情店铺')
assert.equal(result.parsed.items.length, 1)
assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a')
assert.equal(result.parsed.items[0]?.quantity, 2)
assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90')
})
@@ -1,803 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import { parseAmountToFen } from '../../../../utils/money.js'
import { parseJsonObject } from '../../../../utils/json.js'
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
type JsonObject = Record<string, unknown>
type AgisoParsedTrade = {
provider?: string
platform?: string
shopId?: string
shopName?: string
platformOrderId?: string
totalAmount?: number
paidAt?: string | null
buyerId?: string
buyerName?: string
receiverContact?: string
rawPayload?: JsonObject
items?: AgisoOrderItem[]
[key: string]: unknown
}
type AgisoOrderItem = {
skuCode: string
skuName: string
externalItemId: string
externalSkuCode: string
externalSkuName: string
quantity: number
spec: JsonObject
snapshot: {
externalItemId: string
externalSkuCode: string
externalSkuName: string
}
}
type AgisoDetailQueryInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchWithTimeout = (
url: string,
options: RequestInit,
timeoutMs: number,
) => Promise<FetchResponseLike>
type AgisoDetailDeps = {
fetchWithTimeout?: FetchWithTimeout
queryAgisoXianyuOrderDetail?: (
input: Required<AgisoDetailQueryInput>,
) => Promise<AgisoDetailResult>
}
type AgisoTradeDetailConfig = {
endpoint: string
apiVersion: string
accessToken: string
appSecret: string
timeoutMs: number
}
type AgisoShopConfig = {
accessToken?: string
appSecret?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string | number
}
type DeliveryState = {
shipped: boolean
shipTime: number
orderStatus: number
}
type AgisoDetailResult = {
success: boolean
reason: string
errorMessage: string
responseStatus: number
payload: JsonObject
detailPayload: JsonObject
totalAmountFen: number
shipped: boolean
shipTime: number
orderStatus: number
}
type ExternalSkuDescriptor = {
raw: string
externalSkuCode: string
externalSkuName: string
}
export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) {
return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId })
}
export async function enrichAgisoXianyuTradeOrderWithDeps(
parsed: AgisoParsedTrade,
{ requestId = '' } = {},
deps: AgisoDetailDeps = {},
) {
const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
return { parsed, enriched: false, reason: 'not_needed' }
}
const detailResult = await queryOrderDetail({
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
requestId,
})
if (!detailResult.success) {
return {
parsed,
enriched: false,
reason: detailResult.reason,
errorMessage: detailResult.errorMessage,
}
}
const detailPayload = detailResult.detailPayload
if (detailResult.totalAmountFen <= 0) {
return {
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
enriched: false,
reason: 'amount_still_missing',
}
}
const merged = mergeEnrichedTrade(parsed, detailPayload)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
totalAmountFen: merged.totalAmount,
paidAt: merged.paidAt,
})
return {
parsed: merged,
enriched: true,
totalAmountFen: merged.totalAmount,
}
}
export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) {
return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId })
}
export async function queryAgisoXianyuOrderDetailWithDeps(
{ shopId = '', platformOrderId = '', requestId = '' } = {},
deps: AgisoDetailDeps = {},
): Promise<AgisoDetailResult> {
const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout
const normalizedShopId = String(shopId || '').trim()
const normalizedPlatformOrderId = String(platformOrderId || '').trim()
const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId)
if (!config.endpoint || !config.accessToken || !config.appSecret) {
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
}, { level: 'warn' })
return createFailedDetailResult('missing_config')
}
const requestBody = buildRequestBody({
platformOrderId: normalizedPlatformOrderId,
appSecret: config.appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
endpoint: config.endpoint,
})
try {
const response = await requestWithTimeout(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
}, config.timeoutMs)
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject
const detailPayload = extractAgisoDetailPayload(payload)
const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload)
const totalAmountFen = resolveTotalAmountFen(detailPayload)
if (response.status < 200 || response.status >= 300) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('http_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isAgisoDetailSuccess(payload)) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
response: payload,
}, { level: 'warn' })
return createFailedDetailResult('business_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
}, { level: 'warn' })
return createFailedDetailResult('empty_detail_payload', {
responseStatus: response.status,
payload,
})
}
if (totalAmountFen <= 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
detailKeys: Object.keys(detailPayload),
}, { level: 'warn' })
}
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: response.status,
payload: isPlainObject(payload) ? payload : {},
detailPayload,
totalAmountFen,
shipped: delivery.shipped,
shipTime: delivery.shipTime,
orderStatus: delivery.orderStatus,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('request_failed', {
errorMessage: message,
})
}
}
function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean {
return parsed?.provider === 'agiso'
&& parsed?.platform === 'xianyu'
&& String(parsed?.platformOrderId || '').trim()
&& Number(parsed?.totalAmount || 0) <= 0
}
function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig {
const baseConfig = runtimeConfig.platforms.agiso.tradeDetail
const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig
return {
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
accessToken: String(shopConfig.accessToken || '').trim(),
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function extractAgisoDetailPayload(payload) {
if (!isPlainObject(payload)) {
return {}
}
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
if (isPlainObject(payload[key])) {
return payload[key]
}
}
return payload
}
export function resolveAgisoXianyuOrderDeliveryState(payload) {
const shipTime = normalizeTimestampValue(pickFirstNonEmpty([
payload?.ship_time,
payload?.shipTime,
payload?.ShipTime,
payload?.delivery_time,
payload?.deliveryTime,
]))
const orderStatus = normalizeInteger(
pickFirstNonEmpty([
payload?.order_status,
payload?.orderStatus,
payload?.status,
]),
)
return {
shipped: shipTime > 0 || orderStatus >= 3,
shipTime,
orderStatus,
}
}
function resolveTotalAmountFen(payload) {
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
payload.payment,
payload.Payment,
payload.post_fee,
payload.postFee,
payload.item?.price,
]))
if (fenAmount > 0) {
return fenAmount
}
return parseAmountToFen(pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.PayFee,
payload.actual_fee,
payload.actualFee,
payload.ActualFee,
payload.total_amount,
payload.totalAmount,
payload.Amount,
payload.amount,
]))
}
function resolvePaidAt(payload, fallbackValue) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
fallbackValue,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
return nowIso()
}
return null
}
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
const mergedPayload = {
...parsed.rawPayload,
_agisoTradeDetail: detailPayload,
}
const totalAmountFen = resolveTotalAmountFen(detailPayload)
const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items))
return {
...parsed,
buyerId: pickFirstNonEmpty([
parsed.buyerId,
detailPayload.encryption_buyer_id,
detailPayload.buyer_id,
detailPayload.buyerId,
detailPayload.BuyerId,
detailPayload.BuyerOpenUid,
detailPayload.buyer_open_uid,
]),
buyerName: pickFirstNonEmpty([
parsed.buyerName,
detailPayload.buyer_name,
detailPayload.buyerName,
detailPayload.BuyerName,
detailPayload.buyer_nick,
detailPayload.nick,
detailPayload.BuyerNick,
]),
receiverContact: pickFirstNonEmpty([
parsed.receiverContact,
detailPayload.receiver_contact,
detailPayload.receiverContact,
detailPayload.receiver_mobile,
detailPayload.receiverMobile,
detailPayload.mobile,
detailPayload.phone,
]),
shopName: pickFirstNonEmpty([
parsed.shopName,
detailPayload.shop_name,
detailPayload.shopName,
detailPayload.ShopName,
detailPayload.seller_name,
detailPayload.sellerName,
detailPayload.seller_nick,
detailPayload.sellerNick,
detailPayload.SellerNick,
]),
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
rawPayload: mergedPayload,
items: mergedItems,
}
}
function normalizeOrderItems(payload, fallbackItems = []) {
const candidates = [
payload.item ? [payload.item] : null,
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
if (!Array.isArray(items) || items.length === 0) {
return Array.isArray(fallbackItems) ? fallbackItems : []
}
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
]),
)
const skuCode = pickFirstNonEmpty([
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
payload.item_id,
payload.itemId,
])
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
if (Number(payload.success) === 1 || payload.success === true) {
return true
}
return false
}
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
try {
return await fetch(url, {
...options,
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
if (/^\d{10,13}$/.test(normalized)) {
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString()
}
}
const isoLike = normalized.replace(' ', 'T')
const parsed = Date.parse(isoLike)
if (Number.isNaN(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
function normalizeInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.round(parsed) : 0
}
function normalizePositiveInteger(value, fallbackValue) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
}
function normalizeFenInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function normalizeTimestampValue(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function createFailedDetailResult(reason, {
responseStatus = 0,
payload = {},
detailPayload = {},
errorMessage = '',
totalAmountFen = 0,
delivery = { shipped: false, shipTime: 0, orderStatus: 0 },
} = {}) {
return {
success: false,
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
payload: isPlainObject(payload) ? payload : {},
detailPayload: isPlainObject(detailPayload) ? detailPayload : {},
totalAmountFen: Number(totalAmountFen || 0),
shipped: Boolean(delivery?.shipped),
shipTime: Number(delivery?.shipTime || 0),
orderStatus: Number(delivery?.orderStatus || 0),
}
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}