优化启动流程
This commit is contained in:
@@ -51,6 +51,18 @@ module.exports = {
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Alds/AldsSend',
|
||||
apiVersion: '1',
|
||||
aldsType: 1,
|
||||
ignoreAldsLog: false,
|
||||
ignoreBlackList: false,
|
||||
ignoreOnOff: false,
|
||||
ignoreRefundCheck: false,
|
||||
ignoreRestricted: false,
|
||||
ignoreTradeStatusCheck: false,
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
[
|
||||
{
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
"shopId": "2209880145223",
|
||||
"skuCode": "sjz_hdl_test01",
|
||||
"skuName": "海底捞干员庆生动作1个",
|
||||
"profileKey": "tencent_claim_redeem",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"config": {},
|
||||
"match": {
|
||||
"externalSkuCode": "6063968816746",
|
||||
"externalItemId": "1041598483721",
|
||||
"externalSkuName": "海底捞干员庆生动作1个",
|
||||
"config": {
|
||||
"resolvedSkuName": "海底捞干员庆生动作1个"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
|
||||
@@ -196,6 +196,40 @@ function applyEnvOverrides(baseConfig) {
|
||||
nextConfig.platforms.agiso.tradeDetail.timeoutMs = agisoTradeDetailTimeoutMs
|
||||
}
|
||||
|
||||
const agisoAutoDeliveryEnabled = parseBoolean(process.env.AGISO_AUTO_DELIVERY_ENABLED)
|
||||
if (agisoAutoDeliveryEnabled !== null) {
|
||||
nextConfig.platforms.agiso.autoDelivery.enabled = agisoAutoDeliveryEnabled
|
||||
}
|
||||
|
||||
const agisoAutoDeliveryEndpoint = String(process.env.AGISO_AUTO_DELIVERY_ENDPOINT || '').trim()
|
||||
if (agisoAutoDeliveryEndpoint) {
|
||||
nextConfig.platforms.agiso.autoDelivery.endpoint = agisoAutoDeliveryEndpoint
|
||||
}
|
||||
|
||||
const agisoAutoDeliveryApiVersion = String(process.env.AGISO_AUTO_DELIVERY_API_VERSION || '').trim()
|
||||
if (agisoAutoDeliveryApiVersion) {
|
||||
nextConfig.platforms.agiso.autoDelivery.apiVersion = agisoAutoDeliveryApiVersion
|
||||
}
|
||||
|
||||
const agisoAutoDeliveryAldsType = parseInteger(process.env.AGISO_AUTO_DELIVERY_ALDS_TYPE)
|
||||
if (agisoAutoDeliveryAldsType !== null) {
|
||||
nextConfig.platforms.agiso.autoDelivery.aldsType = agisoAutoDeliveryAldsType
|
||||
}
|
||||
|
||||
for (const [envKey, configKey] of [
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_ALDS_LOG', 'ignoreAldsLog'],
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_BLACK_LIST', 'ignoreBlackList'],
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_ON_OFF', 'ignoreOnOff'],
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_REFUND_CHECK', 'ignoreRefundCheck'],
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_RESTRICTED', 'ignoreRestricted'],
|
||||
['AGISO_AUTO_DELIVERY_IGNORE_TRADE_STATUS_CHECK', 'ignoreTradeStatusCheck'],
|
||||
]) {
|
||||
const parsed = parseBoolean(process.env[envKey])
|
||||
if (parsed !== null) {
|
||||
nextConfig.platforms.agiso.autoDelivery[configKey] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
const agisoAppId = String(process.env.AGISO_APP_ID || '').trim()
|
||||
if (agisoAppId) {
|
||||
nextConfig.platforms.agiso.messaging.appId = agisoAppId
|
||||
|
||||
+202
-9
@@ -10,15 +10,15 @@ import { ensureAdminUsersBootstrapped } from './services/admin/admin-auth-servic
|
||||
import { ensureFulfillmentCatalogBootstrapped } from './services/bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/session/ocr.js'
|
||||
import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session/session.js'
|
||||
import { buildSuccessPayload } from './utils/http.js'
|
||||
import { logError, logInfo, logWarn } from './utils/logger.js'
|
||||
import tencentRouter from './routes/tencent.js'
|
||||
|
||||
const CORE_BOOT_RETRY_DELAY_MS = 5_000
|
||||
|
||||
const app = express()
|
||||
const port = Number(runtimeConfig.server.port || 3000)
|
||||
|
||||
await runDatabaseMigrations()
|
||||
await ensureFulfillmentCatalogBootstrapped()
|
||||
await ensureAdminUsersBootstrapped()
|
||||
const startupState = createStartupState()
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
@@ -37,10 +37,49 @@ app.use(express.json({ limit: '2mb' }))
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
res.json(buildSuccessPayload(buildHealthPayload(), startupState.core.ready ? 'ready' : 'starting'))
|
||||
})
|
||||
|
||||
app.get('/health/live', (_req, res) => {
|
||||
res.json(buildSuccessPayload({
|
||||
status: shutdownStarted ? 'shutting_down' : 'alive',
|
||||
pid: process.pid,
|
||||
}))
|
||||
})
|
||||
|
||||
app.get('/health/ready', (_req, res) => {
|
||||
if (startupState.core.ready) {
|
||||
res.json(buildSuccessPayload(buildHealthPayload(), 'ready'))
|
||||
return
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
code: 1,
|
||||
msg: startupState.core.lastError || '服务启动中,请稍后重试',
|
||||
errorCode: 'service_not_ready',
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
data: buildHealthPayload(),
|
||||
})
|
||||
})
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (startupState.core.ready) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
res.status(503).json({
|
||||
code: 1,
|
||||
msg: startupState.core.lastError || '服务启动中,请稍后重试',
|
||||
errorCode: 'service_not_ready',
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
data: {
|
||||
startup: {
|
||||
phase: startupState.phase,
|
||||
attemptCount: startupState.core.attemptCount,
|
||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,23 +90,85 @@ app.use('/api/v1/admin', adminRouter)
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
logInfo('[startup]', `order-site-backend listening on http://127.0.0.1:${port}`)
|
||||
void bootstrapBrowser()
|
||||
void bootstrapOcr()
|
||||
void bootstrapCoreServices()
|
||||
})
|
||||
|
||||
server.on('error', (error) => {
|
||||
logError('[startup]', 'HTTP server failed', error)
|
||||
})
|
||||
|
||||
async function bootstrapCoreServices() {
|
||||
if (startupState.core.running || shutdownStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
startupState.core.running = true
|
||||
|
||||
while (!shutdownStarted && !startupState.core.ready) {
|
||||
startupState.phase = startupState.core.attemptCount === 0 ? 'starting' : 'retrying'
|
||||
startupState.core.attemptCount += 1
|
||||
startupState.core.lastAttemptAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
logInfo('[startup]', '开始执行核心启动步骤', {
|
||||
attempt: startupState.core.attemptCount,
|
||||
})
|
||||
|
||||
await runDatabaseMigrations()
|
||||
await ensureFulfillmentCatalogBootstrapped()
|
||||
await ensureAdminUsersBootstrapped()
|
||||
|
||||
startupState.core.ready = true
|
||||
startupState.core.lastError = ''
|
||||
startupState.phase = 'ready'
|
||||
startupState.core.readyAt = new Date().toISOString()
|
||||
|
||||
logInfo('[startup]', '核心启动步骤完成,服务已就绪', {
|
||||
attempt: startupState.core.attemptCount,
|
||||
})
|
||||
|
||||
void bootstrapBrowser()
|
||||
void bootstrapOcr()
|
||||
break
|
||||
} catch (error) {
|
||||
const message = formatStartupError(error)
|
||||
startupState.phase = 'retrying'
|
||||
startupState.core.lastError = message
|
||||
logError('[startup]', '核心启动步骤失败,将自动重试', {
|
||||
attempt: startupState.core.attemptCount,
|
||||
retryDelayMs: CORE_BOOT_RETRY_DELAY_MS,
|
||||
error,
|
||||
})
|
||||
|
||||
await sleep(CORE_BOOT_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
startupState.core.running = false
|
||||
}
|
||||
|
||||
async function bootstrapBrowser() {
|
||||
startupState.browser.status = 'starting'
|
||||
startupState.browser.lastAttemptAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
const result = await warmupTencentBrowser()
|
||||
|
||||
if (!result.warmed) {
|
||||
startupState.browser.status = 'skipped'
|
||||
startupState.browser.message = 'browser prewarm skipped'
|
||||
logInfo('[startup]', 'browser prewarm skipped')
|
||||
return
|
||||
}
|
||||
|
||||
startupState.browser.status = 'ready'
|
||||
startupState.browser.message = 'browser prewarm ready'
|
||||
logInfo('[startup]', 'browser prewarm ready')
|
||||
} catch (error) {
|
||||
if (isMissingPlaywrightBrowserError(error)) {
|
||||
const installCommand = resolveBrowserInstallCommand()
|
||||
startupState.browser.status = 'degraded'
|
||||
startupState.browser.message = formatErrorMessage(error)
|
||||
logWarn('[startup]', `browser prewarm skipped: ${formatErrorMessage(error)}`)
|
||||
logWarn('[startup]', `请先安装浏览器依赖: ${installCommand}`)
|
||||
|
||||
@@ -78,19 +179,88 @@ async function bootstrapBrowser() {
|
||||
return
|
||||
}
|
||||
|
||||
startupState.browser.status = 'degraded'
|
||||
startupState.browser.message = formatStartupError(error)
|
||||
logError('[startup]', 'browser prewarm failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapOcr() {
|
||||
startupState.ocr.status = 'starting'
|
||||
startupState.ocr.lastAttemptAt = new Date().toISOString()
|
||||
|
||||
try {
|
||||
await warmupLocalOcrWorker()
|
||||
startupState.ocr.status = 'ready'
|
||||
startupState.ocr.message = 'local OCR ready'
|
||||
logInfo('[startup]', 'local OCR ready')
|
||||
} catch (error) {
|
||||
startupState.ocr.status = 'degraded'
|
||||
startupState.ocr.message = formatStartupError(error)
|
||||
logWarn('[startup]', `local OCR skipped: ${formatStartupError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function buildHealthPayload() {
|
||||
return {
|
||||
phase: startupState.phase,
|
||||
startedAt: startupState.startedAt,
|
||||
core: {
|
||||
ready: startupState.core.ready,
|
||||
running: startupState.core.running,
|
||||
attemptCount: startupState.core.attemptCount,
|
||||
readyAt: startupState.core.readyAt,
|
||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
||||
lastError: startupState.core.lastError,
|
||||
},
|
||||
browser: {
|
||||
status: startupState.browser.status,
|
||||
message: startupState.browser.message,
|
||||
lastAttemptAt: startupState.browser.lastAttemptAt,
|
||||
},
|
||||
ocr: {
|
||||
status: startupState.ocr.status,
|
||||
message: startupState.ocr.message,
|
||||
lastAttemptAt: startupState.ocr.lastAttemptAt,
|
||||
},
|
||||
process: {
|
||||
pid: process.pid,
|
||||
shutdownStarted,
|
||||
lastUnhandledRejection: startupState.process.lastUnhandledRejection,
|
||||
lastUncaughtException: startupState.process.lastUncaughtException,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createStartupState() {
|
||||
return {
|
||||
phase: 'starting',
|
||||
startedAt: new Date().toISOString(),
|
||||
core: {
|
||||
ready: false,
|
||||
running: false,
|
||||
attemptCount: 0,
|
||||
readyAt: '',
|
||||
lastAttemptAt: '',
|
||||
lastError: '',
|
||||
},
|
||||
browser: {
|
||||
status: 'pending',
|
||||
message: '',
|
||||
lastAttemptAt: '',
|
||||
},
|
||||
ocr: {
|
||||
status: 'pending',
|
||||
message: '',
|
||||
lastAttemptAt: '',
|
||||
},
|
||||
process: {
|
||||
lastUnhandledRejection: null,
|
||||
lastUncaughtException: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatStartupError(error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message.split('\n')[0].trim()
|
||||
@@ -126,6 +296,12 @@ function resolveBrowserInstallCommand() {
|
||||
return 'npm run browser:install'
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
let shutdownStarted = false
|
||||
|
||||
async function shutdown(signal) {
|
||||
@@ -134,6 +310,7 @@ async function shutdown(signal) {
|
||||
}
|
||||
|
||||
shutdownStarted = true
|
||||
startupState.phase = 'shutting_down'
|
||||
logInfo('[shutdown]', `received ${signal}, closing browser sessions and HTTP server`)
|
||||
|
||||
try {
|
||||
@@ -171,3 +348,19 @@ process.on('SIGINT', () => {
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM')
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
startupState.process.lastUnhandledRejection = {
|
||||
time: new Date().toISOString(),
|
||||
message: formatStartupError(reason),
|
||||
}
|
||||
logError('[process]', 'unhandled promise rejection', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
startupState.process.lastUncaughtException = {
|
||||
time: new Date().toISOString(),
|
||||
message: formatStartupError(error),
|
||||
}
|
||||
logError('[process]', 'uncaught exception captured', error)
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ import { reserveInventoryForTask } from '../order/inventory-service.js'
|
||||
import { replayAgisoTradeWebhookEvent } from '../order/webhook-service.js'
|
||||
import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||
@@ -1268,9 +1269,17 @@ export async function completeAdminTaskManualDispatch(taskId, payload = {}, sess
|
||||
completedBy: manualDispatch.completedBy,
|
||||
}, now)
|
||||
|
||||
const taskAfterAutoDelivery = outcome === 'delivered'
|
||||
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: await getOrderById(task.order_id),
|
||||
task: updatedTask,
|
||||
trigger: 'manual_dispatch_completed',
|
||||
})).task || updatedTask
|
||||
: updatedTask
|
||||
|
||||
return {
|
||||
outcome,
|
||||
task: mapTaskActionPayload(updatedTask),
|
||||
task: mapTaskActionPayload(taskAfterAutoDelivery),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
releaseReservedInventoryItem,
|
||||
} from '../../repositories/inventory-repo.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../platforms/agiso/xianyu/auto-delivery-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
@@ -215,10 +216,15 @@ async function finalizeClaimTaskRedeem(context) {
|
||||
})
|
||||
|
||||
await markInventoryItemDelivered(inventoryItem.id, nowIso())
|
||||
const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order: context.order,
|
||||
task: updatedTask,
|
||||
trigger: 'claim_redeemed',
|
||||
})
|
||||
|
||||
return buildClaimDetailPayload({
|
||||
claimToken: context.claimToken,
|
||||
task: updatedTask,
|
||||
task: autoDeliveryResult.task || updatedTask,
|
||||
order: context.order,
|
||||
orderItem: context.orderItem,
|
||||
session,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { runtimeConfig } from '../../config/runtime.js'
|
||||
import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js'
|
||||
import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js'
|
||||
import {
|
||||
extractAgisoTradeOrderItemSources,
|
||||
extractAgisoTradePayload,
|
||||
resolveAgisoTradePlatformOrderId,
|
||||
} from './agiso-trade-parsing.js'
|
||||
@@ -572,7 +573,7 @@ function parsePayloadJson(rawJson) {
|
||||
}
|
||||
|
||||
function normalizeOrderItems(payload) {
|
||||
const items = extractOrderItemSources(payload)
|
||||
const items = extractAgisoTradeOrderItemSources(payload)
|
||||
|
||||
return items.map((item) => {
|
||||
const source = isPlainObject(item) ? item : {}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
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 { parseJsonObject } from '../../../../utils/json.js'
|
||||
import { logWebhook } from '../../../../utils/logger.js'
|
||||
import { nowIso } from '../../../../utils/time.js'
|
||||
|
||||
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
|
||||
order,
|
||||
task,
|
||||
trigger = 'task_delivered',
|
||||
} = {}) {
|
||||
if (!isAgisoXianyuOrder(order) || !task?.id) {
|
||||
return { sent: false, skipped: true, reason: 'not_supported', task }
|
||||
}
|
||||
|
||||
if (String(task.delivery_status || '').trim() !== 'delivered') {
|
||||
return { sent: false, skipped: true, reason: 'task_not_delivered', task }
|
||||
}
|
||||
|
||||
const orderTasks = await listTasksByOrderId(order.id)
|
||||
if (hasAgisoAutoDeliverySucceeded(orderTasks)) {
|
||||
return { sent: false, skipped: true, reason: 'already_sent', task }
|
||||
}
|
||||
|
||||
if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) {
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: task.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 }
|
||||
}
|
||||
|
||||
const config = resolveAgisoXianyuAutoDeliveryConfig(order)
|
||||
if (!config.enabled) {
|
||||
return persistAgisoAutoDeliveryResult(task, {
|
||||
status: 'skipped',
|
||||
trigger,
|
||||
reason: 'auto_delivery_disabled',
|
||||
order,
|
||||
})
|
||||
}
|
||||
|
||||
if (!config.endpoint || !config.accessToken || !config.appSecret) {
|
||||
return persistAgisoAutoDeliveryResult(task, {
|
||||
status: 'skipped',
|
||||
trigger,
|
||||
reason: 'missing_config',
|
||||
order,
|
||||
detail: {
|
||||
hasEndpoint: Boolean(config.endpoint),
|
||||
hasAccessToken: Boolean(config.accessToken),
|
||||
hasAppSecret: Boolean(config.appSecret),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const requestHeaders = buildRequestHeaders({
|
||||
accessToken: config.accessToken,
|
||||
apiVersion: config.apiVersion,
|
||||
})
|
||||
const requestBody = buildRequestBody({
|
||||
tids: String(order.platform_order_id || '').trim(),
|
||||
appSecret: config.appSecret,
|
||||
aldsType: config.aldsType,
|
||||
ignoreAldsLog: config.ignoreAldsLog,
|
||||
ignoreBlackList: config.ignoreBlackList,
|
||||
ignoreOnOff: config.ignoreOnOff,
|
||||
ignoreRefundCheck: config.ignoreRefundCheck,
|
||||
ignoreRestricted: config.ignoreRestricted,
|
||||
ignoreTradeStatusCheck: config.ignoreTradeStatusCheck,
|
||||
})
|
||||
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
endpoint: config.endpoint,
|
||||
aldsType: config.aldsType,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(config.endpoint, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
})
|
||||
const rawText = await response.text()
|
||||
const parsed = parseJsonObject(rawText)
|
||||
const success = isAgisoAutoDeliverySuccess(response.status, parsed)
|
||||
|
||||
if (success) {
|
||||
const requestId = String(parsed?.RequestId || '').trim()
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
responseStatus: response.status,
|
||||
requestId,
|
||||
})
|
||||
|
||||
return persistAgisoAutoDeliveryResult(task, {
|
||||
status: 'success',
|
||||
trigger,
|
||||
reason: '',
|
||||
order,
|
||||
responseStatus: response.status,
|
||||
response: parsed,
|
||||
detail: {
|
||||
requestId,
|
||||
aldsType: config.aldsType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status)
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
responseStatus: response.status,
|
||||
errorMessage,
|
||||
response: parsed,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return persistAgisoAutoDeliveryResult(task, {
|
||||
status: 'failed',
|
||||
trigger,
|
||||
reason: 'request_failed',
|
||||
order,
|
||||
responseStatus: response.status,
|
||||
response: parsed,
|
||||
errorMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败')
|
||||
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', {
|
||||
trigger,
|
||||
orderId: order.id,
|
||||
taskId: task.id,
|
||||
shopId: order.shop_id,
|
||||
platformOrderId: order.platform_order_id,
|
||||
errorMessage,
|
||||
}, { level: 'warn' })
|
||||
|
||||
return persistAgisoAutoDeliveryResult(task, {
|
||||
status: 'failed',
|
||||
trigger,
|
||||
reason: 'request_error',
|
||||
order,
|
||||
responseStatus: 0,
|
||||
response: {},
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAgisoAutoDeliverySucceeded(tasks = []) {
|
||||
return (Array.isArray(tasks) ? tasks : []).some((task) => {
|
||||
const context = parseTaskContext(task)
|
||||
return String(context?.agisoAutoDelivery?.status || '').trim() === 'success'
|
||||
})
|
||||
}
|
||||
|
||||
export function isOrderReadyForAgisoAutoDelivery(tasks = []) {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
return normalizedTasks.length > 0
|
||||
&& normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered')
|
||||
}
|
||||
|
||||
function resolveAgisoXianyuAutoDeliveryConfig(order) {
|
||||
const baseConfig = runtimeConfig.platforms?.agiso?.autoDelivery || {}
|
||||
const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {}
|
||||
|
||||
return {
|
||||
enabled: normalizeBooleanLike(baseConfig.enabled, true),
|
||||
endpoint: String(baseConfig.endpoint || '').trim(),
|
||||
apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1',
|
||||
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
|
||||
accessToken: String(shopConfig.accessToken || runtimeConfig.platforms?.agiso?.messaging?.accessToken || '').trim(),
|
||||
aldsType: normalizePositiveInteger(baseConfig.aldsType, 1),
|
||||
ignoreAldsLog: normalizeBooleanLike(baseConfig.ignoreAldsLog, false),
|
||||
ignoreBlackList: normalizeBooleanLike(baseConfig.ignoreBlackList, false),
|
||||
ignoreOnOff: normalizeBooleanLike(baseConfig.ignoreOnOff, false),
|
||||
ignoreRefundCheck: normalizeBooleanLike(baseConfig.ignoreRefundCheck, false),
|
||||
ignoreRestricted: normalizeBooleanLike(baseConfig.ignoreRestricted, false),
|
||||
ignoreTradeStatusCheck: normalizeBooleanLike(baseConfig.ignoreTradeStatusCheck, false),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestHeaders({ accessToken, apiVersion }) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
ApiVersion: String(apiVersion || '1').trim() || '1',
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestBody({
|
||||
tids,
|
||||
appSecret,
|
||||
aldsType,
|
||||
ignoreAldsLog,
|
||||
ignoreBlackList,
|
||||
ignoreOnOff,
|
||||
ignoreRefundCheck,
|
||||
ignoreRestricted,
|
||||
ignoreTradeStatusCheck,
|
||||
}) {
|
||||
const payload = {
|
||||
tids: String(tids || '').trim(),
|
||||
aldsType: String(normalizePositiveInteger(aldsType, 1)),
|
||||
ignoreAldsLog: String(Boolean(ignoreAldsLog)),
|
||||
ignoreBlackList: String(Boolean(ignoreBlackList)),
|
||||
ignoreOnOff: String(Boolean(ignoreOnOff)),
|
||||
ignoreRefundCheck: String(Boolean(ignoreRefundCheck)),
|
||||
ignoreRestricted: String(Boolean(ignoreRestricted)),
|
||||
ignoreTradeStatusCheck: String(Boolean(ignoreTradeStatusCheck)),
|
||||
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, appSecret)
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params, appSecret) {
|
||||
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()
|
||||
}
|
||||
|
||||
function isAgisoAutoDeliverySuccess(statusCode, payload) {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (payload.IsSuccess === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.Error_Code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Number(payload.code) === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveAgisoAutoDeliveryErrorMessage(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 persistAgisoAutoDeliveryResult(task, {
|
||||
status,
|
||||
trigger,
|
||||
reason,
|
||||
order,
|
||||
responseStatus = 0,
|
||||
response = {},
|
||||
errorMessage = '',
|
||||
detail = {},
|
||||
} = {}) {
|
||||
const now = nowIso()
|
||||
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 updateTask(task.id, {
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (status === 'success' || status === 'failed' || status === 'skipped') {
|
||||
await createTaskEvent(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) {
|
||||
const value = task?.context_json
|
||||
|
||||
if (!value) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value || '{}'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isAgisoXianyuOrder(order) {
|
||||
return String(order?.provider || '').trim() === 'agiso'
|
||||
&& String(order?.platform || '').trim() === 'xianyu'
|
||||
&& Boolean(String(order?.platform_order_id || '').trim())
|
||||
&& Number(order?.id || 0) > 0
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
function normalizeBooleanLike(value, fallbackValue) {
|
||||
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) {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
hasAgisoAutoDeliverySucceeded,
|
||||
isOrderReadyForAgisoAutoDelivery,
|
||||
} from './auto-delivery-service.js'
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -54,6 +54,9 @@ export function formatTaskEventType(eventType: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
manual_dispatch_completed: '人工履约已回写',
|
||||
inventory_binding_released: '库存绑定已释放',
|
||||
agiso_auto_delivery_success: '咸鱼自动发货成功',
|
||||
agiso_auto_delivery_failed: '咸鱼自动发货失败',
|
||||
agiso_auto_delivery_skipped: '咸鱼自动发货已跳过',
|
||||
}
|
||||
|
||||
return formatStatusWithRaw(eventType, labelMap)
|
||||
|
||||
@@ -147,6 +147,11 @@ function formatTaskEventPayload(payload: Record<string, unknown>) {
|
||||
payload.resultCode ? `代码 ${payload.resultCode}` : '',
|
||||
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
|
||||
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
|
||||
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
|
||||
payload.reason ? `原因 ${payload.reason}` : '',
|
||||
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
|
||||
payload.requestId ? `请求 ${payload.requestId}` : '',
|
||||
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
if (parts.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user