行业电子凭证接入

- 新增 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
+2
View File
@@ -46,7 +46,9 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
},
kuaishouIndustry: {
appKey: "",
appSecret: "",
signSecret: "",
messageSecret: "",
provider: "kuaishou-industry",
platform: "kuaishou",
shopId: "kuaishou-industry",
+10
View File
@@ -229,11 +229,21 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"kuaishouIndustry",
"appKey",
]),
stringEnv("KUASHOU_INDUSTRY_APP_SECRET", [
"platforms",
"kuaishouIndustry",
"appSecret",
]),
stringEnv("KUASHOU_INDUSTRY_SIGN_SECRET", [
"platforms",
"kuaishouIndustry",
"signSecret",
]),
stringEnv("KUASHOU_INDUSTRY_MESSAGE_SECRET", [
"platforms",
"kuaishouIndustry",
"messageSecret",
]),
stringEnv("KUASHOU_INDUSTRY_PROVIDER", [
"platforms",
"kuaishouIndustry",
@@ -4,6 +4,7 @@ import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
import { handleSendCode } from '../services/platforms/kuaishou-industry/send-code-service.js'
import { handleDestroyCode } from '../services/platforms/kuaishou-industry/destroy-code-service.js'
import { handleQueryCode } from '../services/platforms/kuaishou-industry/query-code-service.js'
import { handleConsumeCode } from '../services/platforms/kuaishou-industry/consume-code-service.js'
import { buildIndustryErrorResponse } from '../services/platforms/kuaishou-industry/response.js'
import { createRequestId, logIntegration } from '../utils/logger.js'
@@ -117,6 +118,39 @@ router.post('/query-code', industryRateLimit, async (req, res) => {
}
})
router.post('/consume-code', industryRateLimit, async (req, res) => {
const requestId = createRequestId('ksind')
const startedAt = Date.now()
logIntegration('[kuaishou-industry/consume-code]', '收到快手行业电子凭证核销回调', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
ip: req.ip,
query: req.query,
body: req.body,
})
try {
const params = mergeRequestParams(req)
const result = await handleConsumeCode(params)
logIntegration('[kuaishou-industry/consume-code]', '核销处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
})
res.status(200).json(result)
} catch (error) {
const message = error instanceof Error ? error.message : '系统异常'
logIntegration('[kuaishou-industry/consume-code]', '核销处理失败', {
requestId,
durationMs: Date.now() - startedAt,
error,
}, { level: 'error' })
res.status(200).json(buildIndustryErrorResponse(4010003, message))
}
})
function mergeRequestParams(req: any): Record<string, any> {
const query = req.query || {}
const body = req.body || {}
@@ -47,7 +47,13 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
const context = await getClaimContext(token)
const now = nowIso()
if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
const executorKey = String(context.task.executor_key || '').trim()
if (executorKey === 'kuaishou-industry') {
return verifyIndustryeVoucherTicket(context, now)
}
if (executorKey !== 'kuaishou_ct_assisted') {
throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', {
statusCode: 409,
errorCode: 'claim_not_kuaishou_cloud',
@@ -237,6 +243,96 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
return getKuaishouCloudClaimDetail(token)
}
async function verifyIndustryeVoucherTicket(
context: Awaited<ReturnType<typeof getClaimContext>>,
now: string,
) {
const taskContext = parseTaskContext(context.task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
if (flow.consume.status === 'success') {
return getKuaishouCloudClaimDetail(context.claimToken.token)
}
const industryContext = typeof taskContext === 'object' ? taskContext : {}
const token = String(industryContext.token || '').trim()
const certExpireType = Number(industryContext.certExpireType || 0)
const certActualStartTime = Number(industryContext.certActualStartTime || 0)
const certActualEndTime = Number(industryContext.certActualEndTime || 0)
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
ticket: {
...flow.ticket,
code: '',
status: 'verified',
capturedAt: flow.ticket.capturedAt || now,
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
verifiedAt: now,
oid: context.order.platform_order_id,
formToken: token,
leftCount: 0,
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
},
consume: {
...flow.consume,
status: 'pending',
shopId: context.order.shop_id,
shopName: context.order.shop_name,
autoConsumeEnabled: true,
},
certInfo: {
certExpireType,
certActualStartTime,
certActualEndTime,
},
},
}
const taskWithTicket = (await updateTask(context.task.id, {
claim_token: context.claimToken.token,
claim_expires_at: context.claimToken.expired_at,
context_json: JSON.stringify(nextContext),
claimed_at: context.task.claimed_at || now,
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
})) || {
...context.task,
context_json: JSON.stringify(nextContext),
claimed_at: context.task.claimed_at || now,
task_status: TASK_STATUS.WAITING_BINDING,
user_action_status: 'pending_claim',
last_error: '',
updated_at: now,
}
const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment)
if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) {
await prepareKuaishouCloudFulfillmentTask(taskWithTicket, {
source: 'send_code_callback',
actor: { source: 'send_code_callback' },
})
}
await createTaskEvent(
context.task.id,
'kuaishou_cloud_ticket_verified',
{
ticketCodeMasked: '',
source: 'send_code_callback',
industryVoucher: true,
verifiedAt: now,
},
now,
)
return getKuaishouCloudClaimDetail(context.claimToken.token)
}
export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
const normalized = String(filename || '').trim()
if (!ALLOWED_GUIDE_FILES.has(normalized)) {
@@ -261,8 +357,18 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
const context = await getClaimContext(token)
let task = context.task
const executorKey = String(task.executor_key || '').trim()
if (executorKey === 'kuaishou-industry') {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
if (flow.consume.status !== 'success') {
const now = nowIso()
await verifyIndustryeVoucherTicket(context, now).catch(() => null)
}
}
if (
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
executorKey === 'kuaishou_ct_assisted' &&
!isKuaishouCloudMockTask(task)
) {
task = (await syncKuaishouCloudRoleInfo(task)) || task
@@ -5,9 +5,16 @@ export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1'
export type JsonObject = Record<string, any>
const KUAISHOU_CLOUD_EXECUTOR_KEYS = new Set(['kuaishou_ct_assisted', 'kuaishou-industry'])
export function isKuaishouCloudTask(task: unknown) {
const source = task && typeof task === 'object' ? (task as JsonObject) : {}
return String(source.executor_key || '').trim() === 'kuaishou_ct_assisted'
return KUAISHOU_CLOUD_EXECUTOR_KEYS.has(String(source.executor_key || '').trim())
}
export function isIndustryEVoucherTask(task: unknown) {
const source = task && typeof task === 'object' ? (task as JsonObject) : {}
return String(source.executor_key || '').trim() === 'kuaishou-industry'
}
export function normalizeKuaishouCloudFlow(value: unknown) {
@@ -24,6 +24,7 @@ import {
} from "../../platforms/kuaishou-eticket/source-config-service.js";
import {
isKuaishouCloudTask,
isIndustryEVoucherTask,
maskCode,
maskPhone,
normalizeKuaishouCloudFlow,
@@ -793,10 +794,15 @@ export async function returnKuaishouCloudFulfillmentTask(
let nextResultCode = "kuaishou_cloud_completed";
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
const consumeAlreadyCompleted = flow.consume.status === "success";
const isIndustryTask = isIndustryEVoucherTask(task)
if (consumeAlreadyCompleted) {
consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now;
} else if (isIndustryTask) {
consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now;
consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销";
} else if (!order) {
consumeStatus = "failed";
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
@@ -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
+2
View File
@@ -79,7 +79,9 @@ export type RuntimeConfig = {
};
kuaishouIndustry: {
appKey: string;
appSecret: string;
signSecret: string;
messageSecret: string;
provider: string;
platform: string;
shopId: string;