接入多发货平台与电子凭证

This commit is contained in:
yml2213
2026-07-07 12:06:19 +08:00
parent c101b5f359
commit ddfa6c278f
35 changed files with 2627 additions and 365 deletions
+8
View File
@@ -57,6 +57,14 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
shopName: "快手行业电子凭证",
version: "1",
},
kuaishouFeifei: {
baseUrl: "http://skin-exchange.yiquyou.icu",
appKey: "",
appSecret: "",
timeoutMs: 10000,
notifyUrl: "",
productRules: [],
},
cloudtentacles: {
baseUrl: "https://123.207.217.176",
timeoutMs: 5000,
@@ -20,6 +20,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
'BACKEND_PORT',
'CADDY_SITE_ADDR',
'CHOKIDAR_USEPOLLING',
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
'NPM_CONFIG_REGISTRY',
'POSTGRES_DB',
'POSTGRES_PASSWORD',
+46
View File
@@ -3,6 +3,7 @@ import process from "node:process";
import type {
AdminDefaultUser,
KuaishouFeifeiProductRule,
RuntimeConfig,
} from "../types/runtime-config.js";
import {
@@ -18,6 +19,7 @@ type RuntimeConfigValue =
| number
| boolean
| AdminDefaultUser[]
| KuaishouFeifeiProductRule[]
| string[];
type RuntimeEnv = Record<string, string | undefined>;
@@ -274,6 +276,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"kuaishouIndustry",
"version",
]),
stringEnv("KUAISHOU_FEIFEI_BASE_URL", [
"platforms",
"kuaishouFeifei",
"baseUrl",
]),
stringEnv("KUAISHOU_FEIFEI_APP_KEY", [
"platforms",
"kuaishouFeifei",
"appKey",
]),
stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [
"platforms",
"kuaishouFeifei",
"appSecret",
]),
integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [
"platforms",
"kuaishouFeifei",
"timeoutMs",
]),
stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [
"platforms",
"kuaishouFeifei",
"notifyUrl",
]),
kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [
"platforms",
"kuaishouFeifei",
"productRules",
]),
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
];
@@ -355,6 +387,20 @@ function adminUsersJsonEnv(
};
}
function kuaishouFeifeiProductRulesEnv(
env: string,
configPath: RuntimeConfigPath
): EnvOverride {
return {
env,
path: configPath,
read(rawValue) {
const parsed = parseJsonArray<KuaishouFeifeiProductRule>(rawValue);
return parsed === null ? null : parsed;
},
};
}
function corsOriginsEnv(
env: string,
configPath: RuntimeConfigPath
@@ -48,6 +48,9 @@ export function validateRuntimeConfig(
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
if (config.platforms?.kuaishouFeifei) {
requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 })
}
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
requireInteger(
issues,
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS kuaishou_industry_vouchers (
id BIGSERIAL PRIMARY KEY,
voucher_code TEXT NOT NULL UNIQUE,
oid TEXT NOT NULL,
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL,
unit_index INTEGER NOT NULL,
token TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'UNUSED',
valid_start_time BIGINT NOT NULL DEFAULT 0,
valid_end_time BIGINT NOT NULL DEFAULT 0,
consume_serial_num TEXT NOT NULL DEFAULT '',
consume_details_json JSONB NOT NULL DEFAULT '[]'::jsonb,
consumed_at TIMESTAMPTZ,
destroyed_at TIMESTAMPTZ,
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(oid, unit_index)
);
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_oid
ON kuaishou_industry_vouchers(oid, unit_index);
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_order_id
ON kuaishou_industry_vouchers(order_id);
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_task_id
ON kuaishou_industry_vouchers(task_id);
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_status
ON kuaishou_industry_vouchers(status);
@@ -0,0 +1,270 @@
import { query } from '../db/client.js'
import type {
KuaishouIndustryVoucherUpdatePatch,
KuaishouIndustryVoucherUpsertInput,
} from '../types/repository/inputs.js'
import type { KuaishouIndustryVoucherRow } from '../types/repository/rows.js'
type VoucherPatchColumn = {
column: string
value: unknown
cast?: string
}
export async function upsertKuaishouIndustryVoucher(
input: KuaishouIndustryVoucherUpsertInput,
): Promise<KuaishouIndustryVoucherRow | null> {
const result = await query<KuaishouIndustryVoucherRow>(
`
WITH next_id AS (
SELECT nextval(pg_get_serial_sequence('kuaishou_industry_vouchers', 'id')) AS id
)
INSERT INTO kuaishou_industry_vouchers (
id,
voucher_code,
oid,
order_id,
task_id,
unit_index,
token,
status,
valid_start_time,
valid_end_time,
consume_serial_num,
consume_details_json,
consumed_at,
destroyed_at,
raw_payload_json,
created_at,
updated_at
)
SELECT
next_id.id,
next_id.id::text,
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10::jsonb,
$11,
$12,
$13::jsonb,
$14,
$15
FROM next_id
ON CONFLICT (oid, unit_index) DO UPDATE
SET
token = CASE
WHEN EXCLUDED.token <> '' THEN EXCLUDED.token
ELSE kuaishou_industry_vouchers.token
END,
order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id),
task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id),
valid_start_time = EXCLUDED.valid_start_time,
valid_end_time = EXCLUDED.valid_end_time,
raw_payload_json = EXCLUDED.raw_payload_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
input.oid,
normalizeNullableId(input.orderId),
normalizeNullableId(input.taskId),
input.unitIndex,
input.token || '',
input.status || 'UNUSED',
input.validStartTime || 0,
input.validEndTime || 0,
input.consumeSerialNum || '',
stringifyJson(input.consumeDetailsJson ?? []),
input.consumedAt || null,
input.destroyedAt || null,
stringifyJson(input.rawPayloadJson ?? {}),
input.createdAt,
input.updatedAt,
],
)
return result.rows[0] || null
}
export async function listKuaishouIndustryVouchersByOid(
oid: string,
): Promise<KuaishouIndustryVoucherRow[]> {
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE oid = $1
ORDER BY unit_index ASC, id ASC
`,
[oid],
)
return result.rows
}
export async function listKuaishouIndustryVouchersByTaskId(
taskId: number | string,
): Promise<KuaishouIndustryVoucherRow[]> {
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE task_id = $1
ORDER BY unit_index ASC, id ASC
`,
[Number(taskId)],
)
return result.rows
}
export async function findKuaishouIndustryVoucherByCode(
voucherCode: string,
oid = '',
): Promise<KuaishouIndustryVoucherRow | null> {
const normalizedCode = String(voucherCode || '').trim()
const normalizedOid = String(oid || '').trim()
if (!normalizedCode) {
return null
}
const params: unknown[] = [normalizedCode]
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
if (normalizedOid) {
params.push(normalizedOid)
}
const result = await query<KuaishouIndustryVoucherRow>(
`
SELECT *
FROM kuaishou_industry_vouchers
WHERE voucher_code = $1
${oidFilter}
LIMIT 1
`,
params,
)
return result.rows[0] || null
}
export async function updateKuaishouIndustryVoucherByCode(
voucherCode: string,
patch: KuaishouIndustryVoucherUpdatePatch,
): Promise<KuaishouIndustryVoucherRow | null> {
const normalizedCode = String(voucherCode || '').trim()
if (!normalizedCode) {
return null
}
const columns = normalizeVoucherPatchColumns(patch)
if (columns.length === 0) {
return findKuaishouIndustryVoucherByCode(normalizedCode)
}
const assignments = columns
.map((item, index) => `${item.column} = $${index + 1}${item.cast || ''}`)
.join(', ')
const params = columns.map((item) => item.value)
params.push(normalizedCode)
const result = await query<KuaishouIndustryVoucherRow>(
`
UPDATE kuaishou_industry_vouchers
SET ${assignments}
WHERE voucher_code = $${params.length}
RETURNING *
`,
params,
)
return result.rows[0] || null
}
function normalizeVoucherPatchColumns(
patch: KuaishouIndustryVoucherUpdatePatch,
): VoucherPatchColumn[] {
const columns: VoucherPatchColumn[] = []
if (patch.orderId !== undefined) {
columns.push({ column: 'order_id', value: normalizeNullableId(patch.orderId) })
}
if (patch.taskId !== undefined) {
columns.push({ column: 'task_id', value: normalizeNullableId(patch.taskId) })
}
if (patch.token !== undefined) {
columns.push({ column: 'token', value: patch.token || '' })
}
if (patch.status !== undefined) {
columns.push({ column: 'status', value: patch.status || 'UNUSED' })
}
if (patch.validStartTime !== undefined) {
columns.push({ column: 'valid_start_time', value: patch.validStartTime || 0 })
}
if (patch.validEndTime !== undefined) {
columns.push({ column: 'valid_end_time', value: patch.validEndTime || 0 })
}
if (patch.consumeSerialNum !== undefined) {
columns.push({ column: 'consume_serial_num', value: patch.consumeSerialNum || '' })
}
if (patch.consumeDetailsJson !== undefined) {
columns.push({
column: 'consume_details_json',
value: stringifyJson(patch.consumeDetailsJson ?? []),
cast: '::jsonb',
})
}
if (patch.consumedAt !== undefined) {
columns.push({ column: 'consumed_at', value: patch.consumedAt || null })
}
if (patch.destroyedAt !== undefined) {
columns.push({ column: 'destroyed_at', value: patch.destroyedAt || null })
}
if (patch.rawPayloadJson !== undefined) {
columns.push({
column: 'raw_payload_json',
value: stringifyJson(patch.rawPayloadJson ?? {}),
cast: '::jsonb',
})
}
if (patch.updatedAt !== undefined) {
columns.push({ column: 'updated_at', value: patch.updatedAt })
}
return columns
}
function normalizeNullableId(value: unknown): number | null {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function stringifyJson(value: unknown): string {
if (typeof value === 'string') {
return value
}
return JSON.stringify(value ?? {})
}
@@ -36,6 +36,15 @@ const CORE_PROFILES: CoreProfile[] = [
inventoryStrategy: 'external_platform',
requirements: [],
},
{
profileKey: 'kuaishou_feifei',
name: 'kuaishou-feifei 履约',
executorKey: 'kuaishou_feifei',
requiresClaim: true,
autoDispatch: false,
inventoryStrategy: 'external_platform',
requirements: [],
},
]
export async function ensureFulfillmentCatalogBootstrapped() {
@@ -87,6 +87,10 @@ export async function getClaimContext(token: unknown) {
}
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
}
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
const displaySkuName = resolveClaimOrderItemDisplaySkuName(
@@ -133,6 +137,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
product,
session: null as null,
kuaishouCloudFulfillment,
kuaishouFeifei: null as null,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
@@ -144,6 +149,93 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
}
}
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
const context = parseTaskContext(task)
const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei)
const product = {
title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
skuCode: String(orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
isBundle: false,
items: [{
cloudSkuId: 0,
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
}],
}
return {
tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token),
flowType: 'kuaishou_feifei',
task: {
taskId: task.id,
taskNo: task.task_no,
status: task.task_status,
executorKey: task.executor_key || '',
requiresSupportReview: false,
expiresAt: claimToken.expired_at,
claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at,
redeemedAt: task.redeemed_at,
loginType: task.login_type,
lastError: task.last_error,
runtimeSessionId: task.runtime_session_id,
},
order: {
orderId: order.id,
platform: order.platform,
platformOrderId: order.platform_order_id,
payStatus: order.pay_status,
orderStatus: order.order_status,
totalAmount: formatFenToAmount(order.total_amount),
totalAmountFen: normalizeFen(order.total_amount),
currency: order.currency,
},
orderItem: {
orderItemId: orderItem.id,
skuCode: orderItem.sku_code,
skuName: product.title,
quantity: orderItem.quantity,
},
product,
session: null as null,
kuaishouCloudFulfillment: null as null,
kuaishouFeifei: flow,
result: task.redeemed_at
? {
resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || ''),
screenshotReady: false,
screenshotUrl: '',
}
: null,
}
}
function mapClaimKuaishouFeifeiFulfillment(value: unknown) {
const source = isPlainObject(value) ? value : {}
const h5 = isPlainObject(source.h5) ? source.h5 : {}
return {
flowType: 'kuaishou_feifei',
productCode: String(source.productCode || '').trim(),
productName: String(source.productName || '').trim(),
platformOrderNo: String(source.platformOrderNo || '').trim(),
orderNo: String(source.orderNo || '').trim(),
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
claimUrl: String(source.claimUrl || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(),
h5: {
entryUrl: String(h5.entryUrl || '').trim(),
rechargeUrl: String(h5.rechargeUrl || '').trim(),
},
lastSyncedAt: source.lastSyncedAt || null,
}
}
function resolveClaimOrderItemDisplaySkuName(
orderItem: OrderItemRow,
kuaishouCloudFulfillment: JsonObject | null,
@@ -34,6 +34,7 @@ import {
rebindKuaishouCloudTaskRole,
refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud/index.js'
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
import type { TaskRow } from '../../types/repository/rows.js'
@@ -42,6 +43,7 @@ const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
type JsonObject = Record<string, any>
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
const context = await getClaimContext(token)
@@ -50,7 +52,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
const executorKey = String(context.task.executor_key || '').trim()
if (executorKey === 'kuaishou-industry') {
return verifyIndustryeVoucherTicket(context, now)
return verifyIndustryVoucherTicket(context, now)
}
if (executorKey !== 'kuaishou_ct_assisted') {
@@ -61,6 +63,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
}
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
if (hasUsableIndustryVoucher(parseTaskContext(context.task))) {
return verifyIndustryVoucherTicket(context, now)
}
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
if (!ticketCode) {
@@ -243,10 +248,10 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
return getKuaishouCloudClaimDetail(token)
}
async function verifyIndustryeVoucherTicket(
async function verifyIndustryVoucherTicket(
context: Awaited<ReturnType<typeof getClaimContext>>,
now: string,
) {
): Promise<ClaimDetailPayload> {
const taskContext = parseTaskContext(context.task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
@@ -255,33 +260,58 @@ async function verifyIndustryeVoucherTicket(
}
const industryContext = typeof taskContext === 'object' ? taskContext : {}
const token = String(industryContext.token || '').trim()
const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher)
if (voucherContext.status === 'DESTROYED') {
throw createHttpError('当前电子凭证已销毁,无法继续领取', {
statusCode: 409,
errorCode: 'claim_kuaishou_industry_voucher_destroyed',
})
}
const token = String(voucherContext.token || industryContext.token || '').trim()
const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim()
const certExpireType = Number(industryContext.certExpireType || 0)
const certActualStartTime = Number(industryContext.certActualStartTime || 0)
const certActualEndTime = Number(industryContext.certActualEndTime || 0)
const certActualStartTime = Number(
voucherContext.validStartTime || industryContext.certActualStartTime || 0,
)
const certActualEndTime = Number(
voucherContext.validEndTime || industryContext.certActualEndTime || 0,
)
const nextContext = {
...taskContext,
kuaishouIndustryVoucher: {
...voucherContext,
oid: voucherContext.oid || context.order.platform_order_id,
token,
eticketId: voucherCode,
voucherCode,
status: voucherContext.status || 'UNUSED',
verifiedAt: voucherContext.verifiedAt || now,
},
kuaishouCloudFulfillment: {
...flow,
ticket: {
...flow.ticket,
code: '',
code: voucherCode,
status: 'verified',
capturedAt: flow.ticket.capturedAt || now,
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
verifiedAt: now,
oid: context.order.platform_order_id,
oid: voucherContext.oid || context.order.platform_order_id,
formToken: token,
leftCount: 0,
leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1,
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
},
consume: {
...flow.consume,
status: 'pending',
status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending',
shopId: context.order.shop_id,
shopName: context.order.shop_name,
autoConsumeEnabled: true,
consumedAt: voucherContext.status === 'CONSUMED'
? voucherContext.consumedAt || flow.consume.consumedAt || now
: flow.consume.consumedAt,
},
certInfo: {
certExpireType,
@@ -353,17 +383,41 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
return filePath
}
export async function getKuaishouCloudClaimDetail(token: unknown) {
export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token)
let task = context.task
const executorKey = String(task.executor_key || '').trim()
if (executorKey === 'kuaishou_feifei') {
task = (await syncKuaishouFeifeiTaskStatus(task)) || task
}
if (executorKey === 'kuaishou-industry') {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
if (flow.consume.status !== 'success') {
const now = nowIso()
await verifyIndustryeVoucherTicket(context, now).catch(() => null)
await verifyIndustryVoucherTicket(context, now).catch(() => null)
}
}
if (executorKey === 'kuaishou_ct_assisted') {
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
const needsIndustryVoucherPrepare =
hasUsableIndustryVoucher(taskContext) &&
(
flow.ticket.status !== 'verified' ||
(flow.binding.prepareStatus !== 'ready' && !flow.binding.bindUrl)
)
if (needsIndustryVoucherPrepare) {
const now = nowIso()
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now)
.catch(() => null)
if (prepared) {
return prepared
}
}
}
@@ -417,6 +471,35 @@ function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject
}
}
function hasUsableIndustryVoucher(context: JsonObject = {}) {
const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher)
if (!voucher.voucherCode && !voucher.eticketId) {
return false
}
return voucher.status !== 'DESTROYED'
}
function normalizeIndustryVoucherContext(value: unknown): JsonObject {
const source = value && typeof value === 'object' && !Array.isArray(value)
? value as JsonObject
: {}
const status = String(source.status || 'UNUSED').trim().toUpperCase()
return {
...source,
oid: String(source.oid || '').trim(),
token: String(source.token || '').trim(),
eticketId: String(source.eticketId || source.voucherCode || '').trim(),
voucherCode: String(source.voucherCode || source.eticketId || '').trim(),
status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED',
validStartTime: Number(source.validStartTime || 0) || 0,
validEndTime: Number(source.validEndTime || 0) || 0,
verifiedAt: source.verifiedAt || null,
consumedAt: source.consumedAt || null,
}
}
async function queryKuaishouEticketConsumeDetailWithShopFallback({
ticketCode,
shopId,
@@ -18,6 +18,7 @@ import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsa
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js";
import {
getKuaishouEticketSourceConfig,
resolveKuaishouEticketShopConfig,
@@ -631,6 +632,15 @@ function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
const voucher = isPlainObject(value.kuaishouIndustryVoucher)
? value.kuaishouIndustryVoucher
: {};
const voucherCode = String(voucher.voucherCode || voucher.eticketId || "").trim();
const status = String(voucher.status || "UNUSED").trim().toUpperCase();
return Boolean(voucherCode && status !== "DESTROYED");
}
export function buildDispatchStockItems(
deliveryItems: DispatchDeliveryItem[],
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
@@ -794,15 +804,46 @@ export async function returnKuaishouCloudFulfillmentTask(
let nextResultCode = "kuaishou_cloud_completed";
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
const consumeAlreadyCompleted = flow.consume.status === "success";
const isIndustryTask = isIndustryEVoucherTask(task)
const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext);
const isIndustryTask = isIndustryEVoucherTask(task);
let industryVoucherContextPatch: JsonObject | null = null;
if (consumeAlreadyCompleted) {
consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now;
} else if (isIndustryTask) {
consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now;
consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销";
} else if (hasIndustryVoucher || isIndustryTask) {
const industryResult = hasIndustryVoucher
? await consumeKuaishouIndustryVouchersForTask(task, {
source: String(options.source || "system_auto_finalize").trim() || "system_auto_finalize",
token: String(taskContext.kuaishouIndustryVoucher?.token || "").trim(),
consumeType: "delivery",
consumeTime: Date.now(),
})
: { ok: true, consumed: [], failed: [] };
if (industryResult.ok) {
consumeStatus = "success";
consumedAt = now;
const consumedVoucher = industryResult.consumed[0] || null;
if (consumedVoucher) {
industryVoucherContextPatch = {
oid: consumedVoucher.oid,
token: consumedVoucher.token,
eticketId: consumedVoucher.voucher_code,
voucherCode: consumedVoucher.voucher_code,
unitIndex: Number(consumedVoucher.unit_index || 0) || 0,
status: "CONSUMED",
validStartTime: Number(consumedVoucher.valid_start_time || 0) || 0,
validEndTime: Number(consumedVoucher.valid_end_time || 0) || 0,
consumedAt: now,
consumeSerialNum: consumedVoucher.consume_serial_num || `CONSUME-${consumedVoucher.voucher_code}`,
};
}
} else {
consumeStatus = "failed";
consumeErrorMessage =
industryResult.failed[0]?.errorMessage || "电子凭证核销回调失败,请人工处理";
}
} else if (!order) {
consumeStatus = "failed";
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
@@ -860,6 +901,16 @@ export async function returnKuaishouCloudFulfillmentTask(
const nextContext = {
...taskContext,
...(industryVoucherContextPatch
? {
kuaishouIndustryVoucher: {
...(isPlainObject(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher
: {}),
...industryVoucherContextPatch,
},
}
: {}),
kuaishouCloudFulfillment: {
...flow,
returnNumber: {
@@ -0,0 +1,256 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { parseTaskContext } from '../../../utils/task-json.js'
import { nowIso } from '../../../utils/time.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
import {
createKuaishouFeifeiOrder,
queryKuaishouFeifeiOrder,
} from '../../platforms/kuaishou-feifei/order-service.js'
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
import type { TaskRow } from '../../../types/repository/rows.js'
type JsonObject = Record<string, any>
export function isKuaishouFeifeiTask(task: Partial<TaskRow> | null | undefined) {
return String(task?.executor_key || '').trim() === 'kuaishou_feifei'
}
export async function prepareKuaishouFeifeiTask(task: TaskRow) {
if (!isKuaishouFeifeiTask(task)) {
throw createHttpError('当前任务不是 kuaishou-feifei 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_feifei_task_invalid',
})
}
const now = nowIso()
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
const claimLinkState = await ensureTaskClaimLink(task)
if (flow.orderNo && flow.h5.rechargeUrl) {
const nextContext = {
...taskContext,
kuaishouFeifei: {
...flow,
claimUrl: claimLinkState.claimUrl,
},
}
return updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED,
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
user_action_status: 'pending_claim',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
}
if (!flow.productCode) {
throw createHttpError('kuaishou-feifei 商品编码缺失,请检查商品规则配置', {
statusCode: 409,
errorCode: 'kuaishou_feifei_product_code_missing',
})
}
const platformOrderNo = flow.platformOrderNo || buildFeifeiPlatformOrderNo(task)
const order = await createKuaishouFeifeiOrder({
platformOrderNo,
productCode: flow.productCode,
platformBuyNum: 1,
})
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
platformOrderNo,
claimUrl: claimLinkState.claimUrl,
syncedAt: now,
})
const updatedTask = await updateTask(task.id, {
task_status: TASK_STATUS.LINK_GENERATED,
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
user_action_status: 'pending_claim',
result_code: 'kuaishou_feifei_order_created',
result_message: order.rechargeStatusLabel || 'kuaishou-feifei 订单已创建',
context_json: JSON.stringify({
...taskContext,
kuaishouFeifei: nextFlow,
}),
updated_at: now,
})
await createTaskEvent(
task.id,
'kuaishou_feifei_order_created',
{
platformOrderNo,
orderNo: order.orderNo,
productCode: flow.productCode,
rechargeStatus: order.rechargeStatus,
rechargeStatusLabel: order.rechargeStatusLabel,
rechargeUrl: order.h5.rechargeUrl,
},
now,
)
return updatedTask
}
export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
if (!isKuaishouFeifeiTask(task)) {
return task
}
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
if (!flow.platformOrderNo && !flow.orderNo) {
return task
}
const now = nowIso()
const order = await queryKuaishouFeifeiOrder({
platformOrderNo: flow.platformOrderNo,
orderNo: flow.orderNo,
})
let nextTaskStatus = task.task_status
let deliveryStatus = task.delivery_status
let redeemedAt = task.redeemed_at
let resultCode = task.result_code
let resultMessage = task.result_message
let lastError = task.last_error
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
syncedAt: now,
claimUrl: flow.claimUrl,
})
if (order.rechargeStatus === 30) {
const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, {
source: 'kuaishou_feifei_completed',
consumeType: 'delivery',
consumeTime: Date.now(),
})
if (consumeResult.ok || consumeResult.vouchers.length === 0) {
nextTaskStatus = TASK_STATUS.COMPLETED
deliveryStatus = 'delivered'
redeemedAt = redeemedAt || now
resultCode = 'kuaishou_feifei_completed'
resultMessage = order.rechargeStatusLabel || 'kuaishou-feifei 履约成功'
lastError = ''
nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required'
} else {
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
resultCode = 'kuaishou_feifei_industry_consume_failed'
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
lastError = resultMessage
nextFlow.consumeStatus = 'failed'
}
} else if ([40, 50, 60].includes(order.rechargeStatus)) {
nextTaskStatus = order.rechargeStatus === 60 ? TASK_STATUS.CLOSED : TASK_STATUS.MANUAL_REVIEW
resultCode = `kuaishou_feifei_status_${order.rechargeStatus}`
resultMessage = order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常'
lastError = resultMessage
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
delivery_status: deliveryStatus,
redeemed_at: redeemedAt,
result_code: resultCode,
result_message: resultMessage,
last_error: lastError,
context_json: JSON.stringify({
...taskContext,
kuaishouFeifei: nextFlow,
}),
updated_at: now,
})
return updatedTask || task
}
export function normalizeKuaishouFeifeiFlow(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 {
flowType: 'kuaishou_feifei',
productCode: String(source.productCode || '').trim(),
productName: String(source.productName || '').trim(),
platformOrderNo: String(source.platformOrderNo || '').trim(),
orderNo: String(source.orderNo || '').trim(),
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
claimUrl: String(source.claimUrl || '').trim(),
consumeStatus: String(source.consumeStatus || 'pending').trim(),
h5: {
entryUrl: String(h5.entryUrl || '').trim(),
rechargeUrl: String(h5.rechargeUrl || '').trim(),
},
lastSyncedAt: source.lastSyncedAt || null,
raw: source.raw && typeof source.raw === 'object' ? source.raw : null,
}
}
function mergeKuaishouFeifeiOrder(
flow: ReturnType<typeof normalizeKuaishouFeifeiFlow>,
order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>,
patch: {
platformOrderNo?: string
claimUrl?: string
syncedAt: string
},
) {
return {
...flow,
productCode: order.productCode || flow.productCode,
productName: order.productName || flow.productName,
platformOrderNo: order.platformOrderNo || patch.platformOrderNo || flow.platformOrderNo,
orderNo: order.orderNo || flow.orderNo,
rechargeStatus: order.rechargeStatus,
rechargeStatusLabel: order.rechargeStatusLabel,
rechargeResultMessage: order.rechargeResultMessage,
claimUrl: patch.claimUrl || flow.claimUrl,
h5: {
entryUrl: order.h5.entryUrl || flow.h5.entryUrl,
rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl,
},
lastSyncedAt: patch.syncedAt,
raw: order.raw,
}
}
async function ensureTaskClaimLink(task: TaskRow) {
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
const expiredAt = task?.primary_claim_expires_at || task?.claim_expires_at || null
if (tokenStatus === 'active' && token && expiredAt && new Date(expiredAt).getTime() > Date.now()) {
return {
token,
expiredAt,
claimUrl: buildClaimUrl(token),
}
}
const claimToken = await createTaskClaimToken(task.id)
return {
token: claimToken.token,
expiredAt: claimToken.expired_at,
claimUrl: claimToken.claimUrl,
}
}
function buildFeifeiPlatformOrderNo(task: TaskRow) {
const taskNo = String(task.task_no || '').trim()
if (taskNo && taskNo.length <= 64) {
return taskNo
}
return `OS-FEIFEI-${task.id}`
}
@@ -2,6 +2,7 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/t
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
import { createTaskClaimToken } from '../claim/claim-service.js'
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import {
@@ -120,7 +121,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const tasks: DeliveryTaskRow[] = []
for (const item of orderItems) {
const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey)
if (!profile) {
continue
@@ -129,6 +130,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const quantity = Math.max(1, Number(item.quantity || 1))
const fulfillmentConfig = parseJsonObject(profile.config_json)
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
@@ -263,6 +265,25 @@ export async function syncDeliveryTasksForOrderWithDeps(
notes: String(fulfillmentConfig.notes || '').trim(),
}
: null,
kuaishouFeifei: isKuaishouFeifeiExecutor(profile.executor_key)
? {
flowType: 'kuaishou_feifei',
productCode: String(kuaishouFeifeiConfig.productCode || '').trim(),
productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(),
platformOrderNo: '',
orderNo: '',
rechargeStatus: 0,
rechargeStatusLabel: '',
rechargeResultMessage: '',
claimUrl: '',
consumeStatus: 'pending',
h5: {
entryUrl: '',
rechargeUrl: '',
},
lastSyncedAt: null,
}
: null,
}),
createdAt,
updatedAt: createdAt,
@@ -330,6 +351,28 @@ async function preparePaidTask(
})
}
if (isKuaishouFeifeiExecutor(task.executor_key)) {
try {
return await prepareKuaishouFeifeiTask(task as TaskRow)
} catch (error) {
const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败'
const updatedTask = await updateDeliveryTask(task.id, {
task_status: TASK_STATUS.MANUAL_REVIEW,
user_action_status: 'not_required',
last_error: message,
result_code: 'kuaishou_feifei_prepare_failed',
result_message: message,
updated_at: now,
})
await notifyManualReview({
task: updatedTask || task,
reason: message,
source: 'kuaishou_feifei_prepare_failed',
})
return updatedTask
}
}
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
const lastError = task.last_error || '当前任务需要人工履约处理'
const updatedTask = await updateDeliveryTask(task.id, {
@@ -462,6 +505,53 @@ async function resolveDynamicCloudtentaclesProfile(
}
}
async function resolveDynamicFulfillmentProfile(
item: OrderItemRow,
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
): Promise<FulfillmentBindingLike | null> {
return (
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
)
}
async function resolveDynamicKuaishouFeifeiProfile(
item: OrderItemRow,
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
): Promise<FulfillmentBindingLike | null> {
const snapshot = parseJsonObject(item.item_snapshot_json)
const feifei = parseJsonObject(snapshot.kuaishouFeifei)
const productCode = String(feifei.productCode || '').trim()
if (!productCode) {
return null
}
const profile = await getProfileByKey('kuaishou_feifei')
if (!profile) {
return null
}
return {
...profile,
profile_id: Number(profile.id || profile.profile_id || 0),
profile_key: 'kuaishou_feifei',
profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(),
executor_key: 'kuaishou_feifei',
requires_claim: true,
auto_dispatch: false,
config_json: {
flowType: 'kuaishou_feifei',
configId: `kuaishou_feifei:${productCode}`,
kuaishouFeifei: {
productCode,
productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(),
matchMode: String(feifei.matchMode || 'kuaishou_feifei_rule').trim(),
},
notes: '91卡券商品名命中 kuaishou-feifei 商品规则',
},
}
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
@@ -542,3 +632,7 @@ function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is
function isKuaishouCloudExecutor(value: unknown): boolean {
return String(value || '').trim() === 'kuaishou_ct_assisted'
}
function isKuaishouFeifeiExecutor(value: unknown): boolean {
return String(value || '').trim() === 'kuaishou_feifei'
}
@@ -6,6 +6,7 @@ import {
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { resolveOrderItemForFulfillment } from './product-match-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js'
import { nowIso } from '../../utils/time.js'
import { logIntegration } from '../../utils/logger.js'
import { createHttpError } from '../../utils/http.js'
@@ -184,6 +185,10 @@ export async function upsertOrderFromSource(
)
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
source: `${sourceLabel}_order_upsert`,
now,
})
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
orderId: order.id,
@@ -3,6 +3,10 @@ import {
resolveCloudtentaclesSkuByProductName,
type CloudtentaclesNameMatchResult,
} from './cloudtentacles-name-match-service.js'
import {
resolveKuaishouFeifeiProductByName,
type KuaishouFeifeiProductMatch,
} from '../platforms/kuaishou-feifei/product-rule-service.js'
export type FulfillmentItem = {
itemId?: string
@@ -34,6 +38,7 @@ type FulfillmentItemCandidate = {
externalSkuNameNormalized: string
resolvedSkuCode: string
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
isConfigured: boolean
}
@@ -64,15 +69,18 @@ export async function resolveOrderItemForFulfillment({
externalSkuName,
externalSkuNameNormalized,
cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
} = candidate
const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
externalSkuCode,
externalItemId,
])
const resolvedSkuName = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.skuName,
item.skuName,
externalSkuName,
resolvedSkuCode,
@@ -100,6 +108,15 @@ export async function resolveOrderItemForFulfillment({
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
}
: null,
kuaishouFeifei: kuaishouFeifeiMatch
? {
matchMode: kuaishouFeifeiMatch.matchMode,
productName: kuaishouFeifeiMatch.productName,
normalizedProductName: kuaishouFeifeiMatch.normalizedProductName,
productCode: kuaishouFeifeiMatch.productCode,
skuName: kuaishouFeifeiMatch.skuName,
}
: null,
isConfigured: candidate.isConfigured,
}
@@ -170,8 +187,12 @@ async function resolveConfiguredItemCandidate({
if (isOpen91KuaishouOrder(provider, platform)) {
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
? null
: resolveKuaishouFeifeiProductByName(externalSkuName)
const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
externalSkuCode,
externalItemId,
])
@@ -183,7 +204,8 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized,
resolvedSkuCode,
cloudtentaclesNameMatch,
isConfigured: Boolean(cloudtentaclesNameMatch),
kuaishouFeifeiMatch,
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
}
}
@@ -199,6 +221,7 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized,
resolvedSkuCode,
cloudtentaclesNameMatch: null,
kuaishouFeifeiMatch: null,
isConfigured: false,
}
}
@@ -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,
@@ -127,3 +127,25 @@ export type TaskUpdatePatch = {
artifacts_json?: string | Record<string, unknown>
state_json?: string | Record<string, unknown>
}
export type KuaishouIndustryVoucherUpsertInput = {
oid: string
unitIndex: number
token?: string
orderId?: number | string | null
taskId?: number | string | null
status?: string
validStartTime?: number
validEndTime?: number
consumeSerialNum?: string
consumeDetailsJson?: string | Record<string, unknown> | unknown[]
consumedAt?: string | null
destroyedAt?: string | null
rawPayloadJson?: string | Record<string, unknown>
createdAt: string
updatedAt: string
}
export type KuaishouIndustryVoucherUpdatePatch = Partial<
Omit<KuaishouIndustryVoucherUpsertInput, 'oid' | 'unitIndex' | 'createdAt'>
>
+21
View File
@@ -38,6 +38,7 @@ export type TaskRow = {
id: number
order_id: number
order_item_id: number
unit_index: number
platform_order_id: string
profile_id: number
task_no: string
@@ -103,6 +104,26 @@ export type TaskEventRow = {
created_at: string
}
export type KuaishouIndustryVoucherRow = {
id: number
voucher_code: string
oid: string
order_id: number | null
task_id: number | null
unit_index: number
token: string
status: string
valid_start_time: number | string
valid_end_time: number | string
consume_serial_num: string
consume_details_json: string | Record<string, unknown> | unknown[]
consumed_at: string | null
destroyed_at: string | null
raw_payload_json: string | Record<string, unknown>
created_at: string
updated_at: string
}
export type OrderListQueryResult = {
items: OrderListRow[]
total: number
+15
View File
@@ -5,6 +5,13 @@ export type AdminDefaultUser = {
status?: string;
};
export type KuaishouFeifeiProductRule = {
productName: string;
productCode: string;
skuName?: string;
enabled?: boolean;
};
export type RuntimeConfig = {
server: {
port: number;
@@ -90,6 +97,14 @@ export type RuntimeConfig = {
shopName: string;
version: string;
};
kuaishouFeifei: {
baseUrl: string;
appKey: string;
appSecret: string;
timeoutMs: number;
notifyUrl: string;
productRules: KuaishouFeifeiProductRule[];
};
};
cors: {
allowedOrigins: string[];