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