From ddfa6c278fa2bd0c97af9f1e6cddeb8318e05562 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 7 Jul 2026 12:06:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=A4=9A=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=8E=E7=94=B5=E5=AD=90=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/config/defaults.ts | 8 + .../src/config/env-documentation.test.ts | 1 + apps/backend/src/config/env-overrides.ts | 46 ++ apps/backend/src/config/runtime-validation.ts | 3 + .../002_kuaishou_industry_vouchers.sql | 29 + .../kuaishou-industry-voucher-repo.ts | 270 ++++++++++ .../fulfillment-bootstrap-service.ts | 9 + .../claim/kuaishou-cloud-claim-context.ts | 92 ++++ .../claim/kuaishou-cloud-claim-service.ts | 107 +++- .../kuaishou-cloud/task-finalization.ts | 61 ++- .../fulfillment/kuaishou-feifei/index.ts | 256 +++++++++ .../services/order/delivery-task-service.ts | 96 +++- .../src/services/order/order-service.ts | 5 + .../services/order/product-match-service.ts | 25 +- .../platforms/kuaishou-feifei/config.ts | 44 ++ .../kuaishou-feifei/http-client.test.ts | 21 + .../platforms/kuaishou-feifei/http-client.ts | 95 ++++ .../kuaishou-feifei/order-service.ts | 75 +++ .../kuaishou-feifei/product-rule-service.ts | 54 ++ .../kuaishou-industry/consume-code-service.ts | 116 ++-- .../kuaishou-industry/destroy-code-service.ts | 67 ++- .../kuaishou-industry/query-code-service.ts | 78 +-- .../kuaishou-industry/send-code-service.ts | 215 ++------ .../voucher-binding-service.ts | 148 +++++ .../kuaishou-industry/voucher-service.test.ts | 31 ++ .../kuaishou-industry/voucher-service.ts | 330 ++++++++++++ .../platforms/ninetyone/order-service.ts | 5 + apps/backend/src/types/repository/inputs.ts | 22 + apps/backend/src/types/repository/rows.ts | 21 + apps/backend/src/types/runtime-config.ts | 15 + apps/frontend/src/components.d.ts | 3 - apps/frontend/src/types/claim.ts | 21 +- .../kuaishou-cloud/KuaishouCloudClaimView.vue | 90 ++++ .../composables/useKuaishouCloudClaim.ts | 24 + docs/履约配置/多发货平台与电子凭证整体链路.md | 509 ++++++++++++++++++ 35 files changed, 2627 insertions(+), 365 deletions(-) create mode 100644 apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql create mode 100644 apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts create mode 100644 apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/config.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts create mode 100644 apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts create mode 100644 docs/履约配置/多发货平台与电子凭证整体链路.md diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 82f30fa2..22f8d2de 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -57,6 +57,14 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { shopName: "快手行业电子凭证", version: "1", }, + kuaishouFeifei: { + baseUrl: "http://skin-exchange.yiquyou.icu", + appKey: "", + appSecret: "", + timeoutMs: 10000, + notifyUrl: "", + productRules: [], + }, cloudtentacles: { baseUrl: "https://123.207.217.176", timeoutMs: 5000, diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index 609c84e6..37f8eedd 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -20,6 +20,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'BACKEND_PORT', 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', + 'KUASHOU_INDUSTRY_RATE_LIMIT_MAX', 'NPM_CONFIG_REGISTRY', 'POSTGRES_DB', 'POSTGRES_PASSWORD', diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index edcb7eeb..b191836f 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -3,6 +3,7 @@ import process from "node:process"; import type { AdminDefaultUser, + KuaishouFeifeiProductRule, RuntimeConfig, } from "../types/runtime-config.js"; import { @@ -18,6 +19,7 @@ type RuntimeConfigValue = | number | boolean | AdminDefaultUser[] + | KuaishouFeifeiProductRule[] | string[]; type RuntimeEnv = Record; @@ -274,6 +276,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "kuaishouIndustry", "version", ]), + stringEnv("KUAISHOU_FEIFEI_BASE_URL", [ + "platforms", + "kuaishouFeifei", + "baseUrl", + ]), + stringEnv("KUAISHOU_FEIFEI_APP_KEY", [ + "platforms", + "kuaishouFeifei", + "appKey", + ]), + stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [ + "platforms", + "kuaishouFeifei", + "appSecret", + ]), + integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [ + "platforms", + "kuaishouFeifei", + "timeoutMs", + ]), + stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [ + "platforms", + "kuaishouFeifei", + "notifyUrl", + ]), + kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [ + "platforms", + "kuaishouFeifei", + "productRules", + ]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), ]; @@ -355,6 +387,20 @@ function adminUsersJsonEnv( }; } +function kuaishouFeifeiProductRulesEnv( + env: string, + configPath: RuntimeConfigPath +): EnvOverride { + return { + env, + path: configPath, + read(rawValue) { + const parsed = parseJsonArray(rawValue); + return parsed === null ? null : parsed; + }, + }; +} + function corsOriginsEnv( env: string, configPath: RuntimeConfigPath diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 6a68eb80..51ea5942 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -48,6 +48,9 @@ export function validateRuntimeConfig( requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) + if (config.platforms?.kuaishouFeifei) { + requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 }) + } requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) requireInteger( issues, diff --git a/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql b/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql new file mode 100644 index 00000000..02a8c65e --- /dev/null +++ b/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql @@ -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); diff --git a/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts new file mode 100644 index 00000000..f3265255 --- /dev/null +++ b/apps/backend/src/repositories/kuaishou-industry-voucher-repo.ts @@ -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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 { + const result = await query( + ` + 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 { + 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( + ` + 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 { + 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( + ` + 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 ?? {}) +} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 8f514764..26eb666d 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -36,6 +36,15 @@ const CORE_PROFILES: CoreProfile[] = [ inventoryStrategy: 'external_platform', requirements: [], }, + { + profileKey: 'kuaishou_feifei', + name: 'kuaishou-feifei 履约', + executorKey: 'kuaishou_feifei', + requiresClaim: true, + autoDispatch: false, + inventoryStrategy: 'external_platform', + requirements: [], + }, ] export async function ensureFulfillmentCatalogBootstrapped() { diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index dfa48539..683159e2 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -87,6 +87,10 @@ export async function getClaimContext(token: unknown) { } export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { + if (String(task.executor_key || '').trim() === 'kuaishou_feifei') { + return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }) + } + const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task) const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) const displaySkuName = resolveClaimOrderItemDisplaySkuName( @@ -133,6 +137,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: product, session: null as null, kuaishouCloudFulfillment, + kuaishouFeifei: null as null, result: task.redeemed_at ? { resultCode: String(task.result_code || ''), @@ -144,6 +149,93 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: } } +function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { + const context = parseTaskContext(task) + const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei) + const product = { + title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), + skuCode: String(orderItem.sku_code || '').trim(), + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + isBundle: false, + items: [{ + cloudSkuId: 0, + name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + }], + } + + return { + tokenStatus: claimToken.status, + claimUrl: buildClaimUrl(claimToken.token), + flowType: 'kuaishou_feifei', + task: { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + executorKey: task.executor_key || '', + requiresSupportReview: false, + expiresAt: claimToken.expired_at, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + loginType: task.login_type, + lastError: task.last_error, + runtimeSessionId: task.runtime_session_id, + }, + order: { + orderId: order.id, + platform: order.platform, + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), + currency: order.currency, + }, + orderItem: { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: product.title, + quantity: orderItem.quantity, + }, + product, + session: null as null, + kuaishouCloudFulfillment: null as null, + kuaishouFeifei: flow, + result: task.redeemed_at + ? { + resultCode: String(task.result_code || ''), + resultMessage: String(task.result_message || ''), + screenshotReady: false, + screenshotUrl: '', + } + : null, + } +} + +function mapClaimKuaishouFeifeiFulfillment(value: unknown) { + const source = isPlainObject(value) ? value : {} + const h5 = isPlainObject(source.h5) ? source.h5 : {} + + return { + flowType: 'kuaishou_feifei', + productCode: String(source.productCode || '').trim(), + productName: String(source.productName || '').trim(), + platformOrderNo: String(source.platformOrderNo || '').trim(), + orderNo: String(source.orderNo || '').trim(), + rechargeStatus: Number(source.rechargeStatus || 0) || 0, + rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(), + rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), + claimUrl: String(source.claimUrl || '').trim(), + consumeStatus: String(source.consumeStatus || 'pending').trim(), + h5: { + entryUrl: String(h5.entryUrl || '').trim(), + rechargeUrl: String(h5.rechargeUrl || '').trim(), + }, + lastSyncedAt: source.lastSyncedAt || null, + } +} + function resolveClaimOrderItemDisplaySkuName( orderItem: OrderItemRow, kuaishouCloudFulfillment: JsonObject | null, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 563564cd..44b2d676 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -34,6 +34,7 @@ import { rebindKuaishouCloudTaskRole, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud/index.js' +import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import type { TaskRow } from '../../types/repository/rows.js' @@ -42,6 +43,7 @@ const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) type JsonObject = Record +type ClaimDetailPayload = ReturnType export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) { const context = await getClaimContext(token) @@ -50,7 +52,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js const executorKey = String(context.task.executor_key || '').trim() if (executorKey === 'kuaishou-industry') { - return verifyIndustryeVoucherTicket(context, now) + return verifyIndustryVoucherTicket(context, now) } if (executorKey !== 'kuaishou_ct_assisted') { @@ -61,6 +63,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js } const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment) + if (hasUsableIndustryVoucher(parseTaskContext(context.task))) { + return verifyIndustryVoucherTicket(context, now) + } const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim() if (!ticketCode) { @@ -243,10 +248,10 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js return getKuaishouCloudClaimDetail(token) } -async function verifyIndustryeVoucherTicket( +async function verifyIndustryVoucherTicket( context: Awaited>, now: string, -) { +): Promise { const taskContext = parseTaskContext(context.task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) @@ -255,33 +260,58 @@ async function verifyIndustryeVoucherTicket( } const industryContext = typeof taskContext === 'object' ? taskContext : {} - const token = String(industryContext.token || '').trim() + const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher) + if (voucherContext.status === 'DESTROYED') { + throw createHttpError('当前电子凭证已销毁,无法继续领取', { + statusCode: 409, + errorCode: 'claim_kuaishou_industry_voucher_destroyed', + }) + } + + const token = String(voucherContext.token || industryContext.token || '').trim() + const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim() const certExpireType = Number(industryContext.certExpireType || 0) - const certActualStartTime = Number(industryContext.certActualStartTime || 0) - const certActualEndTime = Number(industryContext.certActualEndTime || 0) + const certActualStartTime = Number( + voucherContext.validStartTime || industryContext.certActualStartTime || 0, + ) + const certActualEndTime = Number( + voucherContext.validEndTime || industryContext.certActualEndTime || 0, + ) const nextContext = { ...taskContext, + kuaishouIndustryVoucher: { + ...voucherContext, + oid: voucherContext.oid || context.order.platform_order_id, + token, + eticketId: voucherCode, + voucherCode, + status: voucherContext.status || 'UNUSED', + verifiedAt: voucherContext.verifiedAt || now, + }, kuaishouCloudFulfillment: { ...flow, ticket: { ...flow.ticket, - code: '', + code: voucherCode, status: 'verified', capturedAt: flow.ticket.capturedAt || now, capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' }, verifiedAt: now, - oid: context.order.platform_order_id, + oid: voucherContext.oid || context.order.platform_order_id, formToken: token, - leftCount: 0, + leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1, goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '', }, consume: { ...flow.consume, - status: 'pending', + status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending', shopId: context.order.shop_id, shopName: context.order.shop_name, autoConsumeEnabled: true, + consumedAt: voucherContext.status === 'CONSUMED' + ? voucherContext.consumedAt || flow.consume.consumedAt || now + : flow.consume.consumedAt, }, certInfo: { certExpireType, @@ -353,17 +383,41 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) { return filePath } -export async function getKuaishouCloudClaimDetail(token: unknown) { +export async function getKuaishouCloudClaimDetail(token: unknown): Promise { const context = await getClaimContext(token) let task = context.task const executorKey = String(task.executor_key || '').trim() + if (executorKey === 'kuaishou_feifei') { + task = (await syncKuaishouFeifeiTaskStatus(task)) || task + } + if (executorKey === 'kuaishou-industry') { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if (flow.consume.status !== 'success') { const now = nowIso() - await verifyIndustryeVoucherTicket(context, now).catch(() => null) + await verifyIndustryVoucherTicket(context, now).catch(() => null) + } + } + + if (executorKey === 'kuaishou_ct_assisted') { + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const needsIndustryVoucherPrepare = + hasUsableIndustryVoucher(taskContext) && + ( + flow.ticket.status !== 'verified' || + (flow.binding.prepareStatus !== 'ready' && !flow.binding.bindUrl) + ) + + if (needsIndustryVoucherPrepare) { + const now = nowIso() + const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now) + .catch(() => null) + if (prepared) { + return prepared + } } } @@ -417,6 +471,35 @@ function parseTaskContext(task: Partial | null | undefined): JsonObject } } +function hasUsableIndustryVoucher(context: JsonObject = {}) { + const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher) + if (!voucher.voucherCode && !voucher.eticketId) { + return false + } + + return voucher.status !== 'DESTROYED' +} + +function normalizeIndustryVoucherContext(value: unknown): JsonObject { + const source = value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonObject + : {} + const status = String(source.status || 'UNUSED').trim().toUpperCase() + + return { + ...source, + oid: String(source.oid || '').trim(), + token: String(source.token || '').trim(), + eticketId: String(source.eticketId || source.voucherCode || '').trim(), + voucherCode: String(source.voucherCode || source.eticketId || '').trim(), + status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED', + validStartTime: Number(source.validStartTime || 0) || 0, + validEndTime: Number(source.validEndTime || 0) || 0, + verifiedAt: source.verifiedAt || null, + consumedAt: source.consumedAt || null, + } +} + async function queryKuaishouEticketConsumeDetailWithShopFallback({ ticketCode, shopId, diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index fbf8ec67..17537869 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -18,6 +18,7 @@ import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsa import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js"; import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js"; +import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js"; import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, @@ -631,6 +632,15 @@ function isPlainObject(value: unknown): value is JsonObject { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { + const voucher = isPlainObject(value.kuaishouIndustryVoucher) + ? value.kuaishouIndustryVoucher + : {}; + const voucherCode = String(voucher.voucherCode || voucher.eticketId || "").trim(); + const status = String(voucher.status || "UNUSED").trim().toUpperCase(); + return Boolean(voucherCode && status !== "DESTROYED"); +} + export function buildDispatchStockItems( deliveryItems: DispatchDeliveryItem[], { skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {} @@ -794,15 +804,46 @@ export async function returnKuaishouCloudFulfillmentTask( let nextResultCode = "kuaishou_cloud_completed"; let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销"; const consumeAlreadyCompleted = flow.consume.status === "success"; - const isIndustryTask = isIndustryEVoucherTask(task) + const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext); + const isIndustryTask = isIndustryEVoucherTask(task); + let industryVoucherContextPatch: JsonObject | null = null; if (consumeAlreadyCompleted) { consumeStatus = "success"; consumedAt = flow.consume.consumedAt || now; - } else if (isIndustryTask) { - consumeStatus = "success"; - consumedAt = flow.consume.consumedAt || now; - consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销"; + } else if (hasIndustryVoucher || isIndustryTask) { + const industryResult = hasIndustryVoucher + ? await consumeKuaishouIndustryVouchersForTask(task, { + source: String(options.source || "system_auto_finalize").trim() || "system_auto_finalize", + token: String(taskContext.kuaishouIndustryVoucher?.token || "").trim(), + consumeType: "delivery", + consumeTime: Date.now(), + }) + : { ok: true, consumed: [], failed: [] }; + + if (industryResult.ok) { + consumeStatus = "success"; + consumedAt = now; + const consumedVoucher = industryResult.consumed[0] || null; + if (consumedVoucher) { + industryVoucherContextPatch = { + oid: consumedVoucher.oid, + token: consumedVoucher.token, + eticketId: consumedVoucher.voucher_code, + voucherCode: consumedVoucher.voucher_code, + unitIndex: Number(consumedVoucher.unit_index || 0) || 0, + status: "CONSUMED", + validStartTime: Number(consumedVoucher.valid_start_time || 0) || 0, + validEndTime: Number(consumedVoucher.valid_end_time || 0) || 0, + consumedAt: now, + consumeSerialNum: consumedVoucher.consume_serial_num || `CONSUME-${consumedVoucher.voucher_code}`, + }; + } + } else { + consumeStatus = "failed"; + consumeErrorMessage = + industryResult.failed[0]?.errorMessage || "电子凭证核销回调失败,请人工处理"; + } } else if (!order) { consumeStatus = "failed"; consumeErrorMessage = "任务关联订单不存在,无法执行快手核销"; @@ -860,6 +901,16 @@ export async function returnKuaishouCloudFulfillmentTask( const nextContext = { ...taskContext, + ...(industryVoucherContextPatch + ? { + kuaishouIndustryVoucher: { + ...(isPlainObject(taskContext.kuaishouIndustryVoucher) + ? taskContext.kuaishouIndustryVoucher + : {}), + ...industryVoucherContextPatch, + }, + } + : {}), kuaishouCloudFulfillment: { ...flow, returnNumber: { diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts new file mode 100644 index 00000000..382ed031 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts @@ -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 + +export function isKuaishouFeifeiTask(task: Partial | 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, + order: Awaited>, + 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}` +} diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 297f98d3..d3f8b63c 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -2,6 +2,7 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/t import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' +import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { @@ -120,7 +121,7 @@ export async function syncDeliveryTasksForOrderWithDeps( const tasks: DeliveryTaskRow[] = [] for (const item of orderItems) { - const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) + const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey) if (!profile) { continue @@ -129,6 +130,7 @@ export async function syncDeliveryTasksForOrderWithDeps( const quantity = Math.max(1, Number(item.quantity || 1)) const fulfillmentConfig = parseJsonObject(profile.config_json) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) + const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) @@ -263,6 +265,25 @@ export async function syncDeliveryTasksForOrderWithDeps( notes: String(fulfillmentConfig.notes || '').trim(), } : null, + kuaishouFeifei: isKuaishouFeifeiExecutor(profile.executor_key) + ? { + flowType: 'kuaishou_feifei', + productCode: String(kuaishouFeifeiConfig.productCode || '').trim(), + productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(), + platformOrderNo: '', + orderNo: '', + rechargeStatus: 0, + rechargeStatusLabel: '', + rechargeResultMessage: '', + claimUrl: '', + consumeStatus: 'pending', + h5: { + entryUrl: '', + rechargeUrl: '', + }, + lastSyncedAt: null, + } + : null, }), createdAt, updatedAt: createdAt, @@ -330,6 +351,28 @@ async function preparePaidTask( }) } + if (isKuaishouFeifeiExecutor(task.executor_key)) { + try { + return await prepareKuaishouFeifeiTask(task as TaskRow) + } catch (error) { + const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' + const updatedTask = await updateDeliveryTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: 'not_required', + last_error: message, + result_code: 'kuaishou_feifei_prepare_failed', + result_message: message, + updated_at: now, + }) + await notifyManualReview({ + task: updatedTask || task, + reason: message, + source: 'kuaishou_feifei_prepare_failed', + }) + return updatedTask + } + } + if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { const lastError = task.last_error || '当前任务需要人工履约处理' const updatedTask = await updateDeliveryTask(task.id, { @@ -462,6 +505,53 @@ async function resolveDynamicCloudtentaclesProfile( } } +async function resolveDynamicFulfillmentProfile( + item: OrderItemRow, + getProfileByKey: (profileKey: string) => Promise, +): Promise { + return ( + await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) || + await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) + ) +} + +async function resolveDynamicKuaishouFeifeiProfile( + item: OrderItemRow, + getProfileByKey: (profileKey: string) => Promise, +): Promise { + const snapshot = parseJsonObject(item.item_snapshot_json) + const feifei = parseJsonObject(snapshot.kuaishouFeifei) + const productCode = String(feifei.productCode || '').trim() + if (!productCode) { + return null + } + + const profile = await getProfileByKey('kuaishou_feifei') + if (!profile) { + return null + } + + return { + ...profile, + profile_id: Number(profile.id || profile.profile_id || 0), + profile_key: 'kuaishou_feifei', + profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(), + executor_key: 'kuaishou_feifei', + requires_claim: true, + auto_dispatch: false, + config_json: { + flowType: 'kuaishou_feifei', + configId: `kuaishou_feifei:${productCode}`, + kuaishouFeifei: { + productCode, + productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(), + matchMode: String(feifei.matchMode || 'kuaishou_feifei_rule').trim(), + }, + notes: '91卡券商品名命中 kuaishou-feifei 商品规则', + }, + } +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -542,3 +632,7 @@ function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is function isKuaishouCloudExecutor(value: unknown): boolean { return String(value || '').trim() === 'kuaishou_ct_assisted' } + +function isKuaishouFeifeiExecutor(value: unknown): boolean { + return String(value || '').trim() === 'kuaishou_feifei' +} diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index e82c6134..d6ef89cb 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -6,6 +6,7 @@ import { import { replaceOrderItems } from '../../repositories/order-item-repo.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' import { resolveOrderItemForFulfillment } from './product-match-service.js' +import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js' import { nowIso } from '../../utils/time.js' import { logIntegration } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' @@ -184,6 +185,10 @@ export async function upsertOrderFromSource( ) const tasks = await syncDeliveryTasksForOrder(order, orderItems) + await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, { + source: `${sourceLabel}_order_upsert`, + now, + }) logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, { orderId: order.id, diff --git a/apps/backend/src/services/order/product-match-service.ts b/apps/backend/src/services/order/product-match-service.ts index 4802415d..f67c9572 100644 --- a/apps/backend/src/services/order/product-match-service.ts +++ b/apps/backend/src/services/order/product-match-service.ts @@ -3,6 +3,10 @@ import { resolveCloudtentaclesSkuByProductName, type CloudtentaclesNameMatchResult, } from './cloudtentacles-name-match-service.js' +import { + resolveKuaishouFeifeiProductByName, + type KuaishouFeifeiProductMatch, +} from '../platforms/kuaishou-feifei/product-rule-service.js' export type FulfillmentItem = { itemId?: string @@ -34,6 +38,7 @@ type FulfillmentItemCandidate = { externalSkuNameNormalized: string resolvedSkuCode: string cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null + kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null isConfigured: boolean } @@ -64,15 +69,18 @@ export async function resolveOrderItemForFulfillment({ externalSkuName, externalSkuNameNormalized, cloudtentaclesNameMatch, + kuaishouFeifeiMatch, } = candidate const resolvedSkuCode = pickFirstNonEmpty([ cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.productCode, externalSkuCode, externalItemId, ]) const resolvedSkuName = pickFirstNonEmpty([ cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.skuName, item.skuName, externalSkuName, resolvedSkuCode, @@ -100,6 +108,15 @@ export async function resolveOrderItemForFulfillment({ skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, } : null, + kuaishouFeifei: kuaishouFeifeiMatch + ? { + matchMode: kuaishouFeifeiMatch.matchMode, + productName: kuaishouFeifeiMatch.productName, + normalizedProductName: kuaishouFeifeiMatch.normalizedProductName, + productCode: kuaishouFeifeiMatch.productCode, + skuName: kuaishouFeifeiMatch.skuName, + } + : null, isConfigured: candidate.isConfigured, } @@ -170,8 +187,12 @@ async function resolveConfiguredItemCandidate({ if (isOpen91KuaishouOrder(provider, platform)) { const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) + const kuaishouFeifeiMatch = cloudtentaclesNameMatch + ? null + : resolveKuaishouFeifeiProductByName(externalSkuName) const resolvedSkuCode = pickFirstNonEmpty([ cloudtentaclesNameMatch?.cloudSkuName, + kuaishouFeifeiMatch?.productCode, externalSkuCode, externalItemId, ]) @@ -183,7 +204,8 @@ async function resolveConfiguredItemCandidate({ externalSkuNameNormalized, resolvedSkuCode, cloudtentaclesNameMatch, - isConfigured: Boolean(cloudtentaclesNameMatch), + kuaishouFeifeiMatch, + isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch), } } @@ -199,6 +221,7 @@ async function resolveConfiguredItemCandidate({ externalSkuNameNormalized, resolvedSkuCode, cloudtentaclesNameMatch: null, + kuaishouFeifeiMatch: null, isConfigured: false, } } diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/config.ts b/apps/backend/src/services/platforms/kuaishou-feifei/config.ts new file mode 100644 index 00000000..7c1de846 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/config.ts @@ -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 = {}) { + 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 +} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts new file mode 100644 index 00000000..85329f77 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts new file mode 100644 index 00000000..ff3f037e --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts @@ -0,0 +1,95 @@ +import crypto from 'node:crypto' + +import { createHttpError } from '../../../utils/http.js' +import { assertKuaishouFeifeiConfig } from './config.js' + +type JsonObject = Record + +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 {} + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts new file mode 100644 index 00000000..602938da --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts @@ -0,0 +1,75 @@ +import { getKuaishouFeifeiConfig } from './config.js' +import { kuaishouFeifeiRequest } from './http-client.js' + +type JsonObject = Record + +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, + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts new file mode 100644 index 00000000..0c3ee39e --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts @@ -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) +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts index 3134ac85..72c70ecf 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/consume-code-service.ts @@ -1,10 +1,9 @@ -import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' -import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js' +import { getTaskById, updateTask } from '../../../repositories/task-repo.js' +import { findKuaishouIndustryVoucherByCode } from '../../../repositories/kuaishou-industry-voucher-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' import { logWarn } from '../../../utils/logger.js' import { TASK_STATUS } from '../../../domain/task-status.js' import { - KUISHOU_INDUSTRY_PLATFORM, getKuaishouIndustryConfig, assertMatchingAppKey, } from './config.js' @@ -15,6 +14,8 @@ import { buildIndustryErrorResponse, } from './response.js' import { consumeCallback } from './consume-callback-service.js' +import { consumeKuaishouIndustryVoucher } from './voucher-service.js' +import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js' type JsonObject = Record @@ -30,87 +31,54 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) { const now = normalizeTimestampIso(new Date().toISOString()) const normalizedOid = params.oid - const order = await findLatestOrderByPlatformOrderId({ - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, - platformOrderId: normalizedOid, - }) - - if (!order) { - return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`) - } - - const tasks = await listTasksByOrderId(order.id) - const matchedIds = new Set(params.etickets.map((e) => String(e.id))) let consumedCount = 0 - for (const task of tasks) { - const taskId = String(task.id || '') - if (!matchedIds.has(taskId)) { + for (const eticket of params.etickets) { + const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid) + if (!voucher) { continue } - let contextJson: JsonObject = {} - try { - contextJson = typeof task.context_json === 'string' - ? JSON.parse(task.context_json) - : (task.context_json || {}) - } catch { - contextJson = {} - } - - const flow = contextJson.kuaishouCloudFulfillment || {} - const existingConsumes = Array.isArray(flow.consumeDetails) - ? contextJson.consumeDetails as JsonObject[] - : Array.isArray(contextJson.consumeDetails) - ? contextJson.consumeDetails as JsonObject[] - : [] - - const consumeDetail = { - serialNum: params.seriallNum, + const task = voucher.task_id ? await getTaskById(voucher.task_id) : null + const consumed = await consumeKuaishouIndustryVoucher(voucher, { + source: 'kuaishou_industry_consume_code', + token: params.token, + eticketType: params.eticketType, consumeType: params.consumeType, - consumeTime: params.consumeTime, - appointmentTime: params.appointmentTime || undefined, - storeName: params.storeName || undefined, - storeAddress: params.storeAddress || undefined, - expressCode: params.expressCode || undefined, - expressNo: params.expressNo || undefined, - consumePoiId: params.consumePoiId || undefined, - eticketType: params.eticketType || undefined, - ext: params.ext || undefined, + consumeTime: params.consumeTime || Date.now(), + storeName: params.storeName, + storeAddress: params.storeAddress, + expressCode: params.expressCode, + expressNo: params.expressNo, + serialNum: params.seriallNum, + skipCallback: true, + ...(task ? { task } : {}), + }) + + if (!consumed.ok || !consumed.voucher) { + continue } - const nextContext = { - ...contextJson, - kuaishouCloudFulfillment: { - ...flow, - consume: { - ...(flow.consume || {}), - status: 'success', - consumedAt: now, - consumeDetail, - }, - }, - consumeDetails: [...existingConsumes, consumeDetail], + if (task) { + const taskStatus = String(task.task_status || '') + const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN + const updatedTask = await updateTask(task.id, { + task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus, + delivery_status: isDispatched ? 'delivered' : task.delivery_status, + result_code: params.status, + result_message: `电子凭证核销方式: ${params.consumeType}`, + redeemed_at: isDispatched ? now : task.redeemed_at, + updated_at: now, + }) + + if (updatedTask) { + await attachKuaishouIndustryVoucherToTask(updatedTask, consumed.voucher, { + source: 'kuaishou_industry_consume_code', + now, + }) + } } - const taskStatus = String(task.task_status || '') - const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN - - const updatePayload: Record = { - 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++ } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts index 17026b7e..81296271 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/destroy-code-service.ts @@ -1,10 +1,12 @@ -import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' -import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { updateTask } from '../../../repositories/task-repo.js' +import { + listKuaishouIndustryVouchersByOid, + updateKuaishouIndustryVoucherByCode, +} from '../../../repositories/kuaishou-industry-voucher-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' import { logWarn } from '../../../utils/logger.js' +import { TASK_STATUS } from '../../../domain/task-status.js' import { - KUISHOU_INDUSTRY_PLATFORM, getKuaishouIndustryConfig, assertMatchingAppKey, } from './config.js' @@ -12,9 +14,9 @@ import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload import { assertKuaishouIndustrySignature } from './crypto.js' import { buildIndustrySuccessResponse, - buildIndustryErrorResponse, } from './response.js' import { destroyCallback } from './destroy-callback-service.js' +import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js' type JsonObject = Record @@ -30,42 +32,39 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) { const now = normalizeTimestampIso(new Date().toISOString()) const normalizedOid = params.oid - const order = await findLatestOrderByPlatformOrderId({ - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, - platformOrderId: normalizedOid, - }) + const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid) + const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean)) + const targetVouchers = targetIds.size > 0 + ? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim())) + : vouchers - if (!order) { - return buildIndustrySuccessResponse({ oid: normalizedOid }) - } - - const tasks = await listTasksByOrderId(order.id) - - if (params.etickets.length > 0) { - const targetIds = new Set(params.etickets.map((e) => String(e.id))) - - for (const task of tasks) { - const taskId = String(task.id || '') - if (targetIds.has(taskId)) { - await updateTask(taskId, { - task_status: 'destroyed', - delivery_status: 'destroyed', - result_code: params.reason, - result_message: `销毁原因: ${params.reason}`, - updated_at: now, - }) - } + for (const voucher of targetVouchers) { + const status = String(voucher.status || '').trim().toUpperCase() + if (status === 'CONSUMED') { + continue } - } else { - for (const task of tasks) { - await updateTask(task.id, { - task_status: 'cancelled', + + const updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, { + status: 'DESTROYED', + destroyedAt: now, + updatedAt: now, + }) + + if (voucher.task_id) { + const updatedTask = await updateTask(voucher.task_id, { + task_status: TASK_STATUS.CLOSED, delivery_status: 'cancelled', result_code: params.reason, - result_message: `订单关闭: ${params.reason}`, + result_message: `电子凭证销毁: ${params.reason}`, updated_at: now, }) + + if (updatedTask && updatedVoucher) { + await attachKuaishouIndustryVoucherToTask(updatedTask, updatedVoucher, { + source: 'kuaishou_industry_destroy_code', + now, + }) + } } } diff --git a/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts index 67c3cf69..5b5fc443 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/query-code-service.ts @@ -1,8 +1,8 @@ -import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' -import { listTasksByOrderId } from '../../../repositories/task-repo.js' -import type { TaskRow } from '../../../types/repository/rows.js' import { - KUISHOU_INDUSTRY_PLATFORM, + findKuaishouIndustryVoucherByCode, + listKuaishouIndustryVouchersByOid, +} from '../../../repositories/kuaishou-industry-voucher-repo.js' +import { getKuaishouIndustryConfig, assertMatchingAppKey, } from './config.js' @@ -11,9 +11,9 @@ import { assertKuaishouIndustrySignature } from './crypto.js' import { buildIndustrySuccessResponse, buildIndustryErrorResponse, - buildIndustryEticketItem, buildIndustryQueryCodeData, } from './response.js' +import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js' type JsonObject = Record @@ -28,25 +28,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) { const normalizedOid = params.oid - const order = await findLatestOrderByPlatformOrderId({ - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, - platformOrderId: normalizedOid, - }) - - if (!order) { - return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`) - } - - const tasks = await listTasksByOrderId(order.id) - if (params.eticketId) { - const matched = tasks.find((t) => String(t.id || '') === params.eticketId) + const matched = await findKuaishouIndustryVoucherByCode(params.eticketId, normalizedOid) if (!matched) { return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`) } - const eticket = buildEticketFromTask(matched, params.eticketType) + const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType) return buildIndustrySuccessResponse( buildIndustryQueryCodeData({ oid: normalizedOid, @@ -57,8 +45,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) { ) } - const etickets = tasks.map((task) => - buildEticketFromTask(task, params.eticketType), + const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid) + if (vouchers.length === 0) { + return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`) + } + + const etickets = vouchers.map((voucher) => + buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType), ) return buildIndustrySuccessResponse( @@ -70,46 +63,3 @@ export async function handleQueryCode(rawBody: JsonObject = {}) { }), ) } - -function buildEticketFromTask(task: TaskRow, eticketType: string) { - const taskId = String(task.id || '') - const taskStatus = String(task.task_status || '') - const status = mapTaskStatusToEticketStatus(taskStatus) - - let contextJson: JsonObject = {} - try { - contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {}) - } catch { - contextJson = {} - } - - return buildIndustryEticketItem({ - id: taskId, - status, - num: 1, - validStartTime: Number(contextJson.certActualStartTime || 0), - validEndTime: Number(contextJson.certActualEndTime || 0), - eticketType, - consumeDetails: [], - }) -} - -function mapTaskStatusToEticketStatus(taskStatus: string) { - switch (taskStatus) { - case 'destroyed': - case 'cancelled': - return 'DESTROYED' - case 'consumed': - case 'redeemed': - case 'completed': - case 'delivered': - return 'CONSUMED' - case 'unused': - case 'pending_payment': - case 'pending_delivery': - case 'pending_fulfill': - case 'pending_review': - default: - return 'UNUSED' - } -} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts index 74bf8353..158086be 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/send-code-service.ts @@ -1,13 +1,10 @@ -import { findLatestOrderByPlatformOrderId, createOrder } from '../../../repositories/order-repo.js' -import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' -import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js' -import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' +import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js' +import { listTasksByOrderId } from '../../../repositories/task-repo.js' +import { upsertKuaishouIndustryVoucher } from '../../../repositories/kuaishou-industry-voucher-repo.js' +import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js' import { normalizeTimestampIso } from '../../../utils/time.js' import { logWarn } from '../../../utils/logger.js' -import type { TaskRow } from '../../../types/repository/rows.js' import { - KUISHOU_INDUSTRY_PROVIDER, - KUISHOU_INDUSTRY_PLATFORM, getKuaishouIndustryConfig, assertMatchingAppKey, } from './config.js' @@ -15,13 +12,15 @@ import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js' import { assertKuaishouIndustrySignature } from './crypto.js' import { buildIndustrySuccessResponse, - buildIndustryErrorResponse, buildIndustryEticketItem, buildIndustrySendCodeData, } from './response.js' import { sendCallback } from './send-callback-service.js' - -const INDUSTRY_PROFILE_KEY = 'kuaishou-industry' +import { + buildKuaishouIndustryEticketFromVoucher, + resolveKuaishouIndustryVoucherValidity, +} from './voucher-service.js' +import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js' type JsonObject = Record @@ -35,112 +34,54 @@ export async function handleSendCode(rawBody: JsonObject = {}) { assertMatchingAppKey(params.appKey) const now = normalizeTimestampIso(new Date().toISOString()) + const nowMs = Date.now() const normalizedOid = params.oid - let order = await findLatestOrderByPlatformOrderId({ - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, + const order = await findLatestOrderByPlatformOrderId({ + provider: OPEN_91_PROVIDER, + platform: OPEN_91_PLATFORM, platformOrderId: normalizedOid, }) + const targetTaskCount = params.num > 0 ? params.num : 1 + const tasks = order ? await listTasksByOrderId(order.id) : [] + const validity = resolveKuaishouIndustryVoucherValidity(params, nowMs) + const vouchers = [] - if (!order) { - order = await createOrder({ - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, - shopId: config.shopId, - shopName: config.shopName, - platformOrderId: normalizedOid, - orderStatus: 'paid', - payStatus: 'paid', - buyerId: params.sellerId, - buyerName: '', - receiverContact: '', - totalAmount: '0', - currency: 'CNY', - rawPayloadJson: JSON.stringify(params), - paidAt: now, + for (let index = 0; index < targetTaskCount; index += 1) { + const unitIndex = index + 1 + const task = tasks.find((item) => Number(item.unit_index || 0) === unitIndex) || null + const voucher = await upsertKuaishouIndustryVoucher({ + oid: normalizedOid, + unitIndex, + token: params.token, + orderId: order?.id || null, + taskId: task?.id || null, + status: 'UNUSED', + validStartTime: validity.validStartTime, + validEndTime: validity.validEndTime, + rawPayloadJson: { + source: 'kuaishou-industry/send-code', + receivedAt: now, + body: params, + }, createdAt: now, updatedAt: now, }) - } - if (!order) { - return buildIndustryErrorResponse(4010003, '创建订单失败') - } - - const profile = await ensureIndustryProfile(now) - if (!profile) { - return buildIndustryErrorResponse(4010003, '创建履约配置失败') - } - - const existingTasks = await listTasksByOrderId(order.id) - const existingTaskCount = existingTasks.length - const targetTaskCount = params.num > 0 ? params.num : 1 - const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount) - - if (tasksToCreate > 0) { - const items = [] - for (let i = 0; i < tasksToCreate; i++) { - const unitIndex = existingTaskCount + i + 1 - items.push({ - skuCode: params.itemId || params.skuId || 'kuaishou-industry', - skuName: params.itemTitle || '行业电子凭证', - quantity: 1, - specJson: JSON.stringify({ - itemId: params.itemId, - skuId: params.skuId, - oid: normalizedOid, - }), - itemSnapshotJson: '{}', - createdAt: now, - updatedAt: now, - }) - } - - const orderItems = await replaceOrderItems(order.id, items) - - for (let i = 0; i < tasksToCreate; i++) { - const unitIndex = existingTaskCount + i + 1 - const item = orderItems[i] - - if (!item) { - continue - } - - await createTask({ - orderId: order.id, - orderItemId: item.id, - unitIndex, - taskNo: `${normalizedOid}-${unitIndex}`, - provider: config.provider, - platform: KUISHOU_INDUSTRY_PLATFORM, - shopId: config.shopId, - shopName: config.shopName, - platformOrderId: normalizedOid, - profileId: profile.id, - executorKey: INDUSTRY_PROFILE_KEY, - taskStatus: 'pending_payment', - deliveryStatus: 'pending', - automationMode: 'manual', - requiresClaim: true, - contextJson: JSON.stringify({ - token: params.token, - certExpireType: params.certExpireType, - certStartTime: params.certStartTime, - certEndTime: params.certEndTime, - certExpDays: params.certExpDays, - certActualStartTime: params.certActualStartTime, - certActualEndTime: params.certActualEndTime, - }), - createdAt: now, - updatedAt: now, - }) + if (voucher) { + vouchers.push(voucher) } } - const allTasks = await listTasksByOrderId(order.id) - const etickets = allTasks.map((task) => - buildEticketFromTask(task, params), + if (order) { + await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, { + source: 'kuaishou_industry_send_code', + now, + }) + } + + const etickets = vouchers.map((voucher) => + buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType), ) const response = buildIndustrySuccessResponse( @@ -164,72 +105,6 @@ export async function handleSendCode(rawBody: JsonObject = {}) { return response } -async function ensureIndustryProfile(now: string) { - const existing = await getFulfillmentProfileByKey(INDUSTRY_PROFILE_KEY) - - if (existing) { - return existing - } - - return upsertFulfillmentProfile({ - profileKey: INDUSTRY_PROFILE_KEY, - name: '快手行业电子凭证', - executorKey: INDUSTRY_PROFILE_KEY, - requiresClaim: true, - autoDispatch: false, - inventoryStrategy: 'static_pool', - configJson: {}, - createdAt: now, - updatedAt: now, - }) -} - -function buildEticketFromTask(task: TaskRow, params: ReturnType) { - const taskId = String(task.id || '') - const taskStatus = String(task.task_status || '') - const status = mapTaskStatusToEticketStatus(taskStatus) - - let contextJson: JsonObject = {} - try { - contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {}) - } catch { - contextJson = {} - } - - const validStartTime = Number(contextJson.certActualStartTime || params.certActualStartTime || 0) - const validEndTime = Number(contextJson.certActualEndTime || params.certActualEndTime || 0) - - return buildIndustryEticketItem({ - id: taskId, - status, - num: 1, - validStartTime, - validEndTime, - eticketType: params.eticketType, - consumeDetails: [], - }) -} - -function mapTaskStatusToEticketStatus(taskStatus: string) { - switch (taskStatus) { - case 'destroyed': - case 'cancelled': - return 'DESTROYED' - case 'consumed': - case 'redeemed': - case 'completed': - case 'delivered': - return 'CONSUMED' - case 'unused': - case 'pending_payment': - case 'pending_delivery': - case 'pending_fulfill': - case 'pending_review': - default: - return 'UNUSED' - } -} - function fireSendCallback(input: { oid: string sendType: string diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts new file mode 100644 index 00000000..e2e2725b --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts @@ -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 + +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, + } +} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts new file mode 100644 index 00000000..676bea13 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts new file mode 100644 index 00000000..5ecb22e6 --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-service.ts @@ -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 + +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[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 { + 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 [] + } +} diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 5b12ecd5..d3f4c735 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -3,6 +3,7 @@ import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } fr import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js' import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' +import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, @@ -175,6 +176,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: updatedAt: now, }, ]) + await bindKuaishouIndustryVouchersToOrderTasks(order, [], { + source: 'open91_pending_order', + now, + }) return { order, diff --git a/apps/backend/src/types/repository/inputs.ts b/apps/backend/src/types/repository/inputs.ts index f8c3bc34..2cc29e95 100644 --- a/apps/backend/src/types/repository/inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -127,3 +127,25 @@ export type TaskUpdatePatch = { artifacts_json?: string | Record state_json?: string | Record } + +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 | unknown[] + consumedAt?: string | null + destroyedAt?: string | null + rawPayloadJson?: string | Record + createdAt: string + updatedAt: string +} + +export type KuaishouIndustryVoucherUpdatePatch = Partial< + Omit +> diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index 9697c661..ad727c9c 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -38,6 +38,7 @@ export type TaskRow = { id: number order_id: number order_item_id: number + unit_index: number platform_order_id: string profile_id: number task_no: string @@ -103,6 +104,26 @@ export type TaskEventRow = { created_at: string } +export type KuaishouIndustryVoucherRow = { + id: number + voucher_code: string + oid: string + order_id: number | null + task_id: number | null + unit_index: number + token: string + status: string + valid_start_time: number | string + valid_end_time: number | string + consume_serial_num: string + consume_details_json: string | Record | unknown[] + consumed_at: string | null + destroyed_at: string | null + raw_payload_json: string | Record + created_at: string + updated_at: string +} + export type OrderListQueryResult = { items: OrderListRow[] total: number diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index caa33ca1..b37dd05b 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -5,6 +5,13 @@ export type AdminDefaultUser = { status?: string; }; +export type KuaishouFeifeiProductRule = { + productName: string; + productCode: string; + skuName?: string; + enabled?: boolean; +}; + export type RuntimeConfig = { server: { port: number; @@ -90,6 +97,14 @@ export type RuntimeConfig = { shopName: string; version: string; }; + kuaishouFeifei: { + baseUrl: string; + appKey: string; + appSecret: string; + timeoutMs: number; + notifyUrl: string; + productRules: KuaishouFeifeiProductRule[]; + }; }; cors: { allowedOrigins: string[]; diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index 10668ffa..16d855cf 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -17,7 +17,6 @@ declare module 'vue' { ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] - ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCol: typeof import('element-plus/es')['ElCol'] ElCollapse: typeof import('element-plus/es')['ElCollapse'] ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] @@ -32,7 +31,6 @@ declare module 'vue' { ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] - ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMenu: typeof import('element-plus/es')['ElMenu'] @@ -45,7 +43,6 @@ declare module 'vue' { ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] - ElSpace: typeof import('element-plus/es')['ElSpace'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 2eb79f68..d1370129 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -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 { tokenStatus: ClaimTokenStatus claimUrl: string - flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {}) + flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {}) task: ClaimTaskInfo order: ClaimOrderInfo orderItem: ClaimOrderItemInfo product: ClaimProductInfo session: unknown | null kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null + kuaishouFeifei: ClaimKuaishouFeifeiFlowInfo | null result: ClaimResultInfo | null } diff --git a/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue b/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue index 161d059a..153714f2 100644 --- a/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue +++ b/apps/frontend/src/views/claim/kuaishou-cloud/KuaishouCloudClaimView.vue @@ -34,6 +34,42 @@ const claim = useKuaishouCloudClaim(() => props.token)

{{ claim.errorMessage.value }}

+ +