行业电子凭证接入
- 新增 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user