Files
order_site/apps/backend/scripts/test-kuaishou-industry-http.ts
T
yml2213 81ae04dae0 接入 ESLint 并清理未使用代码
- 前后端接入 ESLint 10 扁平配置(typescript-eslint 类型感知检查 + react-hooks/react-refresh),新增 lint / lint:check / lint:fix 脚本并纳入 check 链路
- 清理全部 no-unused-vars 与 no-useless-assignment 警告,修复 floating promises、正则转义等问题
- 删除确认无引用的死代码(markKuaishouCloudBindUrlRefreshFailed、未使用 helper 等)
2026-08-16 18:04:55 +08:00

659 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 快手行业电子凭证接口 HTTP 测试 & 压力测试脚本
*
* 用法:
* # 本地测试
* npm run test:industry:http
* npm run test:industry:http -- --baseUrl=http://127.0.0.1:3000
*
* # 远程测试(生产/测试环境)
* npm run test:industry:http -- --provider=production
* npm run test:industry:http -- --baseUrl=https://xxx.xxx.com --appKey=xxx --signSecret=xxx
*
* # 压力测试
* npm run test:industry:http -- --load --tps=100 --duration=10
* npm run test:industry:http -- --baseUrl=https://xxx.xxx.com --load --endpoint=query-code --tps=500 --duration=30
*
* 前提:
* 1. 目标服务已启动
* 2. .env 中配置了 KUASHOU_INDUSTRY_APP_KEY 和 KUASHOU_INDUSTRY_SIGN_SECRET
* (可通过 --appKey 和 --signSecret 覆盖)
* 3. 压力测试前需调高限流阈值 (KUASHOU_INDUSTRY_RATE_LIMIT_MAX=60000)
*/
import crypto from 'node:crypto'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { loadEnvFiles } from '../src/config/runtime-env.js'
type JsonObject = Record<string, unknown>
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
const args = parseArgs(process.argv.slice(2))
// 环境预设
const providerPresets: Record<string, { baseUrl: string; description: string }> = {
production: {
baseUrl: 'https://openapi.kwaixiaodian.com',
description: '快手开放平台线上环境',
},
'production-backup': {
baseUrl: 'https://open.kwaixiaodian.com',
description: '快手开放平台线上环境(备用)',
},
staging: {
baseUrl: 'https://gw-merchant-staging.test.gifshow.com',
description: '快手开放平台测试环境',
},
}
let baseUrl: string
if (args.provider && providerPresets[String(args.provider)]) {
const preset = providerPresets[String(args.provider)]
baseUrl = String(args.baseUrl || preset.baseUrl).replace(/\/+$/, '')
if (!args.baseUrl) {
console.log(` 预设环境: ${preset.description}`)
}
} else {
baseUrl = String(
args.baseUrl || process.env.KUASHOU_INDUSTRY_TEST_BASE_URL || 'http://127.0.0.1:3000',
).replace(/\/+$/, '')
}
// 签名配置:CLI 参数 > 环境变量 > 默认值
const appKey = (String(args.appKey || '') || process.env.KUASHOU_INDUSTRY_APP_KEY || '').trim()
const signSecret = (
String(args.signSecret || '') ||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET ||
''
).trim()
const signMethod = String(args.signMethod || 'MD5')
.trim()
.toUpperCase()
const isLoadTest = Boolean(args.load)
const targetTps = normalizePositiveInteger(args.tps, 100)
const testDuration = normalizePositiveInteger(args.duration, 10)
const testEndpoint = String(args.endpoint || '').trim()
const insecure = Boolean(args.insecure) // 跳过 SSL 证书验证(仅测试环境)
if (args.help) {
printHelp()
process.exit(0)
}
if (!appKey || !signSecret) {
console.error('缺少签名配置。请通过以下任一方式提供:')
console.error(' 1. .env 中设置 KUASHOU_INDUSTRY_APP_KEY 和 KUASHOU_INDUSTRY_SIGN_SECRET')
console.error(' 2. 命令行: --appKey=xxx --signSecret=xxx')
process.exit(1)
}
if (insecure) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
console.log(' ⚠ SSL 证书验证已跳过(--insecure')
}
// ─────────────────────────────────────────────────────────────
// 功能测试
// ─────────────────────────────────────────────────────────────
if (!isLoadTest) {
const testOid = `HTTP_TEST_${Date.now()}`
const testNum = 3
console.log('')
console.log('╔══════════════════════════════════════════════════════╗')
console.log('║ 快手行业电子凭证 HTTP 接口功能测试 ║')
console.log('╠══════════════════════════════════════════════════════╣')
console.log(`║ 服务地址 : ${baseUrl.padEnd(36)}║`)
console.log(`║ appKey : ${appKey.padEnd(36)}║`)
console.log(`║ signMethod : ${signMethod.padEnd(36)}║`)
console.log(`║ 测试订单号 : ${testOid.padEnd(36)}║`)
console.log(`║ 发码数量 : ${String(testNum).padEnd(36)}║`)
console.log('╚══════════════════════════════════════════════════════╝')
console.log('')
let passed = 0
let failed = 0
// 1. 通知商家发码
const sendParams = buildSignedParams(
appKey,
{
oid: testOid,
sellerId: '2174425348',
num: testNum,
itemId: '20842040339391',
skuId: '87457167264391',
itemTitle: '测试电子凭证商品',
sendType: 'VIRTUAL',
eticketType: 'DINING_OPEN_TICKET',
token: `test-token-${testOid}`,
certExpireType: 3,
certExpDays: 30,
certActualStartTime: Date.now(),
certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000,
},
signMethod,
)
const r1 = await post('/send-code', sendParams, { oid: testOid })
if (assertResult(1, '通知商家发码', r1)) passed++
else failed++
if (r1.ok) {
const len = r1.data?.data?.etickets?.length || 0
const firstId = r1.data?.data?.etickets?.[0]?.id
console.log(` 返回卡券数: ${len},首张 ID: ${firstId}`)
}
// 2. 重复发码(幂等性)
const r2 = await post('/send-code', sendParams, { oid: testOid })
if (assertResult(2, '重复发码(幂等性)', r2)) passed++
else failed++
// 3. 查询全部卡券
const qBiz: JsonObject = { oid: testOid, sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET' }
const r3 = await post('/query-code', buildSignedParams(appKey, qBiz, signMethod), {
oid: testOid,
})
if (assertResult(3, '查询全部卡券', r3)) passed++
else failed++
if (r3.ok) {
console.log(` 卡券数: ${r3.data?.data?.etickets?.length || 0}`)
r3.data?.data?.etickets?.forEach((e: JsonObject, i: number) => {
console.log(` [${i + 1}] id=${e.id} status=${e.status}`)
})
}
// 4. 查询单个卡券
const firstEticketId = r3.data?.data?.etickets?.[0]?.id
if (firstEticketId) {
const r4 = await post(
'/query-code',
buildSignedParams(
appKey,
{
oid: testOid,
eticketId: String(firstEticketId),
sendType: 'VIRTUAL',
eticketType: 'DINING_OPEN_TICKET',
},
signMethod,
),
{ oid: testOid },
)
if (assertResult(4, `查询单个卡券 (eticketId=${firstEticketId})`, r4)) passed++
else failed++
}
// 5. 查询不存在的订单
const fakeOid = `FAKE_${Date.now()}`
const r5 = await post(
'/query-code',
buildSignedParams(appKey, { oid: fakeOid, sendType: 'VIRTUAL' }, signMethod),
{ oid: fakeOid },
)
const expect4012002 = r5.data?.result === 4012002
if (expect4012002) {
passed++
console.log(` ✅ [5] 查询不存在订单: result=4012002 (符合预期)`)
} else {
failed++
console.log(` ❌ [5] 查询不存在订单: 期望 4012002 实际 ${r5.data?.result}`)
}
// 6. 销毁指定卡券
if (firstEticketId) {
const r6 = await post(
'/destroy-code',
buildSignedParams(
appKey,
{
oid: testOid,
reason: 'USER_APPLY_REFUND',
etickets: [{ id: String(firstEticketId), num: 1, goodsValue: 100 }],
},
signMethod,
),
{ oid: testOid },
)
if (assertResult(6, `销毁卡券 (id=${firstEticketId})`, r6)) passed++
else failed++
// 7. 验证销毁后状态
const r7 = await post(
'/query-code',
buildSignedParams(
appKey,
{ oid: testOid, eticketId: String(firstEticketId), sendType: 'VIRTUAL' },
signMethod,
),
{ oid: testOid },
)
const destroyed = r7.data?.result === 1 && r7.data?.data?.etickets?.[0]?.status === 'DESTROYED'
if (destroyed) {
passed++
console.log(` ✅ [7] 销毁后验证: status=DESTROYED`)
} else {
failed++
console.log(
` ❌ [7] 销毁后验证: 期望 DESTROYED 实际 ${r7.data?.data?.etickets?.[0]?.status}`,
)
}
}
// 8. 整单销毁
const r8 = await post(
'/destroy-code',
buildSignedParams(appKey, { oid: testOid, reason: 'ETICKET_EXPIRED' }, signMethod),
{ oid: testOid },
)
if (assertResult(8, '整单销毁', r8)) passed++
else failed++
// 9. 签名错误测试
const badParams = buildSignedParams(appKey, { oid: testOid, sendType: 'VIRTUAL' }, signMethod)
badParams.sign = 'bad_sign_value'
const r9 = await post('/query-code', badParams, { oid: testOid })
const signRejected = r9.status === 400 || (r9.data?.result && r9.data?.result !== 1)
if (signRejected) {
passed++
console.log(` ✅ [9] 签名错误被拒绝: HTTP ${r9.status}`)
} else {
failed++
console.log(` ❌ [9] 签名错误未被拒绝`)
}
console.log('')
console.log('╔══════════════════════════════════════════════════════╗')
console.log(
`║ HTTP 功能测试: ${String(passed).padStart(2)}/${passed + failed} 通过, ${String(failed).padStart(2)}/${passed + failed} 失败 ║`,
)
console.log('╚══════════════════════════════════════════════════════╝')
console.log('')
process.exit(failed > 0 ? 1 : 0)
}
// ─────────────────────────────────────────────────────────────
// 压力测试
// ─────────────────────────────────────────────────────────────
const endpointsToTest = testEndpoint ? [testEndpoint] : ['send-code', 'query-code', 'destroy-code']
const loadTestOid = `LOAD_${Date.now()}`
// 先发码创建订单以供查询/销毁
console.log('')
console.log(` 准备测试数据: 通知商家发码 (oid=${loadTestOid})...`)
const prepParams = buildSignedParams(
appKey,
{
oid: loadTestOid,
sellerId: '2174425348',
num: 10,
itemId: '20842040339391',
skuId: '87457167264391',
itemTitle: '压测商品',
sendType: 'VIRTUAL',
eticketType: 'DINING_OPEN_TICKET',
token: `load-test-token-${loadTestOid}`,
certExpireType: 3,
certExpDays: 30,
certActualStartTime: Date.now(),
certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000,
},
signMethod,
)
const prepResult = await post('/send-code', prepParams, {})
if (prepResult.data?.result === 1) {
console.log(` 数据准备完成 (${prepResult.ms}ms)`)
} else {
console.log(
` ⚠ 数据准备失败: result=${prepResult.data?.result} error_msg="${prepResult.data?.error_msg}" (${prepResult.ms}ms)`,
)
console.log(` 请确认服务端已配置正确的 appKey/signSecret 且未触发限流`)
}
for (const endpoint of endpointsToTest) {
console.log('')
console.log('╔══════════════════════════════════════════════════════╗')
console.log(`║ 压测: ${endpoint.padEnd(46)}║`)
console.log(
`║ 目标: ${String(targetTps).padEnd(4)} TPS, 持续 ${String(testDuration).padEnd(3)}s ║`,
)
console.log('╚══════════════════════════════════════════════════════╝')
const result = await runLoadTest(endpoint, targetTps, testDuration, loadTestOid)
console.log(` 成功 TPS : ${result.successTps.toFixed(1)} (业务 result=1)`)
console.log(` 总请求数 : ${result.totalReqs}`)
console.log(` 业务成功 : ${result.successes}`)
console.log(
` 业务失败 : ${result.bizErrors}${result.bizSample ? ` (示例: ${result.bizSample})` : ''}`,
)
console.log(` 被限流 : ${result.rateLimited}`)
console.log(` 平均耗时 : ${result.avgMs.toFixed(1)} ms`)
console.log(` P50 : ${result.p50Ms.toFixed(1)} ms`)
console.log(` P95 : ${result.p95Ms.toFixed(1)} ms`)
console.log(` P99 : ${result.p99Ms.toFixed(1)} ms`)
console.log(` 最小耗时 : ${result.minMs.toFixed(1)} ms`)
console.log(` 最大耗时 : ${result.maxMs.toFixed(1)} ms`)
if (result.rateLimited > result.totalReqs * 0.1) {
console.log(
` ⚠ 注意: ${((result.rateLimited / result.totalReqs) * 100).toFixed(0)}% 请求被限流,建议调高 KUASHOU_INDUSTRY_RATE_LIMIT_MAX`,
)
}
}
console.log('')
// ─────────────────── 核心函数 ───────────────────
async function post(
path: string,
params: JsonObject,
_ctx: JsonObject,
): Promise<{ ok: boolean; status: number; data: JsonObject; ms: number }> {
const url = `${baseUrl}/api/v1/open/kuaishou-industry${path}`
const body = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (key !== 'param') {
body.append(key, String(value))
}
}
body.append('param', String(params.param || '{}'))
const startedAt = Date.now()
let status = 0
let data: JsonObject
let ok = false
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
status = res.status
const text = await res.text()
try {
data = JSON.parse(text)
} catch {
data = { raw: text }
}
ok = res.ok && data.result === 1
} catch (err) {
data = { error: String(err) }
}
return { ok, status, data, ms: Date.now() - startedAt }
}
async function runLoadTest(
endpoint: string,
tps: number,
duration: number,
oid: string,
): Promise<{
successTps: number
totalReqs: number
successes: number
bizErrors: number
bizSample: string
rateLimited: number
avgMs: number
p50Ms: number
p95Ms: number
p99Ms: number
minMs: number
maxMs: number
}> {
const latencies: number[] = []
let successes = 0
let bizErrors = 0
let rateLimited = 0
let bizSample = ''
const startedAt = Date.now()
const deadline = startedAt + duration * 1000
const intervalMs = Math.max(1, Math.floor(1000 / tps))
const worker = async () => {
while (Date.now() < deadline) {
const startedReq = Date.now()
const bizParams: JsonObject = {
oid,
sendType: 'VIRTUAL',
eticketType: 'DINING_OPEN_TICKET',
}
if (endpoint === 'send-code') {
bizParams.sellerId = '2174425348'
bizParams.num = 1
bizParams.itemId = '20842040339391'
bizParams.token = `load-${Date.now()}`
bizParams.certExpireType = 3
bizParams.certExpDays = 30
bizParams.certActualStartTime = Date.now()
bizParams.certActualEndTime = Date.now() + 30 * 24 * 60 * 60 * 1000
}
if (endpoint === 'destroy-code') {
bizParams.reason = 'ETICKET_EXPIRED'
}
const params = buildSignedParams(appKey, bizParams, signMethod)
const { ok, data, ms } = await post(`/${endpoint}`, params, { oid })
latencies.push(ms)
if (ok) {
successes++
} else if (data?.error_msg && String(data.error_msg).includes('频繁')) {
rateLimited++
} else if (data?.result === 4010003 && String(data?.error_msg || '').includes('频繁')) {
rateLimited++
} else if (data?.result != null && data?.result !== 1) {
bizErrors++
if (!bizSample) {
bizSample = `result=${data.result} "${data.error_msg}"`
}
} else {
bizErrors++
if (!bizSample) {
bizSample = `HTTP ${ms}ms ${JSON.stringify(data).slice(0, 100)}`
}
}
const elapsed = Date.now() - startedReq
const sleep = intervalMs - elapsed
if (sleep > 0) {
await delay(sleep)
}
}
}
const concurrency = Math.max(1, Math.ceil(tps / 50))
const workers = Array.from({ length: concurrency }, () => worker())
await Promise.all(workers)
latencies.sort((a, b) => a - b)
return {
successTps: successes / Math.max(1, (Date.now() - startedAt) / 1000),
totalReqs: latencies.length,
successes,
bizErrors,
bizSample,
rateLimited,
avgMs: avg(latencies),
p50Ms: percentile(latencies, 50),
p95Ms: percentile(latencies, 95),
p99Ms: percentile(latencies, 99),
minMs: latencies[0] || 0,
maxMs: latencies[latencies.length - 1] || 0,
}
}
// ─────────────────── 签名 ───────────────────
function buildSignedParams(
currentAppKey: string,
bizParams: JsonObject,
method: string,
): JsonObject {
const paramStr = JSON.stringify(bizParams)
const params: JsonObject = {
appkey: currentAppKey,
version: '1',
timestamp: Date.now(),
signMethod: method,
access_token: `test-token-${Date.now()}`,
method: 'integration.virtual.eticket.callback',
param: paramStr,
}
const sign = signKuaishouIndustry(params, method)
params.sign = sign
return params
}
function signKuaishouIndustry(params: JsonObject, method: string): string {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
const queryString = entries.map(([key, value]) => `${key}=${stringifySignVal(value)}`).join('&')
const source = `${queryString}&signSecret=${signSecret}`
if (method === 'HMAC_SHA256') {
return crypto.createHmac('sha256', signSecret).update(source, 'utf8').digest('base64')
}
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
}
function stringifySignVal(value: unknown): string {
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (value == null) return ''
if (typeof value === 'object')
return JSON.stringify(value, Object.keys(value as JsonObject).sort())
return String(value)
}
// ─────────────────── 工具 ───────────────────
function assertResult(
num: number,
label: string,
r: { ok: boolean; status: number; data: JsonObject; ms: number },
) {
if (r.ok) {
console.log(` ✅ [${num}] ${label}: result=1 (${r.ms}ms)`)
return true
}
console.log(
` ❌ [${num}] ${label}: HTTP ${r.status} result=${r.data?.result} error_msg="${r.data?.error_msg}" (${r.ms}ms)`,
)
return false
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function avg(arr: number[]): number {
if (arr.length === 0) return 0
return arr.reduce((s, v) => s + v, 0) / arr.length
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0
const idx = Math.ceil((p / 100) * sorted.length) - 1
return sorted[Math.max(0, Math.min(idx, sorted.length - 1))]
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function parseArgs(rawArgs: string[]): JsonObject {
const parsed: JsonObject = {}
for (const rawArg of rawArgs) {
const arg = String(rawArg || '').trim()
if (!arg) continue
if (arg === '--help' || arg === '-h') {
parsed.help = true
continue
}
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
const eqIdx = normalized.indexOf('=')
if (eqIdx < 0) {
parsed[normalized] = true
continue
}
const key = normalized.slice(0, eqIdx).trim()
const value = normalized.slice(eqIdx + 1).trim()
if (key) parsed[key] = value
}
return parsed
}
function printHelp() {
console.log(`
快手行业电子凭证 HTTP 测试 & 压力测试
功能测试:
npm run test:industry:http
npm run test:industry:http -- --baseUrl=http://127.0.0.1:3000
npm run test:industry:http -- --provider=production
npm run test:industry:http -- --baseUrl=https://xxx.xxx.com --appKey=xxx --signSecret=xxx
压力测试:
npm run test:industry:http -- --load --tps=100 --duration=10
npm run test:industry:http -- --baseUrl=https://xxx.xxx.com --load --endpoint=query-code --tps=500 --duration=30
参数:
--baseUrl 目标服务地址,默认 http://127.0.0.1:3000
--provider 环境预设: production / production-backup / staging
--appKey 快手分配的 appKey(覆盖 .env
--signSecret 签名密钥(覆盖 .env
--signMethod 签名算法 MD5 或 HMAC_SHA256,默认 MD5
--load 启用压力测试模式
--tps 目标 TPS/QPS,默认 100
--duration 持续时间(秒),默认 10
--endpoint 指定测试接口: send-code / query-code / destroy-code,不指定则全部测试
--insecure 跳过 SSL 证书验证(仅限测试环境)
远程测试示例:
# 测试生产环境(使用 .env 中的生产密钥)
npm run test:industry:http -- --provider=production
# 测试测试环境(白名单 IP 需要提前配置)
npm run test:industry:http -- --provider=staging --insecure
# 自定义远程地址
npm run test:industry:http -- --baseUrl=https://order.khhao.com
# 远程压力测试
npm run test:industry:http -- --baseUrl=https://order.khhao.com --load --tps=100 --duration=30
注意:
- 测试环境需要将出口 IP 加入快手白名单
- 压力测试前需调高限流阈值(KUASHOU_INDUSTRY_RATE_LIMIT_MAX=60000
- 远程压力测试注意不要影响线上业务
- 签名密钥请勿提交到 git,建议通过环境变量或命令行传入
`)
}