/** * 生成测试用例文档脚本 * 运行所有场景,输出请求/响应报文用于填写 测试用例.md * * 用法: * npm run test:industry:gen -- --baseUrl=http://127.0.0.1 */ 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 baseUrl = String(args.baseUrl || 'http://127.0.0.1').replace(/\/+$/, '') 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() if (!appKey || !signSecret) { console.error('请配置 appKey 和 signSecret') process.exit(1) } const oid = `TEST_CASE_${Date.now()}` console.log('# 测试用例生成\n') console.log(`> 服务地址: ${baseUrl}`) console.log(`> 测试订单号: ${oid}`) console.log(`> appKey: ${appKey}`) console.log(`> signMethod: ${signMethod}\n`) // ───── helpers ───── function buildParams(biz: JsonObject, method = signMethod): JsonObject { const paramStr = JSON.stringify(biz) const p: JsonObject = { appkey: appKey, version: '1', timestamp: Date.now(), signMethod: method, access_token: '', method: '', param: paramStr, } p.sign = signKuaishou(p, method) return p } function buildParamsFakeSign(biz: JsonObject): JsonObject { const p = buildParams(biz) p.sign = '00000000000000000000000000000000' return p } function buildParamsNoAppKey(biz: JsonObject): JsonObject { const paramStr = JSON.stringify(biz) const p: JsonObject = { version: '1', timestamp: Date.now(), signMethod: signMethod, access_token: '', method: '', param: paramStr, } p.sign = signKuaishou(p, signMethod) return p } function signKuaishou(params: JsonObject, method: string): string { const entries = Object.entries(params) .filter(([k]) => k !== 'sign' && k !== 'signSecret') .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) const qs = entries.map(([k, v]) => `${k}=${stringifyVal(v)}`).join('&') const src = `${qs}&signSecret=${signSecret}` if (method === 'HMAC_SHA256') return crypto.createHmac('sha256', signSecret).update(src, 'utf8').digest('base64') return crypto.createHash('md5').update(src, 'utf8').digest('hex').toLowerCase() } function stringifyVal(v: unknown): string { if (typeof v === 'number' && Number.isFinite(v)) return String(v) if (typeof v === 'boolean') return v ? 'true' : 'false' if (v == null) return '' if (typeof v === 'object') return JSON.stringify(v, Object.keys(v as JsonObject).sort()) return String(v) } async function call(endpoint: string, params: JsonObject) { const url = `${baseUrl}/api/v1/open/kuaishou-industry${endpoint}` const body = new URLSearchParams() for (const [k, v] of Object.entries(params)) { if (k !== 'param') body.append(k, String(v)) } body.append('param', String(params.param || '{}')) const started = Date.now() let status = 0 let json: JsonObject = {} 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 { json = JSON.parse(text) } catch { json = { raw: text } } } catch (err) { json = { error: String(err) } } return { status, json, ms: Date.now() - started } } function paramsToForm(params: JsonObject): string { const p: string[] = [] for (const [k, v] of Object.entries(params)) { if (k !== 'param' && k !== 'sign') p.push(`${k}=${v}`) } const signPart = params.sign ? `&sign=${params.sign}` : '' return p.join('&') + `¶m=${encodeURIComponent(String(params.param || '{}'))}` + signPart } function pp(v: unknown): string { return JSON.stringify(v, null, 2) } // ───── 先准备订单 ───── console.log('---') console.log('准备测试订单...\n') const prepBiz: JsonObject = { oid, sellerId: '2174425348', num: 3, itemId: '20842040339391', skuId: '87457167264391', itemTitle: '测试电子凭证商品', sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET', token: `token-${oid}`, certExpireType: 3, certExpDays: 30, certActualStartTime: Date.now(), certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000, } const prepParams = buildParams(prepBiz) const prep = await call('/send-code', prepParams) console.log(` 发码结果: result=${prep.json.result} (${prep.ms}ms)\n`) let firstTicketId = '' if (prep.json.data?.etickets?.length > 0) { firstTicketId = String(prep.json.data.etickets[0].id) } // ──────────────────────────────────────────── console.log('═══════════════════════════════════════════') console.log('一、通知发码接口测试用例') console.log('═══════════════════════════════════════════\n') // 1.1 收单成功 console.log('【场景1】收单成功\n') const sendBiz: JsonObject = { oid: `SUCCESS_${Date.now()}`, sellerId: '2174425348', num: 2, itemId: '20842040339391', skuId: '87457167264391', itemTitle: '测试电子凭证', sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET', token: `token-success-${Date.now()}`, certExpireType: 1, certStartTime: Date.now(), certEndTime: Date.now() + 365 * 24 * 60 * 60 * 1000, certActualStartTime: Date.now(), certActualEndTime: Date.now() + 365 * 24 * 60 * 60 * 1000, } const sendParams = buildParams(sendBiz) const sendRes = await call('/send-code', sendParams) console.log(`请求报文 (application/x-www-form-urlencoded):`) console.log(` ${paramsToForm(sendParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(sendRes.json)}\n`) console.log(`HTTP ${sendRes.status} (${sendRes.ms}ms)\n`) // 1.2 拒单:appkey 未传入 console.log('【场景2】拒单:appkey 未传入\n') const noAppKeyBiz: JsonObject = { oid: `NOKEY_${Date.now()}`, sellerId: '2174425348', num: 1, itemId: '20842040339391', sendType: 'VIRTUAL', token: `token-nokey-${Date.now()}`, certExpireType: 3, certExpDays: 30, certActualStartTime: Date.now(), certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000, } const noKeyParams = buildParamsNoAppKey(noAppKeyBiz) const noKeyRes = await call('/send-code', noKeyParams) console.log(`请求报文:`) console.log(` ${paramsToForm(noKeyParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(noKeyRes.json)}\n`) console.log(`HTTP ${noKeyRes.status} (${noKeyRes.ms}ms)\n`) // 1.3 拒单:签名错误 console.log('【场景3】拒单:签名错误\n') const badSignBiz: JsonObject = { oid: `BADSIGN_${Date.now()}`, sellerId: '2174425348', num: 1, itemId: '20842040339391', sendType: 'VIRTUAL', token: `token-badsign-${Date.now()}`, certExpireType: 3, certExpDays: 30, certActualStartTime: Date.now(), certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000, } const badSignParams = buildParamsFakeSign(badSignBiz) const badSignRes = await call('/send-code', badSignParams) console.log(`请求报文:`) console.log(` ${paramsToForm(badSignParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(badSignRes.json)}\n`) console.log(`HTTP ${badSignRes.status} (${badSignRes.ms}ms)\n`) // 1.4 拒单:参数缺失 console.log('【场景4】拒单:参数缺失(缺少必要字段 oid)\n') const missingParams = buildParams({ sellerId: '123', num: 1, sendType: 'VIRTUAL' }) const missingRes = await call('/send-code', missingParams) console.log(`请求报文:`) console.log(` ${paramsToForm(missingParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(missingRes.json)}\n`) console.log(`HTTP ${missingRes.status} (${missingRes.ms}ms)\n`) // ──────────────────────────────────────────── console.log('═══════════════════════════════════════════') console.log('二、查询发码结果接口测试用例') console.log('═══════════════════════════════════════════\n') // 2.1 查询成功 console.log('【场景5】查询成功:返回卡券信息\n') const queryBiz: JsonObject = { oid, sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET' } const queryParams = buildParams(queryBiz) const queryRes = await call('/query-code', queryParams) console.log(`请求报文:`) console.log(` ${paramsToForm(queryParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(queryRes.json)}\n`) console.log(`HTTP ${queryRes.status} (${queryRes.ms}ms)\n`) // 2.2 查询单个卡券 if (firstTicketId) { console.log('【场景6】查询成功:查询单个卡券(指定 eticketId)\n') const singleBiz: JsonObject = { oid, eticketId: firstTicketId, sendType: 'VIRTUAL' } const singleParams = buildParams(singleBiz) const singleRes = await call('/query-code', singleParams) console.log(`请求报文:`) console.log(` ${paramsToForm(singleParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(singleRes.json)}\n`) console.log(`HTTP ${singleRes.status} (${singleRes.ms}ms)\n`) // 2.3 卡券不存在 console.log('【场景7】查询失败:卡券不存在(错误码 4012005)\n') const notFoundBiz: JsonObject = { oid, eticketId: '99999', sendType: 'VIRTUAL' } const notFoundParams = buildParams(notFoundBiz) const notFoundRes = await call('/query-code', notFoundParams) console.log(`请求报文:`) console.log(` ${paramsToForm(notFoundParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(notFoundRes.json)}\n`) console.log(`HTTP ${notFoundRes.status} (${notFoundRes.ms}ms)\n`) } // 2.4 订单不存在 console.log('【场景8】查询失败:订单不存在(错误码 4012002)\n') const noOrderBiz: JsonObject = { oid: `NOT_EXIST_${Date.now()}`, sendType: 'VIRTUAL' } const noOrderParams = buildParams(noOrderBiz) const noOrderRes = await call('/query-code', noOrderParams) console.log(`请求报文:`) console.log(` ${paramsToForm(noOrderParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(noOrderRes.json)}\n`) console.log(`HTTP ${noOrderRes.status} (${noOrderRes.ms}ms)\n`) // ──────────────────────────────────────────── console.log('═══════════════════════════════════════════') console.log('三、关单 / 销毁接口测试用例') console.log('═══════════════════════════════════════════\n') // 3.1 销毁指定卡券成功 if (firstTicketId) { console.log('【场景9】销毁指定卡券成功\n') const destroyBiz: JsonObject = { oid, reason: 'USER_APPLY_REFUND', etickets: [{ id: firstTicketId, num: 1, goodsValue: 100 }], } const destroyParams = buildParams(destroyBiz) const destroyRes = await call('/destroy-code', destroyParams) console.log(`请求报文:`) console.log(` ${paramsToForm(destroyParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(destroyRes.json)}\n`) console.log(`HTTP ${destroyRes.status} (${destroyRes.ms}ms)\n`) // 3.2 销毁后查询验证 console.log('【场景10】销毁后验证卡券状态为 DESTROYED\n') const verifyBiz: JsonObject = { oid, eticketId: firstTicketId, sendType: 'VIRTUAL' } const verifyParams = buildParams(verifyBiz) const verifyRes = await call('/query-code', verifyParams) console.log(`请求报文:`) console.log(` ${paramsToForm(verifyParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(verifyRes.json)}\n`) console.log(`HTTP ${verifyRes.status} (${verifyRes.ms}ms)\n`) } // 3.3 整单销毁 console.log('【场景11】整单销毁(不传 etickets,关闭全部剩余卡券)\n') const destroyAllBiz: JsonObject = { oid, reason: 'ETICKET_EXPIRED' } const destroyAllParams = buildParams(destroyAllBiz) const destroyAllRes = await call('/destroy-code', destroyAllParams) console.log(`请求报文:`) console.log(` ${paramsToForm(destroyAllParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(destroyAllRes.json)}\n`) console.log(`HTTP ${destroyAllRes.status} (${destroyAllRes.ms}ms)\n`) // 3.4 销毁不存在的订单(应返回成功) console.log('【场景12】销毁不存在的订单(应返回 result=1)\n') const destroyFakeBiz: JsonObject = { oid: `FAKE_DESTROY_${Date.now()}`, reason: 'ETICKET_EXPIRED' } const destroyFakeParams = buildParams(destroyFakeBiz) const destroyFakeRes = await call('/destroy-code', destroyFakeParams) console.log(`请求报文:`) console.log(` ${paramsToForm(destroyFakeParams)}\n`) console.log(`响应报文:`) console.log(` ${JSON.stringify(destroyFakeRes.json)}\n`) console.log(`HTTP ${destroyFakeRes.status} (${destroyFakeRes.ms}ms)\n`) // ──────────────────────────────────────────── console.log('═══════════════════════════════════════════') console.log('四、发码回调接口(商家 → 快手)') console.log('═══════════════════════════════════════════') console.log(` 说明:该接口由商家调用快手开放平台。 接口: POST https://openapi.kwaixiaodian.com/integration/virtual/eticket/callback 测试方式:调用快手正式环境,返回"订单不存在"即为通过。 请求参数由商家根据实际业务提供。 `) console.log(`请求参数(示例):`) console.log(` oid: "2319200000001216"`) console.log(` etickets: [{ "id": "1", "code": "", "num": 1, "status": "ISSUED" }]`) console.log(` token: "token-xxx" (来自发码通知中的 token)\n`) console.log('═══════════════════════════════════════════') console.log('五、核销回调接口(商家 → 快手,可选)') console.log('═══════════════════════════════════════════') console.log(` 说明:该接口由商家调用快手开放平台。 接口: POST https://openapi.kwaixiaodian.com/integration/callback/virtual/eticket/consume 测试方式:调用快手正式环境,出现"订单不存在"即视为调试通过。 详见: docs/行业电子凭证/4.电子凭证核销回调接口.md `) console.log(`请求参数(示例):`) console.log(` oid: "2319200000001216"`) console.log(` etickets: [{ "id": "12313", "num": 1, "goodsValue": 10 }]`) console.log(` status: "CONSUMED"`) console.log(` consumeType: "consume"`) console.log(` storeName: "测试门店"`) console.log(` storeAddress: "测试地址"`) console.log(` token: "token-xxx"\n`) console.log('═══════════════════════════════════════════') console.log('六、销毁回调接口(商家 → 快手,可选)') console.log('═══════════════════════════════════════════') console.log(` 说明:该接口由商家调用快手开放平台。 接口: POST https://openapi.kwaixiaodian.com/integration/callback/virtual/eticket/destroy 测试方式:调用快手正式环境,出现"订单不存在"即视为调试通过。 `) console.log(`请求参数(示例):`) console.log(` oid: "2319200000001216"`) console.log(` etickets: [{ "id": "1", "num": 1, "goodsValue": 100 }]`) console.log(` reason: "ETICKET_EXPIRED"`) console.log(` token: "token-xxx"\n`) console.log('═══════════════════════════════════════════') console.log('完成。') console.log('═══════════════════════════════════════════') function parseArgs(rawArgs: string[]) { const parsed: JsonObject = {} for (const raw of rawArgs) { const arg = String(raw || '').trim() if (!arg) continue const n = arg.startsWith('--') ? arg.slice(2) : arg const eq = n.indexOf('=') if (eq < 0) { parsed[n] = true continue } parsed[n.slice(0, eq).trim()] = n.slice(eq + 1).trim() } return parsed }