diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 0ba39552..17e76433 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -1,6 +1,5 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' import { - getFulfillmentProfileByKey, listFulfillmentProfileRequirements, resolveFulfillmentBinding, } from '../../repositories/fulfillment-profile-repo.js' @@ -34,10 +33,11 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { platform: order.platform, shopId: order.shop_id, }) - const profile = binding || await getFulfillmentProfileByKey('manual_review') - if (!profile) { + + if (!binding) { continue } + const profile = binding const requirements = await listFulfillmentProfileRequirements(profile.profile_id || profile.id) const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null const quantity = Math.max(1, Number(item.quantity || 1)) diff --git a/apps/backend/src/services/order/order-service.js b/apps/backend/src/services/order/order-service.js index 2840f62a..135dfff3 100644 --- a/apps/backend/src/services/order/order-service.js +++ b/apps/backend/src/services/order/order-service.js @@ -26,6 +26,37 @@ export async function upsertOrderFromWebhook(event) { existingOrderId: existing?.id || null, }) + const resolvedItems = await Promise.all( + event.items.map((item) => resolveOrderItemForFulfillment({ + provider: event.provider, + platform: event.platform, + shopId: event.shopId, + item, + })), + ) + const configuredItems = resolvedItems.filter((item) => item.isConfigured) + + if (configuredItems.length === 0) { + logWebhook('[order-service]', 'Webhook 订单已忽略:未命中任何已配置履约商品', { + provider: event.provider, + platform: event.platform, + shopId: event.shopId, + shopName: event.shopName, + platformOrderId: event.platformOrderId, + existingOrderId: existing?.id || null, + rawItemCount: event.items.length, + }) + + return { + ignored: true, + ignoreReason: 'unconfigured_items', + order: null, + orderItems: [], + tasks: [], + messageDeliveries: [], + } + } + const basePayload = { provider: event.provider, platform: event.platform, @@ -54,18 +85,9 @@ export async function upsertOrderFromWebhook(event) { updatedAt: now, }) - const resolvedItems = await Promise.all( - event.items.map((item) => resolveOrderItemForFulfillment({ - provider: event.provider, - platform: event.platform, - shopId: event.shopId, - item, - })), - ) - const orderItems = await replaceOrderItems( order.id, - resolvedItems.map((item) => ({ + configuredItems.map((item) => ({ skuCode: item.skuCode, skuName: item.skuName, quantity: item.quantity, @@ -88,7 +110,7 @@ export async function upsertOrderFromWebhook(event) { platformOrderId: order.platform_order_id, orderItemCount: orderItems.length, taskCount: tasks.length, - resolvedSkuCodes: resolvedItems.map((item) => item.skuCode), + resolvedSkuCodes: configuredItems.map((item) => item.skuCode), }) for (const task of tasks) { diff --git a/apps/backend/src/services/order/product-match-service.js b/apps/backend/src/services/order/product-match-service.js index 46dbec0f..26538c52 100644 --- a/apps/backend/src/services/order/product-match-service.js +++ b/apps/backend/src/services/order/product-match-service.js @@ -1,3 +1,4 @@ +import { resolveFulfillmentBinding } from '../../repositories/fulfillment-profile-repo.js' import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js' export async function resolveOrderItemForFulfillment({ @@ -5,6 +6,105 @@ export async function resolveOrderItemForFulfillment({ platform = '', shopId = '', item = {}, +}) { + const candidate = await resolveConfiguredItemCandidate({ + provider, + platform, + shopId, + item, + }) + const { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + matchedRule, + binding, + } = candidate + + const resolvedSkuCode = pickFirstNonEmpty([ + matchedRule?.resolved_sku_code, + externalSkuCode, + externalItemId, + ]) + const resolvedSkuName = pickFirstNonEmpty([ + readConfigValue(matchedRule?.config_json, 'resolvedSkuName'), + readConfigValue(matchedRule?.config_json, 'internalProductName'), + item.skuName, + externalSkuName, + resolvedSkuCode, + ]) + const snapshot = { + ...(isPlainObject(item.snapshot) ? item.snapshot : {}), + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + resolvedSkuCode, + matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null, + matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(), + matchedFulfillmentBindingId: binding ? Number(binding.id) : null, + matchedFulfillmentProfileKey: String(binding?.profile_key || '').trim(), + isConfigured: candidate.isConfigured, + } + + return { + ...item, + itemId: externalItemId, + externalItemId, + externalSkuCode, + externalSkuName, + skuCode: resolvedSkuCode, + skuName: resolvedSkuName, + snapshot, + isConfigured: candidate.isConfigured, + } +} + +export async function hasConfiguredOrderItems({ + provider = '', + platform = '', + shopId = '', + items = [], +}) { + const candidates = await Promise.all( + (Array.isArray(items) ? items : []).map((item) => resolveConfiguredItemCandidate({ + provider, + platform, + shopId, + item, + })), + ) + + return candidates.some((item) => item.isConfigured) +} + +export function normalizeProductName(value) { + return String(value || '') + .toLowerCase() + .replace(/[【】\[\]()()]/g, ' ') + .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function pickFirstNonEmpty(values) { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} + +async function resolveConfiguredItemCandidate({ + provider = '', + platform = '', + shopId = '', + item = {}, }) { const externalItemId = pickFirstNonEmpty([ item.externalItemId, @@ -30,61 +130,30 @@ export async function resolveOrderItemForFulfillment({ externalSkuCode, externalSkuNameNormalized, }) - const resolvedSkuCode = pickFirstNonEmpty([ matchedRule?.resolved_sku_code, externalSkuCode, externalItemId, ]) - const resolvedSkuName = pickFirstNonEmpty([ - readConfigValue(matchedRule?.config_json, 'resolvedSkuName'), - readConfigValue(matchedRule?.config_json, 'internalProductName'), - item.skuName, - externalSkuName, - resolvedSkuCode, - ]) - const snapshot = { - ...(isPlainObject(item.snapshot) ? item.snapshot : {}), + const binding = resolvedSkuCode + ? await resolveFulfillmentBinding({ + skuCode: resolvedSkuCode, + provider, + platform, + shopId, + }) + : null + + return { externalItemId, externalSkuCode, externalSkuName, externalSkuNameNormalized, + matchedRule, resolvedSkuCode, - matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null, - matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(), + binding, + isConfigured: Boolean(binding), } - - return { - ...item, - itemId: externalItemId, - externalItemId, - externalSkuCode, - externalSkuName, - skuCode: resolvedSkuCode, - skuName: resolvedSkuName, - snapshot, - } -} - -export function normalizeProductName(value) { - return String(value || '') - .toLowerCase() - .replace(/[【】\[\]()()]/g, ' ') - .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .replace(/\s+/g, ' ') - .trim() -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' } function readConfigValue(rawValue, key) { diff --git a/apps/backend/src/services/order/webhook-service.js b/apps/backend/src/services/order/webhook-service.js index 01a5f9bc..a2ce1f78 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 { upsertOrderFromWebhook } from './order-service.js' +import { hasConfiguredOrderItems } from './product-match-service.js' import { createHttpError } from '../../utils/http.js' import { parseJsonObject } from '../../utils/json.js' import { logWebhook } from '../../utils/logger.js' @@ -89,6 +90,52 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' }) } + const shouldProcess = await hasConfiguredOrderItems({ + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + items: parsed.items, + }) + + if (!shouldProcess) { + await updateWebhookEvent(webhookEventId, { + processed: true, + process_error: 'ignored_unconfigured_product', + related_order_id: null, + }) + + logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:未命中任何已配置履约商品', { + requestId, + webhookEventId, + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + platformOrderId: parsed.platformOrderId, + rawItemCount: Array.isArray(parsed.items) ? parsed.items.length : 0, + }) + + return { + accepted: true, + ignored: true, + ignoreReason: 'unconfigured_product', + provider: parsed.provider, + platform: parsed.platform, + shopId: parsed.shopId, + shopName: parsed.shopName, + platformRaw: parsed.platformRaw, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + orderId: null, + totalAmountFen: Number(parsed.totalAmount || 0), + enriched: false, + enrichReason: 'skipped_unconfigured_product', + taskCount: 0, + messageDeliveries: [], + tasks: [], + } + } + const enriched = await enrichTradeBeforeUpsert(parsed, { requestId, webhookEventId, @@ -96,8 +143,8 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' const result = await upsertOrderFromWebhook(enriched.parsed) await updateWebhookEvent(webhookEventId, { processed: true, - process_error: '', - related_order_id: result.order.id, + process_error: result.ignored ? 'ignored_unconfigured_product' : '', + related_order_id: result.order?.id || null, }) logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', { @@ -110,7 +157,9 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' eventKey: parsed.eventKey, eventType: parsed.eventType, platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order.id, + orderId: result.order?.id || null, + ignored: Boolean(result.ignored), + ignoreReason: String(result.ignoreReason || ''), enriched: enriched.enriched, enrichReason: enriched.reason || '', totalAmountFen: Number(enriched.parsed.totalAmount || 0), @@ -127,8 +176,10 @@ async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' platformRaw: enriched.parsed.platformRaw, eventType: enriched.parsed.eventType, platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order.id, + orderId: result.order?.id || null, totalAmountFen: Number(enriched.parsed.totalAmount || 0), + ignored: Boolean(result.ignored), + ignoreReason: String(result.ignoreReason || ''), enriched: enriched.enriched, enrichReason: enriched.reason || '', taskCount: result.tasks.length, diff --git a/apps/frontend/src/composables/tencent/session-errors.ts b/apps/frontend/src/composables/tencent/session-errors.ts index 5525c454..6bc31eb6 100644 --- a/apps/frontend/src/composables/tencent/session-errors.ts +++ b/apps/frontend/src/composables/tencent/session-errors.ts @@ -1,4 +1,4 @@ -import { ElMessage } from 'element-plus' +import { showError } from '@/lib/feedback' export type TencentActionError = Error & { errorCode?: string @@ -8,7 +8,7 @@ export type TencentActionError = Error & { export function notifyTencentActionError(error: unknown, fallbackMessage: string) { const message = resolveTencentActionMessage(error, fallbackMessage) console.error(error) - ElMessage.error(message) + showError(message) } export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) { diff --git a/apps/frontend/src/composables/useClaimPage.ts b/apps/frontend/src/composables/useClaimPage.ts index 06c1e16d..4299fa41 100644 --- a/apps/frontend/src/composables/useClaimPage.ts +++ b/apps/frontend/src/composables/useClaimPage.ts @@ -1,6 +1,6 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' -import { ElMessage } from 'element-plus' +import { showSuccess } from '@/lib/feedback' import { confirmClaimRole, createClaimSession, @@ -262,7 +262,7 @@ export function useClaimPage(token: string) { detail.value = mergeClaimDetailData(detail.value, response.data) roleConfirmed.value = true - ElMessage.success(response.msg || '角色已确认') + showSuccess(response.msg || '角色已确认') restartPollingIfNeeded(response.data) } catch (error) { notifyTencentActionError(error, '角色确认失败') @@ -289,7 +289,7 @@ export function useClaimPage(token: string) { } detail.value = mergeClaimDetailData(detail.value, response.data) - ElMessage.success(response.msg || '兑换完成') + showSuccess(response.msg || '兑换完成') restartPollingIfNeeded(response.data) } catch (error) { notifyTencentActionError(error, '领取兑换失败') diff --git a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts index 46d44acc..fedaaf43 100644 --- a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts +++ b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts @@ -1,6 +1,6 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue' -import { ElMessage } from 'element-plus' +import { showError, showSuccess } from '@/lib/feedback' import { createTencentBrowserSession, refreshTencentBrowserSessionPage, @@ -154,12 +154,12 @@ export function useTencentBrowserSessionPage() { async function redeemNow() { if (!session.value?.sessionId) { - ElMessage.error('请先创建浏览器会话') + showError('请先创建浏览器会话') return } if (!redeemCode.value.trim()) { - ElMessage.error('请输入兑换码') + showError('请输入兑换码') return } @@ -178,7 +178,7 @@ export function useTencentBrowserSessionPage() { } session.value = response.data - ElMessage.success(response.msg || '兑换完成') + showSuccess(response.msg || '兑换完成') } catch (error) { notifyTencentActionError(error, '浏览器兑换失败') await refreshSession({ silent: true }) diff --git a/apps/frontend/src/lib/feedback.ts b/apps/frontend/src/lib/feedback.ts new file mode 100644 index 00000000..74b160a1 --- /dev/null +++ b/apps/frontend/src/lib/feedback.ts @@ -0,0 +1,62 @@ +import { ElMessage, ElMessageBox } from 'element-plus' +import type { ElMessageBoxOptions, MessageOptions } from 'element-plus' + +import 'element-plus/theme-chalk/el-message.css' +import 'element-plus/theme-chalk/el-message-box.css' + +const baseMessageOptions: Partial = { + grouping: true, +} + +const baseConfirmOptions: ElMessageBoxOptions = { + type: 'warning', + confirmButtonText: '继续执行', + cancelButtonText: '取消', + closeOnClickModal: false, + closeOnPressEscape: false, + autofocus: false, + distinguishCancelAndClose: true, +} + +const basePromptOptions: ElMessageBoxOptions = { + confirmButtonText: '提交', + cancelButtonText: '取消', + closeOnClickModal: false, + closeOnPressEscape: false, + autofocus: false, + distinguishCancelAndClose: true, +} + +export function showSuccess(message: string, options: Partial = {}) { + return ElMessage.success({ + ...baseMessageOptions, + ...options, + message, + }) +} + +export function showError(message: string, options: Partial = {}) { + return ElMessage.error({ + ...baseMessageOptions, + ...options, + message, + }) +} + +export function showConfirm(message: string, title = '确认操作', options: ElMessageBoxOptions = {}) { + return ElMessageBox.confirm(message, title, { + ...baseConfirmOptions, + ...options, + }) +} + +export function showPrompt(message: string, title: string, options: ElMessageBoxOptions = {}) { + return ElMessageBox.prompt(message, title, { + ...basePromptOptions, + ...options, + }) +} + +export function isFeedbackDismissed(error: unknown) { + return error === 'cancel' || error === 'close' +} diff --git a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue index 45006f7a..2b8d6e4b 100644 --- a/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue +++ b/apps/frontend/src/views/admin/AdminFulfillmentBindingsView.vue @@ -1,7 +1,7 @@