新增 affiliate_dash 履约配置与商品映射(阶段 3)

- bootstrap CORE_PROFILES + routing ROUTABLE/DEFAULT_PRIORITY 接入 affiliate_dash
- planner 上下文 affiliateDash 块 + resolveDynamicAffiliateDashProfile
- product-resolution: 91 productNo→sku 映射匹配(skuMapping) + snapshot.affiliateDash
- prepare 前余额预检(getAffiliateDashWallet)
- AFFILIATE_DASH_SKU_MAPPING_JSON env 注入
- admin 后端接口(GET/POST 配置、match、products、wallet)+ 前端 Tabs 面板
- 联调:模拟 91 进单命中 affiliate_dash 路由, 未命中回退;前后端 typecheck + 212 测试通过
This commit is contained in:
yml2213
2026-08-05 15:08:41 +08:00
parent 867ffd2f3c
commit 3284fed7a1
16 changed files with 817 additions and 5 deletions
@@ -10,6 +10,7 @@ import type { JsonObject } from '../../../types/json.js'
import {
createAffiliateDashOrder,
getAffiliateDashOrder,
getAffiliateDashWallet,
type AffiliateDashOrder,
} from '../../platforms/affiliate-dash/order-service.js'
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.js'
@@ -60,6 +61,9 @@ export async function prepareAffiliateDashTask(task: TaskRow) {
const data = buildAffiliateDashOrderData(task, taskContext)
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
// 余额预检:钱包余额不足提前拦截(避免建单 400 后才降级);预检失败不阻塞下单
await preflightAffiliateDashWallet(flow.sku)
const order = await createAffiliateDashOrder({
clientOrderNo,
sku: flow.sku,
@@ -278,6 +282,31 @@ export function buildAffiliateDashClientOrderNo(task: TaskRow) {
return String(task.task_no || `OS-AD-${task.id}`).trim()
}
async function preflightAffiliateDashWallet(sku: string) {
try {
const wallet = await getAffiliateDashWallet()
if (wallet.availableBalance <= 0) {
throw createHttpError('affiliate-dash 钱包余额不足,请先充值', {
statusCode: 409,
errorCode: 'affiliate_dash_wallet_not_enough',
context: {
sku,
availableBalance: wallet.availableBalance,
currency: wallet.currency,
},
})
}
} catch (error) {
if ((error as { errorCode?: string })?.errorCode === 'affiliate_dash_wallet_not_enough') {
throw error
}
logIntegration('[affiliate-dash]', '余额预检失败(忽略,继续下单)', {
sku,
error: error instanceof Error ? error.message : String(error),
}, { level: 'warn' })
}
}
function buildAffiliateDashOrderData(task: TaskRow, context: Record<string, unknown>): JsonObject {
const identity = getClaimIdentityFromContext(context)
const data: JsonObject = {}
@@ -3,6 +3,7 @@ import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
isAffiliateDashExecutor,
isKuaishouCloudExecutor,
isKuaishouFeifeiExecutor,
} from './executors/types.js'
@@ -98,6 +99,7 @@ function buildFulfillmentTaskContext({
const itemSnapshot = parseJsonObject(item.item_snapshot_json)
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash)
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
@@ -223,6 +225,33 @@ function buildFulfillmentTaskContext({
lastSyncedAt: null,
}
: null,
affiliateDash: isAffiliateDashExecutor(executorKey)
? {
flowType: 'affiliate_dash',
sku: String(affiliateDashConfig.sku || '').trim(),
productName: String(
affiliateDashConfig.productName || item.sku_name || '',
).trim(),
orderNo: '',
clientOrderNo: '',
orderStatus: '',
canShip: false,
cannotShipReason: '',
providerOrderNo: '',
failureReason: '',
amount: 0,
currency: '',
bindUuid: '',
bindUrl: '',
qrUrl: '',
gameAccount: '',
expectedGameAccount: '',
bindMismatch: false,
submitStatus: '',
consumeStatus: 'pending',
lastSyncedAt: null,
}
: null,
}
}
@@ -240,6 +269,10 @@ async function resolveDynamicFulfillmentProfile(
return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) {
return resolveDynamicAffiliateDashProfile(item, getProfileByKey)
}
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
}
@@ -247,7 +280,8 @@ async function resolveDynamicFulfillmentProfile(
return (
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) ||
await resolveDynamicAffiliateDashProfile(item, getProfileByKey)
)
}
@@ -357,6 +391,45 @@ async function resolveDynamicKuaishouFeifeiProfile(
}
}
async function resolveDynamicAffiliateDashProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
): Promise<FulfillmentBindingLike | null> {
const snapshot = parseJsonObject(item.item_snapshot_json)
const affiliateDash = parseJsonObject(snapshot.affiliateDash)
const sku = String(affiliateDash.sku || '').trim()
if (!sku) {
return null
}
const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH)
if (!profile) {
return null
}
return {
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
profile_name: String(profile.profile_name || profile.name || 'affiliate-dash 履约').trim(),
executor_key: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
requires_claim: true,
auto_dispatch: false,
config_json: {
flowType: 'affiliate_dash',
configId: `affiliate_dash:${sku}`,
affiliateDash: {
sku,
productName: String(
affiliateDash.skuName || affiliateDash.productName || item.sku_name || '',
).trim(),
matchMode: String(affiliateDash.matchMode || 'affiliate_dash_sku_mapping').trim(),
},
notes: '91卡券商品自动匹配 affiliate-dash sku 映射',
},
}
}
async function resolveDynamicManualDispatchProfile(
item: OrderItemRow,
getProfileByKey: FulfillmentProfileResolver,
@@ -11,6 +11,7 @@ import {
resolveFulfillmentRoute,
type FulfillmentRouteResult,
} from './routing-config-service.js'
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
export type FulfillmentItem = {
@@ -36,6 +37,13 @@ type HasConfiguredOrderItemsInput = Omit<ResolveOrderItemForFulfillmentInput, 'i
items?: FulfillmentItem[]
}
export type AffiliateDashSkuMatch = {
sku: string
productNo: string
skuName: string
matchMode: 'affiliate_dash_sku_mapping'
}
type FulfillmentItemCandidate = {
externalItemId: string
externalSkuCode: string
@@ -44,6 +52,7 @@ type FulfillmentItemCandidate = {
resolvedSkuCode: string
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
affiliateDashMatch: AffiliateDashSkuMatch | null
fulfillmentRoute: FulfillmentRouteResult | null
isConfigured: boolean
}
@@ -76,17 +85,18 @@ export async function resolveOrderItemForFulfillment({
externalSkuNameNormalized,
cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
affiliateDashMatch,
fulfillmentRoute,
} = candidate
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
const resolvedSkuCode = pickFirstNonEmpty([
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
? cloudtentaclesNameMatch?.cloudSkuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.productCode
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
externalSkuCode,
externalItemId,
])
@@ -97,6 +107,9 @@ export async function resolveOrderItemForFulfillment({
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.skuName
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
item.skuName,
externalSkuName,
resolvedSkuCode,
@@ -113,7 +126,9 @@ export async function resolveOrderItemForFulfillment({
? cloudtentaclesNameMatch?.matchMode || ''
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.matchMode || ''
: '',
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.matchMode || ''
: '',
fulfillmentRoute,
cloudtentacles: cloudtentaclesNameMatch
? {
@@ -139,6 +154,14 @@ export async function resolveOrderItemForFulfillment({
skuName: kuaishouFeifeiMatch.skuName,
}
: null,
affiliateDash: affiliateDashMatch
? {
matchMode: affiliateDashMatch.matchMode,
productNo: affiliateDashMatch.productNo,
sku: affiliateDashMatch.sku,
skuName: affiliateDashMatch.skuName,
}
: null,
isConfigured: candidate.isConfigured,
}
@@ -210,6 +233,7 @@ async function resolveConfiguredItemCandidate({
if (isOpen91KuaishouOrder(provider, platform)) {
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
const affiliateDashMatch = resolveAffiliateDashSkuByProductNo(item)
const fulfillmentRoute = resolveFulfillmentRoute({
productName: externalSkuName,
candidates: [
@@ -223,6 +247,11 @@ async function resolveConfiguredItemCandidate({
available: Boolean(kuaishouFeifeiMatch),
reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则',
},
{
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
available: Boolean(affiliateDashMatch),
reason: affiliateDashMatch ? '' : '未命中 affiliate-dash sku 映射',
},
],
})
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
@@ -233,6 +262,9 @@ async function resolveConfiguredItemCandidate({
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
? kuaishouFeifeiMatch?.productCode
: '',
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
? affiliateDashMatch?.sku
: '',
externalSkuCode,
externalItemId,
])
@@ -245,6 +277,7 @@ async function resolveConfiguredItemCandidate({
resolvedSkuCode,
cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
affiliateDashMatch,
fulfillmentRoute,
isConfigured: Boolean(selectedExecutorKey),
}
@@ -263,11 +296,39 @@ async function resolveConfiguredItemCandidate({
resolvedSkuCode,
cloudtentaclesNameMatch: null,
kuaishouFeifeiMatch: null,
affiliateDashMatch: null,
fulfillmentRoute: null,
isConfigured: false,
}
}
function resolveAffiliateDashSkuByProductNo(item: FulfillmentItem): AffiliateDashSkuMatch | null {
const config = getAffiliateDashConfig()
if (config.enabled === false || !config.baseUrl) {
return null
}
const snapshot = isPlainObject(item.snapshot) ? item.snapshot : {}
const productNo = pickFirstNonEmpty([
snapshot.productNo,
item.externalSkuCode,
item.externalItemId,
])
const sku = productNo
? String(config.skuMapping[String(productNo)] || '').trim()
: ''
if (!productNo || !sku) {
return null
}
return {
sku,
productNo: String(productNo),
skuName: String(item.externalSkuName || '').trim(),
matchMode: 'affiliate_dash_sku_mapping',
}
}
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
return String(provider || '').trim() === '91kaquan' &&
String(platform || '').trim() === 'kuaishou'
@@ -67,12 +67,14 @@ const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join(
const ROUTABLE_EXECUTOR_KEYS: string[] = [
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
]
const DEFAULT_EXECUTOR_PRIORITY = [
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
]
export function getFulfillmentRoutingConfigFilePath() {