删除咸鱼旧链路
This commit is contained in:
@@ -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]'
|
||||
}
|
||||
Reference in New Issue
Block a user