新增 affiliate_dash 发货平台客户端与签名(阶段 1)

- 平台配置接入: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(含契约验证结论与深度分析)
This commit is contained in:
yml2213
2026-08-05 14:51:40 +08:00
parent 784459232d
commit a6ba5f1c7f
15 changed files with 1725 additions and 0 deletions
@@ -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<AffiliateDashRuntimeConfig> = {}) {
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<AffiliateDashRuntimeConfig>,
savedValue: Partial<AffiliateDashSourceConfig> | null,
overrides: Partial<AffiliateDashRuntimeConfig>,
): 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<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -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)
})
@@ -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<string, string> = {
'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<string, string> = {}
headers.forEach((value, key) => {
result[key] = value
})
return result
}
@@ -0,0 +1,239 @@
import type { JsonObject } from '../../../types/json.js'
import { affiliateDashRequest } from './http-client.js'
export type AffiliateDashOrder = ReturnType<typeof mapAffiliateDashOrder>
export type AffiliateDashDeliveryInfo = ReturnType<typeof mapAffiliateDashDeliveryInfo>
export type AffiliateDashBindResult = ReturnType<typeof mapAffiliateDashBindResult>
export type AffiliateDashWallet = ReturnType<typeof mapAffiliateDashWallet>
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)
}
@@ -0,0 +1,80 @@
import { affiliateDashRequest } from './http-client.js'
export type AffiliateDashProduct = ReturnType<typeof mapAffiliateDashProduct>
export type AffiliateDashProductListResult = ReturnType<typeof mapAffiliateDashProductList>
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<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
? (value as Record<string, unknown>)
: {}
}
@@ -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=<sha256(body)>&timestamp=<ts>,均为 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 应归一为小写后通过',
)
})
@@ -0,0 +1,111 @@
import crypto from 'node:crypto'
/**
* affiliate_dash 商户开放接口(/api/client/v1)签名。
*
* 请求签名(§3.1):body 先 sha256,参数按 ASCII 字典序拼接(固定顺序恰好为字典序):
* content = app_key=<appKey>&body_sha256=<sha256hex(body)>&method=<GET|POST>&nonce=<nonce>&path=<path>&timestamp=<unix秒>
* X-Sign = hex(HMAC-SHA256(secret, content)),小写。
* ⚠️ 参数名是 `app_key`(非旧接口的 `api_key`),且 GET 请求 body 为空串。
*
* 回调验签(§3.2):content = body_sha256=<sha256hex(原始body)>&timestamp=<X-Timestamp>
* secret 为回调 secretcb_ 开头)。
*/
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-SignX-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)}&timestamp=${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)
}
@@ -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<AffiliateDashSourceConfig> {
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]'
}
@@ -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, {})
})
@@ -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<string, string | string[] | undefined>
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]'
}