diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 675ea139..4d8b6607 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -17,6 +17,7 @@ type RuntimeConfigValue = | AdminDefaultUser[] | KuaishouFeifeiProductRule[] | string[] + | Record type RuntimeEnv = Record type EnvOverride = { @@ -182,6 +183,11 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ 'affiliateDash', 'timestampToleranceSeconds', ]), + affiliateDashSkuMappingEnv('AFFILIATE_DASH_SKU_MAPPING_JSON', [ + 'platforms', + 'affiliateDash', + 'skuMapping', + ]), 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 + } + } catch { + // 忽略非法 JSON + } + return null + }, + } +} + function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 6602ea2b..fedd8f53 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -7,6 +7,7 @@ import kuaishouFeifeiRouter from "./platform-config/kuaishou-feifei.js"; import ninetyoneRouter from "./platform-config/ninetyone.js"; import notificationsRouter from "./platform-config/notifications.js"; import fulfillmentRoutingRouter from "./platform-config/fulfillment-routing.js"; +import affiliateDashRouter from "./platform-config/affiliate-dash.js"; const router = Router(); @@ -17,5 +18,6 @@ router.use("/platform-config", kuaishouFeifeiRouter); router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", fulfillmentRoutingRouter); router.use("/platform-config", cloudtentaclesRouter); +router.use("/platform-config", affiliateDashRouter); export default router; diff --git a/apps/backend/src/routes/admin/platform-config/affiliate-dash.ts b/apps/backend/src/routes/admin/platform-config/affiliate-dash.ts new file mode 100644 index 00000000..d792f66e --- /dev/null +++ b/apps/backend/src/routes/admin/platform-config/affiliate-dash.ts @@ -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; diff --git a/apps/backend/src/services/admin/platform-config/affiliate-dash-service.ts b/apps/backend/src/services/admin/platform-config/affiliate-dash-service.ts new file mode 100644 index 00000000..ed0009a6 --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/affiliate-dash-service.ts @@ -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) { + 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, + } +} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index b3b2bb59..068a99c3 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -45,6 +45,15 @@ const CORE_PROFILES: CoreProfile[] = [ inventoryStrategy: 'external_platform', requirements: [], }, + { + profileKey: 'affiliate_dash', + name: 'affiliate-dash 履约', + executorKey: 'affiliate_dash', + requiresClaim: true, + autoDispatch: false, + inventoryStrategy: 'external_platform', + requirements: [], + }, ] export async function ensureFulfillmentCatalogBootstrapped() { diff --git a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts index 565a2ff0..ab0465c1 100644 --- a/apps/backend/src/services/fulfillment/affiliate-dash/index.ts +++ b/apps/backend/src/services/fulfillment/affiliate-dash/index.ts @@ -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): JsonObject { const identity = getClaimIdentityFromContext(context) const data: JsonObject = {} diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts index 080fc31d..38e70ead 100644 --- a/apps/backend/src/services/fulfillment/planner.ts +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -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 { + 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, diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index dc3d19cb..be38bb2e 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -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, }, + { + key: 'affiliate-dash', + label: 'affiliate-dash', + children: , + }, { key: 'kuaishou-cloud', label: 'kuaishou-lewan', @@ -1315,3 +1331,278 @@ function formatSkuOption(sku: AdminCloudtentaclesSkuItem) { function createRuleId() { 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([]) + const [matchInput, setMatchInput] = useState('') + const [matchResult, setMatchResult] = useState(null) + const [wallet, setWallet] = useState(null) + const [products, setProducts] = useState([]) + const [productTotal, setProductTotal] = useState(0) + const [productPage, setProductPage] = useState(1) + const [productsLoading, setProductsLoading] = useState(false) + + const skuMapping = skuRows.reduce>((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) { + setSkuRows((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row))) + } + + const productColumns: TableColumnsType = [ + { 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) => {v} }, + ] + + return ( + + + + + + } + > + {errorMessage ? : null} + + + + + + + 启用 + + + + BASE_URL + setBaseUrl(e.target.value)} placeholder="https://skin.khhao.com" /> + + + App Key + setAppKey(e.target.value)} placeholder="ak_..." /> + + + App Secret + setAppSecret(e.target.value)} placeholder="sk_..." /> + + + 回调 Secret + setCallbackSecret(e.target.value)} placeholder="cb_..." /> + + + 超时(ms) + setTimeoutMs(Number(v || 10000))} /> + 时间容差(s) + setTimestampToleranceSeconds(Number(v || 300))} /> + + + 回调 URL(展示) + {notifyUrl || '未配置'} + + {wallet ? ( + + 钱包余额 + {wallet.availableBalance} {wallet.currency} + (冻结 {wallet.frozenBalance}) + + ) : null} + + + + + + {skuRows.map((row, index) => ( + + updateSkuRow(index, { productNo: e.target.value })} + /> + updateSkuRow(index, { sku: e.target.value })} + /> + + + + + + + + setMatchInput(e.target.value)} + /> + + {matchResult ? ( + + {matchResult.matched + ? `命中 sku: ${matchResult.sku}` + : `未命中(productNo=${matchResult.productNo || '-'})`} + + ) : null} + + + rowKey="sku" + size="small" + loading={productsLoading} + columns={productColumns} + dataSource={products} + pagination={buildAdminLocalTablePagination({ + current: productPage, + pageSize: 20, + total: productTotal, + onChange: (page) => loadProducts(page), + })} + /> + + + + + + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/affiliate-dash.ts b/apps/frontend/src/services/admin/platform-config/affiliate-dash.ts new file mode 100644 index 00000000..7693abbc --- /dev/null +++ b/apps/frontend/src/services/admin/platform-config/affiliate-dash.ts @@ -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( + '/api/v1/admin/platform-config/affiliate-dash', + ) +} + +export function saveAdminAffiliateDashConfig(payload: AdminAffiliateDashConfig) { + return apiPost( + '/api/v1/admin/platform-config/affiliate-dash', + payload, + ) +} + +export function matchAdminAffiliateDashSku(productNo: string) { + return apiPost( + '/api/v1/admin/platform-config/affiliate-dash/match', + { productNo }, + ) +} + +export function fetchAdminAffiliateDashProducts(payload: { + page?: number + size?: number +} = {}) { + return apiPost( + '/api/v1/admin/platform-config/affiliate-dash/products', + payload, + ) +} + +export function fetchAdminAffiliateDashWallet() { + return apiPost( + '/api/v1/admin/platform-config/affiliate-dash/wallet', + {}, + ) +} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 1c1aaaa9..3b55a1da 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -2,5 +2,6 @@ export * from './notifications' export * from './scheduled-jobs' export * from './kuaishou-industry' export * from './kuaishou-feifei' +export * from './affiliate-dash' export * from './cloudtentacles' export * from './fulfillment-routing' diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index d922b34f..5e2fca5c 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -99,4 +99,11 @@ export type { AdminFulfillmentRoutingExecutorConfig, AdminFulfillmentRoutingPreviewResult, AdminFulfillmentRoutingRule, + AdminAffiliateDashConfig, + AdminAffiliateDashEffectiveConfig, + AdminAffiliateDashConfigResponse, + AdminAffiliateDashMatchResult, + AdminAffiliateDashProductItem, + AdminAffiliateDashProductListResult, + AdminAffiliateDashWallet, } from './platform-config' diff --git a/apps/frontend/src/types/admin/platform-config/affiliate-dash.ts b/apps/frontend/src/types/admin/platform-config/affiliate-dash.ts new file mode 100644 index 00000000..5fa885d1 --- /dev/null +++ b/apps/frontend/src/types/admin/platform-config/affiliate-dash.ts @@ -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 +} + +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 +} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index f337696f..ed6fe284 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -39,6 +39,16 @@ export type { AdminKuaishouFeifeiOrderResult, } from './kuaishou-feifei' +export type { + AdminAffiliateDashConfig, + AdminAffiliateDashEffectiveConfig, + AdminAffiliateDashConfigResponse, + AdminAffiliateDashMatchResult, + AdminAffiliateDashProductItem, + AdminAffiliateDashProductListResult, + AdminAffiliateDashWallet, +} from './affiliate-dash' + export type { AdminCloudtentaclesSourceItem, AdminCloudtentaclesSourcesConfig, diff --git a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md index b9d352f8..71adf1cd 100644 --- a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md +++ b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md @@ -468,3 +468,28 @@ order_site 处理要求: | GET /orders/{order_no}/delivery | `data` 透传(91单号 + game_account)正确 ✅ | **联调中发现并修复**:`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 测试通过。