// @ts-check import { query } from '../../db/client.js' import { getAgisoMessagingDefaults, getAgisoShopConfig, getAgisoShopConfigMap, getAgisoShopsFilePath, saveAgisoMessagingConfig, } from '../platforms/agiso/shop-config-service.js' import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' import { mapKhhaoOrderPreviewList } from '../platforms/khhao/order-mapper-service.js' import { queryKhhaoOrderList } from '../platforms/khhao/order-query-service.js' import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js' import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js' import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../platforms/khhao/sync-state-service.js' import { loginKhhaoSession } from '../platforms/khhao/session-service.js' import { getCloudtentaclesSourceConfig, getCloudtentaclesSourcesFilePath, saveCloudtentaclesSourceConfig, } from '../platforms/cloudtentacles/source-config-service.js' import { loginCloudtentaclesSession, sendCloudtentaclesSmsCode, validateCloudtentaclesSession, } from '../platforms/cloudtentacles/session-service.js' import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-service.js' import { getOrderFulfillmentBindingConfigs, getOrderFulfillmentBindingsFilePath, saveOrderFulfillmentBindingConfigs, } from '../order/fulfillment-binding-config-service.js' import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount } from '../../utils/money.js' import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js' import { normalizeProductName } from '../order/product-match-service.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js' /** @typedef {import('../../types/admin-write-inputs.js').AdminAgisoShopConfigSaveInput} AdminAgisoShopConfigSaveInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */ export async function getAdminAgisoShopConfigs() { const configMap = getAgisoShopConfigMap() const defaults = getAgisoMessagingDefaults() const rowsResult = await query( ` SELECT shop_id, MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name, MAX(created_at) AS latest_seen_at, COUNT(*)::int AS webhook_event_count FROM webhook_events WHERE provider = 'agiso' AND trim(shop_id) != '' GROUP BY shop_id ORDER BY latest_seen_at DESC, shop_id DESC `, ) const rows = rowsResult.rows return { filePath: getAgisoShopsFilePath(), defaults: mapAdminAgisoMessagingDefaults(defaults), shops: Object.entries(configMap) .sort(([left], [right]) => left.localeCompare(right)) .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), observedShops: rows.map((row) => ({ shopId: String(row.shop_id || '').trim(), detectedShopName: String(row.detected_shop_name || '').trim(), displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name), latestSeenAt: row.latest_seen_at || null, webhookEventCount: Number(row.webhook_event_count || 0), configured: Boolean(configMap[String(row.shop_id || '').trim()]), })), } } /** @param {AdminAgisoShopConfigSaveInput} [payload] */ export function updateAdminAgisoShopConfigs(payload = /** @type {AdminAgisoShopConfigSaveInput} */ ({})) { const rawItems = Array.isArray(payload.shops) ? payload.shops : [] const currentDefaults = getAgisoMessagingDefaults() const currentMap = getAgisoShopConfigMap() const nextDefaults = { ...currentDefaults } const nextMap = {} applyOptionalStringField(nextDefaults, 'messageTemplate', payload.defaults) applyOptionalStringField(nextDefaults, 'autoDeliveryMessageTemplate', payload.defaults) for (const item of rawItems) { const shopId = String(item?.shopId || '').trim() if (!shopId) { continue } const current = currentMap[shopId] || {} const next = { ...current } const shopName = String(item?.shopName || '').trim() const accessToken = String(item?.accessToken || '').trim() const messageTemplate = String(item?.messageTemplate || '').trim() const autoDeliveryMessageTemplate = String(item?.autoDeliveryMessageTemplate || '').trim() const appSecret = String(item?.appSecret || '').trim() const apiVersion = String(item?.apiVersion || '').trim() const sendMessageEndpoint = String(item?.sendMessageEndpoint || '').trim() if (shopName) { next.shopName = shopName } else { delete next.shopName } if (accessToken) { next.accessToken = accessToken } if (messageTemplate) { next.messageTemplate = messageTemplate } else if (typeof item?.messageTemplate === 'string') { delete next.messageTemplate } if (autoDeliveryMessageTemplate) { next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate } else if (typeof item?.autoDeliveryMessageTemplate === 'string') { delete next.autoDeliveryMessageTemplate } if (appSecret) { next.appSecret = appSecret } if (apiVersion) { next.apiVersion = apiVersion } if (sendMessageEndpoint) { next.sendMessageEndpoint = sendMessageEndpoint } if (typeof item?.enabled === 'boolean') { next.enabled = item.enabled } if (!next.accessToken) { continue } nextMap[shopId] = next } const saved = saveAgisoMessagingConfig({ defaults: nextDefaults, shops: nextMap, }) return { filePath: getAgisoShopsFilePath(), defaults: mapAdminAgisoMessagingDefaults(saved.defaults), shops: Object.entries(saved.shops) .sort(([left], [right]) => left.localeCompare(right)) .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), } } function mapAdminAgisoMessagingDefaults(defaults = {}) { return { messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()), autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(String(defaults.autoDeliveryMessageTemplate || '').trim()), } } function mapAdminAgisoShopConfigItem(shopId, config = {}) { return { shopId, shopName: String(config.shopName || '').trim(), accessToken: String(config.accessToken || '').trim(), accessTokenMasked: maskSecret(config.accessToken), enabled: typeof config.enabled === 'boolean' ? config.enabled : null, messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()), autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(String(config.autoDeliveryMessageTemplate || '').trim()), appSecretConfigured: Boolean(String(config.appSecret || '').trim()), apiVersion: String(config.apiVersion || '').trim(), sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), } } function applyOptionalStringField(target, key, source) { if (!source || typeof source[key] !== 'string') { return } const value = String(source[key] || '').trim() if (value) { target[key] = normalizeAgisoMessageTemplate(value) return } delete target[key] } export function getAdminKhhaoSourceConfig() { const config = getKhhaoSourceConfig() const syncState = getKhhaoSyncState() return { filePath: getKhhaoSourcesFilePath(), stateFilePath: getKhhaoSyncStateFilePath(), source: { enabled: config.enabled !== false, baseUrl: String(config.baseUrl || '').trim(), username: String(config.username || '').trim(), password: String(config.password || '').trim(), maxCaptchaAttempts: Number(config.maxCaptchaAttempts || 3) || 3, autoSync: { enabled: config.autoSync?.enabled === true, intervalMinutes: Number(config.autoSync?.intervalMinutes || 3) || 3, pages: Number(config.autoSync?.pages || 2) || 2, pageSize: Number(config.autoSync?.pageSize || 20) || 20, }, }, syncState: { syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(), lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(), lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(), lastRunStatus: String(syncState.lastRunStatus || '').trim(), lastErrorMessage: String(syncState.lastErrorMessage || '').trim(), fetchedCount: Number(syncState.fetchedCount || 0), syncedCount: Number(syncState.syncedCount || 0), ignoredCount: Number(syncState.ignoredCount || 0), }, } } /** @param {AdminKhhaoSourceConfigInput} [payload] */ export function updateAdminKhhaoSourceConfig(payload = /** @type {AdminKhhaoSourceConfigInput} */ ({})) { const saved = saveKhhaoSourceConfig({ enabled: payload.enabled !== false, baseUrl: String(payload.baseUrl || '').trim() || 'https://admin.khhao.com', username: String(payload.username || '').trim(), password: String(payload.password || '').trim(), maxCaptchaAttempts: Number(payload.maxCaptchaAttempts || 3) || 3, autoSync: { enabled: payload.autoSync?.enabled === true, intervalMinutes: Number(payload.autoSync?.intervalMinutes || 3) || 3, pages: Number(payload.autoSync?.pages || 2) || 2, pageSize: Number(payload.autoSync?.pageSize || 20) || 20, }, }) const syncState = saved.autoSync?.enabled ? ensureKhhaoSyncBaseline() : getKhhaoSyncState() return { filePath: getKhhaoSourcesFilePath(), stateFilePath: getKhhaoSyncStateFilePath(), source: { enabled: saved.enabled !== false, baseUrl: String(saved.baseUrl || '').trim(), username: String(saved.username || '').trim(), password: String(saved.password || '').trim(), maxCaptchaAttempts: Number(saved.maxCaptchaAttempts || 3) || 3, autoSync: { enabled: saved.autoSync?.enabled === true, intervalMinutes: Number(saved.autoSync?.intervalMinutes || 3) || 3, pages: Number(saved.autoSync?.pages || 2) || 2, pageSize: Number(saved.autoSync?.pageSize || 20) || 20, }, }, syncState: { syncFromCreatedAt: String(syncState.syncFromCreatedAt || '').trim(), lastRunStartedAt: String(syncState.lastRunStartedAt || '').trim(), lastRunFinishedAt: String(syncState.lastRunFinishedAt || '').trim(), lastRunStatus: String(syncState.lastRunStatus || '').trim(), lastErrorMessage: String(syncState.lastErrorMessage || '').trim(), fetchedCount: Number(syncState.fetchedCount || 0), syncedCount: Number(syncState.syncedCount || 0), ignoredCount: Number(syncState.ignoredCount || 0), }, } } /** @param {AdminKhhaoTestLoginInput} [payload] */ export async function testAdminKhhaoLogin(payload = /** @type {AdminKhhaoTestLoginInput} */ ({})) { const savedSource = getKhhaoSourceConfig() const session = await loginKhhaoSession({ baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(), username: String(payload.username || savedSource.username || '').trim(), password: String(payload.password || savedSource.password || '').trim(), maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts, includeImageBase64: payload.includeImageBase64, requestId: `admin-khhao-test-login:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`, }) return { baseUrl: session.baseUrl, username: session.username, loggedInAt: session.loggedInAt, attempt: session.attempt, responseMessage: session.responseMessage, captcha: { recognizedText: session.captchaText, imageBase64: session.captchaImageBase64, }, session: { cookieKeys: Object.keys(session.cookieMap), cookieCount: Object.keys(session.cookieMap).length, cookieHeaderMasked: maskSecret(session.cookieHeader), }, } } /** @param {AdminKhhaoOrderQueryInput} [payload] */ export async function queryAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) { const savedSource = getKhhaoSourceConfig() const session = await loginKhhaoSession({ baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(), username: String(payload.username || savedSource.username || '').trim(), password: String(payload.password || savedSource.password || '').trim(), maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts, requestId: `admin-khhao-query-orders:${String(payload.username || savedSource.username || '').trim() || 'anonymous'}`, }) const result = await queryKhhaoOrderList({ baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(), page: payload.page, limit: payload.limit, session, }) const previews = mapKhhaoOrderPreviewList(result.items) return { baseUrl: session.baseUrl, page: result.page, limit: result.limit, total: result.total, itemCount: result.items.length, items: previews, rawItems: result.items, } } /** @param {AdminKhhaoOrderQueryInput} [payload] */ export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQueryInput} */ ({})) { const savedSource = getKhhaoSourceConfig() return syncKhhaoOrders({ ...payload, baseUrl: String(payload.baseUrl || savedSource.baseUrl || '').trim(), username: String(payload.username || savedSource.username || '').trim(), password: String(payload.password || savedSource.password || '').trim(), maxCaptchaAttempts: payload.maxCaptchaAttempts || savedSource.maxCaptchaAttempts, }) } export function getAdminCloudtentaclesSourceConfig() { const config = getCloudtentaclesSourceConfig() return { filePath: getCloudtentaclesSourcesFilePath(), source: { enabled: config.enabled !== false, baseUrl: String(config.baseUrl || '').trim(), username: String(config.username || '').trim(), password: String(config.password || '').trim(), phone: String(config.phone || '').trim(), deviceId: String(config.deviceId || '-').trim() || '-', deviceType: Number(config.deviceType || 0), }, } } /** @param {AdminCloudtentaclesSourceConfigInput} [payload] */ export function updateAdminCloudtentaclesSourceConfig(payload = /** @type {AdminCloudtentaclesSourceConfigInput} */ ({})) { const saved = saveCloudtentaclesSourceConfig({ enabled: payload.enabled !== false, baseUrl: String(payload.baseUrl || '').trim() || 'https://123.207.217.176', username: String(payload.username || '').trim(), password: String(payload.password || '').trim(), phone: String(payload.phone || '').trim(), deviceId: String(payload.deviceId || '-').trim() || '-', deviceType: Number(payload.deviceType || 0), }) return { filePath: getCloudtentaclesSourcesFilePath(), source: { enabled: saved.enabled !== false, baseUrl: String(saved.baseUrl || '').trim(), username: String(saved.username || '').trim(), password: String(saved.password || '').trim(), phone: String(saved.phone || '').trim(), deviceId: String(saved.deviceId || '-').trim() || '-', deviceType: Number(saved.deviceType || 0), }, } } /** @param {AdminCloudtentaclesSendSmsCodeInput} [payload] */ export async function sendAdminCloudtentaclesSmsCode(payload = /** @type {AdminCloudtentaclesSendSmsCodeInput} */ ({})) { const savedSource = getCloudtentaclesSourceConfig() const result = await sendCloudtentaclesSmsCode({ baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), username: pickFirstNonEmpty([payload.username, savedSource.username]), phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), deviceType: payload.deviceType ?? savedSource.deviceType, }) return { ...result, phoneMasked: maskPhone(result.phone), } } /** @param {AdminCloudtentaclesTestLoginInput} [payload] */ export async function testAdminCloudtentaclesLogin(payload = /** @type {AdminCloudtentaclesTestLoginInput} */ ({})) { const savedSource = getCloudtentaclesSourceConfig() const session = await loginCloudtentaclesSession({ baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), username: pickFirstNonEmpty([payload.username, savedSource.username]), password: pickFirstNonEmpty([payload.password, savedSource.password]), phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), code: String(payload.code || '').trim(), deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), deviceType: payload.deviceType ?? savedSource.deviceType, }) return { baseUrl: session.baseUrl, username: session.username, phoneMasked: maskPhone(session.phone), loggedInAt: session.loggedInAt, responseMessage: session.responseMessage, token: session.token, session: { tokenMasked: maskSecret(session.token), permissionCount: session.permissions.length, permissions: session.permissions, }, userInfo: session.userInfo, asset: session.asset, } } /** @param {AdminCloudtentaclesValidateSessionInput} [payload] */ export async function validateAdminCloudtentaclesSession(payload = /** @type {AdminCloudtentaclesValidateSessionInput} */ ({})) { const savedSource = getCloudtentaclesSourceConfig() const session = await validateCloudtentaclesSession({ baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), token: String(payload.token || '').trim(), deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), deviceType: payload.deviceType ?? savedSource.deviceType, }) return { baseUrl: session.baseUrl, loggedInAt: session.loggedInAt, session: { tokenMasked: maskSecret(session.token), permissionCount: session.permissions.length, permissions: session.permissions, }, userInfo: session.userInfo, asset: session.asset, } } export async function getAdminFulfillmentBindingConfigs() { const bindings = getOrderFulfillmentBindingConfigs() const rowsResult = await query( ` SELECT o.provider, o.platform, o.shop_id, MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name, COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id, COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code, COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name, MAX(oi.created_at) AS latest_seen_at, COUNT(*)::int AS order_item_count FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != '' OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != '' OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != '' GROUP BY o.provider, o.platform, o.shop_id, external_item_id, external_sku_code, external_sku_name ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC LIMIT 200 `, ) return { filePath: getOrderFulfillmentBindingsFilePath(), bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), observedProducts: rowsResult.rows.map((row) => mapAdminObservedProductItem({ provider: String(row.provider || '').trim(), platform: String(row.platform || '').trim(), shopId: String(row.shop_id || '').trim(), shopName: String(row.shop_name || '').trim(), externalItemId: String(row.external_item_id || '').trim(), externalSkuCode: String(row.external_sku_code || '').trim(), externalSkuName: String(row.external_sku_name || '').trim(), latestSeenAt: row.latest_seen_at || null, orderItemCount: Number(row.order_item_count || 0), }, bindings)), } } /** @param {AdminFulfillmentBindingLookupInput} [payload] */ export async function lookupAdminFulfillmentBindingOrder(payload = /** @type {AdminFulfillmentBindingLookupInput} */ ({})) { const provider = String(payload.provider || 'agiso').trim() || 'agiso' const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' const shopId = String(payload.shopId || '').trim() const platformOrderId = String(payload.platformOrderId || '').trim() if (!shopId) { throw createHttpError('请先填写店铺 ID', { statusCode: 400, errorCode: 'admin_fulfillment_lookup_missing_shop_id', }) } if (!platformOrderId) { throw createHttpError('请先填写平台订单号', { statusCode: 400, errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', }) } if (provider !== 'agiso' || platform !== 'xianyu') { throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { statusCode: 400, errorCode: 'admin_fulfillment_lookup_platform_not_supported', }) } const detailResult = await enrichAgisoXianyuTradeOrder({ provider, platform, shopId, shopName: '', platformOrderId, orderStatus: 'created', payStatus: 'unpaid', buyerId: '', buyerName: '', receiverContact: '', totalAmount: 0, currency: 'CNY', paidAt: null, rawPayload: {}, items: [], }, { requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, }) const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} const items = Array.isArray(detail.items) ? detail.items : [] if (items.length === 0) { const detailReason = String(detailResult?.reason || '').trim() const detailMessage = String(detailResult?.errorMessage || '').trim() let message = detailMessage if (!message && detailReason === 'missing_config') { message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' } if (!message) { message = `未查询到订单 ${platformOrderId} 的商品明细` } throw createHttpError(message, { statusCode: 404, errorCode: 'admin_fulfillment_lookup_order_items_not_found', }) } const bindings = getOrderFulfillmentBindingConfigs() const resolvedShopName = pickFirstNonEmpty([ detail.shopName, getAgisoShopConfig(shopId)?.shopName, ]) return { order: { provider, platform, shopId, shopName: resolvedShopName, platformOrderId, buyerName: String(detail.buyerName || '').trim(), totalAmountFen: Number(detail.totalAmount || 0), totalAmount: formatFenToAmount(detail.totalAmount), paidAt: detail.paidAt || null, enriched: Boolean(detailResult?.enriched), enrichReason: String(detailResult?.reason || '').trim(), errorMessage: String(detailResult?.errorMessage || '').trim(), }, items: items.map((item, index) => { const observed = { provider, platform, shopId, shopName: resolvedShopName, externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), externalSkuCode: pickFirstNonEmpty([item?.externalSkuCode, item?.skuCode, item?.externalItemId, item?.itemId]), externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), latestSeenAt: null, orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), } return { lineId: [ platformOrderId, index + 1, observed.externalSkuCode || 'na', observed.externalItemId || 'na', ].join(':'), itemTitle: pickFirstNonEmpty([ item?.skuName, item?.externalSkuName, item?.externalSkuCode, item?.externalItemId, ]), quantity: observed.orderItemCount, ...mapAdminObservedProductItem(observed, bindings), } }), } } /** @param {AdminFulfillmentBindingConfigSaveInput} [payload] */ export async function updateAdminFulfillmentBindingConfigs( payload = /** @type {AdminFulfillmentBindingConfigSaveInput} */ ({}), ) { const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] await validateAdminFulfillmentBindingConfigs(bindingsInput) const saved = saveOrderFulfillmentBindingConfigs(bindingsInput) await syncConfiguredFulfillmentBindings() return { filePath: getOrderFulfillmentBindingsFilePath(), bindings: saved.map(mapAdminFulfillmentBindingConfigItem), } } async function validateAdminFulfillmentBindingConfigs(bindings = []) { if (!Array.isArray(bindings)) { throw createHttpError('履约配置格式不正确', { statusCode: 400, errorCode: 'admin_fulfillment_bindings_invalid_payload', }) } const seenKeys = new Set() for (const [index, rawBinding] of bindings.entries()) { if (!isPlainObject(rawBinding)) { throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { statusCode: 400, errorCode: 'admin_fulfillment_bindings_invalid_item', }) } const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' const platform = String(rawBinding.platform || '').trim() const shopId = String(rawBinding.shopId || '').trim() const skuCode = String(rawBinding.skuCode || '').trim() const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} const externalItemId = String(match.externalItemId || '').trim() const externalSkuCode = String(match.externalSkuCode || '').trim() const externalSkuName = String(match.externalSkuName || '').trim() if (!skuCode) { throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { statusCode: 400, errorCode: 'admin_fulfillment_bindings_missing_sku_code', }) } if (!externalItemId && !externalSkuCode && !externalSkuName) { throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { statusCode: 400, errorCode: 'admin_fulfillment_bindings_missing_match_condition', }) } const profile = await getFulfillmentProfileByKey(profileKey) if (!profile) { throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${profileKey}`, { statusCode: 400, errorCode: 'admin_fulfillment_bindings_invalid_profile_key', }) } const uniqueKey = [ provider, platform, shopId, externalItemId, externalSkuCode, normalizeProductName(externalSkuName), skuCode, ].join('::') if (seenKeys.has(uniqueKey)) { throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { statusCode: 409, errorCode: 'admin_fulfillment_bindings_duplicate_rule', }) } seenKeys.add(uniqueKey) } } function mapAdminFulfillmentBindingConfigItem(item) { const match = item?.match || {} return { provider: String(item?.provider || '').trim(), platform: String(item?.platform || '').trim(), shopId: String(item?.shopId || '').trim(), skuCode: String(item?.skuCode || '').trim(), skuName: String(item?.skuName || '').trim(), profileKey: String(item?.profileKey || '').trim(), enabled: item?.enabled !== false, priority: Number(item?.priority || 100), config: item?.config || {}, match: { externalSkuCode: String(match.externalSkuCode || '').trim(), externalItemId: String(match.externalItemId || '').trim(), externalSkuName: String(match.externalSkuName || '').trim(), config: match.config || {}, }, } } function matchesObservedProduct(binding, observed) { const provider = String(binding?.provider || '').trim() const platform = String(binding?.platform || '').trim() const shopId = String(binding?.shopId || '').trim() const match = binding?.match || {} const externalSkuCode = String(match.externalSkuCode || '').trim() const externalItemId = String(match.externalItemId || '').trim() const externalSkuName = String(match.externalSkuName || '').trim() if (provider && provider !== String(observed?.provider || '').trim()) { return false } if (platform && platform !== String(observed?.platform || '').trim()) { return false } if (shopId && shopId !== String(observed?.shopId || '').trim()) { return false } return ( (externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim()) || (externalItemId && externalItemId === String(observed?.externalItemId || '').trim()) || (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim()) ) } function mapAdminObservedProductItem(item, bindings = []) { const matchedBinding = findMatchingObservedBinding(bindings, item) return { provider: String(item?.provider || '').trim(), platform: String(item?.platform || '').trim(), shopId: String(item?.shopId || '').trim(), shopName: String(item?.shopName || '').trim(), externalItemId: String(item?.externalItemId || '').trim(), externalSkuCode: String(item?.externalSkuCode || '').trim(), externalSkuName: String(item?.externalSkuName || '').trim(), latestSeenAt: item?.latestSeenAt || null, orderItemCount: Number(item?.orderItemCount || 0), configured: Boolean(matchedBinding), matchedBinding: matchedBinding ? { skuCode: String(matchedBinding.skuCode || '').trim(), skuName: String(matchedBinding.skuName || '').trim(), profileKey: String(matchedBinding.profileKey || '').trim(), } : null, } } function findMatchingObservedBinding(bindings, observed) { return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null } function maskSecret(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } if (normalized.length <= 10) { return `${normalized.slice(0, 2)}****${normalized.slice(-2)}` } return `${normalized.slice(0, 6)}****${normalized.slice(-6)}` } function maskPhone(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } if (normalized.length < 7) { return normalized } return `${normalized.slice(0, 3)}****${normalized.slice(-4)}` } function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } function pickFirstNonEmpty(values) { for (const value of values) { const normalized = String(value || '').trim() if (normalized) { return normalized } } return '' }