统一前后端代码格式化配置
This commit is contained in:
@@ -21,14 +21,19 @@ export function getAffiliateDashConfig(overrides: Partial<AffiliateDashRuntimeCo
|
||||
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || '').trim().replace(/\/+$/, ''),
|
||||
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),
|
||||
timestampToleranceSeconds: Math.max(
|
||||
1,
|
||||
Number(config.timestampToleranceSeconds || AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS) ||
|
||||
AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS,
|
||||
),
|
||||
preferredMatchEnabled: config.preferredMatchEnabled !== false,
|
||||
skuMapping: isRecord(config.skuMapping) ? config.skuMapping : {},
|
||||
}
|
||||
|
||||
@@ -95,15 +95,18 @@ export async function affiliateDashRequest(input: {
|
||||
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,
|
||||
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)) {
|
||||
|
||||
@@ -70,10 +70,7 @@ export async function getAffiliateDashDelivery(orderNo: string) {
|
||||
return mapAffiliateDashDeliveryInfo(json.data)
|
||||
}
|
||||
|
||||
export async function bindAffiliateDashDelivery(input: {
|
||||
orderNo: string
|
||||
gameAccount: string
|
||||
}) {
|
||||
export async function bindAffiliateDashDelivery(input: { orderNo: string; gameAccount: string }) {
|
||||
const json = await affiliateDashRequest({
|
||||
method: 'POST',
|
||||
pathname: `${ORDERS_PATH}/${encodeURIComponent(input.orderNo)}/delivery/bind`,
|
||||
@@ -82,10 +79,7 @@ export async function bindAffiliateDashDelivery(input: {
|
||||
return mapAffiliateDashBindResult(json.data)
|
||||
}
|
||||
|
||||
export async function getAffiliateDashBindResult(input: {
|
||||
orderNo: string
|
||||
bindUuid: string
|
||||
}) {
|
||||
export async function getAffiliateDashBindResult(input: { orderNo: string; bindUuid: string }) {
|
||||
const json = await affiliateDashRequest({
|
||||
method: 'GET',
|
||||
pathname:
|
||||
|
||||
@@ -3,10 +3,12 @@ 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
|
||||
} = {}) {
|
||||
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))
|
||||
@@ -23,9 +25,11 @@ export async function listAffiliateDashProducts(input: {
|
||||
}
|
||||
|
||||
/** 翻页拉取全部可售商品(默认每页 100)。 */
|
||||
export async function listAllAffiliateDashProducts(input: {
|
||||
pageSize?: number | undefined
|
||||
} = {}) {
|
||||
export async function listAllAffiliateDashProducts(
|
||||
input: {
|
||||
pageSize?: number | undefined
|
||||
} = {},
|
||||
) {
|
||||
const size = Math.max(1, Number(input.pageSize || 100) || 100)
|
||||
const products: AffiliateDashProduct[] = []
|
||||
let page = 1
|
||||
|
||||
@@ -86,10 +86,7 @@ test('verifyCallbackSign accepts valid sign and rejects tampering', () => {
|
||||
const timestamp = '1783394218'
|
||||
const sign = buildCallbackSign({ callbackSecret, rawBody, timestamp })
|
||||
|
||||
assert.equal(
|
||||
verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign }),
|
||||
true,
|
||||
)
|
||||
assert.equal(verifyCallbackSign({ callbackSecret, rawBody, timestamp, sign }), true)
|
||||
assert.equal(
|
||||
verifyCallbackSign({
|
||||
callbackSecret,
|
||||
|
||||
@@ -96,7 +96,12 @@ export function verifyCallbackSign(input: {
|
||||
timestamp: input.timestamp,
|
||||
})
|
||||
|
||||
return timingSafeEqualString(expected, String(input.sign || '').trim().toLowerCase())
|
||||
return timingSafeEqualString(
|
||||
expected,
|
||||
String(input.sign || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
}
|
||||
|
||||
export function timingSafeEqualString(left: string, right: string): boolean {
|
||||
|
||||
@@ -35,7 +35,9 @@ export function getAffiliateDashSourceConfig(): AffiliateDashSourceConfig {
|
||||
})
|
||||
}
|
||||
|
||||
export function saveAffiliateDashSourceConfig(rawValue: unknown): Promise<AffiliateDashSourceConfig> {
|
||||
export function saveAffiliateDashSourceConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<AffiliateDashSourceConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: AFFILIATE_DASH_CONFIG_KEY,
|
||||
value: rawValue,
|
||||
@@ -48,7 +50,9 @@ export function normalizeAffiliateDashSourceConfig(rawValue: unknown): Affiliate
|
||||
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
baseUrl: String(source.baseUrl || '').trim().replace(/\/+$/, ''),
|
||||
baseUrl: String(source.baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, ''),
|
||||
appKey: String(source.appKey || '').trim(),
|
||||
appSecret: String(source.appSecret || '').trim(),
|
||||
callbackSecret: String(source.callbackSecret || '').trim(),
|
||||
|
||||
@@ -59,7 +59,8 @@ test('verifyAffiliateDashCallback rejects tampered body', () => {
|
||||
|
||||
assert.throws(
|
||||
() => verifyAffiliateDashCallback(input, { callbackSecret: CALLBACK_SECRET }),
|
||||
(error: Error & { errorCode?: string }) => error.errorCode === 'affiliate_dash_callback_sign_invalid',
|
||||
(error: Error & { errorCode?: string }) =>
|
||||
error.errorCode === 'affiliate_dash_callback_sign_invalid',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -107,10 +108,9 @@ 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 },
|
||||
)
|
||||
const result = verifyAffiliateDashCallback(makeCallback({ rawBody, timestamp }), {
|
||||
callbackSecret: CALLBACK_SECRET,
|
||||
})
|
||||
|
||||
assert.equal(result.event, '')
|
||||
assert.deepEqual(result.data, {})
|
||||
|
||||
@@ -75,12 +75,14 @@ export function verifyAffiliateDashCallback(
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyCallbackSign({
|
||||
callbackSecret: config.callbackSecret,
|
||||
rawBody,
|
||||
timestamp,
|
||||
sign,
|
||||
})) {
|
||||
if (
|
||||
!verifyCallbackSign({
|
||||
callbackSecret: config.callbackSecret,
|
||||
rawBody,
|
||||
timestamp,
|
||||
sign,
|
||||
})
|
||||
) {
|
||||
throw createHttpError('affiliate-dash 回调验签失败', {
|
||||
statusCode: 401,
|
||||
errorCode: 'affiliate_dash_callback_sign_invalid',
|
||||
|
||||
@@ -4,7 +4,11 @@ import { resolveCloudtentaclesConfig } from './helpers.js'
|
||||
import type { JsonObject } from '../../../types/json.js'
|
||||
|
||||
export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 余额查询缺少 token', 'cloudtentacles_asset_missing_token')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 余额查询缺少 token',
|
||||
'cloudtentacles_asset_missing_token',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.assetPath, {
|
||||
@@ -14,7 +18,10 @@ export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
|
||||
contentType: 'application/json',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'asset_get', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'asset_get',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_asset_failed',
|
||||
})
|
||||
@@ -27,7 +34,11 @@ export async function getCloudtentaclesAsset(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 分类查询缺少 token', 'cloudtentacles_categories_missing_token')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 分类查询缺少 token',
|
||||
'cloudtentacles_categories_missing_token',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.categoriesPath, {
|
||||
@@ -37,7 +48,10 @@ export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
|
||||
contentType: 'application/json',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'categories_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'categories_list',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_categories_failed',
|
||||
})
|
||||
@@ -53,7 +67,11 @@ export async function getCloudtentaclesCategories(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function listCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles SKU 列表查询缺少 token', 'cloudtentacles_sku_list_missing_token')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles SKU 列表查询缺少 token',
|
||||
'cloudtentacles_sku_list_missing_token',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.skuListPath, {
|
||||
@@ -63,7 +81,10 @@ export async function listCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
contentType: 'application/json',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'sku_list', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'sku_list',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_sku_list_failed',
|
||||
})
|
||||
@@ -79,8 +100,16 @@ export async function listCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function buyCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 购买 SKU 缺少 token', 'cloudtentacles_sku_buy_missing_token')
|
||||
const skuId = requireId(payload.id, 'cloudtentacles 购买 SKU 缺少商品 id', 'cloudtentacles_sku_buy_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 购买 SKU 缺少 token',
|
||||
'cloudtentacles_sku_buy_missing_token',
|
||||
)
|
||||
const skuId = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 购买 SKU 缺少商品 id',
|
||||
'cloudtentacles_sku_buy_missing_id',
|
||||
)
|
||||
const count = requireCount(payload.count)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
@@ -114,8 +143,16 @@ export async function buyCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function useCloudtentaclesSku(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 发货缺少 token', 'cloudtentacles_sku_use_missing_token')
|
||||
const skuId = requireId(payload.id, 'cloudtentacles 发货缺少商品 id', 'cloudtentacles_sku_use_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 发货缺少 token',
|
||||
'cloudtentacles_sku_use_missing_token',
|
||||
)
|
||||
const skuId = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 发货缺少商品 id',
|
||||
'cloudtentacles_sku_use_missing_id',
|
||||
)
|
||||
const virtualNumberId = requireId(
|
||||
payload.virtualNumberId,
|
||||
'cloudtentacles 发货缺少虚拟号 id',
|
||||
|
||||
@@ -5,7 +5,9 @@ import { createHttpError } from '../../../utils/http.js'
|
||||
import { resolveCloudtentaclesConfig } from './helpers.js'
|
||||
|
||||
export function md5CloudtentaclesPassword(password: unknown) {
|
||||
return createHash('md5').update(String(password || ''), 'utf8').digest('hex')
|
||||
return createHash('md5')
|
||||
.update(String(password || ''), 'utf8')
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export function encryptCloudtentaclesPayload(payload: JsonObject = {}, options: JsonObject = {}) {
|
||||
@@ -20,7 +22,8 @@ export function encryptCloudtentaclesPayload(payload: JsonObject = {}, options:
|
||||
}
|
||||
|
||||
const timestamp = Number(options.timestamp || Date.now())
|
||||
const randomValue = String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16)
|
||||
const randomValue =
|
||||
String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16)
|
||||
const normalizedPayload = removeEmptyFields(payload)
|
||||
const plaintext = JSON.stringify({
|
||||
t: timestamp,
|
||||
@@ -59,6 +62,8 @@ function removeEmptyFields(payload: unknown) {
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== '' && value !== null && typeof value !== 'undefined'),
|
||||
Object.entries(payload).filter(
|
||||
([, value]) => value !== '' && value !== null && typeof value !== 'undefined',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { getCloudtentaclesAsset, listCloudtentaclesSku, buyCloudtentaclesSku } from './catalog-service.js'
|
||||
import {
|
||||
getCloudtentaclesAsset,
|
||||
listCloudtentaclesSku,
|
||||
buyCloudtentaclesSku,
|
||||
} from './catalog-service.js'
|
||||
import { getCloudtentaclesKnapsack } from './knapsack-service.js'
|
||||
import type { JsonObject } from '../../../types/json.js'
|
||||
import {
|
||||
@@ -55,15 +59,22 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
await sleep(350)
|
||||
const beforeKnapsack = await runFlowStep('查询购买前背包', () => getCloudtentaclesKnapsack(payload))
|
||||
const beforeKnapsack = await runFlowStep('查询购买前背包', () =>
|
||||
getCloudtentaclesKnapsack(payload),
|
||||
)
|
||||
const beforeItem = findKnapsackItem(beforeKnapsack.items, skuId)
|
||||
|
||||
await sleep(500)
|
||||
const buyResult = await runFlowStep('购买 SKU', () => buyCloudtentaclesSku({
|
||||
...payload,
|
||||
id: skuId,
|
||||
count: skuCount,
|
||||
}), { retries: 1, retryDelayMs: 1200 })
|
||||
const buyResult = await runFlowStep(
|
||||
'购买 SKU',
|
||||
() =>
|
||||
buyCloudtentaclesSku({
|
||||
...payload,
|
||||
id: skuId,
|
||||
count: skuCount,
|
||||
}),
|
||||
{ retries: 1, retryDelayMs: 1200 },
|
||||
)
|
||||
|
||||
await sleep(1200)
|
||||
const afterAsset = await runFlowStep('查询购买后余额', () => getCloudtentaclesAsset(payload), {
|
||||
@@ -71,10 +82,14 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
|
||||
retryDelayMs: 1200,
|
||||
})
|
||||
await sleep(1200)
|
||||
const afterKnapsack = await runFlowStep('查询购买后背包', () => getCloudtentaclesKnapsack(payload), {
|
||||
retries: 2,
|
||||
retryDelayMs: 1200,
|
||||
})
|
||||
const afterKnapsack = await runFlowStep(
|
||||
'查询购买后背包',
|
||||
() => getCloudtentaclesKnapsack(payload),
|
||||
{
|
||||
retries: 2,
|
||||
retryDelayMs: 1200,
|
||||
},
|
||||
)
|
||||
const afterItem = findKnapsackItem(afterKnapsack.items, skuId)
|
||||
|
||||
const beforeCount = Number(beforeItem?.count || 0)
|
||||
@@ -82,17 +97,25 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
|
||||
const knapsackIncreased = afterCount >= beforeCount + skuCount
|
||||
|
||||
if (!knapsackIncreased) {
|
||||
throw createHttpError(`购买后背包校验失败,购买前 ${beforeCount},购买后 ${afterCount},期望至少 ${beforeCount + skuCount}`, {
|
||||
statusCode: 409,
|
||||
errorCode: 'cloudtentacles_full_flow_knapsack_not_updated',
|
||||
})
|
||||
throw createHttpError(
|
||||
`购买后背包校验失败,购买前 ${beforeCount},购买后 ${afterCount},期望至少 ${beforeCount + skuCount}`,
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'cloudtentacles_full_flow_knapsack_not_updated',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
await sleep(700)
|
||||
const appointed = await runFlowStep('申请虚拟号', () => appointCloudtentaclesVirtualNumber({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
}), { retries: 1, retryDelayMs: 1200 })
|
||||
const appointed = await runFlowStep(
|
||||
'申请虚拟号',
|
||||
() =>
|
||||
appointCloudtentaclesVirtualNumber({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
}),
|
||||
{ retries: 1, retryDelayMs: 1200 },
|
||||
)
|
||||
const appointedId = Number(appointed.item?.id || 0)
|
||||
const appointedPhone = String(appointed.item?.phone || '').trim()
|
||||
|
||||
@@ -104,33 +127,53 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
await sleep(1200)
|
||||
const generateCodeResult = await runFlowStep('生成登录码', () => generateCloudtentaclesLoginCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
}), { retries: 1, retryDelayMs: 1200 })
|
||||
const generateCodeResult = await runFlowStep(
|
||||
'生成登录码',
|
||||
() =>
|
||||
generateCloudtentaclesLoginCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
}),
|
||||
{ retries: 1, retryDelayMs: 1200 },
|
||||
)
|
||||
|
||||
await sleep(1600)
|
||||
const fetchedCode = await runFlowStep('获取验证码', () => fetchCloudtentaclesVirtualNumberCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
phone: appointedPhone,
|
||||
}), { retries: 2, retryDelayMs: 1500 })
|
||||
const fetchedCode = await runFlowStep(
|
||||
'获取验证码',
|
||||
() =>
|
||||
fetchCloudtentaclesVirtualNumberCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
phone: appointedPhone,
|
||||
}),
|
||||
{ retries: 2, retryDelayMs: 1500 },
|
||||
)
|
||||
|
||||
await sleep(1000)
|
||||
const verified = await runFlowStep('校验验证码', () => verifyCloudtentaclesLoginCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
code: fetchedCode.code,
|
||||
}), { retries: 1, retryDelayMs: 1200 })
|
||||
const verified = await runFlowStep(
|
||||
'校验验证码',
|
||||
() =>
|
||||
verifyCloudtentaclesLoginCode({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
code: fetchedCode.code,
|
||||
}),
|
||||
{ retries: 1, retryDelayMs: 1200 },
|
||||
)
|
||||
|
||||
await sleep(1000)
|
||||
const bindUrlResult = await runFlowStep('获取兑换链接', () => getCloudtentaclesBindUrl({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
}), { retries: 1, retryDelayMs: 1200 })
|
||||
const bindUrlResult = await runFlowStep(
|
||||
'获取兑换链接',
|
||||
() =>
|
||||
getCloudtentaclesBindUrl({
|
||||
...payload,
|
||||
key: vnKey,
|
||||
id: appointedId,
|
||||
}),
|
||||
{ retries: 1, retryDelayMs: 1200 },
|
||||
)
|
||||
|
||||
return {
|
||||
sku: {
|
||||
@@ -173,7 +216,10 @@ function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
}
|
||||
|
||||
function findKnapsackItem(items: unknown, skuId: unknown) {
|
||||
return (Array.isArray(items) ? items : []).find((item) => Number(item.id || 0) === Number(skuId)) || null
|
||||
return (
|
||||
(Array.isArray(items) ? items : []).find((item) => Number(item.id || 0) === Number(skuId)) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function runFlowStep<T>(
|
||||
@@ -184,7 +230,8 @@ async function runFlowStep<T>(
|
||||
const rawRetries = Number(options.retries)
|
||||
const rawRetryDelayMs = Number(options.retryDelayMs)
|
||||
const retries = Number.isInteger(rawRetries) && rawRetries > 0 ? rawRetries : 0
|
||||
const retryDelayMs = Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000
|
||||
const retryDelayMs =
|
||||
Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
@@ -221,7 +268,9 @@ function wrapStepError(label: string, error: unknown) {
|
||||
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||
const statusCode = Number(Reflect.get(error, 'statusCode') || 500)
|
||||
const errorCode = String(Reflect.get(error, 'errorCode') || 'cloudtentacles_full_flow_step_failed')
|
||||
const errorCode = String(
|
||||
Reflect.get(error, 'errorCode') || 'cloudtentacles_full_flow_step_failed',
|
||||
)
|
||||
|
||||
return createHttpError(`cloudtentacles ${label}失败:${message}`, {
|
||||
statusCode,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_ID =
|
||||
"08bc9d8c-fd15-48ea-bc00-8d754076cafc";
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1;
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_ID = '08bc9d8c-fd15-48ea-bc00-8d754076cafc'
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1
|
||||
|
||||
export function normalizeCloudtentaclesDeviceId(value: unknown) {
|
||||
const normalized = String(value || "").trim();
|
||||
return normalized && normalized !== "-"
|
||||
? normalized
|
||||
: DEFAULT_CLOUDTENTACLES_DEVICE_ID;
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized && normalized !== '-' ? normalized : DEFAULT_CLOUDTENTACLES_DEVICE_ID
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesDeviceType(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE;
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CLOUDTENTACLES_DEVICE_TYPE
|
||||
}
|
||||
|
||||
@@ -52,46 +52,98 @@ export function resolveCloudtentaclesConfig(overrides: Partial<CloudtentaclesRun
|
||||
return {
|
||||
baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl || 'https://123.207.217.176'),
|
||||
timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000),
|
||||
sendSmsPath: normalizePath(overrides.sendSmsPath || baseConfig.sendSmsPath, '/public/verif_code'),
|
||||
sendSmsPath: normalizePath(
|
||||
overrides.sendSmsPath || baseConfig.sendSmsPath,
|
||||
'/public/verif_code',
|
||||
),
|
||||
loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/public/login'),
|
||||
userInfoPath: normalizePath(overrides.userInfoPath || baseConfig.userInfoPath, '/user/info'),
|
||||
assetPath: normalizePath(overrides.assetPath || baseConfig.assetPath, '/user/get_asset'),
|
||||
permissionPath: normalizePath(overrides.permissionPath || baseConfig.permissionPath, '/user/get_permission'),
|
||||
categoriesPath: normalizePath(overrides.categoriesPath || baseConfig.categoriesPath, '/categories/get'),
|
||||
permissionPath: normalizePath(
|
||||
overrides.permissionPath || baseConfig.permissionPath,
|
||||
'/user/get_permission',
|
||||
),
|
||||
categoriesPath: normalizePath(
|
||||
overrides.categoriesPath || baseConfig.categoriesPath,
|
||||
'/categories/get',
|
||||
),
|
||||
skuListPath: normalizePath(overrides.skuListPath || baseConfig.skuListPath, '/sku/list'),
|
||||
skuBuyPath: normalizePath(overrides.skuBuyPath || baseConfig.skuBuyPath, '/sku/buy'),
|
||||
skuUsePath: normalizePath(overrides.skuUsePath || baseConfig.skuUsePath, '/sku/use'),
|
||||
knapsackPath: normalizePath(overrides.knapsackPath || baseConfig.knapsackPath, '/user/get_knapsack'),
|
||||
knapsackPath: normalizePath(
|
||||
overrides.knapsackPath || baseConfig.knapsackPath,
|
||||
'/user/get_knapsack',
|
||||
),
|
||||
vnListPath: normalizePath(overrides.vnListPath || baseConfig.vnListPath, '/vn/list'),
|
||||
vnAppointPath: normalizePath(overrides.vnAppointPath || baseConfig.vnAppointPath, '/vn/appoint'),
|
||||
vnAppointPath: normalizePath(
|
||||
overrides.vnAppointPath || baseConfig.vnAppointPath,
|
||||
'/vn/appoint',
|
||||
),
|
||||
vnGenerateLoginCodePath: normalizePath(
|
||||
overrides.vnGenerateLoginCodePath || baseConfig.vnGenerateLoginCodePath,
|
||||
'/vn/generate_login_code',
|
||||
),
|
||||
vnVerifCodePath: normalizePath(overrides.vnVerifCodePath || baseConfig.vnVerifCodePath, '/public/vn_verif_code'),
|
||||
vnVerifCodePath: normalizePath(
|
||||
overrides.vnVerifCodePath || baseConfig.vnVerifCodePath,
|
||||
'/public/vn_verif_code',
|
||||
),
|
||||
vnVerifyLoginCodePath: normalizePath(
|
||||
overrides.vnVerifyLoginCodePath || baseConfig.vnVerifyLoginCodePath,
|
||||
'/vn/verify_login_code',
|
||||
),
|
||||
vnBindUrlPath: normalizePath(overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, '/vn/bind_url'),
|
||||
vnBindInfoPath: normalizePath(overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, '/vn/bind_info'),
|
||||
vnBindUrlPath: normalizePath(
|
||||
overrides.vnBindUrlPath || baseConfig.vnBindUrlPath,
|
||||
'/vn/bind_url',
|
||||
),
|
||||
vnBindInfoPath: normalizePath(
|
||||
overrides.vnBindInfoPath || baseConfig.vnBindInfoPath,
|
||||
'/vn/bind_info',
|
||||
),
|
||||
vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'),
|
||||
bindUrlTtlSeconds: normalizePositiveInteger(overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, 600),
|
||||
bindUrlTtlSeconds: normalizePositiveInteger(
|
||||
overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds,
|
||||
600,
|
||||
),
|
||||
bindUrlProbeIntervalSeconds: normalizePositiveInteger(
|
||||
overrides.bindUrlProbeIntervalSeconds || baseConfig.bindUrlProbeIntervalSeconds,
|
||||
30,
|
||||
),
|
||||
bindUrlProbeTimeoutMs: normalizePositiveInteger(overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, 5000),
|
||||
bindUrlProbeUserAgent: String(overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '').trim(),
|
||||
bindUrlProbeEndpoint: String(overrides.bindUrlProbeEndpoint || baseConfig.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim(),
|
||||
bindUrlProbeChartId: String(overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794').trim(),
|
||||
bindUrlProbeSubChartId: String(overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794').trim(),
|
||||
bindUrlProbeIdeToken: String(overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo').trim(),
|
||||
bindUrlProbeActivityUrl: String(overrides.bindUrlProbeActivityUrl || baseConfig.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim(),
|
||||
bindUrlProbeReferer: String(overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/').trim(),
|
||||
bindUrlProbeExtraCookie: String(overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '').trim(),
|
||||
bindUrlProbeTimeoutMs: normalizePositiveInteger(
|
||||
overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs,
|
||||
5000,
|
||||
),
|
||||
bindUrlProbeUserAgent: String(
|
||||
overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '',
|
||||
).trim(),
|
||||
bindUrlProbeEndpoint: String(
|
||||
overrides.bindUrlProbeEndpoint ||
|
||||
baseConfig.bindUrlProbeEndpoint ||
|
||||
'https://comm.ams.game.qq.com/ide/',
|
||||
).trim(),
|
||||
bindUrlProbeChartId: String(
|
||||
overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794',
|
||||
).trim(),
|
||||
bindUrlProbeSubChartId: String(
|
||||
overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794',
|
||||
).trim(),
|
||||
bindUrlProbeIdeToken: String(
|
||||
overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo',
|
||||
).trim(),
|
||||
bindUrlProbeActivityUrl: String(
|
||||
overrides.bindUrlProbeActivityUrl ||
|
||||
baseConfig.bindUrlProbeActivityUrl ||
|
||||
'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
|
||||
).trim(),
|
||||
bindUrlProbeReferer: String(
|
||||
overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/',
|
||||
).trim(),
|
||||
bindUrlProbeExtraCookie: String(
|
||||
overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '',
|
||||
).trim(),
|
||||
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
|
||||
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
|
||||
clientSource:
|
||||
String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() ||
|
||||
'ct-client',
|
||||
deviceId: normalizeCloudtentaclesDeviceId(overrides.deviceId ?? baseConfig.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(overrides.deviceType ?? baseConfig.deviceType),
|
||||
}
|
||||
@@ -102,7 +154,10 @@ export function buildCloudtentaclesUrl(
|
||||
pathname: unknown,
|
||||
searchParams: JsonObject | null = null,
|
||||
) {
|
||||
const url = new URL(normalizePath(pathname, '/'), normalizeBaseUrl(baseUrl) || 'https://123.207.217.176')
|
||||
const url = new URL(
|
||||
normalizePath(pathname, '/'),
|
||||
normalizeBaseUrl(baseUrl) || 'https://123.207.217.176',
|
||||
)
|
||||
|
||||
if (searchParams && typeof searchParams === 'object') {
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
@@ -146,7 +201,9 @@ export function buildCloudtentaclesHeaders({
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: unknown) {
|
||||
return String(value || '').trim().replace(/\/+$/, '')
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function normalizePath(value: unknown, fallback: string) {
|
||||
@@ -175,7 +232,12 @@ function normalizeHeaderMap(extra: unknown) {
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(extra)
|
||||
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
|
||||
.map(([key, value]) => [
|
||||
String(key || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
String(value || '').trim(),
|
||||
])
|
||||
.filter(([key, value]) => key && value),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,8 @@ test('cloudtentaclesRequest retries high-frequency business errors', async () =>
|
||||
const server = await createMockServer(() => {
|
||||
callCount += 1
|
||||
return {
|
||||
body: callCount === 1
|
||||
? { code: 1, message: 'high-frequency Request' }
|
||||
: { code: 0, data: 'ok' },
|
||||
body:
|
||||
callCount === 1 ? { code: 1, message: 'high-frequency Request' } : { code: 0, data: 'ok' },
|
||||
}
|
||||
})
|
||||
|
||||
@@ -155,15 +154,16 @@ async function createMockServer(handler: () => MockResponse) {
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address?.port}`,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { JsonObject } from '../../../types/json.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||
import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js'
|
||||
import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './helpers.js'
|
||||
import {
|
||||
buildCloudtentaclesHeaders,
|
||||
buildCloudtentaclesUrl,
|
||||
resolveCloudtentaclesConfig,
|
||||
} from './helpers.js'
|
||||
|
||||
type HeaderAdapter = {
|
||||
get(name: unknown): string | null
|
||||
@@ -32,10 +36,13 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
const config = resolveCloudtentaclesConfig(options)
|
||||
const url = buildCloudtentaclesUrl(config.baseUrl, normalizedPathname, options.searchParams)
|
||||
const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000)
|
||||
const method = String(options.method || 'GET').trim().toUpperCase()
|
||||
const method = String(options.method || 'GET')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const headers = buildCloudtentaclesHeaders({
|
||||
token: options.token,
|
||||
contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body),
|
||||
contentType:
|
||||
options.contentType === null ? '' : options.contentType || inferContentType(options.body),
|
||||
deviceId: options.deviceId ?? config.deviceId,
|
||||
deviceType: options.deviceType ?? config.deviceType,
|
||||
extra: options.headers,
|
||||
@@ -52,7 +59,10 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
options.rateLimitIntervalMs,
|
||||
resolveDefaultRateLimitIntervalMs(normalizedPathname),
|
||||
)
|
||||
const maxRetries = normalizeNonNegativeInteger(options.rateLimitRetries, DEFAULT_RATE_LIMIT_RETRIES)
|
||||
const maxRetries = normalizeNonNegativeInteger(
|
||||
options.rateLimitRetries,
|
||||
DEFAULT_RATE_LIMIT_RETRIES,
|
||||
)
|
||||
const retryDelayMs = normalizeNonNegativeInteger(
|
||||
options.rateLimitRetryDelayMs,
|
||||
resolveDefaultRateLimitRetryDelayMs(normalizedPathname),
|
||||
@@ -219,7 +229,9 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
|
||||
/** 导出供单测:识别上游限频文案/错误码(含中文 110001) */
|
||||
export function isHighFrequencyMessage(message: unknown, code: unknown = null) {
|
||||
const text = String(message || '').trim().toLowerCase()
|
||||
const text = String(message || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const codeText = String(code ?? '').trim()
|
||||
|
||||
if (codeText === '110001' || Number(code) === 110001) {
|
||||
@@ -243,8 +255,12 @@ export function isHighFrequencyMessage(message: unknown, code: unknown = null) {
|
||||
}
|
||||
|
||||
function isCloudtentaclesExpiredMessage(message: unknown) {
|
||||
const normalized = String(message || '').trim().toLowerCase()
|
||||
return normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效')
|
||||
const normalized = String(message || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return (
|
||||
normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效')
|
||||
)
|
||||
}
|
||||
|
||||
function isCloudtentaclesHttpRateLimited(status: unknown) {
|
||||
@@ -267,7 +283,9 @@ function resolveDefaultRateLimitRetryDelayMs(pathname: string) {
|
||||
}
|
||||
|
||||
function isBindInfoPath(pathname: string) {
|
||||
const normalized = String(pathname || '').trim().toLowerCase()
|
||||
const normalized = String(pathname || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return normalized.includes('bind_info') || normalized.endsWith('/vn/bind_info')
|
||||
}
|
||||
|
||||
@@ -445,7 +463,12 @@ function tryParseJson(text: string) {
|
||||
|
||||
async function requestViaNodeHttp(
|
||||
url: URL,
|
||||
{ method, headers, body, signal }: {
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal,
|
||||
}: {
|
||||
method: string
|
||||
headers: JsonObject
|
||||
body?: string | URLSearchParams
|
||||
@@ -456,27 +479,31 @@ async function requestViaNodeHttp(
|
||||
const transport = isHttps ? https : http
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = transport.request(url, {
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = []
|
||||
const request = transport.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf8')
|
||||
resolve({
|
||||
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
|
||||
status: Number(response.statusCode || 0),
|
||||
headers: createHeaderAdapter(response.headers),
|
||||
bodyText,
|
||||
response.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
const bodyText = Buffer.concat(chunks).toString('utf8')
|
||||
resolve({
|
||||
ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300,
|
||||
status: Number(response.statusCode || 0),
|
||||
headers: createHeaderAdapter(response.headers),
|
||||
bodyText,
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
request.on('error', reject)
|
||||
|
||||
@@ -486,11 +513,15 @@ async function requestViaNodeHttp(
|
||||
error.name = 'AbortError'
|
||||
request.destroy(error)
|
||||
} else {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('Request aborted')
|
||||
error.name = 'AbortError'
|
||||
request.destroy(error)
|
||||
}, { once: true })
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('Request aborted')
|
||||
error.name = 'AbortError'
|
||||
request.destroy(error)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ export async function getCloudtentaclesKnapsack(payload: JsonObject = {}) {
|
||||
contentType: 'application/json',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'knapsack_get', sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'knapsack_get',
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_knapsack_failed',
|
||||
})
|
||||
|
||||
@@ -26,11 +26,9 @@ export async function listCloudtentaclesDeliveryRecords(
|
||||
deviceId: payload.deviceId,
|
||||
deviceType: payload.deviceType,
|
||||
}),
|
||||
...(
|
||||
typeof payload.timeoutMs === 'undefined'
|
||||
? {}
|
||||
: { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) }
|
||||
),
|
||||
...(typeof payload.timeoutMs === 'undefined'
|
||||
? {}
|
||||
: { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) }),
|
||||
})
|
||||
const page = normalizePositiveInteger(payload.page, 1)
|
||||
const size = normalizePageSize(payload.size)
|
||||
|
||||
@@ -24,10 +24,13 @@ export async function sendCloudtentaclesSmsCode(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
const encrypted = encryptCloudtentaclesPayload({
|
||||
account: username,
|
||||
phone,
|
||||
}, config)
|
||||
const encrypted = encryptCloudtentaclesPayload(
|
||||
{
|
||||
account: username,
|
||||
phone,
|
||||
},
|
||||
config,
|
||||
)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.sendSmsPath, {
|
||||
...config,
|
||||
@@ -90,12 +93,15 @@ export async function loginCloudtentaclesSession(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
const encrypted = encryptCloudtentaclesPayload({
|
||||
account: username,
|
||||
password: md5CloudtentaclesPassword(password),
|
||||
phone,
|
||||
code,
|
||||
}, config)
|
||||
const encrypted = encryptCloudtentaclesPayload(
|
||||
{
|
||||
account: username,
|
||||
password: md5CloudtentaclesPassword(password),
|
||||
phone,
|
||||
code,
|
||||
},
|
||||
config,
|
||||
)
|
||||
|
||||
const loginResult = await cloudtentaclesRequest(config.loginPath, {
|
||||
...config,
|
||||
@@ -175,7 +181,9 @@ export async function validateCloudtentaclesSession(payload: JsonObject = {}) {
|
||||
userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {},
|
||||
asset: Number(assetResult.payload?.data || 0),
|
||||
permissions: Array.isArray(permissionResult.payload?.data)
|
||||
? permissionResult.payload.data.map((item: unknown) => String(item || '').trim()).filter(Boolean)
|
||||
? permissionResult.payload.data
|
||||
.map((item: unknown) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import type { JsonObject } from '../../../types/json.js'
|
||||
import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js'
|
||||
import { PROJECT_ROOT } from '../../../config/runtime.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import {
|
||||
readAppConfigEntry,
|
||||
saveAppConfigEntry,
|
||||
} from '../../config/app-config-store.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../../config/app-config-store.js'
|
||||
import {
|
||||
DEFAULT_CLOUDTENTACLES_DEVICE_ID,
|
||||
DEFAULT_CLOUDTENTACLES_DEVICE_TYPE,
|
||||
@@ -15,7 +12,11 @@ import {
|
||||
normalizeCloudtentaclesDeviceType,
|
||||
} from './defaults.js'
|
||||
|
||||
const CLOUDTENTACLES_SESSION_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-session.json')
|
||||
const CLOUDTENTACLES_SESSION_FILE_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'cloudtentacles-session.json',
|
||||
)
|
||||
|
||||
type CloudtentaclesSessionState = ReturnType<typeof createDefaultCloudtentaclesSessionState>
|
||||
type CloudtentaclesSessionStatesFile = {
|
||||
@@ -115,12 +116,14 @@ export async function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = [
|
||||
const allowedKeys = new Set(
|
||||
Array.isArray(sourceKeys)
|
||||
? sourceKeys.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
)
|
||||
|
||||
const states = loadCloudtentaclesSessionStates()
|
||||
states.sessions = Object.fromEntries(
|
||||
Object.entries(states.sessions || {}).filter(([key]) => allowedKeys.has(String(key || '').trim()))
|
||||
Object.entries(states.sessions || {}).filter(([key]) =>
|
||||
allowedKeys.has(String(key || '').trim()),
|
||||
),
|
||||
)
|
||||
|
||||
await saveCloudtentaclesSessionState(states)
|
||||
@@ -156,17 +159,21 @@ export function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSes
|
||||
if (isPlainObject(rawValue) && !rawValue.sessions) {
|
||||
return {
|
||||
sessions: {
|
||||
'default': normalizeCloudtentaclesSessionState(rawValue),
|
||||
default: normalizeCloudtentaclesSessionState(rawValue),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: isPlainObject(rawValue) && isPlainObject(rawValue.sessions)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawValue.sessions).map(([k, v]) => [k, normalizeCloudtentaclesSessionState(v)])
|
||||
)
|
||||
: {},
|
||||
sessions:
|
||||
isPlainObject(rawValue) && isPlainObject(rawValue.sessions)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawValue.sessions).map(([k, v]) => [
|
||||
k,
|
||||
normalizeCloudtentaclesSessionState(v),
|
||||
]),
|
||||
)
|
||||
: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +192,7 @@ function createDefaultCloudtentaclesSessionState() {
|
||||
function createDefaultCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile {
|
||||
return {
|
||||
sessions: {
|
||||
'default': createDefaultCloudtentaclesSessionState(),
|
||||
default: createDefaultCloudtentaclesSessionState(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,14 @@ import type { JsonObject } from '../../../types/json.js'
|
||||
import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js'
|
||||
import { PROJECT_ROOT } from '../../../config/runtime.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import {
|
||||
readAppConfigEntry,
|
||||
saveAppConfigEntry,
|
||||
} from '../../config/app-config-store.js'
|
||||
import {
|
||||
normalizeCloudtentaclesDeviceId,
|
||||
normalizeCloudtentaclesDeviceType,
|
||||
} from './defaults.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../../config/app-config-store.js'
|
||||
import { normalizeCloudtentaclesDeviceId, normalizeCloudtentaclesDeviceType } from './defaults.js'
|
||||
|
||||
const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-sources.json')
|
||||
const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'cloudtentacles-sources.json',
|
||||
)
|
||||
|
||||
export function getCloudtentaclesSourcesFilePath() {
|
||||
return CLOUDTENTACLES_SOURCES_FILE_PATH
|
||||
@@ -22,7 +20,7 @@ export function getCloudtentaclesSourcesFilePath() {
|
||||
// 兼容旧调用:返回 key='default' 的单账号配置。
|
||||
export function getCloudtentaclesSourceConfig() {
|
||||
const config = loadCloudtentaclesSourcesConfig()
|
||||
const defaultSource = config.sources.find(s => s.key === 'default')
|
||||
const defaultSource = config.sources.find((s) => s.key === 'default')
|
||||
return defaultSource || normalizeCloudtentaclesSourceItem({ key: 'default' })
|
||||
}
|
||||
|
||||
@@ -31,7 +29,7 @@ export function getCloudtentaclesSourceByKey(sourceKey: unknown) {
|
||||
const config = loadCloudtentaclesSourcesConfig()
|
||||
const key = String(sourceKey || '').trim()
|
||||
if (!key) return null
|
||||
return config.sources.find(s => s.key === key) || null
|
||||
return config.sources.find((s) => s.key === key) || null
|
||||
}
|
||||
|
||||
// 返回完整的多账号配置。
|
||||
@@ -67,7 +65,7 @@ export async function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: Js
|
||||
|
||||
const config = loadCloudtentaclesSourcesConfig()
|
||||
const normalizedItem = normalizeCloudtentaclesSourceItem({ ...data, key })
|
||||
const existingIndex = config.sources.findIndex(s => s.key === key)
|
||||
const existingIndex = config.sources.findIndex((s) => s.key === key)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
config.sources[existingIndex] = normalizedItem
|
||||
@@ -90,7 +88,7 @@ export async function deleteCloudtentaclesSourceByKey(sourceKey: unknown) {
|
||||
}
|
||||
|
||||
const config = loadCloudtentaclesSourcesConfig()
|
||||
const existingIndex = config.sources.findIndex(s => s.key === key)
|
||||
const existingIndex = config.sources.findIndex((s) => s.key === key)
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return config
|
||||
@@ -119,7 +117,8 @@ function normalizeCloudtentaclesSourceItem(rawValue: unknown) {
|
||||
key: String(source.key || 'default').trim() || 'default',
|
||||
label: String(source.label || '').trim(),
|
||||
enabled: source.enabled !== false,
|
||||
baseUrl: String(source.baseUrl || 'https://123.207.217.176').trim() || 'https://123.207.217.176',
|
||||
baseUrl:
|
||||
String(source.baseUrl || 'https://123.207.217.176').trim() || 'https://123.207.217.176',
|
||||
username: String(source.username || '').trim(),
|
||||
password: String(source.password || '').trim(),
|
||||
phone: String(source.phone || '').trim(),
|
||||
@@ -137,7 +136,7 @@ export function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) {
|
||||
normalizeCloudtentaclesSourceItem({
|
||||
key: 'default',
|
||||
label: '默认账号',
|
||||
...rawValue, // 旧字段自动映射为 default 账号。
|
||||
...rawValue, // 旧字段自动映射为 default 账号。
|
||||
}),
|
||||
].filter(Boolean),
|
||||
}
|
||||
@@ -145,18 +144,17 @@ export function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) {
|
||||
|
||||
return {
|
||||
enabled: isPlainObject(rawValue) ? rawValue.enabled !== false : true,
|
||||
sources: isPlainObject(rawValue) && Array.isArray(rawValue.sources)
|
||||
? rawValue.sources.map(s => normalizeCloudtentaclesSourceItem(s)).filter(Boolean)
|
||||
: [],
|
||||
sources:
|
||||
isPlainObject(rawValue) && Array.isArray(rawValue.sources)
|
||||
? rawValue.sources.map((s) => normalizeCloudtentaclesSourceItem(s)).filter(Boolean)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultCloudtentaclesSourcesConfig() {
|
||||
return {
|
||||
enabled: true,
|
||||
sources: [
|
||||
normalizeCloudtentaclesSourceItem({ key: 'default', label: '默认账号' }),
|
||||
],
|
||||
sources: [normalizeCloudtentaclesSourceItem({ key: 'default', label: '默认账号' })],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,16 @@ type AmsSignatureParams = {
|
||||
}
|
||||
|
||||
export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 虚拟号列表缺少 token', 'cloudtentacles_vn_list_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 虚拟号列表缺少 key', 'cloudtentacles_vn_list_missing_key')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 虚拟号列表缺少 token',
|
||||
'cloudtentacles_vn_list_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 虚拟号列表缺少 key',
|
||||
'cloudtentacles_vn_list_missing_key',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.vnListPath, {
|
||||
@@ -52,13 +60,21 @@ export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {})
|
||||
body: { key },
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_list', vnKey: key, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_list',
|
||||
vnKey: key,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_vn_list_failed',
|
||||
})
|
||||
|
||||
const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapVirtualNumberItem) : []
|
||||
const occupiedItemCount = items.filter((item: ReturnType<typeof mapVirtualNumberItem>) => item.status !== 0).length
|
||||
const items = Array.isArray(result.payload?.data)
|
||||
? result.payload.data.map(mapVirtualNumberItem)
|
||||
: []
|
||||
const occupiedItemCount = items.filter(
|
||||
(item: ReturnType<typeof mapVirtualNumberItem>) => item.status !== 0,
|
||||
).length
|
||||
|
||||
return {
|
||||
baseUrl: config.baseUrl,
|
||||
@@ -71,8 +87,16 @@ export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {})
|
||||
}
|
||||
|
||||
export async function appointCloudtentaclesVirtualNumber(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 申请虚拟号缺少 token', 'cloudtentacles_vn_appoint_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 申请虚拟号缺少 key', 'cloudtentacles_vn_appoint_missing_key')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 申请虚拟号缺少 token',
|
||||
'cloudtentacles_vn_appoint_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 申请虚拟号缺少 key',
|
||||
'cloudtentacles_vn_appoint_missing_key',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.vnAppointPath, {
|
||||
@@ -82,7 +106,11 @@ export async function appointCloudtentaclesVirtualNumber(payload: JsonObject = {
|
||||
body: { key },
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_appoint', vnKey: key, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_appoint',
|
||||
vnKey: key,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_vn_appoint_failed',
|
||||
})
|
||||
@@ -96,9 +124,21 @@ export async function appointCloudtentaclesVirtualNumber(payload: JsonObject = {
|
||||
}
|
||||
|
||||
export async function generateCloudtentaclesLoginCode(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 生成登录码缺少 token', 'cloudtentacles_vn_generate_code_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 生成登录码缺少 key', 'cloudtentacles_vn_generate_code_missing_key')
|
||||
const id = requireId(payload.id, 'cloudtentacles 生成登录码缺少 id', 'cloudtentacles_vn_generate_code_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 生成登录码缺少 token',
|
||||
'cloudtentacles_vn_generate_code_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 生成登录码缺少 key',
|
||||
'cloudtentacles_vn_generate_code_missing_key',
|
||||
)
|
||||
const id = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 生成登录码缺少 id',
|
||||
'cloudtentacles_vn_generate_code_missing_id',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.vnGenerateLoginCodePath, {
|
||||
@@ -108,7 +148,12 @@ export async function generateCloudtentaclesLoginCode(payload: JsonObject = {})
|
||||
body: { key, id },
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_generate_code', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_generate_code',
|
||||
vnKey: key,
|
||||
vnId: id,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_vn_generate_code_failed',
|
||||
})
|
||||
@@ -122,8 +167,16 @@ export async function generateCloudtentaclesLoginCode(payload: JsonObject = {})
|
||||
}
|
||||
|
||||
export async function fetchCloudtentaclesVirtualNumberCode(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 获取验证码缺少 token', 'cloudtentacles_vn_verif_code_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 获取验证码缺少 key', 'cloudtentacles_vn_verif_code_missing_key')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 获取验证码缺少 token',
|
||||
'cloudtentacles_vn_verif_code_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 获取验证码缺少 key',
|
||||
'cloudtentacles_vn_verif_code_missing_key',
|
||||
)
|
||||
const phone = String(payload.phone || '').trim()
|
||||
if (!phone) {
|
||||
throw createHttpError('cloudtentacles 获取验证码缺少手机号', {
|
||||
@@ -140,7 +193,12 @@ export async function fetchCloudtentaclesVirtualNumberCode(payload: JsonObject =
|
||||
body: { key, phone },
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_fetch_code', vnKey: key, phoneMasked: maskPhone(phone), sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_fetch_code',
|
||||
vnKey: key,
|
||||
phoneMasked: maskPhone(phone),
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_vn_verif_code_failed',
|
||||
})
|
||||
@@ -155,9 +213,21 @@ export async function fetchCloudtentaclesVirtualNumberCode(payload: JsonObject =
|
||||
}
|
||||
|
||||
export async function verifyCloudtentaclesLoginCode(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 校验登录码缺少 token', 'cloudtentacles_vn_verify_code_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 校验登录码缺少 key', 'cloudtentacles_vn_verify_code_missing_key')
|
||||
const id = requireId(payload.id, 'cloudtentacles 校验登录码缺少 id', 'cloudtentacles_vn_verify_code_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 校验登录码缺少 token',
|
||||
'cloudtentacles_vn_verify_code_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 校验登录码缺少 key',
|
||||
'cloudtentacles_vn_verify_code_missing_key',
|
||||
)
|
||||
const id = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 校验登录码缺少 id',
|
||||
'cloudtentacles_vn_verify_code_missing_id',
|
||||
)
|
||||
const code = String(payload.code || '').trim()
|
||||
if (!code) {
|
||||
throw createHttpError('cloudtentacles 校验登录码缺少验证码', {
|
||||
@@ -174,7 +244,12 @@ export async function verifyCloudtentaclesLoginCode(payload: JsonObject = {}) {
|
||||
body: { key, id, code },
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_verify_code', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_verify_code',
|
||||
vnKey: key,
|
||||
vnId: id,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
businessErrorStatusCode: 401,
|
||||
businessErrorCode: 'cloudtentacles_vn_verify_code_failed',
|
||||
})
|
||||
@@ -189,9 +264,21 @@ export async function verifyCloudtentaclesLoginCode(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function getCloudtentaclesBindUrl(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 获取兑换链接缺少 token', 'cloudtentacles_vn_bind_url_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 获取兑换链接缺少 key', 'cloudtentacles_vn_bind_url_missing_key')
|
||||
const id = requireId(payload.id, 'cloudtentacles 获取兑换链接缺少 id', 'cloudtentacles_vn_bind_url_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 获取兑换链接缺少 token',
|
||||
'cloudtentacles_vn_bind_url_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 获取兑换链接缺少 key',
|
||||
'cloudtentacles_vn_bind_url_missing_key',
|
||||
)
|
||||
const id = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 获取兑换链接缺少 id',
|
||||
'cloudtentacles_vn_bind_url_missing_id',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.vnBindUrlPath, {
|
||||
@@ -247,7 +334,9 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) {
|
||||
|
||||
const timeoutMs = Number(payload.timeoutMs || config.bindUrlProbeTimeoutMs || 5000)
|
||||
const userAgent = String(payload.userAgent || config.bindUrlProbeUserAgent || '').trim()
|
||||
const endpoint = String(payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim()
|
||||
const endpoint = String(
|
||||
payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/',
|
||||
).trim()
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
@@ -262,10 +351,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) {
|
||||
const expired = isAmsSignatureExpired(raw)
|
||||
const bindInfo = extractAmsBindInfo(raw)
|
||||
const roleInfo = normalizeAmsBindRoleInfo(bindInfo)
|
||||
const ok = response.status >= 200
|
||||
&& response.status < 300
|
||||
&& !expired
|
||||
&& isAmsBusinessOk(raw)
|
||||
const ok = response.status >= 200 && response.status < 300 && !expired && isAmsBusinessOk(raw)
|
||||
|
||||
return {
|
||||
valid: ok,
|
||||
@@ -275,7 +361,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) {
|
||||
ret: String(raw?.ret ?? raw?.iRet ?? ''),
|
||||
iRet: String(raw?.iRet ?? raw?.jData?.iRet ?? ''),
|
||||
message: String(raw?.sMsg || raw?.jData?.sMsg || ''),
|
||||
reason: expired ? 'signature_expired' : (ok ? 'ok' : 'ams_business_not_ok'),
|
||||
reason: expired ? 'signature_expired' : ok ? 'ok' : 'ams_business_not_ok',
|
||||
roleInfo,
|
||||
raw,
|
||||
durationMs: Date.now() - startedAt,
|
||||
@@ -295,9 +381,21 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 获取绑定信息缺少 token', 'cloudtentacles_vn_bind_info_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 获取绑定信息缺少 key', 'cloudtentacles_vn_bind_info_missing_key')
|
||||
const id = requireId(payload.id, 'cloudtentacles 获取绑定信息缺少 id', 'cloudtentacles_vn_bind_info_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 获取绑定信息缺少 token',
|
||||
'cloudtentacles_vn_bind_info_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 获取绑定信息缺少 key',
|
||||
'cloudtentacles_vn_bind_info_missing_key',
|
||||
)
|
||||
const id = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 获取绑定信息缺少 id',
|
||||
'cloudtentacles_vn_bind_info_missing_id',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
const sourceKey = String(payload.sourceKey || '').trim()
|
||||
const cacheKey = `${sourceKey || token}|${id}|${key}`
|
||||
@@ -325,11 +423,17 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) {
|
||||
rateLimitIsolatePath: true,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey,
|
||||
accountLabel: payload.accountLabel,
|
||||
context: { operation: 'vn_bind_info', vnKey: key, vnId: id, sourceKey: payload.sourceKey || payload.resolvedSourceKey || '' },
|
||||
context: {
|
||||
operation: 'vn_bind_info',
|
||||
vnKey: key,
|
||||
vnId: id,
|
||||
sourceKey: payload.sourceKey || payload.resolvedSourceKey || '',
|
||||
},
|
||||
})
|
||||
|
||||
const items = Array.isArray(result.payload?.data) ? result.payload.data : []
|
||||
const matchedItem = items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {}
|
||||
const matchedItem =
|
||||
items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {}
|
||||
const bindInfo = parseBindInfo(matchedItem?.bind_info)
|
||||
|
||||
const value: BindInfoResult = {
|
||||
@@ -359,9 +463,21 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) {
|
||||
}
|
||||
|
||||
export async function backCloudtentaclesVirtualNumber(payload: JsonObject = {}) {
|
||||
const token = requireToken(payload.token, 'cloudtentacles 退还号码缺少 token', 'cloudtentacles_vn_back_missing_token')
|
||||
const key = requireKey(payload.key, 'cloudtentacles 退还号码缺少 key', 'cloudtentacles_vn_back_missing_key')
|
||||
const id = requireId(payload.id, 'cloudtentacles 退还号码缺少 id', 'cloudtentacles_vn_back_missing_id')
|
||||
const token = requireToken(
|
||||
payload.token,
|
||||
'cloudtentacles 退还号码缺少 token',
|
||||
'cloudtentacles_vn_back_missing_token',
|
||||
)
|
||||
const key = requireKey(
|
||||
payload.key,
|
||||
'cloudtentacles 退还号码缺少 key',
|
||||
'cloudtentacles_vn_back_missing_key',
|
||||
)
|
||||
const id = requireId(
|
||||
payload.id,
|
||||
'cloudtentacles 退还号码缺少 id',
|
||||
'cloudtentacles_vn_back_missing_id',
|
||||
)
|
||||
const config = resolveCloudtentaclesConfig(payload)
|
||||
|
||||
const result = await cloudtentaclesRequest(config.vnBackPath, {
|
||||
@@ -454,7 +570,10 @@ function parseBindInfo(value: unknown) {
|
||||
return tryParseJson(bindInfoText) || bindInfoText || null
|
||||
}
|
||||
|
||||
async function requestAmsIdeProbe(endpoint: string, options: JsonObject = {}): Promise<AmsProbeResponse> {
|
||||
async function requestAmsIdeProbe(
|
||||
endpoint: string,
|
||||
options: JsonObject = {},
|
||||
): Promise<AmsProbeResponse> {
|
||||
const url = new URL(endpoint)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('unsupported_ams_probe_protocol')
|
||||
@@ -463,41 +582,46 @@ async function requestAmsIdeProbe(endpoint: string, options: JsonObject = {}): P
|
||||
const timeoutMs = Number(options.timeoutMs || 5000)
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const transport = isHttps ? https : http
|
||||
const body = options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '')
|
||||
const body =
|
||||
options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = transport.request(url, {
|
||||
method: 'POST',
|
||||
rejectUnauthorized: false,
|
||||
headers: {
|
||||
...normalizeHeaderMap(options.headers),
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'accept-encoding': 'identity',
|
||||
const request = transport.request(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
rejectUnauthorized: false,
|
||||
headers: {
|
||||
...normalizeHeaderMap(options.headers),
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'accept-encoding': 'identity',
|
||||
},
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = []
|
||||
let receivedBytes = 0
|
||||
(response) => {
|
||||
const chunks: Buffer[] = []
|
||||
let receivedBytes = 0
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) {
|
||||
return
|
||||
}
|
||||
response.on('data', (chunk) => {
|
||||
if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) {
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes
|
||||
chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer)
|
||||
receivedBytes += Math.min(buffer.length, remainingBytes)
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
status: Number(response.statusCode || 0),
|
||||
headers: createHeaderAdapter(response.headers),
|
||||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes
|
||||
chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer)
|
||||
receivedBytes += Math.min(buffer.length, remainingBytes)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
clearTimeout(timer)
|
||||
resolve({
|
||||
status: Number(response.statusCode || 0),
|
||||
headers: createHeaderAdapter(response.headers),
|
||||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
request.destroy(new Error('bind_url_probe_timeout'))
|
||||
@@ -534,7 +658,11 @@ function createHeaderAdapter(headers: http.IncomingHttpHeaders): HeaderAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAmsIdeProbeBody(signatureParams: AmsSignatureParams, config: JsonObject, payload: JsonObject = {}) {
|
||||
function buildAmsIdeProbeBody(
|
||||
signatureParams: AmsSignatureParams,
|
||||
config: JsonObject,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const body = new URLSearchParams()
|
||||
const chartId = String(payload.chartId || config.bindUrlProbeChartId || '323794').trim()
|
||||
const subChartId = String(payload.subChartId || config.bindUrlProbeSubChartId || chartId).trim()
|
||||
@@ -544,8 +672,20 @@ function buildAmsIdeProbeBody(signatureParams: AmsSignatureParams, config: JsonO
|
||||
body.set('sIdeToken', String(payload.ideToken || config.bindUrlProbeIdeToken || 'z90Syo').trim())
|
||||
body.set('e_code', '0')
|
||||
body.set('g_code', '0')
|
||||
body.set('eas_url', String(payload.easUrl || config.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim())
|
||||
body.set('eas_refer', String(payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`).trim())
|
||||
body.set(
|
||||
'eas_url',
|
||||
String(
|
||||
payload.easUrl ||
|
||||
config.bindUrlProbeActivityUrl ||
|
||||
'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
|
||||
).trim(),
|
||||
)
|
||||
body.set(
|
||||
'eas_refer',
|
||||
String(
|
||||
payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`,
|
||||
).trim(),
|
||||
)
|
||||
body.set('sMiloTag', String(payload.miloTag || `AMS-gp-${Date.now()}`).trim())
|
||||
body.set('userId', signatureParams.userId)
|
||||
body.set('timestamp', signatureParams.timestamp)
|
||||
@@ -608,7 +748,9 @@ function extractBindUrlSignatureParams(bindUrl: string): AmsSignatureParams | nu
|
||||
}
|
||||
const tokenParams = String(params.get('tokenParams') || '').trim()
|
||||
if (tokenParams) {
|
||||
const nested = new URLSearchParams(tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams)
|
||||
const nested = new URLSearchParams(
|
||||
tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams,
|
||||
)
|
||||
for (const [key, value] of nested.entries()) {
|
||||
if (!params.has(key)) {
|
||||
params.set(key, value)
|
||||
@@ -653,21 +795,18 @@ function isAmsSignatureExpired(raw: any) {
|
||||
raw?.jData?.arrErrNodeInfo?.errorCode,
|
||||
].map((value) => String(value ?? '').trim())
|
||||
|
||||
const messages = [
|
||||
raw?.sMsg,
|
||||
raw?.jData?.sMsg,
|
||||
raw?.jData?.jData?.sMsg,
|
||||
].map((value) => String(value ?? '').trim())
|
||||
const messages = [raw?.sMsg, raw?.jData?.sMsg, raw?.jData?.jData?.sMsg].map((value) =>
|
||||
String(value ?? '').trim(),
|
||||
)
|
||||
|
||||
return codes.includes(AMS_SIGNATURE_EXPIRED_CODE) || messages.some((message) => message.includes('签名已过期'))
|
||||
return (
|
||||
codes.includes(AMS_SIGNATURE_EXPIRED_CODE) ||
|
||||
messages.some((message) => message.includes('签名已过期'))
|
||||
)
|
||||
}
|
||||
|
||||
function extractAmsBindInfo(raw: any) {
|
||||
const candidates = [
|
||||
raw?.jData?.sBindInfo,
|
||||
raw?.jData?.jData?.sBindInfo,
|
||||
raw?.sBindInfo,
|
||||
]
|
||||
const candidates = [raw?.jData?.sBindInfo, raw?.jData?.jData?.sBindInfo, raw?.sBindInfo]
|
||||
|
||||
return candidates.find((item) => isPlainObject(item)) || null
|
||||
}
|
||||
@@ -689,7 +828,12 @@ function normalizeHeaderMap(headers: unknown) {
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers)
|
||||
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
|
||||
.map(([key, value]) => [
|
||||
String(key || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
String(value || '').trim(),
|
||||
])
|
||||
.filter(([key, value]) => key && value),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
|
||||
|
||||
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
|
||||
const runtimeValue = runtimeConfig.platforms?.kuaishouFeifei || {}
|
||||
const savedValue = hasKuaishouFeifeiConfigFile()
|
||||
? getKuaishouFeifeiSourceConfig()
|
||||
: null
|
||||
const savedValue = hasKuaishouFeifeiConfigFile() ? getKuaishouFeifeiSourceConfig() : null
|
||||
const config = mergeKuaishouFeifeiConfig(runtimeValue, savedValue, overrides)
|
||||
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
|
||||
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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js'
|
||||
|
||||
@@ -44,13 +44,15 @@ export async function handleKuaishouFeifeiNotify(input: {
|
||||
})
|
||||
}
|
||||
|
||||
if (!verifyKuaishouFeifeiNotifySignature({
|
||||
appKey,
|
||||
appSecret: config.appSecret,
|
||||
timestamp,
|
||||
rawBody,
|
||||
sign,
|
||||
})) {
|
||||
if (
|
||||
!verifyKuaishouFeifeiNotifySignature({
|
||||
appKey,
|
||||
appSecret: config.appSecret,
|
||||
timestamp,
|
||||
rawBody,
|
||||
sign,
|
||||
})
|
||||
) {
|
||||
throw createHttpError('kuaishou-feifei 通知验签失败', {
|
||||
statusCode: 401,
|
||||
errorCode: 'kuaishou_feifei_notify_sign_invalid',
|
||||
@@ -129,7 +131,12 @@ export function verifyKuaishouFeifeiNotifySignature(input: {
|
||||
body: input.rawBody,
|
||||
})
|
||||
|
||||
return timingSafeEqualString(expected, String(input.sign || '').trim().toLowerCase())
|
||||
return timingSafeEqualString(
|
||||
expected,
|
||||
String(input.sign || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string | string[] | undefined) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { JsonObject } from '../../../types/json.js'
|
||||
|
||||
export type KuaishouFeifeiProductListResult = ReturnType<typeof mapKuaishouFeifeiProductList>
|
||||
|
||||
export async function listKuaishouFeifeiProducts(input: {
|
||||
page?: number
|
||||
perPage?: number
|
||||
status?: string
|
||||
supplyProductName?: string
|
||||
} = {}) {
|
||||
export async function listKuaishouFeifeiProducts(
|
||||
input: {
|
||||
page?: number
|
||||
perPage?: number
|
||||
status?: string
|
||||
supplyProductName?: string
|
||||
} = {},
|
||||
) {
|
||||
const page = Math.max(1, Number(input.page || 1) || 1)
|
||||
const perPage = Math.min(100, Math.max(1, Number(input.perPage || 20) || 20))
|
||||
const filters: JsonObject = {}
|
||||
@@ -72,10 +74,9 @@ export async function queryKuaishouFeifeiOrder(input: {
|
||||
}
|
||||
|
||||
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 : {}
|
||||
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(),
|
||||
@@ -100,9 +101,8 @@ export function mapKuaishouFeifeiOrder(value: unknown) {
|
||||
}
|
||||
|
||||
export function mapKuaishouFeifeiProductList(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||
const list = Array.isArray(source.list) ? source.list : []
|
||||
|
||||
return {
|
||||
@@ -115,9 +115,8 @@ export function mapKuaishouFeifeiProductList(value: unknown) {
|
||||
}
|
||||
|
||||
export function mapKuaishouFeifeiProduct(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const source =
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||||
|
||||
return {
|
||||
productCode: String(source.product_code || '').trim(),
|
||||
@@ -131,7 +130,8 @@ export function mapKuaishouFeifeiProduct(value: unknown) {
|
||||
feePoints: Number(source.fee_points || 0) || 0,
|
||||
unitCostPoints: Number(source.unit_cost_points || 0) || 0,
|
||||
maxOrderQuantity: Number(source.max_order_quantity || 0) || 0,
|
||||
salePricePoints: source.sale_price_points == null ? null : Number(source.sale_price_points || 0) || 0,
|
||||
salePricePoints:
|
||||
source.sale_price_points == null ? null : Number(source.sale_price_points || 0) || 0,
|
||||
raw: source,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ export function resolveKuaishouFeifeiProductByName(
|
||||
}
|
||||
|
||||
const rules = listKuaishouFeifeiProductRules(options)
|
||||
const matched = rules.find((rule) =>
|
||||
normalizeCloudtentaclesMatchName(rule.productName || rule.normalizedProductName) === normalizedProductName,
|
||||
const matched = rules.find(
|
||||
(rule) =>
|
||||
normalizeCloudtentaclesMatchName(rule.productName || rule.normalizedProductName) ===
|
||||
normalizedProductName,
|
||||
)
|
||||
|
||||
if (!matched) {
|
||||
|
||||
@@ -32,10 +32,7 @@ export function getKuaishouFeifeiConfigFilePath() {
|
||||
}
|
||||
|
||||
export function hasKuaishouFeifeiConfigFile() {
|
||||
return hasAppConfigEntry(
|
||||
APP_CONFIG_KEYS.kuaishouFeifei,
|
||||
KUAISHOU_FEIFEI_CONFIG_FILE_PATH,
|
||||
)
|
||||
return hasAppConfigEntry(APP_CONFIG_KEYS.kuaishouFeifei, KUAISHOU_FEIFEI_CONFIG_FILE_PATH)
|
||||
}
|
||||
|
||||
export function getKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig {
|
||||
@@ -47,7 +44,9 @@ export function getKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig {
|
||||
})
|
||||
}
|
||||
|
||||
export function saveKuaishouFeifeiSourceConfig(rawValue: unknown): Promise<KuaishouFeifeiSourceConfig> {
|
||||
export function saveKuaishouFeifeiSourceConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<KuaishouFeifeiSourceConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.kuaishouFeifei,
|
||||
legacyFilePath: KUAISHOU_FEIFEI_CONFIG_FILE_PATH,
|
||||
|
||||
@@ -33,8 +33,10 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustrySou
|
||||
sellerId: String(config.sellerId || '').trim(),
|
||||
lastRefreshedAt: String(config.lastRefreshedAt || '').trim(),
|
||||
lastRefreshError: String(config.lastRefreshError || '').trim(),
|
||||
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||
platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
|
||||
provider:
|
||||
String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||
platform:
|
||||
String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
|
||||
shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||
shopName: String(config.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证',
|
||||
version: String(config.version || '1').trim() || '1',
|
||||
|
||||
+13
-9
@@ -6,11 +6,13 @@ import { buildConsumeCallbackBizParams } from './consume-callback-service.js'
|
||||
test('buildConsumeCallbackBizParams omits unsupported consume fields', () => {
|
||||
const bizParams = buildConsumeCallbackBizParams({
|
||||
oid: '2619000086068982',
|
||||
etickets: [{
|
||||
id: 'KSVASXT6Q955WT4SS36',
|
||||
num: 1,
|
||||
status: 'CONSUMED',
|
||||
} as any],
|
||||
etickets: [
|
||||
{
|
||||
id: 'KSVASXT6Q955WT4SS36',
|
||||
num: 1,
|
||||
status: 'CONSUMED',
|
||||
} as any,
|
||||
],
|
||||
status: 'CONSUMED',
|
||||
consumeType: 'consume',
|
||||
consumeTime: 1783596649024,
|
||||
@@ -19,10 +21,12 @@ test('buildConsumeCallbackBizParams omits unsupported consume fields', () => {
|
||||
seriallNum: 'CONSUME-KSVASXT6Q955WT4SS36',
|
||||
} as any)
|
||||
|
||||
assert.deepEqual(bizParams.etickets, [{
|
||||
id: 'KSVASXT6Q955WT4SS36',
|
||||
num: 1,
|
||||
}])
|
||||
assert.deepEqual(bizParams.etickets, [
|
||||
{
|
||||
id: 'KSVASXT6Q955WT4SS36',
|
||||
num: 1,
|
||||
},
|
||||
])
|
||||
assert.equal(bizParams.consumeType, 'consume')
|
||||
assert.equal('eticketType' in bizParams, false)
|
||||
})
|
||||
|
||||
@@ -4,16 +4,10 @@ import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import type { JsonObject } from '../../../types/json.js'
|
||||
import {
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
import { getKuaishouIndustryConfig, assertMatchingAppKey } from './config.js'
|
||||
import { normalizeConsumeCodePayload, assertConsumeCodePayload } from './payload.js'
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
} from './response.js'
|
||||
import { buildIndustrySuccessResponse, buildIndustryErrorResponse } from './response.js'
|
||||
import { consumeCallback } from './consume-callback-service.js'
|
||||
import { consumeKuaishouIndustryVoucher } from './voucher-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
@@ -85,7 +79,10 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
||||
}
|
||||
|
||||
if (consumedCount === 0) {
|
||||
return buildIndustryErrorResponse(4012005, `未找到匹配的卡券: ${params.etickets.map((e) => e.id).join(',')}`)
|
||||
return buildIndustryErrorResponse(
|
||||
4012005,
|
||||
`未找到匹配的卡券: ${params.etickets.map((e) => e.id).join(',')}`,
|
||||
)
|
||||
}
|
||||
|
||||
fireConsumeCallback({
|
||||
|
||||
@@ -7,7 +7,10 @@ import { assertKuaishouIndustryConfig } from './config.js'
|
||||
|
||||
export type SignMethod = 'MD5' | 'HMAC_SHA256'
|
||||
|
||||
export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signSecret } = assertKuaishouIndustryConfig()) {
|
||||
export function buildKuaishouIndustrySignSource(
|
||||
params: JsonObject = {},
|
||||
{ signSecret } = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
const entries = Object.entries(params)
|
||||
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
|
||||
.sort(([left], [right]) => {
|
||||
@@ -17,9 +20,7 @@ export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signS
|
||||
return left < right ? -1 : 1
|
||||
})
|
||||
|
||||
const queryString = entries
|
||||
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
|
||||
.join('&')
|
||||
const queryString = entries.map(([key, value]) => `${key}=${stringifySignValue(value)}`).join('&')
|
||||
|
||||
return `${queryString}&signSecret=${signSecret}`
|
||||
}
|
||||
@@ -35,11 +36,7 @@ export function signKuaishouIndustryPayload(
|
||||
return hmacSha256Sign(source, config.signSecret)
|
||||
}
|
||||
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(source, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
export function verifyKuaishouIndustrySignature(
|
||||
@@ -64,7 +61,10 @@ export function verifyKuaishouIndustrySignature(
|
||||
return true
|
||||
}
|
||||
|
||||
export function assertKuaishouIndustrySignature(params: JsonObject = {}, config = assertKuaishouIndustryConfig()) {
|
||||
export function assertKuaishouIndustrySignature(
|
||||
params: JsonObject = {},
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!verifyKuaishouIndustrySignature(params, config)) {
|
||||
throw createHttpError('签名验证失败', {
|
||||
statusCode: 400,
|
||||
@@ -74,14 +74,13 @@ export function assertKuaishouIndustrySignature(params: JsonObject = {}, config
|
||||
}
|
||||
|
||||
function hmacSha256Sign(source: string, secret: string) {
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(source, 'utf8')
|
||||
.digest('base64')
|
||||
return crypto.createHmac('sha256', secret).update(source, 'utf8').digest('base64')
|
||||
}
|
||||
|
||||
function normalizeSignMethod(value: unknown): SignMethod {
|
||||
const normalized = String(value || '').trim().toUpperCase()
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (normalized === 'HMAC_SHA256' || normalized === 'HMAC-SHA256') {
|
||||
return 'HMAC_SHA256'
|
||||
}
|
||||
|
||||
@@ -28,10 +28,7 @@ test('normalizeDestroyCodePayload keeps destroy callback token when provided', (
|
||||
})
|
||||
|
||||
test('resolveDestroyCallbackToken falls back to stored voucher token', () => {
|
||||
const token = resolveDestroyCallbackToken([
|
||||
{ token: '' },
|
||||
{ token: ' stored-token ' },
|
||||
])
|
||||
const token = resolveDestroyCallbackToken([{ token: '' }, { token: ' stored-token ' }])
|
||||
|
||||
assert.equal(token, 'stored-token')
|
||||
})
|
||||
|
||||
@@ -7,15 +7,10 @@ import {
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import {
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
import { getKuaishouIndustryConfig, assertMatchingAppKey } from './config.js'
|
||||
import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload.js'
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
} from './response.js'
|
||||
import { buildIndustrySuccessResponse } from './response.js'
|
||||
import { destroyCallback } from './destroy-callback-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
import { resolveKuaishouIndustryEticketType } from './voucher-service.js'
|
||||
@@ -34,18 +29,22 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
||||
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||
const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean))
|
||||
const targetVouchers = targetIds.size > 0
|
||||
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||
: vouchers
|
||||
const callbackSellerId = params.sellerId
|
||||
|| String(targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '').trim()
|
||||
const targetVouchers =
|
||||
targetIds.size > 0
|
||||
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||
: vouchers
|
||||
const callbackSellerId =
|
||||
params.sellerId ||
|
||||
String(
|
||||
targetVouchers.find((voucher) => String(voucher.seller_id || '').trim())?.seller_id || '',
|
||||
).trim()
|
||||
const callbackToken = params.token || resolveDestroyCallbackToken(targetVouchers)
|
||||
const eticketType = resolveKuaishouIndustryEticketType(
|
||||
targetVouchers[0] || null,
|
||||
)
|
||||
const eticketType = resolveKuaishouIndustryEticketType(targetVouchers[0] || null)
|
||||
|
||||
for (const voucher of targetVouchers) {
|
||||
const status = String(voucher.status || '').trim().toUpperCase()
|
||||
const status = String(voucher.status || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (status === 'CONSUMED') {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -36,11 +36,7 @@ export async function requestKuaishouIndustryOpenApi(
|
||||
const apiLabel = formatKuaishouOpenApiLabel(input.apiMethod)
|
||||
|
||||
if (!config.enabled) {
|
||||
logKuaishouOpenApi(
|
||||
input,
|
||||
`快手 OpenAPI 未启用,跳过 ${apiLabel}`,
|
||||
undefined,
|
||||
)
|
||||
logKuaishouOpenApi(input, `快手 OpenAPI 未启用,跳过 ${apiLabel}`, undefined)
|
||||
return { success: true, skippedReason: 'disabled' }
|
||||
}
|
||||
|
||||
@@ -70,12 +66,7 @@ export async function requestKuaishouIndustryOpenApi(
|
||||
}
|
||||
|
||||
if (!config.accessToken) {
|
||||
logKuaishouOpenApi(
|
||||
input,
|
||||
`快手 OpenAPI accessToken 缺失 ${apiLabel}`,
|
||||
undefined,
|
||||
'warn',
|
||||
)
|
||||
logKuaishouOpenApi(input, `快手 OpenAPI accessToken 缺失 ${apiLabel}`, undefined, 'warn')
|
||||
return {
|
||||
success: false,
|
||||
skippedReason: 'missing_access_token',
|
||||
@@ -115,13 +106,14 @@ export async function requestKuaishouIndustryOpenApi(
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const res = httpMethod === 'GET'
|
||||
? await fetch(`${url}?${body.toString()}`, { method: 'GET' })
|
||||
: await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const res =
|
||||
httpMethod === 'GET'
|
||||
? await fetch(`${url}?${body.toString()}`, { method: 'GET' })
|
||||
: await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const text = await res.text()
|
||||
let json: JsonObject = {}
|
||||
try {
|
||||
@@ -134,9 +126,7 @@ export async function requestKuaishouIndustryOpenApi(
|
||||
const success = res.ok && Number(json.result) === 1
|
||||
logKuaishouOpenApi(
|
||||
input,
|
||||
success
|
||||
? `快手 OpenAPI 调用成功 ${apiLabel}`
|
||||
: `快手 OpenAPI 调用失败 ${apiLabel}`,
|
||||
success ? `快手 OpenAPI 调用成功 ${apiLabel}` : `快手 OpenAPI 调用失败 ${apiLabel}`,
|
||||
{
|
||||
durationMs,
|
||||
httpStatus: res.status,
|
||||
@@ -201,17 +191,21 @@ export function buildKuaishouOpenApiRequestLog({
|
||||
}
|
||||
|
||||
export function resolveKuaishouOpenApiBaseUrl(value: unknown): string {
|
||||
return String(value || DEFAULT_KUAISHOU_OPEN_API).trim().replace(/\/+$/, '') ||
|
||||
DEFAULT_KUAISHOU_OPEN_API
|
||||
return (
|
||||
String(value || DEFAULT_KUAISHOU_OPEN_API)
|
||||
.trim()
|
||||
.replace(/\/+$/, '') || DEFAULT_KUAISHOU_OPEN_API
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveKuaishouOpenApiErrorDetail(error: unknown): JsonObject {
|
||||
const detail: JsonObject = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
const cause = error && typeof error === 'object' && 'cause' in error
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null
|
||||
const cause =
|
||||
error && typeof error === 'object' && 'cause' in error
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null
|
||||
if (!cause || typeof cause !== 'object') {
|
||||
return detail
|
||||
}
|
||||
@@ -285,16 +279,16 @@ function resolveKuaishouOpenApiName(method: unknown): string {
|
||||
'integration.callback.virtual.eticket.destroy': '电子凭证销毁回调',
|
||||
'integration.callback.virtual.eticket.reverse': '电子凭证冲正回调',
|
||||
'open.virtual.eticket.checkavailable': '检查电子凭证有效性',
|
||||
'kwaishop_refund_addRefund': '新增退款单消息',
|
||||
'kwaishop_refund_updateRefund': '退款单更新消息',
|
||||
'kwaishop_aftersales_addRefund': '售后单新增消息',
|
||||
'kwaishop_order_addOrder': '订单新增消息',
|
||||
'kwaishop_order_statusChange': '订单状态变更消息',
|
||||
'kwaishop_order_totalFeeChange': '订单费用变更消息',
|
||||
'kwaishop_order_addNote': '订单备注消息',
|
||||
'KwaishopVirtualEticketSendService': '快手电子凭证通知发货',
|
||||
'KwaishopVirtualEticketQueryService': '电子凭证查询发码结果或券码状态',
|
||||
'KwaishopDigitalETicketDestoryService': '电子凭证发起销毁',
|
||||
kwaishop_refund_addRefund: '新增退款单消息',
|
||||
kwaishop_refund_updateRefund: '退款单更新消息',
|
||||
kwaishop_aftersales_addRefund: '售后单新增消息',
|
||||
kwaishop_order_addOrder: '订单新增消息',
|
||||
kwaishop_order_statusChange: '订单状态变更消息',
|
||||
kwaishop_order_totalFeeChange: '订单费用变更消息',
|
||||
kwaishop_order_addNote: '订单备注消息',
|
||||
KwaishopVirtualEticketSendService: '快手电子凭证通知发货',
|
||||
KwaishopVirtualEticketQueryService: '电子凭证查询发码结果或券码状态',
|
||||
KwaishopDigitalETicketDestoryService: '电子凭证发起销毁',
|
||||
'integration.virtual.eticket.send': '电子凭证通知发码',
|
||||
'integration.virtual.eticket.query': '电子凭证查询发码结果或券码状态',
|
||||
'integration.virtual.eticket.destroy': '电子凭证发起销毁',
|
||||
|
||||
@@ -47,7 +47,5 @@ export function normalizeStringList(value: unknown): string[] {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { assertKuaishouIndustryConfig } from './config.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
|
||||
export function normalizeIndustryString(value: unknown) {
|
||||
return String(value || '').trim()
|
||||
@@ -80,7 +80,10 @@ export function normalizeQueryCodePayload(raw: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSendCodePayload(payload: ReturnType<typeof normalizeSendCodePayload>, config = assertKuaishouIndustryConfig()) {
|
||||
export function assertSendCodePayload(
|
||||
payload: ReturnType<typeof normalizeSendCodePayload>,
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!payload.oid) {
|
||||
throw createHttpError('缺少 oid', {
|
||||
statusCode: 400,
|
||||
@@ -105,7 +108,10 @@ export function assertSendCodePayload(payload: ReturnType<typeof normalizeSendCo
|
||||
assertCommonPayload(payload, config)
|
||||
}
|
||||
|
||||
export function assertDestroyCodePayload(payload: ReturnType<typeof normalizeDestroyCodePayload>, config = assertKuaishouIndustryConfig()) {
|
||||
export function assertDestroyCodePayload(
|
||||
payload: ReturnType<typeof normalizeDestroyCodePayload>,
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!payload.oid) {
|
||||
throw createHttpError('缺少 oid', {
|
||||
statusCode: 400,
|
||||
@@ -123,7 +129,10 @@ export function assertDestroyCodePayload(payload: ReturnType<typeof normalizeDes
|
||||
assertCommonPayload(payload, config)
|
||||
}
|
||||
|
||||
export function assertQueryCodePayload(payload: ReturnType<typeof normalizeQueryCodePayload>, config = assertKuaishouIndustryConfig()) {
|
||||
export function assertQueryCodePayload(
|
||||
payload: ReturnType<typeof normalizeQueryCodePayload>,
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!payload.oid) {
|
||||
throw createHttpError('缺少 oid', {
|
||||
statusCode: 400,
|
||||
@@ -165,7 +174,10 @@ export function normalizeConsumeCodePayload(raw: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertConsumeCodePayload(payload: ReturnType<typeof normalizeConsumeCodePayload>, config = assertKuaishouIndustryConfig()) {
|
||||
export function assertConsumeCodePayload(
|
||||
payload: ReturnType<typeof normalizeConsumeCodePayload>,
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!payload.oid) {
|
||||
throw createHttpError('缺少 oid', {
|
||||
statusCode: 400,
|
||||
@@ -205,7 +217,16 @@ export function assertConsumeCodePayload(payload: ReturnType<typeof normalizeCon
|
||||
}
|
||||
|
||||
function assertCommonPayload(
|
||||
payload: { appKey: string, version: string, timestamp: number, signMethod: string, sign: string, method: string, accessToken: string, paramRaw: string },
|
||||
payload: {
|
||||
appKey: string
|
||||
version: string
|
||||
timestamp: number
|
||||
signMethod: string
|
||||
sign: string
|
||||
method: string
|
||||
accessToken: string
|
||||
paramRaw: string
|
||||
},
|
||||
config = assertKuaishouIndustryConfig(),
|
||||
) {
|
||||
if (!payload.signMethod) {
|
||||
@@ -223,7 +244,11 @@ function assertCommonPayload(
|
||||
}
|
||||
|
||||
const normalizedMethod = payload.signMethod.toUpperCase()
|
||||
if (normalizedMethod !== 'MD5' && normalizedMethod !== 'HMAC_SHA256' && normalizedMethod !== 'HMAC-SHA256') {
|
||||
if (
|
||||
normalizedMethod !== 'MD5' &&
|
||||
normalizedMethod !== 'HMAC_SHA256' &&
|
||||
normalizedMethod !== 'HMAC-SHA256'
|
||||
) {
|
||||
throw createHttpError(`不支持的签名算法: ${payload.signMethod},仅支持 MD5 或 HMAC_SHA256`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'kuaishou_industry_unsupported_sign_method',
|
||||
|
||||
@@ -3,10 +3,7 @@ import {
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
import { getKuaishouIndustryConfig, assertMatchingAppKey } from './config.js'
|
||||
import { normalizeQueryCodePayload, assertQueryCodePayload } from './payload.js'
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
@@ -50,8 +47,9 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
const vouchers = (await listKuaishouIndustryVouchersByOid(normalizedOid))
|
||||
.filter(isKuaishouIndustryVoucherSendCallbackSuccess)
|
||||
const vouchers = (await listKuaishouIndustryVouchersByOid(normalizedOid)).filter(
|
||||
isKuaishouIndustryVoucherSendCallbackSuccess,
|
||||
)
|
||||
if (vouchers.length === 0) {
|
||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||
}
|
||||
|
||||
@@ -51,15 +51,23 @@ export function listKuaishouIndustryRefunds(input: KuaishouIndustryRefundListInp
|
||||
type: normalizeOpenApiInteger(input.type, 8),
|
||||
pageSize: normalizeOpenApiPositiveInteger(input.pageSize, 50),
|
||||
currentPage: normalizeOpenApiPositiveInteger(input.currentPage, 1),
|
||||
sort: input.sort === undefined || input.sort === '' ? undefined : normalizeOpenApiInteger(input.sort, 1),
|
||||
queryType: input.queryType === undefined || input.queryType === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.queryType, 1),
|
||||
negotiateStatus: input.negotiateStatus === undefined || input.negotiateStatus === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.negotiateStatus, 0),
|
||||
sort:
|
||||
input.sort === undefined || input.sort === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.sort, 1),
|
||||
queryType:
|
||||
input.queryType === undefined || input.queryType === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.queryType, 1),
|
||||
negotiateStatus:
|
||||
input.negotiateStatus === undefined || input.negotiateStatus === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.negotiateStatus, 0),
|
||||
pcursor: String(input.pcursor ?? ''),
|
||||
status: input.status === undefined || input.status === '' ? undefined : normalizeOpenApiInteger(input.status, 0),
|
||||
status:
|
||||
input.status === undefined || input.status === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.status, 0),
|
||||
option: input.option && typeof input.option === 'object' ? input.option : undefined,
|
||||
orderId: normalizeOpenApiLong(input.orderId),
|
||||
})
|
||||
@@ -78,13 +86,18 @@ export function approveKuaishouIndustryRefund(input: KuaishouIndustryRefundAppro
|
||||
refundId: normalizeOpenApiLong(input.refundId),
|
||||
desc: String(input.desc ?? '').trim(),
|
||||
refundAmount: normalizeOpenApiLong(input.refundAmount),
|
||||
status: input.status === undefined || input.status === '' ? undefined : normalizeOpenApiInteger(input.status, 0),
|
||||
negotiateStatus: input.negotiateStatus === undefined || input.negotiateStatus === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.negotiateStatus, 0),
|
||||
refundHandingWay: input.refundHandingWay === undefined || input.refundHandingWay === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.refundHandingWay, 0),
|
||||
status:
|
||||
input.status === undefined || input.status === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.status, 0),
|
||||
negotiateStatus:
|
||||
input.negotiateStatus === undefined || input.negotiateStatus === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.negotiateStatus, 0),
|
||||
refundHandingWay:
|
||||
input.refundHandingWay === undefined || input.refundHandingWay === ''
|
||||
? undefined
|
||||
: normalizeOpenApiInteger(input.refundHandingWay, 0),
|
||||
})
|
||||
|
||||
return requestKuaishouIndustryOpenApi({
|
||||
|
||||
@@ -21,7 +21,9 @@ type SendCallbackInput = {
|
||||
expressNo?: string
|
||||
}
|
||||
|
||||
export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
|
||||
export async function sendCallback(
|
||||
input: SendCallbackInput,
|
||||
): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
|
||||
const bizParams: JsonObject = {
|
||||
oid: input.oid,
|
||||
sendType: input.sendType,
|
||||
@@ -56,7 +58,11 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
sellerId: input.sellerId || '',
|
||||
...request,
|
||||
}
|
||||
logIntegration('[kuaishou-industry/send-callback]', `发起电子凭证发货回调 oid=${input.oid}`, requestLog)
|
||||
logIntegration(
|
||||
'[kuaishou-industry/send-callback]',
|
||||
`发起电子凭证发货回调 oid=${input.oid}`,
|
||||
requestLog,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,9 +73,14 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
|
||||
if (result.skippedReason === 'token_error') {
|
||||
const message = result.error || 'accessToken 刷新失败'
|
||||
logIntegration('[kuaishou-industry/send-callback]', 'accessToken 刷新失败,无法发起发货回调', {
|
||||
error: message,
|
||||
}, { level: 'warn' })
|
||||
logIntegration(
|
||||
'[kuaishou-industry/send-callback]',
|
||||
'accessToken 刷新失败,无法发起发货回调',
|
||||
{
|
||||
error: message,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
|
||||
@@ -94,17 +105,27 @@ export async function sendCallback(input: SendCallbackInput): Promise<{ success:
|
||||
response: result.response || null,
|
||||
})
|
||||
} else if (result.error) {
|
||||
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调异常 oid=${input.oid}`, {
|
||||
...(result.errorDetail || { error: result.error }),
|
||||
request: requestLog,
|
||||
}, { level: 'warn' })
|
||||
logIntegration(
|
||||
'[kuaishou-industry/send-callback]',
|
||||
`电子凭证发货回调异常 oid=${input.oid}`,
|
||||
{
|
||||
...(result.errorDetail || { error: result.error }),
|
||||
request: requestLog,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
} else {
|
||||
logIntegration('[kuaishou-industry/send-callback]', `电子凭证发货回调失败 oid=${input.oid}`, {
|
||||
durationMs: result.durationMs,
|
||||
status: result.httpStatus,
|
||||
request: requestLog,
|
||||
response: result.response || null,
|
||||
}, { level: 'warn' })
|
||||
logIntegration(
|
||||
'[kuaishou-industry/send-callback]',
|
||||
`电子凭证发货回调失败 oid=${input.oid}`,
|
||||
{
|
||||
durationMs: result.durationMs,
|
||||
status: result.httpStatus,
|
||||
request: requestLog,
|
||||
response: result.response || null,
|
||||
},
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -29,11 +29,7 @@ test('resolveSendCallbackPreferredTotalGoodsValue falls back to order total amou
|
||||
})
|
||||
|
||||
test('resolveSendCallbackGoodsValuePlan prefers 91 amount over ext payment', () => {
|
||||
const plan = resolveSendCallbackGoodsValuePlan(
|
||||
[{}],
|
||||
'{"payment":100,"platformBearAmount":0}',
|
||||
1,
|
||||
)
|
||||
const plan = resolveSendCallbackGoodsValuePlan([{}], '{"payment":100,"platformBearAmount":0}', 1)
|
||||
|
||||
assert.deepEqual(plan.goodsValues, [1])
|
||||
assert.equal(plan.totalGoodsValue, 1)
|
||||
@@ -41,11 +37,7 @@ test('resolveSendCallbackGoodsValuePlan prefers 91 amount over ext payment', ()
|
||||
})
|
||||
|
||||
test('resolveSendCallbackGoodsValuePlan splits preferred amount and keeps sum equal to total', () => {
|
||||
const plan = resolveSendCallbackGoodsValuePlan(
|
||||
[{}, {}, {}],
|
||||
'{"payment":999}',
|
||||
100,
|
||||
)
|
||||
const plan = resolveSendCallbackGoodsValuePlan([{}, {}, {}], '{"payment":999}', 100)
|
||||
|
||||
assert.deepEqual(plan.goodsValues, [33, 33, 34])
|
||||
assert.equal(plan.totalGoodsValue, 100)
|
||||
@@ -53,20 +45,14 @@ test('resolveSendCallbackGoodsValuePlan splits preferred amount and keeps sum eq
|
||||
})
|
||||
|
||||
test('resolveSendCallbackGoodsValuePlan uses ext payment for a single voucher', () => {
|
||||
const plan = resolveSendCallbackGoodsValuePlan(
|
||||
[{}],
|
||||
'{"payment":1,"platformBearAmount":0}',
|
||||
)
|
||||
const plan = resolveSendCallbackGoodsValuePlan([{}], '{"payment":1,"platformBearAmount":0}')
|
||||
|
||||
assert.deepEqual(plan.goodsValues, [1])
|
||||
assert.equal(plan.totalGoodsValue, 1)
|
||||
})
|
||||
|
||||
test('resolveSendCallbackGoodsValuePlan splits ext payment and keeps sum equal to total', () => {
|
||||
const plan = resolveSendCallbackGoodsValuePlan(
|
||||
[{}, {}, {}],
|
||||
'{"payment":100}',
|
||||
)
|
||||
const plan = resolveSendCallbackGoodsValuePlan([{}, {}, {}], '{"payment":100}')
|
||||
|
||||
assert.deepEqual(plan.goodsValues, [33, 33, 34])
|
||||
assert.equal(plan.totalGoodsValue, 100)
|
||||
|
||||
@@ -505,9 +505,9 @@ async function syncOpen91OrderAfterSendCallbackSuccess(
|
||||
function isOpen91OrderAwaitingFulfillmentConfig(error: unknown) {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
String((error as { errorCode?: unknown }).errorCode || '').trim() ===
|
||||
'open91_order_still_unconfigured',
|
||||
typeof error === 'object' &&
|
||||
String((error as { errorCode?: unknown }).errorCode || '').trim() ===
|
||||
'open91_order_still_unconfigured',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { JsonObject } from '../../../types/json.js'
|
||||
|
||||
import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js'
|
||||
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
|
||||
import {
|
||||
readAppConfigEntry,
|
||||
saveAppConfigEntry,
|
||||
} from '../../config/app-config-store.js'
|
||||
import { readAppConfigEntry, saveAppConfigEntry } from '../../config/app-config-store.js'
|
||||
|
||||
const KUAISHOU_INDUSTRY_SOURCE_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'kuaishou-industry-source.json')
|
||||
const KUAISHOU_INDUSTRY_SOURCE_FILE_PATH = path.join(
|
||||
PROJECT_ROOT,
|
||||
'data',
|
||||
'kuaishou-industry-source.json',
|
||||
)
|
||||
const DEFAULT_CALLBACK_BASE_URL = 'https://openapi.kwaixiaodian.com'
|
||||
const DEFAULT_AUTH_BASE_URL = 'https://open.kwaixiaodian.com'
|
||||
const LEGACY_DEFAULT_REDIRECT_URI = 'https://ks.khhao.com/oauth-callback'
|
||||
@@ -73,7 +74,9 @@ export function getKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConfig
|
||||
})
|
||||
}
|
||||
|
||||
export function saveKuaishouIndustrySourceConfig(rawValue: unknown): Promise<KuaishouIndustrySourceConfig> {
|
||||
export function saveKuaishouIndustrySourceConfig(
|
||||
rawValue: unknown,
|
||||
): Promise<KuaishouIndustrySourceConfig> {
|
||||
return saveAppConfigEntry({
|
||||
configKey: APP_CONFIG_KEYS.kuaishouIndustrySource,
|
||||
legacyFilePath: KUAISHOU_INDUSTRY_SOURCE_FILE_PATH,
|
||||
@@ -106,8 +109,11 @@ export function findKuaishouIndustryShopConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
return listKuaishouIndustryShopConfigs(source)
|
||||
.find((shop) => String(shop.sellerId || shop.shopId || '').trim() === normalizedSellerId) || null
|
||||
return (
|
||||
listKuaishouIndustryShopConfigs(source).find(
|
||||
(shop) => String(shop.sellerId || shop.shopId || '').trim() === normalizedSellerId,
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
export function patchKuaishouIndustryShopConfig(
|
||||
@@ -127,8 +133,9 @@ export function patchKuaishouIndustryShopConfig(
|
||||
return Promise.resolve(source)
|
||||
}
|
||||
|
||||
const shops = listKuaishouIndustryShopConfigs(source)
|
||||
.filter((shop) => String(shop.sellerId || shop.shopId || '').trim() !== normalizedSellerId)
|
||||
const shops = listKuaishouIndustryShopConfigs(source).filter(
|
||||
(shop) => String(shop.sellerId || shop.shopId || '').trim() !== normalizedSellerId,
|
||||
)
|
||||
|
||||
return saveKuaishouIndustrySourceConfig({
|
||||
...source,
|
||||
@@ -136,7 +143,9 @@ export function patchKuaishouIndustryShopConfig(
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeKuaishouIndustrySourceConfig(rawValue: unknown): KuaishouIndustrySourceConfig {
|
||||
export function normalizeKuaishouIndustrySourceConfig(
|
||||
rawValue: unknown,
|
||||
): KuaishouIndustrySourceConfig {
|
||||
const fallback = createDefaultKuaishouIndustrySourceConfig()
|
||||
const source = isPlainObject(rawValue) ? rawValue : {}
|
||||
const shops = normalizeKuaishouIndustryShopList(source)
|
||||
@@ -204,9 +213,7 @@ function createDefaultKuaishouIndustrySourceConfig(): KuaishouIndustrySourceConf
|
||||
}
|
||||
|
||||
function normalizeKuaishouIndustryShopList(source: JsonObject): KuaishouIndustryShopConfig[] {
|
||||
const rawShops = Array.isArray(source.shops)
|
||||
? source.shops
|
||||
: buildLegacySingleShopList(source)
|
||||
const rawShops = Array.isArray(source.shops) ? source.shops : buildLegacySingleShopList(source)
|
||||
|
||||
const deduped = new Map<string, KuaishouIndustryShopConfig>()
|
||||
let unnamedIndex = 0
|
||||
@@ -237,7 +244,15 @@ function normalizeKuaishouIndustryShopConfig(rawValue: unknown): KuaishouIndustr
|
||||
const refreshToken = normalizeString(rawValue.refreshToken, '')
|
||||
const openId = normalizeString(rawValue.openId, '')
|
||||
|
||||
if (!sellerId && !shopId && !shopName && !customShopName && !accessToken && !refreshToken && !openId) {
|
||||
if (
|
||||
!sellerId &&
|
||||
!shopId &&
|
||||
!shopName &&
|
||||
!customShopName &&
|
||||
!accessToken &&
|
||||
!refreshToken &&
|
||||
!openId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -336,7 +351,11 @@ function normalizeScopeText(value: unknown): string {
|
||||
}
|
||||
|
||||
function normalizeUrlLike(value: unknown, fallback: string): string {
|
||||
return String(value || fallback).trim().replace(/\/+$/, '') || fallback
|
||||
return (
|
||||
String(value || fallback)
|
||||
.trim()
|
||||
.replace(/\/+$/, '') || fallback
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeNullableIso(value: unknown): string {
|
||||
|
||||
@@ -1,54 +1,77 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
ensureKuaishouIndustryAccessToken,
|
||||
shouldRefreshAccessToken,
|
||||
} from './token-service.js'
|
||||
import { ensureKuaishouIndustryAccessToken, shouldRefreshAccessToken } from './token-service.js'
|
||||
import type {
|
||||
KuaishouIndustryShopConfig,
|
||||
KuaishouIndustrySourceConfig,
|
||||
} from './source-config-service.js'
|
||||
|
||||
test('shouldRefreshAccessToken refreshes missing or expiring tokens', () => {
|
||||
assert.equal(shouldRefreshAccessToken(createConfig({
|
||||
accessToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
})), true)
|
||||
assert.equal(
|
||||
shouldRefreshAccessToken(
|
||||
createConfig({
|
||||
accessToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(shouldRefreshAccessToken(createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
})), true)
|
||||
assert.equal(
|
||||
shouldRefreshAccessToken(
|
||||
createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(shouldRefreshAccessToken(createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 10 * 1000).toISOString(),
|
||||
})), true)
|
||||
assert.equal(
|
||||
shouldRefreshAccessToken(
|
||||
createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() - 10 * 1000).toISOString(),
|
||||
}),
|
||||
),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('shouldRefreshAccessToken keeps valid tokens without a forced refresh', () => {
|
||||
assert.equal(shouldRefreshAccessToken(createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: '',
|
||||
})), false)
|
||||
assert.equal(
|
||||
shouldRefreshAccessToken(
|
||||
createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: '',
|
||||
}),
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(shouldRefreshAccessToken(createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
})), false)
|
||||
assert.equal(
|
||||
shouldRefreshAccessToken(
|
||||
createConfig({
|
||||
accessToken: 'token',
|
||||
accessTokenExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('ensureKuaishouIndustryAccessToken requires sellerId when multiple shops are enabled', async () => {
|
||||
await assert.rejects(
|
||||
() => ensureKuaishouIndustryAccessToken({
|
||||
config: createConfig({
|
||||
shops: [
|
||||
createShop({ sellerId: 'seller-a', accessToken: 'token-a' }),
|
||||
createShop({ sellerId: 'seller-b', accessToken: 'token-b' }),
|
||||
],
|
||||
() =>
|
||||
ensureKuaishouIndustryAccessToken({
|
||||
config: createConfig({
|
||||
shops: [
|
||||
createShop({ sellerId: 'seller-a', accessToken: 'token-a' }),
|
||||
createShop({ sellerId: 'seller-b', accessToken: 'token-b' }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
message: '存在多个快手行业电子凭证店铺授权,请指定 sellerId',
|
||||
errorCode: 'kuaishou_industry_missing_seller_id',
|
||||
@@ -105,9 +128,7 @@ function createConfig(
|
||||
}
|
||||
}
|
||||
|
||||
function createShop(
|
||||
patch: Partial<KuaishouIndustryShopConfig> = {},
|
||||
): KuaishouIndustryShopConfig {
|
||||
function createShop(patch: Partial<KuaishouIndustryShopConfig> = {}): KuaishouIndustryShopConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
sellerId: 'seller-a',
|
||||
|
||||
@@ -159,12 +159,18 @@ export async function exchangeKuaishouIndustryAuthorizationCode(
|
||||
await patchKuaishouIndustrySourceConfig({
|
||||
lastRefreshError: message,
|
||||
})
|
||||
logWarn('[kuaishou-industry/token]', '授权码换取 accessToken 失败', resolveRefreshErrorDetail(error))
|
||||
logWarn(
|
||||
'[kuaishou-industry/token]',
|
||||
'授权码换取 accessToken 失败',
|
||||
resolveRefreshErrorDetail(error),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRefreshAccessToken(config: Pick<KuaishouIndustryShopConfig, 'accessToken' | 'accessTokenExpiresAt'>): boolean {
|
||||
export function shouldRefreshAccessToken(
|
||||
config: Pick<KuaishouIndustryShopConfig, 'accessToken' | 'accessTokenExpiresAt'>,
|
||||
): boolean {
|
||||
if (!String(config.accessToken || '').trim()) {
|
||||
return true
|
||||
}
|
||||
@@ -201,8 +207,9 @@ function resolveTokenShop(
|
||||
return matched
|
||||
}
|
||||
|
||||
const enabledShops = listKuaishouIndustryShopConfigs(config)
|
||||
.filter((shop) => shop.enabled !== false)
|
||||
const enabledShops = listKuaishouIndustryShopConfigs(config).filter(
|
||||
(shop) => shop.enabled !== false,
|
||||
)
|
||||
|
||||
if (enabledShops.length === 1) {
|
||||
return enabledShops[0] as KuaishouIndustryShopConfig
|
||||
@@ -237,7 +244,10 @@ function createEmptyShopConfig(): KuaishouIndustryShopConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function assertRefreshConfig(config: KuaishouIndustrySourceConfig, shop: KuaishouIndustryShopConfig) {
|
||||
function assertRefreshConfig(
|
||||
config: KuaishouIndustrySourceConfig,
|
||||
shop: KuaishouIndustryShopConfig,
|
||||
) {
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('快手开放平台 API 地址未配置', {
|
||||
statusCode: 400,
|
||||
@@ -298,15 +308,18 @@ async function requestKuaishouIndustryToken({
|
||||
failureMessage: string
|
||||
errorCode: string
|
||||
}) {
|
||||
const baseUrl = String(config.baseUrl || '').trim().replace(/\/+$/, '')
|
||||
const baseUrl = String(config.baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
const url = new URL(`${baseUrl}${path}`)
|
||||
const response = method === 'GET'
|
||||
? await fetch(appendSearchParams(url, params))
|
||||
: await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
})
|
||||
const response =
|
||||
method === 'GET'
|
||||
? await fetch(appendSearchParams(url, params))
|
||||
: await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
})
|
||||
const text = await response.text()
|
||||
const json = parseJsonObject(text)
|
||||
const tokenPayload = normalizeTokenResponse(json, config)
|
||||
@@ -347,8 +360,11 @@ async function saveTokenPayload(
|
||||
accessToken: tokenPayload.accessToken,
|
||||
refreshToken: nextRefreshToken,
|
||||
accessTokenExpiresAt: tokenPayload.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: tokenPayload.refreshTokenExpiresAt
|
||||
|| (tokenPayload.refreshToken ? new Date(Date.now() + DEFAULT_REFRESH_TOKEN_TTL_MS).toISOString() : shop.refreshTokenExpiresAt),
|
||||
refreshTokenExpiresAt:
|
||||
tokenPayload.refreshTokenExpiresAt ||
|
||||
(tokenPayload.refreshToken
|
||||
? new Date(Date.now() + DEFAULT_REFRESH_TOKEN_TTL_MS).toISOString()
|
||||
: shop.refreshTokenExpiresAt),
|
||||
openId: tokenPayload.openId || shop.openId,
|
||||
grantedScopes: tokenPayload.grantedScopes || shop.grantedScopes,
|
||||
lastRefreshedAt: new Date().toISOString(),
|
||||
@@ -361,16 +377,9 @@ async function saveTokenPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokenResponse(
|
||||
json: JsonObject,
|
||||
currentConfig: KuaishouIndustrySourceConfig,
|
||||
) {
|
||||
function normalizeTokenResponse(json: JsonObject, currentConfig: KuaishouIndustrySourceConfig) {
|
||||
const payload = isPlainObject(json.data) ? json.data : json
|
||||
const accessToken = pickFirstString(payload, [
|
||||
'access_token',
|
||||
'accessToken',
|
||||
'accessTokenValue',
|
||||
])
|
||||
const accessToken = pickFirstString(payload, ['access_token', 'accessToken', 'accessTokenValue'])
|
||||
const refreshToken = pickFirstString(payload, [
|
||||
'refresh_token',
|
||||
'refreshToken',
|
||||
@@ -386,26 +395,24 @@ function normalizeTokenResponse(
|
||||
sellerId,
|
||||
openId,
|
||||
grantedScopes,
|
||||
accessTokenExpiresAt: resolveExpiresAt(payload, [
|
||||
'access_token_expires_at',
|
||||
'accessTokenExpiresAt',
|
||||
'accessTokenExpireAt',
|
||||
'expires_at',
|
||||
'expireAt',
|
||||
], [
|
||||
'expires_in',
|
||||
'expiresIn',
|
||||
'access_token_expires_in',
|
||||
'accessTokenExpiresIn',
|
||||
], DEFAULT_ACCESS_TOKEN_TTL_MS),
|
||||
refreshTokenExpiresAt: resolveExpiresAt(payload, [
|
||||
'refresh_token_expires_at',
|
||||
'refreshTokenExpiresAt',
|
||||
'refreshTokenExpireAt',
|
||||
], [
|
||||
'refresh_token_expires_in',
|
||||
'refreshTokenExpiresIn',
|
||||
], 0),
|
||||
accessTokenExpiresAt: resolveExpiresAt(
|
||||
payload,
|
||||
[
|
||||
'access_token_expires_at',
|
||||
'accessTokenExpiresAt',
|
||||
'accessTokenExpireAt',
|
||||
'expires_at',
|
||||
'expireAt',
|
||||
],
|
||||
['expires_in', 'expiresIn', 'access_token_expires_in', 'accessTokenExpiresIn'],
|
||||
DEFAULT_ACCESS_TOKEN_TTL_MS,
|
||||
),
|
||||
refreshTokenExpiresAt: resolveExpiresAt(
|
||||
payload,
|
||||
['refresh_token_expires_at', 'refreshTokenExpiresAt', 'refreshTokenExpireAt'],
|
||||
['refresh_token_expires_in', 'refreshTokenExpiresIn'],
|
||||
0,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,15 +445,11 @@ function resolveExpiresAt(
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackDurationMs > 0
|
||||
? new Date(Date.now() + fallbackDurationMs).toISOString()
|
||||
: ''
|
||||
return fallbackDurationMs > 0 ? new Date(Date.now() + fallbackDurationMs).toISOString() : ''
|
||||
}
|
||||
|
||||
function parseTimestampLike(value: unknown): number {
|
||||
const raw = typeof value === 'number'
|
||||
? value
|
||||
: Number(String(value || '').trim())
|
||||
const raw = typeof value === 'number' ? value : Number(String(value || '').trim())
|
||||
|
||||
if (Number.isFinite(raw) && raw > 0) {
|
||||
return raw > 10_000_000_000 ? raw : raw * 1000
|
||||
@@ -467,14 +470,16 @@ function isTokenResponseSuccess(json: JsonObject): boolean {
|
||||
|
||||
function resolveTokenErrorMessage(json: JsonObject, status: number, fallback: string): string {
|
||||
const payload = isPlainObject(json.data) ? json.data : json
|
||||
return pickFirstString(payload, [
|
||||
'error_msg',
|
||||
'errorMsg',
|
||||
'message',
|
||||
'msg',
|
||||
'error_description',
|
||||
'errorDescription',
|
||||
]) || `${fallback},HTTP ${status}`
|
||||
return (
|
||||
pickFirstString(payload, [
|
||||
'error_msg',
|
||||
'errorMsg',
|
||||
'message',
|
||||
'msg',
|
||||
'error_description',
|
||||
'errorDescription',
|
||||
]) || `${fallback},HTTP ${status}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): JsonObject {
|
||||
@@ -500,7 +505,10 @@ function pickFirstString(payload: JsonObject, keys: string[]): string {
|
||||
function pickScopes(payload: JsonObject): string {
|
||||
const value = payload.scopes ?? payload.scope
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean).join(',')
|
||||
return value
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
return String(value || '').trim()
|
||||
@@ -510,9 +518,10 @@ function resolveRefreshErrorDetail(error: unknown): JsonObject {
|
||||
const detail: JsonObject = {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
const cause = error && typeof error === 'object' && 'cause' in error
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null
|
||||
const cause =
|
||||
error && typeof error === 'object' && 'cause' in error
|
||||
? (error as { cause?: unknown }).cause
|
||||
: null
|
||||
if (!cause || typeof cause !== 'object') {
|
||||
return detail
|
||||
}
|
||||
|
||||
@@ -79,11 +79,14 @@ export async function attachKuaishouIndustryVoucherToTask(
|
||||
|
||||
if (
|
||||
sendCallbackConfirmed &&
|
||||
(String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' || context.kuaishouCloudFulfillment)
|
||||
(String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' ||
|
||||
context.kuaishouCloudFulfillment)
|
||||
) {
|
||||
const flow = normalizeKuaishouCloudFlow(context.kuaishouCloudFulfillment)
|
||||
const consumedAt = voucher.consumed_at || nextVoucherContext.consumedAt || null
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
const status = String(voucher.status || 'UNUSED')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
nextContext.kuaishouCloudFulfillment = {
|
||||
...flow,
|
||||
@@ -138,10 +141,13 @@ export function buildKuaishouIndustryVoucherContext(
|
||||
existingValue: unknown,
|
||||
now: string,
|
||||
) {
|
||||
const existing = existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? existingValue as JsonObject
|
||||
: {}
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
const existing =
|
||||
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? (existingValue as JsonObject)
|
||||
: {}
|
||||
const status = String(voucher.status || 'UNUSED')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
return {
|
||||
...existing,
|
||||
|
||||
@@ -81,12 +81,15 @@ test('normalizeKuaishouIndustryConsumeType defaults to consume', () => {
|
||||
})
|
||||
|
||||
test('buildKuaishouIndustryEticketFromVoucher mirrors voucher code into id and code', () => {
|
||||
const eticket = buildKuaishouIndustryEticketFromVoucher({
|
||||
voucher_code: 'KSV15K821FDHPMTX1ST',
|
||||
status: 'UNUSED',
|
||||
valid_start_time: 1783427213868,
|
||||
valid_end_time: 1786019213868,
|
||||
} as any, 'GAME_OPEN_TICKET_CONSUME')
|
||||
const eticket = buildKuaishouIndustryEticketFromVoucher(
|
||||
{
|
||||
voucher_code: 'KSV15K821FDHPMTX1ST',
|
||||
status: 'UNUSED',
|
||||
valid_start_time: 1783427213868,
|
||||
valid_end_time: 1786019213868,
|
||||
} as any,
|
||||
'GAME_OPEN_TICKET_CONSUME',
|
||||
)
|
||||
|
||||
assert.equal(eticket.id, 'KSV15K821FDHPMTX1ST')
|
||||
assert.equal(eticket.code, 'KSV15K821FDHPMTX1ST')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { getTaskById, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import { asJsonObject, type JsonObject } from '../../../types/json.js'
|
||||
import {
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByTaskId,
|
||||
@@ -104,7 +104,9 @@ export function buildKuaishouIndustryEticketFromVoucher(
|
||||
}
|
||||
|
||||
export function normalizeKuaishouIndustrySendCallbackStatus(value: unknown) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (
|
||||
normalized === KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS.PENDING ||
|
||||
normalized === KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS.FAILED
|
||||
@@ -118,8 +120,10 @@ export function normalizeKuaishouIndustrySendCallbackStatus(value: unknown) {
|
||||
export function isKuaishouIndustryVoucherSendCallbackSuccess(
|
||||
voucher: Pick<KuaishouIndustryVoucherRow, 'send_callback_status'>,
|
||||
) {
|
||||
return normalizeKuaishouIndustrySendCallbackStatus(voucher.send_callback_status) ===
|
||||
return (
|
||||
normalizeKuaishouIndustrySendCallbackStatus(voucher.send_callback_status) ===
|
||||
KUAISHOU_INDUSTRY_SEND_CALLBACK_STATUS.SUCCESS
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveKuaishouIndustryVoucherSendCallbackMessage(
|
||||
@@ -237,7 +241,9 @@ export async function consumeKuaishouIndustryVoucher(
|
||||
const now = new Date().toISOString()
|
||||
const consumeTime = Number(input.consumeTime || Date.now()) || Date.now()
|
||||
const consumeType = normalizeKuaishouIndustryConsumeType(input.consumeType)
|
||||
const serialNum = String(input.serialNum || voucher.consume_serial_num || `CONSUME-${voucher.voucher_code}`).trim()
|
||||
const serialNum = String(
|
||||
input.serialNum || voucher.consume_serial_num || `CONSUME-${voucher.voucher_code}`,
|
||||
).trim()
|
||||
const token = String(input.token || voucher.token || '').trim()
|
||||
const consumeDetail = {
|
||||
serialNum,
|
||||
@@ -255,10 +261,12 @@ export async function consumeKuaishouIndustryVoucher(
|
||||
: await consumeCallback({
|
||||
oid: voucher.oid,
|
||||
sellerId: String(voucher.seller_id || '').trim(),
|
||||
etickets: [{
|
||||
id: voucher.voucher_code,
|
||||
num: 1,
|
||||
}],
|
||||
etickets: [
|
||||
{
|
||||
id: voucher.voucher_code,
|
||||
num: 1,
|
||||
},
|
||||
],
|
||||
status: 'CONSUMED',
|
||||
consumeType,
|
||||
consumeTime,
|
||||
@@ -349,12 +357,14 @@ export async function destroyKuaishouIndustryVoucher(
|
||||
: await destroyCallback({
|
||||
oid: voucher.oid,
|
||||
sellerId: String(voucher.seller_id || '').trim(),
|
||||
etickets: [{
|
||||
id: voucher.voucher_code,
|
||||
code: voucher.voucher_code,
|
||||
num: 1,
|
||||
...(input.goodsValue != null ? { goodsValue: input.goodsValue } : {}),
|
||||
}],
|
||||
etickets: [
|
||||
{
|
||||
id: voucher.voucher_code,
|
||||
code: voucher.voucher_code,
|
||||
num: 1,
|
||||
...(input.goodsValue != null ? { goodsValue: input.goodsValue } : {}),
|
||||
},
|
||||
],
|
||||
reason,
|
||||
token,
|
||||
...(eticketType ? { eticketType } : {}),
|
||||
@@ -375,25 +385,23 @@ export async function destroyKuaishouIndustryVoucher(
|
||||
status: 'DESTROYED',
|
||||
destroyedAt: now,
|
||||
updatedAt: now,
|
||||
...(eticketType && !String(voucher.eticket_type || '').trim()
|
||||
? { eticketType }
|
||||
: {}),
|
||||
...(eticketType && !String(voucher.eticket_type || '').trim() ? { eticketType } : {}),
|
||||
})
|
||||
updated = next || voucher
|
||||
}
|
||||
|
||||
let task =
|
||||
input.task ||
|
||||
(voucher.task_id ? await getTaskById(voucher.task_id).catch(() => null) : null)
|
||||
input.task || (voucher.task_id ? await getTaskById(voucher.task_id).catch(() => null) : null)
|
||||
|
||||
if (task && !alreadyDestroyed) {
|
||||
task = await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.CLOSED,
|
||||
delivery_status: 'cancelled',
|
||||
result_code: reason,
|
||||
result_message: `电子凭证销毁: ${reason}`,
|
||||
updated_at: now,
|
||||
}) || task
|
||||
task =
|
||||
(await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.CLOSED,
|
||||
delivery_status: 'cancelled',
|
||||
result_code: reason,
|
||||
result_message: `电子凭证销毁: ${reason}`,
|
||||
updated_at: now,
|
||||
})) || task
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
@@ -420,7 +428,9 @@ export async function destroyKuaishouIndustryVoucher(
|
||||
}
|
||||
|
||||
export function normalizeKuaishouIndustryDestroyReason(value: unknown): string {
|
||||
const normalized = String(value || '').trim().toUpperCase()
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (
|
||||
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.ETICKET_EXPIRED ||
|
||||
normalized === KUAISHOU_INDUSTRY_DESTROY_REASONS.USER_APPLY_REFUND ||
|
||||
@@ -448,7 +458,7 @@ function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[
|
||||
}
|
||||
|
||||
return findKuaishouIndustryVoucherByCode(voucherCode, oid)
|
||||
.then((row) => row ? [row] : [])
|
||||
.then((row) => (row ? [row] : []))
|
||||
.catch((error) => {
|
||||
logWarn('[kuaishou-industry/voucher]', '按上下文查询电子凭证失败', {
|
||||
taskId: task?.id || null,
|
||||
@@ -469,11 +479,13 @@ function resolveVoucherConsumeDetails(voucher: KuaishouIndustryVoucherRow): Json
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
serialNum: voucher.consume_serial_num,
|
||||
consumeType: KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE,
|
||||
consumeTime: voucher.consumed_at ? Date.parse(voucher.consumed_at) : 0,
|
||||
}]
|
||||
return [
|
||||
{
|
||||
serialNum: voucher.consume_serial_num,
|
||||
consumeType: KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE,
|
||||
consumeTime: voucher.consumed_at ? Date.parse(voucher.consumed_at) : 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): JsonObject[] {
|
||||
@@ -486,7 +498,9 @@ function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): Jso
|
||||
}
|
||||
|
||||
function normalizeVoucherStatus(value: unknown): string {
|
||||
const normalized = String(value || '').trim().toUpperCase()
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (normalized === 'CONSUMED' || normalized === 'DESTROYED') {
|
||||
return normalized
|
||||
}
|
||||
@@ -495,8 +509,10 @@ function normalizeVoucherStatus(value: unknown): string {
|
||||
}
|
||||
|
||||
export function normalizeKuaishouIndustryConsumeType(value: unknown): string {
|
||||
return String(value || KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE).trim() ||
|
||||
return (
|
||||
String(value || KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE).trim() ||
|
||||
KUAISHOU_INDUSTRY_DEFAULT_CONSUME_TYPE
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePositiveTimestamp(value: unknown): number {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildOpen91SourceEvent,
|
||||
parseOpen91ProductNo,
|
||||
} from './order-service.js'
|
||||
import { buildOpen91SourceEvent, parseOpen91ProductNo } from './order-service.js'
|
||||
|
||||
test('buildOpen91SourceEvent maps 91 productNo into source item identities', () => {
|
||||
const event = buildOpen91SourceEvent({
|
||||
orderNo: 'P91KS202605130001',
|
||||
productNo: 'KS-CLOUD-SKU-001',
|
||||
buyNum: 2,
|
||||
maxAmount: '0.01',
|
||||
timestamp: 1778670308,
|
||||
version: '1.0',
|
||||
sign: 'SIGN',
|
||||
}, {
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
})
|
||||
const event = buildOpen91SourceEvent(
|
||||
{
|
||||
orderNo: 'P91KS202605130001',
|
||||
productNo: 'KS-CLOUD-SKU-001',
|
||||
buyNum: 2,
|
||||
maxAmount: '0.01',
|
||||
timestamp: 1778670308,
|
||||
version: '1.0',
|
||||
sign: 'SIGN',
|
||||
},
|
||||
{
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(event.provider, '91kaquan')
|
||||
assert.equal(event.platform, 'kuaishou')
|
||||
@@ -34,29 +34,29 @@ test('buildOpen91SourceEvent maps 91 productNo into source item identities', ()
|
||||
})
|
||||
|
||||
test('parseOpen91ProductNo splits product name and kuaishou shop id by four dashes', () => {
|
||||
assert.deepEqual(
|
||||
parseOpen91ProductNo('测试-关联商品1----3676797936'),
|
||||
{
|
||||
rawProductNo: '测试-关联商品1----3676797936',
|
||||
productName: '测试-关联商品1',
|
||||
shopId: '3676797936',
|
||||
},
|
||||
)
|
||||
assert.deepEqual(parseOpen91ProductNo('测试-关联商品1----3676797936'), {
|
||||
rawProductNo: '测试-关联商品1----3676797936',
|
||||
productName: '测试-关联商品1',
|
||||
shopId: '3676797936',
|
||||
})
|
||||
})
|
||||
|
||||
test('buildOpen91SourceEvent keeps productNo suffix for kuaishou consume only', () => {
|
||||
const event = buildOpen91SourceEvent({
|
||||
orderNo: 'P91KS202605130002',
|
||||
productNo: '测试-关联商品1----3676797936',
|
||||
buyNum: 1,
|
||||
maxAmount: '0.01',
|
||||
timestamp: 1778670308,
|
||||
version: '1.0',
|
||||
sign: 'SIGN',
|
||||
}, {
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
})
|
||||
const event = buildOpen91SourceEvent(
|
||||
{
|
||||
orderNo: 'P91KS202605130002',
|
||||
productNo: '测试-关联商品1----3676797936',
|
||||
buyNum: 1,
|
||||
maxAmount: '0.01',
|
||||
timestamp: 1778670308,
|
||||
version: '1.0',
|
||||
sign: 'SIGN',
|
||||
},
|
||||
{
|
||||
shopId: '91kaquan',
|
||||
shopName: '91卡券',
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(event.shopId, '91kaquan')
|
||||
assert.equal(event.shopName, '91卡券')
|
||||
|
||||
Reference in New Issue
Block a user