feat: 实现快手电子凭证三个出站回调接口
- 新增 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 环境变量
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 生成快手 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<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 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
|
||||
}
|
||||
Reference in New Issue
Block a user