/** * 生成快手 Open API 回调 curl 命令(手动测试用) * * 用法: * npm run test:industry:curl # 发码回调 (默认) * npm run test:industry:curl -- --mode=consume # 核销回调 * npm run test:industry:curl -- --oid=xxx --token=xxx --accessToken=xxx */ 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 accessToken = String(process.env.KUASHOU_INDUSTRY_ACCESS_TOKEN || 'your_access_token').trim() const appKey = String(process.env.KUASHOU_INDUSTRY_APP_KEY || 'your_app_key').trim() const signSecret = String(process.env.KUASHOU_INDUSTRY_SIGN_SECRET || 'your_sign_secret').trim() const args = parseArgs(process.argv.slice(2)) const mode = String(args.mode || 'send').trim() // send | consume | destroy const oid = String(args.oid || String(Date.now())).trim() // 快手要求纯数字 oid const sendNum = normalizeInt(args.num, 3) const token = String(args.token || `test-token-${Date.now()}`).trim() const bodyAccessToken = String(args.accessToken || accessToken).trim() const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com' const now = Date.now() const validEnd = now + 30 * 24 * 60 * 60 * 1000 const bizParams: JsonObject = mode === 'consume' ? { oid, etickets: Array.from({ length: sendNum }, (_, i) => ({ id: String(i + 1), num: 1, goodsValue: 10, })), status: 'CONSUMED', consumeType: 'consume', consumeTime: now, storeName: '测试门店', storeAddress: '测试地址', seriallNum: `SN${Date.now()}`, token, } : mode === 'destroy' ? { oid, etickets: Array.from({ length: sendNum }, (_, i) => ({ id: String(i + 1), num: 1, goodsValue: 100, })), reason: 'ETICKET_EXPIRED', token, } : { oid, sendType: 'VIRTUAL', etickets: Array.from({ length: sendNum }, (_, i) => ({ id: String(i + 1), num: 1, validStartTime: now, validEndTime: validEnd, })), sendNum, token, } const method = mode === 'consume' ? 'integration.callback.virtual.eticket.consume' : mode === 'destroy' ? 'integration.callback.virtual.eticket.destroy' : 'integration.callback.virtual.eticket.send' const endpoint = mode === 'consume' ? '/integration/callback/virtual/eticket/consume' : mode === 'destroy' ? '/integration/callback/virtual/eticket/destroy' : '/integration/callback/virtual/eticket/send' const paramStr = JSON.stringify(bizParams) const signParams: JsonObject = { method, appkey: appKey, access_token: bodyAccessToken, version: '1', timestamp: now, signMethod: 'MD5', param: paramStr, } const sign = signPayload(signParams, signSecret) console.log('') const modeLabel = mode === 'consume' ? '核销' : mode === 'destroy' ? '销毁' : '发码' console.log(`# 电子凭证${modeLabel}回调 curl 命令`) console.log(`# oid=${oid} sendNum=${sendNum} access_token=${bodyAccessToken.slice(0, 10)}...`) console.log(`# 先测试网络连通性:`) console.log(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 https://openapi.kwaixiaodian.com/`) console.log('# 如果返回 000,说明网络不通,需要关闭代理或换服务器') console.log('') console.log('curl -X POST \\') console.log(` '${KUAISHOU_OPEN_API}${endpoint}' \\`) console.log(" -H 'Content-Type: application/x-www-form-urlencoded' \\") console.log(` --data-urlencode 'method=${method}' \\`) console.log(` --data-urlencode 'appkey=${appKey}' \\`) console.log(` --data-urlencode 'access_token=${bodyAccessToken}' \\`) console.log(` --data-urlencode 'version=1' \\`) console.log(` --data-urlencode 'timestamp=${now}' \\`) console.log(` --data-urlencode 'signMethod=MD5' \\`) console.log(` --data-urlencode 'sign=${sign}' \\`) console.log(` --data-urlencode 'param=${paramStr}'`) console.log('') // 本地签名校验(不调快手) console.log('# 本地签名校验(不会真正调用快手):') const localEndpoint = mode === 'consume' ? 'consume-code' : mode === 'destroy' ? 'destroy-code' : 'send-code' console.log(`curl -s -X POST http://127.0.0.1:3000/api/v1/open/kuaishou-industry/${localEndpoint} \\`) console.log(" -H 'Content-Type: application/x-www-form-urlencoded' \\") console.log(` -d 'appkey=${appKey}&version=1×tamp=${now}&signMethod=MD5&access_token=&method=&sign=${sign}¶m=${encodeURIComponent(paramStr)}'`) console.log('') function signPayload(params: JsonObject, secret: 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}=${stringify(v)}`).join('&') return crypto.createHash('md5').update(`${qs}&signSecret=${secret}`, 'utf8').digest('hex').toLowerCase() } function stringify(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) } function normalizeInt(v: unknown, d: number): number { const n = Number(v) return Number.isInteger(n) && n > 0 ? n : d } function parseArgs(raw: string[]): JsonObject { const p: JsonObject = {} for (const a of raw) { const arg = String(a || '').trim() if (!arg) continue const n = arg.startsWith('--') ? arg.slice(2) : arg const eq = n.indexOf('=') if (eq < 0) { p[n] = true; continue } p[n.slice(0, eq).trim()] = n.slice(eq + 1).trim() } return p }