- 新增 send-callback-service.ts: 卡券发码回调 (eticket/send) - 新增 consume-callback-service.ts: 核销回调 (eticket/consume) - 新增 destroy-callback-service.ts: 销毁回调 (eticket/destroy) - 三个回调均在对应入站接口处理成功后异步触发(fire-and-forget) - 共享 sendCallbackEnabled 开关控制,默认关闭 - 新增 accessToken 配置项(OAuth获取) - 新增 scripts/curl-send-callback.ts: 手动测试 curl 生成脚本 - docker-compose 添加 KUASHOU_INDUSTRY_ACCESS_TOKEN / SEND_CALLBACK_ENABLED 环境变量
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
/**
|
||
* 生成测试用例文档脚本
|
||
* 运行所有场景,输出请求/响应报文用于填写 测试用例.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<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 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
|
||
}
|