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:
@@ -33,6 +33,8 @@ KUASHOU_INDUSTRY_APP_KEY=
|
|||||||
KUASHOU_INDUSTRY_APP_SECRET=
|
KUASHOU_INDUSTRY_APP_SECRET=
|
||||||
KUASHOU_INDUSTRY_SIGN_SECRET=
|
KUASHOU_INDUSTRY_SIGN_SECRET=
|
||||||
KUASHOU_INDUSTRY_MESSAGE_SECRET=
|
KUASHOU_INDUSTRY_MESSAGE_SECRET=
|
||||||
|
KUASHOU_INDUSTRY_ACCESS_TOKEN=
|
||||||
|
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=false
|
||||||
|
|
||||||
# Cloudtentacles
|
# Cloudtentacles
|
||||||
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
"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": "tsx scripts/test-kuaishou-industry.ts",
|
||||||
"test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts",
|
"test:industry:http": "tsx scripts/test-kuaishou-industry-http.ts",
|
||||||
|
"test:industry:gen": "tsx scripts/gen-test-cases.ts",
|
||||||
|
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"start:src": "tsx src/index.ts"
|
"start:src": "tsx src/index.ts"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* 生成测试用例文档脚本
|
||||||
|
* 运行所有场景,输出请求/响应报文用于填写 测试用例.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
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
appSecret: "",
|
appSecret: "",
|
||||||
signSecret: "",
|
signSecret: "",
|
||||||
messageSecret: "",
|
messageSecret: "",
|
||||||
|
accessToken: "",
|
||||||
|
sendCallbackEnabled: false,
|
||||||
provider: "kuaishou-industry",
|
provider: "kuaishou-industry",
|
||||||
platform: "kuaishou",
|
platform: "kuaishou",
|
||||||
shopId: "kuaishou-industry",
|
shopId: "kuaishou-industry",
|
||||||
|
|||||||
@@ -244,6 +244,16 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
"kuaishouIndustry",
|
"kuaishouIndustry",
|
||||||
"messageSecret",
|
"messageSecret",
|
||||||
]),
|
]),
|
||||||
|
stringEnv("KUASHOU_INDUSTRY_ACCESS_TOKEN", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouIndustry",
|
||||||
|
"accessToken",
|
||||||
|
]),
|
||||||
|
booleanEnv("KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouIndustry",
|
||||||
|
"sendCallbackEnabled",
|
||||||
|
]),
|
||||||
stringEnv("KUASHOU_INDUSTRY_PROVIDER", [
|
stringEnv("KUASHOU_INDUSTRY_PROVIDER", [
|
||||||
"platforms",
|
"platforms",
|
||||||
"kuaishouIndustry",
|
"kuaishouIndustry",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRun
|
|||||||
appSecret: String(config.appSecret || '').trim(),
|
appSecret: String(config.appSecret || '').trim(),
|
||||||
signSecret: String(config.signSecret || '').trim(),
|
signSecret: String(config.signSecret || '').trim(),
|
||||||
messageSecret: String(config.messageSecret || '').trim(),
|
messageSecret: String(config.messageSecret || '').trim(),
|
||||||
|
accessToken: String(config.accessToken || '').trim(),
|
||||||
|
sendCallbackEnabled: Boolean(config.sendCallbackEnabled),
|
||||||
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||||
platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
|
platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
|
||||||
shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||||
|
import { getKuaishouIndustryConfig } from './config.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
const KUAISHOU_OPEN_API = 'https://openapi.kuaixiaodian.com'
|
||||||
|
|
||||||
|
type ConsumeCallbackInput = {
|
||||||
|
oid: string
|
||||||
|
etickets: Array<{
|
||||||
|
id: string
|
||||||
|
code?: string
|
||||||
|
num: number
|
||||||
|
goodsValue?: number
|
||||||
|
status?: string
|
||||||
|
}>
|
||||||
|
status: string
|
||||||
|
consumeType: string
|
||||||
|
consumeTime?: number
|
||||||
|
storeName?: string
|
||||||
|
storeAddress?: string
|
||||||
|
expressNo?: string
|
||||||
|
expressCode?: string
|
||||||
|
appointmentTime?: string
|
||||||
|
eticketType?: string
|
||||||
|
ext?: string
|
||||||
|
token: string
|
||||||
|
seriallNum?: string
|
||||||
|
consumePoiId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeCallback(input: ConsumeCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
|
||||||
|
const config = getKuaishouIndustryConfig()
|
||||||
|
|
||||||
|
if (!config.sendCallbackEnabled) {
|
||||||
|
logInfo('[kuaishou-industry/consume-callback]', '回调未启用,跳过')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.accessToken) {
|
||||||
|
logWarn('[kuaishou-industry/consume-callback]', 'accessToken 未配置,跳过')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bizParams: JsonObject = {
|
||||||
|
oid: input.oid,
|
||||||
|
etickets: input.etickets.map((e) => {
|
||||||
|
const item: JsonObject = { id: e.id, num: e.num }
|
||||||
|
if (e.code) item.code = e.code
|
||||||
|
if (e.goodsValue != null) item.goodsValue = e.goodsValue
|
||||||
|
if (e.status) item.status = e.status
|
||||||
|
return item
|
||||||
|
}),
|
||||||
|
status: input.status,
|
||||||
|
consumeType: input.consumeType,
|
||||||
|
token: input.token,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.consumeTime != null) bizParams.consumeTime = input.consumeTime
|
||||||
|
if (input.storeName) bizParams.storeName = input.storeName
|
||||||
|
if (input.storeAddress) bizParams.storeAddress = input.storeAddress
|
||||||
|
if (input.expressNo) bizParams.expressNo = input.expressNo
|
||||||
|
if (input.expressCode) bizParams.expressCode = input.expressCode
|
||||||
|
if (input.appointmentTime) bizParams.appointmentTime = input.appointmentTime
|
||||||
|
if (input.eticketType) bizParams.eticketType = input.eticketType
|
||||||
|
if (input.ext) bizParams.ext = input.ext
|
||||||
|
if (input.seriallNum) bizParams.seriallNum = input.seriallNum
|
||||||
|
if (input.consumePoiId != null) bizParams.consumePoiId = input.consumePoiId
|
||||||
|
|
||||||
|
const paramStr = JSON.stringify(bizParams)
|
||||||
|
|
||||||
|
const signParams: JsonObject = {
|
||||||
|
method: 'integration.callback.virtual.eticket.consume',
|
||||||
|
appkey: config.appKey,
|
||||||
|
access_token: config.accessToken,
|
||||||
|
version: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
signMethod: 'MD5',
|
||||||
|
param: paramStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = signPayload(signParams, config.signSecret)
|
||||||
|
signParams.sign = sign
|
||||||
|
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(signParams)) {
|
||||||
|
body.append(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/consume`
|
||||||
|
|
||||||
|
logInfo('[kuaishou-industry/consume-callback]', `发起核销回调 oid=${input.oid}`, {
|
||||||
|
url,
|
||||||
|
oid: input.oid,
|
||||||
|
status: input.status,
|
||||||
|
consumeType: input.consumeType,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
let json: JsonObject = {}
|
||||||
|
try { json = JSON.parse(text) } catch { json = { raw: text } }
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
const ok = res.ok && Number(json.result) === 1
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
logInfo('[kuaishou-industry/consume-callback]', `核销回调成功 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logWarn('[kuaishou-industry/consume-callback]', `核销回调失败 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
status: res.status,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: ok, response: json }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logWarn('[kuaishou-industry/consume-callback]', `核销回调异常 oid=${input.oid}`, {
|
||||||
|
error: message,
|
||||||
|
})
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(params: JsonObject, signSecret: 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}=${stringifySignValue(value)}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const source = `${queryString}&signSecret=${signSecret}`
|
||||||
|
|
||||||
|
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySignValue(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 Record<string, unknown>).sort())
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||||
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
KUISHOU_INDUSTRY_PLATFORM,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
buildIndustrySuccessResponse,
|
buildIndustrySuccessResponse,
|
||||||
buildIndustryErrorResponse,
|
buildIndustryErrorResponse,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
|
import { consumeCallback } from './consume-callback-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -116,8 +118,40 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
|||||||
return buildIndustryErrorResponse(4012005, `未找到匹配的卡券: ${params.etickets.map((e) => e.id).join(',')}`)
|
return buildIndustryErrorResponse(4012005, `未找到匹配的卡券: ${params.etickets.map((e) => e.id).join(',')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fireConsumeCallback({
|
||||||
|
oid: normalizedOid,
|
||||||
|
etickets: params.etickets.map((e) => ({
|
||||||
|
id: String(e.id),
|
||||||
|
code: e.code,
|
||||||
|
num: Number(e.num) || 1,
|
||||||
|
goodsValue: e.goodsValue,
|
||||||
|
})),
|
||||||
|
status: params.status,
|
||||||
|
consumeType: params.consumeType,
|
||||||
|
consumeTime: params.consumeTime,
|
||||||
|
storeName: params.storeName,
|
||||||
|
storeAddress: params.storeAddress,
|
||||||
|
expressNo: params.expressNo,
|
||||||
|
expressCode: params.expressCode,
|
||||||
|
appointmentTime: params.appointmentTime,
|
||||||
|
eticketType: params.eticketType,
|
||||||
|
ext: params.ext,
|
||||||
|
token: params.token,
|
||||||
|
seriallNum: params.seriallNum,
|
||||||
|
consumePoiId: params.consumePoiId,
|
||||||
|
})
|
||||||
|
|
||||||
return buildIndustrySuccessResponse({
|
return buildIndustrySuccessResponse({
|
||||||
oid: normalizedOid,
|
oid: normalizedOid,
|
||||||
consumedCount,
|
consumedCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fireConsumeCallback(input: Parameters<typeof consumeCallback>[0]) {
|
||||||
|
consumeCallback(input).catch((err) => {
|
||||||
|
logWarn('[kuaishou-industry/consume-code]', '核销回调异步执行异常', {
|
||||||
|
oid: input.oid,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||||
|
import { getKuaishouIndustryConfig } from './config.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
const KUAISHOU_OPEN_API = 'https://openapi.kuaixiaodian.com'
|
||||||
|
|
||||||
|
type DestroyCallbackInput = {
|
||||||
|
oid: string
|
||||||
|
etickets?: Array<{
|
||||||
|
id: string
|
||||||
|
code?: string
|
||||||
|
num: number
|
||||||
|
goodsValue?: number
|
||||||
|
}>
|
||||||
|
reason: string
|
||||||
|
eticketType?: string
|
||||||
|
ext?: string
|
||||||
|
token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function destroyCallback(input: DestroyCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
|
||||||
|
const config = getKuaishouIndustryConfig()
|
||||||
|
|
||||||
|
if (!config.sendCallbackEnabled) {
|
||||||
|
logInfo('[kuaishou-industry/destroy-callback]', '回调未启用,跳过')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.accessToken) {
|
||||||
|
logWarn('[kuaishou-industry/destroy-callback]', 'accessToken 未配置,跳过')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bizParams: JsonObject = {
|
||||||
|
oid: input.oid,
|
||||||
|
reason: input.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.etickets && input.etickets.length > 0) {
|
||||||
|
bizParams.etickets = input.etickets.map((e) => {
|
||||||
|
const item: JsonObject = { id: e.id, num: e.num }
|
||||||
|
if (e.code) item.code = e.code
|
||||||
|
if (e.goodsValue != null) item.goodsValue = e.goodsValue
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.eticketType) bizParams.eticketType = input.eticketType
|
||||||
|
if (input.ext) bizParams.ext = input.ext
|
||||||
|
if (input.token) bizParams.token = input.token
|
||||||
|
|
||||||
|
const paramStr = JSON.stringify(bizParams)
|
||||||
|
|
||||||
|
const signParams: JsonObject = {
|
||||||
|
method: 'integration.callback.virtual.eticket.destroy',
|
||||||
|
appkey: config.appKey,
|
||||||
|
access_token: config.accessToken,
|
||||||
|
version: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
signMethod: 'MD5',
|
||||||
|
param: paramStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = signPayload(signParams, config.signSecret)
|
||||||
|
signParams.sign = sign
|
||||||
|
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(signParams)) {
|
||||||
|
body.append(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/destroy`
|
||||||
|
|
||||||
|
logInfo('[kuaishou-industry/destroy-callback]', `发起销毁回调 oid=${input.oid}`, {
|
||||||
|
url,
|
||||||
|
oid: input.oid,
|
||||||
|
reason: input.reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
let json: JsonObject = {}
|
||||||
|
try { json = JSON.parse(text) } catch { json = { raw: text } }
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
const ok = res.ok && Number(json.result) === 1
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
logInfo('[kuaishou-industry/destroy-callback]', `销毁回调成功 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logWarn('[kuaishou-industry/destroy-callback]', `销毁回调失败 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
status: res.status,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: ok, response: json }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logWarn('[kuaishou-industry/destroy-callback]', `销毁回调异常 oid=${input.oid}`, {
|
||||||
|
error: message,
|
||||||
|
})
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(params: JsonObject, signSecret: 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}=${stringifySignValue(value)}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const source = `${queryString}&signSecret=${signSecret}`
|
||||||
|
|
||||||
|
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySignValue(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 Record<string, unknown>).sort())
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-re
|
|||||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||||
import { updateTask } from '../../../repositories/task-repo.js'
|
import { updateTask } from '../../../repositories/task-repo.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
KUISHOU_INDUSTRY_PLATFORM,
|
||||||
getKuaishouIndustryConfig,
|
getKuaishouIndustryConfig,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
buildIndustrySuccessResponse,
|
buildIndustrySuccessResponse,
|
||||||
buildIndustryErrorResponse,
|
buildIndustryErrorResponse,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
|
import { destroyCallback } from './destroy-callback-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -67,5 +69,25 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fireDestroyCallback({
|
||||||
|
oid: normalizedOid,
|
||||||
|
etickets: params.etickets.map((e) => ({
|
||||||
|
id: String(e.id),
|
||||||
|
code: e.code,
|
||||||
|
num: Number(e.num) || 1,
|
||||||
|
goodsValue: e.goodsValue,
|
||||||
|
})),
|
||||||
|
reason: params.reason,
|
||||||
|
})
|
||||||
|
|
||||||
return buildIndustrySuccessResponse({ oid: normalizedOid })
|
return buildIndustrySuccessResponse({ oid: normalizedOid })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fireDestroyCallback(input: Parameters<typeof destroyCallback>[0]) {
|
||||||
|
destroyCallback(input).catch((err) => {
|
||||||
|
logWarn('[kuaishou-industry/destroy-code]', '销毁回调异步执行异常', {
|
||||||
|
oid: input.oid,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||||
|
import { getKuaishouIndustryConfig } from './config.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
const KUAISHOU_OPEN_API = 'https://openapi.kuaixiaodian.com'
|
||||||
|
|
||||||
|
type SendCallbackInput = {
|
||||||
|
oid: string
|
||||||
|
sendType: string
|
||||||
|
etickets: Array<{
|
||||||
|
id: string
|
||||||
|
code?: string
|
||||||
|
num: number
|
||||||
|
validStartTime: number
|
||||||
|
validEndTime: number
|
||||||
|
goodsValue?: number
|
||||||
|
}>
|
||||||
|
sendNum: number
|
||||||
|
totalGoodsValue?: number
|
||||||
|
token: string
|
||||||
|
eticketType?: string
|
||||||
|
ext?: string
|
||||||
|
expressCode?: string
|
||||||
|
expressNo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendCallback(input: SendCallbackInput): Promise<{ success: boolean; response?: JsonObject; error?: string }> {
|
||||||
|
const config = getKuaishouIndustryConfig()
|
||||||
|
|
||||||
|
if (!config.sendCallbackEnabled) {
|
||||||
|
logInfo('[kuaishou-industry/send-callback]', '发码回调未启用,跳过')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.accessToken) {
|
||||||
|
logWarn('[kuaishou-industry/send-callback]', 'accessToken 未配置,跳过发码回调')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bizParams: JsonObject = {
|
||||||
|
oid: input.oid,
|
||||||
|
sendType: input.sendType,
|
||||||
|
etickets: input.etickets.map((e) => {
|
||||||
|
const item: JsonObject = {
|
||||||
|
id: e.id,
|
||||||
|
num: e.num,
|
||||||
|
validStartTime: e.validStartTime,
|
||||||
|
validEndTime: e.validEndTime,
|
||||||
|
}
|
||||||
|
if (e.code) item.code = e.code
|
||||||
|
if (e.goodsValue != null) item.goodsValue = e.goodsValue
|
||||||
|
return item
|
||||||
|
}),
|
||||||
|
sendNum: input.sendNum,
|
||||||
|
token: input.token,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.totalGoodsValue != null) bizParams.totalGoodsValue = input.totalGoodsValue
|
||||||
|
if (input.eticketType) bizParams.eticketType = input.eticketType
|
||||||
|
if (input.ext) bizParams.ext = input.ext
|
||||||
|
if (input.expressCode) bizParams.expressCode = input.expressCode
|
||||||
|
if (input.expressNo) bizParams.expressNo = input.expressNo
|
||||||
|
|
||||||
|
const paramStr = JSON.stringify(bizParams)
|
||||||
|
|
||||||
|
const signParams: JsonObject = {
|
||||||
|
method: 'integration.callback.virtual.eticket.send',
|
||||||
|
appkey: config.appKey,
|
||||||
|
access_token: config.accessToken,
|
||||||
|
version: '1',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
signMethod: 'MD5',
|
||||||
|
param: paramStr,
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = signPayload(signParams, config.signSecret)
|
||||||
|
signParams.sign = sign
|
||||||
|
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(signParams)) {
|
||||||
|
body.append(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${KUAISHOU_OPEN_API}/integration/callback/virtual/eticket/send`
|
||||||
|
|
||||||
|
logInfo('[kuaishou-industry/send-callback]', `发起发码回调 oid=${input.oid}`, {
|
||||||
|
url,
|
||||||
|
oid: input.oid,
|
||||||
|
sendNum: input.sendNum,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
})
|
||||||
|
const text = await res.text()
|
||||||
|
let json: JsonObject = {}
|
||||||
|
try { json = JSON.parse(text) } catch { json = { raw: text } }
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
const ok = res.ok && Number(json.result) === 1
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
logInfo('[kuaishou-industry/send-callback]', `发码回调成功 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logWarn('[kuaishou-industry/send-callback]', `发码回调失败 oid=${input.oid}`, {
|
||||||
|
durationMs,
|
||||||
|
status: res.status,
|
||||||
|
response: json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: ok, response: json }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logWarn('[kuaishou-industry/send-callback]', `发码回调异常 oid=${input.oid}`, {
|
||||||
|
error: message,
|
||||||
|
})
|
||||||
|
return { success: false, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(params: JsonObject, signSecret: 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}=${stringifySignValue(value)}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const source = `${queryString}&signSecret=${signSecret}`
|
||||||
|
|
||||||
|
return crypto.createHash('md5').update(source, 'utf8').digest('hex').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySignValue(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 Record<string, unknown>).sort())
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositorie
|
|||||||
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js'
|
||||||
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PROVIDER,
|
KUISHOU_INDUSTRY_PROVIDER,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
buildIndustryEticketItem,
|
buildIndustryEticketItem,
|
||||||
buildIndustrySendCodeData,
|
buildIndustrySendCodeData,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
|
import { sendCallback } from './send-callback-service.js'
|
||||||
|
|
||||||
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
|
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
|||||||
buildEticketFromTask(task, params),
|
buildEticketFromTask(task, params),
|
||||||
)
|
)
|
||||||
|
|
||||||
return buildIndustrySuccessResponse(
|
const response = buildIndustrySuccessResponse(
|
||||||
buildIndustrySendCodeData({
|
buildIndustrySendCodeData({
|
||||||
oid: normalizedOid,
|
oid: normalizedOid,
|
||||||
sendType: params.sendType,
|
sendType: params.sendType,
|
||||||
@@ -149,6 +151,17 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
|||||||
etickets,
|
etickets,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fireSendCallback({
|
||||||
|
oid: normalizedOid,
|
||||||
|
sendType: params.sendType,
|
||||||
|
etickets,
|
||||||
|
token: params.token,
|
||||||
|
eticketType: params.eticketType,
|
||||||
|
ext: params.ext,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureIndustryProfile(now: string) {
|
async function ensureIndustryProfile(now: string) {
|
||||||
@@ -216,3 +229,35 @@ function mapTaskStatusToEticketStatus(taskStatus: string) {
|
|||||||
return 'UNUSED'
|
return 'UNUSED'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fireSendCallback(input: {
|
||||||
|
oid: string
|
||||||
|
sendType: string
|
||||||
|
etickets: ReturnType<typeof buildIndustryEticketItem>[]
|
||||||
|
token: string
|
||||||
|
eticketType?: string
|
||||||
|
ext?: string
|
||||||
|
}) {
|
||||||
|
const eticketItems = input.etickets.map((e) => ({
|
||||||
|
id: String(e.id || ''),
|
||||||
|
code: e.code,
|
||||||
|
num: Number(e.num) || 1,
|
||||||
|
validStartTime: Number(e.validStartTime) || 0,
|
||||||
|
validEndTime: Number(e.validEndTime) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
sendCallback({
|
||||||
|
oid: input.oid,
|
||||||
|
sendType: input.sendType,
|
||||||
|
etickets: eticketItems,
|
||||||
|
sendNum: eticketItems.reduce((sum, e) => sum + e.num, 0),
|
||||||
|
token: input.token,
|
||||||
|
...(input.eticketType ? { eticketType: input.eticketType } : {}),
|
||||||
|
...(input.ext ? { ext: input.ext } : {}),
|
||||||
|
}).catch((err) => {
|
||||||
|
logWarn('[kuaishou-industry/send-code]', '发码回调异步执行异常', {
|
||||||
|
oid: input.oid,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export type RuntimeConfig = {
|
|||||||
appSecret: string;
|
appSecret: string;
|
||||||
signSecret: string;
|
signSecret: string;
|
||||||
messageSecret: string;
|
messageSecret: string;
|
||||||
|
accessToken: string;
|
||||||
|
sendCallbackEnabled: boolean;
|
||||||
provider: string;
|
provider: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
shopId: string;
|
shopId: string;
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ services:
|
|||||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||||
|
KUASHOU_INDUSTRY_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
|
||||||
|
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false}
|
||||||
logging: *json-log-rotation
|
logging: *json-log-rotation
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/backend:/app
|
- ./apps/backend:/app
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ services:
|
|||||||
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET}
|
||||||
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON}
|
||||||
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||||
|
KUASHOU_INDUSTRY_ACCESS_TOKEN: ${KUASHOU_INDUSTRY_ACCESS_TOKEN:-}
|
||||||
|
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED: ${KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED:-false}
|
||||||
|
KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120}
|
||||||
logging: *json-log-rotation
|
logging: *json-log-rotation
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/backend/data:/app/data
|
- ./apps/backend/data:/app/data
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
第二部分 接口测试用例
|
||||||
|
2.4.2.2 电子凭证协议
|
||||||
|
商家请认真填写,调试完成后做成文档,发送给快手技术进行审核!!!
|
||||||
|
一、通知发码接口测试用例
|
||||||
|
测试场景 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
收单成功(重要!!!) appkey=ks660621772091030245&version=1×tamp=1782966971471&signMethod=MD5¶m=%7B%22oid%22%3A%22SUCCESS_1782966971471%22%2C%22sellerId%22%3A%222174425348%22%2C%22num%22%3A2%2C%22itemId%22%3A%2220842040339391%22%2C%22skuId%22%3A%2287457167264391%22%2C%22itemTitle%22%3A%22%E6%B5%8B%E8%AF%95%E7%94%B5%E5%AD%90%E5%87%AD%E8%AF%81%22%2C%22sendType%22%3A%22VIRTUAL%22%2C%22eticketType%22%3A%22DINING_OPEN_TICKET%22%2C%22token%22%3A%22token-success-1782966971471%22%2C%22certExpireType%22%3A1%2C%22certStartTime%22%3A1782966971471%2C%22certEndTime%22%3A1814502971471%2C%22certActualStartTime%22%3A1782966971471%2C%22certActualEndTime%22%3A1814502971471%7D&sign=573c1159de0164632b97d7c38a3e17df {"result":1,"error_msg":"","data":{"oid":"SUCCESS_1782966971471","sendType":"VIRTUAL","sendNum":2,"etickets":[{"id":"67","status":"UNUSED","num":1,"validStartTime":1782966971471,"validEndTime":1814502971471,"eticketType":"DINING_OPEN_TICKET"},{"id":"68","status":"UNUSED","num":1,"validStartTime":1782966971471,"validEndTime":1814502971471,"eticketType":"DINING_OPEN_TICKET"}]}} 商家补充截图
|
||||||
|
拒单场景:appkey 未传入
|
||||||
|
(快手按 10s、30s、1min、2min、3min… 策略重试,最多 16 次) version=1×tamp=1782966971487&signMethod=MD5¶m=%7B%22oid%22%3A%22NOKEY_1782966971487%22%2C%22sellerId%22%3A%222174425348%22%2C%22num%22%3A1%2C%22itemId%22%3A%2220842040339391%22%2C%22sendType%22%3A%22VIRTUAL%22%2C%22token%22%3A%22token-nokey-1782966971487%22%2C%22certExpireType%22%3A3%2C%22certExpDays%22%3A30%2C%22certActualStartTime%22%3A1782966971487%2C%22certActualEndTime%22%3A1785558971487%7D&sign=6d07ef1b34dae634722085e69998d5ce {"result":4010003,"error_msg":"appkey 不匹配","data":null} 商家补充截图
|
||||||
|
拒单场景:签名错误
|
||||||
|
(快手按 10s、30s、1min、2min、3min… 策略重试,最多 16 次) appkey=ks660621772091030245&version=1×tamp=1782966971496&signMethod=MD5¶m=%7B%22oid%22%3A%22BADSIGN_1782966971496%22%2C%22sellerId%22%3A%222174425348%22%2C%22num%22%3A1%2C%22itemId%22%3A%2220842040339391%22%2C%22sendType%22%3A%22VIRTUAL%22%2C%22token%22%3A%22token-badsign-1782966971496%22%2C%22certExpireType%22%3A3%2C%22certExpDays%22%3A30%2C%22certActualStartTime%22%3A1782966971496%2C%22certActualEndTime%22%3A1785558971496%7D&sign=00000000000000000000000000000000 {"result":4010003,"error_msg":"签名验证失败","data":null} 商家补充截图
|
||||||
|
拒单场景:参数缺失(缺少 oid)
|
||||||
|
(快手按 10s、30s、1min、2min、3min… 策略重试,最多 16 次) appkey=ks660621772091030245&version=1×tamp=1782966971499&signMethod=MD5¶m=%7B%22sellerId%22%3A%22123%22%2C%22num%22%3A1%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=154c203c15b779c708bc3a1e508ba78d {"result":4010003,"error_msg":"缺少 oid","data":null} 商家补充截图
|
||||||
|
|
||||||
|
二、查询发码结果接口测试用例
|
||||||
|
测试场景 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
查询成功:返回卡券信息(全部卡券) appkey=ks660621772091030245&version=1×tamp=1782966971504&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=9a012f1aa5843082ee6ed63961bd3173 {"result":1,"error_msg":"","data":{"oid":"TEST_CASE_1782966971344","sendType":"VIRTUAL","sendNum":3,"etickets":[{"id":"64","status":"UNUSED","num":1,"validStartTime":1782966971344,"validEndTime":1785558971344},{"id":"65","status":"UNUSED","num":1,"validStartTime":1782966971344,"validEndTime":1785558971344},{"id":"66","status":"UNUSED","num":1,"validStartTime":1782966971344,"validEndTime":1785558971344}]}} 商家补充截图
|
||||||
|
查询成功:返回卡券信息(指定 eticketId) appkey=ks660621772091030245&version=1×tamp=1782966971509&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22eticketId%22%3A%2264%22%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=2c49920c246566219d448e9837299d7f {"result":1,"error_msg":"","data":{"oid":"TEST_CASE_1782966971344","sendType":"VIRTUAL","sendNum":1,"etickets":[{"id":"64","status":"UNUSED","num":1,"validStartTime":1782966971344,"validEndTime":1785558971344}]}} 商家补充截图
|
||||||
|
查询失败:返回卡券不存在
|
||||||
|
必须返回错误码「4012005」 appkey=ks660621772091030245&version=1×tamp=1782966971512&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22eticketId%22%3A%2299999%22%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=90e584abb3d92a857175af1e8b235747 {"result":4012005,"error_msg":"卡券不存在: 99999","data":null} 商家补充截图
|
||||||
|
查询失败:订单不存在
|
||||||
|
必须返回错误码「4012002」 appkey=ks660621772091030245&version=1×tamp=1782966971516&signMethod=MD5¶m=%7B%22oid%22%3A%22NOT_EXIST_1782966971516%22%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=1a55c5588006c0de81122a213aac714c {"result":4012002,"error_msg":"订单不存在: NOT_EXIST_1782966971516","data":null} 商家补充截图
|
||||||
|
|
||||||
|
三、关单 / 销毁接口测试用例
|
||||||
|
测试场景 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
关单/销毁成功(指定卡券 DESTROYED)
|
||||||
|
(注意:不存在的订单,请返回关单成功) appkey=ks660621772091030245&version=1×tamp=1782966971518&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22reason%22%3A%22USER_APPLY_REFUND%22%2C%22etickets%22%3A%5B%7B%22id%22%3A%2264%22%2C%22num%22%3A1%2C%22goodsValue%22%3A100%7D%5D%7D&sign=098882a0a3ef3ccf4abe56aad9927f80 {"result":1,"error_msg":"","data":{"oid":"TEST_CASE_1782966971344"}} 商家补充截图
|
||||||
|
销毁后验证卡券状态为 DESTROYED appkey=ks660621772091030245&version=1×tamp=1782966971525&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22eticketId%22%3A%2264%22%2C%22sendType%22%3A%22VIRTUAL%22%7D&sign=72b9574b73af8353c5f060469b5213ad {"result":1,"error_msg":"","data":{"oid":"TEST_CASE_1782966971344","sendType":"VIRTUAL","sendNum":1,"etickets":[{"id":"64","status":"DESTROYED","num":1,"validStartTime":1782966971344,"validEndTime":1785558971344}]}} 商家补充截图
|
||||||
|
关单/销毁成功(整单销毁,不传 etickets) appkey=ks660621772091030245&version=1×tamp=1782966971528&signMethod=MD5¶m=%7B%22oid%22%3A%22TEST_CASE_1782966971344%22%2C%22reason%22%3A%22ETICKET_EXPIRED%22%7D&sign=cda4984e91bef29e7aa1c65b4d208553 {"result":1,"error_msg":"","data":{"oid":"TEST_CASE_1782966971344"}} 商家补充截图
|
||||||
|
关单/销毁成功(订单不存在,也必须返回成功) appkey=ks660621772091030245&version=1×tamp=1782966971538&signMethod=MD5¶m=%7B%22oid%22%3A%22FAKE_DESTROY_1782966971538%22%2C%22reason%22%3A%22ETICKET_EXPIRED%22%7D&sign=b5c9ebf65c1b9d535d8dad0ac55775c4 {"result":1,"error_msg":"","data":{"oid":"FAKE_DESTROY_1782966971538"}} 商家补充截图
|
||||||
|
|
||||||
|
四、发码回调接口
|
||||||
|
📌 注意:该接口由商家发起。请调用快手线上地址,出现"订单不存在"即可视为调试通过!
|
||||||
|
接口: POST https://openapi.kwaixiaodian.com/integration/virtual/eticket/callback
|
||||||
|
测试场景 请求参数(商家提供) 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
卡券发码 oid: "2319200000001216"
|
||||||
|
etickets: [{"id":"67","code":"","num":1,"status":"ISSUED"}]
|
||||||
|
token: "token-success-1782966971471" access_token=xxx&appkey=ks660621772091030245&method=integration.virtual.eticket.callback¶m=%7B%22oid%22%3A%222319200000001216%22%2C%22etickets%22%3A%5B%7B%22id%22%3A%2267%22%2C%22num%22%3A1%2C%22status%22%3A%22ISSUED%22%7D%5D%2C%22token%22%3A%22token-success-1782966971471%22%7D&signMethod=MD5×tamp=1782966971471&version=1&sign=xxx 调用快手正式环境,出现"订单不存在"即为通过 商家补充截图
|
||||||
|
|
||||||
|
五、核销回调接口(可选,取决商家选择的对接方案)
|
||||||
|
📌 注意:该接口由商家发起。请调用快手线上地址,出现"订单不存在"即可视为调试通过!
|
||||||
|
接口: POST https://openapi.kwaixiaodian.com/integration/callback/virtual/eticket/consume
|
||||||
|
测试场景 请求参数(商家提供) 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
卡券核销 oid: "2319200000001216"
|
||||||
|
etickets: [{"id":"67","num":1,"goodsValue":10}]
|
||||||
|
status: "CONSUMED"
|
||||||
|
consumeType: "consume"
|
||||||
|
storeName: "测试门店"
|
||||||
|
storeAddress: "测试地址"
|
||||||
|
seriallNum: "SN202607020001"
|
||||||
|
token: "token-success-1782966971471"
|
||||||
|
consumeTime: 1782966971471 access_token=xxx&appkey=ks660621772091030245&method=integration.callback.virtual.eticket.consume¶m=%7B%22oid%22%3A%222319200000001216%22%2C%22etickets%22%3A%5B%7B%22id%22%3A%2267%22%2C%22num%22%3A1%2C%22goodsValue%22%3A10%7D%5D%2C%22status%22%3A%22CONSUMED%22%2C%22consumeType%22%3A%22consume%22%2C%22storeName%22%3A%22%E6%B5%8B%E8%AF%95%E9%97%A8%E5%BA%97%22%2C%22storeAddress%22%3A%22%E6%B5%8B%E8%AF%95%E5%9C%B0%E5%9D%80%22%2C%22seriallNum%22%3A%22SN202607020001%22%2C%22token%22%3A%22token-success-1782966971471%22%2C%22consumeTime%22%3A1782966971471%7D&signMethod=MD5×tamp=1782966971471&version=1&sign=xxx 调用快手正式环境,出现"订单不存在"即为通过 商家补充截图
|
||||||
|
|
||||||
|
六、销毁回调接口(可选,取决商家选择的对接方案)
|
||||||
|
📌 注意:该接口由商家发起。请调用快手线上地址,出现"订单不存在"即可视为调试通过!
|
||||||
|
接口: POST https://openapi.kwaixiaodian.com/integration/callback/virtual/eticket/destroy
|
||||||
|
测试场景 请求参数(商家提供) 请求报文(商家填写) 响应报文(商家填写) 截图(商家补充)
|
||||||
|
销毁成功 oid: "2319200000001216"
|
||||||
|
etickets: [{"id":"67","num":1,"goodsValue":100}]
|
||||||
|
reason: "ETICKET_EXPIRED"
|
||||||
|
token: "token-success-1782966971471" access_token=xxx&appkey=ks660621772091030245&method=integration.callback.virtual.eticket.destroy¶m=%7B%22oid%22%3A%222319200000001216%22%2C%22etickets%22%3A%5B%7B%22id%22%3A%2267%22%2C%22num%22%3A1%2C%22goodsValue%22%3A100%7D%5D%2C%22reason%22%3A%22ETICKET_EXPIRED%22%2C%22token%22%3A%22token-success-1782966971471%22%7D&signMethod=MD5×tamp=1782966971471&version=1&sign=xxx 调用快手正式环境,出现"订单不存在"即为通过 商家补充截图
|
||||||
Reference in New Issue
Block a user