新增 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:
@@ -17,6 +17,7 @@ type RuntimeConfigValue =
|
|||||||
| AdminDefaultUser[]
|
| AdminDefaultUser[]
|
||||||
| KuaishouFeifeiProductRule[]
|
| KuaishouFeifeiProductRule[]
|
||||||
| string[]
|
| string[]
|
||||||
|
| Record<string, string>
|
||||||
type RuntimeEnv = Record<string, string | undefined>
|
type RuntimeEnv = Record<string, string | undefined>
|
||||||
|
|
||||||
type EnvOverride = {
|
type EnvOverride = {
|
||||||
@@ -182,6 +183,11 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
'affiliateDash',
|
'affiliateDash',
|
||||||
'timestampToleranceSeconds',
|
'timestampToleranceSeconds',
|
||||||
]),
|
]),
|
||||||
|
affiliateDashSkuMappingEnv('AFFILIATE_DASH_SKU_MAPPING_JSON', [
|
||||||
|
'platforms',
|
||||||
|
'affiliateDash',
|
||||||
|
'skuMapping',
|
||||||
|
]),
|
||||||
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -268,6 +274,28 @@ function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function affiliateDashSkuMappingEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
path: configPath,
|
||||||
|
read(rawValue) {
|
||||||
|
const text = String(rawValue ?? '').trim()
|
||||||
|
if (!text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, string>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略非法 JSON
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||||
return {
|
return {
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js";
|
|||||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||||
import notificationsRouter from "./platform-config/notifications.js";
|
import notificationsRouter from "./platform-config/notifications.js";
|
||||||
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
|
import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js";
|
||||||
|
import affiliateDashRouter from "./platform-config/affiliate-dash.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -17,5 +18,6 @@ router.use("/platform-config", kuaishouFeifeiRouter);
|
|||||||
router.use("/platform-config", ninetyoneRouter);
|
router.use("/platform-config", ninetyoneRouter);
|
||||||
router.use("/platform-config", fulfillmentRoutingRouter);
|
router.use("/platform-config", fulfillmentRoutingRouter);
|
||||||
router.use("/platform-config", cloudtentaclesRouter);
|
router.use("/platform-config", cloudtentaclesRouter);
|
||||||
|
router.use("/platform-config", affiliateDashRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminAffiliateDashConfig,
|
||||||
|
getAdminAffiliateDashWallet,
|
||||||
|
listAdminAffiliateDashProducts,
|
||||||
|
matchAdminAffiliateDashSku,
|
||||||
|
updateAdminAffiliateDashConfig,
|
||||||
|
} from "../../../services/admin/platform-config/affiliate-dash-service.js";
|
||||||
|
import { createJsonHandler } from "../session.js";
|
||||||
|
import type { JsonRecord } from "../../../types/json.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/affiliate-dash",
|
||||||
|
createJsonHandler(() => getAdminAffiliateDashConfig(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取 affiliate-dash 配置失败",
|
||||||
|
scope: "[admin/platform-config/affiliate-dash]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/affiliate-dash",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => updateAdminAffiliateDashConfig(req.body as JsonRecord),
|
||||||
|
{
|
||||||
|
successMessage: "affiliate-dash 配置已保存",
|
||||||
|
errorMessage: "保存 affiliate-dash 配置失败",
|
||||||
|
scope: "[admin/platform-config/affiliate-dash]",
|
||||||
|
audit: (_req, data) => {
|
||||||
|
const result = data as JsonRecord;
|
||||||
|
const source = result.source as JsonRecord | undefined;
|
||||||
|
const effective = result.effective as JsonRecord | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "platform_affiliate_dash_config_updated",
|
||||||
|
targetType: "platform_config",
|
||||||
|
targetId: "affiliate_dash",
|
||||||
|
data: {
|
||||||
|
enabled: effective?.enabled !== false,
|
||||||
|
hasAppKey: Boolean(effective?.hasAppKey),
|
||||||
|
hasAppSecret: Boolean(effective?.hasAppSecret),
|
||||||
|
hasCallbackSecret: Boolean(effective?.hasCallbackSecret),
|
||||||
|
skuMappingCount: Number(source?.skuMapping && typeof source.skuMapping === "object"
|
||||||
|
? Object.keys(source.skuMapping).length
|
||||||
|
: 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/affiliate-dash/match",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => matchAdminAffiliateDashSku(req.body as JsonRecord),
|
||||||
|
{
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "匹配 affiliate-dash sku 失败",
|
||||||
|
scope: "[admin/platform-config/affiliate-dash/match]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/affiliate-dash/products",
|
||||||
|
createJsonHandler(
|
||||||
|
(req) => listAdminAffiliateDashProducts(req.body as JsonRecord),
|
||||||
|
{
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "查询 affiliate-dash 商品失败",
|
||||||
|
scope: "[admin/platform-config/affiliate-dash/products]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/affiliate-dash/wallet",
|
||||||
|
createJsonHandler(() => getAdminAffiliateDashWallet(), {
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "查询 affiliate-dash 钱包失败",
|
||||||
|
scope: "[admin/platform-config/affiliate-dash/wallet]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.js'
|
||||||
|
import type { JsonObject } from '../../../types/json.js'
|
||||||
|
import {
|
||||||
|
getAffiliateDashWallet,
|
||||||
|
} from '../../platforms/affiliate-dash/order-service.js'
|
||||||
|
import { listAffiliateDashProducts } from '../../platforms/affiliate-dash/product-service.js'
|
||||||
|
import {
|
||||||
|
getAffiliateDashSourceConfig,
|
||||||
|
hasAffiliateDashConfig,
|
||||||
|
normalizeAffiliateDashSourceConfig,
|
||||||
|
saveAffiliateDashSourceConfig,
|
||||||
|
} from '../../platforms/affiliate-dash/source-config-service.js'
|
||||||
|
|
||||||
|
export function getAdminAffiliateDashConfig() {
|
||||||
|
const source = getAdminEditableAffiliateDashConfig()
|
||||||
|
const effective = getAffiliateDashConfig()
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
effective: mapEffectiveAffiliateDashConfig(effective),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminAffiliateDashConfig(payload: JsonObject = {}) {
|
||||||
|
const saved = await saveAffiliateDashSourceConfig(payload)
|
||||||
|
const effective = getAffiliateDashConfig()
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: saved,
|
||||||
|
effective: mapEffectiveAffiliateDashConfig(effective),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 91 商品编码(productNo)测试 sku 映射命中。 */
|
||||||
|
export function matchAdminAffiliateDashSku(payload: JsonObject = {}) {
|
||||||
|
const productNo = String(payload.productNo || payload.product_no || '').trim()
|
||||||
|
const source = getAdminEditableAffiliateDashConfig()
|
||||||
|
const sku = productNo
|
||||||
|
? String(source.skuMapping[productNo] || '').trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
productNo,
|
||||||
|
sku,
|
||||||
|
matched: Boolean(productNo && sku),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminAffiliateDashProducts(payload: JsonObject = {}) {
|
||||||
|
return listAffiliateDashProducts({
|
||||||
|
page: Number(payload.page || 1) || 1,
|
||||||
|
size: Math.min(200, Math.max(1, Number(payload.size || 50) || 50)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminAffiliateDashWallet() {
|
||||||
|
return getAffiliateDashWallet()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminEditableAffiliateDashConfig() {
|
||||||
|
if (hasAffiliateDashConfig()) {
|
||||||
|
return getAffiliateDashSourceConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeAffiliateDashSourceConfig(getAffiliateDashConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEffectiveAffiliateDashConfig(config: ReturnType<typeof getAffiliateDashConfig>) {
|
||||||
|
return {
|
||||||
|
enabled: config.enabled !== false,
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
timeoutMs: config.timeoutMs,
|
||||||
|
notifyUrl: config.notifyUrl,
|
||||||
|
timestampToleranceSeconds: config.timestampToleranceSeconds,
|
||||||
|
hasAppKey: Boolean(config.appKey),
|
||||||
|
hasAppSecret: Boolean(config.appSecret),
|
||||||
|
hasCallbackSecret: Boolean(config.callbackSecret),
|
||||||
|
skuMappingCount: Object.keys(config.skuMapping).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ const CORE_PROFILES: CoreProfile[] = [
|
|||||||
inventoryStrategy: 'external_platform',
|
inventoryStrategy: 'external_platform',
|
||||||
requirements: [],
|
requirements: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
profileKey: 'affiliate_dash',
|
||||||
|
name: 'affiliate-dash 履约',
|
||||||
|
executorKey: 'affiliate_dash',
|
||||||
|
requiresClaim: true,
|
||||||
|
autoDispatch: false,
|
||||||
|
inventoryStrategy: 'external_platform',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { JsonObject } from '../../../types/json.js'
|
|||||||
import {
|
import {
|
||||||
createAffiliateDashOrder,
|
createAffiliateDashOrder,
|
||||||
getAffiliateDashOrder,
|
getAffiliateDashOrder,
|
||||||
|
getAffiliateDashWallet,
|
||||||
type AffiliateDashOrder,
|
type AffiliateDashOrder,
|
||||||
} from '../../platforms/affiliate-dash/order-service.js'
|
} from '../../platforms/affiliate-dash/order-service.js'
|
||||||
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.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 data = buildAffiliateDashOrderData(task, taskContext)
|
||||||
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
|
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
|
||||||
|
|
||||||
|
// 余额预检:钱包余额不足提前拦截(避免建单 400 后才降级);预检失败不阻塞下单
|
||||||
|
await preflightAffiliateDashWallet(flow.sku)
|
||||||
|
|
||||||
const order = await createAffiliateDashOrder({
|
const order = await createAffiliateDashOrder({
|
||||||
clientOrderNo,
|
clientOrderNo,
|
||||||
sku: flow.sku,
|
sku: flow.sku,
|
||||||
@@ -278,6 +282,31 @@ export function buildAffiliateDashClientOrderNo(task: TaskRow) {
|
|||||||
return String(task.task_no || `OS-AD-${task.id}`).trim()
|
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 {
|
function buildAffiliateDashOrderData(task: TaskRow, context: Record<string, unknown>): JsonObject {
|
||||||
const identity = getClaimIdentityFromContext(context)
|
const identity = getClaimIdentityFromContext(context)
|
||||||
const data: JsonObject = {}
|
const data: JsonObject = {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { JsonObject } from '../../types/json.js'
|
|||||||
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js'
|
||||||
import {
|
import {
|
||||||
FULFILLMENT_EXECUTOR_KEYS,
|
FULFILLMENT_EXECUTOR_KEYS,
|
||||||
|
isAffiliateDashExecutor,
|
||||||
isKuaishouCloudExecutor,
|
isKuaishouCloudExecutor,
|
||||||
isKuaishouFeifeiExecutor,
|
isKuaishouFeifeiExecutor,
|
||||||
} from './executors/types.js'
|
} from './executors/types.js'
|
||||||
@@ -98,6 +99,7 @@ function buildFulfillmentTaskContext({
|
|||||||
const itemSnapshot = parseJsonObject(item.item_snapshot_json)
|
const itemSnapshot = parseJsonObject(item.item_snapshot_json)
|
||||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||||
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
|
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
|
||||||
|
const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash)
|
||||||
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
||||||
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
||||||
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
|
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
|
||||||
@@ -223,6 +225,33 @@ function buildFulfillmentTaskContext({
|
|||||||
lastSyncedAt: null,
|
lastSyncedAt: null,
|
||||||
}
|
}
|
||||||
: 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)
|
return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) {
|
||||||
|
return resolveDynamicAffiliateDashProfile(item, getProfileByKey)
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
|
if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) {
|
||||||
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
|
return resolveDynamicManualDispatchProfile(item, getProfileByKey)
|
||||||
}
|
}
|
||||||
@@ -247,7 +280,8 @@ async function resolveDynamicFulfillmentProfile(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
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(
|
async function resolveDynamicManualDispatchProfile(
|
||||||
item: OrderItemRow,
|
item: OrderItemRow,
|
||||||
getProfileByKey: FulfillmentProfileResolver,
|
getProfileByKey: FulfillmentProfileResolver,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveFulfillmentRoute,
|
resolveFulfillmentRoute,
|
||||||
type FulfillmentRouteResult,
|
type FulfillmentRouteResult,
|
||||||
} from './routing-config-service.js'
|
} from './routing-config-service.js'
|
||||||
|
import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js'
|
||||||
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js'
|
||||||
|
|
||||||
export type FulfillmentItem = {
|
export type FulfillmentItem = {
|
||||||
@@ -36,6 +37,13 @@ type HasConfiguredOrderItemsInput = Omit<ResolveOrderItemForFulfillmentInput, 'i
|
|||||||
items?: FulfillmentItem[]
|
items?: FulfillmentItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AffiliateDashSkuMatch = {
|
||||||
|
sku: string
|
||||||
|
productNo: string
|
||||||
|
skuName: string
|
||||||
|
matchMode: 'affiliate_dash_sku_mapping'
|
||||||
|
}
|
||||||
|
|
||||||
type FulfillmentItemCandidate = {
|
type FulfillmentItemCandidate = {
|
||||||
externalItemId: string
|
externalItemId: string
|
||||||
externalSkuCode: string
|
externalSkuCode: string
|
||||||
@@ -44,6 +52,7 @@ type FulfillmentItemCandidate = {
|
|||||||
resolvedSkuCode: string
|
resolvedSkuCode: string
|
||||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||||
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
||||||
|
affiliateDashMatch: AffiliateDashSkuMatch | null
|
||||||
fulfillmentRoute: FulfillmentRouteResult | null
|
fulfillmentRoute: FulfillmentRouteResult | null
|
||||||
isConfigured: boolean
|
isConfigured: boolean
|
||||||
}
|
}
|
||||||
@@ -76,17 +85,18 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
kuaishouFeifeiMatch,
|
kuaishouFeifeiMatch,
|
||||||
|
affiliateDashMatch,
|
||||||
fulfillmentRoute,
|
fulfillmentRoute,
|
||||||
} = candidate
|
} = candidate
|
||||||
|
|
||||||
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
|
const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || ''
|
||||||
const resolvedSkuCode = pickFirstNonEmpty([
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD
|
|
||||||
? cloudtentaclesNameMatch?.cloudSkuName
|
|
||||||
: '',
|
|
||||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
? kuaishouFeifeiMatch?.productCode
|
? kuaishouFeifeiMatch?.productCode
|
||||||
: '',
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||||
|
? affiliateDashMatch?.sku
|
||||||
|
: '',
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
@@ -97,6 +107,9 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
? kuaishouFeifeiMatch?.skuName
|
? kuaishouFeifeiMatch?.skuName
|
||||||
: '',
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||||
|
? affiliateDashMatch?.sku
|
||||||
|
: '',
|
||||||
item.skuName,
|
item.skuName,
|
||||||
externalSkuName,
|
externalSkuName,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
@@ -113,7 +126,9 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
? cloudtentaclesNameMatch?.matchMode || ''
|
? cloudtentaclesNameMatch?.matchMode || ''
|
||||||
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
? kuaishouFeifeiMatch?.matchMode || ''
|
? kuaishouFeifeiMatch?.matchMode || ''
|
||||||
: '',
|
: selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||||
|
? affiliateDashMatch?.matchMode || ''
|
||||||
|
: '',
|
||||||
fulfillmentRoute,
|
fulfillmentRoute,
|
||||||
cloudtentacles: cloudtentaclesNameMatch
|
cloudtentacles: cloudtentaclesNameMatch
|
||||||
? {
|
? {
|
||||||
@@ -139,6 +154,14 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
skuName: kuaishouFeifeiMatch.skuName,
|
skuName: kuaishouFeifeiMatch.skuName,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
affiliateDash: affiliateDashMatch
|
||||||
|
? {
|
||||||
|
matchMode: affiliateDashMatch.matchMode,
|
||||||
|
productNo: affiliateDashMatch.productNo,
|
||||||
|
sku: affiliateDashMatch.sku,
|
||||||
|
skuName: affiliateDashMatch.skuName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
isConfigured: candidate.isConfigured,
|
isConfigured: candidate.isConfigured,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +233,7 @@ async function resolveConfiguredItemCandidate({
|
|||||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||||
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
|
const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||||
|
const affiliateDashMatch = resolveAffiliateDashSkuByProductNo(item)
|
||||||
const fulfillmentRoute = resolveFulfillmentRoute({
|
const fulfillmentRoute = resolveFulfillmentRoute({
|
||||||
productName: externalSkuName,
|
productName: externalSkuName,
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -223,6 +247,11 @@ async function resolveConfiguredItemCandidate({
|
|||||||
available: Boolean(kuaishouFeifeiMatch),
|
available: Boolean(kuaishouFeifeiMatch),
|
||||||
reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则',
|
reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||||
|
available: Boolean(affiliateDashMatch),
|
||||||
|
reason: affiliateDashMatch ? '' : '未命中 affiliate-dash sku 映射',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
|
const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey
|
||||||
@@ -233,6 +262,9 @@ async function resolveConfiguredItemCandidate({
|
|||||||
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI
|
||||||
? kuaishouFeifeiMatch?.productCode
|
? kuaishouFeifeiMatch?.productCode
|
||||||
: '',
|
: '',
|
||||||
|
selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH
|
||||||
|
? affiliateDashMatch?.sku
|
||||||
|
: '',
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
@@ -245,6 +277,7 @@ async function resolveConfiguredItemCandidate({
|
|||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
kuaishouFeifeiMatch,
|
kuaishouFeifeiMatch,
|
||||||
|
affiliateDashMatch,
|
||||||
fulfillmentRoute,
|
fulfillmentRoute,
|
||||||
isConfigured: Boolean(selectedExecutorKey),
|
isConfigured: Boolean(selectedExecutorKey),
|
||||||
}
|
}
|
||||||
@@ -263,11 +296,39 @@ async function resolveConfiguredItemCandidate({
|
|||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch: null,
|
cloudtentaclesNameMatch: null,
|
||||||
kuaishouFeifeiMatch: null,
|
kuaishouFeifeiMatch: null,
|
||||||
|
affiliateDashMatch: null,
|
||||||
fulfillmentRoute: null,
|
fulfillmentRoute: null,
|
||||||
isConfigured: false,
|
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) {
|
function isOpen91KuaishouOrder(provider: unknown, platform: unknown) {
|
||||||
return String(provider || '').trim() === '91kaquan' &&
|
return String(provider || '').trim() === '91kaquan' &&
|
||||||
String(platform || '').trim() === 'kuaishou'
|
String(platform || '').trim() === 'kuaishou'
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join(
|
|||||||
const ROUTABLE_EXECUTOR_KEYS: string[] = [
|
const ROUTABLE_EXECUTOR_KEYS: string[] = [
|
||||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||||
|
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||||
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH,
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEFAULT_EXECUTOR_PRIORITY = [
|
const DEFAULT_EXECUTOR_PRIORITY = [
|
||||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD,
|
||||||
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI,
|
||||||
|
FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getFulfillmentRoutingConfigFilePath() {
|
export function getFulfillmentRoutingConfigFilePath() {
|
||||||
|
|||||||
@@ -29,18 +29,28 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import PageHeader from '@/components/admin/PageHeader'
|
import PageHeader from '@/components/admin/PageHeader'
|
||||||
import { showError, showSuccess } from '@/lib/feedback'
|
import { showError, showSuccess } from '@/lib/feedback'
|
||||||
import {
|
import {
|
||||||
|
fetchAdminAffiliateDashConfig,
|
||||||
|
fetchAdminAffiliateDashProducts,
|
||||||
|
fetchAdminAffiliateDashWallet,
|
||||||
fetchAdminCloudtentaclesOverrideRules,
|
fetchAdminCloudtentaclesOverrideRules,
|
||||||
fetchAdminCloudtentaclesSkuList,
|
fetchAdminCloudtentaclesSkuList,
|
||||||
fetchAdminCloudtentaclesSourceConfig,
|
fetchAdminCloudtentaclesSourceConfig,
|
||||||
fetchAdminFulfillmentRoutingConfig,
|
fetchAdminFulfillmentRoutingConfig,
|
||||||
fetchAdminKuaishouFeifeiConfig,
|
fetchAdminKuaishouFeifeiConfig,
|
||||||
|
matchAdminAffiliateDashSku,
|
||||||
matchAdminKuaishouFeifeiProduct,
|
matchAdminKuaishouFeifeiProduct,
|
||||||
previewAdminFulfillmentRouting,
|
previewAdminFulfillmentRouting,
|
||||||
|
saveAdminAffiliateDashConfig,
|
||||||
saveAdminCloudtentaclesOverrideRules,
|
saveAdminCloudtentaclesOverrideRules,
|
||||||
saveAdminFulfillmentRoutingConfig,
|
saveAdminFulfillmentRoutingConfig,
|
||||||
syncAdminKuaishouFeifeiProducts,
|
syncAdminKuaishouFeifeiProducts,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type {
|
||||||
|
AdminAffiliateDashConfig,
|
||||||
|
AdminAffiliateDashConfigResponse,
|
||||||
|
AdminAffiliateDashMatchResult,
|
||||||
|
AdminAffiliateDashProductItem,
|
||||||
|
AdminAffiliateDashWallet,
|
||||||
AdminCloudtentaclesOverrideDeliveryItem,
|
AdminCloudtentaclesOverrideDeliveryItem,
|
||||||
AdminCloudtentaclesOverrideRule,
|
AdminCloudtentaclesOverrideRule,
|
||||||
AdminCloudtentaclesSessionItem,
|
AdminCloudtentaclesSessionItem,
|
||||||
@@ -72,6 +82,7 @@ type SourceRow = AdminCloudtentaclesSourceItem & {
|
|||||||
const ROUTING_EXECUTORS = [
|
const ROUTING_EXECUTORS = [
|
||||||
{ key: 'kuaishou_ct_assisted', label: 'kuaishou-lewan', color: 'blue' },
|
{ key: 'kuaishou_ct_assisted', label: 'kuaishou-lewan', color: 'blue' },
|
||||||
{ key: 'kuaishou_feifei', label: 'kuaishou-feifei', color: 'purple' },
|
{ key: 'kuaishou_feifei', label: 'kuaishou-feifei', color: 'purple' },
|
||||||
|
{ key: 'affiliate_dash', label: 'affiliate-dash', color: 'gold' },
|
||||||
{ key: 'manual_dispatch', label: '人工履约', color: 'orange' },
|
{ key: 'manual_dispatch', label: '人工履约', color: 'orange' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -102,6 +113,11 @@ export default function AdminPlatformFulfillmentPage() {
|
|||||||
label: 'kuaishou-feifei',
|
label: 'kuaishou-feifei',
|
||||||
children: <KuaishouFeifeiFulfillmentPanel />,
|
children: <KuaishouFeifeiFulfillmentPanel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'affiliate-dash',
|
||||||
|
label: 'affiliate-dash',
|
||||||
|
children: <AffiliateDashFulfillmentPanel />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kuaishou-cloud',
|
key: 'kuaishou-cloud',
|
||||||
label: 'kuaishou-lewan',
|
label: 'kuaishou-lewan',
|
||||||
@@ -1315,3 +1331,278 @@ function formatSkuOption(sku: AdminCloudtentaclesSkuItem) {
|
|||||||
function createRuleId() {
|
function createRuleId() {
|
||||||
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkuMappingRow = { productNo: string; sku: string }
|
||||||
|
|
||||||
|
function AffiliateDashFulfillmentPanel() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [enabled, setEnabled] = useState(true)
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [appKey, setAppKey] = useState('')
|
||||||
|
const [appSecret, setAppSecret] = useState('')
|
||||||
|
const [callbackSecret, setCallbackSecret] = useState('')
|
||||||
|
const [timeoutMs, setTimeoutMs] = useState(10000)
|
||||||
|
const [notifyUrl, setNotifyUrl] = useState('')
|
||||||
|
const [timestampToleranceSeconds, setTimestampToleranceSeconds] = useState(300)
|
||||||
|
const [skuRows, setSkuRows] = useState<SkuMappingRow[]>([])
|
||||||
|
const [matchInput, setMatchInput] = useState('')
|
||||||
|
const [matchResult, setMatchResult] = useState<AdminAffiliateDashMatchResult | null>(null)
|
||||||
|
const [wallet, setWallet] = useState<AdminAffiliateDashWallet | null>(null)
|
||||||
|
const [products, setProducts] = useState<AdminAffiliateDashProductItem[]>([])
|
||||||
|
const [productTotal, setProductTotal] = useState(0)
|
||||||
|
const [productPage, setProductPage] = useState(1)
|
||||||
|
const [productsLoading, setProductsLoading] = useState(false)
|
||||||
|
|
||||||
|
const skuMapping = skuRows.reduce<Record<string, string>>((acc, row) => {
|
||||||
|
if (row.productNo.trim() && row.sku.trim()) {
|
||||||
|
acc[row.productNo.trim()] = row.sku.trim()
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig()
|
||||||
|
void loadWallet()
|
||||||
|
void loadProducts(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
setLoading(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminAffiliateDashConfig()
|
||||||
|
hydrateConfig(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : '读取 affiliate-dash 配置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateConfig(response: AdminAffiliateDashConfigResponse) {
|
||||||
|
const source = response.source
|
||||||
|
setEnabled(source.enabled !== false)
|
||||||
|
setBaseUrl(source.baseUrl || '')
|
||||||
|
setAppKey(source.appKey || '')
|
||||||
|
setAppSecret(source.appSecret || '')
|
||||||
|
setCallbackSecret(source.callbackSecret || '')
|
||||||
|
setTimeoutMs(Number(source.timeoutMs || 10000))
|
||||||
|
setNotifyUrl(source.notifyUrl || '')
|
||||||
|
setTimestampToleranceSeconds(Number(source.timestampToleranceSeconds || 300))
|
||||||
|
setSkuRows(
|
||||||
|
Object.entries(source.skuMapping || {}).map(([productNo, sku]) => ({
|
||||||
|
productNo,
|
||||||
|
sku: String(sku || ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
setSaving(true)
|
||||||
|
setErrorMessage('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: AdminAffiliateDashConfig = {
|
||||||
|
enabled,
|
||||||
|
baseUrl,
|
||||||
|
appKey,
|
||||||
|
appSecret,
|
||||||
|
callbackSecret,
|
||||||
|
timeoutMs,
|
||||||
|
notifyUrl,
|
||||||
|
timestampToleranceSeconds,
|
||||||
|
skuMapping,
|
||||||
|
}
|
||||||
|
const response = await saveAdminAffiliateDashConfig(payload)
|
||||||
|
hydrateConfig(response.data)
|
||||||
|
showSuccess('affiliate-dash 配置已保存')
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '保存 affiliate-dash 配置失败'
|
||||||
|
setErrorMessage(message)
|
||||||
|
showError(message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewMatch() {
|
||||||
|
const productNo = matchInput.trim()
|
||||||
|
if (!productNo) {
|
||||||
|
showError('请输入 91 商品编码(productNo)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatchResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await matchAdminAffiliateDashSku(productNo)
|
||||||
|
setMatchResult(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '预览 affiliate-dash sku 映射失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWallet() {
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminAffiliateDashWallet()
|
||||||
|
setWallet(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '查询 affiliate-dash 钱包失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProducts(page: number) {
|
||||||
|
setProductsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminAffiliateDashProducts({ page, size: 20 })
|
||||||
|
setProducts(response.data.list)
|
||||||
|
setProductTotal(response.data.total)
|
||||||
|
setProductPage(response.data.page)
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '拉取 affiliate-dash 商品失败')
|
||||||
|
} finally {
|
||||||
|
setProductsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSkuRow(index: number, patch: Partial<SkuMappingRow>) {
|
||||||
|
setSkuRows((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const productColumns: TableColumnsType<AdminAffiliateDashProductItem> = [
|
||||||
|
{ title: 'sku', dataIndex: 'sku', key: 'sku', width: 180 },
|
||||||
|
{ title: '名称', dataIndex: 'displayName', key: 'displayName' },
|
||||||
|
{ title: '单价', dataIndex: 'priceAmount', key: 'priceAmount', width: 90 },
|
||||||
|
{ title: '库存', dataIndex: 'stock', key: 'stock', width: 70, render: (v: number) => (v === -1 ? '不限' : v) },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v}</Tag> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="affiliate-dash 发货平台"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={loadWallet}>刷新钱包</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => loadProducts(productPage)}>刷新商品</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={saveConfig}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{errorMessage ? <Alert type="error" message={errorMessage} showIcon style={{ marginBottom: 16 }} /> : null}
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<Card size="small" title="对接配置">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space>
|
||||||
|
<Typography.Text>启用</Typography.Text>
|
||||||
|
<Switch checked={enabled} onChange={setEnabled} />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>BASE_URL</span>
|
||||||
|
<Input style={{ width: 320 }} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://skin.khhao.com" />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>App Key</span>
|
||||||
|
<Input style={{ width: 320 }} value={appKey} onChange={(e) => setAppKey(e.target.value)} placeholder="ak_..." />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>App Secret</span>
|
||||||
|
<Input.Password style={{ width: 320 }} value={appSecret} onChange={(e) => setAppSecret(e.target.value)} placeholder="sk_..." />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>回调 Secret</span>
|
||||||
|
<Input.Password style={{ width: 320 }} value={callbackSecret} onChange={(e) => setCallbackSecret(e.target.value)} placeholder="cb_..." />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>超时(ms)</span>
|
||||||
|
<InputNumber min={1000} value={timeoutMs} onChange={(v) => setTimeoutMs(Number(v || 10000))} />
|
||||||
|
<span>时间容差(s)</span>
|
||||||
|
<InputNumber min={1} value={timestampToleranceSeconds} onChange={(v) => setTimestampToleranceSeconds(Number(v || 300))} />
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>回调 URL(展示)</span>
|
||||||
|
<Typography.Text type="secondary">{notifyUrl || '未配置'}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{wallet ? (
|
||||||
|
<Space wrap>
|
||||||
|
<span>钱包余额</span>
|
||||||
|
<Typography.Text strong>{wallet.availableBalance} {wallet.currency}</Typography.Text>
|
||||||
|
<span>(冻结 {wallet.frozenBalance})</span>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="91 商品编码 → affiliate_dash sku 映射">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{skuRows.map((row, index) => (
|
||||||
|
<Space key={`${index}_${row.productNo}`} wrap>
|
||||||
|
<Input
|
||||||
|
style={{ width: 240 }}
|
||||||
|
placeholder="91 商品编码(productNo)"
|
||||||
|
value={row.productNo}
|
||||||
|
onChange={(e) => updateSkuRow(index, { productNo: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ width: 240 }}
|
||||||
|
placeholder="affiliate_dash sku"
|
||||||
|
value={row.sku}
|
||||||
|
onChange={(e) => updateSkuRow(index, { sku: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => setSkuRows((rows) => rows.filter((_, i) => i !== index))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button icon={<PlusOutlined />} onClick={() => setSkuRows((rows) => [...rows, { productNo: '', sku: '' }])}>
|
||||||
|
新增映射
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" title="映射测试与商品目录">
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Input
|
||||||
|
style={{ width: 240 }}
|
||||||
|
placeholder="输入 91 商品编码测试"
|
||||||
|
value={matchInput}
|
||||||
|
onChange={(e) => setMatchInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button icon={<SearchOutlined />} onClick={previewMatch}>测试映射</Button>
|
||||||
|
{matchResult ? (
|
||||||
|
<Typography.Text type={matchResult.matched ? 'success' : 'warning'}>
|
||||||
|
{matchResult.matched
|
||||||
|
? `命中 sku: ${matchResult.sku}`
|
||||||
|
: `未命中(productNo=${matchResult.productNo || '-'})`}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Table<AdminAffiliateDashProductItem>
|
||||||
|
rowKey="sku"
|
||||||
|
size="small"
|
||||||
|
loading={productsLoading}
|
||||||
|
columns={productColumns}
|
||||||
|
dataSource={products}
|
||||||
|
pagination={buildAdminLocalTablePagination({
|
||||||
|
current: productPage,
|
||||||
|
pageSize: 20,
|
||||||
|
total: productTotal,
|
||||||
|
onChange: (page) => loadProducts(page),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { apiGet, apiPost } from '@/lib/http'
|
||||||
|
import type {
|
||||||
|
AdminAffiliateDashConfig,
|
||||||
|
AdminAffiliateDashConfigResponse,
|
||||||
|
AdminAffiliateDashMatchResult,
|
||||||
|
AdminAffiliateDashProductListResult,
|
||||||
|
AdminAffiliateDashWallet,
|
||||||
|
} from '@/types/admin'
|
||||||
|
|
||||||
|
export function fetchAdminAffiliateDashConfig() {
|
||||||
|
return apiGet<AdminAffiliateDashConfigResponse>(
|
||||||
|
'/api/v1/admin/platform-config/affiliate-dash',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdminAffiliateDashConfig(payload: AdminAffiliateDashConfig) {
|
||||||
|
return apiPost<AdminAffiliateDashConfigResponse>(
|
||||||
|
'/api/v1/admin/platform-config/affiliate-dash',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchAdminAffiliateDashSku(productNo: string) {
|
||||||
|
return apiPost<AdminAffiliateDashMatchResult>(
|
||||||
|
'/api/v1/admin/platform-config/affiliate-dash/match',
|
||||||
|
{ productNo },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminAffiliateDashProducts(payload: {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
} = {}) {
|
||||||
|
return apiPost<AdminAffiliateDashProductListResult>(
|
||||||
|
'/api/v1/admin/platform-config/affiliate-dash/products',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminAffiliateDashWallet() {
|
||||||
|
return apiPost<AdminAffiliateDashWallet>(
|
||||||
|
'/api/v1/admin/platform-config/affiliate-dash/wallet',
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ export * from './notifications'
|
|||||||
export * from './scheduled-jobs'
|
export * from './scheduled-jobs'
|
||||||
export * from './kuaishou-industry'
|
export * from './kuaishou-industry'
|
||||||
export * from './kuaishou-feifei'
|
export * from './kuaishou-feifei'
|
||||||
|
export * from './affiliate-dash'
|
||||||
export * from './cloudtentacles'
|
export * from './cloudtentacles'
|
||||||
export * from './fulfillment-routing'
|
export * from './fulfillment-routing'
|
||||||
|
|||||||
@@ -99,4 +99,11 @@ export type {
|
|||||||
AdminFulfillmentRoutingExecutorConfig,
|
AdminFulfillmentRoutingExecutorConfig,
|
||||||
AdminFulfillmentRoutingPreviewResult,
|
AdminFulfillmentRoutingPreviewResult,
|
||||||
AdminFulfillmentRoutingRule,
|
AdminFulfillmentRoutingRule,
|
||||||
|
AdminAffiliateDashConfig,
|
||||||
|
AdminAffiliateDashEffectiveConfig,
|
||||||
|
AdminAffiliateDashConfigResponse,
|
||||||
|
AdminAffiliateDashMatchResult,
|
||||||
|
AdminAffiliateDashProductItem,
|
||||||
|
AdminAffiliateDashProductListResult,
|
||||||
|
AdminAffiliateDashWallet,
|
||||||
} from './platform-config'
|
} from './platform-config'
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export interface AdminAffiliateDashConfig {
|
||||||
|
enabled: boolean
|
||||||
|
baseUrl: string
|
||||||
|
appKey: string
|
||||||
|
appSecret: string
|
||||||
|
callbackSecret: string
|
||||||
|
timeoutMs: number
|
||||||
|
notifyUrl: string
|
||||||
|
timestampToleranceSeconds: number
|
||||||
|
skuMapping: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashEffectiveConfig {
|
||||||
|
enabled: boolean
|
||||||
|
baseUrl: string
|
||||||
|
timeoutMs: number
|
||||||
|
notifyUrl: string
|
||||||
|
timestampToleranceSeconds: number
|
||||||
|
hasAppKey: boolean
|
||||||
|
hasAppSecret: boolean
|
||||||
|
hasCallbackSecret: boolean
|
||||||
|
skuMappingCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashConfigResponse {
|
||||||
|
source: AdminAffiliateDashConfig
|
||||||
|
effective: AdminAffiliateDashEffectiveConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashMatchResult {
|
||||||
|
productNo: string
|
||||||
|
sku: string
|
||||||
|
matched: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashProductItem {
|
||||||
|
sku: string
|
||||||
|
displayName: string
|
||||||
|
priceAmount: number
|
||||||
|
costAmount: number
|
||||||
|
currency: string
|
||||||
|
stock: number
|
||||||
|
status: string
|
||||||
|
category: string
|
||||||
|
code: string
|
||||||
|
productId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashProductListResult {
|
||||||
|
list: AdminAffiliateDashProductItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAffiliateDashWallet {
|
||||||
|
availableBalance: number
|
||||||
|
frozenBalance: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
@@ -39,6 +39,16 @@ export type {
|
|||||||
AdminKuaishouFeifeiOrderResult,
|
AdminKuaishouFeifeiOrderResult,
|
||||||
} from './kuaishou-feifei'
|
} from './kuaishou-feifei'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AdminAffiliateDashConfig,
|
||||||
|
AdminAffiliateDashEffectiveConfig,
|
||||||
|
AdminAffiliateDashConfigResponse,
|
||||||
|
AdminAffiliateDashMatchResult,
|
||||||
|
AdminAffiliateDashProductItem,
|
||||||
|
AdminAffiliateDashProductListResult,
|
||||||
|
AdminAffiliateDashWallet,
|
||||||
|
} from './affiliate-dash'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AdminCloudtentaclesSourceItem,
|
AdminCloudtentaclesSourceItem,
|
||||||
AdminCloudtentaclesSourcesConfig,
|
AdminCloudtentaclesSourcesConfig,
|
||||||
|
|||||||
@@ -468,3 +468,28 @@ order_site 处理要求:
|
|||||||
| GET /orders/{order_no}/delivery | `data` 透传(91单号 + game_account)正确 ✅ |
|
| GET /orders/{order_no}/delivery | `data` 透传(91单号 + game_account)正确 ✅ |
|
||||||
|
|
||||||
**联调中发现并修复**:`POST /orders` 响应是 `data.order`(非 `data` 直接),首次实现 map 错了层级导致 orderNo 全空;已改为取 `data.order` 并暴露 `idempotent` 标志。幂等校验要求**同单号参数完全一致**,否则 400「参数与原订单不一致」——重试时必须复用原参数。
|
**联调中发现并修复**:`POST /orders` 响应是 `data.order`(非 `data` 直接),首次实现 map 错了层级导致 orderNo 全空;已改为取 `data.order` 并暴露 `idempotent` 标志。幂等校验要求**同单号参数完全一致**,否则 400「参数与原订单不一致」——重试时必须复用原参数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 阶段 3 落地记录(v2.3 · 已完成)
|
||||||
|
|
||||||
|
产出文件(`apps/backend/src/` + `apps/frontend/src/`):
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `services/bootstrap/fulfillment-bootstrap-service.ts` | `CORE_PROFILES` 追加 affiliate_dash profile(requiresClaim: true, external_platform) |
|
||||||
|
| `services/fulfillment/routing-config-service.ts` | `ROUTABLE_EXECUTOR_KEYS` + `DEFAULT_EXECUTOR_PRIORITY` 加 `AFFILIATE_DASH`(路由可命中) |
|
||||||
|
| `services/fulfillment/planner.ts` | `buildFulfillmentTaskContext` 加 `affiliateDash` 块(flowType/sku/productName/orderNo/... 与 `AffiliateDashFlow` 对齐);`resolveDynamicFulfillmentProfile` 加 AFFILIATE_DASH 分支 + 新增 `resolveDynamicAffiliateDashProfile`(configId `affiliate_dash:${sku}`) |
|
||||||
|
| `services/fulfillment/product-resolution-service.ts` | `resolveConfiguredItemCandidate` 91 分支加 affiliate_dash 候选(`resolveAffiliateDashSkuByProductNo`,key=snapshot.productNo)+ resolvedSkuCode/resolvedSkuName/snapshot.affiliateDash/matchMode |
|
||||||
|
| `services/fulfillment/affiliate-dash/index.ts` | `prepareAffiliateDashTask` 下单前 `preflightAffiliateDashWallet`(余额 ≤0 → `affiliate_dash_wallet_not_enough` 提前拦截;查询失败不阻塞) |
|
||||||
|
| `config/env-overrides.ts` | `AFFILIATE_DASH_SKU_MAPPING_JSON` env 注入(仿 KUAISHOU_FEIFEI_PRODUCT_RULES_JSON) |
|
||||||
|
| `routes/admin/platform-config/affiliate-dash.ts`(新)+ `platform-config.ts` | GET/POST 配置、POST match(productNo→sku)、POST products、POST wallet |
|
||||||
|
| `services/admin/platform-config/affiliate-dash-service.ts`(新) | get/update/match/listProducts/wallet;audit 不落密钥 |
|
||||||
|
| 前端 `types/admin/.../affiliate-dash.ts` + `services/admin/platform-config/affiliate-dash.ts` + 两处 index export | 类型与 API 封装 |
|
||||||
|
| 前端 `AdminPlatformFulfillmentPage.tsx` | `ROUTING_EXECUTORS` 加 affiliate_dash(gold)+ Tabs 面板 `AffiliateDashFulfillmentPanel`(配置表单/密钥、skuMapping 行编辑、映射测试、商品目录、钱包余额) |
|
||||||
|
|
||||||
|
**联调验证(模拟 91 进单 → resolveOrderItemForFulfillment)**:
|
||||||
|
- `productNo='X91-LUCKY-90'` + skuMapping 命中 → `selectedExecutorKey='affiliate_dash'`、`matchMode='affiliate_dash_sku_mapping'`、`resolvedSkuCode='lucky_coin_x90'`、snapshot.affiliateDash 完整 ✅
|
||||||
|
- 未命中映射的商品 → 不选 affiliate_dash,回退 ✅
|
||||||
|
|
||||||
|
**验证**:前后端 typecheck 通过;后端全量 212 测试通过。
|
||||||
|
|||||||
Reference in New Issue
Block a user