接入多发货平台与电子凭证
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user