diff --git a/.env.server.example b/.env.server.example index 935bef6d..219424a8 100644 --- a/.env.server.example +++ b/.env.server.example @@ -33,6 +33,8 @@ KUASHOU_INDUSTRY_APP_KEY= KUASHOU_INDUSTRY_APP_SECRET= KUASHOU_INDUSTRY_SIGN_SECRET= KUASHOU_INDUSTRY_MESSAGE_SECRET= +KUASHOU_INDUSTRY_ACCESS_TOKEN= +KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=false # Cloudtentacles CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc diff --git a/apps/backend/package.json b/apps/backend/package.json index f7a14f9b..79348b72 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -14,6 +14,8 @@ "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: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", "start": "node dist/index.js", "start:src": "tsx src/index.ts" diff --git a/apps/backend/scripts/curl-send-callback.ts b/apps/backend/scripts/curl-send-callback.ts new file mode 100644 index 00000000..82680454 --- /dev/null +++ b/apps/backend/scripts/curl-send-callback.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 + +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 +} diff --git a/apps/backend/scripts/gen-test-cases.ts b/apps/backend/scripts/gen-test-cases.ts new file mode 100644 index 00000000..9729aee1 --- /dev/null +++ b/apps/backend/scripts/gen-test-cases.ts @@ -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 + +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 +} diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index e716e680..82f30fa2 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -49,6 +49,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { appSecret: "", signSecret: "", messageSecret: "", + accessToken: "", + sendCallbackEnabled: false, provider: "kuaishou-industry", platform: "kuaishou", shopId: "kuaishou-industry", diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 4c220f9d..edcb7eeb 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -244,6 +244,16 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "kuaishouIndustry", "messageSecret", ]), + stringEnv("KUASHOU_INDUSTRY_ACCESS_TOKEN", [ + "platforms", + "kuaishouIndustry", + "accessToken", + ]), + booleanEnv("KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED", [ + "platforms", + "kuaishouIndustry", + "sendCallbackEnabled", + ]), stringEnv("KUASHOU_INDUSTRY_PROVIDER", [ "platforms", "kuaishouIndustry", diff --git a/apps/backend/src/services/platforms/kuaishou-industry/config.ts b/apps/backend/src/services/platforms/kuaishou-industry/config.ts index ac07e862..ae63c077 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/config.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/config.ts @@ -18,6 +18,8 @@ export function getKuaishouIndustryConfig(overrides: Partial + +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).sort()) + return String(value) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts index 410ef0f1..3134ac85 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts @@ -1,6 +1,7 @@ import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' +import { logWarn } from '../../../utils/logger.js' import { TASK_STATUS } from '../../../domain/task-status.js' import { KUISHOU_INDUSTRY_PLATFORM, @@ -13,6 +14,7 @@ import { buildIndustrySuccessResponse, buildIndustryErrorResponse, } from './response.js' +import { consumeCallback } from './consume-callback-service.js' type JsonObject = Record @@ -116,8 +118,40 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) { 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({ oid: normalizedOid, consumedCount, }) } + +function fireConsumeCallback(input: Parameters[0]) { + consumeCallback(input).catch((err) => { + logWarn('[kuaishou-industry/consume-code]', '核销回调异步执行异常', { + oid: input.oid, + error: err instanceof Error ? err.message : String(err), + }) + }) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts new file mode 100644 index 00000000..3a950541 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-callback-service.ts @@ -0,0 +1,140 @@ +import crypto from 'node:crypto' + +import { logInfo, logWarn } from '../../../utils/logger.js' +import { getKuaishouIndustryConfig } from './config.js' + +type JsonObject = Record + +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).sort()) + return String(value) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts index e385a9f4..17026b7e 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts @@ -2,6 +2,7 @@ import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-re import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { updateTask } from '../../../repositories/task-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' +import { logWarn } from '../../../utils/logger.js' import { KUISHOU_INDUSTRY_PLATFORM, getKuaishouIndustryConfig, @@ -13,6 +14,7 @@ import { buildIndustrySuccessResponse, buildIndustryErrorResponse, } from './response.js' +import { destroyCallback } from './destroy-callback-service.js' type JsonObject = Record @@ -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 }) } + +function fireDestroyCallback(input: Parameters[0]) { + destroyCallback(input).catch((err) => { + logWarn('[kuaishou-industry/destroy-code]', '销毁回调异步执行异常', { + oid: input.oid, + error: err instanceof Error ? err.message : String(err), + }) + }) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts new file mode 100644 index 00000000..bbeead8c --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-callback-service.ts @@ -0,0 +1,152 @@ +import crypto from 'node:crypto' + +import { logInfo, logWarn } from '../../../utils/logger.js' +import { getKuaishouIndustryConfig } from './config.js' + +type JsonObject = Record + +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).sort()) + return String(value) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts index 6b21a757..74bf8353 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -3,6 +3,7 @@ import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositorie import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js' import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' +import { logWarn } from '../../../utils/logger.js' import type { TaskRow } from '../../../types/repository/rows.js' import { KUISHOU_INDUSTRY_PROVIDER, @@ -18,6 +19,7 @@ import { buildIndustryEticketItem, buildIndustrySendCodeData, } from './response.js' +import { sendCallback } from './send-callback-service.js' const INDUSTRY_PROFILE_KEY = 'kuaishou-industry' @@ -141,7 +143,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) { buildEticketFromTask(task, params), ) - return buildIndustrySuccessResponse( + const response = buildIndustrySuccessResponse( buildIndustrySendCodeData({ oid: normalizedOid, sendType: params.sendType, @@ -149,6 +151,17 @@ export async function handleSendCode(rawBody: JsonObject = {}) { etickets, }), ) + + fireSendCallback({ + oid: normalizedOid, + sendType: params.sendType, + etickets, + token: params.token, + eticketType: params.eticketType, + ext: params.ext, + }) + + return response } async function ensureIndustryProfile(now: string) { @@ -216,3 +229,35 @@ function mapTaskStatusToEticketStatus(taskStatus: string) { return 'UNUSED' } } + +function fireSendCallback(input: { + oid: string + sendType: string + etickets: ReturnType[] + 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), + }) + }) +} diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 28abe92f..caa33ca1 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -82,6 +82,8 @@ export type RuntimeConfig = { appSecret: string; signSecret: string; messageSecret: string; + accessToken: string; + sendCallbackEnabled: boolean; provider: string; platform: string; shopId: string; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 05fbb996..ad7033b9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -59,6 +59,8 @@ services: ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} 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 volumes: - ./apps/backend:/app diff --git a/docker-compose.yml b/docker-compose.yml index b7e98f6c..3d938f9d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,9 @@ services: ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET} ADMIN_DEFAULT_USERS_JSON: ${ADMIN_DEFAULT_USERS_JSON} 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 volumes: - ./apps/backend/data:/app/data diff --git a/测试用例.md b/测试用例.md new file mode 100644 index 00000000..bcf0b7bf --- /dev/null +++ b/测试用例.md @@ -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 调用快手正式环境,出现"订单不存在"即为通过 商家补充截图