接入多发货平台与电子凭证

This commit is contained in:
yml2213
2026-07-07 12:06:19 +08:00
parent c101b5f359
commit ddfa6c278f
35 changed files with 2627 additions and 365 deletions
@@ -0,0 +1,44 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import type { RuntimeConfig } from '../../../types/runtime-config.js'
export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
const config = {
...(runtimeConfig.platforms?.kuaishouFeifei || {}),
...overrides,
}
return {
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
appKey: String(config.appKey || '').trim(),
appSecret: String(config.appSecret || '').trim(),
timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000),
notifyUrl: String(config.notifyUrl || '').trim(),
productRules: Array.isArray(config.productRules) ? config.productRules : [],
}
}
export function assertKuaishouFeifeiConfig() {
const config = getKuaishouFeifeiConfig()
if (!config.baseUrl) {
throw createHttpError('kuaishou-feifei baseUrl 未配置', {
statusCode: 500,
errorCode: 'kuaishou_feifei_missing_base_url',
})
}
if (!config.appKey || !config.appSecret) {
throw createHttpError('kuaishou-feifei App Key / App Secret 未配置', {
statusCode: 500,
errorCode: 'kuaishou_feifei_missing_credential',
})
}
return config
}
@@ -0,0 +1,21 @@
import crypto from 'node:crypto'
import test from 'node:test'
import assert from 'node:assert/strict'
import { signKuaishouFeifeiPayload } from './http-client.js'
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
const input = {
appKey: 'app-key-1',
appSecret: 'secret-1',
timestamp: '1783394218',
body: '{"platform_order_no":"DT-1","product_code":"10000001","platform_buy_num":1}',
}
const expected = crypto
.createHmac('sha256', input.appSecret)
.update(`${input.appKey}${input.timestamp}${input.body}`, 'utf8')
.digest('hex')
.toLowerCase()
assert.equal(signKuaishouFeifeiPayload(input), expected)
})
@@ -0,0 +1,95 @@
import crypto from 'node:crypto'
import { createHttpError } from '../../../utils/http.js'
import { assertKuaishouFeifeiConfig } from './config.js'
type JsonObject = Record<string, any>
export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObject = {}) {
const config = assertKuaishouFeifeiConfig()
const body = JSON.stringify(payload)
const timestamp = String(Math.floor(Date.now() / 1000))
const sign = signKuaishouFeifeiPayload({
appKey: config.appKey,
appSecret: config.appSecret,
timestamp,
body,
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
try {
const response = await fetch(`${config.baseUrl}${pathname}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-app-key': config.appKey,
'x-timestamp': timestamp,
'x-sign': sign,
},
body,
signal: controller.signal,
})
const text = await response.text()
const json = parseJsonObject(text)
if (!response.ok) {
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
statusCode: 502,
errorCode: 'kuaishou_feifei_http_failed',
context: {
status: response.status,
body: text,
},
})
}
if (Number(json.code || 0) !== 0) {
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
statusCode: 502,
errorCode: 'kuaishou_feifei_business_failed',
context: json,
})
}
return json
} catch (error) {
if ((error as Error)?.name === 'AbortError') {
throw createHttpError('kuaishou-feifei 请求超时', {
statusCode: 504,
errorCode: 'kuaishou_feifei_timeout',
})
}
throw error
} finally {
clearTimeout(timeout)
}
}
export function signKuaishouFeifeiPayload({
appKey,
appSecret,
timestamp,
body,
}: {
appKey: string
appSecret: string
timestamp: string
body: string
}) {
return crypto
.createHmac('sha256', appSecret)
.update(`${appKey}${timestamp}${body}`, 'utf8')
.digest('hex')
.toLowerCase()
}
function parseJsonObject(text: string): JsonObject {
try {
const parsed = JSON.parse(text || '{}')
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
@@ -0,0 +1,75 @@
import { getKuaishouFeifeiConfig } from './config.js'
import { kuaishouFeifeiRequest } from './http-client.js'
type JsonObject = Record<string, any>
export async function createKuaishouFeifeiOrder(input: {
platformOrderNo: string
productCode: string
platformBuyNum?: number
platformAmount?: number
playerAccount?: string
playerGameRegion?: string
playerGameSrv?: string
playerGameRole?: string
submitPlayer?: boolean
notifyUrl?: string
}) {
const config = getKuaishouFeifeiConfig()
const payload: JsonObject = {
platform_order_no: input.platformOrderNo,
product_code: input.productCode,
platform_buy_num: Math.max(1, Number(input.platformBuyNum || 1) || 1),
}
if (input.platformAmount != null) payload.platform_amount = input.platformAmount
if (input.playerAccount) payload.player_account = input.playerAccount
if (input.playerGameRegion) payload.player_game_region = input.playerGameRegion
if (input.playerGameSrv) payload.player_game_srv = input.playerGameSrv
if (input.playerGameRole) payload.player_game_role = input.playerGameRole
if (input.submitPlayer === true) payload.submit_player = true
if (input.notifyUrl || config.notifyUrl) payload.notify_url = input.notifyUrl || config.notifyUrl
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/store', payload)
return mapKuaishouFeifeiOrder(response.data?.order)
}
export async function queryKuaishouFeifeiOrder(input: {
platformOrderNo?: string
orderNo?: string
}) {
const payload: JsonObject = {}
if (input.platformOrderNo) payload.platform_order_no = input.platformOrderNo
if (input.orderNo) payload.order_no = input.orderNo
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/show', payload)
return mapKuaishouFeifeiOrder(response.data?.order)
}
export function mapKuaishouFeifeiOrder(value: unknown) {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
return {
orderNo: String(source.order_no || '').trim(),
platformOrderNo: String(source.platform_order_no || '').trim(),
productCode: String(source.product_code || '').trim(),
productName: String(source.product_name || '').trim(),
rechargeStatus: Number(source.recharge_status ?? source.status ?? 0) || 0,
rechargeStatusLabel: String(source.recharge_status_label || source.status_label || '').trim(),
pointsCharged: Number(source.points_charged || 0) || 0,
playerAccount: String(source.player_account || '').trim(),
platformBuyNum: Number(source.platform_buy_num || 1) || 1,
rechargeResultMessage: String(source.recharge_result_message || '').trim(),
createdAt: String(source.created_at || '').trim(),
updatedAt: String(source.updated_at || '').trim(),
rechargeFinishAt: String(source.recharge_finish_at || '').trim(),
h5: {
entryUrl: String(h5.entry_url || '').trim(),
rechargeUrl: String(h5.recharge_url || '').trim(),
},
raw: source,
}
}
@@ -0,0 +1,54 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
export type KuaishouFeifeiProductMatch = {
matchMode: 'kuaishou_feifei_rule'
productName: string
normalizedProductName: string
productCode: string
skuName: string
}
export function resolveKuaishouFeifeiProductByName(
productName: unknown,
): KuaishouFeifeiProductMatch | null {
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
if (!normalizedProductName) {
return null
}
const rules = listKuaishouFeifeiProductRules()
const matched = rules.find((rule) =>
normalizeCloudtentaclesMatchName(rule.productName) === normalizedProductName,
)
if (!matched) {
return null
}
const productCode = String(matched.productCode || '').trim()
if (!productCode) {
return null
}
return {
matchMode: 'kuaishou_feifei_rule',
productName: String(matched.productName || productName || '').trim(),
normalizedProductName,
productCode,
skuName: String(matched.skuName || matched.productName || productName || productCode).trim(),
}
}
function listKuaishouFeifeiProductRules(): KuaishouFeifeiProductRule[] {
const rules = runtimeConfig.platforms?.kuaishouFeifei?.productRules
return (Array.isArray(rules) ? rules : [])
.map((rule) => ({
productName: String(rule.productName || '').trim(),
productCode: String(rule.productCode || '').trim(),
skuName: String(rule.skuName || '').trim(),
enabled: rule.enabled !== false,
}))
.filter((rule) => rule.enabled && rule.productName && rule.productCode)
}