接入多发货平台与电子凭证
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import type { RuntimeConfig } from '../../../types/runtime-config.js'
|
||||
|
||||
export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
|
||||
export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
|
||||
|
||||
type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
|
||||
|
||||
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
|
||||
const config = {
|
||||
...(runtimeConfig.platforms?.kuaishouFeifei || {}),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
|
||||
appKey: String(config.appKey || '').trim(),
|
||||
appSecret: String(config.appSecret || '').trim(),
|
||||
timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000),
|
||||
notifyUrl: String(config.notifyUrl || '').trim(),
|
||||
productRules: Array.isArray(config.productRules) ? config.productRules : [],
|
||||
}
|
||||
}
|
||||
|
||||
export function assertKuaishouFeifeiConfig() {
|
||||
const config = getKuaishouFeifeiConfig()
|
||||
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('kuaishou-feifei baseUrl 未配置', {
|
||||
statusCode: 500,
|
||||
errorCode: 'kuaishou_feifei_missing_base_url',
|
||||
})
|
||||
}
|
||||
|
||||
if (!config.appKey || !config.appSecret) {
|
||||
throw createHttpError('kuaishou-feifei App Key / App Secret 未配置', {
|
||||
statusCode: 500,
|
||||
errorCode: 'kuaishou_feifei_missing_credential',
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import crypto from 'node:crypto'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { signKuaishouFeifeiPayload } from './http-client.js'
|
||||
|
||||
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
|
||||
const input = {
|
||||
appKey: 'app-key-1',
|
||||
appSecret: 'secret-1',
|
||||
timestamp: '1783394218',
|
||||
body: '{"platform_order_no":"DT-1","product_code":"10000001","platform_buy_num":1}',
|
||||
}
|
||||
const expected = crypto
|
||||
.createHmac('sha256', input.appSecret)
|
||||
.update(`${input.appKey}${input.timestamp}${input.body}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
assert.equal(signKuaishouFeifeiPayload(input), expected)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { assertKuaishouFeifeiConfig } from './config.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObject = {}) {
|
||||
const config = assertKuaishouFeifeiConfig()
|
||||
const body = JSON.stringify(payload)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const sign = signKuaishouFeifeiPayload({
|
||||
appKey: config.appKey,
|
||||
appSecret: config.appSecret,
|
||||
timestamp,
|
||||
body,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.baseUrl}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-app-key': config.appKey,
|
||||
'x-timestamp': timestamp,
|
||||
'x-sign': sign,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const text = await response.text()
|
||||
const json = parseJsonObject(text)
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_feifei_http_failed',
|
||||
context: {
|
||||
status: response.status,
|
||||
body: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Number(json.code || 0) !== 0) {
|
||||
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_feifei_business_failed',
|
||||
context: json,
|
||||
})
|
||||
}
|
||||
|
||||
return json
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw createHttpError('kuaishou-feifei 请求超时', {
|
||||
statusCode: 504,
|
||||
errorCode: 'kuaishou_feifei_timeout',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function signKuaishouFeifeiPayload({
|
||||
appKey,
|
||||
appSecret,
|
||||
timestamp,
|
||||
body,
|
||||
}: {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
timestamp: string
|
||||
body: string
|
||||
}) {
|
||||
return crypto
|
||||
.createHmac('sha256', appSecret)
|
||||
.update(`${appKey}${timestamp}${body}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): JsonObject {
|
||||
try {
|
||||
const parsed = JSON.parse(text || '{}')
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getKuaishouFeifeiConfig } from './config.js'
|
||||
import { kuaishouFeifeiRequest } from './http-client.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function createKuaishouFeifeiOrder(input: {
|
||||
platformOrderNo: string
|
||||
productCode: string
|
||||
platformBuyNum?: number
|
||||
platformAmount?: number
|
||||
playerAccount?: string
|
||||
playerGameRegion?: string
|
||||
playerGameSrv?: string
|
||||
playerGameRole?: string
|
||||
submitPlayer?: boolean
|
||||
notifyUrl?: string
|
||||
}) {
|
||||
const config = getKuaishouFeifeiConfig()
|
||||
const payload: JsonObject = {
|
||||
platform_order_no: input.platformOrderNo,
|
||||
product_code: input.productCode,
|
||||
platform_buy_num: Math.max(1, Number(input.platformBuyNum || 1) || 1),
|
||||
}
|
||||
|
||||
if (input.platformAmount != null) payload.platform_amount = input.platformAmount
|
||||
if (input.playerAccount) payload.player_account = input.playerAccount
|
||||
if (input.playerGameRegion) payload.player_game_region = input.playerGameRegion
|
||||
if (input.playerGameSrv) payload.player_game_srv = input.playerGameSrv
|
||||
if (input.playerGameRole) payload.player_game_role = input.playerGameRole
|
||||
if (input.submitPlayer === true) payload.submit_player = true
|
||||
if (input.notifyUrl || config.notifyUrl) payload.notify_url = input.notifyUrl || config.notifyUrl
|
||||
|
||||
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/store', payload)
|
||||
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||
}
|
||||
|
||||
export async function queryKuaishouFeifeiOrder(input: {
|
||||
platformOrderNo?: string
|
||||
orderNo?: string
|
||||
}) {
|
||||
const payload: JsonObject = {}
|
||||
if (input.platformOrderNo) payload.platform_order_no = input.platformOrderNo
|
||||
if (input.orderNo) payload.order_no = input.orderNo
|
||||
|
||||
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/show', payload)
|
||||
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||
}
|
||||
|
||||
export function mapKuaishouFeifeiOrder(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
|
||||
|
||||
return {
|
||||
orderNo: String(source.order_no || '').trim(),
|
||||
platformOrderNo: String(source.platform_order_no || '').trim(),
|
||||
productCode: String(source.product_code || '').trim(),
|
||||
productName: String(source.product_name || '').trim(),
|
||||
rechargeStatus: Number(source.recharge_status ?? source.status ?? 0) || 0,
|
||||
rechargeStatusLabel: String(source.recharge_status_label || source.status_label || '').trim(),
|
||||
pointsCharged: Number(source.points_charged || 0) || 0,
|
||||
playerAccount: String(source.player_account || '').trim(),
|
||||
platformBuyNum: Number(source.platform_buy_num || 1) || 1,
|
||||
rechargeResultMessage: String(source.recharge_result_message || '').trim(),
|
||||
createdAt: String(source.created_at || '').trim(),
|
||||
updatedAt: String(source.updated_at || '').trim(),
|
||||
rechargeFinishAt: String(source.recharge_finish_at || '').trim(),
|
||||
h5: {
|
||||
entryUrl: String(h5.entry_url || '').trim(),
|
||||
rechargeUrl: String(h5.recharge_url || '').trim(),
|
||||
},
|
||||
raw: source,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
|
||||
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
|
||||
|
||||
export type KuaishouFeifeiProductMatch = {
|
||||
matchMode: 'kuaishou_feifei_rule'
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
productCode: string
|
||||
skuName: string
|
||||
}
|
||||
|
||||
export function resolveKuaishouFeifeiProductByName(
|
||||
productName: unknown,
|
||||
): KuaishouFeifeiProductMatch | null {
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rules = listKuaishouFeifeiProductRules()
|
||||
const matched = rules.find((rule) =>
|
||||
normalizeCloudtentaclesMatchName(rule.productName) === normalizedProductName,
|
||||
)
|
||||
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
|
||||
const productCode = String(matched.productCode || '').trim()
|
||||
if (!productCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
matchMode: 'kuaishou_feifei_rule',
|
||||
productName: String(matched.productName || productName || '').trim(),
|
||||
normalizedProductName,
|
||||
productCode,
|
||||
skuName: String(matched.skuName || matched.productName || productName || productCode).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function listKuaishouFeifeiProductRules(): KuaishouFeifeiProductRule[] {
|
||||
const rules = runtimeConfig.platforms?.kuaishouFeifei?.productRules
|
||||
return (Array.isArray(rules) ? rules : [])
|
||||
.map((rule) => ({
|
||||
productName: String(rule.productName || '').trim(),
|
||||
productCode: String(rule.productCode || '').trim(),
|
||||
skuName: String(rule.skuName || '').trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
}))
|
||||
.filter((rule) => rule.enabled && rule.productName && rule.productCode)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { getTaskById, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { findKuaishouIndustryVoucherByCode } from '../../../repositories/kuaishou-industry-voucher-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,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
buildIndustryErrorResponse,
|
||||
} from './response.js'
|
||||
import { consumeCallback } from './consume-callback-service.js'
|
||||
import { consumeKuaishouIndustryVoucher } from './voucher-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -30,87 +31,54 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
||||
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)) {
|
||||
for (const eticket of params.etickets) {
|
||||
const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid)
|
||||
if (!voucher) {
|
||||
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,
|
||||
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
|
||||
const consumed = await consumeKuaishouIndustryVoucher(voucher, {
|
||||
source: 'kuaishou_industry_consume_code',
|
||||
token: params.token,
|
||||
eticketType: params.eticketType,
|
||||
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,
|
||||
consumeTime: params.consumeTime || Date.now(),
|
||||
storeName: params.storeName,
|
||||
storeAddress: params.storeAddress,
|
||||
expressCode: params.expressCode,
|
||||
expressNo: params.expressNo,
|
||||
serialNum: params.seriallNum,
|
||||
skipCallback: true,
|
||||
...(task ? { task } : {}),
|
||||
})
|
||||
|
||||
if (!consumed.ok || !consumed.voucher) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
...contextJson,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
consume: {
|
||||
...(flow.consume || {}),
|
||||
status: 'success',
|
||||
consumedAt: now,
|
||||
consumeDetail,
|
||||
},
|
||||
},
|
||||
consumeDetails: [...existingConsumes, consumeDetail],
|
||||
if (task) {
|
||||
const taskStatus = String(task.task_status || '')
|
||||
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
|
||||
delivery_status: isDispatched ? 'delivered' : task.delivery_status,
|
||||
result_code: params.status,
|
||||
result_message: `电子凭证核销方式: ${params.consumeType}`,
|
||||
redeemed_at: isDispatched ? now : task.redeemed_at,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (updatedTask) {
|
||||
await attachKuaishouIndustryVoucherToTask(updatedTask, consumed.voucher, {
|
||||
source: 'kuaishou_industry_consume_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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++
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-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,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -12,9 +14,9 @@ import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
} from './response.js'
|
||||
import { destroyCallback } from './destroy-callback-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -30,42 +32,39 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
||||
const now = normalizeTimestampIso(new Date().toISOString())
|
||||
const normalizedOid = params.oid
|
||||
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||
const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean))
|
||||
const targetVouchers = targetIds.size > 0
|
||||
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||
: vouchers
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
for (const voucher of targetVouchers) {
|
||||
const status = String(voucher.status || '').trim().toUpperCase()
|
||||
if (status === 'CONSUMED') {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
for (const task of tasks) {
|
||||
await updateTask(task.id, {
|
||||
task_status: 'cancelled',
|
||||
|
||||
const updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
status: 'DESTROYED',
|
||||
destroyedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (voucher.task_id) {
|
||||
const updatedTask = await updateTask(voucher.task_id, {
|
||||
task_status: TASK_STATUS.CLOSED,
|
||||
delivery_status: 'cancelled',
|
||||
result_code: params.reason,
|
||||
result_message: `订单关闭: ${params.reason}`,
|
||||
result_message: `电子凭证销毁: ${params.reason}`,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (updatedTask && updatedVoucher) {
|
||||
await attachKuaishouIndustryVoucherToTask(updatedTask, updatedVoucher, {
|
||||
source: 'kuaishou_industry_destroy_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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,
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -11,9 +11,9 @@ import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
buildIndustryEticketItem,
|
||||
buildIndustryQueryCodeData,
|
||||
} from './response.js'
|
||||
import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -28,25 +28,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
|
||||
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)
|
||||
const matched = await findKuaishouIndustryVoucherByCode(params.eticketId, normalizedOid)
|
||||
if (!matched) {
|
||||
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
|
||||
}
|
||||
|
||||
const eticket = buildEticketFromTask(matched, params.eticketType)
|
||||
const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType)
|
||||
return buildIndustrySuccessResponse(
|
||||
buildIndustryQueryCodeData({
|
||||
oid: normalizedOid,
|
||||
@@ -57,8 +45,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
const etickets = tasks.map((task) =>
|
||||
buildEticketFromTask(task, params.eticketType),
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||
if (vouchers.length === 0) {
|
||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||
}
|
||||
|
||||
const etickets = vouchers.map((voucher) =>
|
||||
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||
)
|
||||
|
||||
return buildIndustrySuccessResponse(
|
||||
@@ -70,46 +63,3 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
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 { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertKuaishouIndustryVoucher } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.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,
|
||||
KUISHOU_INDUSTRY_PLATFORM,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -15,13 +12,15 @@ import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
buildIndustryEticketItem,
|
||||
buildIndustrySendCodeData,
|
||||
} from './response.js'
|
||||
import { sendCallback } from './send-callback-service.js'
|
||||
|
||||
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
|
||||
import {
|
||||
buildKuaishouIndustryEticketFromVoucher,
|
||||
resolveKuaishouIndustryVoucherValidity,
|
||||
} from './voucher-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -35,112 +34,54 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
assertMatchingAppKey(params.appKey)
|
||||
|
||||
const now = normalizeTimestampIso(new Date().toISOString())
|
||||
const nowMs = Date.now()
|
||||
const normalizedOid = params.oid
|
||||
|
||||
let order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
const targetTaskCount = params.num > 0 ? params.num : 1
|
||||
const tasks = order ? await listTasksByOrderId(order.id) : []
|
||||
const validity = resolveKuaishouIndustryVoucherValidity(params, nowMs)
|
||||
const vouchers = []
|
||||
|
||||
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,
|
||||
for (let index = 0; index < targetTaskCount; index += 1) {
|
||||
const unitIndex = index + 1
|
||||
const task = tasks.find((item) => Number(item.unit_index || 0) === unitIndex) || null
|
||||
const voucher = await upsertKuaishouIndustryVoucher({
|
||||
oid: normalizedOid,
|
||||
unitIndex,
|
||||
token: params.token,
|
||||
orderId: order?.id || null,
|
||||
taskId: task?.id || null,
|
||||
status: 'UNUSED',
|
||||
validStartTime: validity.validStartTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
rawPayloadJson: {
|
||||
source: 'kuaishou-industry/send-code',
|
||||
receivedAt: now,
|
||||
body: params,
|
||||
},
|
||||
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,
|
||||
})
|
||||
if (voucher) {
|
||||
vouchers.push(voucher)
|
||||
}
|
||||
}
|
||||
|
||||
const allTasks = await listTasksByOrderId(order.id)
|
||||
const etickets = allTasks.map((task) =>
|
||||
buildEticketFromTask(task, params),
|
||||
if (order) {
|
||||
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
|
||||
source: 'kuaishou_industry_send_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
const etickets = vouchers.map((voucher) =>
|
||||
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||
)
|
||||
|
||||
const response = buildIndustrySuccessResponse(
|
||||
@@ -164,72 +105,6 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
return response
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function fireSendCallback(input: {
|
||||
oid: string
|
||||
sendType: string
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||
import { normalizeKuaishouCloudFlow } from '../../fulfillment/kuaishou-cloud/domain.js'
|
||||
import type {
|
||||
KuaishouIndustryVoucherRow,
|
||||
OrderRow,
|
||||
TaskRow,
|
||||
} from '../../../types/repository/rows.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function bindKuaishouIndustryVouchersToOrderTasks(
|
||||
order: OrderRow | null | undefined,
|
||||
tasks: TaskRow[] = [],
|
||||
options: {
|
||||
source?: string
|
||||
now?: string
|
||||
} = {},
|
||||
) {
|
||||
const oid = String(order?.platform_order_id || '').trim()
|
||||
if (!order || !oid) {
|
||||
return []
|
||||
}
|
||||
|
||||
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
|
||||
const bound: KuaishouIndustryVoucherRow[] = []
|
||||
|
||||
for (const voucher of vouchers) {
|
||||
const task = resolveTaskForVoucher(voucher, tasks)
|
||||
const nextVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
orderId: order.id,
|
||||
...(task ? { taskId: task.id } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
const currentVoucher = nextVoucher || voucher
|
||||
bound.push(currentVoucher)
|
||||
|
||||
if (task) {
|
||||
await attachKuaishouIndustryVoucherToTask(task, currentVoucher, {
|
||||
source: options.source || 'voucher_bind',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bound
|
||||
}
|
||||
|
||||
export async function attachKuaishouIndustryVoucherToTask(
|
||||
task: TaskRow,
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
options: {
|
||||
source?: string
|
||||
now?: string
|
||||
} = {},
|
||||
) {
|
||||
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||
const context = parseTaskContext(task)
|
||||
const nextVoucherContext = buildVoucherContext(voucher, context.kuaishouIndustryVoucher, now)
|
||||
const nextContext: JsonObject = {
|
||||
...context,
|
||||
kuaishouIndustryVoucher: nextVoucherContext,
|
||||
}
|
||||
|
||||
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' || context.kuaishouCloudFulfillment) {
|
||||
const flow = normalizeKuaishouCloudFlow(context.kuaishouCloudFulfillment)
|
||||
const consumedAt = voucher.consumed_at || nextVoucherContext.consumedAt || null
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
|
||||
nextContext.kuaishouCloudFulfillment = {
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: String(voucher.voucher_code || '').trim(),
|
||||
status: status === 'DESTROYED' ? 'destroyed' : 'verified',
|
||||
capturedAt: flow.ticket.capturedAt || now,
|
||||
capturedBy: flow.ticket.capturedBy || {
|
||||
source: options.source || 'kuaishou_industry_voucher',
|
||||
},
|
||||
verifiedAt: flow.ticket.verifiedAt || now,
|
||||
oid: String(voucher.oid || task.platform_order_id || '').trim(),
|
||||
formToken: String(voucher.token || '').trim(),
|
||||
leftCount: status === 'CONSUMED' || status === 'DESTROYED' ? 0 : 1,
|
||||
goodsTitle: flow.ticket.goodsTitle || task.sku_name || task.sku_code || '',
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
status: status === 'CONSUMED' ? 'success' : flow.consume.status,
|
||||
autoConsumeEnabled: true,
|
||||
consumedAt: status === 'CONSUMED' ? consumedAt : flow.consume.consumedAt,
|
||||
errorMessage: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(context) === JSON.stringify(nextContext)) {
|
||||
return task
|
||||
}
|
||||
|
||||
return updateTask(task.id, {
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTaskForVoucher(voucher: KuaishouIndustryVoucherRow, tasks: TaskRow[]) {
|
||||
const unitIndex = Number(voucher.unit_index || 0)
|
||||
if (unitIndex > 0) {
|
||||
const matched = tasks.find((task) => Number(task.unit_index || 0) === unitIndex)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
return tasks[unitIndex - 1] || null
|
||||
}
|
||||
|
||||
function buildVoucherContext(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
existingValue: unknown,
|
||||
now: string,
|
||||
) {
|
||||
const existing = existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? existingValue as JsonObject
|
||||
: {}
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
|
||||
return {
|
||||
...existing,
|
||||
oid: String(voucher.oid || '').trim(),
|
||||
token: String(voucher.token || '').trim(),
|
||||
eticketId: String(voucher.voucher_code || '').trim(),
|
||||
voucherCode: String(voucher.voucher_code || '').trim(),
|
||||
unitIndex: Number(voucher.unit_index || 0) || 0,
|
||||
status,
|
||||
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||
verifiedAt: existing.verifiedAt || now,
|
||||
consumedAt: voucher.consumed_at || existing.consumedAt || null,
|
||||
destroyedAt: voucher.destroyed_at || existing.destroyedAt || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveKuaishouIndustryVoucherValidity } from './voucher-service.js'
|
||||
|
||||
test('resolveKuaishouIndustryVoucherValidity uses certExpDays when explicit times are zero', () => {
|
||||
const nowMs = 1783394218325
|
||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||
certActualStartTime: 0,
|
||||
certActualEndTime: 0,
|
||||
certStartTime: 0,
|
||||
certEndTime: 0,
|
||||
certExpDays: 3,
|
||||
}, nowMs)
|
||||
|
||||
assert.equal(validity.validStartTime, nowMs)
|
||||
assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000)
|
||||
})
|
||||
|
||||
test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => {
|
||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||
certActualStartTime: 1000,
|
||||
certActualEndTime: 5000,
|
||||
certStartTime: 2000,
|
||||
certEndTime: 6000,
|
||||
certExpDays: 3,
|
||||
}, 9000)
|
||||
|
||||
assert.equal(validity.validStartTime, 1000)
|
||||
assert.equal(validity.validEndTime, 5000)
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import {
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByTaskId,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { buildIndustryEticketItem } from './response.js'
|
||||
import { consumeCallback } from './consume-callback-service.js'
|
||||
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3
|
||||
|
||||
export function resolveKuaishouIndustryVoucherValidity(
|
||||
input: {
|
||||
certActualStartTime?: unknown
|
||||
certStartTime?: unknown
|
||||
certActualEndTime?: unknown
|
||||
certEndTime?: unknown
|
||||
certExpDays?: unknown
|
||||
} = {},
|
||||
nowMs = Date.now(),
|
||||
) {
|
||||
const certActualStartTime = normalizePositiveTimestamp(input.certActualStartTime)
|
||||
const certStartTime = normalizePositiveTimestamp(input.certStartTime)
|
||||
const certActualEndTime = normalizePositiveTimestamp(input.certActualEndTime)
|
||||
const certEndTime = normalizePositiveTimestamp(input.certEndTime)
|
||||
const certExpDays = normalizePositiveInteger(
|
||||
input.certExpDays,
|
||||
KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS,
|
||||
)
|
||||
const durationMs = certExpDays * 86_400_000
|
||||
const validStartTime = certActualStartTime || certStartTime || normalizePositiveTimestamp(nowMs)
|
||||
const validEndTime =
|
||||
certActualEndTime ||
|
||||
certEndTime ||
|
||||
(certStartTime ? certStartTime + durationMs : validStartTime + durationMs)
|
||||
|
||||
return {
|
||||
validStartTime,
|
||||
validEndTime,
|
||||
certExpDays,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKuaishouIndustryEticketFromVoucher(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
eticketType = '',
|
||||
) {
|
||||
return buildIndustryEticketItem({
|
||||
id: String(voucher.voucher_code || ''),
|
||||
status: normalizeVoucherStatus(voucher.status),
|
||||
num: 1,
|
||||
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||
eticketType,
|
||||
consumeDetails: resolveVoucherConsumeDetails(voucher),
|
||||
})
|
||||
}
|
||||
|
||||
export async function consumeKuaishouIndustryVouchersForTask(
|
||||
task: TaskRow,
|
||||
input: {
|
||||
source?: string
|
||||
token?: string
|
||||
eticketType?: string
|
||||
consumeType?: string
|
||||
consumeTime?: number
|
||||
storeName?: string
|
||||
storeAddress?: string
|
||||
expressCode?: string
|
||||
expressNo?: string
|
||||
} = {},
|
||||
) {
|
||||
const vouchers = await resolveTaskVouchers(task)
|
||||
const consumed: KuaishouIndustryVoucherRow[] = []
|
||||
const failed: Array<{ voucher: KuaishouIndustryVoucherRow; errorMessage: string }> = []
|
||||
|
||||
for (const voucher of vouchers) {
|
||||
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
|
||||
source: input.source || 'fulfillment_completed',
|
||||
consumeType: input.consumeType || 'delivery',
|
||||
consumeTime: input.consumeTime || Date.now(),
|
||||
task,
|
||||
}
|
||||
if (input.token !== undefined) consumeInput.token = input.token
|
||||
if (input.eticketType !== undefined) consumeInput.eticketType = input.eticketType
|
||||
if (input.storeName !== undefined) consumeInput.storeName = input.storeName
|
||||
if (input.storeAddress !== undefined) consumeInput.storeAddress = input.storeAddress
|
||||
if (input.expressCode !== undefined) consumeInput.expressCode = input.expressCode
|
||||
if (input.expressNo !== undefined) consumeInput.expressNo = input.expressNo
|
||||
|
||||
const result = await consumeKuaishouIndustryVoucher(voucher, consumeInput)
|
||||
|
||||
if (result.ok && result.voucher) {
|
||||
consumed.push(result.voucher)
|
||||
continue
|
||||
}
|
||||
|
||||
failed.push({
|
||||
voucher,
|
||||
errorMessage: result.errorMessage || '电子凭证核销回调失败',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
vouchers,
|
||||
consumed,
|
||||
failed,
|
||||
ok: vouchers.length > 0 && failed.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeKuaishouIndustryVoucher(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
input: {
|
||||
source?: string
|
||||
token?: string
|
||||
eticketType?: string
|
||||
consumeType?: string
|
||||
consumeTime?: number
|
||||
storeName?: string
|
||||
storeAddress?: string
|
||||
expressCode?: string
|
||||
expressNo?: string
|
||||
task?: TaskRow
|
||||
serialNum?: string
|
||||
skipCallback?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const currentStatus = normalizeVoucherStatus(voucher.status)
|
||||
if (currentStatus === 'CONSUMED') {
|
||||
return {
|
||||
ok: true,
|
||||
voucher,
|
||||
callbackSuccess: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStatus === 'DESTROYED') {
|
||||
return {
|
||||
ok: false,
|
||||
voucher,
|
||||
callbackSuccess: false,
|
||||
errorMessage: '电子凭证已销毁,不能核销',
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const consumeTime = Number(input.consumeTime || Date.now()) || Date.now()
|
||||
const consumeType = String(input.consumeType || 'delivery').trim() || 'delivery'
|
||||
const serialNum = String(input.serialNum || voucher.consume_serial_num || `CONSUME-${voucher.voucher_code}`).trim()
|
||||
const token = String(input.token || voucher.token || '').trim()
|
||||
const consumeDetail = {
|
||||
serialNum,
|
||||
consumeType,
|
||||
consumeTime,
|
||||
storeName: input.storeName || undefined,
|
||||
storeAddress: input.storeAddress || undefined,
|
||||
expressCode: input.expressCode || undefined,
|
||||
expressNo: input.expressNo || undefined,
|
||||
source: input.source || 'system',
|
||||
}
|
||||
|
||||
const callbackResult = input.skipCallback
|
||||
? { success: true as const }
|
||||
: await consumeCallback({
|
||||
oid: voucher.oid,
|
||||
etickets: [{
|
||||
id: voucher.voucher_code,
|
||||
num: 1,
|
||||
status: 'CONSUMED',
|
||||
}],
|
||||
status: 'CONSUMED',
|
||||
consumeType,
|
||||
consumeTime,
|
||||
token,
|
||||
seriallNum: serialNum,
|
||||
...(input.storeName ? { storeName: input.storeName } : {}),
|
||||
...(input.storeAddress ? { storeAddress: input.storeAddress } : {}),
|
||||
...(input.expressCode ? { expressCode: input.expressCode } : {}),
|
||||
...(input.expressNo ? { expressNo: input.expressNo } : {}),
|
||||
...(input.eticketType ? { eticketType: input.eticketType } : {}),
|
||||
})
|
||||
|
||||
if (!callbackResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
voucher,
|
||||
callbackSuccess: false,
|
||||
errorMessage: callbackResult.error || '电子凭证核销回调失败',
|
||||
}
|
||||
}
|
||||
|
||||
const nextDetails = appendConsumeDetail(resolveVoucherConsumeDetails(voucher), consumeDetail)
|
||||
const updated = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
status: 'CONSUMED',
|
||||
consumeSerialNum: serialNum,
|
||||
consumeDetailsJson: nextDetails,
|
||||
consumedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (input.task) {
|
||||
await createTaskEvent(
|
||||
input.task.id,
|
||||
'kuaishou_industry_voucher_consumed',
|
||||
{
|
||||
oid: voucher.oid,
|
||||
voucherCode: voucher.voucher_code,
|
||||
serialNum,
|
||||
consumeType,
|
||||
consumeTime,
|
||||
source: input.source || 'system',
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
voucher: updated || voucher,
|
||||
callbackSuccess: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[]> {
|
||||
const taskId = Number(task?.id || 0)
|
||||
if (taskId > 0) {
|
||||
return listKuaishouIndustryVouchersByTaskId(taskId)
|
||||
}
|
||||
|
||||
const context = parseJsonObject(task?.context_json)
|
||||
const voucher = parseJsonObject(context.kuaishouIndustryVoucher)
|
||||
const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim()
|
||||
const oid = String(voucher.oid || task?.platform_order_id || '').trim()
|
||||
if (!voucherCode) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
return findKuaishouIndustryVoucherByCode(voucherCode, oid)
|
||||
.then((row) => row ? [row] : [])
|
||||
.catch((error) => {
|
||||
logWarn('[kuaishou-industry/voucher]', '按上下文查询电子凭证失败', {
|
||||
taskId: task?.id || null,
|
||||
voucherCode,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function resolveVoucherConsumeDetails(voucher: KuaishouIndustryVoucherRow): JsonObject[] {
|
||||
const parsed = parseJsonArray(voucher.consume_details_json)
|
||||
if (parsed.length > 0) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
if (normalizeVoucherStatus(voucher.status) !== 'CONSUMED' || !voucher.consume_serial_num) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
serialNum: voucher.consume_serial_num,
|
||||
consumeType: 'delivery',
|
||||
consumeTime: voucher.consumed_at ? Date.parse(voucher.consumed_at) : 0,
|
||||
}]
|
||||
}
|
||||
|
||||
function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): JsonObject[] {
|
||||
const serialNum = String(nextDetail.serialNum || '').trim()
|
||||
if (serialNum && details.some((item) => String(item.serialNum || '').trim() === serialNum)) {
|
||||
return details
|
||||
}
|
||||
|
||||
return [...details, nextDetail]
|
||||
}
|
||||
|
||||
function normalizeVoucherStatus(value: unknown): string {
|
||||
const normalized = String(value || '').trim().toUpperCase()
|
||||
if (normalized === 'CONSUMED' || normalized === 'DESTROYED') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return 'UNUSED'
|
||||
}
|
||||
|
||||
function normalizePositiveTimestamp(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): JsonObject {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): JsonObject[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is JsonObject =>
|
||||
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '[]'))
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is JsonObject =>
|
||||
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||
)
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } fr
|
||||
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||
import {
|
||||
OPEN_91_PLATFORM,
|
||||
OPEN_91_PROVIDER,
|
||||
@@ -175,6 +176,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
await bindKuaishouIndustryVouchersToOrderTasks(order, [], {
|
||||
source: 'open91_pending_order',
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
order,
|
||||
|
||||
Reference in New Issue
Block a user