接入多发货平台与电子凭证
This commit is contained in:
@@ -57,6 +57,14 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
shopName: "快手行业电子凭证",
|
||||
version: "1",
|
||||
},
|
||||
kuaishouFeifei: {
|
||||
baseUrl: "http://skin-exchange.yiquyou.icu",
|
||||
appKey: "",
|
||||
appSecret: "",
|
||||
timeoutMs: 10000,
|
||||
notifyUrl: "",
|
||||
productRules: [],
|
||||
},
|
||||
cloudtentacles: {
|
||||
baseUrl: "https://123.207.217.176",
|
||||
timeoutMs: 5000,
|
||||
|
||||
@@ -20,6 +20,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'BACKEND_PORT',
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_PASSWORD',
|
||||
|
||||
@@ -3,6 +3,7 @@ import process from "node:process";
|
||||
|
||||
import type {
|
||||
AdminDefaultUser,
|
||||
KuaishouFeifeiProductRule,
|
||||
RuntimeConfig,
|
||||
} from "../types/runtime-config.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ type RuntimeConfigValue =
|
||||
| number
|
||||
| boolean
|
||||
| AdminDefaultUser[]
|
||||
| KuaishouFeifeiProductRule[]
|
||||
| string[];
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
@@ -274,6 +276,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
"kuaishouIndustry",
|
||||
"version",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_BASE_URL", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"baseUrl",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_APP_KEY", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"appKey",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"appSecret",
|
||||
]),
|
||||
integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"timeoutMs",
|
||||
]),
|
||||
stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"notifyUrl",
|
||||
]),
|
||||
kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [
|
||||
"platforms",
|
||||
"kuaishouFeifei",
|
||||
"productRules",
|
||||
]),
|
||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||
];
|
||||
|
||||
@@ -355,6 +387,20 @@ function adminUsersJsonEnv(
|
||||
};
|
||||
}
|
||||
|
||||
function kuaishouFeifeiProductRulesEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
const parsed = parseJsonArray<KuaishouFeifeiProductRule>(rawValue);
|
||||
return parsed === null ? null : parsed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function corsOriginsEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
|
||||
@@ -48,6 +48,9 @@ export function validateRuntimeConfig(
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||
if (config.platforms?.kuaishouFeifei) {
|
||||
requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 })
|
||||
}
|
||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||
requireInteger(
|
||||
issues,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS kuaishou_industry_vouchers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
voucher_code TEXT NOT NULL UNIQUE,
|
||||
oid TEXT NOT NULL,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL,
|
||||
unit_index INTEGER NOT NULL,
|
||||
token TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'UNUSED',
|
||||
valid_start_time BIGINT NOT NULL DEFAULT 0,
|
||||
valid_end_time BIGINT NOT NULL DEFAULT 0,
|
||||
consume_serial_num TEXT NOT NULL DEFAULT '',
|
||||
consume_details_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
destroyed_at TIMESTAMPTZ,
|
||||
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE(oid, unit_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_oid
|
||||
ON kuaishou_industry_vouchers(oid, unit_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_order_id
|
||||
ON kuaishou_industry_vouchers(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_task_id
|
||||
ON kuaishou_industry_vouchers(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_status
|
||||
ON kuaishou_industry_vouchers(status);
|
||||
@@ -0,0 +1,270 @@
|
||||
import { query } from '../db/client.js'
|
||||
import type {
|
||||
KuaishouIndustryVoucherUpdatePatch,
|
||||
KuaishouIndustryVoucherUpsertInput,
|
||||
} from '../types/repository/inputs.js'
|
||||
import type { KuaishouIndustryVoucherRow } from '../types/repository/rows.js'
|
||||
|
||||
type VoucherPatchColumn = {
|
||||
column: string
|
||||
value: unknown
|
||||
cast?: string
|
||||
}
|
||||
|
||||
export async function upsertKuaishouIndustryVoucher(
|
||||
input: KuaishouIndustryVoucherUpsertInput,
|
||||
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||
const result = await query<KuaishouIndustryVoucherRow>(
|
||||
`
|
||||
WITH next_id AS (
|
||||
SELECT nextval(pg_get_serial_sequence('kuaishou_industry_vouchers', 'id')) AS id
|
||||
)
|
||||
INSERT INTO kuaishou_industry_vouchers (
|
||||
id,
|
||||
voucher_code,
|
||||
oid,
|
||||
order_id,
|
||||
task_id,
|
||||
unit_index,
|
||||
token,
|
||||
status,
|
||||
valid_start_time,
|
||||
valid_end_time,
|
||||
consume_serial_num,
|
||||
consume_details_json,
|
||||
consumed_at,
|
||||
destroyed_at,
|
||||
raw_payload_json,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
next_id.id,
|
||||
next_id.id::text,
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10::jsonb,
|
||||
$11,
|
||||
$12,
|
||||
$13::jsonb,
|
||||
$14,
|
||||
$15
|
||||
FROM next_id
|
||||
ON CONFLICT (oid, unit_index) DO UPDATE
|
||||
SET
|
||||
token = CASE
|
||||
WHEN EXCLUDED.token <> '' THEN EXCLUDED.token
|
||||
ELSE kuaishou_industry_vouchers.token
|
||||
END,
|
||||
order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id),
|
||||
task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id),
|
||||
valid_start_time = EXCLUDED.valid_start_time,
|
||||
valid_end_time = EXCLUDED.valid_end_time,
|
||||
raw_payload_json = EXCLUDED.raw_payload_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.oid,
|
||||
normalizeNullableId(input.orderId),
|
||||
normalizeNullableId(input.taskId),
|
||||
input.unitIndex,
|
||||
input.token || '',
|
||||
input.status || 'UNUSED',
|
||||
input.validStartTime || 0,
|
||||
input.validEndTime || 0,
|
||||
input.consumeSerialNum || '',
|
||||
stringifyJson(input.consumeDetailsJson ?? []),
|
||||
input.consumedAt || null,
|
||||
input.destroyedAt || null,
|
||||
stringifyJson(input.rawPayloadJson ?? {}),
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listKuaishouIndustryVouchersByOid(
|
||||
oid: string,
|
||||
): Promise<KuaishouIndustryVoucherRow[]> {
|
||||
const result = await query<KuaishouIndustryVoucherRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM kuaishou_industry_vouchers
|
||||
WHERE oid = $1
|
||||
ORDER BY unit_index ASC, id ASC
|
||||
`,
|
||||
[oid],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listKuaishouIndustryVouchersByTaskId(
|
||||
taskId: number | string,
|
||||
): Promise<KuaishouIndustryVoucherRow[]> {
|
||||
const result = await query<KuaishouIndustryVoucherRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM kuaishou_industry_vouchers
|
||||
WHERE task_id = $1
|
||||
ORDER BY unit_index ASC, id ASC
|
||||
`,
|
||||
[Number(taskId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function findKuaishouIndustryVoucherByCode(
|
||||
voucherCode: string,
|
||||
oid = '',
|
||||
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||
const normalizedCode = String(voucherCode || '').trim()
|
||||
const normalizedOid = String(oid || '').trim()
|
||||
if (!normalizedCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params: unknown[] = [normalizedCode]
|
||||
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
|
||||
if (normalizedOid) {
|
||||
params.push(normalizedOid)
|
||||
}
|
||||
|
||||
const result = await query<KuaishouIndustryVoucherRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM kuaishou_industry_vouchers
|
||||
WHERE voucher_code = $1
|
||||
${oidFilter}
|
||||
LIMIT 1
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function updateKuaishouIndustryVoucherByCode(
|
||||
voucherCode: string,
|
||||
patch: KuaishouIndustryVoucherUpdatePatch,
|
||||
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||
const normalizedCode = String(voucherCode || '').trim()
|
||||
if (!normalizedCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const columns = normalizeVoucherPatchColumns(patch)
|
||||
if (columns.length === 0) {
|
||||
return findKuaishouIndustryVoucherByCode(normalizedCode)
|
||||
}
|
||||
|
||||
const assignments = columns
|
||||
.map((item, index) => `${item.column} = $${index + 1}${item.cast || ''}`)
|
||||
.join(', ')
|
||||
const params = columns.map((item) => item.value)
|
||||
params.push(normalizedCode)
|
||||
|
||||
const result = await query<KuaishouIndustryVoucherRow>(
|
||||
`
|
||||
UPDATE kuaishou_industry_vouchers
|
||||
SET ${assignments}
|
||||
WHERE voucher_code = $${params.length}
|
||||
RETURNING *
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
function normalizeVoucherPatchColumns(
|
||||
patch: KuaishouIndustryVoucherUpdatePatch,
|
||||
): VoucherPatchColumn[] {
|
||||
const columns: VoucherPatchColumn[] = []
|
||||
|
||||
if (patch.orderId !== undefined) {
|
||||
columns.push({ column: 'order_id', value: normalizeNullableId(patch.orderId) })
|
||||
}
|
||||
|
||||
if (patch.taskId !== undefined) {
|
||||
columns.push({ column: 'task_id', value: normalizeNullableId(patch.taskId) })
|
||||
}
|
||||
|
||||
if (patch.token !== undefined) {
|
||||
columns.push({ column: 'token', value: patch.token || '' })
|
||||
}
|
||||
|
||||
if (patch.status !== undefined) {
|
||||
columns.push({ column: 'status', value: patch.status || 'UNUSED' })
|
||||
}
|
||||
|
||||
if (patch.validStartTime !== undefined) {
|
||||
columns.push({ column: 'valid_start_time', value: patch.validStartTime || 0 })
|
||||
}
|
||||
|
||||
if (patch.validEndTime !== undefined) {
|
||||
columns.push({ column: 'valid_end_time', value: patch.validEndTime || 0 })
|
||||
}
|
||||
|
||||
if (patch.consumeSerialNum !== undefined) {
|
||||
columns.push({ column: 'consume_serial_num', value: patch.consumeSerialNum || '' })
|
||||
}
|
||||
|
||||
if (patch.consumeDetailsJson !== undefined) {
|
||||
columns.push({
|
||||
column: 'consume_details_json',
|
||||
value: stringifyJson(patch.consumeDetailsJson ?? []),
|
||||
cast: '::jsonb',
|
||||
})
|
||||
}
|
||||
|
||||
if (patch.consumedAt !== undefined) {
|
||||
columns.push({ column: 'consumed_at', value: patch.consumedAt || null })
|
||||
}
|
||||
|
||||
if (patch.destroyedAt !== undefined) {
|
||||
columns.push({ column: 'destroyed_at', value: patch.destroyedAt || null })
|
||||
}
|
||||
|
||||
if (patch.rawPayloadJson !== undefined) {
|
||||
columns.push({
|
||||
column: 'raw_payload_json',
|
||||
value: stringifyJson(patch.rawPayloadJson ?? {}),
|
||||
cast: '::jsonb',
|
||||
})
|
||||
}
|
||||
|
||||
if (patch.updatedAt !== undefined) {
|
||||
columns.push({ column: 'updated_at', value: patch.updatedAt })
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
function normalizeNullableId(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
return JSON.stringify(value ?? {})
|
||||
}
|
||||
@@ -36,6 +36,15 @@ const CORE_PROFILES: CoreProfile[] = [
|
||||
inventoryStrategy: 'external_platform',
|
||||
requirements: [],
|
||||
},
|
||||
{
|
||||
profileKey: 'kuaishou_feifei',
|
||||
name: 'kuaishou-feifei 履约',
|
||||
executorKey: 'kuaishou_feifei',
|
||||
requiresClaim: true,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'external_platform',
|
||||
requirements: [],
|
||||
},
|
||||
]
|
||||
|
||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||
|
||||
@@ -87,6 +87,10 @@ export async function getClaimContext(token: unknown) {
|
||||
}
|
||||
|
||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
|
||||
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
|
||||
return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
|
||||
}
|
||||
|
||||
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
|
||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||
const displaySkuName = resolveClaimOrderItemDisplaySkuName(
|
||||
@@ -133,6 +137,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
product,
|
||||
session: null as null,
|
||||
kuaishouCloudFulfillment,
|
||||
kuaishouFeifei: null as null,
|
||||
result: task.redeemed_at
|
||||
? {
|
||||
resultCode: String(task.result_code || ''),
|
||||
@@ -144,6 +149,93 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
|
||||
}
|
||||
}
|
||||
|
||||
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
|
||||
const context = parseTaskContext(task)
|
||||
const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei)
|
||||
const product = {
|
||||
title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
|
||||
skuCode: String(orderItem.sku_code || '').trim(),
|
||||
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
|
||||
isBundle: false,
|
||||
items: [{
|
||||
cloudSkuId: 0,
|
||||
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
|
||||
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
|
||||
}],
|
||||
}
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
flowType: 'kuaishou_feifei',
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
executorKey: task.executor_key || '',
|
||||
requiresSupportReview: false,
|
||||
expiresAt: claimToken.expired_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
loginType: task.login_type,
|
||||
lastError: task.last_error,
|
||||
runtimeSessionId: task.runtime_session_id,
|
||||
},
|
||||
order: {
|
||||
orderId: order.id,
|
||||
platform: order.platform,
|
||||
platformOrderId: order.platform_order_id,
|
||||
payStatus: order.pay_status,
|
||||
orderStatus: order.order_status,
|
||||
totalAmount: formatFenToAmount(order.total_amount),
|
||||
totalAmountFen: normalizeFen(order.total_amount),
|
||||
currency: order.currency,
|
||||
},
|
||||
orderItem: {
|
||||
orderItemId: orderItem.id,
|
||||
skuCode: orderItem.sku_code,
|
||||
skuName: product.title,
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
product,
|
||||
session: null as null,
|
||||
kuaishouCloudFulfillment: null as null,
|
||||
kuaishouFeifei: flow,
|
||||
result: task.redeemed_at
|
||||
? {
|
||||
resultCode: String(task.result_code || ''),
|
||||
resultMessage: String(task.result_message || ''),
|
||||
screenshotReady: false,
|
||||
screenshotUrl: '',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mapClaimKuaishouFeifeiFulfillment(value: unknown) {
|
||||
const source = isPlainObject(value) ? value : {}
|
||||
const h5 = isPlainObject(source.h5) ? source.h5 : {}
|
||||
|
||||
return {
|
||||
flowType: 'kuaishou_feifei',
|
||||
productCode: String(source.productCode || '').trim(),
|
||||
productName: String(source.productName || '').trim(),
|
||||
platformOrderNo: String(source.platformOrderNo || '').trim(),
|
||||
orderNo: String(source.orderNo || '').trim(),
|
||||
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
|
||||
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
|
||||
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
||||
claimUrl: String(source.claimUrl || '').trim(),
|
||||
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||||
h5: {
|
||||
entryUrl: String(h5.entryUrl || '').trim(),
|
||||
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
||||
},
|
||||
lastSyncedAt: source.lastSyncedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClaimOrderItemDisplaySkuName(
|
||||
orderItem: OrderItemRow,
|
||||
kuaishouCloudFulfillment: JsonObject | null,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
rebindKuaishouCloudTaskRole,
|
||||
refreshKuaishouCloudTaskRoleInfo,
|
||||
} from '../fulfillment/kuaishou-cloud/index.js'
|
||||
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js'
|
||||
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
||||
import type { TaskRow } from '../../types/repository/rows.js'
|
||||
@@ -42,6 +43,7 @@ const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
||||
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
|
||||
|
||||
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
|
||||
const context = await getClaimContext(token)
|
||||
@@ -50,7 +52,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
const executorKey = String(context.task.executor_key || '').trim()
|
||||
|
||||
if (executorKey === 'kuaishou-industry') {
|
||||
return verifyIndustryeVoucherTicket(context, now)
|
||||
return verifyIndustryVoucherTicket(context, now)
|
||||
}
|
||||
|
||||
if (executorKey !== 'kuaishou_ct_assisted') {
|
||||
@@ -61,6 +63,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
}
|
||||
|
||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
|
||||
if (hasUsableIndustryVoucher(parseTaskContext(context.task))) {
|
||||
return verifyIndustryVoucherTicket(context, now)
|
||||
}
|
||||
|
||||
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
|
||||
if (!ticketCode) {
|
||||
@@ -243,10 +248,10 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
||||
return getKuaishouCloudClaimDetail(token)
|
||||
}
|
||||
|
||||
async function verifyIndustryeVoucherTicket(
|
||||
async function verifyIndustryVoucherTicket(
|
||||
context: Awaited<ReturnType<typeof getClaimContext>>,
|
||||
now: string,
|
||||
) {
|
||||
): Promise<ClaimDetailPayload> {
|
||||
const taskContext = parseTaskContext(context.task)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
|
||||
@@ -255,33 +260,58 @@ async function verifyIndustryeVoucherTicket(
|
||||
}
|
||||
|
||||
const industryContext = typeof taskContext === 'object' ? taskContext : {}
|
||||
const token = String(industryContext.token || '').trim()
|
||||
const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher)
|
||||
if (voucherContext.status === 'DESTROYED') {
|
||||
throw createHttpError('当前电子凭证已销毁,无法继续领取', {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_kuaishou_industry_voucher_destroyed',
|
||||
})
|
||||
}
|
||||
|
||||
const token = String(voucherContext.token || industryContext.token || '').trim()
|
||||
const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim()
|
||||
const certExpireType = Number(industryContext.certExpireType || 0)
|
||||
const certActualStartTime = Number(industryContext.certActualStartTime || 0)
|
||||
const certActualEndTime = Number(industryContext.certActualEndTime || 0)
|
||||
const certActualStartTime = Number(
|
||||
voucherContext.validStartTime || industryContext.certActualStartTime || 0,
|
||||
)
|
||||
const certActualEndTime = Number(
|
||||
voucherContext.validEndTime || industryContext.certActualEndTime || 0,
|
||||
)
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouIndustryVoucher: {
|
||||
...voucherContext,
|
||||
oid: voucherContext.oid || context.order.platform_order_id,
|
||||
token,
|
||||
eticketId: voucherCode,
|
||||
voucherCode,
|
||||
status: voucherContext.status || 'UNUSED',
|
||||
verifiedAt: voucherContext.verifiedAt || now,
|
||||
},
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: '',
|
||||
code: voucherCode,
|
||||
status: 'verified',
|
||||
capturedAt: flow.ticket.capturedAt || now,
|
||||
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
|
||||
verifiedAt: now,
|
||||
oid: context.order.platform_order_id,
|
||||
oid: voucherContext.oid || context.order.platform_order_id,
|
||||
formToken: token,
|
||||
leftCount: 0,
|
||||
leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1,
|
||||
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
status: 'pending',
|
||||
status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending',
|
||||
shopId: context.order.shop_id,
|
||||
shopName: context.order.shop_name,
|
||||
autoConsumeEnabled: true,
|
||||
consumedAt: voucherContext.status === 'CONSUMED'
|
||||
? voucherContext.consumedAt || flow.consume.consumedAt || now
|
||||
: flow.consume.consumedAt,
|
||||
},
|
||||
certInfo: {
|
||||
certExpireType,
|
||||
@@ -353,17 +383,41 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
export async function getKuaishouCloudClaimDetail(token: unknown) {
|
||||
export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
|
||||
const context = await getClaimContext(token)
|
||||
let task = context.task
|
||||
|
||||
const executorKey = String(task.executor_key || '').trim()
|
||||
|
||||
if (executorKey === 'kuaishou_feifei') {
|
||||
task = (await syncKuaishouFeifeiTaskStatus(task)) || task
|
||||
}
|
||||
|
||||
if (executorKey === 'kuaishou-industry') {
|
||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
|
||||
if (flow.consume.status !== 'success') {
|
||||
const now = nowIso()
|
||||
await verifyIndustryeVoucherTicket(context, now).catch(() => null)
|
||||
await verifyIndustryVoucherTicket(context, now).catch(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
if (executorKey === 'kuaishou_ct_assisted') {
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||
const needsIndustryVoucherPrepare =
|
||||
hasUsableIndustryVoucher(taskContext) &&
|
||||
(
|
||||
flow.ticket.status !== 'verified' ||
|
||||
(flow.binding.prepareStatus !== 'ready' && !flow.binding.bindUrl)
|
||||
)
|
||||
|
||||
if (needsIndustryVoucherPrepare) {
|
||||
const now = nowIso()
|
||||
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now)
|
||||
.catch(() => null)
|
||||
if (prepared) {
|
||||
return prepared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +471,35 @@ function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject
|
||||
}
|
||||
}
|
||||
|
||||
function hasUsableIndustryVoucher(context: JsonObject = {}) {
|
||||
const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher)
|
||||
if (!voucher.voucherCode && !voucher.eticketId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return voucher.status !== 'DESTROYED'
|
||||
}
|
||||
|
||||
function normalizeIndustryVoucherContext(value: unknown): JsonObject {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const status = String(source.status || 'UNUSED').trim().toUpperCase()
|
||||
|
||||
return {
|
||||
...source,
|
||||
oid: String(source.oid || '').trim(),
|
||||
token: String(source.token || '').trim(),
|
||||
eticketId: String(source.eticketId || source.voucherCode || '').trim(),
|
||||
voucherCode: String(source.voucherCode || source.eticketId || '').trim(),
|
||||
status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED',
|
||||
validStartTime: Number(source.validStartTime || 0) || 0,
|
||||
validEndTime: Number(source.validEndTime || 0) || 0,
|
||||
verifiedAt: source.verifiedAt || null,
|
||||
consumedAt: source.consumedAt || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function queryKuaishouEticketConsumeDetailWithShopFallback({
|
||||
ticketCode,
|
||||
shopId,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsa
|
||||
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
|
||||
import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js";
|
||||
import {
|
||||
getKuaishouEticketSourceConfig,
|
||||
resolveKuaishouEticketShopConfig,
|
||||
@@ -631,6 +632,15 @@ function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
|
||||
const voucher = isPlainObject(value.kuaishouIndustryVoucher)
|
||||
? value.kuaishouIndustryVoucher
|
||||
: {};
|
||||
const voucherCode = String(voucher.voucherCode || voucher.eticketId || "").trim();
|
||||
const status = String(voucher.status || "UNUSED").trim().toUpperCase();
|
||||
return Boolean(voucherCode && status !== "DESTROYED");
|
||||
}
|
||||
|
||||
export function buildDispatchStockItems(
|
||||
deliveryItems: DispatchDeliveryItem[],
|
||||
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
|
||||
@@ -794,15 +804,46 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
let nextResultCode = "kuaishou_cloud_completed";
|
||||
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
|
||||
const consumeAlreadyCompleted = flow.consume.status === "success";
|
||||
const isIndustryTask = isIndustryEVoucherTask(task)
|
||||
const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext);
|
||||
const isIndustryTask = isIndustryEVoucherTask(task);
|
||||
let industryVoucherContextPatch: JsonObject | null = null;
|
||||
|
||||
if (consumeAlreadyCompleted) {
|
||||
consumeStatus = "success";
|
||||
consumedAt = flow.consume.consumedAt || now;
|
||||
} else if (isIndustryTask) {
|
||||
consumeStatus = "success";
|
||||
consumedAt = flow.consume.consumedAt || now;
|
||||
consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销";
|
||||
} else if (hasIndustryVoucher || isIndustryTask) {
|
||||
const industryResult = hasIndustryVoucher
|
||||
? await consumeKuaishouIndustryVouchersForTask(task, {
|
||||
source: String(options.source || "system_auto_finalize").trim() || "system_auto_finalize",
|
||||
token: String(taskContext.kuaishouIndustryVoucher?.token || "").trim(),
|
||||
consumeType: "delivery",
|
||||
consumeTime: Date.now(),
|
||||
})
|
||||
: { ok: true, consumed: [], failed: [] };
|
||||
|
||||
if (industryResult.ok) {
|
||||
consumeStatus = "success";
|
||||
consumedAt = now;
|
||||
const consumedVoucher = industryResult.consumed[0] || null;
|
||||
if (consumedVoucher) {
|
||||
industryVoucherContextPatch = {
|
||||
oid: consumedVoucher.oid,
|
||||
token: consumedVoucher.token,
|
||||
eticketId: consumedVoucher.voucher_code,
|
||||
voucherCode: consumedVoucher.voucher_code,
|
||||
unitIndex: Number(consumedVoucher.unit_index || 0) || 0,
|
||||
status: "CONSUMED",
|
||||
validStartTime: Number(consumedVoucher.valid_start_time || 0) || 0,
|
||||
validEndTime: Number(consumedVoucher.valid_end_time || 0) || 0,
|
||||
consumedAt: now,
|
||||
consumeSerialNum: consumedVoucher.consume_serial_num || `CONSUME-${consumedVoucher.voucher_code}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage =
|
||||
industryResult.failed[0]?.errorMessage || "电子凭证核销回调失败,请人工处理";
|
||||
}
|
||||
} else if (!order) {
|
||||
consumeStatus = "failed";
|
||||
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
|
||||
@@ -860,6 +901,16 @@ export async function returnKuaishouCloudFulfillmentTask(
|
||||
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
...(industryVoucherContextPatch
|
||||
? {
|
||||
kuaishouIndustryVoucher: {
|
||||
...(isPlainObject(taskContext.kuaishouIndustryVoucher)
|
||||
? taskContext.kuaishouIndustryVoucher
|
||||
: {}),
|
||||
...industryVoucherContextPatch,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
returnNumber: {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||
import { nowIso } from '../../../utils/time.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
|
||||
import {
|
||||
createKuaishouFeifeiOrder,
|
||||
queryKuaishouFeifeiOrder,
|
||||
} from '../../platforms/kuaishou-feifei/order-service.js'
|
||||
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export function isKuaishouFeifeiTask(task: Partial<TaskRow> | null | undefined) {
|
||||
return String(task?.executor_key || '').trim() === 'kuaishou_feifei'
|
||||
}
|
||||
|
||||
export async function prepareKuaishouFeifeiTask(task: TaskRow) {
|
||||
if (!isKuaishouFeifeiTask(task)) {
|
||||
throw createHttpError('当前任务不是 kuaishou-feifei 履约任务', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_feifei_task_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
|
||||
const claimLinkState = await ensureTaskClaimLink(task)
|
||||
|
||||
if (flow.orderNo && flow.h5.rechargeUrl) {
|
||||
const nextContext = {
|
||||
...taskContext,
|
||||
kuaishouFeifei: {
|
||||
...flow,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
},
|
||||
}
|
||||
return updateTask(task.id, {
|
||||
task_status: TASK_STATUS.LINK_GENERATED,
|
||||
claim_token: claimLinkState.token || task.claim_token || '',
|
||||
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
|
||||
user_action_status: 'pending_claim',
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!flow.productCode) {
|
||||
throw createHttpError('kuaishou-feifei 商品编码缺失,请检查商品规则配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'kuaishou_feifei_product_code_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const platformOrderNo = flow.platformOrderNo || buildFeifeiPlatformOrderNo(task)
|
||||
const order = await createKuaishouFeifeiOrder({
|
||||
platformOrderNo,
|
||||
productCode: flow.productCode,
|
||||
platformBuyNum: 1,
|
||||
})
|
||||
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
|
||||
platformOrderNo,
|
||||
claimUrl: claimLinkState.claimUrl,
|
||||
syncedAt: now,
|
||||
})
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: TASK_STATUS.LINK_GENERATED,
|
||||
claim_token: claimLinkState.token || task.claim_token || '',
|
||||
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
|
||||
user_action_status: 'pending_claim',
|
||||
result_code: 'kuaishou_feifei_order_created',
|
||||
result_message: order.rechargeStatusLabel || 'kuaishou-feifei 订单已创建',
|
||||
context_json: JSON.stringify({
|
||||
...taskContext,
|
||||
kuaishouFeifei: nextFlow,
|
||||
}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
await createTaskEvent(
|
||||
task.id,
|
||||
'kuaishou_feifei_order_created',
|
||||
{
|
||||
platformOrderNo,
|
||||
orderNo: order.orderNo,
|
||||
productCode: flow.productCode,
|
||||
rechargeStatus: order.rechargeStatus,
|
||||
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||
rechargeUrl: order.h5.rechargeUrl,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
|
||||
if (!isKuaishouFeifeiTask(task)) {
|
||||
return task
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
|
||||
if (!flow.platformOrderNo && !flow.orderNo) {
|
||||
return task
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
const order = await queryKuaishouFeifeiOrder({
|
||||
platformOrderNo: flow.platformOrderNo,
|
||||
orderNo: flow.orderNo,
|
||||
})
|
||||
let nextTaskStatus = task.task_status
|
||||
let deliveryStatus = task.delivery_status
|
||||
let redeemedAt = task.redeemed_at
|
||||
let resultCode = task.result_code
|
||||
let resultMessage = task.result_message
|
||||
let lastError = task.last_error
|
||||
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
|
||||
syncedAt: now,
|
||||
claimUrl: flow.claimUrl,
|
||||
})
|
||||
|
||||
if (order.rechargeStatus === 30) {
|
||||
const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, {
|
||||
source: 'kuaishou_feifei_completed',
|
||||
consumeType: 'delivery',
|
||||
consumeTime: Date.now(),
|
||||
})
|
||||
|
||||
if (consumeResult.ok || consumeResult.vouchers.length === 0) {
|
||||
nextTaskStatus = TASK_STATUS.COMPLETED
|
||||
deliveryStatus = 'delivered'
|
||||
redeemedAt = redeemedAt || now
|
||||
resultCode = 'kuaishou_feifei_completed'
|
||||
resultMessage = order.rechargeStatusLabel || 'kuaishou-feifei 履约成功'
|
||||
lastError = ''
|
||||
nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required'
|
||||
} else {
|
||||
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
|
||||
resultCode = 'kuaishou_feifei_industry_consume_failed'
|
||||
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
|
||||
lastError = resultMessage
|
||||
nextFlow.consumeStatus = 'failed'
|
||||
}
|
||||
} else if ([40, 50, 60].includes(order.rechargeStatus)) {
|
||||
nextTaskStatus = order.rechargeStatus === 60 ? TASK_STATUS.CLOSED : TASK_STATUS.MANUAL_REVIEW
|
||||
resultCode = `kuaishou_feifei_status_${order.rechargeStatus}`
|
||||
resultMessage = order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常'
|
||||
lastError = resultMessage
|
||||
}
|
||||
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: nextTaskStatus,
|
||||
delivery_status: deliveryStatus,
|
||||
redeemed_at: redeemedAt,
|
||||
result_code: resultCode,
|
||||
result_message: resultMessage,
|
||||
last_error: lastError,
|
||||
context_json: JSON.stringify({
|
||||
...taskContext,
|
||||
kuaishouFeifei: nextFlow,
|
||||
}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return updatedTask || task
|
||||
}
|
||||
|
||||
export function normalizeKuaishouFeifeiFlow(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
|
||||
|
||||
return {
|
||||
flowType: 'kuaishou_feifei',
|
||||
productCode: String(source.productCode || '').trim(),
|
||||
productName: String(source.productName || '').trim(),
|
||||
platformOrderNo: String(source.platformOrderNo || '').trim(),
|
||||
orderNo: String(source.orderNo || '').trim(),
|
||||
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
|
||||
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
|
||||
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
||||
claimUrl: String(source.claimUrl || '').trim(),
|
||||
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||||
h5: {
|
||||
entryUrl: String(h5.entryUrl || '').trim(),
|
||||
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
||||
},
|
||||
lastSyncedAt: source.lastSyncedAt || null,
|
||||
raw: source.raw && typeof source.raw === 'object' ? source.raw : null,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeKuaishouFeifeiOrder(
|
||||
flow: ReturnType<typeof normalizeKuaishouFeifeiFlow>,
|
||||
order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>,
|
||||
patch: {
|
||||
platformOrderNo?: string
|
||||
claimUrl?: string
|
||||
syncedAt: string
|
||||
},
|
||||
) {
|
||||
return {
|
||||
...flow,
|
||||
productCode: order.productCode || flow.productCode,
|
||||
productName: order.productName || flow.productName,
|
||||
platformOrderNo: order.platformOrderNo || patch.platformOrderNo || flow.platformOrderNo,
|
||||
orderNo: order.orderNo || flow.orderNo,
|
||||
rechargeStatus: order.rechargeStatus,
|
||||
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||
rechargeResultMessage: order.rechargeResultMessage,
|
||||
claimUrl: patch.claimUrl || flow.claimUrl,
|
||||
h5: {
|
||||
entryUrl: order.h5.entryUrl || flow.h5.entryUrl,
|
||||
rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl,
|
||||
},
|
||||
lastSyncedAt: patch.syncedAt,
|
||||
raw: order.raw,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTaskClaimLink(task: TaskRow) {
|
||||
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
|
||||
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
|
||||
const expiredAt = task?.primary_claim_expires_at || task?.claim_expires_at || null
|
||||
|
||||
if (tokenStatus === 'active' && token && expiredAt && new Date(expiredAt).getTime() > Date.now()) {
|
||||
return {
|
||||
token,
|
||||
expiredAt,
|
||||
claimUrl: buildClaimUrl(token),
|
||||
}
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
return {
|
||||
token: claimToken.token,
|
||||
expiredAt: claimToken.expired_at,
|
||||
claimUrl: claimToken.claimUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFeifeiPlatformOrderNo(task: TaskRow) {
|
||||
const taskNo = String(task.task_no || '').trim()
|
||||
if (taskNo && taskNo.length <= 64) {
|
||||
return taskNo
|
||||
}
|
||||
|
||||
return `OS-FEIFEI-${task.id}`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/t
|
||||
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
||||
import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { randomId } from '../../utils/random.js'
|
||||
import {
|
||||
@@ -120,7 +121,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
const tasks: DeliveryTaskRow[] = []
|
||||
|
||||
for (const item of orderItems) {
|
||||
const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
|
||||
const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey)
|
||||
|
||||
if (!profile) {
|
||||
continue
|
||||
@@ -129,6 +130,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
|
||||
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
||||
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
||||
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
|
||||
@@ -263,6 +265,25 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
||||
notes: String(fulfillmentConfig.notes || '').trim(),
|
||||
}
|
||||
: null,
|
||||
kuaishouFeifei: isKuaishouFeifeiExecutor(profile.executor_key)
|
||||
? {
|
||||
flowType: 'kuaishou_feifei',
|
||||
productCode: String(kuaishouFeifeiConfig.productCode || '').trim(),
|
||||
productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(),
|
||||
platformOrderNo: '',
|
||||
orderNo: '',
|
||||
rechargeStatus: 0,
|
||||
rechargeStatusLabel: '',
|
||||
rechargeResultMessage: '',
|
||||
claimUrl: '',
|
||||
consumeStatus: 'pending',
|
||||
h5: {
|
||||
entryUrl: '',
|
||||
rechargeUrl: '',
|
||||
},
|
||||
lastSyncedAt: null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
@@ -330,6 +351,28 @@ async function preparePaidTask(
|
||||
})
|
||||
}
|
||||
|
||||
if (isKuaishouFeifeiExecutor(task.executor_key)) {
|
||||
try {
|
||||
return await prepareKuaishouFeifeiTask(task as TaskRow)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败'
|
||||
const updatedTask = await updateDeliveryTask(task.id, {
|
||||
task_status: TASK_STATUS.MANUAL_REVIEW,
|
||||
user_action_status: 'not_required',
|
||||
last_error: message,
|
||||
result_code: 'kuaishou_feifei_prepare_failed',
|
||||
result_message: message,
|
||||
updated_at: now,
|
||||
})
|
||||
await notifyManualReview({
|
||||
task: updatedTask || task,
|
||||
reason: message,
|
||||
source: 'kuaishou_feifei_prepare_failed',
|
||||
})
|
||||
return updatedTask
|
||||
}
|
||||
}
|
||||
|
||||
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
|
||||
const lastError = task.last_error || '当前任务需要人工履约处理'
|
||||
const updatedTask = await updateDeliveryTask(task.id, {
|
||||
@@ -462,6 +505,53 @@ async function resolveDynamicCloudtentaclesProfile(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDynamicFulfillmentProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
return (
|
||||
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
||||
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveDynamicKuaishouFeifeiProfile(
|
||||
item: OrderItemRow,
|
||||
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
|
||||
): Promise<FulfillmentBindingLike | null> {
|
||||
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||
const feifei = parseJsonObject(snapshot.kuaishouFeifei)
|
||||
const productCode = String(feifei.productCode || '').trim()
|
||||
if (!productCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await getProfileByKey('kuaishou_feifei')
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||
profile_key: 'kuaishou_feifei',
|
||||
profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(),
|
||||
executor_key: 'kuaishou_feifei',
|
||||
requires_claim: true,
|
||||
auto_dispatch: false,
|
||||
config_json: {
|
||||
flowType: 'kuaishou_feifei',
|
||||
configId: `kuaishou_feifei:${productCode}`,
|
||||
kuaishouFeifei: {
|
||||
productCode,
|
||||
productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(),
|
||||
matchMode: String(feifei.matchMode || 'kuaishou_feifei_rule').trim(),
|
||||
},
|
||||
notes: '91卡券商品名命中 kuaishou-feifei 商品规则',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
@@ -542,3 +632,7 @@ function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is
|
||||
function isKuaishouCloudExecutor(value: unknown): boolean {
|
||||
return String(value || '').trim() === 'kuaishou_ct_assisted'
|
||||
}
|
||||
|
||||
function isKuaishouFeifeiExecutor(value: unknown): boolean {
|
||||
return String(value || '').trim() === 'kuaishou_feifei'
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { logIntegration } from '../../utils/logger.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
@@ -184,6 +185,10 @@ export async function upsertOrderFromSource(
|
||||
)
|
||||
|
||||
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
|
||||
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
|
||||
source: `${sourceLabel}_order_upsert`,
|
||||
now,
|
||||
})
|
||||
|
||||
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
||||
orderId: order.id,
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
resolveCloudtentaclesSkuByProductName,
|
||||
type CloudtentaclesNameMatchResult,
|
||||
} from './cloudtentacles-name-match-service.js'
|
||||
import {
|
||||
resolveKuaishouFeifeiProductByName,
|
||||
type KuaishouFeifeiProductMatch,
|
||||
} from '../platforms/kuaishou-feifei/product-rule-service.js'
|
||||
|
||||
export type FulfillmentItem = {
|
||||
itemId?: string
|
||||
@@ -34,6 +38,7 @@ type FulfillmentItemCandidate = {
|
||||
externalSkuNameNormalized: string
|
||||
resolvedSkuCode: string
|
||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
||||
isConfigured: boolean
|
||||
}
|
||||
|
||||
@@ -64,15 +69,18 @@ export async function resolveOrderItemForFulfillment({
|
||||
externalSkuName,
|
||||
externalSkuNameNormalized,
|
||||
cloudtentaclesNameMatch,
|
||||
kuaishouFeifeiMatch,
|
||||
} = candidate
|
||||
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.productCode,
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
const resolvedSkuName = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.skuName,
|
||||
item.skuName,
|
||||
externalSkuName,
|
||||
resolvedSkuCode,
|
||||
@@ -100,6 +108,15 @@ export async function resolveOrderItemForFulfillment({
|
||||
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
|
||||
}
|
||||
: null,
|
||||
kuaishouFeifei: kuaishouFeifeiMatch
|
||||
? {
|
||||
matchMode: kuaishouFeifeiMatch.matchMode,
|
||||
productName: kuaishouFeifeiMatch.productName,
|
||||
normalizedProductName: kuaishouFeifeiMatch.normalizedProductName,
|
||||
productCode: kuaishouFeifeiMatch.productCode,
|
||||
skuName: kuaishouFeifeiMatch.skuName,
|
||||
}
|
||||
: null,
|
||||
isConfigured: candidate.isConfigured,
|
||||
}
|
||||
|
||||
@@ -170,8 +187,12 @@ async function resolveConfiguredItemCandidate({
|
||||
|
||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
|
||||
? null
|
||||
: resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||
const resolvedSkuCode = pickFirstNonEmpty([
|
||||
cloudtentaclesNameMatch?.cloudSkuName,
|
||||
kuaishouFeifeiMatch?.productCode,
|
||||
externalSkuCode,
|
||||
externalItemId,
|
||||
])
|
||||
@@ -183,7 +204,8 @@ async function resolveConfiguredItemCandidate({
|
||||
externalSkuNameNormalized,
|
||||
resolvedSkuCode,
|
||||
cloudtentaclesNameMatch,
|
||||
isConfigured: Boolean(cloudtentaclesNameMatch),
|
||||
kuaishouFeifeiMatch,
|
||||
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +221,7 @@ async function resolveConfiguredItemCandidate({
|
||||
externalSkuNameNormalized,
|
||||
resolvedSkuCode,
|
||||
cloudtentaclesNameMatch: null,
|
||||
kuaishouFeifeiMatch: null,
|
||||
isConfigured: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import type { RuntimeConfig } from '../../../types/runtime-config.js'
|
||||
|
||||
export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
|
||||
export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
|
||||
|
||||
type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
|
||||
|
||||
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
|
||||
const config = {
|
||||
...(runtimeConfig.platforms?.kuaishouFeifei || {}),
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
|
||||
appKey: String(config.appKey || '').trim(),
|
||||
appSecret: String(config.appSecret || '').trim(),
|
||||
timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000),
|
||||
notifyUrl: String(config.notifyUrl || '').trim(),
|
||||
productRules: Array.isArray(config.productRules) ? config.productRules : [],
|
||||
}
|
||||
}
|
||||
|
||||
export function assertKuaishouFeifeiConfig() {
|
||||
const config = getKuaishouFeifeiConfig()
|
||||
|
||||
if (!config.baseUrl) {
|
||||
throw createHttpError('kuaishou-feifei baseUrl 未配置', {
|
||||
statusCode: 500,
|
||||
errorCode: 'kuaishou_feifei_missing_base_url',
|
||||
})
|
||||
}
|
||||
|
||||
if (!config.appKey || !config.appSecret) {
|
||||
throw createHttpError('kuaishou-feifei App Key / App Secret 未配置', {
|
||||
statusCode: 500,
|
||||
errorCode: 'kuaishou_feifei_missing_credential',
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import crypto from 'node:crypto'
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { signKuaishouFeifeiPayload } from './http-client.js'
|
||||
|
||||
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
|
||||
const input = {
|
||||
appKey: 'app-key-1',
|
||||
appSecret: 'secret-1',
|
||||
timestamp: '1783394218',
|
||||
body: '{"platform_order_no":"DT-1","product_code":"10000001","platform_buy_num":1}',
|
||||
}
|
||||
const expected = crypto
|
||||
.createHmac('sha256', input.appSecret)
|
||||
.update(`${input.appKey}${input.timestamp}${input.body}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
|
||||
assert.equal(signKuaishouFeifeiPayload(input), expected)
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { assertKuaishouFeifeiConfig } from './config.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObject = {}) {
|
||||
const config = assertKuaishouFeifeiConfig()
|
||||
const body = JSON.stringify(payload)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const sign = signKuaishouFeifeiPayload({
|
||||
appKey: config.appKey,
|
||||
appSecret: config.appSecret,
|
||||
timestamp,
|
||||
body,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.baseUrl}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-app-key': config.appKey,
|
||||
'x-timestamp': timestamp,
|
||||
'x-sign': sign,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const text = await response.text()
|
||||
const json = parseJsonObject(text)
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_feifei_http_failed',
|
||||
context: {
|
||||
status: response.status,
|
||||
body: text,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Number(json.code || 0) !== 0) {
|
||||
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
|
||||
statusCode: 502,
|
||||
errorCode: 'kuaishou_feifei_business_failed',
|
||||
context: json,
|
||||
})
|
||||
}
|
||||
|
||||
return json
|
||||
} catch (error) {
|
||||
if ((error as Error)?.name === 'AbortError') {
|
||||
throw createHttpError('kuaishou-feifei 请求超时', {
|
||||
statusCode: 504,
|
||||
errorCode: 'kuaishou_feifei_timeout',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function signKuaishouFeifeiPayload({
|
||||
appKey,
|
||||
appSecret,
|
||||
timestamp,
|
||||
body,
|
||||
}: {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
timestamp: string
|
||||
body: string
|
||||
}) {
|
||||
return crypto
|
||||
.createHmac('sha256', appSecret)
|
||||
.update(`${appKey}${timestamp}${body}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): JsonObject {
|
||||
try {
|
||||
const parsed = JSON.parse(text || '{}')
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getKuaishouFeifeiConfig } from './config.js'
|
||||
import { kuaishouFeifeiRequest } from './http-client.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function createKuaishouFeifeiOrder(input: {
|
||||
platformOrderNo: string
|
||||
productCode: string
|
||||
platformBuyNum?: number
|
||||
platformAmount?: number
|
||||
playerAccount?: string
|
||||
playerGameRegion?: string
|
||||
playerGameSrv?: string
|
||||
playerGameRole?: string
|
||||
submitPlayer?: boolean
|
||||
notifyUrl?: string
|
||||
}) {
|
||||
const config = getKuaishouFeifeiConfig()
|
||||
const payload: JsonObject = {
|
||||
platform_order_no: input.platformOrderNo,
|
||||
product_code: input.productCode,
|
||||
platform_buy_num: Math.max(1, Number(input.platformBuyNum || 1) || 1),
|
||||
}
|
||||
|
||||
if (input.platformAmount != null) payload.platform_amount = input.platformAmount
|
||||
if (input.playerAccount) payload.player_account = input.playerAccount
|
||||
if (input.playerGameRegion) payload.player_game_region = input.playerGameRegion
|
||||
if (input.playerGameSrv) payload.player_game_srv = input.playerGameSrv
|
||||
if (input.playerGameRole) payload.player_game_role = input.playerGameRole
|
||||
if (input.submitPlayer === true) payload.submit_player = true
|
||||
if (input.notifyUrl || config.notifyUrl) payload.notify_url = input.notifyUrl || config.notifyUrl
|
||||
|
||||
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/store', payload)
|
||||
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||
}
|
||||
|
||||
export async function queryKuaishouFeifeiOrder(input: {
|
||||
platformOrderNo?: string
|
||||
orderNo?: string
|
||||
}) {
|
||||
const payload: JsonObject = {}
|
||||
if (input.platformOrderNo) payload.platform_order_no = input.platformOrderNo
|
||||
if (input.orderNo) payload.order_no = input.orderNo
|
||||
|
||||
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/show', payload)
|
||||
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||
}
|
||||
|
||||
export function mapKuaishouFeifeiOrder(value: unknown) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: {}
|
||||
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
|
||||
|
||||
return {
|
||||
orderNo: String(source.order_no || '').trim(),
|
||||
platformOrderNo: String(source.platform_order_no || '').trim(),
|
||||
productCode: String(source.product_code || '').trim(),
|
||||
productName: String(source.product_name || '').trim(),
|
||||
rechargeStatus: Number(source.recharge_status ?? source.status ?? 0) || 0,
|
||||
rechargeStatusLabel: String(source.recharge_status_label || source.status_label || '').trim(),
|
||||
pointsCharged: Number(source.points_charged || 0) || 0,
|
||||
playerAccount: String(source.player_account || '').trim(),
|
||||
platformBuyNum: Number(source.platform_buy_num || 1) || 1,
|
||||
rechargeResultMessage: String(source.recharge_result_message || '').trim(),
|
||||
createdAt: String(source.created_at || '').trim(),
|
||||
updatedAt: String(source.updated_at || '').trim(),
|
||||
rechargeFinishAt: String(source.recharge_finish_at || '').trim(),
|
||||
h5: {
|
||||
entryUrl: String(h5.entry_url || '').trim(),
|
||||
rechargeUrl: String(h5.recharge_url || '').trim(),
|
||||
},
|
||||
raw: source,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { runtimeConfig } from '../../../config/runtime.js'
|
||||
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
|
||||
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
|
||||
|
||||
export type KuaishouFeifeiProductMatch = {
|
||||
matchMode: 'kuaishou_feifei_rule'
|
||||
productName: string
|
||||
normalizedProductName: string
|
||||
productCode: string
|
||||
skuName: string
|
||||
}
|
||||
|
||||
export function resolveKuaishouFeifeiProductByName(
|
||||
productName: unknown,
|
||||
): KuaishouFeifeiProductMatch | null {
|
||||
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||
if (!normalizedProductName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rules = listKuaishouFeifeiProductRules()
|
||||
const matched = rules.find((rule) =>
|
||||
normalizeCloudtentaclesMatchName(rule.productName) === normalizedProductName,
|
||||
)
|
||||
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
|
||||
const productCode = String(matched.productCode || '').trim()
|
||||
if (!productCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
matchMode: 'kuaishou_feifei_rule',
|
||||
productName: String(matched.productName || productName || '').trim(),
|
||||
normalizedProductName,
|
||||
productCode,
|
||||
skuName: String(matched.skuName || matched.productName || productName || productCode).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function listKuaishouFeifeiProductRules(): KuaishouFeifeiProductRule[] {
|
||||
const rules = runtimeConfig.platforms?.kuaishouFeifei?.productRules
|
||||
return (Array.isArray(rules) ? rules : [])
|
||||
.map((rule) => ({
|
||||
productName: String(rule.productName || '').trim(),
|
||||
productCode: String(rule.productCode || '').trim(),
|
||||
skuName: String(rule.skuName || '').trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
}))
|
||||
.filter((rule) => rule.enabled && rule.productName && rule.productCode)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { getTaskById, updateTask } from '../../../repositories/task-repo.js'
|
||||
import { findKuaishouIndustryVoucherByCode } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import {
|
||||
KUISHOU_INDUSTRY_PLATFORM,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
buildIndustryErrorResponse,
|
||||
} from './response.js'
|
||||
import { consumeCallback } from './consume-callback-service.js'
|
||||
import { consumeKuaishouIndustryVoucher } from './voucher-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -30,87 +31,54 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
||||
const now = normalizeTimestampIso(new Date().toISOString())
|
||||
const normalizedOid = params.oid
|
||||
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||
}
|
||||
|
||||
const tasks = await listTasksByOrderId(order.id)
|
||||
const matchedIds = new Set(params.etickets.map((e) => String(e.id)))
|
||||
let consumedCount = 0
|
||||
|
||||
for (const task of tasks) {
|
||||
const taskId = String(task.id || '')
|
||||
if (!matchedIds.has(taskId)) {
|
||||
for (const eticket of params.etickets) {
|
||||
const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid)
|
||||
if (!voucher) {
|
||||
continue
|
||||
}
|
||||
|
||||
let contextJson: JsonObject = {}
|
||||
try {
|
||||
contextJson = typeof task.context_json === 'string'
|
||||
? JSON.parse(task.context_json)
|
||||
: (task.context_json || {})
|
||||
} catch {
|
||||
contextJson = {}
|
||||
}
|
||||
|
||||
const flow = contextJson.kuaishouCloudFulfillment || {}
|
||||
const existingConsumes = Array.isArray(flow.consumeDetails)
|
||||
? contextJson.consumeDetails as JsonObject[]
|
||||
: Array.isArray(contextJson.consumeDetails)
|
||||
? contextJson.consumeDetails as JsonObject[]
|
||||
: []
|
||||
|
||||
const consumeDetail = {
|
||||
serialNum: params.seriallNum,
|
||||
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
|
||||
const consumed = await consumeKuaishouIndustryVoucher(voucher, {
|
||||
source: 'kuaishou_industry_consume_code',
|
||||
token: params.token,
|
||||
eticketType: params.eticketType,
|
||||
consumeType: params.consumeType,
|
||||
consumeTime: params.consumeTime,
|
||||
appointmentTime: params.appointmentTime || undefined,
|
||||
storeName: params.storeName || undefined,
|
||||
storeAddress: params.storeAddress || undefined,
|
||||
expressCode: params.expressCode || undefined,
|
||||
expressNo: params.expressNo || undefined,
|
||||
consumePoiId: params.consumePoiId || undefined,
|
||||
eticketType: params.eticketType || undefined,
|
||||
ext: params.ext || undefined,
|
||||
consumeTime: params.consumeTime || Date.now(),
|
||||
storeName: params.storeName,
|
||||
storeAddress: params.storeAddress,
|
||||
expressCode: params.expressCode,
|
||||
expressNo: params.expressNo,
|
||||
serialNum: params.seriallNum,
|
||||
skipCallback: true,
|
||||
...(task ? { task } : {}),
|
||||
})
|
||||
|
||||
if (!consumed.ok || !consumed.voucher) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextContext = {
|
||||
...contextJson,
|
||||
kuaishouCloudFulfillment: {
|
||||
...flow,
|
||||
consume: {
|
||||
...(flow.consume || {}),
|
||||
status: 'success',
|
||||
consumedAt: now,
|
||||
consumeDetail,
|
||||
},
|
||||
},
|
||||
consumeDetails: [...existingConsumes, consumeDetail],
|
||||
if (task) {
|
||||
const taskStatus = String(task.task_status || '')
|
||||
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||
const updatedTask = await updateTask(task.id, {
|
||||
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
|
||||
delivery_status: isDispatched ? 'delivered' : task.delivery_status,
|
||||
result_code: params.status,
|
||||
result_message: `电子凭证核销方式: ${params.consumeType}`,
|
||||
redeemed_at: isDispatched ? now : task.redeemed_at,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (updatedTask) {
|
||||
await attachKuaishouIndustryVoucherToTask(updatedTask, consumed.voucher, {
|
||||
source: 'kuaishou_industry_consume_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const taskStatus = String(task.task_status || '')
|
||||
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
|
||||
result_code: params.status,
|
||||
result_message: `核销方式: ${params.consumeType}`,
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
if (isDispatched) {
|
||||
updatePayload.delivery_status = 'delivered'
|
||||
}
|
||||
|
||||
await updateTask(taskId, updatePayload as any)
|
||||
|
||||
consumedCount++
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||
import {
|
||||
KUISHOU_INDUSTRY_PLATFORM,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -12,9 +14,9 @@ import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
} from './response.js'
|
||||
import { destroyCallback } from './destroy-callback-service.js'
|
||||
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -30,42 +32,39 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
||||
const now = normalizeTimestampIso(new Date().toISOString())
|
||||
const normalizedOid = params.oid
|
||||
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||
const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean))
|
||||
const targetVouchers = targetIds.size > 0
|
||||
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||
: vouchers
|
||||
|
||||
if (!order) {
|
||||
return buildIndustrySuccessResponse({ oid: normalizedOid })
|
||||
}
|
||||
|
||||
const tasks = await listTasksByOrderId(order.id)
|
||||
|
||||
if (params.etickets.length > 0) {
|
||||
const targetIds = new Set(params.etickets.map((e) => String(e.id)))
|
||||
|
||||
for (const task of tasks) {
|
||||
const taskId = String(task.id || '')
|
||||
if (targetIds.has(taskId)) {
|
||||
await updateTask(taskId, {
|
||||
task_status: 'destroyed',
|
||||
delivery_status: 'destroyed',
|
||||
result_code: params.reason,
|
||||
result_message: `销毁原因: ${params.reason}`,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
for (const voucher of targetVouchers) {
|
||||
const status = String(voucher.status || '').trim().toUpperCase()
|
||||
if (status === 'CONSUMED') {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
for (const task of tasks) {
|
||||
await updateTask(task.id, {
|
||||
task_status: 'cancelled',
|
||||
|
||||
const updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
status: 'DESTROYED',
|
||||
destroyedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (voucher.task_id) {
|
||||
const updatedTask = await updateTask(voucher.task_id, {
|
||||
task_status: TASK_STATUS.CLOSED,
|
||||
delivery_status: 'cancelled',
|
||||
result_code: params.reason,
|
||||
result_message: `订单关闭: ${params.reason}`,
|
||||
result_message: `电子凭证销毁: ${params.reason}`,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
if (updatedTask && updatedVoucher) {
|
||||
await attachKuaishouIndustryVoucherToTask(updatedTask, updatedVoucher, {
|
||||
source: 'kuaishou_industry_destroy_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
import {
|
||||
KUISHOU_INDUSTRY_PLATFORM,
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import {
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -11,9 +11,9 @@ import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
buildIndustryEticketItem,
|
||||
buildIndustryQueryCodeData,
|
||||
} from './response.js'
|
||||
import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -28,25 +28,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
|
||||
const normalizedOid = params.oid
|
||||
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||
}
|
||||
|
||||
const tasks = await listTasksByOrderId(order.id)
|
||||
|
||||
if (params.eticketId) {
|
||||
const matched = tasks.find((t) => String(t.id || '') === params.eticketId)
|
||||
const matched = await findKuaishouIndustryVoucherByCode(params.eticketId, normalizedOid)
|
||||
if (!matched) {
|
||||
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
|
||||
}
|
||||
|
||||
const eticket = buildEticketFromTask(matched, params.eticketType)
|
||||
const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType)
|
||||
return buildIndustrySuccessResponse(
|
||||
buildIndustryQueryCodeData({
|
||||
oid: normalizedOid,
|
||||
@@ -57,8 +45,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
const etickets = tasks.map((task) =>
|
||||
buildEticketFromTask(task, params.eticketType),
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||
if (vouchers.length === 0) {
|
||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||
}
|
||||
|
||||
const etickets = vouchers.map((voucher) =>
|
||||
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||
)
|
||||
|
||||
return buildIndustrySuccessResponse(
|
||||
@@ -70,46 +63,3 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function buildEticketFromTask(task: TaskRow, eticketType: string) {
|
||||
const taskId = String(task.id || '')
|
||||
const taskStatus = String(task.task_status || '')
|
||||
const status = mapTaskStatusToEticketStatus(taskStatus)
|
||||
|
||||
let contextJson: JsonObject = {}
|
||||
try {
|
||||
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
|
||||
} catch {
|
||||
contextJson = {}
|
||||
}
|
||||
|
||||
return buildIndustryEticketItem({
|
||||
id: taskId,
|
||||
status,
|
||||
num: 1,
|
||||
validStartTime: Number(contextJson.certActualStartTime || 0),
|
||||
validEndTime: Number(contextJson.certActualEndTime || 0),
|
||||
eticketType,
|
||||
consumeDetails: [],
|
||||
})
|
||||
}
|
||||
|
||||
function mapTaskStatusToEticketStatus(taskStatus: string) {
|
||||
switch (taskStatus) {
|
||||
case 'destroyed':
|
||||
case 'cancelled':
|
||||
return 'DESTROYED'
|
||||
case 'consumed':
|
||||
case 'redeemed':
|
||||
case 'completed':
|
||||
case 'delivered':
|
||||
return 'CONSUMED'
|
||||
case 'unused':
|
||||
case 'pending_payment':
|
||||
case 'pending_delivery':
|
||||
case 'pending_fulfill':
|
||||
case 'pending_review':
|
||||
default:
|
||||
return 'UNUSED'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { findLatestOrderByPlatformOrderId, createOrder } from '../../../repositories/order-repo.js'
|
||||
import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js'
|
||||
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertKuaishouIndustryVoucher } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||
import {
|
||||
KUISHOU_INDUSTRY_PROVIDER,
|
||||
KUISHOU_INDUSTRY_PLATFORM,
|
||||
getKuaishouIndustryConfig,
|
||||
assertMatchingAppKey,
|
||||
} from './config.js'
|
||||
@@ -15,13 +12,15 @@ import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
|
||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||
import {
|
||||
buildIndustrySuccessResponse,
|
||||
buildIndustryErrorResponse,
|
||||
buildIndustryEticketItem,
|
||||
buildIndustrySendCodeData,
|
||||
} from './response.js'
|
||||
import { sendCallback } from './send-callback-service.js'
|
||||
|
||||
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
|
||||
import {
|
||||
buildKuaishouIndustryEticketFromVoucher,
|
||||
resolveKuaishouIndustryVoucherValidity,
|
||||
} from './voucher-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
@@ -35,112 +34,54 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
assertMatchingAppKey(params.appKey)
|
||||
|
||||
const now = normalizeTimestampIso(new Date().toISOString())
|
||||
const nowMs = Date.now()
|
||||
const normalizedOid = params.oid
|
||||
|
||||
let order = await findLatestOrderByPlatformOrderId({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
const order = await findLatestOrderByPlatformOrderId({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
platformOrderId: normalizedOid,
|
||||
})
|
||||
const targetTaskCount = params.num > 0 ? params.num : 1
|
||||
const tasks = order ? await listTasksByOrderId(order.id) : []
|
||||
const validity = resolveKuaishouIndustryVoucherValidity(params, nowMs)
|
||||
const vouchers = []
|
||||
|
||||
if (!order) {
|
||||
order = await createOrder({
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
shopId: config.shopId,
|
||||
shopName: config.shopName,
|
||||
platformOrderId: normalizedOid,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: params.sellerId,
|
||||
buyerName: '',
|
||||
receiverContact: '',
|
||||
totalAmount: '0',
|
||||
currency: 'CNY',
|
||||
rawPayloadJson: JSON.stringify(params),
|
||||
paidAt: now,
|
||||
for (let index = 0; index < targetTaskCount; index += 1) {
|
||||
const unitIndex = index + 1
|
||||
const task = tasks.find((item) => Number(item.unit_index || 0) === unitIndex) || null
|
||||
const voucher = await upsertKuaishouIndustryVoucher({
|
||||
oid: normalizedOid,
|
||||
unitIndex,
|
||||
token: params.token,
|
||||
orderId: order?.id || null,
|
||||
taskId: task?.id || null,
|
||||
status: 'UNUSED',
|
||||
validStartTime: validity.validStartTime,
|
||||
validEndTime: validity.validEndTime,
|
||||
rawPayloadJson: {
|
||||
source: 'kuaishou-industry/send-code',
|
||||
receivedAt: now,
|
||||
body: params,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return buildIndustryErrorResponse(4010003, '创建订单失败')
|
||||
}
|
||||
|
||||
const profile = await ensureIndustryProfile(now)
|
||||
if (!profile) {
|
||||
return buildIndustryErrorResponse(4010003, '创建履约配置失败')
|
||||
}
|
||||
|
||||
const existingTasks = await listTasksByOrderId(order.id)
|
||||
const existingTaskCount = existingTasks.length
|
||||
const targetTaskCount = params.num > 0 ? params.num : 1
|
||||
const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount)
|
||||
|
||||
if (tasksToCreate > 0) {
|
||||
const items = []
|
||||
for (let i = 0; i < tasksToCreate; i++) {
|
||||
const unitIndex = existingTaskCount + i + 1
|
||||
items.push({
|
||||
skuCode: params.itemId || params.skuId || 'kuaishou-industry',
|
||||
skuName: params.itemTitle || '行业电子凭证',
|
||||
quantity: 1,
|
||||
specJson: JSON.stringify({
|
||||
itemId: params.itemId,
|
||||
skuId: params.skuId,
|
||||
oid: normalizedOid,
|
||||
}),
|
||||
itemSnapshotJson: '{}',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
const orderItems = await replaceOrderItems(order.id, items)
|
||||
|
||||
for (let i = 0; i < tasksToCreate; i++) {
|
||||
const unitIndex = existingTaskCount + i + 1
|
||||
const item = orderItems[i]
|
||||
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
|
||||
await createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: item.id,
|
||||
unitIndex,
|
||||
taskNo: `${normalizedOid}-${unitIndex}`,
|
||||
provider: config.provider,
|
||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
||||
shopId: config.shopId,
|
||||
shopName: config.shopName,
|
||||
platformOrderId: normalizedOid,
|
||||
profileId: profile.id,
|
||||
executorKey: INDUSTRY_PROFILE_KEY,
|
||||
taskStatus: 'pending_payment',
|
||||
deliveryStatus: 'pending',
|
||||
automationMode: 'manual',
|
||||
requiresClaim: true,
|
||||
contextJson: JSON.stringify({
|
||||
token: params.token,
|
||||
certExpireType: params.certExpireType,
|
||||
certStartTime: params.certStartTime,
|
||||
certEndTime: params.certEndTime,
|
||||
certExpDays: params.certExpDays,
|
||||
certActualStartTime: params.certActualStartTime,
|
||||
certActualEndTime: params.certActualEndTime,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
if (voucher) {
|
||||
vouchers.push(voucher)
|
||||
}
|
||||
}
|
||||
|
||||
const allTasks = await listTasksByOrderId(order.id)
|
||||
const etickets = allTasks.map((task) =>
|
||||
buildEticketFromTask(task, params),
|
||||
if (order) {
|
||||
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
|
||||
source: 'kuaishou_industry_send_code',
|
||||
now,
|
||||
})
|
||||
}
|
||||
|
||||
const etickets = vouchers.map((voucher) =>
|
||||
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||
)
|
||||
|
||||
const response = buildIndustrySuccessResponse(
|
||||
@@ -164,72 +105,6 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
||||
return response
|
||||
}
|
||||
|
||||
async function ensureIndustryProfile(now: string) {
|
||||
const existing = await getFulfillmentProfileByKey(INDUSTRY_PROFILE_KEY)
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
return upsertFulfillmentProfile({
|
||||
profileKey: INDUSTRY_PROFILE_KEY,
|
||||
name: '快手行业电子凭证',
|
||||
executorKey: INDUSTRY_PROFILE_KEY,
|
||||
requiresClaim: true,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'static_pool',
|
||||
configJson: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
function buildEticketFromTask(task: TaskRow, params: ReturnType<typeof normalizeSendCodePayload>) {
|
||||
const taskId = String(task.id || '')
|
||||
const taskStatus = String(task.task_status || '')
|
||||
const status = mapTaskStatusToEticketStatus(taskStatus)
|
||||
|
||||
let contextJson: JsonObject = {}
|
||||
try {
|
||||
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
|
||||
} catch {
|
||||
contextJson = {}
|
||||
}
|
||||
|
||||
const validStartTime = Number(contextJson.certActualStartTime || params.certActualStartTime || 0)
|
||||
const validEndTime = Number(contextJson.certActualEndTime || params.certActualEndTime || 0)
|
||||
|
||||
return buildIndustryEticketItem({
|
||||
id: taskId,
|
||||
status,
|
||||
num: 1,
|
||||
validStartTime,
|
||||
validEndTime,
|
||||
eticketType: params.eticketType,
|
||||
consumeDetails: [],
|
||||
})
|
||||
}
|
||||
|
||||
function mapTaskStatusToEticketStatus(taskStatus: string) {
|
||||
switch (taskStatus) {
|
||||
case 'destroyed':
|
||||
case 'cancelled':
|
||||
return 'DESTROYED'
|
||||
case 'consumed':
|
||||
case 'redeemed':
|
||||
case 'completed':
|
||||
case 'delivered':
|
||||
return 'CONSUMED'
|
||||
case 'unused':
|
||||
case 'pending_payment':
|
||||
case 'pending_delivery':
|
||||
case 'pending_fulfill':
|
||||
case 'pending_review':
|
||||
default:
|
||||
return 'UNUSED'
|
||||
}
|
||||
}
|
||||
|
||||
function fireSendCallback(input: {
|
||||
oid: string
|
||||
sendType: string
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { updateTask } from '../../../repositories/task-repo.js'
|
||||
import {
|
||||
listKuaishouIndustryVouchersByOid,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||
import { normalizeKuaishouCloudFlow } from '../../fulfillment/kuaishou-cloud/domain.js'
|
||||
import type {
|
||||
KuaishouIndustryVoucherRow,
|
||||
OrderRow,
|
||||
TaskRow,
|
||||
} from '../../../types/repository/rows.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function bindKuaishouIndustryVouchersToOrderTasks(
|
||||
order: OrderRow | null | undefined,
|
||||
tasks: TaskRow[] = [],
|
||||
options: {
|
||||
source?: string
|
||||
now?: string
|
||||
} = {},
|
||||
) {
|
||||
const oid = String(order?.platform_order_id || '').trim()
|
||||
if (!order || !oid) {
|
||||
return []
|
||||
}
|
||||
|
||||
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
|
||||
const bound: KuaishouIndustryVoucherRow[] = []
|
||||
|
||||
for (const voucher of vouchers) {
|
||||
const task = resolveTaskForVoucher(voucher, tasks)
|
||||
const nextVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
orderId: order.id,
|
||||
...(task ? { taskId: task.id } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
const currentVoucher = nextVoucher || voucher
|
||||
bound.push(currentVoucher)
|
||||
|
||||
if (task) {
|
||||
await attachKuaishouIndustryVoucherToTask(task, currentVoucher, {
|
||||
source: options.source || 'voucher_bind',
|
||||
now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bound
|
||||
}
|
||||
|
||||
export async function attachKuaishouIndustryVoucherToTask(
|
||||
task: TaskRow,
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
options: {
|
||||
source?: string
|
||||
now?: string
|
||||
} = {},
|
||||
) {
|
||||
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||
const context = parseTaskContext(task)
|
||||
const nextVoucherContext = buildVoucherContext(voucher, context.kuaishouIndustryVoucher, now)
|
||||
const nextContext: JsonObject = {
|
||||
...context,
|
||||
kuaishouIndustryVoucher: nextVoucherContext,
|
||||
}
|
||||
|
||||
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' || context.kuaishouCloudFulfillment) {
|
||||
const flow = normalizeKuaishouCloudFlow(context.kuaishouCloudFulfillment)
|
||||
const consumedAt = voucher.consumed_at || nextVoucherContext.consumedAt || null
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
|
||||
nextContext.kuaishouCloudFulfillment = {
|
||||
...flow,
|
||||
ticket: {
|
||||
...flow.ticket,
|
||||
code: String(voucher.voucher_code || '').trim(),
|
||||
status: status === 'DESTROYED' ? 'destroyed' : 'verified',
|
||||
capturedAt: flow.ticket.capturedAt || now,
|
||||
capturedBy: flow.ticket.capturedBy || {
|
||||
source: options.source || 'kuaishou_industry_voucher',
|
||||
},
|
||||
verifiedAt: flow.ticket.verifiedAt || now,
|
||||
oid: String(voucher.oid || task.platform_order_id || '').trim(),
|
||||
formToken: String(voucher.token || '').trim(),
|
||||
leftCount: status === 'CONSUMED' || status === 'DESTROYED' ? 0 : 1,
|
||||
goodsTitle: flow.ticket.goodsTitle || task.sku_name || task.sku_code || '',
|
||||
},
|
||||
consume: {
|
||||
...flow.consume,
|
||||
status: status === 'CONSUMED' ? 'success' : flow.consume.status,
|
||||
autoConsumeEnabled: true,
|
||||
consumedAt: status === 'CONSUMED' ? consumedAt : flow.consume.consumedAt,
|
||||
errorMessage: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(context) === JSON.stringify(nextContext)) {
|
||||
return task
|
||||
}
|
||||
|
||||
return updateTask(task.id, {
|
||||
context_json: JSON.stringify(nextContext),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTaskForVoucher(voucher: KuaishouIndustryVoucherRow, tasks: TaskRow[]) {
|
||||
const unitIndex = Number(voucher.unit_index || 0)
|
||||
if (unitIndex > 0) {
|
||||
const matched = tasks.find((task) => Number(task.unit_index || 0) === unitIndex)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
return tasks[unitIndex - 1] || null
|
||||
}
|
||||
|
||||
function buildVoucherContext(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
existingValue: unknown,
|
||||
now: string,
|
||||
) {
|
||||
const existing = existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||
? existingValue as JsonObject
|
||||
: {}
|
||||
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||
|
||||
return {
|
||||
...existing,
|
||||
oid: String(voucher.oid || '').trim(),
|
||||
token: String(voucher.token || '').trim(),
|
||||
eticketId: String(voucher.voucher_code || '').trim(),
|
||||
voucherCode: String(voucher.voucher_code || '').trim(),
|
||||
unitIndex: Number(voucher.unit_index || 0) || 0,
|
||||
status,
|
||||
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||
verifiedAt: existing.verifiedAt || now,
|
||||
consumedAt: voucher.consumed_at || existing.consumedAt || null,
|
||||
destroyedAt: voucher.destroyed_at || existing.destroyedAt || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveKuaishouIndustryVoucherValidity } from './voucher-service.js'
|
||||
|
||||
test('resolveKuaishouIndustryVoucherValidity uses certExpDays when explicit times are zero', () => {
|
||||
const nowMs = 1783394218325
|
||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||
certActualStartTime: 0,
|
||||
certActualEndTime: 0,
|
||||
certStartTime: 0,
|
||||
certEndTime: 0,
|
||||
certExpDays: 3,
|
||||
}, nowMs)
|
||||
|
||||
assert.equal(validity.validStartTime, nowMs)
|
||||
assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000)
|
||||
})
|
||||
|
||||
test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => {
|
||||
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||
certActualStartTime: 1000,
|
||||
certActualEndTime: 5000,
|
||||
certStartTime: 2000,
|
||||
certEndTime: 6000,
|
||||
certExpDays: 3,
|
||||
}, 9000)
|
||||
|
||||
assert.equal(validity.validStartTime, 1000)
|
||||
assert.equal(validity.validEndTime, 5000)
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||
import {
|
||||
findKuaishouIndustryVoucherByCode,
|
||||
listKuaishouIndustryVouchersByTaskId,
|
||||
updateKuaishouIndustryVoucherByCode,
|
||||
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||
import { logWarn } from '../../../utils/logger.js'
|
||||
import { buildIndustryEticketItem } from './response.js'
|
||||
import { consumeCallback } from './consume-callback-service.js'
|
||||
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3
|
||||
|
||||
export function resolveKuaishouIndustryVoucherValidity(
|
||||
input: {
|
||||
certActualStartTime?: unknown
|
||||
certStartTime?: unknown
|
||||
certActualEndTime?: unknown
|
||||
certEndTime?: unknown
|
||||
certExpDays?: unknown
|
||||
} = {},
|
||||
nowMs = Date.now(),
|
||||
) {
|
||||
const certActualStartTime = normalizePositiveTimestamp(input.certActualStartTime)
|
||||
const certStartTime = normalizePositiveTimestamp(input.certStartTime)
|
||||
const certActualEndTime = normalizePositiveTimestamp(input.certActualEndTime)
|
||||
const certEndTime = normalizePositiveTimestamp(input.certEndTime)
|
||||
const certExpDays = normalizePositiveInteger(
|
||||
input.certExpDays,
|
||||
KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS,
|
||||
)
|
||||
const durationMs = certExpDays * 86_400_000
|
||||
const validStartTime = certActualStartTime || certStartTime || normalizePositiveTimestamp(nowMs)
|
||||
const validEndTime =
|
||||
certActualEndTime ||
|
||||
certEndTime ||
|
||||
(certStartTime ? certStartTime + durationMs : validStartTime + durationMs)
|
||||
|
||||
return {
|
||||
validStartTime,
|
||||
validEndTime,
|
||||
certExpDays,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildKuaishouIndustryEticketFromVoucher(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
eticketType = '',
|
||||
) {
|
||||
return buildIndustryEticketItem({
|
||||
id: String(voucher.voucher_code || ''),
|
||||
status: normalizeVoucherStatus(voucher.status),
|
||||
num: 1,
|
||||
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||
eticketType,
|
||||
consumeDetails: resolveVoucherConsumeDetails(voucher),
|
||||
})
|
||||
}
|
||||
|
||||
export async function consumeKuaishouIndustryVouchersForTask(
|
||||
task: TaskRow,
|
||||
input: {
|
||||
source?: string
|
||||
token?: string
|
||||
eticketType?: string
|
||||
consumeType?: string
|
||||
consumeTime?: number
|
||||
storeName?: string
|
||||
storeAddress?: string
|
||||
expressCode?: string
|
||||
expressNo?: string
|
||||
} = {},
|
||||
) {
|
||||
const vouchers = await resolveTaskVouchers(task)
|
||||
const consumed: KuaishouIndustryVoucherRow[] = []
|
||||
const failed: Array<{ voucher: KuaishouIndustryVoucherRow; errorMessage: string }> = []
|
||||
|
||||
for (const voucher of vouchers) {
|
||||
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
|
||||
source: input.source || 'fulfillment_completed',
|
||||
consumeType: input.consumeType || 'delivery',
|
||||
consumeTime: input.consumeTime || Date.now(),
|
||||
task,
|
||||
}
|
||||
if (input.token !== undefined) consumeInput.token = input.token
|
||||
if (input.eticketType !== undefined) consumeInput.eticketType = input.eticketType
|
||||
if (input.storeName !== undefined) consumeInput.storeName = input.storeName
|
||||
if (input.storeAddress !== undefined) consumeInput.storeAddress = input.storeAddress
|
||||
if (input.expressCode !== undefined) consumeInput.expressCode = input.expressCode
|
||||
if (input.expressNo !== undefined) consumeInput.expressNo = input.expressNo
|
||||
|
||||
const result = await consumeKuaishouIndustryVoucher(voucher, consumeInput)
|
||||
|
||||
if (result.ok && result.voucher) {
|
||||
consumed.push(result.voucher)
|
||||
continue
|
||||
}
|
||||
|
||||
failed.push({
|
||||
voucher,
|
||||
errorMessage: result.errorMessage || '电子凭证核销回调失败',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
vouchers,
|
||||
consumed,
|
||||
failed,
|
||||
ok: vouchers.length > 0 && failed.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeKuaishouIndustryVoucher(
|
||||
voucher: KuaishouIndustryVoucherRow,
|
||||
input: {
|
||||
source?: string
|
||||
token?: string
|
||||
eticketType?: string
|
||||
consumeType?: string
|
||||
consumeTime?: number
|
||||
storeName?: string
|
||||
storeAddress?: string
|
||||
expressCode?: string
|
||||
expressNo?: string
|
||||
task?: TaskRow
|
||||
serialNum?: string
|
||||
skipCallback?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const currentStatus = normalizeVoucherStatus(voucher.status)
|
||||
if (currentStatus === 'CONSUMED') {
|
||||
return {
|
||||
ok: true,
|
||||
voucher,
|
||||
callbackSuccess: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStatus === 'DESTROYED') {
|
||||
return {
|
||||
ok: false,
|
||||
voucher,
|
||||
callbackSuccess: false,
|
||||
errorMessage: '电子凭证已销毁,不能核销',
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const consumeTime = Number(input.consumeTime || Date.now()) || Date.now()
|
||||
const consumeType = String(input.consumeType || 'delivery').trim() || 'delivery'
|
||||
const serialNum = String(input.serialNum || voucher.consume_serial_num || `CONSUME-${voucher.voucher_code}`).trim()
|
||||
const token = String(input.token || voucher.token || '').trim()
|
||||
const consumeDetail = {
|
||||
serialNum,
|
||||
consumeType,
|
||||
consumeTime,
|
||||
storeName: input.storeName || undefined,
|
||||
storeAddress: input.storeAddress || undefined,
|
||||
expressCode: input.expressCode || undefined,
|
||||
expressNo: input.expressNo || undefined,
|
||||
source: input.source || 'system',
|
||||
}
|
||||
|
||||
const callbackResult = input.skipCallback
|
||||
? { success: true as const }
|
||||
: await consumeCallback({
|
||||
oid: voucher.oid,
|
||||
etickets: [{
|
||||
id: voucher.voucher_code,
|
||||
num: 1,
|
||||
status: 'CONSUMED',
|
||||
}],
|
||||
status: 'CONSUMED',
|
||||
consumeType,
|
||||
consumeTime,
|
||||
token,
|
||||
seriallNum: serialNum,
|
||||
...(input.storeName ? { storeName: input.storeName } : {}),
|
||||
...(input.storeAddress ? { storeAddress: input.storeAddress } : {}),
|
||||
...(input.expressCode ? { expressCode: input.expressCode } : {}),
|
||||
...(input.expressNo ? { expressNo: input.expressNo } : {}),
|
||||
...(input.eticketType ? { eticketType: input.eticketType } : {}),
|
||||
})
|
||||
|
||||
if (!callbackResult.success) {
|
||||
return {
|
||||
ok: false,
|
||||
voucher,
|
||||
callbackSuccess: false,
|
||||
errorMessage: callbackResult.error || '电子凭证核销回调失败',
|
||||
}
|
||||
}
|
||||
|
||||
const nextDetails = appendConsumeDetail(resolveVoucherConsumeDetails(voucher), consumeDetail)
|
||||
const updated = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||
status: 'CONSUMED',
|
||||
consumeSerialNum: serialNum,
|
||||
consumeDetailsJson: nextDetails,
|
||||
consumedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (input.task) {
|
||||
await createTaskEvent(
|
||||
input.task.id,
|
||||
'kuaishou_industry_voucher_consumed',
|
||||
{
|
||||
oid: voucher.oid,
|
||||
voucherCode: voucher.voucher_code,
|
||||
serialNum,
|
||||
consumeType,
|
||||
consumeTime,
|
||||
source: input.source || 'system',
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
voucher: updated || voucher,
|
||||
callbackSuccess: true,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[]> {
|
||||
const taskId = Number(task?.id || 0)
|
||||
if (taskId > 0) {
|
||||
return listKuaishouIndustryVouchersByTaskId(taskId)
|
||||
}
|
||||
|
||||
const context = parseJsonObject(task?.context_json)
|
||||
const voucher = parseJsonObject(context.kuaishouIndustryVoucher)
|
||||
const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim()
|
||||
const oid = String(voucher.oid || task?.platform_order_id || '').trim()
|
||||
if (!voucherCode) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
return findKuaishouIndustryVoucherByCode(voucherCode, oid)
|
||||
.then((row) => row ? [row] : [])
|
||||
.catch((error) => {
|
||||
logWarn('[kuaishou-industry/voucher]', '按上下文查询电子凭证失败', {
|
||||
taskId: task?.id || null,
|
||||
voucherCode,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function resolveVoucherConsumeDetails(voucher: KuaishouIndustryVoucherRow): JsonObject[] {
|
||||
const parsed = parseJsonArray(voucher.consume_details_json)
|
||||
if (parsed.length > 0) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
if (normalizeVoucherStatus(voucher.status) !== 'CONSUMED' || !voucher.consume_serial_num) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
serialNum: voucher.consume_serial_num,
|
||||
consumeType: 'delivery',
|
||||
consumeTime: voucher.consumed_at ? Date.parse(voucher.consumed_at) : 0,
|
||||
}]
|
||||
}
|
||||
|
||||
function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): JsonObject[] {
|
||||
const serialNum = String(nextDetail.serialNum || '').trim()
|
||||
if (serialNum && details.some((item) => String(item.serialNum || '').trim() === serialNum)) {
|
||||
return details
|
||||
}
|
||||
|
||||
return [...details, nextDetail]
|
||||
}
|
||||
|
||||
function normalizeVoucherStatus(value: unknown): string {
|
||||
const normalized = String(value || '').trim().toUpperCase()
|
||||
if (normalized === 'CONSUMED' || normalized === 'DESTROYED') {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return 'UNUSED'
|
||||
}
|
||||
|
||||
function normalizePositiveTimestamp(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): JsonObject {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '{}'))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): JsonObject[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is JsonObject =>
|
||||
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || '[]'))
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is JsonObject =>
|
||||
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||
)
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } fr
|
||||
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||
import {
|
||||
OPEN_91_PLATFORM,
|
||||
OPEN_91_PROVIDER,
|
||||
@@ -175,6 +176,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
||||
updatedAt: now,
|
||||
},
|
||||
])
|
||||
await bindKuaishouIndustryVouchersToOrderTasks(order, [], {
|
||||
source: 'open91_pending_order',
|
||||
now,
|
||||
})
|
||||
|
||||
return {
|
||||
order,
|
||||
|
||||
@@ -127,3 +127,25 @@ export type TaskUpdatePatch = {
|
||||
artifacts_json?: string | Record<string, unknown>
|
||||
state_json?: string | Record<string, unknown>
|
||||
}
|
||||
|
||||
export type KuaishouIndustryVoucherUpsertInput = {
|
||||
oid: string
|
||||
unitIndex: number
|
||||
token?: string
|
||||
orderId?: number | string | null
|
||||
taskId?: number | string | null
|
||||
status?: string
|
||||
validStartTime?: number
|
||||
validEndTime?: number
|
||||
consumeSerialNum?: string
|
||||
consumeDetailsJson?: string | Record<string, unknown> | unknown[]
|
||||
consumedAt?: string | null
|
||||
destroyedAt?: string | null
|
||||
rawPayloadJson?: string | Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type KuaishouIndustryVoucherUpdatePatch = Partial<
|
||||
Omit<KuaishouIndustryVoucherUpsertInput, 'oid' | 'unitIndex' | 'createdAt'>
|
||||
>
|
||||
|
||||
@@ -38,6 +38,7 @@ export type TaskRow = {
|
||||
id: number
|
||||
order_id: number
|
||||
order_item_id: number
|
||||
unit_index: number
|
||||
platform_order_id: string
|
||||
profile_id: number
|
||||
task_no: string
|
||||
@@ -103,6 +104,26 @@ export type TaskEventRow = {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type KuaishouIndustryVoucherRow = {
|
||||
id: number
|
||||
voucher_code: string
|
||||
oid: string
|
||||
order_id: number | null
|
||||
task_id: number | null
|
||||
unit_index: number
|
||||
token: string
|
||||
status: string
|
||||
valid_start_time: number | string
|
||||
valid_end_time: number | string
|
||||
consume_serial_num: string
|
||||
consume_details_json: string | Record<string, unknown> | unknown[]
|
||||
consumed_at: string | null
|
||||
destroyed_at: string | null
|
||||
raw_payload_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type OrderListQueryResult = {
|
||||
items: OrderListRow[]
|
||||
total: number
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Vendored
-3
@@ -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']
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,42 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
||||
<p>{{ claim.errorMessage.value }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="claim.detail.value?.flowType === 'kuaishou_feifei' && claim.feifei.value">
|
||||
<section class="content-card feifei-card">
|
||||
<div class="feifei-main">
|
||||
<p class="feifei-label">kuaishou-feifei</p>
|
||||
<h2>{{ claim.product.value?.title || claim.orderItem.value?.skuName || '商品领取' }}</h2>
|
||||
<p class="feifei-status">
|
||||
{{ claim.feifei.value.rechargeStatusLabel || '待领取' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="feifei-meta">
|
||||
<div>
|
||||
<dt>订单号</dt>
|
||||
<dd>{{ claim.order.value?.platformOrderId || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>平台单号</dt>
|
||||
<dd>{{ claim.feifei.value.orderNo || claim.feifei.value.platformOrderNo || '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!claim.feifei.value.h5.rechargeUrl && !claim.feifei.value.h5.entryUrl"
|
||||
@click="claim.openFeifeiUrl(false)"
|
||||
>
|
||||
打开领取链接
|
||||
</el-button>
|
||||
|
||||
<p v-if="claim.task.value?.lastError" class="feifei-error">
|
||||
{{ claim.task.value.lastError }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="claim.detail.value && claim.flow.value">
|
||||
<ClaimTicketStep
|
||||
v-if="claim.currentStep.value === 1"
|
||||
@@ -145,6 +181,60 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.feifei-card {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.feifei-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feifei-label {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.feifei-main h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.feifei-status {
|
||||
margin: 0;
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feifei-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feifei-meta div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.feifei-meta dt {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.feifei-meta dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.feifei-error {
|
||||
margin: 0;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
// ── derived data ────────────────────────────────────────
|
||||
|
||||
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||
const feifei = computed(() => detail.value?.kuaishouFeifei || null)
|
||||
const order = computed(() => detail.value?.order || null)
|
||||
const orderItem = computed(() => detail.value?.orderItem || null)
|
||||
const product = computed(() => detail.value?.product || null)
|
||||
@@ -55,6 +56,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
|
||||
const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
|
||||
const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
|
||||
const isFeifeiFlow = computed(() => detail.value?.flowType === 'kuaishou_feifei')
|
||||
const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
|
||||
|
||||
const isBindUrlExpired = computed(() => {
|
||||
@@ -125,6 +127,9 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
)
|
||||
|
||||
const currentStep = computed(() => {
|
||||
if (isFeifeiFlow.value) {
|
||||
return isCompleted.value ? 4 : 2
|
||||
}
|
||||
if (hasRedeemResult.value) {
|
||||
return 4
|
||||
}
|
||||
@@ -138,6 +143,9 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
})
|
||||
|
||||
const progressText = computed(() => {
|
||||
if (isFeifeiFlow.value) {
|
||||
return feifei.value?.rechargeStatusLabel || '请打开领取链接'
|
||||
}
|
||||
if (hasRedeemResult.value) {
|
||||
return '兑换结果已生成'
|
||||
}
|
||||
@@ -365,6 +373,19 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
window.open(bindUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function openFeifeiUrl(useCurrentPage = false) {
|
||||
const url = String(feifei.value?.h5.rechargeUrl || feifei.value?.h5.entryUrl || '').trim()
|
||||
if (!url) {
|
||||
showError('领取链接还没准备好,请稍后刷新')
|
||||
return
|
||||
}
|
||||
if (useCurrentPage) {
|
||||
window.location.assign(url)
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// ── polling ─────────────────────────────────────────────
|
||||
|
||||
function stopPolling() {
|
||||
@@ -457,6 +478,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
qrCodeDataUrl,
|
||||
// derived data
|
||||
flow,
|
||||
feifei,
|
||||
order,
|
||||
orderItem,
|
||||
product,
|
||||
@@ -464,6 +486,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
// computed status
|
||||
roleName,
|
||||
roleId,
|
||||
isFeifeiFlow,
|
||||
isTicketVerified,
|
||||
isBindUrlExpired,
|
||||
isBindingPrepared,
|
||||
@@ -493,6 +516,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
||||
rebindRole,
|
||||
confirmRedeem,
|
||||
openBindUrl,
|
||||
openFeifeiUrl,
|
||||
// utility
|
||||
formatAdminDateTime,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user