diff --git a/apps/backend/package.json b/apps/backend/package.json index a01d9187..f7a14f9b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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" diff --git a/apps/backend/scripts/test-kuaishou-industry-http.ts b/apps/backend/scripts/test-kuaishou-industry-http.ts new file mode 100644 index 00000000..bfc6d84c --- /dev/null +++ b/apps/backend/scripts/test-kuaishou-industry-http.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 + +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) +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 { + 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,建议通过环境变量或命令行传入 +`) +} diff --git a/apps/backend/scripts/test-kuaishou-industry.ts b/apps/backend/scripts/test-kuaishou-industry.ts new file mode 100644 index 00000000..bca6205a --- /dev/null +++ b/apps/backend/scripts/test-kuaishou-industry.ts @@ -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 = { + 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 = { + 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, 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 = { + 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 = { + 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) => ({ + id: String(e.id), + num: 1, + goodsValue: 100, + })) + printStep(6, `通知商家销毁(指定卡券 id=${destroyTargetIds[0].id})`) + + const destroyBizParams: Record = { + 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 = { + 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 = { + 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, + method: string, +): Record { + const paramStr = JSON.stringify(bizParams) + + const params: Record = { + 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) { + 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 = {} + + 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) +`) +} diff --git a/apps/backend/src/routes/kuaishou-industry.ts b/apps/backend/src/routes/kuaishou-industry.ts index ae9a5348..12cddfa0 100644 --- a/apps/backend/src/routes/kuaishou-industry.ts +++ b/apps/backend/src/routes/kuaishou-industry.ts @@ -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 { 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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4d584741..05fbb996 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 06ef2dc6..b7e98f6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/行业电子凭证/5.测试指南.md b/docs/行业电子凭证/5.测试指南.md new file mode 100644 index 00000000..9b4dcfa2 --- /dev/null +++ b/docs/行业电子凭证/5.测试指南.md @@ -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 或命令行传入