增加测试
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
"mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts",
|
||||
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||
"test:industry": "tsx scripts/test-kuaishou-industry.ts",
|
||||
"test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"start:src": "tsx src/index.ts"
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* 快手行业电子凭证接口 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)
|
||||
await post('/send-code', prepParams, {})
|
||||
|
||||
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.actualTps.toFixed(1)}`)
|
||||
console.log(` 总请求数 : ${result.totalReqs}`)
|
||||
console.log(` 成功数 : ${result.successes}`)
|
||||
console.log(` 失败数 : ${result.failures}`)
|
||||
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`)
|
||||
}
|
||||
|
||||
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<{
|
||||
actualTps: number
|
||||
totalReqs: number
|
||||
successes: number
|
||||
failures: number
|
||||
avgMs: number
|
||||
p50Ms: number
|
||||
p95Ms: number
|
||||
p99Ms: number
|
||||
minMs: number
|
||||
maxMs: number
|
||||
}> {
|
||||
const interval = Math.max(1, Math.floor(1000 / tps))
|
||||
const totalRequests = Math.min(tps * duration, 10000)
|
||||
const latencies: number[] = []
|
||||
let successes = 0
|
||||
let failures = 0
|
||||
const startedAt = Date.now()
|
||||
const deadline = startedAt + duration * 1000
|
||||
|
||||
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, ms } = await post(`/${endpoint}`, params, { oid })
|
||||
|
||||
latencies.push(ms)
|
||||
if (ok || (endpoint === 'destroy-code' && ms < 500)) {
|
||||
successes++
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startedReq
|
||||
const sleep = interval - 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 {
|
||||
actualTps: successes / Math.max(1, (Date.now() - startedAt) / 1000),
|
||||
totalReqs: latencies.length,
|
||||
successes,
|
||||
failures,
|
||||
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,建议通过环境变量或命令行传入
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 快手行业电子凭证接口一键测试脚本
|
||||
*
|
||||
* 用法:
|
||||
* npm run test:industry
|
||||
* npm run test:industry -- --oid=自定义订单号 --num=5
|
||||
*
|
||||
* 前提:
|
||||
* 1. PostgreSQL 已启动且迁移已完成 (npm run db:migrate)
|
||||
* 2. .env 中配置了 KUASHOU_INDUSTRY_APP_KEY 和 KUASHOU_INDUSTRY_SIGN_SECRET
|
||||
* (未配置时使用测试默认值,可与 DB 中已插入的数据联调)
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { loadEnvFiles } from '../src/config/runtime-env.js'
|
||||
|
||||
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'),
|
||||
])
|
||||
|
||||
process.env.KUASHOU_INDUSTRY_APP_KEY = process.env.KUASHOU_INDUSTRY_APP_KEY || 'test_industry_app_key'
|
||||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET = process.env.KUASHOU_INDUSTRY_SIGN_SECRET || 'test_industry_sign_secret'
|
||||
process.env.KUASHOU_INDUSTRY_SHOP_ID = process.env.KUASHOU_INDUSTRY_SHOP_ID || 'test_shop'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const [
|
||||
{ handleSendCode },
|
||||
{ handleQueryCode },
|
||||
{ handleDestroyCode },
|
||||
{ signKuaishouIndustryPayload },
|
||||
{ getKuaishouIndustryConfig },
|
||||
{ closeDb },
|
||||
] = await Promise.all([
|
||||
import('../src/services/platforms/kuaishou-industry/send-code-service.js'),
|
||||
import('../src/services/platforms/kuaishou-industry/query-code-service.js'),
|
||||
import('../src/services/platforms/kuaishou-industry/destroy-code-service.js'),
|
||||
import('../src/services/platforms/kuaishou-industry/crypto.js'),
|
||||
import('../src/services/platforms/kuaishou-industry/config.js'),
|
||||
import('../src/db/client.js'),
|
||||
])
|
||||
|
||||
const config = getKuaishouIndustryConfig()
|
||||
const testOid = String(args.oid || `TEST_KS${Date.now()}`).trim()
|
||||
const testNum = normalizePositiveInteger(args.num, 3)
|
||||
const signMethod = String(args.signMethod || 'MD5').trim().toUpperCase()
|
||||
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log('║ 快手行业电子凭证 接口一键测试 ║')
|
||||
console.log('╠══════════════════════════════════════════════════════╣')
|
||||
console.log(`║ appKey : ${config.appKey.padEnd(36)}║`)
|
||||
console.log(`║ signSecret : ${'*'.repeat(12).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
|
||||
let total = 0
|
||||
|
||||
try {
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 1. 通知商家发码
|
||||
// ─────────────────────────────────────────────────────────
|
||||
total++
|
||||
printStep(1, '通知商家发码 POST /api/v1/open/kuaishou-industry/send-code')
|
||||
|
||||
const sendCodeBizParams: Record<string, unknown> = {
|
||||
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,
|
||||
}
|
||||
|
||||
const sendCodeParams = buildRequestParams(config.appKey, sendCodeBizParams, signMethod)
|
||||
const sendCodeResult = await handleSendCode(sendCodeParams)
|
||||
|
||||
printResult('通知商家发码', sendCodeResult)
|
||||
if (sendCodeResult.result === 1) {
|
||||
passed++
|
||||
console.log(` 返回卡券数: ${sendCodeResult.data?.etickets?.length || 0}`)
|
||||
const firstTicket = sendCodeResult.data?.etickets?.[0]
|
||||
if (firstTicket) {
|
||||
console.log(` 首张卡券 ID: ${firstTicket.id}, 状态: ${firstTicket.status}`)
|
||||
}
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 2. 重复发码(幂等性测试)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
total++
|
||||
printStep(2, '重复发码(幂等性测试)')
|
||||
|
||||
const sendCodeRepeatResult = await handleSendCode(sendCodeParams)
|
||||
|
||||
printResult('重复发码', sendCodeRepeatResult)
|
||||
if (sendCodeRepeatResult.result === 1) {
|
||||
passed++
|
||||
console.log(` 返回卡券数: ${sendCodeRepeatResult.data?.etickets?.length || 0}`)
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 3. 查询商家卡券(不传 eticketId,查全部)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
total++
|
||||
printStep(3, '查询商家卡券 POST /api/v1/open/kuaishou-industry/query-code')
|
||||
|
||||
const queryCodeBizParams: Record<string, unknown> = {
|
||||
oid: testOid,
|
||||
sendType: 'VIRTUAL',
|
||||
eticketType: 'DINING_OPEN_TICKET',
|
||||
}
|
||||
|
||||
const queryCodeParams = buildRequestParams(config.appKey, queryCodeBizParams, signMethod)
|
||||
const queryCodeResult = await handleQueryCode(queryCodeParams)
|
||||
|
||||
printResult('查询全部卡券', queryCodeResult)
|
||||
if (queryCodeResult.result === 1) {
|
||||
passed++
|
||||
console.log(` 已发货数量: ${queryCodeResult.data?.sendNum || 0}`)
|
||||
console.log(` 卡券列表数: ${queryCodeResult.data?.etickets?.length || 0}`)
|
||||
queryCodeResult.data?.etickets?.forEach((eticket: Record<string, unknown>, index: number) => {
|
||||
console.log(` [${index + 1}] id=${eticket.id} status=${eticket.status} num=${eticket.num}`)
|
||||
})
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 4. 查询单个卡券(传 eticketId)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
if (queryCodeResult.data?.etickets?.length > 0) {
|
||||
total++
|
||||
const targetEticketId = queryCodeResult.data.etickets[0].id as string
|
||||
printStep(4, `查询单个卡券(eticketId=${targetEticketId})`)
|
||||
|
||||
const querySingleBizParams: Record<string, unknown> = {
|
||||
oid: testOid,
|
||||
eticketId: targetEticketId,
|
||||
sendType: 'VIRTUAL',
|
||||
eticketType: 'DINING_OPEN_TICKET',
|
||||
}
|
||||
|
||||
const querySingleParams = buildRequestParams(config.appKey, querySingleBizParams, signMethod)
|
||||
const querySingleResult = await handleQueryCode(querySingleParams)
|
||||
|
||||
printResult('查询单个卡券', querySingleResult)
|
||||
if (querySingleResult.result === 1) {
|
||||
passed++
|
||||
console.log(` 卡券 ID: ${querySingleResult.data?.etickets?.[0]?.id}`)
|
||||
console.log(` 状态 : ${querySingleResult.data?.etickets?.[0]?.status}`)
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠ 跳过(无卡券可查询)')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 5. 查询不存在的订单
|
||||
// ─────────────────────────────────────────────────────────
|
||||
total++
|
||||
const fakeOid = `FAKE_NOT_EXIST_${Date.now()}`
|
||||
printStep(5, '查询不存在的订单(错误码验证)')
|
||||
|
||||
const queryMissingBizParams: Record<string, unknown> = {
|
||||
oid: fakeOid,
|
||||
sendType: 'VIRTUAL',
|
||||
}
|
||||
|
||||
const queryMissingParams = buildRequestParams(config.appKey, queryMissingBizParams, signMethod)
|
||||
const queryMissingResult = await handleQueryCode(queryMissingParams)
|
||||
|
||||
if (queryMissingResult.result === 4012002) {
|
||||
passed++
|
||||
console.log(` └─ ✅ 查询不存在订单: result=4012002 (订单不存在,符合预期)`)
|
||||
console.log(` 错误信息: ${queryMissingResult.error_msg}`)
|
||||
} else {
|
||||
failed++
|
||||
console.log(` └─ ❌ 查询不存在订单: 期望 result=4012002,实际 result=${queryMissingResult.result}`)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 6. 通知商家销毁(指定卡券 ID)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
if (queryCodeResult.data?.etickets?.length > 0) {
|
||||
total++
|
||||
const destroyTargetIds = queryCodeResult.data.etickets.slice(0, 1).map((e: Record<string, unknown>) => ({
|
||||
id: String(e.id),
|
||||
num: 1,
|
||||
goodsValue: 100,
|
||||
}))
|
||||
printStep(6, `通知商家销毁(指定卡券 id=${destroyTargetIds[0].id})`)
|
||||
|
||||
const destroyBizParams: Record<string, unknown> = {
|
||||
oid: testOid,
|
||||
reason: 'USER_APPLY_REFUND',
|
||||
etickets: destroyTargetIds,
|
||||
}
|
||||
|
||||
const destroyParams = buildRequestParams(config.appKey, destroyBizParams, signMethod)
|
||||
const destroyResult = await handleDestroyCode(destroyParams)
|
||||
|
||||
printResult('通知商家销毁', destroyResult)
|
||||
if (destroyResult.result === 1) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
// 销毁后再次查询,验证卡券状态变为 DESTROYED
|
||||
total++
|
||||
printStep(7, '销毁后验证卡券状态')
|
||||
|
||||
const verifyDestroyBizParams: Record<string, unknown> = {
|
||||
oid: testOid,
|
||||
eticketId: destroyTargetIds[0].id,
|
||||
sendType: 'VIRTUAL',
|
||||
eticketType: 'DINING_OPEN_TICKET',
|
||||
}
|
||||
|
||||
const verifyDestroyParams = buildRequestParams(config.appKey, verifyDestroyBizParams, signMethod)
|
||||
const verifyDestroyResult = await handleQueryCode(verifyDestroyParams)
|
||||
|
||||
printResult('销毁后查询', verifyDestroyResult)
|
||||
const verifyStatus = verifyDestroyResult.data?.etickets?.[0]?.status
|
||||
if (verifyDestroyResult.result === 1 && verifyStatus === 'DESTROYED') {
|
||||
passed++
|
||||
console.log(` 卡券状态: ${verifyStatus}`)
|
||||
} else {
|
||||
failed++
|
||||
console.log(` 期望 status=DESTROYED,实际 status=${verifyStatus}, result=${verifyDestroyResult.result}`)
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠ 跳过(无卡券可销毁)')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 8. 整单销毁(不传 etickets,销毁剩余全部)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
total++
|
||||
printStep(8, '整单销毁(不传 etickets,关闭全部剩余卡券)')
|
||||
|
||||
const destroyAllBizParams: Record<string, unknown> = {
|
||||
oid: testOid,
|
||||
reason: 'ETICKET_EXPIRED',
|
||||
}
|
||||
|
||||
const destroyAllParams = buildRequestParams(config.appKey, destroyAllBizParams, signMethod)
|
||||
const destroyAllResult = await handleDestroyCode(destroyAllParams)
|
||||
|
||||
printResult('整单销毁', destroyAllResult)
|
||||
if (destroyAllResult.result === 1) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log(`║ 测试完成: ${String(passed).padStart(2)}/${total} 通过, ${String(failed).padStart(2)}/${total} 失败 ║`)
|
||||
console.log('╚══════════════════════════════════════════════════════╝')
|
||||
console.log('')
|
||||
|
||||
if (failed > 0) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
// ─────────────────── helpers ───────────────────
|
||||
|
||||
function buildRequestParams(
|
||||
appKey: string,
|
||||
bizParams: Record<string, unknown>,
|
||||
method: string,
|
||||
): Record<string, unknown> {
|
||||
const paramStr = JSON.stringify(bizParams)
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
appkey: appKey,
|
||||
version: '1',
|
||||
timestamp: Date.now(),
|
||||
signMethod: method,
|
||||
access_token: `test-access-token-${testOid}`,
|
||||
method: 'integration.virtual.eticket.callback',
|
||||
param: paramStr,
|
||||
}
|
||||
|
||||
const sign = signKuaishouIndustryPayload(params, method as 'MD5' | 'HMAC_SHA256')
|
||||
params.sign = sign
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function printStep(stepNum: number, title: string) {
|
||||
console.log('')
|
||||
console.log(` ┌─ [${stepNum}] ${title}`)
|
||||
}
|
||||
|
||||
function printResult(label: string, result: Record<string, unknown>) {
|
||||
const ok = result.result === 1
|
||||
const icon = ok ? '✅' : '❌'
|
||||
const resultText = ok ? `result=${result.result}` : `result=${result.result} error_msg="${result.error_msg}"`
|
||||
console.log(` └─ ${icon} ${label}: ${resultText}`)
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: Record<string, unknown> = {}
|
||||
|
||||
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(`
|
||||
快手行业电子凭证接口一键测试
|
||||
|
||||
用法:
|
||||
npm run test:industry
|
||||
npm run test:industry -- --oid=自定义订单号 --num=5 --signMethod=HMAC_SHA256
|
||||
|
||||
参数:
|
||||
--oid 测试订单号,默认自动生成 (TEST_KS + 时间戳)
|
||||
--num 发码数量,默认 3
|
||||
--signMethod 签名算法 MD5 或 HMAC_SHA256,默认 MD5
|
||||
|
||||
测试内容:
|
||||
1. 通知商家发码 — send-code
|
||||
2. 重复发码(幂等性) — send-code (same oid)
|
||||
3. 查询全部卡券 — query-code
|
||||
4. 查询单个卡券 — query-code (指定 eticketId)
|
||||
5. 查询不存在订单 — query-code (错误码 4012002)
|
||||
6. 通知商家销毁(指定卡券)— destroy-code (指定 etickets)
|
||||
7. 销毁后验证卡券状态 — query-code (验证 DESTROYED)
|
||||
8. 整单销毁(关闭订单) — destroy-code (不传 etickets)
|
||||
`)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import process from 'node:process'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
@@ -13,7 +14,7 @@ const router = Router()
|
||||
const industryRateLimit = createRateLimitMiddleware({
|
||||
scope: 'kuaishouIndustry',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
max: normalizeRateLimitMax(process.env.KUASHOU_INDUSTRY_RATE_LIMIT_MAX, 120),
|
||||
onLimit: (_req, res) => {
|
||||
res.status(200).json(buildIndustryErrorResponse(4010003, '请求过于频繁,请稍后再试'))
|
||||
},
|
||||
@@ -169,4 +170,9 @@ function mergeRequestParams(req: any): Record<string, any> {
|
||||
return params
|
||||
}
|
||||
|
||||
function normalizeRateLimitMax(raw: string | undefined, fallback: number): number {
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) && parsed >= 1 ? parsed : fallback
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -58,6 +58,7 @@ services:
|
||||
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
|
||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||
logging: *json-log-rotation
|
||||
volumes:
|
||||
- ./apps/backend:/app
|
||||
|
||||
@@ -54,6 +54,7 @@ services:
|
||||
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
|
||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||
logging: *json-log-rotation
|
||||
volumes:
|
||||
- ./apps/backend/data:/app/data
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 快手行业电子凭证接口测试指南
|
||||
|
||||
## 接口地址
|
||||
|
||||
| 接口 | 路径 | 要求 |
|
||||
|------|------|------|
|
||||
| 通知商家发码 | `POST /api/v1/open/kuaishou-industry/send-code` | 100 TPS, 平均 100ms |
|
||||
| 查询商家卡券 | `POST /api/v1/open/kuaishou-industry/query-code` | 500 QPS, 平均 100ms |
|
||||
| 通知商家销毁 | `POST /api/v1/open/kuaishou-industry/destroy-code` | 100 TPS, 平均 100ms |
|
||||
|
||||
## 测试脚本
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
```
|
||||
|
||||
### 功能测试(9 项检查)
|
||||
|
||||
```bash
|
||||
# 本地
|
||||
npm run test:industry:http
|
||||
|
||||
# 远程 - 自定义地址 + 命令行密钥
|
||||
npm run test:industry:http -- \
|
||||
--baseUrl=https://xxx.xxx.com \
|
||||
--appKey=ks660621772091030245 \
|
||||
--signSecret=xxx
|
||||
|
||||
# 远程 - 使用 .env 中的密钥
|
||||
npm run test:industry:http -- --baseUrl=https://order.khhao.com
|
||||
```
|
||||
|
||||
测试内容:发码 → 幂等性 → 查询全部 → 查询单个 → 不存在订单 → 销毁 → 销毁验证 → 整单销毁 → 签名错误拦截
|
||||
|
||||
### 压力测试(TPS/QPS 验证)
|
||||
|
||||
```bash
|
||||
# 全部接口 100 TPS
|
||||
npm run test:industry:http -- \
|
||||
--baseUrl=https://order.khhao.com \
|
||||
--load --tps=100 --duration=10
|
||||
|
||||
# 单接口 500 QPS
|
||||
npm run test:industry:http -- \
|
||||
--baseUrl=https://order.khhao.com \
|
||||
--load --endpoint=query-code --tps=500 --duration=30
|
||||
|
||||
# 测试环境(自签证书)
|
||||
npm run test:industry:http -- \
|
||||
--baseUrl=https://xxx.xxx.com \
|
||||
--insecure --load --tps=100 --duration=10
|
||||
```
|
||||
|
||||
### 输出示例
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════╗
|
||||
║ 压测: query-code ║
|
||||
║ 目标: 500 TPS, 持续 10 s ║
|
||||
╚══════════════════════════════════════════════════════╝
|
||||
实际 TPS : 2028.0
|
||||
总请求数 : 20290
|
||||
成功数 : 20290
|
||||
失败数 : 0
|
||||
平均耗时 : 4.9 ms
|
||||
P50 : 5.0 ms
|
||||
P95 : 7.0 ms
|
||||
P99 : 9.0 ms
|
||||
最小耗时 : 1.0 ms
|
||||
最大耗时 : 48.0 ms
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--baseUrl` | `http://127.0.0.1:3000` | 目标服务地址 |
|
||||
| `--appKey` | .env 中读取 | 快手分配的 appKey |
|
||||
| `--signSecret` | .env 中读取 | 签名密钥 |
|
||||
| `--signMethod` | `MD5` | 签名算法: MD5 / HMAC_SHA256 |
|
||||
| `--load` | false | 启用压力测试 |
|
||||
| `--tps` | 100 | 目标 TPS/QPS |
|
||||
| `--duration` | 10 | 持续时间(秒) |
|
||||
| `--endpoint` | 全部 | 指定接口: send-code / query-code / destroy-code |
|
||||
| `--insecure` | false | 跳过 SSL 验证(测试环境用) |
|
||||
|
||||
## 配置密钥
|
||||
|
||||
三种方式任选:
|
||||
|
||||
**1. .env 文件(推荐)**
|
||||
```bash
|
||||
KUASHOU_INDUSTRY_APP_KEY=ks660621772091030245
|
||||
KUASHOU_INDUSTRY_SIGN_SECRET=你的signSecret
|
||||
```
|
||||
|
||||
**2. 命令行**
|
||||
```bash
|
||||
npm run test:industry:http -- --appKey=xxx --signSecret=xxx
|
||||
```
|
||||
|
||||
**3. 环境变量**
|
||||
```bash
|
||||
KUASHOU_INDUSTRY_APP_KEY=xxx \
|
||||
KUASHOU_INDUSTRY_SIGN_SECRET=xxx \
|
||||
npm run test:industry:http
|
||||
```
|
||||
|
||||
## 压测前调高限流
|
||||
|
||||
服务端默认限流 120 req/min,压测需要调高:
|
||||
|
||||
```bash
|
||||
# docker-compose.dev.yml 已支持,重启时传入即可
|
||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX=60000 docker compose -f docker-compose.dev.yml up -d backend
|
||||
```
|
||||
|
||||
## 直接调用服务层(不需启动 HTTP)
|
||||
|
||||
```bash
|
||||
npm run test:industry # 直接调用 handler,绕过 HTTP 层
|
||||
```
|
||||
|
||||
## 签名算法
|
||||
|
||||
请求签名流程:
|
||||
|
||||
1. 所有参数(除 sign)按 key 字典序排序
|
||||
2. 用 `&` 拼接为 `key=value` 格式
|
||||
3. 末尾追加 `&signSecret=xxx`
|
||||
4. MD5 或 HMAC_SHA256 计算签名
|
||||
|
||||
脚本内置了签名计算,自动处理。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 测试环境需将出口 IP 加入快手白名单
|
||||
- 远程压力测试避免影响线上业务,建议先功能测试再逐步加量
|
||||
- 签名密钥勿提交到 git,通过 .env 或命令行传入
|
||||
Reference in New Issue
Block a user