行业电子凭证接入

- 新增 4 个回调接口:send-code/destroy-code/query-code/consume-code
- 支持快手开放平台 MD5/HMAC_SHA256 签名验签
- send-code 回调自动创建 order/order_items/fulfillment_tasks/profiles
- consume-code 回调与既有 flow.consume 结构兼容
- isKuaishouCloudTask 同时匹配 kuaishou-industry executor_key
- claim 页面自动验证行业凭证 task,跳过核销码输入
- 退回时行业 task 跳过快手核销 API
- 新增 KUASHOU_INDUSTRY_* 环境变量配置
This commit is contained in:
yml2213
2026-06-30 17:33:51 +08:00
parent 693f4de45a
commit f38efa2edf
16 changed files with 471 additions and 4 deletions
@@ -15,7 +15,9 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRun
return {
appKey: String(config.appKey || '').trim(),
appSecret: String(config.appSecret || '').trim(),
signSecret: String(config.signSecret || '').trim(),
messageSecret: String(config.messageSecret || '').trim(),
provider: String(config.provider || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
platform: String(config.platform || KUISHOU_INDUSTRY_PLATFORM).trim() || KUISHOU_INDUSTRY_PLATFORM,
shopId: String(config.shopId || KUISHOU_INDUSTRY_PROVIDER).trim() || KUISHOU_INDUSTRY_PROVIDER,
@@ -27,6 +29,13 @@ export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRun
export function assertKuaishouIndustryConfig() {
const config = getKuaishouIndustryConfig()
if (!config.appKey) {
throw createHttpError('快手行业电子凭证 appKey 未配置', {
statusCode: 500,
errorCode: 'kuaishou_industry_missing_app_key',
})
}
if (!config.signSecret) {
throw createHttpError('快手行业电子凭证 signSecret 未配置', {
statusCode: 500,
@@ -36,3 +45,18 @@ export function assertKuaishouIndustryConfig() {
return config
}
export function assertMatchingAppKey(incomingAppKey: string) {
const config = getKuaishouIndustryConfig()
if (!config.appKey) {
return
}
if (incomingAppKey !== config.appKey) {
throw createHttpError('appkey 不匹配', {
statusCode: 400,
errorCode: 'kuaishou_industry_app_key_mismatch',
})
}
}
@@ -0,0 +1,123 @@
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
assertMatchingAppKey,
} from './config.js'
import { normalizeConsumeCodePayload, assertConsumeCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
import {
buildIndustrySuccessResponse,
buildIndustryErrorResponse,
} from './response.js'
type JsonObject = Record<string, any>
export async function handleConsumeCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
assertKuaishouIndustrySignature(rawBody, config)
const params = normalizeConsumeCodePayload(rawBody)
assertConsumeCodePayload(params, config)
assertMatchingAppKey(params.appKey)
const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid
const order = await findLatestOrderByPlatformOrderId({
provider: config.provider,
platform: KUISHOU_INDUSTRY_PLATFORM,
platformOrderId: normalizedOid,
})
if (!order) {
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
}
const tasks = await listTasksByOrderId(order.id)
const matchedIds = new Set(params.etickets.map((e) => String(e.id)))
let consumedCount = 0
for (const task of tasks) {
const taskId = String(task.id || '')
if (!matchedIds.has(taskId)) {
continue
}
let contextJson: JsonObject = {}
try {
contextJson = typeof task.context_json === 'string'
? JSON.parse(task.context_json)
: (task.context_json || {})
} catch {
contextJson = {}
}
const flow = contextJson.kuaishouCloudFulfillment || {}
const existingConsumes = Array.isArray(flow.consumeDetails)
? contextJson.consumeDetails as JsonObject[]
: Array.isArray(contextJson.consumeDetails)
? contextJson.consumeDetails as JsonObject[]
: []
const consumeDetail = {
serialNum: params.seriallNum,
consumeType: params.consumeType,
consumeTime: params.consumeTime,
appointmentTime: params.appointmentTime || undefined,
storeName: params.storeName || undefined,
storeAddress: params.storeAddress || undefined,
expressCode: params.expressCode || undefined,
expressNo: params.expressNo || undefined,
consumePoiId: params.consumePoiId || undefined,
eticketType: params.eticketType || undefined,
ext: params.ext || undefined,
}
const nextContext = {
...contextJson,
kuaishouCloudFulfillment: {
...flow,
consume: {
...(flow.consume || {}),
status: 'success',
consumedAt: now,
consumeDetail,
},
},
consumeDetails: [...existingConsumes, consumeDetail],
}
const taskStatus = String(task.task_status || '')
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
const updatePayload: Record<string, unknown> = {
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
result_code: params.status,
result_message: `核销方式: ${params.consumeType}`,
context_json: JSON.stringify(nextContext),
updated_at: now,
}
if (isDispatched) {
updatePayload.delivery_status = 'delivered'
}
await updateTask(taskId, updatePayload as any)
consumedCount++
}
if (consumedCount === 0) {
return buildIndustryErrorResponse(4012005, `未找到匹配的卡券: ${params.etickets.map((e) => e.id).join(',')}`)
}
return buildIndustrySuccessResponse({
oid: normalizedOid,
consumedCount,
})
}
@@ -1,10 +1,11 @@
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { updateTask, getTaskById } from '../../../repositories/task-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
assertMatchingAppKey,
} from './config.js'
import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
@@ -22,6 +23,7 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
const params = normalizeDestroyCodePayload(rawBody)
assertDestroyCodePayload(params, config)
assertMatchingAppKey(params.appKey)
const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid
@@ -125,6 +125,75 @@ export function assertQueryCodePayload(payload: ReturnType<typeof normalizeQuery
assertCommonPayload(payload, config)
}
export function normalizeConsumeCodePayload(raw: JsonObject = {}) {
const param = parseParamField(raw)
return {
appKey: normalizeIndustryString(raw.appkey),
version: normalizeIndustryString(raw.version),
timestamp: normalizeIndustryTimestamp(raw.timestamp),
signMethod: normalizeIndustryString(raw.signMethod || 'MD5'),
sign: normalizeIndustryString(raw.sign),
accessToken: normalizeIndustryString(raw.access_token),
method: normalizeIndustryString(raw.method),
paramRaw: normalizeIndustryString(raw.param),
oid: normalizeIndustryString(param.oid),
etickets: normalizeConsumeEtickets(param.etickets),
status: normalizeIndustryString(param.status),
consumeType: normalizeIndustryString(param.consumeType),
consumeTime: normalizeIndustryLong(param.consumeTime),
storeName: normalizeIndustryString(param.storeName),
storeAddress: normalizeIndustryString(param.storeAddress),
expressNo: normalizeIndustryString(param.expressNo),
expressCode: normalizeIndustryString(param.expressCode),
appointmentTime: normalizeIndustryString(param.appointmentTime),
eticketType: normalizeIndustryString(param.eticketType),
ext: normalizeIndustryString(param.ext),
token: normalizeIndustryString(param.token),
seriallNum: normalizeIndustryString(param.seriallNum),
consumePoiId: normalizeIndustryLong(param.consumePoiId),
}
}
export function assertConsumeCodePayload(payload: ReturnType<typeof normalizeConsumeCodePayload>, config = assertKuaishouIndustryConfig()) {
if (!payload.oid) {
throw createHttpError('缺少 oid', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_oid',
})
}
if (!Array.isArray(payload.etickets) || payload.etickets.length === 0) {
throw createHttpError('缺少 etickets', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_etickets',
})
}
if (!payload.status) {
throw createHttpError('缺少 status', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_status',
})
}
if (!payload.consumeType) {
throw createHttpError('缺少 consumeType', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_consume_type',
})
}
if (!payload.seriallNum) {
throw createHttpError('缺少 seriallNum', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_serial_num',
})
}
assertCommonPayload(payload, config)
}
function assertCommonPayload(
payload: { appKey: string, version: string, timestamp: number, signMethod: string, sign: string, method: string, accessToken: string, paramRaw: string },
config = assertKuaishouIndustryConfig(),
@@ -192,3 +261,16 @@ function normalizeDestroyEtickets(value: unknown) {
goodsValue: normalizeIndustryLong(item?.goodsValue),
}))
}
function normalizeConsumeEtickets(value: unknown) {
if (!Array.isArray(value)) {
return []
}
return value.map((item) => ({
id: normalizeIndustryString(item?.id),
code: normalizeIndustryString(item?.code),
num: normalizeIndustryInteger(item?.num),
goodsValue: normalizeIndustryLong(item?.goodsValue),
}))
}
@@ -4,6 +4,7 @@ import type { TaskRow } from '../../../types/repository/rows.js'
import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
assertMatchingAppKey,
} from './config.js'
import { normalizeQueryCodePayload, assertQueryCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
@@ -23,6 +24,7 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
const params = normalizeQueryCodePayload(rawBody)
assertQueryCodePayload(params, config)
assertMatchingAppKey(params.appKey)
const normalizedOid = params.oid
@@ -8,6 +8,7 @@ import {
KUISHOU_INDUSTRY_PROVIDER,
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
assertMatchingAppKey,
} from './config.js'
import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
@@ -29,6 +30,7 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
const params = normalizeSendCodePayload(rawBody)
assertSendCodePayload(params, config)
assertMatchingAppKey(params.appKey)
const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid