diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index f1429115..819e6807 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -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', diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json index 23572206..a23fd7b8 100644 --- a/apps/backend/data/order-fulfillment-bindings.json +++ b/apps/backend/data/order-fulfillment-bindings.json @@ -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", diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 4e915e9b..2bd3e7c6 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -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 diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js index 058de7cb..c0bf2617 100644 --- a/apps/backend/src/index.js +++ b/apps/backend/src/index.js @@ -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) +}) diff --git a/apps/backend/src/services/admin/admin-service.js b/apps/backend/src/services/admin/admin-service.js index a8a42f67..4a254ede 100644 --- a/apps/backend/src/services/admin/admin-service.js +++ b/apps/backend/src/services/admin/admin-service.js @@ -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), } } diff --git a/apps/backend/src/services/claim/claim-session-service.js b/apps/backend/src/services/claim/claim-session-service.js index f9c2025a..79149c28 100644 --- a/apps/backend/src/services/claim/claim-session-service.js +++ b/apps/backend/src/services/claim/claim-session-service.js @@ -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, diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index d2951a91..e37eb32b 100644 --- a/apps/backend/src/services/order/webhook-service.js +++ b/apps/backend/src/services/order/webhook-service.js @@ -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 : {} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js new file mode 100644 index 00000000..7c500b6a --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.js @@ -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]' +} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js new file mode 100644 index 00000000..10edc311 --- /dev/null +++ b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.js @@ -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) +}) diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index b0195958..6ff15538 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -54,6 +54,9 @@ export function formatTaskEventType(eventType: string) { const labelMap: Record = { manual_dispatch_completed: '人工履约已回写', inventory_binding_released: '库存绑定已释放', + agiso_auto_delivery_success: '咸鱼自动发货成功', + agiso_auto_delivery_failed: '咸鱼自动发货失败', + agiso_auto_delivery_skipped: '咸鱼自动发货已跳过', } return formatStatusWithRaw(eventType, labelMap) diff --git a/apps/frontend/src/views/admin/AdminTaskDetailView.vue b/apps/frontend/src/views/admin/AdminTaskDetailView.vue index 4bed3a4f..de5c78bb 100644 --- a/apps/frontend/src/views/admin/AdminTaskDetailView.vue +++ b/apps/frontend/src/views/admin/AdminTaskDetailView.vue @@ -147,6 +147,11 @@ function formatTaskEventPayload(payload: Record) { 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) { diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 8bfb9b38..2156da7e 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -4,7 +4,7 @@ reverse_proxy backend:3000 } - handle /health { + handle /health* { reverse_proxy backend:3000 } diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index fc1f4f64..a6508560 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -4,7 +4,7 @@ reverse_proxy backend:3000 } - handle /health { + handle /health* { reverse_proxy backend:3000 } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c88f781e..29d9ca3e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,6 +32,12 @@ services: depends_on: postgres: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s environment: TZ: ${TZ:-Asia/Shanghai} PORT: ${BACKEND_PORT:-3000} @@ -81,8 +87,10 @@ services: dockerfile: deploy/docker/dev-caddy.Dockerfile restart: unless-stopped depends_on: - - backend - - frontend + backend: + condition: service_healthy + frontend: + condition: service_started ports: - "80:80" - "443:443" diff --git a/docker-compose.yml b/docker-compose.yml index a65785b5..d6fcb5bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,12 @@ services: depends_on: postgres: condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s environment: TZ: ${TZ:-Asia/Shanghai} PORT: ${BACKEND_PORT:-3000} @@ -57,7 +63,8 @@ services: APP_DOMAIN: ${APP_DOMAIN:-localhost} restart: unless-stopped depends_on: - - backend + backend: + condition: service_healthy ports: - "80:80" - "443:443" diff --git a/docs/app信息.md b/docs/app信息.md index 072582e2..e80379b9 100644 --- a/docs/app信息.md +++ b/docs/app信息.md @@ -58,12 +58,15 @@ DJQFf7HgONCL4XD4EH ---- ngrok +ngrok http 80 https://5cc8-193-176-84-38.ngrok-free.app https://5cc8-193-176-84-38.ngrok-free.app/api/v1/webhooks/agiso/trade +https://221329.cc.cd/api/v1/webhooks/agiso/trade + ---- 服务器 https://order.khhao.com/api/v1/webhooks/agiso/trade diff --git a/docs/咸鱼api.md b/docs/xianyu_api/咸鱼api.md similarity index 100% rename from docs/咸鱼api.md rename to docs/xianyu_api/咸鱼api.md diff --git a/docs/xianyu_api/自动发货.md b/docs/xianyu_api/自动发货.md new file mode 100644 index 00000000..d930cbc6 --- /dev/null +++ b/docs/xianyu_api/自动发货.md @@ -0,0 +1,86 @@ +执行自动发货 +简要描述: 接口调用示例 + +¥高级 +调用该接口将根据您在自动发货后台设定的发货规则,进行一次发货逻辑的处理。该操作等同于在手动发货页面的操作。注意:该页面执行后,也会自动发送闲鱼消息,可以通过参数中的选项控制发货中的一些限制。 +请求URL: + +https://gw-api.agiso.com/aldsIdle/Alds/AldsSend +请求方式: + +POST +公共Header: + +Authorization +是 +string +httpPost.addHeader("Authorization","Bearer "+ accessToken) +ApiVersion +是 +string +httpPost.addHeader("ApiVersion", "1") +公共参数: + +timestamp +是 +Date +时间戳,例如:1468476350。API服务端允许客户端请求最大时间误差为10分钟。 +sign +是 +string +API输入参数签名结果,签名算法参照下面的介绍。 +参数: + +tids +是 +String +订单编号,多个订单编号间用,分隔开 +ignoreAldsLog +否 +Boolean +忽略发货记录 +默认值:'false',可选值'true'、'false' +ignoreBlackList +否 +Boolean +忽略黑名单 +默认值:'false',可选值'true'、'false' +ignoreOnOff +否 +Boolean +忽略发货开关 +默认值:'false',可选值'true'、'false' +ignoreRefundCheck +否 +Boolean +退款中也发 +默认值:'false',可选值'true'、'false' +ignoreRestricted +否 +Boolean +忽略限拍限购 +默认值:'false',可选值'true'、'false' +ignoreTradeStatusCheck +否 +Boolean +忽略订单状态验证 +默认值:'false',可选值'true'、'false' +aldsType +是 +Int +自动发货类型,具体查看底部备注 +返回示例 + + + { + "IsSuccess": true, + "Data": null + "Error_Code": 0, + "Error_Msg": "" + "AllowRetry": null, + "RequestId": "20221027142251106" + } + +备注 + +aldsType自动发货类型:付款后发货=1,确认收货后赠送=2 \ No newline at end of file