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

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: "快手行业电子凭证", shopName: "快手行业电子凭证",
version: "1", version: "1",
}, },
kuaishouFeifei: {
baseUrl: "http://skin-exchange.yiquyou.icu",
appKey: "",
appSecret: "",
timeoutMs: 10000,
notifyUrl: "",
productRules: [],
},
cloudtentacles: { cloudtentacles: {
baseUrl: "https://123.207.217.176", baseUrl: "https://123.207.217.176",
timeoutMs: 5000, timeoutMs: 5000,
@@ -20,6 +20,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
'BACKEND_PORT', 'BACKEND_PORT',
'CADDY_SITE_ADDR', 'CADDY_SITE_ADDR',
'CHOKIDAR_USEPOLLING', 'CHOKIDAR_USEPOLLING',
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
'NPM_CONFIG_REGISTRY', 'NPM_CONFIG_REGISTRY',
'POSTGRES_DB', 'POSTGRES_DB',
'POSTGRES_PASSWORD', 'POSTGRES_PASSWORD',
+46
View File
@@ -3,6 +3,7 @@ import process from "node:process";
import type { import type {
AdminDefaultUser, AdminDefaultUser,
KuaishouFeifeiProductRule,
RuntimeConfig, RuntimeConfig,
} from "../types/runtime-config.js"; } from "../types/runtime-config.js";
import { import {
@@ -18,6 +19,7 @@ type RuntimeConfigValue =
| number | number
| boolean | boolean
| AdminDefaultUser[] | AdminDefaultUser[]
| KuaishouFeifeiProductRule[]
| string[]; | string[];
type RuntimeEnv = Record<string, string | undefined>; type RuntimeEnv = Record<string, string | undefined>;
@@ -274,6 +276,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"kuaishouIndustry", "kuaishouIndustry",
"version", "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"]), 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( function corsOriginsEnv(
env: string, env: string,
configPath: RuntimeConfigPath configPath: RuntimeConfigPath
@@ -48,6 +48,9 @@ export function validateRuntimeConfig(
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { 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, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
requireInteger( requireInteger(
issues, 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', inventoryStrategy: 'external_platform',
requirements: [], requirements: [],
}, },
{
profileKey: 'kuaishou_feifei',
name: 'kuaishou-feifei 履约',
executorKey: 'kuaishou_feifei',
requiresClaim: true,
autoDispatch: false,
inventoryStrategy: 'external_platform',
requirements: [],
},
] ]
export async function ensureFulfillmentCatalogBootstrapped() { export async function ensureFulfillmentCatalogBootstrapped() {
@@ -87,6 +87,10 @@ export async function getClaimContext(token: unknown) {
} }
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { 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 kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
const displaySkuName = resolveClaimOrderItemDisplaySkuName( const displaySkuName = resolveClaimOrderItemDisplaySkuName(
@@ -133,6 +137,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
product, product,
session: null as null, session: null as null,
kuaishouCloudFulfillment, kuaishouCloudFulfillment,
kuaishouFeifei: null as null,
result: task.redeemed_at result: task.redeemed_at
? { ? {
resultCode: String(task.result_code || ''), 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( function resolveClaimOrderItemDisplaySkuName(
orderItem: OrderItemRow, orderItem: OrderItemRow,
kuaishouCloudFulfillment: JsonObject | null, kuaishouCloudFulfillment: JsonObject | null,
@@ -34,6 +34,7 @@ import {
rebindKuaishouCloudTaskRole, rebindKuaishouCloudTaskRole,
refreshKuaishouCloudTaskRoleInfo, refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud/index.js' } from '../fulfillment/kuaishou-cloud/index.js'
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js'
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
import type { TaskRow } from '../../types/repository/rows.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']) const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) { export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
const context = await getClaimContext(token) 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() const executorKey = String(context.task.executor_key || '').trim()
if (executorKey === 'kuaishou-industry') { if (executorKey === 'kuaishou-industry') {
return verifyIndustryeVoucherTicket(context, now) return verifyIndustryVoucherTicket(context, now)
} }
if (executorKey !== 'kuaishou_ct_assisted') { if (executorKey !== 'kuaishou_ct_assisted') {
@@ -61,6 +63,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
} }
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment) const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
if (hasUsableIndustryVoucher(parseTaskContext(context.task))) {
return verifyIndustryVoucherTicket(context, now)
}
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim() const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
if (!ticketCode) { if (!ticketCode) {
@@ -243,10 +248,10 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
return getKuaishouCloudClaimDetail(token) return getKuaishouCloudClaimDetail(token)
} }
async function verifyIndustryeVoucherTicket( async function verifyIndustryVoucherTicket(
context: Awaited<ReturnType<typeof getClaimContext>>, context: Awaited<ReturnType<typeof getClaimContext>>,
now: string, now: string,
) { ): Promise<ClaimDetailPayload> {
const taskContext = parseTaskContext(context.task) const taskContext = parseTaskContext(context.task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
@@ -255,33 +260,58 @@ async function verifyIndustryeVoucherTicket(
} }
const industryContext = typeof taskContext === 'object' ? taskContext : {} 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 certExpireType = Number(industryContext.certExpireType || 0)
const certActualStartTime = Number(industryContext.certActualStartTime || 0) const certActualStartTime = Number(
const certActualEndTime = Number(industryContext.certActualEndTime || 0) voucherContext.validStartTime || industryContext.certActualStartTime || 0,
)
const certActualEndTime = Number(
voucherContext.validEndTime || industryContext.certActualEndTime || 0,
)
const nextContext = { const nextContext = {
...taskContext, ...taskContext,
kuaishouIndustryVoucher: {
...voucherContext,
oid: voucherContext.oid || context.order.platform_order_id,
token,
eticketId: voucherCode,
voucherCode,
status: voucherContext.status || 'UNUSED',
verifiedAt: voucherContext.verifiedAt || now,
},
kuaishouCloudFulfillment: { kuaishouCloudFulfillment: {
...flow, ...flow,
ticket: { ticket: {
...flow.ticket, ...flow.ticket,
code: '', code: voucherCode,
status: 'verified', status: 'verified',
capturedAt: flow.ticket.capturedAt || now, capturedAt: flow.ticket.capturedAt || now,
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' }, capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
verifiedAt: now, verifiedAt: now,
oid: context.order.platform_order_id, oid: voucherContext.oid || context.order.platform_order_id,
formToken: token, formToken: token,
leftCount: 0, leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1,
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '', goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
}, },
consume: { consume: {
...flow.consume, ...flow.consume,
status: 'pending', status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending',
shopId: context.order.shop_id, shopId: context.order.shop_id,
shopName: context.order.shop_name, shopName: context.order.shop_name,
autoConsumeEnabled: true, autoConsumeEnabled: true,
consumedAt: voucherContext.status === 'CONSUMED'
? voucherContext.consumedAt || flow.consume.consumedAt || now
: flow.consume.consumedAt,
}, },
certInfo: { certInfo: {
certExpireType, certExpireType,
@@ -353,17 +383,41 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
return filePath return filePath
} }
export async function getKuaishouCloudClaimDetail(token: unknown) { export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
const context = await getClaimContext(token) const context = await getClaimContext(token)
let task = context.task let task = context.task
const executorKey = String(task.executor_key || '').trim() const executorKey = String(task.executor_key || '').trim()
if (executorKey === 'kuaishou_feifei') {
task = (await syncKuaishouFeifeiTaskStatus(task)) || task
}
if (executorKey === 'kuaishou-industry') { if (executorKey === 'kuaishou-industry') {
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
if (flow.consume.status !== 'success') { if (flow.consume.status !== 'success') {
const now = nowIso() 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({ async function queryKuaishouEticketConsumeDetailWithShopFallback({
ticketCode, ticketCode,
shopId, shopId,
@@ -18,6 +18,7 @@ import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsa
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js"; import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js"; import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js";
import { import {
getKuaishouEticketSourceConfig, getKuaishouEticketSourceConfig,
resolveKuaishouEticketShopConfig, resolveKuaishouEticketShopConfig,
@@ -631,6 +632,15 @@ function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); 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( export function buildDispatchStockItems(
deliveryItems: DispatchDeliveryItem[], deliveryItems: DispatchDeliveryItem[],
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {} { skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
@@ -794,15 +804,46 @@ export async function returnKuaishouCloudFulfillmentTask(
let nextResultCode = "kuaishou_cloud_completed"; let nextResultCode = "kuaishou_cloud_completed";
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销"; let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
const consumeAlreadyCompleted = flow.consume.status === "success"; 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) { if (consumeAlreadyCompleted) {
consumeStatus = "success"; consumeStatus = "success";
consumedAt = flow.consume.consumedAt || now; consumedAt = flow.consume.consumedAt || now;
} else if (isIndustryTask) { } else if (hasIndustryVoucher || isIndustryTask) {
consumeStatus = "success"; const industryResult = hasIndustryVoucher
consumedAt = flow.consume.consumedAt || now; ? await consumeKuaishouIndustryVouchersForTask(task, {
consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销"; 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) { } else if (!order) {
consumeStatus = "failed"; consumeStatus = "failed";
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销"; consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
@@ -860,6 +901,16 @@ export async function returnKuaishouCloudFulfillmentTask(
const nextContext = { const nextContext = {
...taskContext, ...taskContext,
...(industryVoucherContextPatch
? {
kuaishouIndustryVoucher: {
...(isPlainObject(taskContext.kuaishouIndustryVoucher)
? taskContext.kuaishouIndustryVoucher
: {}),
...industryVoucherContextPatch,
},
}
: {}),
kuaishouCloudFulfillment: { kuaishouCloudFulfillment: {
...flow, ...flow,
returnNumber: { 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 { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
import { createTaskClaimToken } from '../claim/claim-service.js' import { createTaskClaimToken } from '../claim/claim-service.js'
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js'
import { nowIso } from '../../utils/time.js' import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js' import { randomId } from '../../utils/random.js'
import { import {
@@ -120,7 +121,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const tasks: DeliveryTaskRow[] = [] const tasks: DeliveryTaskRow[] = []
for (const item of orderItems) { for (const item of orderItems) {
const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey)
if (!profile) { if (!profile) {
continue continue
@@ -129,6 +130,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const quantity = Math.max(1, Number(item.quantity || 1)) const quantity = Math.max(1, Number(item.quantity || 1))
const fulfillmentConfig = parseJsonObject(profile.config_json) const fulfillmentConfig = parseJsonObject(profile.config_json)
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
@@ -263,6 +265,25 @@ export async function syncDeliveryTasksForOrderWithDeps(
notes: String(fulfillmentConfig.notes || '').trim(), notes: String(fulfillmentConfig.notes || '').trim(),
} }
: null, : 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, createdAt,
updatedAt: 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') { if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
const lastError = task.last_error || '当前任务需要人工履约处理' const lastError = task.last_error || '当前任务需要人工履约处理'
const updatedTask = await updateDeliveryTask(task.id, { 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[] { function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return [] return []
@@ -542,3 +632,7 @@ function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is
function isKuaishouCloudExecutor(value: unknown): boolean { function isKuaishouCloudExecutor(value: unknown): boolean {
return String(value || '').trim() === 'kuaishou_ct_assisted' 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 { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { resolveOrderItemForFulfillment } from './product-match-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 { nowIso } from '../../utils/time.js'
import { logIntegration } from '../../utils/logger.js' import { logIntegration } from '../../utils/logger.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
@@ -184,6 +185,10 @@ export async function upsertOrderFromSource(
) )
const tasks = await syncDeliveryTasksForOrder(order, orderItems) const tasks = await syncDeliveryTasksForOrder(order, orderItems)
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
source: `${sourceLabel}_order_upsert`,
now,
})
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, { logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
orderId: order.id, orderId: order.id,
@@ -3,6 +3,10 @@ import {
resolveCloudtentaclesSkuByProductName, resolveCloudtentaclesSkuByProductName,
type CloudtentaclesNameMatchResult, type CloudtentaclesNameMatchResult,
} from './cloudtentacles-name-match-service.js' } from './cloudtentacles-name-match-service.js'
import {
resolveKuaishouFeifeiProductByName,
type KuaishouFeifeiProductMatch,
} from '../platforms/kuaishou-feifei/product-rule-service.js'
export type FulfillmentItem = { export type FulfillmentItem = {
itemId?: string itemId?: string
@@ -34,6 +38,7 @@ type FulfillmentItemCandidate = {
externalSkuNameNormalized: string externalSkuNameNormalized: string
resolvedSkuCode: string resolvedSkuCode: string
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
isConfigured: boolean isConfigured: boolean
} }
@@ -64,15 +69,18 @@ export async function resolveOrderItemForFulfillment({
externalSkuName, externalSkuName,
externalSkuNameNormalized, externalSkuNameNormalized,
cloudtentaclesNameMatch, cloudtentaclesNameMatch,
kuaishouFeifeiMatch,
} = candidate } = candidate
const resolvedSkuCode = pickFirstNonEmpty([ const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName, cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
externalSkuCode, externalSkuCode,
externalItemId, externalItemId,
]) ])
const resolvedSkuName = pickFirstNonEmpty([ const resolvedSkuName = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName, cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.skuName,
item.skuName, item.skuName,
externalSkuName, externalSkuName,
resolvedSkuCode, resolvedSkuCode,
@@ -100,6 +108,15 @@ export async function resolveOrderItemForFulfillment({
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
} }
: null, : null,
kuaishouFeifei: kuaishouFeifeiMatch
? {
matchMode: kuaishouFeifeiMatch.matchMode,
productName: kuaishouFeifeiMatch.productName,
normalizedProductName: kuaishouFeifeiMatch.normalizedProductName,
productCode: kuaishouFeifeiMatch.productCode,
skuName: kuaishouFeifeiMatch.skuName,
}
: null,
isConfigured: candidate.isConfigured, isConfigured: candidate.isConfigured,
} }
@@ -170,8 +187,12 @@ async function resolveConfiguredItemCandidate({
if (isOpen91KuaishouOrder(provider, platform)) { if (isOpen91KuaishouOrder(provider, platform)) {
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
? null
: resolveKuaishouFeifeiProductByName(externalSkuName)
const resolvedSkuCode = pickFirstNonEmpty([ const resolvedSkuCode = pickFirstNonEmpty([
cloudtentaclesNameMatch?.cloudSkuName, cloudtentaclesNameMatch?.cloudSkuName,
kuaishouFeifeiMatch?.productCode,
externalSkuCode, externalSkuCode,
externalItemId, externalItemId,
]) ])
@@ -183,7 +204,8 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized, externalSkuNameNormalized,
resolvedSkuCode, resolvedSkuCode,
cloudtentaclesNameMatch, cloudtentaclesNameMatch,
isConfigured: Boolean(cloudtentaclesNameMatch), kuaishouFeifeiMatch,
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
} }
} }
@@ -199,6 +221,7 @@ async function resolveConfiguredItemCandidate({
externalSkuNameNormalized, externalSkuNameNormalized,
resolvedSkuCode, resolvedSkuCode,
cloudtentaclesNameMatch: null, cloudtentaclesNameMatch: null,
kuaishouFeifeiMatch: null,
isConfigured: false, 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 { getTaskById, updateTask } from '../../../repositories/task-repo.js'
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js' import { findKuaishouIndustryVoucherByCode } from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js' import { normalizeTimestampIso } from '../../../utils/time.js'
import { logWarn } from '../../../utils/logger.js' import { logWarn } from '../../../utils/logger.js'
import { TASK_STATUS } from '../../../domain/task-status.js' import { TASK_STATUS } from '../../../domain/task-status.js'
import { import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig, getKuaishouIndustryConfig,
assertMatchingAppKey, assertMatchingAppKey,
} from './config.js' } from './config.js'
@@ -15,6 +14,8 @@ import {
buildIndustryErrorResponse, buildIndustryErrorResponse,
} from './response.js' } from './response.js'
import { consumeCallback } from './consume-callback-service.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> type JsonObject = Record<string, any>
@@ -30,87 +31,54 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
const now = normalizeTimestampIso(new Date().toISOString()) const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid 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 let consumedCount = 0
for (const task of tasks) { for (const eticket of params.etickets) {
const taskId = String(task.id || '') const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid)
if (!matchedIds.has(taskId)) { if (!voucher) {
continue continue
} }
let contextJson: JsonObject = {} const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
try { const consumed = await consumeKuaishouIndustryVoucher(voucher, {
contextJson = typeof task.context_json === 'string' source: 'kuaishou_industry_consume_code',
? JSON.parse(task.context_json) token: params.token,
: (task.context_json || {}) eticketType: params.eticketType,
} catch {
contextJson = {}
}
const flow = contextJson.kuaishouCloudFulfillment || {}
const existingConsumes = Array.isArray(flow.consumeDetails)
? contextJson.consumeDetails as JsonObject[]
: Array.isArray(contextJson.consumeDetails)
? contextJson.consumeDetails as JsonObject[]
: []
const consumeDetail = {
serialNum: params.seriallNum,
consumeType: params.consumeType, consumeType: params.consumeType,
consumeTime: params.consumeTime, consumeTime: params.consumeTime || Date.now(),
appointmentTime: params.appointmentTime || undefined, storeName: params.storeName,
storeName: params.storeName || undefined, storeAddress: params.storeAddress,
storeAddress: params.storeAddress || undefined, expressCode: params.expressCode,
expressCode: params.expressCode || undefined, expressNo: params.expressNo,
expressNo: params.expressNo || undefined, serialNum: params.seriallNum,
consumePoiId: params.consumePoiId || undefined, skipCallback: true,
eticketType: params.eticketType || undefined, ...(task ? { task } : {}),
ext: params.ext || undefined, })
if (!consumed.ok || !consumed.voucher) {
continue
} }
const nextContext = { if (task) {
...contextJson, const taskStatus = String(task.task_status || '')
kuaishouCloudFulfillment: { const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
...flow, const updatedTask = await updateTask(task.id, {
consume: { task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
...(flow.consume || {}), delivery_status: isDispatched ? 'delivered' : task.delivery_status,
status: 'success', result_code: params.status,
consumedAt: now, result_message: `电子凭证核销方式: ${params.consumeType}`,
consumeDetail, redeemed_at: isDispatched ? now : task.redeemed_at,
}, updated_at: now,
}, })
consumeDetails: [...existingConsumes, consumeDetail],
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++ 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 { updateTask } from '../../../repositories/task-repo.js'
import {
listKuaishouIndustryVouchersByOid,
updateKuaishouIndustryVoucherByCode,
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { normalizeTimestampIso } from '../../../utils/time.js' import { normalizeTimestampIso } from '../../../utils/time.js'
import { logWarn } from '../../../utils/logger.js' import { logWarn } from '../../../utils/logger.js'
import { TASK_STATUS } from '../../../domain/task-status.js'
import { import {
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig, getKuaishouIndustryConfig,
assertMatchingAppKey, assertMatchingAppKey,
} from './config.js' } from './config.js'
@@ -12,9 +14,9 @@ import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload
import { assertKuaishouIndustrySignature } from './crypto.js' import { assertKuaishouIndustrySignature } from './crypto.js'
import { import {
buildIndustrySuccessResponse, buildIndustrySuccessResponse,
buildIndustryErrorResponse,
} from './response.js' } from './response.js'
import { destroyCallback } from './destroy-callback-service.js' import { destroyCallback } from './destroy-callback-service.js'
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
@@ -30,42 +32,39 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
const now = normalizeTimestampIso(new Date().toISOString()) const now = normalizeTimestampIso(new Date().toISOString())
const normalizedOid = params.oid const normalizedOid = params.oid
const order = await findLatestOrderByPlatformOrderId({ const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
provider: config.provider, const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean))
platform: KUISHOU_INDUSTRY_PLATFORM, const targetVouchers = targetIds.size > 0
platformOrderId: normalizedOid, ? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
}) : vouchers
if (!order) { for (const voucher of targetVouchers) {
return buildIndustrySuccessResponse({ oid: normalizedOid }) const status = String(voucher.status || '').trim().toUpperCase()
} if (status === 'CONSUMED') {
continue
const tasks = await listTasksByOrderId(order.id)
if (params.etickets.length > 0) {
const targetIds = new Set(params.etickets.map((e) => String(e.id)))
for (const task of tasks) {
const taskId = String(task.id || '')
if (targetIds.has(taskId)) {
await updateTask(taskId, {
task_status: 'destroyed',
delivery_status: 'destroyed',
result_code: params.reason,
result_message: `销毁原因: ${params.reason}`,
updated_at: now,
})
}
} }
} else {
for (const task of tasks) { const updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
await updateTask(task.id, { status: 'DESTROYED',
task_status: 'cancelled', destroyedAt: now,
updatedAt: now,
})
if (voucher.task_id) {
const updatedTask = await updateTask(voucher.task_id, {
task_status: TASK_STATUS.CLOSED,
delivery_status: 'cancelled', delivery_status: 'cancelled',
result_code: params.reason, result_code: params.reason,
result_message: `订单关闭: ${params.reason}`, result_message: `电子凭证销毁: ${params.reason}`,
updated_at: now, 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 { import {
KUISHOU_INDUSTRY_PLATFORM, findKuaishouIndustryVoucherByCode,
listKuaishouIndustryVouchersByOid,
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
import {
getKuaishouIndustryConfig, getKuaishouIndustryConfig,
assertMatchingAppKey, assertMatchingAppKey,
} from './config.js' } from './config.js'
@@ -11,9 +11,9 @@ import { assertKuaishouIndustrySignature } from './crypto.js'
import { import {
buildIndustrySuccessResponse, buildIndustrySuccessResponse,
buildIndustryErrorResponse, buildIndustryErrorResponse,
buildIndustryEticketItem,
buildIndustryQueryCodeData, buildIndustryQueryCodeData,
} from './response.js' } from './response.js'
import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
@@ -28,25 +28,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
const normalizedOid = params.oid 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) { if (params.eticketId) {
const matched = tasks.find((t) => String(t.id || '') === params.eticketId) const matched = await findKuaishouIndustryVoucherByCode(params.eticketId, normalizedOid)
if (!matched) { if (!matched) {
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`) return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
} }
const eticket = buildEticketFromTask(matched, params.eticketType) const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType)
return buildIndustrySuccessResponse( return buildIndustrySuccessResponse(
buildIndustryQueryCodeData({ buildIndustryQueryCodeData({
oid: normalizedOid, oid: normalizedOid,
@@ -57,8 +45,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
) )
} }
const etickets = tasks.map((task) => const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
buildEticketFromTask(task, params.eticketType), if (vouchers.length === 0) {
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
}
const etickets = vouchers.map((voucher) =>
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
) )
return buildIndustrySuccessResponse( 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 { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js' import { upsertKuaishouIndustryVoucher } from '../../../repositories/kuaishou-industry-voucher-repo.js'
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
import { normalizeTimestampIso } from '../../../utils/time.js' import { normalizeTimestampIso } from '../../../utils/time.js'
import { logWarn } from '../../../utils/logger.js' import { logWarn } from '../../../utils/logger.js'
import type { TaskRow } from '../../../types/repository/rows.js'
import { import {
KUISHOU_INDUSTRY_PROVIDER,
KUISHOU_INDUSTRY_PLATFORM,
getKuaishouIndustryConfig, getKuaishouIndustryConfig,
assertMatchingAppKey, assertMatchingAppKey,
} from './config.js' } from './config.js'
@@ -15,13 +12,15 @@ import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
import { assertKuaishouIndustrySignature } from './crypto.js' import { assertKuaishouIndustrySignature } from './crypto.js'
import { import {
buildIndustrySuccessResponse, buildIndustrySuccessResponse,
buildIndustryErrorResponse,
buildIndustryEticketItem, buildIndustryEticketItem,
buildIndustrySendCodeData, buildIndustrySendCodeData,
} from './response.js' } from './response.js'
import { sendCallback } from './send-callback-service.js' import { sendCallback } from './send-callback-service.js'
import {
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry' buildKuaishouIndustryEticketFromVoucher,
resolveKuaishouIndustryVoucherValidity,
} from './voucher-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
@@ -35,112 +34,54 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
assertMatchingAppKey(params.appKey) assertMatchingAppKey(params.appKey)
const now = normalizeTimestampIso(new Date().toISOString()) const now = normalizeTimestampIso(new Date().toISOString())
const nowMs = Date.now()
const normalizedOid = params.oid const normalizedOid = params.oid
let order = await findLatestOrderByPlatformOrderId({ const order = await findLatestOrderByPlatformOrderId({
provider: config.provider, provider: OPEN_91_PROVIDER,
platform: KUISHOU_INDUSTRY_PLATFORM, platform: OPEN_91_PLATFORM,
platformOrderId: normalizedOid, 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) { for (let index = 0; index < targetTaskCount; index += 1) {
order = await createOrder({ const unitIndex = index + 1
provider: config.provider, const task = tasks.find((item) => Number(item.unit_index || 0) === unitIndex) || null
platform: KUISHOU_INDUSTRY_PLATFORM, const voucher = await upsertKuaishouIndustryVoucher({
shopId: config.shopId, oid: normalizedOid,
shopName: config.shopName, unitIndex,
platformOrderId: normalizedOid, token: params.token,
orderStatus: 'paid', orderId: order?.id || null,
payStatus: 'paid', taskId: task?.id || null,
buyerId: params.sellerId, status: 'UNUSED',
buyerName: '', validStartTime: validity.validStartTime,
receiverContact: '', validEndTime: validity.validEndTime,
totalAmount: '0', rawPayloadJson: {
currency: 'CNY', source: 'kuaishou-industry/send-code',
rawPayloadJson: JSON.stringify(params), receivedAt: now,
paidAt: now, body: params,
},
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
}
if (!order) { if (voucher) {
return buildIndustryErrorResponse(4010003, '创建订单失败') vouchers.push(voucher)
}
const profile = await ensureIndustryProfile(now)
if (!profile) {
return buildIndustryErrorResponse(4010003, '创建履约配置失败')
}
const existingTasks = await listTasksByOrderId(order.id)
const existingTaskCount = existingTasks.length
const targetTaskCount = params.num > 0 ? params.num : 1
const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount)
if (tasksToCreate > 0) {
const items = []
for (let i = 0; i < tasksToCreate; i++) {
const unitIndex = existingTaskCount + i + 1
items.push({
skuCode: params.itemId || params.skuId || 'kuaishou-industry',
skuName: params.itemTitle || '行业电子凭证',
quantity: 1,
specJson: JSON.stringify({
itemId: params.itemId,
skuId: params.skuId,
oid: normalizedOid,
}),
itemSnapshotJson: '{}',
createdAt: now,
updatedAt: now,
})
}
const orderItems = await replaceOrderItems(order.id, items)
for (let i = 0; i < tasksToCreate; i++) {
const unitIndex = existingTaskCount + i + 1
const item = orderItems[i]
if (!item) {
continue
}
await createTask({
orderId: order.id,
orderItemId: item.id,
unitIndex,
taskNo: `${normalizedOid}-${unitIndex}`,
provider: config.provider,
platform: KUISHOU_INDUSTRY_PLATFORM,
shopId: config.shopId,
shopName: config.shopName,
platformOrderId: normalizedOid,
profileId: profile.id,
executorKey: INDUSTRY_PROFILE_KEY,
taskStatus: 'pending_payment',
deliveryStatus: 'pending',
automationMode: 'manual',
requiresClaim: true,
contextJson: JSON.stringify({
token: params.token,
certExpireType: params.certExpireType,
certStartTime: params.certStartTime,
certEndTime: params.certEndTime,
certExpDays: params.certExpDays,
certActualStartTime: params.certActualStartTime,
certActualEndTime: params.certActualEndTime,
}),
createdAt: now,
updatedAt: now,
})
} }
} }
const allTasks = await listTasksByOrderId(order.id) if (order) {
const etickets = allTasks.map((task) => await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
buildEticketFromTask(task, params), source: 'kuaishou_industry_send_code',
now,
})
}
const etickets = vouchers.map((voucher) =>
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
) )
const response = buildIndustrySuccessResponse( const response = buildIndustrySuccessResponse(
@@ -164,72 +105,6 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
return response 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: { function fireSendCallback(input: {
oid: string oid: string
sendType: 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 { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { listTasksByOrderId } from '../../../repositories/task-repo.js'
import { upsertOrderFromSource } from '../../order/order-service.js' import { upsertOrderFromSource } from '../../order/order-service.js'
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
import { import {
OPEN_91_PLATFORM, OPEN_91_PLATFORM,
OPEN_91_PROVIDER, OPEN_91_PROVIDER,
@@ -175,6 +176,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
updatedAt: now, updatedAt: now,
}, },
]) ])
await bindKuaishouIndustryVouchersToOrderTasks(order, [], {
source: 'open91_pending_order',
now,
})
return { return {
order, order,
@@ -127,3 +127,25 @@ export type TaskUpdatePatch = {
artifacts_json?: string | Record<string, unknown> artifacts_json?: string | Record<string, unknown>
state_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 id: number
order_id: number order_id: number
order_item_id: number order_item_id: number
unit_index: number
platform_order_id: string platform_order_id: string
profile_id: number profile_id: number
task_no: string task_no: string
@@ -103,6 +104,26 @@ export type TaskEventRow = {
created_at: string 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 = { export type OrderListQueryResult = {
items: OrderListRow[] items: OrderListRow[]
total: number total: number
+15
View File
@@ -5,6 +5,13 @@ export type AdminDefaultUser = {
status?: string; status?: string;
}; };
export type KuaishouFeifeiProductRule = {
productName: string;
productCode: string;
skuName?: string;
enabled?: boolean;
};
export type RuntimeConfig = { export type RuntimeConfig = {
server: { server: {
port: number; port: number;
@@ -90,6 +97,14 @@ export type RuntimeConfig = {
shopName: string; shopName: string;
version: string; version: string;
}; };
kuaishouFeifei: {
baseUrl: string;
appKey: string;
appSecret: string;
timeoutMs: number;
notifyUrl: string;
productRules: KuaishouFeifeiProductRule[];
};
}; };
cors: { cors: {
allowedOrigins: string[]; allowedOrigins: string[];
-3
View File
@@ -17,7 +17,6 @@ declare module 'vue' {
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
@@ -32,7 +31,6 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
@@ -45,7 +43,6 @@ declare module 'vue' {
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
+20 -1
View File
@@ -135,15 +135,34 @@ export interface ClaimKuaishouCloudFlowInfo {
} }
} }
export interface ClaimKuaishouFeifeiFlowInfo {
flowType: 'kuaishou_feifei'
productCode: string
productName: string
platformOrderNo: string
orderNo: string
rechargeStatus: number
rechargeStatusLabel: string
rechargeResultMessage: string
claimUrl: string
consumeStatus: string
h5: {
entryUrl: string
rechargeUrl: string
}
lastSyncedAt: string | null
}
export interface ClaimDetailData { export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus tokenStatus: ClaimTokenStatus
claimUrl: string claimUrl: string
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {}) flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {})
task: ClaimTaskInfo task: ClaimTaskInfo
order: ClaimOrderInfo order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo orderItem: ClaimOrderItemInfo
product: ClaimProductInfo product: ClaimProductInfo
session: unknown | null session: unknown | null
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
kuaishouFeifei: ClaimKuaishouFeifeiFlowInfo | null
result: ClaimResultInfo | null result: ClaimResultInfo | null
} }
@@ -34,6 +34,42 @@ const claim = useKuaishouCloudClaim(() => props.token)
<p>{{ claim.errorMessage.value }}</p> <p>{{ claim.errorMessage.value }}</p>
</div> </div>
<template v-else-if="claim.detail.value?.flowType === 'kuaishou_feifei' && claim.feifei.value">
<section class="content-card feifei-card">
<div class="feifei-main">
<p class="feifei-label">kuaishou-feifei</p>
<h2>{{ claim.product.value?.title || claim.orderItem.value?.skuName || '商品领取' }}</h2>
<p class="feifei-status">
{{ claim.feifei.value.rechargeStatusLabel || '待领取' }}
</p>
</div>
<dl class="feifei-meta">
<div>
<dt>订单号</dt>
<dd>{{ claim.order.value?.platformOrderId || '-' }}</dd>
</div>
<div>
<dt>平台单号</dt>
<dd>{{ claim.feifei.value.orderNo || claim.feifei.value.platformOrderNo || '-' }}</dd>
</div>
</dl>
<el-button
type="primary"
size="large"
:disabled="!claim.feifei.value.h5.rechargeUrl && !claim.feifei.value.h5.entryUrl"
@click="claim.openFeifeiUrl(false)"
>
打开领取链接
</el-button>
<p v-if="claim.task.value?.lastError" class="feifei-error">
{{ claim.task.value.lastError }}
</p>
</section>
</template>
<template v-else-if="claim.detail.value && claim.flow.value"> <template v-else-if="claim.detail.value && claim.flow.value">
<ClaimTicketStep <ClaimTicketStep
v-if="claim.currentStep.value === 1" v-if="claim.currentStep.value === 1"
@@ -145,6 +181,60 @@ const claim = useKuaishouCloudClaim(() => props.token)
color: #dc2626; color: #dc2626;
} }
.feifei-card {
gap: 22px;
}
.feifei-main {
display: grid;
gap: 8px;
}
.feifei-label {
margin: 0;
color: #64748b;
font-size: 13px;
}
.feifei-main h2 {
margin: 0;
font-size: 22px;
color: #0f172a;
}
.feifei-status {
margin: 0;
color: #2563eb;
font-weight: 600;
}
.feifei-meta {
margin: 0;
display: grid;
gap: 12px;
}
.feifei-meta div {
display: grid;
gap: 4px;
}
.feifei-meta dt {
color: #64748b;
font-size: 13px;
}
.feifei-meta dd {
margin: 0;
color: #0f172a;
word-break: break-all;
}
.feifei-error {
margin: 0;
color: #b45309;
}
.spinning { .spinning {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@@ -46,6 +46,7 @@ export function useKuaishouCloudClaim(token: () => string) {
// ── derived data ──────────────────────────────────────── // ── derived data ────────────────────────────────────────
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null) const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
const feifei = computed(() => detail.value?.kuaishouFeifei || null)
const order = computed(() => detail.value?.order || null) const order = computed(() => detail.value?.order || null)
const orderItem = computed(() => detail.value?.orderItem || null) const orderItem = computed(() => detail.value?.orderItem || null)
const product = computed(() => detail.value?.product || null) const product = computed(() => detail.value?.product || null)
@@ -55,6 +56,7 @@ export function useKuaishouCloudClaim(token: () => string) {
const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '') const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '') const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
const isFeifeiFlow = computed(() => detail.value?.flowType === 'kuaishou_feifei')
const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified') const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
const isBindUrlExpired = computed(() => { const isBindUrlExpired = computed(() => {
@@ -125,6 +127,9 @@ export function useKuaishouCloudClaim(token: () => string) {
) )
const currentStep = computed(() => { const currentStep = computed(() => {
if (isFeifeiFlow.value) {
return isCompleted.value ? 4 : 2
}
if (hasRedeemResult.value) { if (hasRedeemResult.value) {
return 4 return 4
} }
@@ -138,6 +143,9 @@ export function useKuaishouCloudClaim(token: () => string) {
}) })
const progressText = computed(() => { const progressText = computed(() => {
if (isFeifeiFlow.value) {
return feifei.value?.rechargeStatusLabel || '请打开领取链接'
}
if (hasRedeemResult.value) { if (hasRedeemResult.value) {
return '兑换结果已生成' return '兑换结果已生成'
} }
@@ -365,6 +373,19 @@ export function useKuaishouCloudClaim(token: () => string) {
window.open(bindUrl, '_blank', 'noopener,noreferrer') window.open(bindUrl, '_blank', 'noopener,noreferrer')
} }
function openFeifeiUrl(useCurrentPage = false) {
const url = String(feifei.value?.h5.rechargeUrl || feifei.value?.h5.entryUrl || '').trim()
if (!url) {
showError('领取链接还没准备好,请稍后刷新')
return
}
if (useCurrentPage) {
window.location.assign(url)
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
// ── polling ───────────────────────────────────────────── // ── polling ─────────────────────────────────────────────
function stopPolling() { function stopPolling() {
@@ -457,6 +478,7 @@ export function useKuaishouCloudClaim(token: () => string) {
qrCodeDataUrl, qrCodeDataUrl,
// derived data // derived data
flow, flow,
feifei,
order, order,
orderItem, orderItem,
product, product,
@@ -464,6 +486,7 @@ export function useKuaishouCloudClaim(token: () => string) {
// computed status // computed status
roleName, roleName,
roleId, roleId,
isFeifeiFlow,
isTicketVerified, isTicketVerified,
isBindUrlExpired, isBindUrlExpired,
isBindingPrepared, isBindingPrepared,
@@ -493,6 +516,7 @@ export function useKuaishouCloudClaim(token: () => string) {
rebindRole, rebindRole,
confirmRedeem, confirmRedeem,
openBindUrl, openBindUrl,
openFeifeiUrl,
// utility // utility
formatAdminDateTime, formatAdminDateTime,
} }
@@ -0,0 +1,509 @@
# 多发货平台与电子凭证整体链路
## 1. 当前结论
当前系统需要把四类能力拆开理解:
| 模块 | 定位 | 说明 |
| --- | --- | --- |
| 91 卡券 | 订单来源 + 发链接通道 | 91 调用我方下单接口,我方生成领取链接;91 查询订单时拿到领取链接,并发送给用户。 |
| kuaishou-cloud | 发货平台 1 | 已对接,用于发放它支持的皮肤、道具。 |
| kuaishou-feifei | 发货平台 2 | 新增发货平台,作为 kuaishou-cloud 的补充,覆盖 kuaishou-cloud 没有的皮肤、道具。 |
| 快手电子凭证 | 领取流程里的自动核销能力 | 用来替代用户在领取页手动输入核销码的步骤,不替代 91,也不替代发货平台。 |
因此整体设计不是“新增订单来源”,而是:
```text
91 负责进单和把领取链接发给用户
我方领取页负责统一承接用户
kuaishou-cloud / kuaishou-feifei 负责真实发货
快手电子凭证负责把领取页里的核销码输入步骤自动化
```
## 2. 目标链路
```text
91 卡券下单
-> 我方创建内部订单和履约任务
-> 按商品选择发货平台
-> kuaishou-cloud
-> kuaishou-feifei
-> 91 卡券查询订单
-> 我方返回统一 claimUrl
-> 91 卡券把 claimUrl 发给用户
-> 用户打开 claimUrl
-> 系统检查快手电子凭证状态
-> 已有关联电子凭证:跳过手动输入核销码
-> 未关联电子凭证:保留兜底处理
-> 用户继续完成绑定 / 跳转发货平台 H5
-> 发货平台完成履约
-> 我方更新任务状态
-> 我方完成快手电子凭证核销闭环
```
## 3. 91 卡券职责边界
91 卡券仍然只承担两件事:
1. 调用我方异步下单接口,提供 `orderNo``productNo``buyNum` 等订单信息。
2. 调用我方查询接口,获取最终要发给用户的卡密内容。
这里的卡密内容继续保持为我方统一领取链接:
```text
claimUrl = https://你的域名/#/claim/{token}
```
不要直接把 kuaishou-cloud 绑定链接或 kuaishou-feifei 的 H5 链接返回给 91。原因是领取页需要统一处理:
- 商品最终走哪个发货平台;
- 是否已经通过快手电子凭证完成自动核销;
- 是否需要展示绑定链接、二维码或 H5;
- 异常时如何兜底、重试、转人工。
## 4. 发货平台选择
履约任务创建时,需要根据商品配置选择发货平台:
```text
91 productNo
-> 商品匹配
-> 命中 kuaishou-cloud 商品
-> executor_key = kuaishou_ct_assisted
-> 命中 kuaishou-feifei 商品
-> executor_key = kuaishou_feifei
-> 未命中
-> 进入待补全 / 人工处理
```
优先级建议先做成可配置规则,不在代码里硬编码。第一阶段可以采用:
1. 显式覆盖规则优先;
2. 再按商品名匹配 kuaishou-cloud
3. 再按商品名或 `product_code` 匹配 kuaishou-feifei
4. 都未命中则进入待补全队列。
## 5. kuaishou-feifei 接入方式
kuaishou-feifei 的主要交付物是 `h5.recharge_url`
创建任务后,系统应向 kuaishou-feifei 创建订单,并把关键字段保存到任务上下文:
```json
{
"kuaishouFeifei": {
"platformOrderNo": "内部幂等单号",
"orderNo": "发货平台内部单号",
"productCode": "商品编码",
"rechargeStatus": 15,
"statusLabel": "待绑定",
"h5": {
"rechargeUrl": "http://..."
},
"lastSyncedAt": "2026-07-07T00:00:00.000Z"
}
}
```
领取页根据当前任务的 executor 展示不同内容:
- `kuaishou_ct_assisted`:展示现有 kuaishou-cloud 绑定流程;
- `kuaishou_feifei`:展示或跳转 kuaishou-feifei 的 `h5.recharge_url`
- `manual_dispatch`:进入人工兜底。
## 6. 快手电子凭证的嵌入点
旧流程中,用户打开领取链接后需要手动输入快手核销码:
```text
用户打开 claimUrl
-> 输入核销码
-> 我方校验 / 核销
-> 继续绑定或发货
```
新流程中,快手电子凭证用于把这一步自动化:
```text
用户打开 claimUrl
-> 系统查任务上下文中的电子凭证信息
-> 如果已有关联的 oid / token / eticketId
-> 自动认为核销凭证已就绪
-> 跳过手动输入核销码
-> 继续进入发货平台绑定 / H5
```
电子凭证信息建议沉淀在 task context 中:
```json
{
"kuaishouIndustryVoucher": {
"oid": "快手订单号",
"token": "订单维度授权 token",
"eticketId": "电子凭证 id",
"status": "UNUSED",
"certActualStartTime": 0,
"certActualEndTime": 0,
"verifiedAt": null,
"consumedAt": null
}
}
```
注意:快手电子凭证不是新的订单来源。它只服务领取流程中的核销自动化。
## 7. 核销完成时机
电子凭证不应该在用户刚打开领取页时就立刻标记为已消费。
推荐时机:
```text
发货平台实际履约成功
-> 我方更新 task 为 completed / redeemed
-> 我方触发快手电子凭证核销回调
-> 电子凭证状态更新为 CONSUMED
```
这样可以避免用户打开链接但未完成绑定时,快手侧已经显示核销成功。
不同发货平台的成功判定:
| 发货平台 | 成功判定 |
| --- | --- |
| kuaishou-cloud | 当前发货流程完成,任务进入完成态。 |
| kuaishou-feifei | 查询或异步通知返回 `recharge_status = 30`。 |
失败时:
- 发货平台失败:任务进入 `manual_review``failed`
- 电子凭证保持 `UNUSED`,不要误核销;
- 需要人工确认时再做销毁、退款或补发。
## 8. 现有代码需要调整的方向
当前项目已经有以下基础能力:
- 91 卡券下单和查询;
- kuaishou-cloud 发货流程;
- 快手电子凭证 `send-code / query-code / destroy-code / consume-code` 接口骨架;
- 领取页手动输入核销码流程。
后续需要把它们串起来:
1. 91 查询仍返回统一 `claimUrl`
2. 商品匹配支持多发货平台。
3. kuaishou-feifei 新增独立 executor 和 API client。
4. 领取页根据 task executor 展示对应发货流程。
5. 快手电子凭证状态进入 task context,用于跳过手动输入核销码。
6. 发货成功后再触发快手电子凭证核销。
7. 保留手动输入核销码作为异常兜底,而不是主路径。
## 9. 数据关联
91 订单和快手电子凭证之间需要稳定关联。
优先方案:
```text
91 orderNo == 快手电子凭证 oid
```
如果不相等,则需要额外映射关系,例如:
```text
91 orderNo
-> 内部 order.id
-> task.id
-> 快手电子凭证 oid / eticketId / token
```
没有这层映射,领取页无法可靠判断当前用户对应哪一张快手电子凭证,也就无法稳定跳过手动核销码输入步骤。
## 10. 真实测试订单复盘
2026-07-07 的真实测试请求验证了优先关联方案成立。
### 10.1 请求时序
```text
10:16:43 91 卡券异步下单
orderNo = 2618800083429561
productNo = 套装-Alan Walker
buyNum = 1
10:16:46 快手电子凭证 send-code
oid = 2618800083429561
itemTitle = 测试连接1111
itemId = 26692927860114
skuId = 188718171010114
eticketId = 2038
10:17:13 91 卡券查询订单
orderStatus = 10
cards = ''
10:17:44 91 卡券再次查询订单
orderStatus = 10
cards = ''
```
### 10.2 已验证事实
这笔真实单中:
```text
91 orderNo = 2618800083429561
快手电子凭证 oid = 2618800083429561
```
因此可以用 `orderNo / oid` 作为 91 订单和快手电子凭证的主关联键。
同时也验证了另一个重要事实:
```text
91 productNo = 套装-Alan Walker
快手 itemTitle = 测试连接1111
```
两者不一定相同。因此,发什么皮肤、走哪个发货平台,应该以 91 的 `productNo` 为准;快手电子凭证的 `itemTitle / itemId / skuId` 主要用于凭证关系、快手侧订单信息和核销闭环,不应该直接用于决定发货商品。
### 10.3 当前断点
当前系统在这笔单上的表现:
```text
91 下单结果:
ignored = true
ignoreReason = unconfigured_items
orderId = 2487
orderStatus = 10
cards = ''
快手电子凭证结果:
result = 1
eticketId = 2038
status = UNUSED
```
说明:
1. 91 订单已经进入系统,但因为 `套装-Alan Walker` 没命中现有履约配置,所以进入待补全队列。
2. 快手电子凭证已经发码成功,但当前实现没有把 `eticketId = 2038` 绑定回 91 订单的履约上下文。
3. 91 查询只看 `provider = 91kaquan` 的订单和任务,因此看不到快手电子凭证链路创建或返回的状态。
4. 因为 91 订单没有可交付的 `claimUrl`,所以持续返回 `orderStatus = 10` 和空 `cards`
### 10.4 实现修正方向
`kuaishou-industry/send-code` 收到请求后,不能只按 `provider = kuaishou-industry` 自己创建孤立订单。它需要优先做关联:
```text
send-code.oid
-> 查找 91 订单 platformOrderId = oid
-> 找到:把电子凭证信息写入该 91 订单对应 task / context
-> 未找到:暂存电子凭证待关联,或创建待关联记录
```
如果 91 订单仍是 `pending_config`,则需要在后续补全商品履约配置后,同时带上已收到的电子凭证信息。
最终目标:
```text
91 订单 task
-> context.kuaishouIndustryVoucher.eticketId = 2038
-> context.kuaishouIndustryVoucher.oid = 2618800083429561
-> context.kuaishouIndustryVoucher.status = UNUSED
-> 领取页打开时跳过手动核销码输入
-> 发货完成后触发电子凭证核销
```
## 11. 电子凭证券号生成与核销幂等
接入电子凭证的核心目的,是减少用户在领取页手动输入快手核销码的步骤。因此,系统返回给快手的电子凭证 `eticket.id` 需要成为后续查询、销毁、核销都能稳定使用的业务券号。
### 11.1 当前逻辑
当前实现中,`send-code` 返回的 `eticket.id` 来自本地 task id
```text
eticket.id = fulfillment_tasks.id
```
真实测试单中:
```text
eticket.id = 2038
```
这说明当前“核销码/券号”不是快手生成的,而是我方创建 task 后,把 task id 当作电子凭证 id 返回给快手。
这个方式有两个问题:
1. task 属于当前实现里的 `kuaishou-industry` 孤立订单链,尚未绑定到 91 订单 task。
2. task id 虽然全局唯一,但它和任务生命周期耦合太强;后续如果任务重建、补配置、切换发货平台,会影响电子凭证反查和幂等。
### 11.2 推荐目标
电子凭证券号应该满足:
| 要求 | 说明 |
| --- | --- |
| 全局唯一 | 不同订单、不同数量拆分出的券不能重复。 |
| 幂等稳定 | 同一个 `oid` 重复 `send-code`,必须返回同一批 `eticket.id`。 |
| 可反查 | `query-code / destroy-code / consume-code` 带回 `eticket.id` 时,能找到原始订单、task 和发货状态。 |
| 可延迟绑定 | 如果快手 `send-code` 早于 91 task 准备完成,券号也能先落库,后续再绑定 task。 |
| 可核销 | 发货完成后,能用同一张券更新为 `CONSUMED`,并记录核销流水。 |
### 11.3 推荐数据模型
建议新增独立的电子凭证券表,而不是继续只依赖 task id:
```text
kuaishou_industry_vouchers
id BIGSERIAL PRIMARY KEY
voucher_code TEXT UNIQUE NOT NULL
oid TEXT NOT NULL
order_id BIGINT NULL
task_id BIGINT NULL
unit_index INTEGER NOT NULL
token TEXT NOT NULL
status TEXT NOT NULL -- UNUSED / CONSUMED / DESTROYED
valid_start_time BIGINT NOT NULL
valid_end_time BIGINT NOT NULL
consume_serial_num TEXT NOT NULL DEFAULT ''
consumed_at TIMESTAMPTZ NULL
destroyed_at TIMESTAMPTZ NULL
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL
唯一约束:
UNIQUE(oid, unit_index)
UNIQUE(voucher_code)
```
券号生成建议:
```text
voucher_code = String(kuaishou_industry_vouchers.id)
```
理由:
- 数字字符串兼容性最好;
- 数据库主键保证不重复;
- 重复 `send-code` 时通过 `UNIQUE(oid, unit_index)` 找回原记录,返回原 `voucher_code`
- 不依赖 task 是否已经创建。
如果后续确认快手完全支持任意字符串,也可以升级为带前缀的业务券号,例如:
```text
voucher_code = KSEV-{oid}-{unitIndex}
```
但第一阶段建议优先使用数字字符串,降低快手侧兼容风险。
### 11.4 send-code 幂等规则
`send-code` 处理流程应改为:
```text
收到 oid + num
-> 按 oid 查 91 订单
-> 为 unitIndex = 1..num 创建或获取 voucher
-> 已存在:复用原 voucher_code
-> 不存在:新建 voucher
-> 如果 91 task 已存在:绑定 voucher.task_id
-> 如果 91 task 未存在:voucher 保持待绑定
-> 返回 etickets[{ id: voucher_code, status: voucher.status }]
```
重复通知时,必须返回相同结果:
```text
第一次 send-code:
oid = 2618800083429561
num = 1
eticket.id = 2038
第二次 send-code:
oid = 2618800083429561
num = 1
eticket.id 仍然 = 2038
```
### 11.5 有效期生成规则
真实测试单里快手没有传 `certActualStartTime / certActualEndTime`,并且 `certStartTime / certEndTime` 也可能都是 0。当前系统返回了:
```text
validStartTime = 0
validEndTime = 0
```
例如 2026-07-07 11:16:58 的真实请求:
```text
certExpireType = 3
certExpDays = 3
certStartTime = 0
certEndTime = 0
certActualStartTime = 0
certActualEndTime = 0
```
这种场景应理解为“购买成功后固定有效天数”,用 `certExpDays` 生成有效期。建议按以下优先级生成:
```text
validStartTime:
1. certActualStartTime > 0
2. certStartTime > 0
3. 当前时间毫秒
validEndTime:
1. certActualEndTime > 0
2. certEndTime > 0
3. certStartTime + certExpDays * 86400000
4. 当前时间 + certExpDays * 86400000
```
如果 `certExpDays` 也为空,则使用保守默认值,例如 3 天。
对于上面的真实请求,返回给快手的电子凭证应类似:
```text
validStartTime = send-code 处理时的当前毫秒时间戳
validEndTime = validStartTime + 3 * 86400000
```
### 11.6 后续核销规则
发货完成后,不再要求用户输入快手核销码,而是系统根据 voucher 主动完成核销闭环:
```text
发货平台履约成功
-> 找到 task 绑定的 voucher
-> 检查 voucher.status = UNUSED
-> 生成稳定 consume_serial_num
-> 调用快手电子凭证核销回调
-> 成功后更新 voucher.status = CONSUMED
-> 更新 task 为 completed / redeemed
```
核销流水号也必须幂等:
```text
consume_serial_num = CONSUME-{voucher_code}
```
这样即使核销回调重试,也不会生成多条不同流水。
销毁规则:
```text
destroy-code
-> 按 oid + eticket.id 找 voucher
-> 未找到:按快手要求仍可返回成功
-> 已 CONSUMED:不应直接改为 DESTROYED,进入人工确认
-> UNUSED:更新为 DESTROYED
```