优化启动流程

This commit is contained in:
yml2213
2026-04-13 19:01:48 +08:00
parent 7008c85e58
commit dc8a014601
18 changed files with 837 additions and 17 deletions
+12
View File
@@ -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",
+34
View File
@@ -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
View File
@@ -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)
})
+3
View File
@@ -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) {