659 lines
22 KiB
TypeScript
659 lines
22 KiB
TypeScript
/**
|
||
* 快手行业电子凭证接口 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,建议通过环境变量或命令行传入
|
||
`)
|
||
}
|