增加测试

This commit is contained in:
yml2213
2026-07-02 12:09:24 +08:00
parent f38efa2edf
commit 664a7b03f8
7 changed files with 1088 additions and 1 deletions
+2
View File
@@ -12,6 +12,8 @@
"mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts", "mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts",
"mock:open91": "tsx scripts/mock-open91-order.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": "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", "typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/index.js", "start": "node dist/index.js",
"start:src": "tsx src/index.ts" "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)
`)
}
+7 -1
View File
@@ -1,3 +1,4 @@
import process from 'node:process'
import { Router } from 'express' import { Router } from 'express'
import { createRateLimitMiddleware } from '../middleware/rate-limit.js' import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
@@ -13,7 +14,7 @@ const router = Router()
const industryRateLimit = createRateLimitMiddleware({ const industryRateLimit = createRateLimitMiddleware({
scope: 'kuaishouIndustry', scope: 'kuaishouIndustry',
windowMs: 60_000, windowMs: 60_000,
max: 120, max: normalizeRateLimitMax(process.env.KUASHOU_INDUSTRY_RATE_LIMIT_MAX, 120),
onLimit: (_req, res) => { onLimit: (_req, res) => {
res.status(200).json(buildIndustryErrorResponse(4010003, '请求过于频繁,请稍后再试')) res.status(200).json(buildIndustryErrorResponse(4010003, '请求过于频繁,请稍后再试'))
}, },
@@ -169,4 +170,9 @@ function mergeRequestParams(req: any): Record<string, any> {
return params 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 export default router
+1
View File
@@ -58,6 +58,7 @@ services:
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
logging: *json-log-rotation logging: *json-log-rotation
volumes: volumes:
- ./apps/backend:/app - ./apps/backend:/app
+1
View File
@@ -54,6 +54,7 @@ services:
CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
logging: *json-log-rotation logging: *json-log-rotation
volumes: volumes:
- ./apps/backend/data:/app/data - ./apps/backend/data:/app/data
+139
View File
@@ -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 或命令行传入