统一前后端代码格式化配置

This commit is contained in:
yml2213
2026-08-16 17:27:12 +08:00
parent 8705f6a6a1
commit 5c7c3e14e3
278 changed files with 6386 additions and 5500 deletions
@@ -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',
@@ -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卡券')