From a6ba5f1c7ffae79155cc4dc60af42e9264fd2888 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 5 Aug 2026 14:51:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20affiliate=5Fdash=20?= =?UTF-8?q?=E5=8F=91=E8=B4=A7=E5=B9=B3=E5=8F=B0=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E7=AD=BE=E5=90=8D(=E9=98=B6=E6=AE=B5=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台配置接入:runtime-config/types/defaults/env-overrides/app-config-keys 增加 affiliateDash - affiliate-dash 平台客户端:请求签名(§3.1 app_key+body_sha256)、回调验签(§3.2 timingSafeEqual)、 http-client 封装(超时/错误归一/全链路日志)、订单/交付/钱包/商品接口封装 - 单测 12/12 + 全量 211 通过,真实联调拉取线上商品 33 个验证签名链路 - 新增 affiliate_dash 对接文档 v2(含契约验证结论与深度分析) --- apps/backend/src/config/app-config-keys.ts | 1 + apps/backend/src/config/defaults.ts | 11 + apps/backend/src/config/env-overrides.ts | 11 + .../platforms/affiliate-dash/config.ts | 125 +++++ .../affiliate-dash/http-client.test.ts | 11 + .../platforms/affiliate-dash/http-client.ts | 230 +++++++++ .../platforms/affiliate-dash/order-service.ts | 239 ++++++++++ .../affiliate-dash/product-service.ts | 80 ++++ .../platforms/affiliate-dash/sign.test.ts | 118 +++++ .../services/platforms/affiliate-dash/sign.ts | 111 +++++ .../affiliate-dash/source-config-service.ts | 98 ++++ .../affiliate-dash/verify-callback.test.ts | 117 +++++ .../affiliate-dash/verify-callback.ts | 115 +++++ apps/backend/src/types/runtime-config.ts | 13 + .../affiliate-dash对接.md | 445 ++++++++++++++++++ 15 files changed, 1725 insertions(+) create mode 100644 apps/backend/src/services/platforms/affiliate-dash/config.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/http-client.test.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/http-client.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/order-service.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/product-service.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/sign.test.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/sign.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/source-config-service.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/verify-callback.test.ts create mode 100644 apps/backend/src/services/platforms/affiliate-dash/verify-callback.ts create mode 100644 docs/ affiliate_dash_发货平台/affiliate-dash对接.md diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index d36a2ed5..a706bf3b 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -8,4 +8,5 @@ export const APP_CONFIG_KEYS = { cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', kuaishouFeifei: 'kuaishou_feifei', kuaishouIndustrySource: 'kuaishou_industry_source', + affiliateDash: 'affiliate_dash', } as const diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 96ee04a2..5520261d 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -75,6 +75,17 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { notifyUrl: '', productRules: [], }, + affiliateDash: { + enabled: true, + baseUrl: '', + appKey: '', + appSecret: '', + callbackSecret: '', + timeoutMs: 10000, + notifyUrl: '', + timestampToleranceSeconds: 300, + skuMapping: {}, + }, cloudtentacles: { baseUrl: 'https://123.207.217.176', timeoutMs: 5000, diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index c5ad02d9..675ea139 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -171,6 +171,17 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ 'kuaishouFeifei', 'productRules', ]), + stringEnv('AFFILIATE_DASH_BASE_URL', ['platforms', 'affiliateDash', 'baseUrl']), + stringEnv('AFFILIATE_DASH_APP_KEY', ['platforms', 'affiliateDash', 'appKey']), + stringEnv('AFFILIATE_DASH_APP_SECRET', ['platforms', 'affiliateDash', 'appSecret']), + stringEnv('AFFILIATE_DASH_CALLBACK_SECRET', ['platforms', 'affiliateDash', 'callbackSecret']), + integerEnv('AFFILIATE_DASH_TIMEOUT_MS', ['platforms', 'affiliateDash', 'timeoutMs']), + stringEnv('AFFILIATE_DASH_NOTIFY_URL', ['platforms', 'affiliateDash', 'notifyUrl']), + integerEnv('AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS', [ + 'platforms', + 'affiliateDash', + 'timestampToleranceSeconds', + ]), corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']), ] diff --git a/apps/backend/src/services/platforms/affiliate-dash/config.ts b/apps/backend/src/services/platforms/affiliate-dash/config.ts new file mode 100644 index 00000000..ebea0645 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/config.ts @@ -0,0 +1,125 @@ +import { runtimeConfig } from '../../../config/runtime.js' +import { createHttpError } from '../../../utils/http.js' +import type { AffiliateDashSkuMapping, RuntimeConfig } from '../../../types/runtime-config.js' +import { + getAffiliateDashSourceConfig, + hasAffiliateDashConfig, + type AffiliateDashSourceConfig, +} from './source-config-service.js' + +export const AFFILIATE_DASH_EXECUTOR_KEY = 'affiliate_dash' +export const AFFILIATE_DASH_PROFILE_KEY = 'affiliate_dash' +export const AFFILIATE_DASH_WEBHOOK_PATH = '/api/v1/open/affiliate-dash' +export const AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS = 300 + +type AffiliateDashRuntimeConfig = RuntimeConfig['platforms']['affiliateDash'] + +export function getAffiliateDashConfig(overrides: Partial = {}) { + const runtimeValue = runtimeConfig.platforms?.affiliateDash || {} + const savedValue = hasAffiliateDashConfig() ? getAffiliateDashSourceConfig() : null + const config = mergeAffiliateDashConfig(runtimeValue, savedValue, overrides) + + return { + enabled: config.enabled !== false, + baseUrl: String(config.baseUrl || '').trim().replace(/\/+$/, ''), + appKey: String(config.appKey || '').trim(), + appSecret: String(config.appSecret || '').trim(), + callbackSecret: String(config.callbackSecret || '').trim(), + timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000), + notifyUrl: String(config.notifyUrl || '').trim(), + timestampToleranceSeconds: + Math.max(1, Number(config.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS) || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS), + skuMapping: isRecord(config.skuMapping) ? config.skuMapping : {}, + } +} + +export function assertAffiliateDashConfig() { + const config = getAffiliateDashConfig() + + if (config.enabled === false) { + throw createHttpError('affiliate-dash 已停用', { + statusCode: 409, + errorCode: 'affiliate_dash_disabled', + }) + } + + if (!config.baseUrl) { + throw createHttpError('affiliate-dash baseUrl 未配置', { + statusCode: 500, + errorCode: 'affiliate_dash_missing_base_url', + }) + } + + if (!config.appKey || !config.appSecret) { + throw createHttpError('affiliate-dash App Key / App Secret 未配置', { + statusCode: 500, + errorCode: 'affiliate_dash_missing_credential', + }) + } + + return config +} + +function mergeAffiliateDashConfig( + runtimeValue: Partial, + savedValue: Partial | null, + overrides: Partial, +): AffiliateDashRuntimeConfig { + const base: AffiliateDashRuntimeConfig = savedValue + ? { + enabled: savedValue.enabled !== false, + baseUrl: savedValue.baseUrl || runtimeValue.baseUrl || '', + appKey: savedValue.appKey || runtimeValue.appKey || '', + appSecret: savedValue.appSecret || runtimeValue.appSecret || '', + callbackSecret: savedValue.callbackSecret || runtimeValue.callbackSecret || '', + timeoutMs: savedValue.timeoutMs || runtimeValue.timeoutMs || 10000, + notifyUrl: savedValue.notifyUrl || runtimeValue.notifyUrl || '', + timestampToleranceSeconds: + savedValue.timestampToleranceSeconds || + runtimeValue.timestampToleranceSeconds || + AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS, + skuMapping: pickSkuMapping(savedValue.skuMapping, runtimeValue.skuMapping), + } + : { + enabled: runtimeValue.enabled !== false, + baseUrl: runtimeValue.baseUrl || '', + appKey: runtimeValue.appKey || '', + appSecret: runtimeValue.appSecret || '', + callbackSecret: runtimeValue.callbackSecret || '', + timeoutMs: runtimeValue.timeoutMs || 10000, + notifyUrl: runtimeValue.notifyUrl || '', + timestampToleranceSeconds: + runtimeValue.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS, + skuMapping: pickSkuMapping(undefined, runtimeValue.skuMapping), + } + + return { + enabled: overrides.enabled ?? base.enabled, + baseUrl: overrides.baseUrl ?? base.baseUrl, + appKey: overrides.appKey ?? base.appKey, + appSecret: overrides.appSecret ?? base.appSecret, + callbackSecret: overrides.callbackSecret ?? base.callbackSecret, + timeoutMs: overrides.timeoutMs ?? base.timeoutMs, + notifyUrl: overrides.notifyUrl ?? base.notifyUrl, + timestampToleranceSeconds: + overrides.timestampToleranceSeconds ?? base.timestampToleranceSeconds, + skuMapping: pickSkuMapping(overrides.skuMapping, base.skuMapping), + } +} + +function pickSkuMapping( + preferred?: AffiliateDashSkuMapping, + fallback?: AffiliateDashSkuMapping, +): AffiliateDashSkuMapping { + if (preferred && Object.keys(preferred).length) { + return preferred + } + if (fallback && Object.keys(fallback).length) { + return fallback + } + return {} +} + +function isRecord(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/http-client.test.ts b/apps/backend/src/services/platforms/affiliate-dash/http-client.test.ts new file mode 100644 index 00000000..7ecdc4fe --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/http-client.test.ts @@ -0,0 +1,11 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { isAffiliateDashSuccessResponse } from './http-client.js' + +test('isAffiliateDashSuccessResponse accepts code 0 and rejects others', () => { + assert.equal(isAffiliateDashSuccessResponse({ code: 0, message: 'ok' }), true) + assert.equal(isAffiliateDashSuccessResponse({ code: 0 }), true) + assert.equal(isAffiliateDashSuccessResponse({ code: 1, message: '余额不足' }), false) + assert.equal(isAffiliateDashSuccessResponse({ code: 40000, message: '签名校验失败' }), false) +}) diff --git a/apps/backend/src/services/platforms/affiliate-dash/http-client.ts b/apps/backend/src/services/platforms/affiliate-dash/http-client.ts new file mode 100644 index 00000000..7e9f55a1 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/http-client.ts @@ -0,0 +1,230 @@ +import { asJsonObject, type JsonObject } from '../../../types/json.js' + +import { createHttpError } from '../../../utils/http.js' +import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js' +import { assertAffiliateDashConfig } from './config.js' +import { createClientSignHeaders } from './sign.js' + +export type AffiliateDashRequestMethod = 'GET' | 'POST' + +/** + * affiliate_dash 商户开放接口请求封装。 + * 自动生成 X-App-Key / X-Timestamp / X-Nonce / X-Sign,统一超时与错误归一。 + */ +export async function affiliateDashRequest(input: { + method: AffiliateDashRequestMethod + pathname: string + payload?: JsonObject | undefined + timeoutMs?: number | undefined +}) { + const config = assertAffiliateDashConfig() + const body = input.payload === undefined ? '' : JSON.stringify(input.payload) + const pathname = normalizePathname(input.pathname) + const url = new URL(pathname, `${config.baseUrl}/`) + // affiliate_dash 签名要求 path 为「仅路径」(不含 query string) + const signPath = url.pathname + const timestamp = String(Math.floor(Date.now() / 1000)) + const headers = createClientSignHeaders({ + appKey: config.appKey, + appSecret: config.appSecret, + method: input.method, + path: signPath, + body, + timestamp, + }) + const timeoutMs = Math.max(1, Number(input.timeoutMs || config.timeoutMs) || config.timeoutMs) + const packetId = createRequestId('ad') + const startedAt = Date.now() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + const requestUrl = url.toString() + const requestHeaders: Record = { + 'content-type': 'application/json', + 'x-app-key': headers['X-App-Key'], + 'x-timestamp': headers['X-Timestamp'], + 'x-nonce': headers['X-Nonce'], + 'x-sign': headers['X-Sign'], + } + let upstreamPacketLogged = false + + logExternalHttpPacket('[affiliate-dash/http]', '发送请求', { + packetId, + method: input.method, + pathname, + requestUrl, + timeoutMs, + request: { + headers: requestHeaders, + body: input.payload, + rawBody: body, + }, + }) + + try { + const response = await fetch(requestUrl, { + method: input.method, + headers: requestHeaders, + ...(input.method === 'POST' ? { body } : {}), + signal: controller.signal, + }) + const text = await response.text() + const json = parseJsonObject(text) + const durationMs = Date.now() - startedAt + const responsePacket = { + packetId, + method: input.method, + pathname, + requestUrl, + status: response.status, + durationMs, + request: { + headers: requestHeaders, + body: input.payload, + }, + response: { + ok: response.ok, + headers: normalizeFetchHeaders(response.headers), + summary: summarizeAffiliateDashResponse(json), + body: json, + rawText: text, + }, + } + + if (!response.ok) { + upstreamPacketLogged = true + logExternalHttpPacket('[affiliate-dash/http]', 'HTTP 响应失败', responsePacket, { + level: 'warn', + }) + throw createHttpError(summarizeAffiliateDashMessage(json) || `affiliate-dash 请求失败: HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'affiliate_dash_http_failed', + context: { + status: response.status, + message: summarizeAffiliateDashMessage(json), + body: text, + }, + }) + } + + if (!isAffiliateDashSuccessResponse(json)) { + upstreamPacketLogged = true + logExternalHttpPacket('[affiliate-dash/http]', '业务响应失败', responsePacket, { + level: 'warn', + }) + throw createHttpError(String(json.message || 'affiliate-dash 业务失败'), { + statusCode: 502, + errorCode: 'affiliate_dash_business_failed', + context: json, + }) + } + + upstreamPacketLogged = true + logExternalHttpPacket('[affiliate-dash/http]', '请求完成', responsePacket) + + return json + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + logExternalHttpPacket( + '[affiliate-dash/http]', + '请求超时', + { + packetId, + method: input.method, + pathname, + requestUrl, + timeoutMs, + durationMs: Date.now() - startedAt, + request: { + headers: requestHeaders, + body: input.payload, + }, + }, + { + level: 'warn', + }, + ) + throw createHttpError('affiliate-dash 请求超时', { + statusCode: 504, + errorCode: 'affiliate_dash_timeout', + }) + } + + if (!upstreamPacketLogged) { + logExternalHttpPacket( + '[affiliate-dash/http]', + '请求异常', + { + packetId, + method: input.method, + pathname, + requestUrl, + timeoutMs, + durationMs: Date.now() - startedAt, + request: { + headers: requestHeaders, + body: input.payload, + }, + error, + }, + { + level: 'error', + }, + ) + } + + throw error + } finally { + clearTimeout(timeout) + } +} + +export function isAffiliateDashSuccessResponse(json: JsonObject) { + return Number(json.code ?? 0) === 0 +} + +function normalizePathname(pathname: string) { + const trimmed = String(pathname || '').trim() + if (!trimmed.startsWith('/')) { + return `/${trimmed}` + } + return trimmed +} + +function parseJsonObject(text: string): JsonObject { + try { + const parsed = JSON.parse(text || '{}') + return asJsonObject(parsed) + } catch { + return {} + } +} + +function summarizeAffiliateDashMessage(json: JsonObject) { + const message = String(json.message || json.msg || '').trim() + return message || '' +} + +function summarizeAffiliateDashResponse(json: JsonObject) { + const data = json.data && typeof json.data === 'object' ? (json.data as JsonObject) : {} + const order = data.order && typeof data.order === 'object' ? (data.order as JsonObject) : null + const list = Array.isArray(data.list) ? data.list : [] + + return { + code: json.code, + message: summarizeAffiliateDashMessage(json), + hasOrder: Boolean(order), + orderNo: String(order?.order_no || '').trim(), + orderStatus: String(order?.order_status || '').trim(), + canShip: Boolean(order?.can_ship), + listCount: list.length, + total: Number(data.total || list.length || 0) || 0, + } +} + +function normalizeFetchHeaders(headers: Headers) { + const result: Record = {} + headers.forEach((value, key) => { + result[key] = value + }) + return result +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/order-service.ts b/apps/backend/src/services/platforms/affiliate-dash/order-service.ts new file mode 100644 index 00000000..60040ff3 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/order-service.ts @@ -0,0 +1,239 @@ +import type { JsonObject } from '../../../types/json.js' + +import { affiliateDashRequest } from './http-client.js' + +export type AffiliateDashOrder = ReturnType +export type AffiliateDashDeliveryInfo = ReturnType +export type AffiliateDashBindResult = ReturnType +export type AffiliateDashWallet = ReturnType + +const ORDERS_PATH = '/api/client/v1/orders' + +export async function createAffiliateDashOrder(input: { + clientOrderNo: string + sku: string + quantity?: number | undefined + buyerReference?: string | undefined + data?: JsonObject | undefined +}) { + const payload: JsonObject = { + client_order_no: input.clientOrderNo, + sku: input.sku, + } + if (input.quantity !== undefined) { + payload.quantity = input.quantity + } + if (input.buyerReference) { + payload.buyer_reference = input.buyerReference + } + if (input.data !== undefined) { + payload.data = input.data + } + + const json = await affiliateDashRequest({ + method: 'POST', + pathname: ORDERS_PATH, + payload, + }) + return mapAffiliateDashOrder(json.data) +} + +export async function getAffiliateDashOrder(orderNo: string) { + const json = await affiliateDashRequest({ + method: 'GET', + pathname: `${ORDERS_PATH}/${encodeURIComponent(orderNo)}`, + }) + return mapAffiliateDashOrder(json.data) +} + +export async function getAffiliateDashDeliveryLink(orderNo: string) { + const json = await affiliateDashRequest({ + method: 'GET', + pathname: `${ORDERS_PATH}/${encodeURIComponent(orderNo)}/delivery-link`, + }) + return mapAffiliateDashDeliveryLink(json.data) +} + +export async function getAffiliateDashDelivery(orderNo: string) { + const json = await affiliateDashRequest({ + method: 'GET', + pathname: `${ORDERS_PATH}/${encodeURIComponent(orderNo)}/delivery`, + }) + return mapAffiliateDashDeliveryInfo(json.data) +} + +export async function bindAffiliateDashDelivery(input: { + orderNo: string + gameAccount: string +}) { + const json = await affiliateDashRequest({ + method: 'POST', + pathname: `${ORDERS_PATH}/${encodeURIComponent(input.orderNo)}/delivery/bind`, + payload: { game_account: input.gameAccount }, + }) + return mapAffiliateDashBindResult(json.data) +} + +export async function getAffiliateDashBindResult(input: { + orderNo: string + bindUuid: string +}) { + const json = await affiliateDashRequest({ + method: 'GET', + pathname: + `${ORDERS_PATH}/${encodeURIComponent(input.orderNo)}/delivery/bind-result` + + `?bind_uuid=${encodeURIComponent(input.bindUuid)}`, + }) + return mapAffiliateDashBindResult(json.data) +} + +export async function submitAffiliateDashDelivery(input: { + orderNo: string + gameAccount: string + bindUuid: string +}) { + const json = await affiliateDashRequest({ + method: 'POST', + pathname: `${ORDERS_PATH}/${encodeURIComponent(input.orderNo)}/delivery/submit`, + payload: { + game_account: input.gameAccount, + bind_uuid: input.bindUuid, + }, + }) + return mapAffiliateDashSubmitResult(json.data) +} + +export async function getAffiliateDashWallet() { + const json = await affiliateDashRequest({ + method: 'GET', + pathname: '/api/client/v1/wallet', + }) + return mapAffiliateDashWallet(json.data) +} + +export function mapAffiliateDashOrder(value: unknown) { + const source = asJsonObject(value) + const product = asJsonObject(source.product) + + return { + orderNo: asString(source.order_no), + clientOrderNo: asString(source.client_order_no), + sku: asString(source.sku || source.product_sku || product.sku), + productName: asString(product.display_name || product.name), + quantity: asInteger(source.quantity, 1), + baseAmount: asNumber(source.base_amount), + serviceFeeAmount: asNumber(source.service_fee_amount), + amount: asNumber(source.amount), + currency: asString(source.currency), + feeType: asString(source.fee_type), + buyerReference: asString(source.buyer_reference), + orderStatus: asString(source.order_status), + canShip: asBoolean(source.can_ship), + cannotShipReason: asString(source.cannot_ship_reason), + providerOrderNo: asString(source.provider_order_no), + failureReason: asString(source.failure_reason), + data: isJsonObject(source.data) ? source.data : {}, + result: asString(source.result), + createdAt: asString(source.created_at), + deliveredAt: asString(source.delivered_at), + cancelledAt: asString(source.cancelled_at), + } +} + +function mapAffiliateDashDeliveryLink(value: unknown) { + const source = asJsonObject(value) + return { + orderNo: asString(source.order_no), + deliveryUrl: asString(source.delivery_url), + expiresAt: asString(source.expires_at), + expiresInSeconds: asInteger(source.exp, undefined), + } +} + +function mapAffiliateDashDeliveryInfo(value: unknown) { + const source = asJsonObject(value) + const product = asJsonObject(source.product) + const good = asJsonObject(source.good) + + return { + status: asString(source.status), + canShip: asBoolean(source.can_ship), + cannotShipReason: asString(source.cannot_ship_reason), + product: { + sku: asString(product.sku), + displayName: asString(product.display_name || product.name), + priceAmount: asNumber(product.price_amount), + currency: asString(product.currency), + }, + buyerName: asString(source.buyer_name), + gameChannel: asString(source.game_channel), + gameUid: asString(source.game_uid), + roleName: asString(source.role_name), + payScore: asNumber(source.pay_score), + data: isJsonObject(source.data) ? source.data : {}, + good: isJsonObject(good) ? good : {}, + } +} + +function mapAffiliateDashBindResult(value: unknown) { + const source = asJsonObject(value) + return { + bindUuid: asString(source.bind_uuid), + bindUrl: asString(source.bind_url), + qrUrl: asString(source.qr_url), + bound: asBoolean(source.bound), + gameAccount: asString(source.game_account), + roleName: asString(source.role_name), + gameChannel: asString(source.game_channel), + expectedGameAccount: asString(source.expected_game_account), + mismatch: asBoolean(source.mismatch), + } +} + +function mapAffiliateDashSubmitResult(value: unknown) { + const source = asJsonObject(value) + return { + orderNo: asString(source.order_no), + status: asString(source.status), + message: asString(source.message), + providerOrderNo: asString(source.provider_order_no), + } +} + +function mapAffiliateDashWallet(value: unknown) { + const source = asJsonObject(value) + return { + availableBalance: asNumber(source.available_balance), + frozenBalance: asNumber(source.frozen_balance), + currency: asString(source.currency), + } +} + +function asJsonObject(value: unknown) { + return isJsonObject(value) ? value : {} +} + +function isJsonObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} + +function asString(value: unknown) { + return String(value ?? '').trim() +} + +function asNumber(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function asInteger(value: unknown, fallback: number | undefined) { + const parsed = Number(value) + if (!Number.isInteger(parsed)) { + return fallback + } + return parsed +} + +function asBoolean(value: unknown) { + return Boolean(value) +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/product-service.ts b/apps/backend/src/services/platforms/affiliate-dash/product-service.ts new file mode 100644 index 00000000..f0529d4a --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/product-service.ts @@ -0,0 +1,80 @@ +import { affiliateDashRequest } from './http-client.js' + +export type AffiliateDashProduct = ReturnType +export type AffiliateDashProductListResult = ReturnType + +export async function listAffiliateDashProducts(input: { + page?: number | undefined + size?: number | undefined +} = {}) { + const params = new URLSearchParams() + if (input.page !== undefined) { + params.set('page', String(input.page)) + } + if (input.size !== undefined) { + params.set('size', String(input.size)) + } + const query = params.toString() + const json = await affiliateDashRequest({ + method: 'GET', + pathname: `/api/client/v1/products${query ? `?${query}` : ''}`, + }) + return mapAffiliateDashProductList(json.data) +} + +/** 翻页拉取全部可售商品(默认每页 100)。 */ +export async function listAllAffiliateDashProducts(input: { + pageSize?: number | undefined +} = {}) { + const size = Math.max(1, Number(input.pageSize || 100) || 100) + const products: AffiliateDashProduct[] = [] + let page = 1 + let total = Infinity + + while (products.length < total) { + const result = await listAffiliateDashProducts({ page, size }) + products.push(...result.list) + total = result.total + if (result.list.length === 0) { + break + } + page += 1 + } + + return { list: products, total: products.length } +} + +export function mapAffiliateDashProductList(value: unknown) { + const source = asRecord(value) + const list = Array.isArray(source.list) ? source.list : [] + return { + list: list.map(mapAffiliateDashProduct), + total: Number(source.total || list.length || 0) || 0, + page: Number(source.page || 1) || 1, + size: Number(source.size || list.length || 0) || 0, + } +} + +export function mapAffiliateDashProduct(value: unknown) { + const source = asRecord(value) + const product = asRecord(source.product) + + return { + sku: String(source.sku || product.code || '').trim(), + displayName: String(source.display_name || product.name || '').trim(), + priceAmount: Number(source.price_amount ?? 0) || 0, + costAmount: Number(source.cost_amount ?? 0) || 0, + currency: String(source.currency || '').trim(), + stock: Number.isInteger(Number(source.stock)) ? Number(source.stock) : -1, + status: String(source.status || product.status || '').trim(), + category: String(product.category || '').trim(), + code: String(product.code || '').trim(), + productId: Number(source.product_id || product.id || 0) || 0, + } +} + +function asRecord(value: unknown): Record { + return Object.prototype.toString.call(value) === '[object Object]' + ? (value as Record) + : {} +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/sign.test.ts b/apps/backend/src/services/platforms/affiliate-dash/sign.test.ts new file mode 100644 index 00000000..5ae180d0 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/sign.test.ts @@ -0,0 +1,118 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildCallbackSign, + buildClientSign, + createClientSignHeaders, + verifyCallbackSign, +} from './sign.js' + +// 黄金向量与 affiliate_dash 线上实现(BuildOpenV1Sign / BuildCallbackSign)同源: +// 请求 content 固定顺序 app_key/body_sha256/method/nonce/path/timestamp(恰为 ASCII 字典序), +// 回调 content 为 body_sha256=×tamp=,均为 HMAC-SHA256 小写 hex。 + +test('buildClientSign matches golden vector (app_key 参数名, body 先 sha256)', () => { + const sign = buildClientSign({ + appKey: 'ak_test_app_key_123456', + appSecret: 'sk_test_secret_789012', + method: 'GET', + path: '/api/client/v1/products', + body: '', + timestamp: '1783394218', + nonce: '0123456789abcdef0123456789abcdef', + }) + + assert.equal(sign, 'af6d58a4db6863b65181949696dee5c4a44fb7e5e10631044942e83f867bb855') +}) + +test('buildClientSign is deterministic and normalizes method case', () => { + const base = { + appKey: 'ak_x', + appSecret: 'sk_x', + path: '/p', + body: '', + timestamp: '1783394218', + nonce: 'n', + } + const lower = buildClientSign({ ...base, method: 'get' }) + const upper = buildClientSign({ ...base, method: 'GET' }) + + assert.equal(lower, upper) + assert.equal(lower, buildClientSign({ ...base, method: 'GET' })) +}) + +test('createClientSignHeaders returns all four headers with consistent sign', () => { + const headers = createClientSignHeaders({ + appKey: 'ak_test_app_key_123456', + appSecret: 'sk_test_secret_789012', + method: 'POST', + path: '/api/client/v1/orders', + body: '{"sku":"x"}', + timestamp: '1783394218', + nonce: 'fixed-nonce-001', + }) + + assert.equal(headers['X-App-Key'], 'ak_test_app_key_123456') + assert.equal(headers['X-Timestamp'], '1783394218') + assert.equal(headers['X-Nonce'], 'fixed-nonce-001') + assert.equal( + headers['X-Sign'], + buildClientSign({ + appKey: 'ak_test_app_key_123456', + appSecret: 'sk_test_secret_789012', + method: 'POST', + path: '/api/client/v1/orders', + body: '{"sku":"x"}', + timestamp: '1783394218', + nonce: 'fixed-nonce-001', + }), + ) +}) + +test('buildCallbackSign matches golden vector', () => { + const sign = buildCallbackSign({ + callbackSecret: 'cb_test_secret_abc', + rawBody: '{"event":"order.shipping.updated"}', + timestamp: '1783394218', + }) + + assert.equal(sign, '96fcd83043c59f5b1c67d5eba2e6c774d53bf371607e0f7f866d6d7b8d5096ea') +}) + +test('verifyCallbackSign accepts valid sign and rejects tampering', () => { + const callbackSecret = 'cb_test_secret_abc' + const rawBody = '{"event":"order.shipping.updated"}' + const timestamp = '1783394218' + const sign = buildCallbackSign({ callbackSecret, rawBody, timestamp }) + + assert.equal( + verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign }), + true, + ) + assert.equal( + verifyCallbackSign({ + callbackSecret, + rawBody: '{"event":"order.created"}', + timestamp, + sign, + }), + false, + '篡改 body 必须拒绝', + ) + assert.equal( + verifyCallbackSign({ callbackSecret, rawBody, timestamp: '1783394219', sign }), + false, + '篡改 timestamp 必须拒绝', + ) + assert.equal( + verifyCallbackSign({ callbackSecret: 'cb_wrong', rawBody, timestamp, sign }), + false, + '错误 secret 必须拒绝', + ) + assert.equal( + verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign: sign.toUpperCase() }), + true, + '大写 hex 应归一为小写后通过', + ) +}) diff --git a/apps/backend/src/services/platforms/affiliate-dash/sign.ts b/apps/backend/src/services/platforms/affiliate-dash/sign.ts new file mode 100644 index 00000000..20e30263 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/sign.ts @@ -0,0 +1,111 @@ +import crypto from 'node:crypto' + +/** + * affiliate_dash 商户开放接口(/api/client/v1)签名。 + * + * 请求签名(§3.1):body 先 sha256,参数按 ASCII 字典序拼接(固定顺序恰好为字典序): + * content = app_key=&body_sha256=&method=&nonce=&path=×tamp= + * X-Sign = hex(HMAC-SHA256(secret, content)),小写。 + * ⚠️ 参数名是 `app_key`(非旧接口的 `api_key`),且 GET 请求 body 为空串。 + * + * 回调验签(§3.2):content = body_sha256=×tamp=, + * secret 为回调 secret(cb_ 开头)。 + */ +export function sha256Hex(input: string): string { + return crypto.createHash('sha256').update(input, 'utf8').digest('hex') +} + +export function hmacSha256Hex(secret: string, content: string): string { + return crypto.createHmac('sha256', secret).update(content, 'utf8').digest('hex').toLowerCase() +} + +export function buildClientSign(input: { + appKey: string + appSecret: string + method: string + path: string + body: string + timestamp: string + nonce: string +}): string { + const content = [ + `app_key=${input.appKey}`, + `body_sha256=${sha256Hex(input.body)}`, + `method=${input.method.toUpperCase()}`, + `nonce=${input.nonce}`, + `path=${input.path}`, + `timestamp=${input.timestamp}`, + ].join('&') + + return hmacSha256Hex(input.appSecret, content) +} + +/** 生成请求所需的 X-Timestamp / X-Nonce / X-Sign(X-App-Key 由调用方放置)。 */ +export function createClientSignHeaders(input: { + appKey: string + appSecret: string + method: string + path: string + body: string + timestamp?: string + nonce?: string +}): { + 'X-App-Key': string + 'X-Timestamp': string + 'X-Nonce': string + 'X-Sign': string +} { + const timestamp = input.timestamp || String(Math.floor(Date.now() / 1000)) + const nonce = input.nonce || crypto.randomBytes(24).toString('hex') + const sign = buildClientSign({ + appKey: input.appKey, + appSecret: input.appSecret, + method: input.method, + path: input.path, + body: input.body, + timestamp, + nonce, + }) + + return { + 'X-App-Key': input.appKey, + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Sign': sign, + } +} + +export function buildCallbackSign(input: { + callbackSecret: string + rawBody: string + timestamp: string +}): string { + const content = `body_sha256=${sha256Hex(input.rawBody)}×tamp=${input.timestamp}` + return hmacSha256Hex(input.callbackSecret, content) +} + +export function verifyCallbackSign(input: { + callbackSecret: string + rawBody: string + timestamp: string + sign: string +}): boolean { + const expected = buildCallbackSign({ + callbackSecret: input.callbackSecret, + rawBody: input.rawBody, + timestamp: input.timestamp, + }) + + return timingSafeEqualString(expected, String(input.sign || '').trim().toLowerCase()) +} + +export function timingSafeEqualString(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, 'utf8') + const rightBuffer = Buffer.from(right, 'utf8') + + if (leftBuffer.length !== rightBuffer.length) { + return false + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer) +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/source-config-service.ts b/apps/backend/src/services/platforms/affiliate-dash/source-config-service.ts new file mode 100644 index 00000000..cac60b1f --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/source-config-service.ts @@ -0,0 +1,98 @@ +import type { JsonObject } from '../../../types/json.js' +import type { AffiliateDashSkuMapping } from '../../../types/runtime-config.js' + +import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' +import { + hasAppConfigEntry, + readAppConfigEntry, + saveAppConfigEntry, +} from '../../config/app-config-store.js' + +const AFFILIATE_DASH_CONFIG_KEY = APP_CONFIG_KEYS.affiliateDash + +export type AffiliateDashSourceConfig = { + enabled: boolean + baseUrl: string + appKey: string + appSecret: string + callbackSecret: string + timeoutMs: number + notifyUrl: string + timestampToleranceSeconds: number + skuMapping: AffiliateDashSkuMapping +} + +export function hasAffiliateDashConfig() { + return hasAppConfigEntry(AFFILIATE_DASH_CONFIG_KEY) +} + +export function getAffiliateDashSourceConfig(): AffiliateDashSourceConfig { + return readAppConfigEntry({ + configKey: AFFILIATE_DASH_CONFIG_KEY, + fallback: createDefaultAffiliateDashSourceConfig, + normalize: normalizeAffiliateDashSourceConfig, + }) +} + +export function saveAffiliateDashSourceConfig(rawValue: unknown): Promise { + return saveAppConfigEntry({ + configKey: AFFILIATE_DASH_CONFIG_KEY, + value: rawValue, + normalize: normalizeAffiliateDashSourceConfig, + }) +} + +export function normalizeAffiliateDashSourceConfig(rawValue: unknown): AffiliateDashSourceConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + + return { + enabled: source.enabled !== false, + baseUrl: String(source.baseUrl || '').trim().replace(/\/+$/, ''), + appKey: String(source.appKey || '').trim(), + appSecret: String(source.appSecret || '').trim(), + callbackSecret: String(source.callbackSecret || '').trim(), + timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000), + notifyUrl: String(source.notifyUrl || '').trim(), + timestampToleranceSeconds: normalizePositiveInteger(source.timestampToleranceSeconds, 300), + skuMapping: normalizeSkuMapping(source.skuMapping), + } +} + +function normalizeSkuMapping(value: unknown): AffiliateDashSkuMapping { + if (!isPlainObject(value)) { + return {} + } + + const mapping: AffiliateDashSkuMapping = {} + for (const [productNo, sku] of Object.entries(value)) { + const key = String(productNo || '').trim() + const skuValue = String(sku || '').trim() + if (key && skuValue) { + mapping[key] = skuValue + } + } + return mapping +} + +function createDefaultAffiliateDashSourceConfig(): AffiliateDashSourceConfig { + return { + enabled: true, + baseUrl: '', + appKey: '', + appSecret: '', + callbackSecret: '', + timeoutMs: 10000, + notifyUrl: '', + timestampToleranceSeconds: 300, + skuMapping: {}, + } +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/platforms/affiliate-dash/verify-callback.test.ts b/apps/backend/src/services/platforms/affiliate-dash/verify-callback.test.ts new file mode 100644 index 00000000..8791dfa4 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/verify-callback.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildCallbackSign } from './sign.js' +import { verifyAffiliateDashCallback } from './verify-callback.js' + +const CALLBACK_SECRET = 'cb_test_secret_abc' + +function makeCallback(input: { + rawBody: string + timestamp: string + eventId?: string | undefined + sign?: string | undefined +}) { + const sign = + input.sign !== undefined + ? input.sign + : buildCallbackSign({ + callbackSecret: CALLBACK_SECRET, + rawBody: input.rawBody, + timestamp: input.timestamp, + }) + + return { + headers: { + 'x-event-id': input.eventId || 'evt-1', + 'x-timestamp': input.timestamp, + 'x-sign': sign, + }, + rawBody: input.rawBody, + } +} + +test('verifyAffiliateDashCallback accepts a valid signed callback', () => { + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawBody = JSON.stringify({ + event_id: 'evt-1', + event: 'order.shipping.updated', + occurred_at: '2026-08-05T14:30:00+08:00', + data: { order_no: 'FO1', order_status: 'delivered' }, + }) + const result = verifyAffiliateDashCallback( + makeCallback({ rawBody, timestamp, eventId: 'evt-1' }), + { callbackSecret: CALLBACK_SECRET, timestampToleranceSeconds: 300 }, + ) + + assert.equal(result.eventId, 'evt-1') + assert.equal(result.event, 'order.shipping.updated') + assert.equal(result.data.order_no, 'FO1') + assert.equal(result.data.order_status, 'delivered') +}) + +test('verifyAffiliateDashCallback rejects tampered body', () => { + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawBody = '{"event":"order.shipping.updated"}' + const tampered = '{"event":"order.cancelled"}' + const input = makeCallback({ rawBody, timestamp }) + input.rawBody = tampered + + assert.throws( + () => verifyAffiliateDashCallback(input, { callbackSecret: CALLBACK_SECRET }), + (error: Error & { errorCode?: string }) => error.errorCode === 'affiliate_dash_callback_sign_invalid', + ) +}) + +test('verifyAffiliateDashCallback rejects timestamp beyond tolerance', () => { + const oldTimestamp = String(Math.floor(Date.now() / 1000) - 3600) + const rawBody = '{"event":"order.shipping.updated"}' + + assert.throws( + () => + verifyAffiliateDashCallback(makeCallback({ rawBody, timestamp: oldTimestamp }), { + callbackSecret: CALLBACK_SECRET, + timestampToleranceSeconds: 300, + }), + (error: Error & { errorCode?: string }) => + error.errorCode === 'affiliate_dash_callback_timestamp_skew', + ) +}) + +test('verifyAffiliateDashCallback rejects missing sign header', () => { + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawBody = '{"event":"order.shipping.updated"}' + const input = makeCallback({ rawBody, timestamp }) + delete input.headers['x-sign'] + + assert.throws( + () => verifyAffiliateDashCallback(input, { callbackSecret: CALLBACK_SECRET }), + (error: Error & { errorCode?: string }) => + error.errorCode === 'affiliate_dash_callback_sign_missing', + ) +}) + +test('verifyAffiliateDashCallback rejects missing raw body', () => { + const timestamp = String(Math.floor(Date.now() / 1000)) + const input = makeCallback({ rawBody: '{}', timestamp }) + input.rawBody = undefined + + assert.throws( + () => verifyAffiliateDashCallback(input, { callbackSecret: CALLBACK_SECRET }), + (error: Error & { errorCode?: string }) => + error.errorCode === 'affiliate_dash_callback_raw_body_missing', + ) +}) + +test('verifyAffiliateDashCallback passes non-JSON body as long as sign matches', () => { + const timestamp = String(Math.floor(Date.now() / 1000)) + const rawBody = 'not-json' + + const result = verifyAffiliateDashCallback( + makeCallback({ rawBody, timestamp }), + { callbackSecret: CALLBACK_SECRET }, + ) + + assert.equal(result.event, '') + assert.deepEqual(result.data, {}) +}) diff --git a/apps/backend/src/services/platforms/affiliate-dash/verify-callback.ts b/apps/backend/src/services/platforms/affiliate-dash/verify-callback.ts new file mode 100644 index 00000000..110a7f08 --- /dev/null +++ b/apps/backend/src/services/platforms/affiliate-dash/verify-callback.ts @@ -0,0 +1,115 @@ +import type { JsonObject } from '../../../types/json.js' + +import { createHttpError } from '../../../utils/http.js' +import { getAffiliateDashConfig } from './config.js' +import { verifyCallbackSign } from './sign.js' + +type CallbackHeaders = Record + +export type AffiliateDashCallbackPayload = { + eventId: string + event: string + occurredAt?: string | undefined + data: JsonObject +} + +/** + * affiliate_dash 回调验签入口。 + * 校验顺序:X-Timestamp 在容差内 → 重算签名(timingSafeEqual)→ 返回事件。 + * X-Event-ID 幂等去重属于业务层(阶段 5),本函数只负责验签与解析。 + */ +export function verifyAffiliateDashCallback( + input: { + headers: CallbackHeaders + rawBody?: string | undefined + }, + configOverride: Partial<{ + callbackSecret: string + timestampToleranceSeconds: number + }> = {}, +) { + const config = getAffiliateDashConfig(configOverride) + const rawBody = String(input.rawBody || '') + const eventId = normalizeHeader(input.headers['x-event-id']) + const timestamp = normalizeHeader(input.headers['x-timestamp']) + const sign = normalizeHeader(input.headers['x-sign']) + + if (!rawBody) { + throw createHttpError('affiliate-dash 回调原始请求体缺失', { + statusCode: 400, + errorCode: 'affiliate_dash_callback_raw_body_missing', + }) + } + + if (!eventId || !timestamp || !sign) { + throw createHttpError('affiliate-dash 回调签名参数缺失', { + statusCode: 401, + errorCode: 'affiliate_dash_callback_sign_missing', + }) + } + + if (!/^\d{10,}$/.test(timestamp)) { + throw createHttpError('affiliate-dash 回调时间戳格式无效', { + statusCode: 401, + errorCode: 'affiliate_dash_callback_timestamp_invalid', + }) + } + + const timestampSeconds = Number(timestamp) + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - timestampSeconds) > config.timestampToleranceSeconds) { + throw createHttpError('affiliate-dash 回调时间戳超出容差', { + statusCode: 401, + errorCode: 'affiliate_dash_callback_timestamp_skew', + context: { + timestamp: timestampSeconds, + toleranceSeconds: config.timestampToleranceSeconds, + }, + }) + } + + if (!config.callbackSecret) { + throw createHttpError('affiliate-dash 回调 Secret 未配置', { + statusCode: 500, + errorCode: 'affiliate_dash_missing_callback_secret', + }) + } + + if (!verifyCallbackSign({ + callbackSecret: config.callbackSecret, + rawBody, + timestamp, + sign, + })) { + throw createHttpError('affiliate-dash 回调验签失败', { + statusCode: 401, + errorCode: 'affiliate_dash_callback_sign_invalid', + }) + } + + let source: JsonObject = {} + try { + const parsed = JSON.parse(rawBody) + if (isPlainObject(parsed)) { + source = parsed + } + } catch { + // 非 JSON body:视为无业务数据,验签已通过 + } + const data = isPlainObject(source.data) ? (source.data as JsonObject) : {} + + return { + eventId, + event: String(source.event || '').trim(), + occurredAt: String(source.occurred_at || '').trim() || undefined, + data, + } satisfies AffiliateDashCallbackPayload +} + +function normalizeHeader(value: string | string[] | undefined) { + return Array.isArray(value) ? String(value[0] || '').trim() : String(value || '').trim() +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index f7cf46f6..b9e946a0 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -15,6 +15,8 @@ export type KuaishouFeifeiProductRule = { notes?: string } +export type AffiliateDashSkuMapping = Record + export type RuntimeConfig = { server: { port: number @@ -117,6 +119,17 @@ export type RuntimeConfig = { notifyUrl: string productRules: KuaishouFeifeiProductRule[] } + affiliateDash: { + enabled: boolean + baseUrl: string + appKey: string + appSecret: string + callbackSecret: string + timeoutMs: number + notifyUrl: string + timestampToleranceSeconds: number + skuMapping: AffiliateDashSkuMapping + } } worker: { sms: { diff --git a/docs/ affiliate_dash_发货平台/affiliate-dash对接.md b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md new file mode 100644 index 00000000..560c0a6e --- /dev/null +++ b/docs/ affiliate_dash_发货平台/affiliate-dash对接.md @@ -0,0 +1,445 @@ +# affiliate_dash 商户接入对接文档(方案 B:统一领取页深度集成) + +> 文档对象:order_site 后端 / 前端对接开发 +> 版本:v1(评审稿) +> 对接方向:order_site 作为 **affiliate_dash 的一个商户**,走商户开放接口 `/api/client/v1`,由 affiliate_dash 完成皮肤/道具履约,order_site 保持自己的统一领取页并自建发货交互。 + +--- + +## 0. 一句话结论 + +order_site 在 affiliate_dash 开通一个商户账号 + API 客户端,下单时调用 `POST /api/client/v1/orders` 幂等建单扣款,领取页通过 `delivery` 系列接口(查询 → 绑定 → 轮询 → 提交)在**本站页面内**完成发货交互,最终由 affiliate_dash 回调 `order.shipping.updated` 驱动 order_site 任务状态与快手电子凭证核销闭环。 + +--- + +## 1. 角色与职责边界 + +| 方 | 承担 | 不承担 | +| --- | --- | --- | +| **order_site** | 91 进单、商品匹配、履约任务、统一领取页、玩家信息收集、回调接收、电子凭证核销、人工兜底 | 不直接对接皮肤源头;不管理 affiliate_dash 钱包 | +| **affiliate_dash** | 商户建单扣款、发货数据查询、绑定、对源头履约、回调推送 | 不接触 91 卡券,不生成 order_site 的 claimUrl | + +> 对接方式与现有 `kuaishou-feifei` 执行器同构:order_site 新增一个 `affiliate_dash` executor,复用一个 `preparePaidTask` + `resolveDeliveryLink`,对外仍然只返回本站统一领取链接。 + +--- + +## 2. 对接前准备(affiliate_dash 侧,管理员手动完成) + +| 项 | 说明 | +| --- | --- | +| 商户账号 | affiliate_dash 后台新建商户(如 `order_site`),状态 active | +| API 客户端 | 商户后台「API 密钥」新建,scopes 至少含 `products:read`、`orders:read`、`orders:write`、`shipping:read`、`wallet:read`;保存 `app_key` / `secret`(secret 仅创建时展示一次) | +| 回调配置 | 商户后台「回调」配置接收 URL(指向 order_site 回调路由),订阅事件 `order.created`、`order.shipping.updated`、`order.cancelled`,保存回调 secret | +| 钱包充值 | affiliate_dash 无真实支付,**下单即扣商户钱包积分**;上线前需充值足够积分 | +| 环境 | 记录 `BASE_URL`(如 `https://affiliate.example.com`),确认网络可达;时间偏差容差 ±300 秒,服务器需 NTP 同步 | + +--- + +## 3. 鉴权与签名 + +### 3.1 请求签名(order_site → affiliate_dash `/api/client/v1`) + +Header: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `X-App-Key` | 是 | 商户 API 客户端 app_key | +| `X-Timestamp` | 是 | Unix 秒时间戳 | +| `X-Nonce` | 是 | 随机串 8~96 位;同 Key 有效期内不可重复(服务端持久化防重放) | +| `X-Sign` | 是 | 见下 | + +签名字符串(**body 先 sha256**,与源头侧 `/api/open/v1` 的原始 body 拼串不同,勿混用;参数名是 **`app_key`** 而非旧接口的 `api_key`,写错会 401): + +```text +app_key=&body_sha256=&method=&nonce=&path=<仅路径>×tamp= +``` + +规则: + +1. 参数按 ASCII 字典序排序(固定为上面顺序); +2. value 原样拼接不做 URL encode;GET 请求 body 为空字符串(`body_sha256` 为空串的 sha256); +3. `X-Sign = hex(HMAC-SHA256(secret, 签名字符串))`,小写。 + +TypeScript 参考: + +```ts +import { createHmac, createHash } from 'node:crypto' + +function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex') +} + +function hmacSha256Hex(secret: string, content: string): string { + return createHmac('sha256', secret).update(content, 'utf8').digest('hex') +} + +export function buildClientSign(params: { + appKey: string + appSecret: string + method: string + path: string + body: string + timestamp: string + nonce: string +}): string { + const content = [ + `app_key=${params.appKey}`, + `body_sha256=${sha256Hex(params.body)}`, + `method=${params.method.toUpperCase()}`, + `nonce=${params.nonce}`, + `path=${params.path}`, + `timestamp=${params.timestamp}`, + ].sort().join('&') + return hmacSha256Hex(params.appSecret, content) +} +``` + +### 3.2 回调验签(affiliate_dash → order_site) + +Header:`X-Event-ID`、`X-Timestamp`、`X-Sign` + +```text +content = body_sha256=×tamp= +X-Sign = hex(HMAC-SHA256(回调secret, content)) +``` + +校验顺序:`X-Timestamp` 在 ±300 秒内 → 按 `X-Event-ID` 幂等去重 → 重算签名比对(`timingSafeEqual`)→ 处理业务。校验失败返回 4xx,成功尽快返回 2xx(affiliate_dash 会重试最多 16 次,间隔为固定递增序列 `15s/15s/30s/3m/10m/20m/30m/30m/30m/1h/3h/3h/3h/6h/6h`,非严格指数退避)。 + +--- + +## 4. 商品映射 + +- 拉取:`GET /api/client/v1/products`(分页 `page`/`size`),关键字段: + +| 字段 | 说明 | +| --- | --- | +| `list[].sku` | 下单时作为 `sku` 传入 | +| `list[].display_name` | 商户展示名 | +| `list[].price_amount` | 单价(积分) | +| `list[].stock` | 库存,`-1` 不限 | +| `list[].status` | `active` 可售 | + +- order_site 侧在履约配置(`fulfillment_profiles.config_json`)维护映射:**91 productNo / order_site 商品 → affiliate_dash sku**。 +- 建议:admin 配置页支持手动关联 + 定时同步(商品下架/缺货在领取前拦截,转人工)。 + +--- + +## 5. 完整时序(方案 B) + +```text +91 卡券下单 + └→ order_site 建内部订单 + 履约 task(executor_key = affiliate_dash) + └→ preparePaidTask: + POST /api/client/v1/orders + { client_order_no: , sku, quantity, buyer_reference, data: { 91单号, 预期游戏账号 } } + → affiliate_dash 扣钱包积分,建单(order_no, order_status=paid) + → order_site 从 201 响应**同步**拿到 order_no,写入 task 上下文,task → link_generated + → 异步回调 order.created 仅作对账/补发(幂等,不改变 task 状态) + └→ resolveDeliveryLink:返回本站统一 claimUrl +91 把 claimUrl 发给用户 +用户打开 order_site 领取页(executor_key=affiliate_dash 分支) + └→ 展示商品/预期账号 + └→ 用户输入/确认游戏 UID → POST /orders/{order_no}/delivery/bind + → 展示 bind_url / qr_url(或跳转绑定) + └→ 轮询 GET /orders/{order_no}/delivery/bind-result(2~3 秒) + → bound=true(含 mismatch 提示)→ 展示角色信息 + └→ POST /orders/{order_no}/delivery/submit { game_account, bind_uuid } + → affiliate_dash 进入 delivering,内部对源头发货 +affiliate_dash 履约完成(delivered / ship_failed) + └→ 回调 order.shipping.updated + → order_site 验签 + 幂等 → 更新 task 状态 + → delivered:task → redeemed/completed,触发快手电子凭证核销 + → ship_failed:task → retry_pending/manual_review,展示失败原因 +``` + +--- + +## 6. 接口明细(order_site 实际使用的端点) + +### 6.1 创建订单 `POST /api/client/v1/orders`(scope `orders:write`) + +| body | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `client_order_no` | string | 是 | **幂等单号**,同一商户下唯一;建议用 order_site 内部单号 | +| `sku` | string | 是 | affiliate_dash 商品 sku | +| `quantity` | int | 否 | 默认 1 | +| `buyer_reference` | string | 否 | 买家标识/备注(可填 91 单号或用户标识) | +| `data` | object | 否 | 透传业务数据(≤2048B);约定字段 `game_account`/`game_channel`/`role_name` 会用于发货处理;回调原样带回 | + +响应 `data.order`:`order_no`、`client_order_no`、`order_status`(=paid)、`can_ship`、`cannot_ship_reason`、`amount`、`base_amount`、`service_fee_amount`、`currency` 等。 + +注意: + +- 成功创建 HTTP **201**;命中幂等返回 **200** 且 `idempotent=true` + 既有订单,**不重复扣款**; +- **无真实支付,下单即扣钱包积分**,余额不足返回 400 —— order_site 必须处理该降级; +- 下单后以 `order_no` 为准进行后续查询/发货/回调匹配。 + +### 6.2 查询订单 `GET /api/client/v1/orders/{order_no}`(`orders:read`) + +返回订单当前状态、金额、失败原因等(⚠️ 实际响应**不含**「发货链接有效期」字段,有效期仅在 delivery-link 接口返回);用于 order_site 主动对账/兜底轮询。 + +### 6.3 获取发货链接 `GET /api/client/v1/orders/{order_no}/delivery-link`(`orders:read`/`shipping:read`) + +返回 `delivery_url`(可直接打开的 affiliate_dash Web 发货页)。方案 B 下作为**备选**(如自建页临时不可用时直接跳转)。 + +### 6.4 查询发货数据 `GET /api/client/v1/orders/{order_no}/delivery`(`orders:read`/`shipping:read`) + +自建发货页的渲染数据:`status`、`can_ship`、`cannot_ship_reason`、`product`、`buyer_name`、`game_channel`、`game_uid`、`role_name`、`pay_score`、`data`(下单透传,用于预填玩家账号)、`good`(商品展示详情)。 + +### 6.5 发起绑定 `POST /api/client/v1/orders/{order_no}/delivery/bind`(`orders:write`) + +| body | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `game_account` | string | 是 | 玩家编号 / UID | + +返回 `bind_uuid`(提交发货凭证)、`bind_url`(绑定跳转地址)、`qr_url`(绑定二维码)。同一订单重复绑定以最新凭证为准。 + +### 6.6 查询绑定结果 `GET /api/client/v1/orders/{order_no}/delivery/bind-result?bind_uuid=..`(`orders:read`/`shipping:read`) + +| 字段 | 说明 | +| --- | --- | +| `bound` | 是否完成绑定;未绑定 `false`,自建页每 2~3 秒轮询 | +| `game_account` / `role_name` / `game_channel` | 绑定成功后返回 | +| `expected_game_account` | 下单 `data.game_account` 透传的预期账号(有传才返回) | +| `mismatch` | 绑定账号与预期不一致时为 `true`;**提交发货会被拒绝** | + +### 6.7 提交发货 `POST /api/client/v1/orders/{order_no}/delivery/submit`(`orders:write`) + +| body | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `game_account` | string | 是 | 玩家编号 / UID | +| `bind_uuid` | string | 是 | 绑定凭证 | + +返回 `order_no`、`status`(=delivering)、`message`、`provider_order_no`。同一订单重复提交直接返回既有结果。**最终成败以回调为准**。 + +### 6.8 余额查询 `GET /api/client/v1/wallet`(`wallet:read`) + +可选:下单前预检余额,或在订单创建 400 时告警。返回 `available_balance`、`currency` 等。 + +--- + +## 7. 回调契约 + +| 事件 | 触发时机 | +| --- | --- | +| `order.created` | 商户下单成功(扣款完成,order_status=paid) | +| `order.shipping.updated` | 发货状态变化(delivering / delivered / ship_failed) | +| `order.cancelled` | 订单取消/退款 | + +payload(统一结构): + +```json +{ + "event_id": "…", + "event": "order.shipping.updated", + "occurred_at": "…", // 真实实现含此字段 + "data": { + "order_no": "FO20260730000123", + "client_order_no": "shop-10001", + "product_sku": "suit_pink_sheep", + "quantity": 1, + "base_amount": 100, + "service_fee_amount": 1, + "amount": 101, + "currency": "POINT", + "order_status": "delivered", + "can_ship": false, + "cannot_ship_reason": "", + "provider_order_no": "…", + "failure_reason": "", + "data": { "91单号": "…", "game_account": "4808146277" } + } +} +``` + +order_site 处理要求: + +1. 验签(见 3.2); +2. 按 `X-Event-ID` / `event_id` 幂等(DB 唯一键或去重表); +3. `order_status` → task 状态迁移(见下); +4. 尽快返回 2xx;失败重试可能重复投递(最多 16 次指数退避)。 + +--- + +## 8. 状态映射 + +| affiliate_dash `order_status` | order_site task 状态 / 动作 | +| --- | --- | +| `paid` | 已建单待履约(task `link_generated`,领取页可开始发货) | +| `delivering` | 履约中(task `redeeming`,领取页展示处理中) | +| `delivered` | 履约成功(task → `redeemed` / `completed`;触发快手电子凭证核销) | +| `ship_failed` | 履约失败(task → `retry_pending`;可重新 `delivery/submit` 或转 `manual_review`) | +| `cancelled` | 已取消/退款(task → `closed`,通知 91/用户) | + +> ⚠️ **状态机合法性**(对照 `apps/backend/src/domain/task-status.ts` 的 `TASK_TRANSITIONS`): +> - affiliate_dash 流程中 task 须经过 `link_generated → claimed → waiting_binding → redeeming` 才能进入履约态:`link_generated`/`claimed` **不能**直接跳 `redeeming`(不在转移表)。落地时:领取页打开置 `claimed`、提交绑定置 `waiting_binding`、`delivery/submit` 成功或收到 delivering 回调置 `redeeming`。 +> - `redeeming → redeemed/completed/retry_pending/manual_review` 均合法;`redeeming → closed` 不在转移表(`updateTask` 实际不校验转移表,属软约束,仍建议先经 `manual_review` 再 `closed`)。 +> - `retry_pending/manual_review → redeeming` 合法,支持 ship_failed 重试闭环。 + +--- + +## 9. 错误与降级 + +| 场景 | affiliate_dash 返回 | order_site 处理 | +| --- | --- | --- | +| 钱包余额不足 | 创建订单 400 | task → `manual_review` + 告警「affiliate_dash 余额不足」,充值后重试 | +| 商品下架/缺货 | 创建订单 400 | 匹配阶段拦截,转人工 | +| 下单超时/网络失败 | 无响应 | 用同 `client_order_no` 幂等重试;仍失败转 `manual_dispatch` | +| 绑定账号不匹配 | `mismatch=true`,submit 被拒 | 领取页提示买家绑回预期账号或重新下单 | +| `ship_failed` | 回调 failure_reason | 领取页/后台展示原因,支持重试或转人工 | + +--- + +## 10. 幂等与一致性设计 + +- **下单幂等**:`client_order_no` = order_site 的 `task_no`(格式 `DT` + 12 hex,唯一且稳定,见 §14.2),重复请求安全; +- **回调幂等**:`X-Event-ID` 去重; +- **提交发货幂等**:重复 `delivery/submit` 返回既有结果; +- **对账兜底**:order_site 定时用 `GET /orders/{order_no}` 对账(可选,回调为主); +- **时间**:双方服务器 NTP 同步,容差 ±300 秒。 + +--- + +## 11. order_site 配置清单(履约配置扩展) + +`fulfillment_profiles` 新增 profile: + +```json +{ + "profile_key": "affiliate_dash", + "name": "affiliate_dash 履约", + "executor_key": "affiliate_dash", + "requires_claim": true, + "auto_dispatch": false, + "config_json": { + "baseUrl": "https://affiliate.example.com", + "appKey": "ak_…", + "appSecret": "sk_…", + "callbackSecret": "…", + "skuMapping": { "91商品编码": "affiliate_dash sku" }, + "fallbackExecutor": "manual_dispatch", + "precheckBalance": true + } +} +``` + +商品匹配规则(复用 `fulfillment-routing.ts`):91 productNo → 命中 affiliate_dash 映射 → `executor_key = affiliate_dash`;未命中走现有 kuaishou / manual 流程。 + +--- + +## 12. 对接计划(分阶段) + +| 阶段 | 内容 | 交付物 | 验收点 | +| --- | --- | --- | --- | +| **0. affiliate_dash 侧准备**(手动) | 建商户、API 客户端、回调配置、充值 | 商户可登录、app_key/secret 可用 | 用 curl 调 `GET /products` 通 | +| **1. 平台客户端与签名** | `services/platforms/affiliate-dash/`:签名、请求封装、验签 | `client.ts`、`verify-callback.ts` | 单元测试:签名向量、验签通过/篡改拒绝 | +| **2. executor** | `affiliate-dash-executor.ts`:`preparePaidTask`(建单+存 order_no)+ `resolveDeliveryLink`(统一 claimUrl);注册进 `registry.ts` | executor + 注册 | 测试订单:91 进单 → task 状态 `link_generated`,affiliate_dash 出现订单 | +| **3. 履约配置与商品映射** | 新增 profile、admin 配置页(sku 映射/余额预检) | 配置页 + 迁移 | 配置页可保存,路由规则生效 | +| **4. 领取页(方案 B 自建)** | 前端按 executor 分发到 affiliate-dash 流程:`delivery` 查数据 → 输入 UID → `bind` → 轮询 `bind-result` → `submit`;异常分支(mismatch/失败重试) | 领取页分支 | 端到端:用户领取 → 绑定 → 提交 → affiliate_dash 显示 delivering | +| **5. 回调路由与状态同步** | `POST /webhooks/affiliate-dash`:验签 + 幂等 + task 状态迁移 + 核销触发 | 回调路由 | 发货完成回调 → task `redeemed` + 电子凭证核销闭环 | +| **6. 联调与上线** | 错误/降级演练、对账、监控告警 | 上线 checklist | 全链路绿灯,余额不足/绑定不匹配演练通过 | + +--- + +## 13. 参考代码位置 + +- order_site executor 模式:`apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts`(最接近,同为「建单 + 统一领取链接」)、`registry.ts`(注册)、`types.ts`(接口定义) +- order_site 平台对接样例:`apps/backend/src/services/platforms/kuaishou-feifei/` +- order_site 履约配置:`apps/backend/src/repositories/fulfillment-profile-repo.ts`、`apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts` +- affiliate_dash 接口契约(在线文档/字段定义):`frontend/src/openapi/endpoints.ts`;鉴权实现:`backend/internal/middleware/open_auth.go`(商户侧)、`backend/internal/service/callback.go`(回调签名 `BuildCallbackSign`) + +--- + +## 14. 深度分析(契约验证结论 · v1 → v2) + +> 本节基于对 affiliate_dash 实际代码(`backend/internal/middleware/open_auth.go`、`backend/internal/service/callback.go`、`backend/internal/handler/open_v1.go`、`backend/internal/service/delivery.go`、`frontend/src/openapi/endpoints.ts` 等)与 order_site 履约体系(`domain/task-status.ts`、`services/fulfillment/executors/*`、`services/claim/*`)的逐项核对。以下结论是阶段 1~6 实现的直接依据。 + +### 14.1 契约验证对照(文档 vs affiliate_dash 实际实现) + +| # | 契约项 | 结论 | +|---| --- | --- | +| 1 | 签名串参数名:文档原写 `api_key=`,实现为 `app_key=`(open_auth.go:194 `BuildOpenV1Sign`;前端在线文档 OpenApiDocs.tsx:425-449 与调试页 ApiDebugger.tsx:65 均用 `app_key=`) | ❌ 已在本版修正;按旧写法签名必然 401 | +| 2 | body 先 sha256、参数字典序、四头、nonce 持久化防重放(长度 8~96)、±300s(OPEN_SIGN_SKEW=300)、scopes(products:read/orders:read/orders:write/shipping:read/wallet:read) | ✅ 一致 | +| 3 | 回调 `content=body_sha256=<..>×tamp=<..>`、头 X-Event-ID/X-Timestamp/X-Sign、事件枚举 order.created/order.shipping.updated/order.cancelled | ✅ 一致 | +| 4 | 回调重试 16 次 | ⚠️ 次数一致(CALLBACK_MAX_ATTEMPTS=16),但为固定递增间隔序列而非严格指数退避(callback.go:30-46) | +| 5 | POST /orders 字段、新建 201 / 幂等 200 + idempotent=true、余额不足 400、order 响应字段(order_no/order_status/can_ship/cannot_ship_reason/amount/base_amount/service_fee_amount/currency + client_order_no/product/quantity/fee_type/buyer_reference/provider_order_no/failure_reason/data/result/created_at/delivered_at/cancelled_at) | ✅ 一致 | +| 6 | GET /products 字段与分页 page/size(status 恒为 active);GET /orders/{order_no};delivery-link → delivery_url/expires_at;delivery → status/can_ship/cannot_ship_reason/product/buyer_name/game_channel/game_uid/role_name/pay_score/data/good;bind → bind_uuid/bind_url/qr_url;bind-result → bound/game_account/role_name/game_channel/expected_game_account/mismatch;submit → order_no/status/message/provider_order_no;wallet → available_balance/currency | ✅ 一致(唯一修正:查询订单响应不含「发货链接有效期」,见 §6.2) | +| 7 | 回调 payload 字段 + 顶层 `occurred_at`(真实存在,§7 已补) | ✅ 一致 | +| 8 | order_status 五枚举 paid/delivering/delivered/ship_failed/cancelled;mismatch 提交被拒为 HTTP 400 | ✅ 一致(语义细节见下) | + +**mismatch 语义细节**:`bind-result.mismatch=true` 判定的是「绑定账号 ≠ 下单 `data.game_account`(预期账号)」(delivery.go:227-233);`submit` 的拒绝条件是「绑定账号 ≠ **请求体提交的** game_account」(delivery.go:354-357)。正常流程提交预期账号时两者等价;若页面让用户提交的是绑定账号本身,则 mismatch=true 时也能提交通过 —— 对账/风控时留意。 + +### 14.2 order_site 落地要点 + +- **client_order_no 用 `task_no`**(`utils/random.ts` `randomId('DT', 6)` → `DT` + 12 hex,≤64 字符满足 affiliate_dash 限制)。order_site 自身无 client_order_no 字段,本地下单幂等由 `orders UNIQUE(provider, platform, shop_id, platform_order_id)` + `listTasks(order.id)` 已建不重建承担;affiliate_dash 侧幂等由 `client_order_no=task_no` 承担,preparePaidTask 失败重试可安全重放同一 task_no。 +- **preparePaidTask 同步拿到 order_no**(201 响应即含),写入 `context_json.affiliateDash.orderNo`;无需等回调。失败必须降级 `MANUAL_REVIEW` + `deps.notifyTaskAutoManualReview`(范式:kuaishou-feifei-executor.ts:36-51)。 +- **状态迁移路径**:见 §8 注 —— 领取页打开 `link_generated → claimed`,绑定 `→ waiting_binding`,submit 成功/回调 delivering `→ redeeming`,delivered `→ redeemed`(+核销),ship_failed `→ retry_pending`,cancelled `→ manual_review → closed`。 +- **回调幂等**:affiliate_dash 投递带 `X-Event-ID`,order_site 现无独立去重表 —— 建议 migration `013_affiliate_dash.sql` 新建 `webhook_events(event_id UNIQUE)` 去重表;事件处理用**状态合并式更新**(仿 `syncKuaishouFeifeiTaskStatus`,重复通知可重入)。接收方注意 `app.ts:31-38` 的 `express.json` 已保存 `req.rawBody`(验签必需),挂载回调路由时确认 rawBody 可用。 +- **核销联动**:`delivered` 回调 → 复用 `consumeKuaishouIndustryVouchersForTask`(`platforms/kuaishou-industry/voucher-service.ts:143`),成功 → `redeemed/completed`,失败 → `manual_review`。 +- **领取页分发**:`flowType` 扩展 `'affiliate_dash'`;`buildClaimDetailPayload`(`services/claim/kuaishou-cloud-claim-context.ts:89-92`)按 executor_key 增加分支,返回 affiliate_dash 专用 payload(商品/预期账号/当前状态/二维码相关);前端 `ClaimPage.tsx:337-373` 增加渲染分支。 + +### 14.3 改动文件清单 + +**后端(16 项)**: + +| # | 文件 | 改动 | +|---|------|------| +| 1 | `services/fulfillment/executors/types.ts` | `FULFILLMENT_EXECUTOR_KEYS` 加 `AFFILIATE_DASH`;按需加 `isAffiliateDashExecutor` 守卫 | +| 2 | `services/fulfillment/executors/affiliate-dash-executor.ts`(新) | 仿 kuaishou-feifei-executor.ts:`{ key, preparePaidTask, resolveDeliveryLink }` | +| 3 | `services/fulfillment/executors/registry.ts` | `EXECUTORS` Map 注册 | +| 4 | `services/fulfillment/affiliate-dash/index.ts`(新) | 业务实现:prepare / sync 状态合并 + flow normalize | +| 5 | `services/platforms/affiliate-dash/`(新) | `config.ts` + `http-client.ts`(签名 §3.1 + 请求封装 + 超时)+ `order-service.ts` + `notify-service.ts`(验签,`timingSafeEqual` 模式) | +| 6 | `services/fulfillment/routing-config-service.ts` | `ROUTABLE_EXECUTOR_KEYS` / `DEFAULT_EXECUTOR_PRIORITY` 加入 affiliate_dash | +| 7 | `services/fulfillment/product-resolution-service.ts` | candidates 加 affiliate_dash 匹配项 + item_snapshot 上下文 | +| 8 | `services/bootstrap/fulfillment-bootstrap-service.ts` | `CORE_PROFILES` 加 affiliate_dash profile | +| 9 | `services/fulfillment/planner.ts` | 动态 profile 解析 + `buildFulfillmentTaskContext` 加 `affiliateDash` 块 | +| 10 | `services/claim/kuaishou-cloud-claim-context.ts` | 详情 payload 加 affiliate_dash 分支 | +| 11 | `services/claim/kuaishou-cloud-claim-service.ts` | executor_key 分发加分支 | +| 12 | `repositories/task-repo.ts` | 仿 `findKuaishouFeifeiTaskByOrder` 加按 `context_json #>> '{affiliateDash,orderNo}'` 查任务 | +| 13 | `routes/affiliate-dash.ts`(新)+ `app.ts` | webhook 路由,挂载 `/api/v1/open/affiliate-dash` | +| 14 | `db/migrations/013_affiliate_dash.sql`(新) | `webhook_events` 去重表(可选:affiliate 流水表) | +| 15 | `routes/admin/platform-config/` + `services/admin/platform-config/` | 平台配置读写(appKey/secret 等) | +| 16 | `services/admin/write/` | admin 手动重试/转人工按 executor 分发分支 | + +**前端(6 项)**: + +| # | 文件 | 改动 | +|---|------|------| +| 17 | `types/claim.ts` | `ClaimAffiliateDashFlowInfo` + flowType 扩展 | +| 18 | `pages/claim/ClaimPage.tsx` + `claim-snapshot.ts` | `isAffiliateDashFlow` 判定与渲染分支 | +| 19 | `pages/claim/ClaimAffiliateDashSteps.tsx`(新) | 领取步骤(查 delivery → 输 UID → bind → 轮询 bind-result → submit) | +| 20 | `services/claim.ts` | claim 侧操作 API | +| 21 | `pages/admin/AdminTasksPage.tsx` 等 | executor label/color 显示 | +| 22 | `domain/task-status.ts` | 如需新增状态/转移同步(按 §8 注已有路径则无需) | + +### 14.4 剩余风险与待确认决策点 + +| 项 | 说明 | +| --- | --- | +| 自建页 vs delivery_url 兜底 | 方案 B 以自建页为主,`delivery-link` 作降级跳转;需确认前端是否允许 iframe 内嵌 affiliate_dash H5(扫码场景无碍,跳转场景有跨域限制) | +| 91 拆单 | 一个 91 单拆多 unit → 每 task 独立 `client_order_no`(=task_no),互不影响 | +| 对账任务 | 回调为主 + 可选定时 `GET /orders/{order_no}` 对账(回调丢失兜底) | +| admin 手动重试 | ship_failed / manual_review 的手动「重新 submit」入口按 executor 分发(文件清单 #16) | +| 回调订阅确认 | 阶段 0 配置回调时确认 affiliate_dash 后台事件订阅粒度(order.created 是否必须订阅,或仅 shipping.updated 即可) | + +--- + +## 15. 阶段 1 落地记录(v2.1 · 已完成) + +产出文件(`apps/backend/src/`): + +| 文件 | 内容 | +| --- | --- | +| `services/platforms/affiliate-dash/config.ts` | `get/assertAffiliateDashConfig`(runtime + saved 合并);常量 `AFFILIATE_DASH_EXECUTOR_KEY='affiliate_dash'`、`AFFILIATE_DASH_WEBHOOK_PATH='/api/v1/open/affiliate-dash'` | +| `services/platforms/affiliate-dash/source-config-service.ts` | saved config 读写(`APP_CONFIG_KEYS.affiliateDash`,密钥存 DB 不进 git;含 `skuMapping` 归一) | +| `services/platforms/affiliate-dash/sign.ts` | `buildClientSign`(§3.1,参数名 `app_key=`)+ `buildCallbackSign`/`verifyCallbackSign`(§3.2,`timingSafeEqual`)+ `createClientSignHeaders` | +| `services/platforms/affiliate-dash/http-client.ts` | `affiliateDashRequest`(GET/POST、四头自动签名、AbortController 超时 502/504、错误归一、`logExternalHttpPacket` 全链路日志) | +| `services/platforms/affiliate-dash/verify-callback.ts` | `verifyAffiliateDashCallback`(时间容差 ±300s → 验签 → 事件解析;X-Event-ID 幂等留待阶段 5) | +| `services/platforms/affiliate-dash/order-service.ts` | 建单/查询/delivery-link/delivery/bind/bind-result/submit/wallet 全端点 + 字段映射 | +| `services/platforms/affiliate-dash/product-service.ts` | `listAffiliateDashProducts` + `listAllAffiliateDashProducts`(翻页拉全) | +| 配置接入 | `runtime-config.ts`、`defaults.ts`、`env-overrides.ts`(`AFFILIATE_DASH_*`)、`app-config-keys.ts` | +| 测试 | `sign.test.ts`(黄金向量)、`verify-callback.test.ts`(通过/篡改/超容差/缺头)、`http-client.test.ts`;12/12 通过,全量 211 通过,`tsc --noEmit` 通过 | + +**真实联调验收**:`listAffiliateDashProducts` 直连线上 `https://skin.khhao.com` → total=33 商品,字段映射正确,签名链路与 affiliate_dash `BuildOpenV1Sign` 一致。 + +**联调中发现并修复**:签名 `path` 必须为**纯路径**(不含 query string);首次实现把 `/products?page=1&size=5` 整串参与签名导致线上 401「签名校验失败」,已改为 `URL.pathname` 参与签名(§3.1 表头 `path=<仅路径>` 属实)。