通知商家发码-ok

This commit is contained in:
yml2213
2026-06-30 15:57:58 +08:00
parent f58ec9a504
commit 693f4de45a
18 changed files with 1531 additions and 0 deletions
+2
View File
@@ -4,6 +4,7 @@ import process from "node:process";
import adminRouter from "./routes/admin.js";
import claimsRouter from "./routes/claims.js";
import kuaishouIndustryRouter from "./routes/kuaishou-industry.js";
import open91Router from "./routes/open-91.js";
import { accessLogMiddleware } from "./middleware/access-log.js";
import { createCorsMiddleware } from "./middleware/cors.js";
@@ -90,6 +91,7 @@ export function createApp({
});
app.use("/api/v1/open/91", open91Router);
app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter);
app.use("/api/v1/claim", claimsRouter);
app.use("/api/v1/admin", adminRouter);
+9
View File
@@ -44,6 +44,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
timestampToleranceSeconds: 600,
cardsEncoding: "aes-256-ecb-base64",
},
kuaishouIndustry: {
appKey: "",
signSecret: "",
provider: "kuaishou-industry",
platform: "kuaishou",
shopId: "kuaishou-industry",
shopName: "快手行业电子凭证",
version: "1",
},
cloudtentacles: {
baseUrl: "https://123.207.217.176",
timeoutMs: 5000,
+30
View File
@@ -224,6 +224,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"cloudtentacles",
"deviceType",
]),
stringEnv("KUASHOU_INDUSTRY_APP_KEY", [
"platforms",
"kuaishouIndustry",
"appKey",
]),
stringEnv("KUASHOU_INDUSTRY_SIGN_SECRET", [
"platforms",
"kuaishouIndustry",
"signSecret",
]),
stringEnv("KUASHOU_INDUSTRY_PROVIDER", [
"platforms",
"kuaishouIndustry",
"provider",
]),
stringEnv("KUASHOU_INDUSTRY_SHOP_ID", [
"platforms",
"kuaishouIndustry",
"shopId",
]),
stringEnv("KUASHOU_INDUSTRY_SHOP_NAME", [
"platforms",
"kuaishouIndustry",
"shopName",
]),
stringEnv("KUASHOU_INDUSTRY_VERSION", [
"platforms",
"kuaishouIndustry",
"version",
]),
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
];
@@ -0,0 +1,138 @@
import { Router } from 'express'
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 { buildIndustryErrorResponse } from '../services/platforms/kuaishou-industry/response.js'
import { createRequestId, logIntegration } from '../utils/logger.js'
const router = Router()
const industryRateLimit = createRateLimitMiddleware({
scope: 'kuaishouIndustry',
windowMs: 60_000,
max: 120,
onLimit: (_req, res) => {
res.status(200).json(buildIndustryErrorResponse(4010003, '请求过于频繁,请稍后再试'))
},
})
router.post('/send-code', industryRateLimit, async (req, res) => {
const requestId = createRequestId('ksind')
const startedAt = Date.now()
logIntegration('[kuaishou-industry/send-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 handleSendCode(params)
logIntegration('[kuaishou-industry/send-code]', '发码处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
})
res.status(200).json(result)
} catch (error) {
const message = error instanceof Error ? error.message : '系统异常'
logIntegration('[kuaishou-industry/send-code]', '发码处理失败', {
requestId,
durationMs: Date.now() - startedAt,
error,
}, { level: 'error' })
res.status(200).json(buildIndustryErrorResponse(4010003, message))
}
})
router.post('/destroy-code', industryRateLimit, async (req, res) => {
const requestId = createRequestId('ksind')
const startedAt = Date.now()
logIntegration('[kuaishou-industry/destroy-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 handleDestroyCode(params)
logIntegration('[kuaishou-industry/destroy-code]', '销毁处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
})
res.status(200).json(result)
} catch (error) {
const message = error instanceof Error ? error.message : '系统异常'
logIntegration('[kuaishou-industry/destroy-code]', '销毁处理失败', {
requestId,
durationMs: Date.now() - startedAt,
error,
}, { level: 'error' })
res.status(200).json(buildIndustryErrorResponse(4010003, message))
}
})
router.post('/query-code', industryRateLimit, async (req, res) => {
const requestId = createRequestId('ksind')
const startedAt = Date.now()
logIntegration('[kuaishou-industry/query-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 handleQueryCode(params)
logIntegration('[kuaishou-industry/query-code]', '查询处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result,
})
res.status(200).json(result)
} catch (error) {
const message = error instanceof Error ? error.message : '系统异常'
logIntegration('[kuaishou-industry/query-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 || {}
const params: Record<string, any> = { ...query }
for (const [key, value] of Object.entries(body)) {
if (params[key] === undefined && key !== 'param') {
params[key] = value
}
}
if (body.param && !params.param) {
params.param = body.param
}
return params
}
export default router
@@ -0,0 +1,38 @@
import { runtimeConfig } from '../../../config/runtime.js'
import { createHttpError } from '../../../utils/http.js'
import type { RuntimeConfig } from '../../../types/runtime-config.js'
export const KUISHOU_INDUSTRY_PROVIDER = 'kuaishou-industry'
export const KUISHOU_INDUSTRY_PLATFORM = 'kuaishou'
type KuaishouIndustryRuntimeConfig = RuntimeConfig['platforms']['kuaishouIndustry']
export function getKuaishouIndustryConfig(overrides: Partial<KuaishouIndustryRuntimeConfig> = {}) {
const config = {
...(runtimeConfig.platforms?.kuaishouIndustry || {}),
...overrides,
}
return {
appKey: String(config.appKey || '').trim(),
signSecret: String(config.signSecret || '').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,
shopName: String(config.shopName || '快手行业电子凭证').trim() || '快手行业电子凭证',
version: String(config.version || '1').trim() || '1',
}
}
export function assertKuaishouIndustryConfig() {
const config = getKuaishouIndustryConfig()
if (!config.signSecret) {
throw createHttpError('快手行业电子凭证 signSecret 未配置', {
statusCode: 500,
errorCode: 'kuaishou_industry_missing_sign_secret',
})
}
return config
}
@@ -0,0 +1,110 @@
import crypto from 'node:crypto'
import { createHttpError } from '../../../utils/http.js'
import { logWarn } from '../../../utils/logger.js'
import { assertKuaishouIndustryConfig } from './config.js'
type JsonObject = Record<string, any>
type SignMethod = 'MD5' | 'HMAC_SHA256'
export function buildKuaishouIndustrySignSource(params: JsonObject = {}, { signSecret } = assertKuaishouIndustryConfig()) {
const entries = Object.entries(params)
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
.sort(([left], [right]) => {
if (left === right) {
return 0
}
return left < right ? -1 : 1
})
const queryString = entries
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
.join('&')
return `${queryString}&signSecret=${signSecret}`
}
export function signKuaishouIndustryPayload(
params: JsonObject = {},
signMethod: SignMethod = 'MD5',
config = assertKuaishouIndustryConfig(),
) {
const source = buildKuaishouIndustrySignSource(params, config)
if (signMethod === 'HMAC_SHA256') {
return hmacSha256Sign(source, config.signSecret)
}
return crypto
.createHash('md5')
.update(source, 'utf8')
.digest('hex')
.toLowerCase()
}
export function verifyKuaishouIndustrySignature(
params: JsonObject = {},
config = assertKuaishouIndustryConfig(),
) {
const signMethod = normalizeSignMethod(params.signMethod)
const source = buildKuaishouIndustrySignSource(params, config)
const expected = signKuaishouIndustryPayload(params, signMethod, config)
const actual = String(params.sign || '').trim()
if (expected !== actual) {
logWarn('[kuaishou-industry/crypto]', '签名验证失败', {
signMethod,
expected,
actual,
source: source.replace(config.signSecret, '***'),
})
return false
}
return true
}
export function assertKuaishouIndustrySignature(params: JsonObject = {}, config = assertKuaishouIndustryConfig()) {
if (!verifyKuaishouIndustrySignature(params, config)) {
throw createHttpError('签名验证失败', {
statusCode: 400,
errorCode: 'kuaishou_industry_invalid_signature',
})
}
}
function hmacSha256Sign(source: string, secret: string) {
return crypto
.createHmac('sha256', secret)
.update(source, 'utf8')
.digest('base64')
}
function normalizeSignMethod(value: unknown): SignMethod {
const normalized = String(value || '').trim().toUpperCase()
if (normalized === 'HMAC_SHA256' || normalized === 'HMAC-SHA256') {
return 'HMAC_SHA256'
}
return 'MD5'
}
function stringifySignValue(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false'
}
if (value == null) {
return ''
}
if (typeof value === 'object') {
return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort())
}
return String(value)
}
@@ -0,0 +1,69 @@
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { updateTask, getTaskById } from '../../../repositories/task-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
} from './config.js'
import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
import {
buildIndustrySuccessResponse,
buildIndustryErrorResponse,
} from './response.js'
type JsonObject = Record<string, any>
export async function handleDestroyCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
assertKuaishouIndustrySignature(rawBody, config)
const params = normalizeDestroyCodePayload(rawBody)
assertDestroyCodePayload(params, config)
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 buildIndustrySuccessResponse({ oid: normalizedOid })
}
const tasks = await listTasksByOrderId(order.id)
if (params.etickets.length > 0) {
const targetIds = new Set(params.etickets.map((e) => String(e.id)))
for (const task of tasks) {
const taskId = String(task.id || '')
if (targetIds.has(taskId)) {
await updateTask(taskId, {
task_status: 'destroyed',
delivery_status: 'destroyed',
result_code: params.reason,
result_message: `销毁原因: ${params.reason}`,
updated_at: now,
})
}
}
} else {
for (const task of tasks) {
await updateTask(task.id, {
task_status: 'cancelled',
delivery_status: 'cancelled',
result_code: params.reason,
result_message: `订单关闭: ${params.reason}`,
updated_at: now,
})
}
}
return buildIndustrySuccessResponse({ oid: normalizedOid })
}
@@ -0,0 +1,194 @@
import { createHttpError } from '../../../utils/http.js'
import { assertKuaishouIndustryConfig } from './config.js'
type JsonObject = Record<string, any>
export function normalizeIndustryString(value: unknown) {
return String(value || '').trim()
}
export function normalizeSendCodePayload(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),
sellerId: normalizeIndustryString(param.sellerId),
num: normalizeIndustryInteger(param.num),
itemId: normalizeIndustryString(param.itemId),
skuId: normalizeIndustryString(param.skuId),
itemTitle: normalizeIndustryString(param.itemTitle),
sendType: normalizeIndustryString(param.sendType) || 'VIRTUAL',
eticketType: normalizeIndustryString(param.eticketType),
token: normalizeIndustryString(param.token),
certExpireType: normalizeIndustryInteger(param.certExpireType),
certStartTime: normalizeIndustryLong(param.certStartTime),
certEndTime: normalizeIndustryLong(param.certEndTime),
certExpDays: normalizeIndustryInteger(param.certExpDays),
certActualStartTime: normalizeIndustryLong(param.certActualStartTime),
certActualEndTime: normalizeIndustryLong(param.certActualEndTime),
expressCode: normalizeIndustryString(param.expressCode),
expressNo: normalizeIndustryString(param.expressNo),
ext: normalizeIndustryString(param.ext),
}
}
export function normalizeDestroyCodePayload(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),
reason: normalizeIndustryString(param.reason),
etickets: normalizeDestroyEtickets(param.etickets),
}
}
export function normalizeQueryCodePayload(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),
eticketId: normalizeIndustryString(param.eticketId),
sendType: normalizeIndustryString(param.sendType) || 'VIRTUAL',
eticketType: normalizeIndustryString(param.eticketType),
ext: normalizeIndustryString(param.ext),
}
}
export function assertSendCodePayload(payload: ReturnType<typeof normalizeSendCodePayload>, config = assertKuaishouIndustryConfig()) {
if (!payload.oid) {
throw createHttpError('缺少 oid', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_oid',
})
}
if (!payload.sendType) {
throw createHttpError('缺少 sendType', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_send_type',
})
}
assertCommonPayload(payload, config)
}
export function assertDestroyCodePayload(payload: ReturnType<typeof normalizeDestroyCodePayload>, config = assertKuaishouIndustryConfig()) {
if (!payload.oid) {
throw createHttpError('缺少 oid', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_oid',
})
}
if (!payload.reason) {
throw createHttpError('缺少 reason', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_reason',
})
}
assertCommonPayload(payload, config)
}
export function assertQueryCodePayload(payload: ReturnType<typeof normalizeQueryCodePayload>, config = assertKuaishouIndustryConfig()) {
if (!payload.oid) {
throw createHttpError('缺少 oid', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_oid',
})
}
assertCommonPayload(payload, config)
}
function assertCommonPayload(
payload: { appKey: string, version: string, timestamp: number, signMethod: string, sign: string, method: string, accessToken: string, paramRaw: string },
config = assertKuaishouIndustryConfig(),
) {
if (!payload.signMethod) {
throw createHttpError('缺少 signMethod', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_sign_method',
})
}
if (!payload.sign) {
throw createHttpError('缺少 sign', {
statusCode: 400,
errorCode: 'kuaishou_industry_missing_sign',
})
}
const normalizedMethod = payload.signMethod.toUpperCase()
if (normalizedMethod !== 'MD5' && normalizedMethod !== 'HMAC_SHA256' && normalizedMethod !== 'HMAC-SHA256') {
throw createHttpError(`不支持的签名算法: ${payload.signMethod},仅支持 MD5 或 HMAC_SHA256`, {
statusCode: 400,
errorCode: 'kuaishou_industry_unsupported_sign_method',
})
}
}
function parseParamField(raw: JsonObject) {
if (raw.param && typeof raw.param === 'object' && !Array.isArray(raw.param)) {
return raw.param
}
try {
const parsed = JSON.parse(normalizeIndustryString(raw.param || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
function normalizeIndustryInteger(value: unknown) {
const parsed = Number(value)
return Number.isInteger(parsed) ? parsed : 0
}
function normalizeIndustryLong(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function normalizeIndustryTimestamp(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function normalizeDestroyEtickets(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),
}))
}
@@ -0,0 +1,113 @@
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
} from './config.js'
import { normalizeQueryCodePayload, assertQueryCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
import {
buildIndustrySuccessResponse,
buildIndustryErrorResponse,
buildIndustryEticketItem,
buildIndustryQueryCodeData,
} from './response.js'
type JsonObject = Record<string, any>
export async function handleQueryCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
assertKuaishouIndustrySignature(rawBody, config)
const params = normalizeQueryCodePayload(rawBody)
assertQueryCodePayload(params, config)
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)
if (params.eticketId) {
const matched = tasks.find((t) => String(t.id || '') === params.eticketId)
if (!matched) {
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
}
const eticket = buildEticketFromTask(matched, params.eticketType)
return buildIndustrySuccessResponse(
buildIndustryQueryCodeData({
oid: normalizedOid,
sendType: params.sendType,
sendNum: 1,
etickets: [eticket],
}),
)
}
const etickets = tasks.map((task) =>
buildEticketFromTask(task, params.eticketType),
)
return buildIndustrySuccessResponse(
buildIndustryQueryCodeData({
oid: normalizedOid,
sendType: params.sendType,
sendNum: etickets.reduce((sum, e) => sum + (Number(e.num) || 0), 0),
etickets,
}),
)
}
function buildEticketFromTask(task: TaskRow, eticketType: string) {
const taskId = String(task.id || '')
const taskStatus = String(task.task_status || '')
const status = mapTaskStatusToEticketStatus(taskStatus)
let contextJson: JsonObject = {}
try {
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
} catch {
contextJson = {}
}
return buildIndustryEticketItem({
id: taskId,
status,
num: 1,
validStartTime: Number(contextJson.certActualStartTime || 0),
validEndTime: Number(contextJson.certActualEndTime || 0),
eticketType,
consumeDetails: [],
})
}
function mapTaskStatusToEticketStatus(taskStatus: string) {
switch (taskStatus) {
case 'destroyed':
case 'cancelled':
return 'DESTROYED'
case 'consumed':
case 'redeemed':
case 'completed':
case 'delivered':
return 'CONSUMED'
case 'unused':
case 'pending_payment':
case 'pending_delivery':
case 'pending_fulfill':
case 'pending_review':
default:
return 'UNUSED'
}
}
@@ -0,0 +1,90 @@
type JsonObject = Record<string, any>
export function buildIndustrySuccessResponse(data: unknown = null) {
return {
result: 1,
error_msg: '',
data: data || {},
}
}
export function buildIndustryErrorResponse(result: number, errorMsg: string) {
return {
result,
error_msg: errorMsg || '系统异常',
data: null as null,
}
}
export function buildIndustryEticketItem(eticket: {
id: string
code?: string
status?: string
num?: number
validStartTime?: number
validEndTime?: number
consumeDetails?: JsonObject[]
eticketType?: string
}) {
const item: JsonObject = {
id: eticket.id,
status: eticket.status || 'UNUSED',
num: eticket.num || 1,
validStartTime: eticket.validStartTime || 0,
validEndTime: eticket.validEndTime || 0,
}
if (eticket.code) {
item.code = eticket.code
}
if (eticket.consumeDetails && eticket.consumeDetails.length > 0) {
item.eticketConsumeDetails = eticket.consumeDetails.map((detail) => ({
serialNum: detail.serialNum || '',
consumeType: detail.consumeType || '',
consumeTime: detail.consumeTime || 0,
appointmentTime: detail.appointmentTime,
storeName: detail.storeName,
storeAddress: detail.storeAddress,
expressCode: detail.expressCode,
expressName: detail.expressName,
expressNo: detail.expressNo,
reason: detail.reason,
ext: detail.ext,
}))
}
if (eticket.eticketType) {
item.eticketType = eticket.eticketType
}
return item
}
export function buildIndustrySendCodeData(data: {
oid: string
sendType: string
sendNum: number
etickets: JsonObject[]
}) {
return {
oid: data.oid,
sendType: data.sendType,
sendNum: data.sendNum,
etickets: data.etickets,
}
}
export function buildIndustryQueryCodeData(data: {
oid: string
sendType: string
sendNum: number
etickets: JsonObject[]
}) {
return {
oid: data.oid,
sendType: data.sendType,
sendNum: data.sendNum,
etickets: data.etickets,
}
}
@@ -0,0 +1,216 @@
import { findLatestOrderByPlatformOrderId, createOrder } from '../../../repositories/order-repo.js'
import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js'
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import {
KUISHOU_INDUSTRY_PROVIDER,
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig,
} from './config.js'
import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js'
import {
buildIndustrySuccessResponse,
buildIndustryErrorResponse,
buildIndustryEticketItem,
buildIndustrySendCodeData,
} from './response.js'
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
type JsonObject = Record<string, any>
export async function handleSendCode(rawBody: JsonObject = {}) {
const config = getKuaishouIndustryConfig()
assertKuaishouIndustrySignature(rawBody, config)
const params = normalizeSendCodePayload(rawBody)
assertSendCodePayload(params, config)
const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid
let order = await findLatestOrderByPlatformOrderId({
provider: config.provider,
platform: KUISHOU_INDUSTRY_PLATFORM,
platformOrderId: normalizedOid,
})
if (!order) {
order = await createOrder({
provider: config.provider,
platform: KUISHOU_INDUSTRY_PLATFORM,
shopId: config.shopId,
shopName: config.shopName,
platformOrderId: normalizedOid,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: params.sellerId,
buyerName: '',
receiverContact: '',
totalAmount: '0',
currency: 'CNY',
rawPayloadJson: JSON.stringify(params),
paidAt: now,
createdAt: now,
updatedAt: now,
})
}
if (!order) {
return buildIndustryErrorResponse(4010003, '创建订单失败')
}
const profile = await ensureIndustryProfile(now)
if (!profile) {
return buildIndustryErrorResponse(4010003, '创建履约配置失败')
}
const existingTasks = await listTasksByOrderId(order.id)
const existingTaskCount = existingTasks.length
const targetTaskCount = params.num > 0 ? params.num : 1
const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount)
if (tasksToCreate > 0) {
const items = []
for (let i = 0; i < tasksToCreate; i++) {
const unitIndex = existingTaskCount + i + 1
items.push({
skuCode: params.itemId || params.skuId || 'kuaishou-industry',
skuName: params.itemTitle || '行业电子凭证',
quantity: 1,
specJson: JSON.stringify({
itemId: params.itemId,
skuId: params.skuId,
oid: normalizedOid,
}),
itemSnapshotJson: '{}',
createdAt: now,
updatedAt: now,
})
}
const orderItems = await replaceOrderItems(order.id, items)
for (let i = 0; i < tasksToCreate; i++) {
const unitIndex = existingTaskCount + i + 1
const item = orderItems[i]
if (!item) {
continue
}
await createTask({
orderId: order.id,
orderItemId: item.id,
unitIndex,
taskNo: `${normalizedOid}-${unitIndex}`,
provider: config.provider,
platform: KUISHOU_INDUSTRY_PLATFORM,
shopId: config.shopId,
shopName: config.shopName,
platformOrderId: normalizedOid,
profileId: profile.id,
executorKey: INDUSTRY_PROFILE_KEY,
taskStatus: 'pending_payment',
deliveryStatus: 'pending',
automationMode: 'manual',
requiresClaim: true,
contextJson: JSON.stringify({
token: params.token,
certExpireType: params.certExpireType,
certStartTime: params.certStartTime,
certEndTime: params.certEndTime,
certExpDays: params.certExpDays,
certActualStartTime: params.certActualStartTime,
certActualEndTime: params.certActualEndTime,
}),
createdAt: now,
updatedAt: now,
})
}
}
const allTasks = await listTasksByOrderId(order.id)
const etickets = allTasks.map((task) =>
buildEticketFromTask(task, params),
)
return buildIndustrySuccessResponse(
buildIndustrySendCodeData({
oid: normalizedOid,
sendType: params.sendType,
sendNum: etickets.length,
etickets,
}),
)
}
async function ensureIndustryProfile(now: string) {
const existing = await getFulfillmentProfileByKey(INDUSTRY_PROFILE_KEY)
if (existing) {
return existing
}
return upsertFulfillmentProfile({
profileKey: INDUSTRY_PROFILE_KEY,
name: '快手行业电子凭证',
executorKey: INDUSTRY_PROFILE_KEY,
requiresClaim: true,
autoDispatch: false,
inventoryStrategy: 'static_pool',
configJson: {},
createdAt: now,
updatedAt: now,
})
}
function buildEticketFromTask(task: TaskRow, params: ReturnType<typeof normalizeSendCodePayload>) {
const taskId = String(task.id || '')
const taskStatus = String(task.task_status || '')
const status = mapTaskStatusToEticketStatus(taskStatus)
let contextJson: JsonObject = {}
try {
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
} catch {
contextJson = {}
}
const validStartTime = Number(contextJson.certActualStartTime || params.certActualStartTime || 0)
const validEndTime = Number(contextJson.certActualEndTime || params.certActualEndTime || 0)
return buildIndustryEticketItem({
id: taskId,
status,
num: 1,
validStartTime,
validEndTime,
eticketType: params.eticketType,
consumeDetails: [],
})
}
function mapTaskStatusToEticketStatus(taskStatus: string) {
switch (taskStatus) {
case 'destroyed':
case 'cancelled':
return 'DESTROYED'
case 'consumed':
case 'redeemed':
case 'completed':
case 'delivered':
return 'CONSUMED'
case 'unused':
case 'pending_payment':
case 'pending_delivery':
case 'pending_fulfill':
case 'pending_review':
default:
return 'UNUSED'
}
}
+9
View File
@@ -77,6 +77,15 @@ export type RuntimeConfig = {
deviceId: string;
deviceType: number;
};
kuaishouIndustry: {
appKey: string;
signSecret: string;
provider: string;
platform: string;
shopId: string;
shopName: string;
version: string;
};
};
cors: {
allowedOrigins: string[];