接入多发货平台与电子凭证
This commit is contained in:
@@ -57,6 +57,14 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
shopName: "快手行业电子凭证",
|
shopName: "快手行业电子凭证",
|
||||||
version: "1",
|
version: "1",
|
||||||
},
|
},
|
||||||
|
kuaishouFeifei: {
|
||||||
|
baseUrl: "http://skin-exchange.yiquyou.icu",
|
||||||
|
appKey: "",
|
||||||
|
appSecret: "",
|
||||||
|
timeoutMs: 10000,
|
||||||
|
notifyUrl: "",
|
||||||
|
productRules: [],
|
||||||
|
},
|
||||||
cloudtentacles: {
|
cloudtentacles: {
|
||||||
baseUrl: "https://123.207.217.176",
|
baseUrl: "https://123.207.217.176",
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'BACKEND_PORT',
|
'BACKEND_PORT',
|
||||||
'CADDY_SITE_ADDR',
|
'CADDY_SITE_ADDR',
|
||||||
'CHOKIDAR_USEPOLLING',
|
'CHOKIDAR_USEPOLLING',
|
||||||
|
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||||
'NPM_CONFIG_REGISTRY',
|
'NPM_CONFIG_REGISTRY',
|
||||||
'POSTGRES_DB',
|
'POSTGRES_DB',
|
||||||
'POSTGRES_PASSWORD',
|
'POSTGRES_PASSWORD',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import process from "node:process";
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminDefaultUser,
|
AdminDefaultUser,
|
||||||
|
KuaishouFeifeiProductRule,
|
||||||
RuntimeConfig,
|
RuntimeConfig,
|
||||||
} from "../types/runtime-config.js";
|
} from "../types/runtime-config.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ type RuntimeConfigValue =
|
|||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| AdminDefaultUser[]
|
| AdminDefaultUser[]
|
||||||
|
| KuaishouFeifeiProductRule[]
|
||||||
| string[];
|
| string[];
|
||||||
type RuntimeEnv = Record<string, string | undefined>;
|
type RuntimeEnv = Record<string, string | undefined>;
|
||||||
|
|
||||||
@@ -274,6 +276,36 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
"kuaishouIndustry",
|
"kuaishouIndustry",
|
||||||
"version",
|
"version",
|
||||||
]),
|
]),
|
||||||
|
stringEnv("KUAISHOU_FEIFEI_BASE_URL", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"baseUrl",
|
||||||
|
]),
|
||||||
|
stringEnv("KUAISHOU_FEIFEI_APP_KEY", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"appKey",
|
||||||
|
]),
|
||||||
|
stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"appSecret",
|
||||||
|
]),
|
||||||
|
integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"timeoutMs",
|
||||||
|
]),
|
||||||
|
stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"notifyUrl",
|
||||||
|
]),
|
||||||
|
kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [
|
||||||
|
"platforms",
|
||||||
|
"kuaishouFeifei",
|
||||||
|
"productRules",
|
||||||
|
]),
|
||||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -355,6 +387,20 @@ function adminUsersJsonEnv(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kuaishouFeifeiProductRulesEnv(
|
||||||
|
env: string,
|
||||||
|
configPath: RuntimeConfigPath
|
||||||
|
): EnvOverride {
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
path: configPath,
|
||||||
|
read(rawValue) {
|
||||||
|
const parsed = parseJsonArray<KuaishouFeifeiProductRule>(rawValue);
|
||||||
|
return parsed === null ? null : parsed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function corsOriginsEnv(
|
function corsOriginsEnv(
|
||||||
env: string,
|
env: string,
|
||||||
configPath: RuntimeConfigPath
|
configPath: RuntimeConfigPath
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export function validateRuntimeConfig(
|
|||||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||||
|
if (config.platforms?.kuaishouFeifei) {
|
||||||
|
requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 })
|
||||||
|
}
|
||||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||||
requireInteger(
|
requireInteger(
|
||||||
issues,
|
issues,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS kuaishou_industry_vouchers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
voucher_code TEXT NOT NULL UNIQUE,
|
||||||
|
oid TEXT NOT NULL,
|
||||||
|
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||||
|
task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL,
|
||||||
|
unit_index INTEGER NOT NULL,
|
||||||
|
token TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'UNUSED',
|
||||||
|
valid_start_time BIGINT NOT NULL DEFAULT 0,
|
||||||
|
valid_end_time BIGINT NOT NULL DEFAULT 0,
|
||||||
|
consume_serial_num TEXT NOT NULL DEFAULT '',
|
||||||
|
consume_details_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
destroyed_at TIMESTAMPTZ,
|
||||||
|
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
UNIQUE(oid, unit_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_oid
|
||||||
|
ON kuaishou_industry_vouchers(oid, unit_index);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_order_id
|
||||||
|
ON kuaishou_industry_vouchers(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_task_id
|
||||||
|
ON kuaishou_industry_vouchers(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_status
|
||||||
|
ON kuaishou_industry_vouchers(status);
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { query } from '../db/client.js'
|
||||||
|
import type {
|
||||||
|
KuaishouIndustryVoucherUpdatePatch,
|
||||||
|
KuaishouIndustryVoucherUpsertInput,
|
||||||
|
} from '../types/repository/inputs.js'
|
||||||
|
import type { KuaishouIndustryVoucherRow } from '../types/repository/rows.js'
|
||||||
|
|
||||||
|
type VoucherPatchColumn = {
|
||||||
|
column: string
|
||||||
|
value: unknown
|
||||||
|
cast?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertKuaishouIndustryVoucher(
|
||||||
|
input: KuaishouIndustryVoucherUpsertInput,
|
||||||
|
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||||
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
|
`
|
||||||
|
WITH next_id AS (
|
||||||
|
SELECT nextval(pg_get_serial_sequence('kuaishou_industry_vouchers', 'id')) AS id
|
||||||
|
)
|
||||||
|
INSERT INTO kuaishou_industry_vouchers (
|
||||||
|
id,
|
||||||
|
voucher_code,
|
||||||
|
oid,
|
||||||
|
order_id,
|
||||||
|
task_id,
|
||||||
|
unit_index,
|
||||||
|
token,
|
||||||
|
status,
|
||||||
|
valid_start_time,
|
||||||
|
valid_end_time,
|
||||||
|
consume_serial_num,
|
||||||
|
consume_details_json,
|
||||||
|
consumed_at,
|
||||||
|
destroyed_at,
|
||||||
|
raw_payload_json,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
next_id.id,
|
||||||
|
next_id.id::text,
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
$6,
|
||||||
|
$7,
|
||||||
|
$8,
|
||||||
|
$9,
|
||||||
|
$10::jsonb,
|
||||||
|
$11,
|
||||||
|
$12,
|
||||||
|
$13::jsonb,
|
||||||
|
$14,
|
||||||
|
$15
|
||||||
|
FROM next_id
|
||||||
|
ON CONFLICT (oid, unit_index) DO UPDATE
|
||||||
|
SET
|
||||||
|
token = CASE
|
||||||
|
WHEN EXCLUDED.token <> '' THEN EXCLUDED.token
|
||||||
|
ELSE kuaishou_industry_vouchers.token
|
||||||
|
END,
|
||||||
|
order_id = COALESCE(kuaishou_industry_vouchers.order_id, EXCLUDED.order_id),
|
||||||
|
task_id = COALESCE(kuaishou_industry_vouchers.task_id, EXCLUDED.task_id),
|
||||||
|
valid_start_time = EXCLUDED.valid_start_time,
|
||||||
|
valid_end_time = EXCLUDED.valid_end_time,
|
||||||
|
raw_payload_json = EXCLUDED.raw_payload_json,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.oid,
|
||||||
|
normalizeNullableId(input.orderId),
|
||||||
|
normalizeNullableId(input.taskId),
|
||||||
|
input.unitIndex,
|
||||||
|
input.token || '',
|
||||||
|
input.status || 'UNUSED',
|
||||||
|
input.validStartTime || 0,
|
||||||
|
input.validEndTime || 0,
|
||||||
|
input.consumeSerialNum || '',
|
||||||
|
stringifyJson(input.consumeDetailsJson ?? []),
|
||||||
|
input.consumedAt || null,
|
||||||
|
input.destroyedAt || null,
|
||||||
|
stringifyJson(input.rawPayloadJson ?? {}),
|
||||||
|
input.createdAt,
|
||||||
|
input.updatedAt,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listKuaishouIndustryVouchersByOid(
|
||||||
|
oid: string,
|
||||||
|
): Promise<KuaishouIndustryVoucherRow[]> {
|
||||||
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM kuaishou_industry_vouchers
|
||||||
|
WHERE oid = $1
|
||||||
|
ORDER BY unit_index ASC, id ASC
|
||||||
|
`,
|
||||||
|
[oid],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listKuaishouIndustryVouchersByTaskId(
|
||||||
|
taskId: number | string,
|
||||||
|
): Promise<KuaishouIndustryVoucherRow[]> {
|
||||||
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM kuaishou_industry_vouchers
|
||||||
|
WHERE task_id = $1
|
||||||
|
ORDER BY unit_index ASC, id ASC
|
||||||
|
`,
|
||||||
|
[Number(taskId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findKuaishouIndustryVoucherByCode(
|
||||||
|
voucherCode: string,
|
||||||
|
oid = '',
|
||||||
|
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||||
|
const normalizedCode = String(voucherCode || '').trim()
|
||||||
|
const normalizedOid = String(oid || '').trim()
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: unknown[] = [normalizedCode]
|
||||||
|
const oidFilter = normalizedOid ? 'AND oid = $2' : ''
|
||||||
|
if (normalizedOid) {
|
||||||
|
params.push(normalizedOid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM kuaishou_industry_vouchers
|
||||||
|
WHERE voucher_code = $1
|
||||||
|
${oidFilter}
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateKuaishouIndustryVoucherByCode(
|
||||||
|
voucherCode: string,
|
||||||
|
patch: KuaishouIndustryVoucherUpdatePatch,
|
||||||
|
): Promise<KuaishouIndustryVoucherRow | null> {
|
||||||
|
const normalizedCode = String(voucherCode || '').trim()
|
||||||
|
if (!normalizedCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = normalizeVoucherPatchColumns(patch)
|
||||||
|
if (columns.length === 0) {
|
||||||
|
return findKuaishouIndustryVoucherByCode(normalizedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = columns
|
||||||
|
.map((item, index) => `${item.column} = $${index + 1}${item.cast || ''}`)
|
||||||
|
.join(', ')
|
||||||
|
const params = columns.map((item) => item.value)
|
||||||
|
params.push(normalizedCode)
|
||||||
|
|
||||||
|
const result = await query<KuaishouIndustryVoucherRow>(
|
||||||
|
`
|
||||||
|
UPDATE kuaishou_industry_vouchers
|
||||||
|
SET ${assignments}
|
||||||
|
WHERE voucher_code = $${params.length}
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVoucherPatchColumns(
|
||||||
|
patch: KuaishouIndustryVoucherUpdatePatch,
|
||||||
|
): VoucherPatchColumn[] {
|
||||||
|
const columns: VoucherPatchColumn[] = []
|
||||||
|
|
||||||
|
if (patch.orderId !== undefined) {
|
||||||
|
columns.push({ column: 'order_id', value: normalizeNullableId(patch.orderId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.taskId !== undefined) {
|
||||||
|
columns.push({ column: 'task_id', value: normalizeNullableId(patch.taskId) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.token !== undefined) {
|
||||||
|
columns.push({ column: 'token', value: patch.token || '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
columns.push({ column: 'status', value: patch.status || 'UNUSED' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.validStartTime !== undefined) {
|
||||||
|
columns.push({ column: 'valid_start_time', value: patch.validStartTime || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.validEndTime !== undefined) {
|
||||||
|
columns.push({ column: 'valid_end_time', value: patch.validEndTime || 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.consumeSerialNum !== undefined) {
|
||||||
|
columns.push({ column: 'consume_serial_num', value: patch.consumeSerialNum || '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.consumeDetailsJson !== undefined) {
|
||||||
|
columns.push({
|
||||||
|
column: 'consume_details_json',
|
||||||
|
value: stringifyJson(patch.consumeDetailsJson ?? []),
|
||||||
|
cast: '::jsonb',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.consumedAt !== undefined) {
|
||||||
|
columns.push({ column: 'consumed_at', value: patch.consumedAt || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.destroyedAt !== undefined) {
|
||||||
|
columns.push({ column: 'destroyed_at', value: patch.destroyedAt || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.rawPayloadJson !== undefined) {
|
||||||
|
columns.push({
|
||||||
|
column: 'raw_payload_json',
|
||||||
|
value: stringifyJson(patch.rawPayloadJson ?? {}),
|
||||||
|
cast: '::jsonb',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.updatedAt !== undefined) {
|
||||||
|
columns.push({ column: 'updated_at', value: patch.updatedAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableId(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyJson(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value ?? {})
|
||||||
|
}
|
||||||
@@ -36,6 +36,15 @@ const CORE_PROFILES: CoreProfile[] = [
|
|||||||
inventoryStrategy: 'external_platform',
|
inventoryStrategy: 'external_platform',
|
||||||
requirements: [],
|
requirements: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
profileKey: 'kuaishou_feifei',
|
||||||
|
name: 'kuaishou-feifei 履约',
|
||||||
|
executorKey: 'kuaishou_feifei',
|
||||||
|
requiresClaim: true,
|
||||||
|
autoDispatch: false,
|
||||||
|
inventoryStrategy: 'external_platform',
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function ensureFulfillmentCatalogBootstrapped() {
|
export async function ensureFulfillmentCatalogBootstrapped() {
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export async function getClaimContext(token: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
|
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
|
||||||
|
if (String(task.executor_key || '').trim() === 'kuaishou_feifei') {
|
||||||
|
return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem })
|
||||||
|
}
|
||||||
|
|
||||||
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
|
const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task)
|
||||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||||
const displaySkuName = resolveClaimOrderItemDisplaySkuName(
|
const displaySkuName = resolveClaimOrderItemDisplaySkuName(
|
||||||
@@ -133,6 +137,7 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
|
|||||||
product,
|
product,
|
||||||
session: null as null,
|
session: null as null,
|
||||||
kuaishouCloudFulfillment,
|
kuaishouCloudFulfillment,
|
||||||
|
kuaishouFeifei: null as null,
|
||||||
result: task.redeemed_at
|
result: task.redeemed_at
|
||||||
? {
|
? {
|
||||||
resultCode: String(task.result_code || ''),
|
resultCode: String(task.result_code || ''),
|
||||||
@@ -144,6 +149,93 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) {
|
||||||
|
const context = parseTaskContext(task)
|
||||||
|
const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei)
|
||||||
|
const product = {
|
||||||
|
title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
|
||||||
|
skuCode: String(orderItem.sku_code || '').trim(),
|
||||||
|
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
|
||||||
|
isBundle: false,
|
||||||
|
items: [{
|
||||||
|
cloudSkuId: 0,
|
||||||
|
name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(),
|
||||||
|
quantity: Math.max(1, Number(orderItem.quantity || 1) || 1),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenStatus: claimToken.status,
|
||||||
|
claimUrl: buildClaimUrl(claimToken.token),
|
||||||
|
flowType: 'kuaishou_feifei',
|
||||||
|
task: {
|
||||||
|
taskId: task.id,
|
||||||
|
taskNo: task.task_no,
|
||||||
|
status: task.task_status,
|
||||||
|
executorKey: task.executor_key || '',
|
||||||
|
requiresSupportReview: false,
|
||||||
|
expiresAt: claimToken.expired_at,
|
||||||
|
claimedAt: task.claimed_at,
|
||||||
|
roleConfirmedAt: task.role_confirmed_at,
|
||||||
|
redeemedAt: task.redeemed_at,
|
||||||
|
loginType: task.login_type,
|
||||||
|
lastError: task.last_error,
|
||||||
|
runtimeSessionId: task.runtime_session_id,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
orderId: order.id,
|
||||||
|
platform: order.platform,
|
||||||
|
platformOrderId: order.platform_order_id,
|
||||||
|
payStatus: order.pay_status,
|
||||||
|
orderStatus: order.order_status,
|
||||||
|
totalAmount: formatFenToAmount(order.total_amount),
|
||||||
|
totalAmountFen: normalizeFen(order.total_amount),
|
||||||
|
currency: order.currency,
|
||||||
|
},
|
||||||
|
orderItem: {
|
||||||
|
orderItemId: orderItem.id,
|
||||||
|
skuCode: orderItem.sku_code,
|
||||||
|
skuName: product.title,
|
||||||
|
quantity: orderItem.quantity,
|
||||||
|
},
|
||||||
|
product,
|
||||||
|
session: null as null,
|
||||||
|
kuaishouCloudFulfillment: null as null,
|
||||||
|
kuaishouFeifei: flow,
|
||||||
|
result: task.redeemed_at
|
||||||
|
? {
|
||||||
|
resultCode: String(task.result_code || ''),
|
||||||
|
resultMessage: String(task.result_message || ''),
|
||||||
|
screenshotReady: false,
|
||||||
|
screenshotUrl: '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapClaimKuaishouFeifeiFulfillment(value: unknown) {
|
||||||
|
const source = isPlainObject(value) ? value : {}
|
||||||
|
const h5 = isPlainObject(source.h5) ? source.h5 : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
flowType: 'kuaishou_feifei',
|
||||||
|
productCode: String(source.productCode || '').trim(),
|
||||||
|
productName: String(source.productName || '').trim(),
|
||||||
|
platformOrderNo: String(source.platformOrderNo || '').trim(),
|
||||||
|
orderNo: String(source.orderNo || '').trim(),
|
||||||
|
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
|
||||||
|
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
|
||||||
|
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
||||||
|
claimUrl: String(source.claimUrl || '').trim(),
|
||||||
|
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||||||
|
h5: {
|
||||||
|
entryUrl: String(h5.entryUrl || '').trim(),
|
||||||
|
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
||||||
|
},
|
||||||
|
lastSyncedAt: source.lastSyncedAt || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveClaimOrderItemDisplaySkuName(
|
function resolveClaimOrderItemDisplaySkuName(
|
||||||
orderItem: OrderItemRow,
|
orderItem: OrderItemRow,
|
||||||
kuaishouCloudFulfillment: JsonObject | null,
|
kuaishouCloudFulfillment: JsonObject | null,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
rebindKuaishouCloudTaskRole,
|
rebindKuaishouCloudTaskRole,
|
||||||
refreshKuaishouCloudTaskRoleInfo,
|
refreshKuaishouCloudTaskRoleInfo,
|
||||||
} from '../fulfillment/kuaishou-cloud/index.js'
|
} from '../fulfillment/kuaishou-cloud/index.js'
|
||||||
|
import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js'
|
||||||
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||||
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
||||||
import type { TaskRow } from '../../types/repository/rows.js'
|
import type { TaskRow } from '../../types/repository/rows.js'
|
||||||
@@ -42,6 +43,7 @@ const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
|||||||
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
|
const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png'])
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
type ClaimDetailPayload = ReturnType<typeof buildClaimDetailPayload>
|
||||||
|
|
||||||
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
|
export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: JsonObject = {}) {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
@@ -50,7 +52,7 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
const executorKey = String(context.task.executor_key || '').trim()
|
const executorKey = String(context.task.executor_key || '').trim()
|
||||||
|
|
||||||
if (executorKey === 'kuaishou-industry') {
|
if (executorKey === 'kuaishou-industry') {
|
||||||
return verifyIndustryeVoucherTicket(context, now)
|
return verifyIndustryVoucherTicket(context, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (executorKey !== 'kuaishou_ct_assisted') {
|
if (executorKey !== 'kuaishou_ct_assisted') {
|
||||||
@@ -61,6 +63,9 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
}
|
}
|
||||||
|
|
||||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
|
const flow = normalizeKuaishouCloudFlow(parseTaskContext(context.task).kuaishouCloudFulfillment)
|
||||||
|
if (hasUsableIndustryVoucher(parseTaskContext(context.task))) {
|
||||||
|
return verifyIndustryVoucherTicket(context, now)
|
||||||
|
}
|
||||||
|
|
||||||
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
|
const ticketCode = String(payload.ticketCode || payload.eTicketId || '').trim()
|
||||||
if (!ticketCode) {
|
if (!ticketCode) {
|
||||||
@@ -243,10 +248,10 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js
|
|||||||
return getKuaishouCloudClaimDetail(token)
|
return getKuaishouCloudClaimDetail(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyIndustryeVoucherTicket(
|
async function verifyIndustryVoucherTicket(
|
||||||
context: Awaited<ReturnType<typeof getClaimContext>>,
|
context: Awaited<ReturnType<typeof getClaimContext>>,
|
||||||
now: string,
|
now: string,
|
||||||
) {
|
): Promise<ClaimDetailPayload> {
|
||||||
const taskContext = parseTaskContext(context.task)
|
const taskContext = parseTaskContext(context.task)
|
||||||
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||||
|
|
||||||
@@ -255,33 +260,58 @@ async function verifyIndustryeVoucherTicket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const industryContext = typeof taskContext === 'object' ? taskContext : {}
|
const industryContext = typeof taskContext === 'object' ? taskContext : {}
|
||||||
const token = String(industryContext.token || '').trim()
|
const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher)
|
||||||
|
if (voucherContext.status === 'DESTROYED') {
|
||||||
|
throw createHttpError('当前电子凭证已销毁,无法继续领取', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'claim_kuaishou_industry_voucher_destroyed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(voucherContext.token || industryContext.token || '').trim()
|
||||||
|
const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim()
|
||||||
const certExpireType = Number(industryContext.certExpireType || 0)
|
const certExpireType = Number(industryContext.certExpireType || 0)
|
||||||
const certActualStartTime = Number(industryContext.certActualStartTime || 0)
|
const certActualStartTime = Number(
|
||||||
const certActualEndTime = Number(industryContext.certActualEndTime || 0)
|
voucherContext.validStartTime || industryContext.certActualStartTime || 0,
|
||||||
|
)
|
||||||
|
const certActualEndTime = Number(
|
||||||
|
voucherContext.validEndTime || industryContext.certActualEndTime || 0,
|
||||||
|
)
|
||||||
|
|
||||||
const nextContext = {
|
const nextContext = {
|
||||||
...taskContext,
|
...taskContext,
|
||||||
|
kuaishouIndustryVoucher: {
|
||||||
|
...voucherContext,
|
||||||
|
oid: voucherContext.oid || context.order.platform_order_id,
|
||||||
|
token,
|
||||||
|
eticketId: voucherCode,
|
||||||
|
voucherCode,
|
||||||
|
status: voucherContext.status || 'UNUSED',
|
||||||
|
verifiedAt: voucherContext.verifiedAt || now,
|
||||||
|
},
|
||||||
kuaishouCloudFulfillment: {
|
kuaishouCloudFulfillment: {
|
||||||
...flow,
|
...flow,
|
||||||
ticket: {
|
ticket: {
|
||||||
...flow.ticket,
|
...flow.ticket,
|
||||||
code: '',
|
code: voucherCode,
|
||||||
status: 'verified',
|
status: 'verified',
|
||||||
capturedAt: flow.ticket.capturedAt || now,
|
capturedAt: flow.ticket.capturedAt || now,
|
||||||
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
|
capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' },
|
||||||
verifiedAt: now,
|
verifiedAt: now,
|
||||||
oid: context.order.platform_order_id,
|
oid: voucherContext.oid || context.order.platform_order_id,
|
||||||
formToken: token,
|
formToken: token,
|
||||||
leftCount: 0,
|
leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1,
|
||||||
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
|
goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '',
|
||||||
},
|
},
|
||||||
consume: {
|
consume: {
|
||||||
...flow.consume,
|
...flow.consume,
|
||||||
status: 'pending',
|
status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending',
|
||||||
shopId: context.order.shop_id,
|
shopId: context.order.shop_id,
|
||||||
shopName: context.order.shop_name,
|
shopName: context.order.shop_name,
|
||||||
autoConsumeEnabled: true,
|
autoConsumeEnabled: true,
|
||||||
|
consumedAt: voucherContext.status === 'CONSUMED'
|
||||||
|
? voucherContext.consumedAt || flow.consume.consumedAt || now
|
||||||
|
: flow.consume.consumedAt,
|
||||||
},
|
},
|
||||||
certInfo: {
|
certInfo: {
|
||||||
certExpireType,
|
certExpireType,
|
||||||
@@ -353,17 +383,41 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) {
|
|||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getKuaishouCloudClaimDetail(token: unknown) {
|
export async function getKuaishouCloudClaimDetail(token: unknown): Promise<ClaimDetailPayload> {
|
||||||
const context = await getClaimContext(token)
|
const context = await getClaimContext(token)
|
||||||
let task = context.task
|
let task = context.task
|
||||||
|
|
||||||
const executorKey = String(task.executor_key || '').trim()
|
const executorKey = String(task.executor_key || '').trim()
|
||||||
|
|
||||||
|
if (executorKey === 'kuaishou_feifei') {
|
||||||
|
task = (await syncKuaishouFeifeiTaskStatus(task)) || task
|
||||||
|
}
|
||||||
|
|
||||||
if (executorKey === 'kuaishou-industry') {
|
if (executorKey === 'kuaishou-industry') {
|
||||||
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
|
const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment)
|
||||||
if (flow.consume.status !== 'success') {
|
if (flow.consume.status !== 'success') {
|
||||||
const now = nowIso()
|
const now = nowIso()
|
||||||
await verifyIndustryeVoucherTicket(context, now).catch(() => null)
|
await verifyIndustryVoucherTicket(context, now).catch(() => null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executorKey === 'kuaishou_ct_assisted') {
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
|
||||||
|
const needsIndustryVoucherPrepare =
|
||||||
|
hasUsableIndustryVoucher(taskContext) &&
|
||||||
|
(
|
||||||
|
flow.ticket.status !== 'verified' ||
|
||||||
|
(flow.binding.prepareStatus !== 'ready' && !flow.binding.bindUrl)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (needsIndustryVoucherPrepare) {
|
||||||
|
const now = nowIso()
|
||||||
|
const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket(context, now)
|
||||||
|
.catch(() => null)
|
||||||
|
if (prepared) {
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +471,35 @@ function parseTaskContext(task: Partial<TaskRow> | null | undefined): JsonObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUsableIndustryVoucher(context: JsonObject = {}) {
|
||||||
|
const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher)
|
||||||
|
if (!voucher.voucherCode && !voucher.eticketId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return voucher.status !== 'DESTROYED'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndustryVoucherContext(value: unknown): JsonObject {
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as JsonObject
|
||||||
|
: {}
|
||||||
|
const status = String(source.status || 'UNUSED').trim().toUpperCase()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
oid: String(source.oid || '').trim(),
|
||||||
|
token: String(source.token || '').trim(),
|
||||||
|
eticketId: String(source.eticketId || source.voucherCode || '').trim(),
|
||||||
|
voucherCode: String(source.voucherCode || source.eticketId || '').trim(),
|
||||||
|
status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED',
|
||||||
|
validStartTime: Number(source.validStartTime || 0) || 0,
|
||||||
|
validEndTime: Number(source.validEndTime || 0) || 0,
|
||||||
|
verifiedAt: source.verifiedAt || null,
|
||||||
|
consumedAt: source.consumedAt || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function queryKuaishouEticketConsumeDetailWithShopFallback({
|
async function queryKuaishouEticketConsumeDetailWithShopFallback({
|
||||||
ticketCode,
|
ticketCode,
|
||||||
shopId,
|
shopId,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsa
|
|||||||
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js";
|
||||||
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js";
|
||||||
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
|
import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js";
|
||||||
|
import { consumeKuaishouIndustryVouchersForTask } from "../../platforms/kuaishou-industry/voucher-service.js";
|
||||||
import {
|
import {
|
||||||
getKuaishouEticketSourceConfig,
|
getKuaishouEticketSourceConfig,
|
||||||
resolveKuaishouEticketShopConfig,
|
resolveKuaishouEticketShopConfig,
|
||||||
@@ -631,6 +632,15 @@ function isPlainObject(value: unknown): value is JsonObject {
|
|||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean {
|
||||||
|
const voucher = isPlainObject(value.kuaishouIndustryVoucher)
|
||||||
|
? value.kuaishouIndustryVoucher
|
||||||
|
: {};
|
||||||
|
const voucherCode = String(voucher.voucherCode || voucher.eticketId || "").trim();
|
||||||
|
const status = String(voucher.status || "UNUSED").trim().toUpperCase();
|
||||||
|
return Boolean(voucherCode && status !== "DESTROYED");
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDispatchStockItems(
|
export function buildDispatchStockItems(
|
||||||
deliveryItems: DispatchDeliveryItem[],
|
deliveryItems: DispatchDeliveryItem[],
|
||||||
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
|
{ skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}
|
||||||
@@ -794,15 +804,46 @@ export async function returnKuaishouCloudFulfillmentTask(
|
|||||||
let nextResultCode = "kuaishou_cloud_completed";
|
let nextResultCode = "kuaishou_cloud_completed";
|
||||||
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
|
let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销";
|
||||||
const consumeAlreadyCompleted = flow.consume.status === "success";
|
const consumeAlreadyCompleted = flow.consume.status === "success";
|
||||||
const isIndustryTask = isIndustryEVoucherTask(task)
|
const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext);
|
||||||
|
const isIndustryTask = isIndustryEVoucherTask(task);
|
||||||
|
let industryVoucherContextPatch: JsonObject | null = null;
|
||||||
|
|
||||||
if (consumeAlreadyCompleted) {
|
if (consumeAlreadyCompleted) {
|
||||||
consumeStatus = "success";
|
consumeStatus = "success";
|
||||||
consumedAt = flow.consume.consumedAt || now;
|
consumedAt = flow.consume.consumedAt || now;
|
||||||
} else if (isIndustryTask) {
|
} else if (hasIndustryVoucher || isIndustryTask) {
|
||||||
|
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";
|
consumeStatus = "success";
|
||||||
consumedAt = flow.consume.consumedAt || now;
|
consumedAt = now;
|
||||||
consumeErrorMessage = "行业电子凭证核销由平台回调处理,已跳过主动核销";
|
const consumedVoucher = industryResult.consumed[0] || null;
|
||||||
|
if (consumedVoucher) {
|
||||||
|
industryVoucherContextPatch = {
|
||||||
|
oid: consumedVoucher.oid,
|
||||||
|
token: consumedVoucher.token,
|
||||||
|
eticketId: consumedVoucher.voucher_code,
|
||||||
|
voucherCode: consumedVoucher.voucher_code,
|
||||||
|
unitIndex: Number(consumedVoucher.unit_index || 0) || 0,
|
||||||
|
status: "CONSUMED",
|
||||||
|
validStartTime: Number(consumedVoucher.valid_start_time || 0) || 0,
|
||||||
|
validEndTime: Number(consumedVoucher.valid_end_time || 0) || 0,
|
||||||
|
consumedAt: now,
|
||||||
|
consumeSerialNum: consumedVoucher.consume_serial_num || `CONSUME-${consumedVoucher.voucher_code}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consumeStatus = "failed";
|
||||||
|
consumeErrorMessage =
|
||||||
|
industryResult.failed[0]?.errorMessage || "电子凭证核销回调失败,请人工处理";
|
||||||
|
}
|
||||||
} else if (!order) {
|
} else if (!order) {
|
||||||
consumeStatus = "failed";
|
consumeStatus = "failed";
|
||||||
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
|
consumeErrorMessage = "任务关联订单不存在,无法执行快手核销";
|
||||||
@@ -860,6 +901,16 @@ export async function returnKuaishouCloudFulfillmentTask(
|
|||||||
|
|
||||||
const nextContext = {
|
const nextContext = {
|
||||||
...taskContext,
|
...taskContext,
|
||||||
|
...(industryVoucherContextPatch
|
||||||
|
? {
|
||||||
|
kuaishouIndustryVoucher: {
|
||||||
|
...(isPlainObject(taskContext.kuaishouIndustryVoucher)
|
||||||
|
? taskContext.kuaishouIndustryVoucher
|
||||||
|
: {}),
|
||||||
|
...industryVoucherContextPatch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
kuaishouCloudFulfillment: {
|
kuaishouCloudFulfillment: {
|
||||||
...flow,
|
...flow,
|
||||||
returnNumber: {
|
returnNumber: {
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||||
|
import { updateTask } from '../../../repositories/task-repo.js'
|
||||||
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||||
|
import { nowIso } from '../../../utils/time.js'
|
||||||
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
|
import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js'
|
||||||
|
import {
|
||||||
|
createKuaishouFeifeiOrder,
|
||||||
|
queryKuaishouFeifeiOrder,
|
||||||
|
} from '../../platforms/kuaishou-feifei/order-service.js'
|
||||||
|
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
|
||||||
|
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export function isKuaishouFeifeiTask(task: Partial<TaskRow> | null | undefined) {
|
||||||
|
return String(task?.executor_key || '').trim() === 'kuaishou_feifei'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareKuaishouFeifeiTask(task: TaskRow) {
|
||||||
|
if (!isKuaishouFeifeiTask(task)) {
|
||||||
|
throw createHttpError('当前任务不是 kuaishou-feifei 履约任务', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'kuaishou_feifei_task_invalid',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = nowIso()
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
|
||||||
|
const claimLinkState = await ensureTaskClaimLink(task)
|
||||||
|
|
||||||
|
if (flow.orderNo && flow.h5.rechargeUrl) {
|
||||||
|
const nextContext = {
|
||||||
|
...taskContext,
|
||||||
|
kuaishouFeifei: {
|
||||||
|
...flow,
|
||||||
|
claimUrl: claimLinkState.claimUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return updateTask(task.id, {
|
||||||
|
task_status: TASK_STATUS.LINK_GENERATED,
|
||||||
|
claim_token: claimLinkState.token || task.claim_token || '',
|
||||||
|
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
|
||||||
|
user_action_status: 'pending_claim',
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flow.productCode) {
|
||||||
|
throw createHttpError('kuaishou-feifei 商品编码缺失,请检查商品规则配置', {
|
||||||
|
statusCode: 409,
|
||||||
|
errorCode: 'kuaishou_feifei_product_code_missing',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformOrderNo = flow.platformOrderNo || buildFeifeiPlatformOrderNo(task)
|
||||||
|
const order = await createKuaishouFeifeiOrder({
|
||||||
|
platformOrderNo,
|
||||||
|
productCode: flow.productCode,
|
||||||
|
platformBuyNum: 1,
|
||||||
|
})
|
||||||
|
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
|
||||||
|
platformOrderNo,
|
||||||
|
claimUrl: claimLinkState.claimUrl,
|
||||||
|
syncedAt: now,
|
||||||
|
})
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
|
task_status: TASK_STATUS.LINK_GENERATED,
|
||||||
|
claim_token: claimLinkState.token || task.claim_token || '',
|
||||||
|
claim_expires_at: claimLinkState.expiredAt || task.claim_expires_at || null,
|
||||||
|
user_action_status: 'pending_claim',
|
||||||
|
result_code: 'kuaishou_feifei_order_created',
|
||||||
|
result_message: order.rechargeStatusLabel || 'kuaishou-feifei 订单已创建',
|
||||||
|
context_json: JSON.stringify({
|
||||||
|
...taskContext,
|
||||||
|
kuaishouFeifei: nextFlow,
|
||||||
|
}),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createTaskEvent(
|
||||||
|
task.id,
|
||||||
|
'kuaishou_feifei_order_created',
|
||||||
|
{
|
||||||
|
platformOrderNo,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
productCode: flow.productCode,
|
||||||
|
rechargeStatus: order.rechargeStatus,
|
||||||
|
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||||
|
rechargeUrl: order.h5.rechargeUrl,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedTask
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) {
|
||||||
|
if (!isKuaishouFeifeiTask(task)) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskContext = parseTaskContext(task)
|
||||||
|
const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei)
|
||||||
|
if (!flow.platformOrderNo && !flow.orderNo) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = nowIso()
|
||||||
|
const order = await queryKuaishouFeifeiOrder({
|
||||||
|
platformOrderNo: flow.platformOrderNo,
|
||||||
|
orderNo: flow.orderNo,
|
||||||
|
})
|
||||||
|
let nextTaskStatus = task.task_status
|
||||||
|
let deliveryStatus = task.delivery_status
|
||||||
|
let redeemedAt = task.redeemed_at
|
||||||
|
let resultCode = task.result_code
|
||||||
|
let resultMessage = task.result_message
|
||||||
|
let lastError = task.last_error
|
||||||
|
const nextFlow = mergeKuaishouFeifeiOrder(flow, order, {
|
||||||
|
syncedAt: now,
|
||||||
|
claimUrl: flow.claimUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (order.rechargeStatus === 30) {
|
||||||
|
const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, {
|
||||||
|
source: 'kuaishou_feifei_completed',
|
||||||
|
consumeType: 'delivery',
|
||||||
|
consumeTime: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (consumeResult.ok || consumeResult.vouchers.length === 0) {
|
||||||
|
nextTaskStatus = TASK_STATUS.COMPLETED
|
||||||
|
deliveryStatus = 'delivered'
|
||||||
|
redeemedAt = redeemedAt || now
|
||||||
|
resultCode = 'kuaishou_feifei_completed'
|
||||||
|
resultMessage = order.rechargeStatusLabel || 'kuaishou-feifei 履约成功'
|
||||||
|
lastError = ''
|
||||||
|
nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required'
|
||||||
|
} else {
|
||||||
|
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
|
||||||
|
resultCode = 'kuaishou_feifei_industry_consume_failed'
|
||||||
|
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
|
||||||
|
lastError = resultMessage
|
||||||
|
nextFlow.consumeStatus = 'failed'
|
||||||
|
}
|
||||||
|
} else if ([40, 50, 60].includes(order.rechargeStatus)) {
|
||||||
|
nextTaskStatus = order.rechargeStatus === 60 ? TASK_STATUS.CLOSED : TASK_STATUS.MANUAL_REVIEW
|
||||||
|
resultCode = `kuaishou_feifei_status_${order.rechargeStatus}`
|
||||||
|
resultMessage = order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常'
|
||||||
|
lastError = resultMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
|
task_status: nextTaskStatus,
|
||||||
|
delivery_status: deliveryStatus,
|
||||||
|
redeemed_at: redeemedAt,
|
||||||
|
result_code: resultCode,
|
||||||
|
result_message: resultMessage,
|
||||||
|
last_error: lastError,
|
||||||
|
context_json: JSON.stringify({
|
||||||
|
...taskContext,
|
||||||
|
kuaishouFeifei: nextFlow,
|
||||||
|
}),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedTask || task
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKuaishouFeifeiFlow(value: unknown) {
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as JsonObject
|
||||||
|
: {}
|
||||||
|
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
flowType: 'kuaishou_feifei',
|
||||||
|
productCode: String(source.productCode || '').trim(),
|
||||||
|
productName: String(source.productName || '').trim(),
|
||||||
|
platformOrderNo: String(source.platformOrderNo || '').trim(),
|
||||||
|
orderNo: String(source.orderNo || '').trim(),
|
||||||
|
rechargeStatus: Number(source.rechargeStatus || 0) || 0,
|
||||||
|
rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(),
|
||||||
|
rechargeResultMessage: String(source.rechargeResultMessage || '').trim(),
|
||||||
|
claimUrl: String(source.claimUrl || '').trim(),
|
||||||
|
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||||||
|
h5: {
|
||||||
|
entryUrl: String(h5.entryUrl || '').trim(),
|
||||||
|
rechargeUrl: String(h5.rechargeUrl || '').trim(),
|
||||||
|
},
|
||||||
|
lastSyncedAt: source.lastSyncedAt || null,
|
||||||
|
raw: source.raw && typeof source.raw === 'object' ? source.raw : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeKuaishouFeifeiOrder(
|
||||||
|
flow: ReturnType<typeof normalizeKuaishouFeifeiFlow>,
|
||||||
|
order: Awaited<ReturnType<typeof createKuaishouFeifeiOrder>>,
|
||||||
|
patch: {
|
||||||
|
platformOrderNo?: string
|
||||||
|
claimUrl?: string
|
||||||
|
syncedAt: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...flow,
|
||||||
|
productCode: order.productCode || flow.productCode,
|
||||||
|
productName: order.productName || flow.productName,
|
||||||
|
platformOrderNo: order.platformOrderNo || patch.platformOrderNo || flow.platformOrderNo,
|
||||||
|
orderNo: order.orderNo || flow.orderNo,
|
||||||
|
rechargeStatus: order.rechargeStatus,
|
||||||
|
rechargeStatusLabel: order.rechargeStatusLabel,
|
||||||
|
rechargeResultMessage: order.rechargeResultMessage,
|
||||||
|
claimUrl: patch.claimUrl || flow.claimUrl,
|
||||||
|
h5: {
|
||||||
|
entryUrl: order.h5.entryUrl || flow.h5.entryUrl,
|
||||||
|
rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl,
|
||||||
|
},
|
||||||
|
lastSyncedAt: patch.syncedAt,
|
||||||
|
raw: order.raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureTaskClaimLink(task: TaskRow) {
|
||||||
|
const tokenStatus = String(task?.primary_claim_token_status || '').trim()
|
||||||
|
const token = String(task?.primary_claim_token || task?.claim_token || '').trim()
|
||||||
|
const expiredAt = task?.primary_claim_expires_at || task?.claim_expires_at || null
|
||||||
|
|
||||||
|
if (tokenStatus === 'active' && token && expiredAt && new Date(expiredAt).getTime() > Date.now()) {
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
expiredAt,
|
||||||
|
claimUrl: buildClaimUrl(token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = await createTaskClaimToken(task.id)
|
||||||
|
return {
|
||||||
|
token: claimToken.token,
|
||||||
|
expiredAt: claimToken.expired_at,
|
||||||
|
claimUrl: claimToken.claimUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFeifeiPlatformOrderNo(task: TaskRow) {
|
||||||
|
const taskNo = String(task.task_no || '').trim()
|
||||||
|
if (taskNo && taskNo.length <= 64) {
|
||||||
|
return taskNo
|
||||||
|
}
|
||||||
|
|
||||||
|
return `OS-FEIFEI-${task.id}`
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/t
|
|||||||
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js'
|
||||||
import { createTaskClaimToken } from '../claim/claim-service.js'
|
import { createTaskClaimToken } from '../claim/claim-service.js'
|
||||||
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js'
|
||||||
|
import { prepareKuaishouFeifeiTask } from '../fulfillment/kuaishou-feifei/index.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { randomId } from '../../utils/random.js'
|
import { randomId } from '../../utils/random.js'
|
||||||
import {
|
import {
|
||||||
@@ -120,7 +121,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
const tasks: DeliveryTaskRow[] = []
|
const tasks: DeliveryTaskRow[] = []
|
||||||
|
|
||||||
for (const item of orderItems) {
|
for (const item of orderItems) {
|
||||||
const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)
|
const profile = await resolveDynamicFulfillmentProfile(item, getProfileByKey)
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
continue
|
continue
|
||||||
@@ -129,6 +130,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
const quantity = Math.max(1, Number(item.quantity || 1))
|
const quantity = Math.max(1, Number(item.quantity || 1))
|
||||||
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
const fulfillmentConfig = parseJsonObject(profile.config_json)
|
||||||
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles)
|
||||||
|
const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei)
|
||||||
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume)
|
||||||
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop)
|
||||||
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
|
const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys)
|
||||||
@@ -263,6 +265,25 @@ export async function syncDeliveryTasksForOrderWithDeps(
|
|||||||
notes: String(fulfillmentConfig.notes || '').trim(),
|
notes: String(fulfillmentConfig.notes || '').trim(),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
kuaishouFeifei: isKuaishouFeifeiExecutor(profile.executor_key)
|
||||||
|
? {
|
||||||
|
flowType: 'kuaishou_feifei',
|
||||||
|
productCode: String(kuaishouFeifeiConfig.productCode || '').trim(),
|
||||||
|
productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(),
|
||||||
|
platformOrderNo: '',
|
||||||
|
orderNo: '',
|
||||||
|
rechargeStatus: 0,
|
||||||
|
rechargeStatusLabel: '',
|
||||||
|
rechargeResultMessage: '',
|
||||||
|
claimUrl: '',
|
||||||
|
consumeStatus: 'pending',
|
||||||
|
h5: {
|
||||||
|
entryUrl: '',
|
||||||
|
rechargeUrl: '',
|
||||||
|
},
|
||||||
|
lastSyncedAt: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
@@ -330,6 +351,28 @@ async function preparePaidTask(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isKuaishouFeifeiExecutor(task.executor_key)) {
|
||||||
|
try {
|
||||||
|
return await prepareKuaishouFeifeiTask(task as TaskRow)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败'
|
||||||
|
const updatedTask = await updateDeliveryTask(task.id, {
|
||||||
|
task_status: TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
user_action_status: 'not_required',
|
||||||
|
last_error: message,
|
||||||
|
result_code: 'kuaishou_feifei_prepare_failed',
|
||||||
|
result_message: message,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
await notifyManualReview({
|
||||||
|
task: updatedTask || task,
|
||||||
|
reason: message,
|
||||||
|
source: 'kuaishou_feifei_prepare_failed',
|
||||||
|
})
|
||||||
|
return updatedTask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
|
if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') {
|
||||||
const lastError = task.last_error || '当前任务需要人工履约处理'
|
const lastError = task.last_error || '当前任务需要人工履约处理'
|
||||||
const updatedTask = await updateDeliveryTask(task.id, {
|
const updatedTask = await updateDeliveryTask(task.id, {
|
||||||
@@ -462,6 +505,53 @@ async function resolveDynamicCloudtentaclesProfile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveDynamicFulfillmentProfile(
|
||||||
|
item: OrderItemRow,
|
||||||
|
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
|
||||||
|
): Promise<FulfillmentBindingLike | null> {
|
||||||
|
return (
|
||||||
|
await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) ||
|
||||||
|
await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDynamicKuaishouFeifeiProfile(
|
||||||
|
item: OrderItemRow,
|
||||||
|
getProfileByKey: (profileKey: string) => Promise<FulfillmentBindingLike | null>,
|
||||||
|
): Promise<FulfillmentBindingLike | null> {
|
||||||
|
const snapshot = parseJsonObject(item.item_snapshot_json)
|
||||||
|
const feifei = parseJsonObject(snapshot.kuaishouFeifei)
|
||||||
|
const productCode = String(feifei.productCode || '').trim()
|
||||||
|
if (!productCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getProfileByKey('kuaishou_feifei')
|
||||||
|
if (!profile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
profile_id: Number(profile.id || profile.profile_id || 0),
|
||||||
|
profile_key: 'kuaishou_feifei',
|
||||||
|
profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(),
|
||||||
|
executor_key: 'kuaishou_feifei',
|
||||||
|
requires_claim: true,
|
||||||
|
auto_dispatch: false,
|
||||||
|
config_json: {
|
||||||
|
flowType: 'kuaishou_feifei',
|
||||||
|
configId: `kuaishou_feifei:${productCode}`,
|
||||||
|
kuaishouFeifei: {
|
||||||
|
productCode,
|
||||||
|
productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(),
|
||||||
|
matchMode: String(feifei.matchMode || 'kuaishou_feifei_rule').trim(),
|
||||||
|
},
|
||||||
|
notes: '91卡券商品名命中 kuaishou-feifei 商品规则',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStringArray(value: unknown): string[] {
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return []
|
return []
|
||||||
@@ -542,3 +632,7 @@ function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is
|
|||||||
function isKuaishouCloudExecutor(value: unknown): boolean {
|
function isKuaishouCloudExecutor(value: unknown): boolean {
|
||||||
return String(value || '').trim() === 'kuaishou_ct_assisted'
|
return String(value || '').trim() === 'kuaishou_ct_assisted'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKuaishouFeifeiExecutor(value: unknown): boolean {
|
||||||
|
return String(value || '').trim() === 'kuaishou_feifei'
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
|
||||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||||
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
import { resolveOrderItemForFulfillment } from './product-match-service.js'
|
||||||
|
import { bindKuaishouIndustryVouchersToOrderTasks } from '../platforms/kuaishou-industry/voucher-binding-service.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { logIntegration } from '../../utils/logger.js'
|
import { logIntegration } from '../../utils/logger.js'
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
@@ -184,6 +185,10 @@ export async function upsertOrderFromSource(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
|
const tasks = await syncDeliveryTasksForOrder(order, orderItems)
|
||||||
|
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
|
||||||
|
source: `${sourceLabel}_order_upsert`,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
resolveCloudtentaclesSkuByProductName,
|
resolveCloudtentaclesSkuByProductName,
|
||||||
type CloudtentaclesNameMatchResult,
|
type CloudtentaclesNameMatchResult,
|
||||||
} from './cloudtentacles-name-match-service.js'
|
} from './cloudtentacles-name-match-service.js'
|
||||||
|
import {
|
||||||
|
resolveKuaishouFeifeiProductByName,
|
||||||
|
type KuaishouFeifeiProductMatch,
|
||||||
|
} from '../platforms/kuaishou-feifei/product-rule-service.js'
|
||||||
|
|
||||||
export type FulfillmentItem = {
|
export type FulfillmentItem = {
|
||||||
itemId?: string
|
itemId?: string
|
||||||
@@ -34,6 +38,7 @@ type FulfillmentItemCandidate = {
|
|||||||
externalSkuNameNormalized: string
|
externalSkuNameNormalized: string
|
||||||
resolvedSkuCode: string
|
resolvedSkuCode: string
|
||||||
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null
|
||||||
|
kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null
|
||||||
isConfigured: boolean
|
isConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,15 +69,18 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
externalSkuName,
|
externalSkuName,
|
||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
|
kuaishouFeifeiMatch,
|
||||||
} = candidate
|
} = candidate
|
||||||
|
|
||||||
const resolvedSkuCode = pickFirstNonEmpty([
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
cloudtentaclesNameMatch?.cloudSkuName,
|
||||||
|
kuaishouFeifeiMatch?.productCode,
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
const resolvedSkuName = pickFirstNonEmpty([
|
const resolvedSkuName = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
cloudtentaclesNameMatch?.cloudSkuName,
|
||||||
|
kuaishouFeifeiMatch?.skuName,
|
||||||
item.skuName,
|
item.skuName,
|
||||||
externalSkuName,
|
externalSkuName,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
@@ -100,6 +108,15 @@ export async function resolveOrderItemForFulfillment({
|
|||||||
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
|
skuSnapshot: cloudtentaclesNameMatch.skuSnapshot,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
kuaishouFeifei: kuaishouFeifeiMatch
|
||||||
|
? {
|
||||||
|
matchMode: kuaishouFeifeiMatch.matchMode,
|
||||||
|
productName: kuaishouFeifeiMatch.productName,
|
||||||
|
normalizedProductName: kuaishouFeifeiMatch.normalizedProductName,
|
||||||
|
productCode: kuaishouFeifeiMatch.productCode,
|
||||||
|
skuName: kuaishouFeifeiMatch.skuName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
isConfigured: candidate.isConfigured,
|
isConfigured: candidate.isConfigured,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +187,12 @@ async function resolveConfiguredItemCandidate({
|
|||||||
|
|
||||||
if (isOpen91KuaishouOrder(provider, platform)) {
|
if (isOpen91KuaishouOrder(provider, platform)) {
|
||||||
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName)
|
||||||
|
const kuaishouFeifeiMatch = cloudtentaclesNameMatch
|
||||||
|
? null
|
||||||
|
: resolveKuaishouFeifeiProductByName(externalSkuName)
|
||||||
const resolvedSkuCode = pickFirstNonEmpty([
|
const resolvedSkuCode = pickFirstNonEmpty([
|
||||||
cloudtentaclesNameMatch?.cloudSkuName,
|
cloudtentaclesNameMatch?.cloudSkuName,
|
||||||
|
kuaishouFeifeiMatch?.productCode,
|
||||||
externalSkuCode,
|
externalSkuCode,
|
||||||
externalItemId,
|
externalItemId,
|
||||||
])
|
])
|
||||||
@@ -183,7 +204,8 @@ async function resolveConfiguredItemCandidate({
|
|||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch,
|
cloudtentaclesNameMatch,
|
||||||
isConfigured: Boolean(cloudtentaclesNameMatch),
|
kuaishouFeifeiMatch,
|
||||||
|
isConfigured: Boolean(cloudtentaclesNameMatch || kuaishouFeifeiMatch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +221,7 @@ async function resolveConfiguredItemCandidate({
|
|||||||
externalSkuNameNormalized,
|
externalSkuNameNormalized,
|
||||||
resolvedSkuCode,
|
resolvedSkuCode,
|
||||||
cloudtentaclesNameMatch: null,
|
cloudtentaclesNameMatch: null,
|
||||||
|
kuaishouFeifeiMatch: null,
|
||||||
isConfigured: false,
|
isConfigured: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { runtimeConfig } from '../../../config/runtime.js'
|
||||||
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
import type { RuntimeConfig } from '../../../types/runtime-config.js'
|
||||||
|
|
||||||
|
export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei'
|
||||||
|
export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei'
|
||||||
|
|
||||||
|
type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei']
|
||||||
|
|
||||||
|
export function getKuaishouFeifeiConfig(overrides: Partial<KuaishouFeifeiRuntimeConfig> = {}) {
|
||||||
|
const config = {
|
||||||
|
...(runtimeConfig.platforms?.kuaishouFeifei || {}),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu').trim().replace(/\/+$/, ''),
|
||||||
|
appKey: String(config.appKey || '').trim(),
|
||||||
|
appSecret: String(config.appSecret || '').trim(),
|
||||||
|
timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000),
|
||||||
|
notifyUrl: String(config.notifyUrl || '').trim(),
|
||||||
|
productRules: Array.isArray(config.productRules) ? config.productRules : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertKuaishouFeifeiConfig() {
|
||||||
|
const config = getKuaishouFeifeiConfig()
|
||||||
|
|
||||||
|
if (!config.baseUrl) {
|
||||||
|
throw createHttpError('kuaishou-feifei baseUrl 未配置', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'kuaishou_feifei_missing_base_url',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.appKey || !config.appSecret) {
|
||||||
|
throw createHttpError('kuaishou-feifei App Key / App Secret 未配置', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'kuaishou_feifei_missing_credential',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { signKuaishouFeifeiPayload } from './http-client.js'
|
||||||
|
|
||||||
|
test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => {
|
||||||
|
const input = {
|
||||||
|
appKey: 'app-key-1',
|
||||||
|
appSecret: 'secret-1',
|
||||||
|
timestamp: '1783394218',
|
||||||
|
body: '{"platform_order_no":"DT-1","product_code":"10000001","platform_buy_num":1}',
|
||||||
|
}
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac('sha256', input.appSecret)
|
||||||
|
.update(`${input.appKey}${input.timestamp}${input.body}`, 'utf8')
|
||||||
|
.digest('hex')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
assert.equal(signKuaishouFeifeiPayload(input), expected)
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
|
import { assertKuaishouFeifeiConfig } from './config.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObject = {}) {
|
||||||
|
const config = assertKuaishouFeifeiConfig()
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const sign = signKuaishouFeifeiPayload({
|
||||||
|
appKey: config.appKey,
|
||||||
|
appSecret: config.appSecret,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.baseUrl}${pathname}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-app-key': config.appKey,
|
||||||
|
'x-timestamp': timestamp,
|
||||||
|
'x-sign': sign,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
const json = parseJsonObject(text)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'kuaishou_feifei_http_failed',
|
||||||
|
context: {
|
||||||
|
status: response.status,
|
||||||
|
body: text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(json.code || 0) !== 0) {
|
||||||
|
throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'kuaishou_feifei_business_failed',
|
||||||
|
context: json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return json
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error)?.name === 'AbortError') {
|
||||||
|
throw createHttpError('kuaishou-feifei 请求超时', {
|
||||||
|
statusCode: 504,
|
||||||
|
errorCode: 'kuaishou_feifei_timeout',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signKuaishouFeifeiPayload({
|
||||||
|
appKey,
|
||||||
|
appSecret,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
appKey: string
|
||||||
|
appSecret: string
|
||||||
|
timestamp: string
|
||||||
|
body: string
|
||||||
|
}) {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', appSecret)
|
||||||
|
.update(`${appKey}${timestamp}${body}`, 'utf8')
|
||||||
|
.digest('hex')
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): JsonObject {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text || '{}')
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { getKuaishouFeifeiConfig } from './config.js'
|
||||||
|
import { kuaishouFeifeiRequest } from './http-client.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export async function createKuaishouFeifeiOrder(input: {
|
||||||
|
platformOrderNo: string
|
||||||
|
productCode: string
|
||||||
|
platformBuyNum?: number
|
||||||
|
platformAmount?: number
|
||||||
|
playerAccount?: string
|
||||||
|
playerGameRegion?: string
|
||||||
|
playerGameSrv?: string
|
||||||
|
playerGameRole?: string
|
||||||
|
submitPlayer?: boolean
|
||||||
|
notifyUrl?: string
|
||||||
|
}) {
|
||||||
|
const config = getKuaishouFeifeiConfig()
|
||||||
|
const payload: JsonObject = {
|
||||||
|
platform_order_no: input.platformOrderNo,
|
||||||
|
product_code: input.productCode,
|
||||||
|
platform_buy_num: Math.max(1, Number(input.platformBuyNum || 1) || 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.platformAmount != null) payload.platform_amount = input.platformAmount
|
||||||
|
if (input.playerAccount) payload.player_account = input.playerAccount
|
||||||
|
if (input.playerGameRegion) payload.player_game_region = input.playerGameRegion
|
||||||
|
if (input.playerGameSrv) payload.player_game_srv = input.playerGameSrv
|
||||||
|
if (input.playerGameRole) payload.player_game_role = input.playerGameRole
|
||||||
|
if (input.submitPlayer === true) payload.submit_player = true
|
||||||
|
if (input.notifyUrl || config.notifyUrl) payload.notify_url = input.notifyUrl || config.notifyUrl
|
||||||
|
|
||||||
|
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/store', payload)
|
||||||
|
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryKuaishouFeifeiOrder(input: {
|
||||||
|
platformOrderNo?: string
|
||||||
|
orderNo?: string
|
||||||
|
}) {
|
||||||
|
const payload: JsonObject = {}
|
||||||
|
if (input.platformOrderNo) payload.platform_order_no = input.platformOrderNo
|
||||||
|
if (input.orderNo) payload.order_no = input.orderNo
|
||||||
|
|
||||||
|
const response = await kuaishouFeifeiRequest('/api/open/v1/orders/show', payload)
|
||||||
|
return mapKuaishouFeifeiOrder(response.data?.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapKuaishouFeifeiOrder(value: unknown) {
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as JsonObject
|
||||||
|
: {}
|
||||||
|
const h5 = source.h5 && typeof source.h5 === 'object' ? source.h5 as JsonObject : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderNo: String(source.order_no || '').trim(),
|
||||||
|
platformOrderNo: String(source.platform_order_no || '').trim(),
|
||||||
|
productCode: String(source.product_code || '').trim(),
|
||||||
|
productName: String(source.product_name || '').trim(),
|
||||||
|
rechargeStatus: Number(source.recharge_status ?? source.status ?? 0) || 0,
|
||||||
|
rechargeStatusLabel: String(source.recharge_status_label || source.status_label || '').trim(),
|
||||||
|
pointsCharged: Number(source.points_charged || 0) || 0,
|
||||||
|
playerAccount: String(source.player_account || '').trim(),
|
||||||
|
platformBuyNum: Number(source.platform_buy_num || 1) || 1,
|
||||||
|
rechargeResultMessage: String(source.recharge_result_message || '').trim(),
|
||||||
|
createdAt: String(source.created_at || '').trim(),
|
||||||
|
updatedAt: String(source.updated_at || '').trim(),
|
||||||
|
rechargeFinishAt: String(source.recharge_finish_at || '').trim(),
|
||||||
|
h5: {
|
||||||
|
entryUrl: String(h5.entry_url || '').trim(),
|
||||||
|
rechargeUrl: String(h5.recharge_url || '').trim(),
|
||||||
|
},
|
||||||
|
raw: source,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { runtimeConfig } from '../../../config/runtime.js'
|
||||||
|
import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js'
|
||||||
|
import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js'
|
||||||
|
|
||||||
|
export type KuaishouFeifeiProductMatch = {
|
||||||
|
matchMode: 'kuaishou_feifei_rule'
|
||||||
|
productName: string
|
||||||
|
normalizedProductName: string
|
||||||
|
productCode: string
|
||||||
|
skuName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKuaishouFeifeiProductByName(
|
||||||
|
productName: unknown,
|
||||||
|
): KuaishouFeifeiProductMatch | null {
|
||||||
|
const normalizedProductName = normalizeCloudtentaclesMatchName(productName)
|
||||||
|
if (!normalizedProductName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = listKuaishouFeifeiProductRules()
|
||||||
|
const matched = rules.find((rule) =>
|
||||||
|
normalizeCloudtentaclesMatchName(rule.productName) === normalizedProductName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const productCode = String(matched.productCode || '').trim()
|
||||||
|
if (!productCode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchMode: 'kuaishou_feifei_rule',
|
||||||
|
productName: String(matched.productName || productName || '').trim(),
|
||||||
|
normalizedProductName,
|
||||||
|
productCode,
|
||||||
|
skuName: String(matched.skuName || matched.productName || productName || productCode).trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listKuaishouFeifeiProductRules(): KuaishouFeifeiProductRule[] {
|
||||||
|
const rules = runtimeConfig.platforms?.kuaishouFeifei?.productRules
|
||||||
|
return (Array.isArray(rules) ? rules : [])
|
||||||
|
.map((rule) => ({
|
||||||
|
productName: String(rule.productName || '').trim(),
|
||||||
|
productCode: String(rule.productCode || '').trim(),
|
||||||
|
skuName: String(rule.skuName || '').trim(),
|
||||||
|
enabled: rule.enabled !== false,
|
||||||
|
}))
|
||||||
|
.filter((rule) => rule.enabled && rule.productName && rule.productCode)
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
import { getTaskById, updateTask } from '../../../repositories/task-repo.js'
|
||||||
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
import { findKuaishouIndustryVoucherByCode } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
import { logWarn } from '../../../utils/logger.js'
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
|
||||||
getKuaishouIndustryConfig,
|
getKuaishouIndustryConfig,
|
||||||
assertMatchingAppKey,
|
assertMatchingAppKey,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
@@ -15,6 +14,8 @@ import {
|
|||||||
buildIndustryErrorResponse,
|
buildIndustryErrorResponse,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
import { consumeCallback } from './consume-callback-service.js'
|
import { consumeCallback } from './consume-callback-service.js'
|
||||||
|
import { consumeKuaishouIndustryVoucher } from './voucher-service.js'
|
||||||
|
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -30,86 +31,53 @@ export async function handleConsumeCode(rawBody: JsonObject = {}) {
|
|||||||
const now = normalizeTimestampIso(new Date().toISOString())
|
const now = normalizeTimestampIso(new Date().toISOString())
|
||||||
const normalizedOid = params.oid
|
const normalizedOid = params.oid
|
||||||
|
|
||||||
const order = await findLatestOrderByPlatformOrderId({
|
|
||||||
provider: config.provider,
|
|
||||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
|
||||||
platformOrderId: normalizedOid,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!order) {
|
|
||||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await listTasksByOrderId(order.id)
|
|
||||||
const matchedIds = new Set(params.etickets.map((e) => String(e.id)))
|
|
||||||
let consumedCount = 0
|
let consumedCount = 0
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const eticket of params.etickets) {
|
||||||
const taskId = String(task.id || '')
|
const voucher = await findKuaishouIndustryVoucherByCode(String(eticket.id || ''), normalizedOid)
|
||||||
if (!matchedIds.has(taskId)) {
|
if (!voucher) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let contextJson: JsonObject = {}
|
const task = voucher.task_id ? await getTaskById(voucher.task_id) : null
|
||||||
try {
|
const consumed = await consumeKuaishouIndustryVoucher(voucher, {
|
||||||
contextJson = typeof task.context_json === 'string'
|
source: 'kuaishou_industry_consume_code',
|
||||||
? JSON.parse(task.context_json)
|
token: params.token,
|
||||||
: (task.context_json || {})
|
eticketType: params.eticketType,
|
||||||
} catch {
|
|
||||||
contextJson = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const flow = contextJson.kuaishouCloudFulfillment || {}
|
|
||||||
const existingConsumes = Array.isArray(flow.consumeDetails)
|
|
||||||
? contextJson.consumeDetails as JsonObject[]
|
|
||||||
: Array.isArray(contextJson.consumeDetails)
|
|
||||||
? contextJson.consumeDetails as JsonObject[]
|
|
||||||
: []
|
|
||||||
|
|
||||||
const consumeDetail = {
|
|
||||||
serialNum: params.seriallNum,
|
|
||||||
consumeType: params.consumeType,
|
consumeType: params.consumeType,
|
||||||
consumeTime: params.consumeTime,
|
consumeTime: params.consumeTime || Date.now(),
|
||||||
appointmentTime: params.appointmentTime || undefined,
|
storeName: params.storeName,
|
||||||
storeName: params.storeName || undefined,
|
storeAddress: params.storeAddress,
|
||||||
storeAddress: params.storeAddress || undefined,
|
expressCode: params.expressCode,
|
||||||
expressCode: params.expressCode || undefined,
|
expressNo: params.expressNo,
|
||||||
expressNo: params.expressNo || undefined,
|
serialNum: params.seriallNum,
|
||||||
consumePoiId: params.consumePoiId || undefined,
|
skipCallback: true,
|
||||||
eticketType: params.eticketType || undefined,
|
...(task ? { task } : {}),
|
||||||
ext: params.ext || undefined,
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const nextContext = {
|
if (!consumed.ok || !consumed.voucher) {
|
||||||
...contextJson,
|
continue
|
||||||
kuaishouCloudFulfillment: {
|
|
||||||
...flow,
|
|
||||||
consume: {
|
|
||||||
...(flow.consume || {}),
|
|
||||||
status: 'success',
|
|
||||||
consumedAt: now,
|
|
||||||
consumeDetail,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
consumeDetails: [...existingConsumes, consumeDetail],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (task) {
|
||||||
const taskStatus = String(task.task_status || '')
|
const taskStatus = String(task.task_status || '')
|
||||||
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
const isDispatched = taskStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||||
|
const updatedTask = await updateTask(task.id, {
|
||||||
const updatePayload: Record<string, unknown> = {
|
|
||||||
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
|
task_status: isDispatched ? TASK_STATUS.COMPLETED : taskStatus,
|
||||||
|
delivery_status: isDispatched ? 'delivered' : task.delivery_status,
|
||||||
result_code: params.status,
|
result_code: params.status,
|
||||||
result_message: `核销方式: ${params.consumeType}`,
|
result_message: `电子凭证核销方式: ${params.consumeType}`,
|
||||||
context_json: JSON.stringify(nextContext),
|
redeemed_at: isDispatched ? now : task.redeemed_at,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
})
|
||||||
|
|
||||||
if (isDispatched) {
|
if (updatedTask) {
|
||||||
updatePayload.delivery_status = 'delivered'
|
await attachKuaishouIndustryVoucherToTask(updatedTask, consumed.voucher, {
|
||||||
|
source: 'kuaishou_industry_consume_code',
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateTask(taskId, updatePayload as any)
|
|
||||||
|
|
||||||
consumedCount++
|
consumedCount++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
|
||||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
|
||||||
import { updateTask } from '../../../repositories/task-repo.js'
|
import { updateTask } from '../../../repositories/task-repo.js'
|
||||||
|
import {
|
||||||
|
listKuaishouIndustryVouchersByOid,
|
||||||
|
updateKuaishouIndustryVoucherByCode,
|
||||||
|
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
import { logWarn } from '../../../utils/logger.js'
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
|
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
|
||||||
getKuaishouIndustryConfig,
|
getKuaishouIndustryConfig,
|
||||||
assertMatchingAppKey,
|
assertMatchingAppKey,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
@@ -12,9 +14,9 @@ import { normalizeDestroyCodePayload, assertDestroyCodePayload } from './payload
|
|||||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||||
import {
|
import {
|
||||||
buildIndustrySuccessResponse,
|
buildIndustrySuccessResponse,
|
||||||
buildIndustryErrorResponse,
|
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
import { destroyCallback } from './destroy-callback-service.js'
|
import { destroyCallback } from './destroy-callback-service.js'
|
||||||
|
import { attachKuaishouIndustryVoucherToTask } from './voucher-binding-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -30,42 +32,39 @@ export async function handleDestroyCode(rawBody: JsonObject = {}) {
|
|||||||
const now = normalizeTimestampIso(new Date().toISOString())
|
const now = normalizeTimestampIso(new Date().toISOString())
|
||||||
const normalizedOid = params.oid
|
const normalizedOid = params.oid
|
||||||
|
|
||||||
const order = await findLatestOrderByPlatformOrderId({
|
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||||
provider: config.provider,
|
const targetIds = new Set(params.etickets.map((e) => String(e.id || '').trim()).filter(Boolean))
|
||||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
const targetVouchers = targetIds.size > 0
|
||||||
platformOrderId: normalizedOid,
|
? vouchers.filter((voucher) => targetIds.has(String(voucher.voucher_code || '').trim()))
|
||||||
|
: vouchers
|
||||||
|
|
||||||
|
for (const voucher of targetVouchers) {
|
||||||
|
const status = String(voucher.status || '').trim().toUpperCase()
|
||||||
|
if (status === 'CONSUMED') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||||
|
status: 'DESTROYED',
|
||||||
|
destroyedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!order) {
|
if (voucher.task_id) {
|
||||||
return buildIndustrySuccessResponse({ oid: normalizedOid })
|
const updatedTask = await updateTask(voucher.task_id, {
|
||||||
}
|
task_status: TASK_STATUS.CLOSED,
|
||||||
|
|
||||||
const tasks = await listTasksByOrderId(order.id)
|
|
||||||
|
|
||||||
if (params.etickets.length > 0) {
|
|
||||||
const targetIds = new Set(params.etickets.map((e) => String(e.id)))
|
|
||||||
|
|
||||||
for (const task of tasks) {
|
|
||||||
const taskId = String(task.id || '')
|
|
||||||
if (targetIds.has(taskId)) {
|
|
||||||
await updateTask(taskId, {
|
|
||||||
task_status: 'destroyed',
|
|
||||||
delivery_status: 'destroyed',
|
|
||||||
result_code: params.reason,
|
|
||||||
result_message: `销毁原因: ${params.reason}`,
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const task of tasks) {
|
|
||||||
await updateTask(task.id, {
|
|
||||||
task_status: 'cancelled',
|
|
||||||
delivery_status: 'cancelled',
|
delivery_status: 'cancelled',
|
||||||
result_code: params.reason,
|
result_code: params.reason,
|
||||||
result_message: `订单关闭: ${params.reason}`,
|
result_message: `电子凭证销毁: ${params.reason}`,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (updatedTask && updatedVoucher) {
|
||||||
|
await attachKuaishouIndustryVoucherToTask(updatedTask, updatedVoucher, {
|
||||||
|
source: 'kuaishou_industry_destroy_code',
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
|
||||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
|
||||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
findKuaishouIndustryVoucherByCode,
|
||||||
|
listKuaishouIndustryVouchersByOid,
|
||||||
|
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
|
import {
|
||||||
getKuaishouIndustryConfig,
|
getKuaishouIndustryConfig,
|
||||||
assertMatchingAppKey,
|
assertMatchingAppKey,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
@@ -11,9 +11,9 @@ import { assertKuaishouIndustrySignature } from './crypto.js'
|
|||||||
import {
|
import {
|
||||||
buildIndustrySuccessResponse,
|
buildIndustrySuccessResponse,
|
||||||
buildIndustryErrorResponse,
|
buildIndustryErrorResponse,
|
||||||
buildIndustryEticketItem,
|
|
||||||
buildIndustryQueryCodeData,
|
buildIndustryQueryCodeData,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
|
import { buildKuaishouIndustryEticketFromVoucher } from './voucher-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -28,25 +28,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
|||||||
|
|
||||||
const normalizedOid = params.oid
|
const normalizedOid = params.oid
|
||||||
|
|
||||||
const order = await findLatestOrderByPlatformOrderId({
|
|
||||||
provider: config.provider,
|
|
||||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
|
||||||
platformOrderId: normalizedOid,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!order) {
|
|
||||||
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await listTasksByOrderId(order.id)
|
|
||||||
|
|
||||||
if (params.eticketId) {
|
if (params.eticketId) {
|
||||||
const matched = tasks.find((t) => String(t.id || '') === params.eticketId)
|
const matched = await findKuaishouIndustryVoucherByCode(params.eticketId, normalizedOid)
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
|
return buildIndustryErrorResponse(4012005, `卡券不存在: ${params.eticketId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const eticket = buildEticketFromTask(matched, params.eticketType)
|
const eticket = buildKuaishouIndustryEticketFromVoucher(matched, params.eticketType)
|
||||||
return buildIndustrySuccessResponse(
|
return buildIndustrySuccessResponse(
|
||||||
buildIndustryQueryCodeData({
|
buildIndustryQueryCodeData({
|
||||||
oid: normalizedOid,
|
oid: normalizedOid,
|
||||||
@@ -57,8 +45,13 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const etickets = tasks.map((task) =>
|
const vouchers = await listKuaishouIndustryVouchersByOid(normalizedOid)
|
||||||
buildEticketFromTask(task, params.eticketType),
|
if (vouchers.length === 0) {
|
||||||
|
return buildIndustryErrorResponse(4012002, `订单不存在: ${normalizedOid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const etickets = vouchers.map((voucher) =>
|
||||||
|
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||||
)
|
)
|
||||||
|
|
||||||
return buildIndustrySuccessResponse(
|
return buildIndustrySuccessResponse(
|
||||||
@@ -70,46 +63,3 @@ export async function handleQueryCode(rawBody: JsonObject = {}) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEticketFromTask(task: TaskRow, eticketType: string) {
|
|
||||||
const taskId = String(task.id || '')
|
|
||||||
const taskStatus = String(task.task_status || '')
|
|
||||||
const status = mapTaskStatusToEticketStatus(taskStatus)
|
|
||||||
|
|
||||||
let contextJson: JsonObject = {}
|
|
||||||
try {
|
|
||||||
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
|
|
||||||
} catch {
|
|
||||||
contextJson = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildIndustryEticketItem({
|
|
||||||
id: taskId,
|
|
||||||
status,
|
|
||||||
num: 1,
|
|
||||||
validStartTime: Number(contextJson.certActualStartTime || 0),
|
|
||||||
validEndTime: Number(contextJson.certActualEndTime || 0),
|
|
||||||
eticketType,
|
|
||||||
consumeDetails: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTaskStatusToEticketStatus(taskStatus: string) {
|
|
||||||
switch (taskStatus) {
|
|
||||||
case 'destroyed':
|
|
||||||
case 'cancelled':
|
|
||||||
return 'DESTROYED'
|
|
||||||
case 'consumed':
|
|
||||||
case 'redeemed':
|
|
||||||
case 'completed':
|
|
||||||
case 'delivered':
|
|
||||||
return 'CONSUMED'
|
|
||||||
case 'unused':
|
|
||||||
case 'pending_payment':
|
|
||||||
case 'pending_delivery':
|
|
||||||
case 'pending_fulfill':
|
|
||||||
case 'pending_review':
|
|
||||||
default:
|
|
||||||
return 'UNUSED'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { findLatestOrderByPlatformOrderId, createOrder } from '../../../repositories/order-repo.js'
|
import { findLatestOrderByPlatformOrderId } from '../../../repositories/order-repo.js'
|
||||||
import { replaceOrderItems, listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
|
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||||
import { listTasksByOrderId, createTask } from '../../../repositories/task-repo.js'
|
import { upsertKuaishouIndustryVoucher } from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
import { upsertFulfillmentProfile, getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
|
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js'
|
||||||
import { normalizeTimestampIso } from '../../../utils/time.js'
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
import { logWarn } from '../../../utils/logger.js'
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
import type { TaskRow } from '../../../types/repository/rows.js'
|
|
||||||
import {
|
import {
|
||||||
KUISHOU_INDUSTRY_PROVIDER,
|
|
||||||
KUISHOU_INDUSTRY_PLATFORM,
|
|
||||||
getKuaishouIndustryConfig,
|
getKuaishouIndustryConfig,
|
||||||
assertMatchingAppKey,
|
assertMatchingAppKey,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
@@ -15,13 +12,15 @@ import { normalizeSendCodePayload, assertSendCodePayload } from './payload.js'
|
|||||||
import { assertKuaishouIndustrySignature } from './crypto.js'
|
import { assertKuaishouIndustrySignature } from './crypto.js'
|
||||||
import {
|
import {
|
||||||
buildIndustrySuccessResponse,
|
buildIndustrySuccessResponse,
|
||||||
buildIndustryErrorResponse,
|
|
||||||
buildIndustryEticketItem,
|
buildIndustryEticketItem,
|
||||||
buildIndustrySendCodeData,
|
buildIndustrySendCodeData,
|
||||||
} from './response.js'
|
} from './response.js'
|
||||||
import { sendCallback } from './send-callback-service.js'
|
import { sendCallback } from './send-callback-service.js'
|
||||||
|
import {
|
||||||
const INDUSTRY_PROFILE_KEY = 'kuaishou-industry'
|
buildKuaishouIndustryEticketFromVoucher,
|
||||||
|
resolveKuaishouIndustryVoucherValidity,
|
||||||
|
} from './voucher-service.js'
|
||||||
|
import { bindKuaishouIndustryVouchersToOrderTasks } from './voucher-binding-service.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
@@ -35,112 +34,54 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
|||||||
assertMatchingAppKey(params.appKey)
|
assertMatchingAppKey(params.appKey)
|
||||||
|
|
||||||
const now = normalizeTimestampIso(new Date().toISOString())
|
const now = normalizeTimestampIso(new Date().toISOString())
|
||||||
|
const nowMs = Date.now()
|
||||||
const normalizedOid = params.oid
|
const normalizedOid = params.oid
|
||||||
|
|
||||||
let order = await findLatestOrderByPlatformOrderId({
|
const order = await findLatestOrderByPlatformOrderId({
|
||||||
provider: config.provider,
|
provider: OPEN_91_PROVIDER,
|
||||||
platform: KUISHOU_INDUSTRY_PLATFORM,
|
platform: OPEN_91_PLATFORM,
|
||||||
platformOrderId: normalizedOid,
|
platformOrderId: normalizedOid,
|
||||||
})
|
})
|
||||||
|
|
||||||
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,
|
|
||||||
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 targetTaskCount = params.num > 0 ? params.num : 1
|
||||||
const tasksToCreate = Math.max(0, targetTaskCount - existingTaskCount)
|
const tasks = order ? await listTasksByOrderId(order.id) : []
|
||||||
|
const validity = resolveKuaishouIndustryVoucherValidity(params, nowMs)
|
||||||
|
const vouchers = []
|
||||||
|
|
||||||
if (tasksToCreate > 0) {
|
for (let index = 0; index < targetTaskCount; index += 1) {
|
||||||
const items = []
|
const unitIndex = index + 1
|
||||||
for (let i = 0; i < tasksToCreate; i++) {
|
const task = tasks.find((item) => Number(item.unit_index || 0) === unitIndex) || null
|
||||||
const unitIndex = existingTaskCount + i + 1
|
const voucher = await upsertKuaishouIndustryVoucher({
|
||||||
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,
|
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,
|
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,
|
token: params.token,
|
||||||
certExpireType: params.certExpireType,
|
orderId: order?.id || null,
|
||||||
certStartTime: params.certStartTime,
|
taskId: task?.id || null,
|
||||||
certEndTime: params.certEndTime,
|
status: 'UNUSED',
|
||||||
certExpDays: params.certExpDays,
|
validStartTime: validity.validStartTime,
|
||||||
certActualStartTime: params.certActualStartTime,
|
validEndTime: validity.validEndTime,
|
||||||
certActualEndTime: params.certActualEndTime,
|
rawPayloadJson: {
|
||||||
}),
|
source: 'kuaishou-industry/send-code',
|
||||||
|
receivedAt: now,
|
||||||
|
body: params,
|
||||||
|
},
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (voucher) {
|
||||||
|
vouchers.push(voucher)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTasks = await listTasksByOrderId(order.id)
|
if (order) {
|
||||||
const etickets = allTasks.map((task) =>
|
await bindKuaishouIndustryVouchersToOrderTasks(order, tasks, {
|
||||||
buildEticketFromTask(task, params),
|
source: 'kuaishou_industry_send_code',
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const etickets = vouchers.map((voucher) =>
|
||||||
|
buildKuaishouIndustryEticketFromVoucher(voucher, params.eticketType),
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = buildIndustrySuccessResponse(
|
const response = buildIndustrySuccessResponse(
|
||||||
@@ -164,72 +105,6 @@ export async function handleSendCode(rawBody: JsonObject = {}) {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureIndustryProfile(now: string) {
|
|
||||||
const existing = await getFulfillmentProfileByKey(INDUSTRY_PROFILE_KEY)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
return upsertFulfillmentProfile({
|
|
||||||
profileKey: INDUSTRY_PROFILE_KEY,
|
|
||||||
name: '快手行业电子凭证',
|
|
||||||
executorKey: INDUSTRY_PROFILE_KEY,
|
|
||||||
requiresClaim: true,
|
|
||||||
autoDispatch: false,
|
|
||||||
inventoryStrategy: 'static_pool',
|
|
||||||
configJson: {},
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEticketFromTask(task: TaskRow, params: ReturnType<typeof normalizeSendCodePayload>) {
|
|
||||||
const taskId = String(task.id || '')
|
|
||||||
const taskStatus = String(task.task_status || '')
|
|
||||||
const status = mapTaskStatusToEticketStatus(taskStatus)
|
|
||||||
|
|
||||||
let contextJson: JsonObject = {}
|
|
||||||
try {
|
|
||||||
contextJson = typeof task.context_json === 'string' ? JSON.parse(task.context_json) : (task.context_json || {})
|
|
||||||
} catch {
|
|
||||||
contextJson = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validStartTime = Number(contextJson.certActualStartTime || params.certActualStartTime || 0)
|
|
||||||
const validEndTime = Number(contextJson.certActualEndTime || params.certActualEndTime || 0)
|
|
||||||
|
|
||||||
return buildIndustryEticketItem({
|
|
||||||
id: taskId,
|
|
||||||
status,
|
|
||||||
num: 1,
|
|
||||||
validStartTime,
|
|
||||||
validEndTime,
|
|
||||||
eticketType: params.eticketType,
|
|
||||||
consumeDetails: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTaskStatusToEticketStatus(taskStatus: string) {
|
|
||||||
switch (taskStatus) {
|
|
||||||
case 'destroyed':
|
|
||||||
case 'cancelled':
|
|
||||||
return 'DESTROYED'
|
|
||||||
case 'consumed':
|
|
||||||
case 'redeemed':
|
|
||||||
case 'completed':
|
|
||||||
case 'delivered':
|
|
||||||
return 'CONSUMED'
|
|
||||||
case 'unused':
|
|
||||||
case 'pending_payment':
|
|
||||||
case 'pending_delivery':
|
|
||||||
case 'pending_fulfill':
|
|
||||||
case 'pending_review':
|
|
||||||
default:
|
|
||||||
return 'UNUSED'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fireSendCallback(input: {
|
function fireSendCallback(input: {
|
||||||
oid: string
|
oid: string
|
||||||
sendType: string
|
sendType: string
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { updateTask } from '../../../repositories/task-repo.js'
|
||||||
|
import {
|
||||||
|
listKuaishouIndustryVouchersByOid,
|
||||||
|
updateKuaishouIndustryVoucherByCode,
|
||||||
|
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
|
import { normalizeTimestampIso } from '../../../utils/time.js'
|
||||||
|
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||||
|
import { normalizeKuaishouCloudFlow } from '../../fulfillment/kuaishou-cloud/domain.js'
|
||||||
|
import type {
|
||||||
|
KuaishouIndustryVoucherRow,
|
||||||
|
OrderRow,
|
||||||
|
TaskRow,
|
||||||
|
} from '../../../types/repository/rows.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export async function bindKuaishouIndustryVouchersToOrderTasks(
|
||||||
|
order: OrderRow | null | undefined,
|
||||||
|
tasks: TaskRow[] = [],
|
||||||
|
options: {
|
||||||
|
source?: string
|
||||||
|
now?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const oid = String(order?.platform_order_id || '').trim()
|
||||||
|
if (!order || !oid) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||||
|
const vouchers = await listKuaishouIndustryVouchersByOid(oid)
|
||||||
|
const bound: KuaishouIndustryVoucherRow[] = []
|
||||||
|
|
||||||
|
for (const voucher of vouchers) {
|
||||||
|
const task = resolveTaskForVoucher(voucher, tasks)
|
||||||
|
const nextVoucher = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||||
|
orderId: order.id,
|
||||||
|
...(task ? { taskId: task.id } : {}),
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
const currentVoucher = nextVoucher || voucher
|
||||||
|
bound.push(currentVoucher)
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
await attachKuaishouIndustryVoucherToTask(task, currentVoucher, {
|
||||||
|
source: options.source || 'voucher_bind',
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bound
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachKuaishouIndustryVoucherToTask(
|
||||||
|
task: TaskRow,
|
||||||
|
voucher: KuaishouIndustryVoucherRow,
|
||||||
|
options: {
|
||||||
|
source?: string
|
||||||
|
now?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const now = normalizeTimestampIso(options.now || new Date().toISOString())
|
||||||
|
const context = parseTaskContext(task)
|
||||||
|
const nextVoucherContext = buildVoucherContext(voucher, context.kuaishouIndustryVoucher, now)
|
||||||
|
const nextContext: JsonObject = {
|
||||||
|
...context,
|
||||||
|
kuaishouIndustryVoucher: nextVoucherContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' || context.kuaishouCloudFulfillment) {
|
||||||
|
const flow = normalizeKuaishouCloudFlow(context.kuaishouCloudFulfillment)
|
||||||
|
const consumedAt = voucher.consumed_at || nextVoucherContext.consumedAt || null
|
||||||
|
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||||
|
|
||||||
|
nextContext.kuaishouCloudFulfillment = {
|
||||||
|
...flow,
|
||||||
|
ticket: {
|
||||||
|
...flow.ticket,
|
||||||
|
code: String(voucher.voucher_code || '').trim(),
|
||||||
|
status: status === 'DESTROYED' ? 'destroyed' : 'verified',
|
||||||
|
capturedAt: flow.ticket.capturedAt || now,
|
||||||
|
capturedBy: flow.ticket.capturedBy || {
|
||||||
|
source: options.source || 'kuaishou_industry_voucher',
|
||||||
|
},
|
||||||
|
verifiedAt: flow.ticket.verifiedAt || now,
|
||||||
|
oid: String(voucher.oid || task.platform_order_id || '').trim(),
|
||||||
|
formToken: String(voucher.token || '').trim(),
|
||||||
|
leftCount: status === 'CONSUMED' || status === 'DESTROYED' ? 0 : 1,
|
||||||
|
goodsTitle: flow.ticket.goodsTitle || task.sku_name || task.sku_code || '',
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
...flow.consume,
|
||||||
|
status: status === 'CONSUMED' ? 'success' : flow.consume.status,
|
||||||
|
autoConsumeEnabled: true,
|
||||||
|
consumedAt: status === 'CONSUMED' ? consumedAt : flow.consume.consumedAt,
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(context) === JSON.stringify(nextContext)) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateTask(task.id, {
|
||||||
|
context_json: JSON.stringify(nextContext),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskForVoucher(voucher: KuaishouIndustryVoucherRow, tasks: TaskRow[]) {
|
||||||
|
const unitIndex = Number(voucher.unit_index || 0)
|
||||||
|
if (unitIndex > 0) {
|
||||||
|
const matched = tasks.find((task) => Number(task.unit_index || 0) === unitIndex)
|
||||||
|
if (matched) {
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks[unitIndex - 1] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVoucherContext(
|
||||||
|
voucher: KuaishouIndustryVoucherRow,
|
||||||
|
existingValue: unknown,
|
||||||
|
now: string,
|
||||||
|
) {
|
||||||
|
const existing = existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
|
||||||
|
? existingValue as JsonObject
|
||||||
|
: {}
|
||||||
|
const status = String(voucher.status || 'UNUSED').trim().toUpperCase()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
oid: String(voucher.oid || '').trim(),
|
||||||
|
token: String(voucher.token || '').trim(),
|
||||||
|
eticketId: String(voucher.voucher_code || '').trim(),
|
||||||
|
voucherCode: String(voucher.voucher_code || '').trim(),
|
||||||
|
unitIndex: Number(voucher.unit_index || 0) || 0,
|
||||||
|
status,
|
||||||
|
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||||
|
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||||
|
verifiedAt: existing.verifiedAt || now,
|
||||||
|
consumedAt: voucher.consumed_at || existing.consumedAt || null,
|
||||||
|
destroyedAt: voucher.destroyed_at || existing.destroyedAt || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { resolveKuaishouIndustryVoucherValidity } from './voucher-service.js'
|
||||||
|
|
||||||
|
test('resolveKuaishouIndustryVoucherValidity uses certExpDays when explicit times are zero', () => {
|
||||||
|
const nowMs = 1783394218325
|
||||||
|
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||||
|
certActualStartTime: 0,
|
||||||
|
certActualEndTime: 0,
|
||||||
|
certStartTime: 0,
|
||||||
|
certEndTime: 0,
|
||||||
|
certExpDays: 3,
|
||||||
|
}, nowMs)
|
||||||
|
|
||||||
|
assert.equal(validity.validStartTime, nowMs)
|
||||||
|
assert.equal(validity.validEndTime, nowMs + 3 * 86_400_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveKuaishouIndustryVoucherValidity prefers actual certificate time range', () => {
|
||||||
|
const validity = resolveKuaishouIndustryVoucherValidity({
|
||||||
|
certActualStartTime: 1000,
|
||||||
|
certActualEndTime: 5000,
|
||||||
|
certStartTime: 2000,
|
||||||
|
certEndTime: 6000,
|
||||||
|
certExpDays: 3,
|
||||||
|
}, 9000)
|
||||||
|
|
||||||
|
assert.equal(validity.validStartTime, 1000)
|
||||||
|
assert.equal(validity.validEndTime, 5000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||||
|
import {
|
||||||
|
findKuaishouIndustryVoucherByCode,
|
||||||
|
listKuaishouIndustryVouchersByTaskId,
|
||||||
|
updateKuaishouIndustryVoucherByCode,
|
||||||
|
} from '../../../repositories/kuaishou-industry-voucher-repo.js'
|
||||||
|
import { logWarn } from '../../../utils/logger.js'
|
||||||
|
import { buildIndustryEticketItem } from './response.js'
|
||||||
|
import { consumeCallback } from './consume-callback-service.js'
|
||||||
|
import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
|
export const KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS = 3
|
||||||
|
|
||||||
|
export function resolveKuaishouIndustryVoucherValidity(
|
||||||
|
input: {
|
||||||
|
certActualStartTime?: unknown
|
||||||
|
certStartTime?: unknown
|
||||||
|
certActualEndTime?: unknown
|
||||||
|
certEndTime?: unknown
|
||||||
|
certExpDays?: unknown
|
||||||
|
} = {},
|
||||||
|
nowMs = Date.now(),
|
||||||
|
) {
|
||||||
|
const certActualStartTime = normalizePositiveTimestamp(input.certActualStartTime)
|
||||||
|
const certStartTime = normalizePositiveTimestamp(input.certStartTime)
|
||||||
|
const certActualEndTime = normalizePositiveTimestamp(input.certActualEndTime)
|
||||||
|
const certEndTime = normalizePositiveTimestamp(input.certEndTime)
|
||||||
|
const certExpDays = normalizePositiveInteger(
|
||||||
|
input.certExpDays,
|
||||||
|
KUAISHOU_INDUSTRY_DEFAULT_VALID_DAYS,
|
||||||
|
)
|
||||||
|
const durationMs = certExpDays * 86_400_000
|
||||||
|
const validStartTime = certActualStartTime || certStartTime || normalizePositiveTimestamp(nowMs)
|
||||||
|
const validEndTime =
|
||||||
|
certActualEndTime ||
|
||||||
|
certEndTime ||
|
||||||
|
(certStartTime ? certStartTime + durationMs : validStartTime + durationMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
validStartTime,
|
||||||
|
validEndTime,
|
||||||
|
certExpDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKuaishouIndustryEticketFromVoucher(
|
||||||
|
voucher: KuaishouIndustryVoucherRow,
|
||||||
|
eticketType = '',
|
||||||
|
) {
|
||||||
|
return buildIndustryEticketItem({
|
||||||
|
id: String(voucher.voucher_code || ''),
|
||||||
|
status: normalizeVoucherStatus(voucher.status),
|
||||||
|
num: 1,
|
||||||
|
validStartTime: Number(voucher.valid_start_time || 0) || 0,
|
||||||
|
validEndTime: Number(voucher.valid_end_time || 0) || 0,
|
||||||
|
eticketType,
|
||||||
|
consumeDetails: resolveVoucherConsumeDetails(voucher),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeKuaishouIndustryVouchersForTask(
|
||||||
|
task: TaskRow,
|
||||||
|
input: {
|
||||||
|
source?: string
|
||||||
|
token?: string
|
||||||
|
eticketType?: string
|
||||||
|
consumeType?: string
|
||||||
|
consumeTime?: number
|
||||||
|
storeName?: string
|
||||||
|
storeAddress?: string
|
||||||
|
expressCode?: string
|
||||||
|
expressNo?: string
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const vouchers = await resolveTaskVouchers(task)
|
||||||
|
const consumed: KuaishouIndustryVoucherRow[] = []
|
||||||
|
const failed: Array<{ voucher: KuaishouIndustryVoucherRow; errorMessage: string }> = []
|
||||||
|
|
||||||
|
for (const voucher of vouchers) {
|
||||||
|
const consumeInput: Parameters<typeof consumeKuaishouIndustryVoucher>[1] = {
|
||||||
|
source: input.source || 'fulfillment_completed',
|
||||||
|
consumeType: input.consumeType || 'delivery',
|
||||||
|
consumeTime: input.consumeTime || Date.now(),
|
||||||
|
task,
|
||||||
|
}
|
||||||
|
if (input.token !== undefined) consumeInput.token = input.token
|
||||||
|
if (input.eticketType !== undefined) consumeInput.eticketType = input.eticketType
|
||||||
|
if (input.storeName !== undefined) consumeInput.storeName = input.storeName
|
||||||
|
if (input.storeAddress !== undefined) consumeInput.storeAddress = input.storeAddress
|
||||||
|
if (input.expressCode !== undefined) consumeInput.expressCode = input.expressCode
|
||||||
|
if (input.expressNo !== undefined) consumeInput.expressNo = input.expressNo
|
||||||
|
|
||||||
|
const result = await consumeKuaishouIndustryVoucher(voucher, consumeInput)
|
||||||
|
|
||||||
|
if (result.ok && result.voucher) {
|
||||||
|
consumed.push(result.voucher)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
failed.push({
|
||||||
|
voucher,
|
||||||
|
errorMessage: result.errorMessage || '电子凭证核销回调失败',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vouchers,
|
||||||
|
consumed,
|
||||||
|
failed,
|
||||||
|
ok: vouchers.length > 0 && failed.length === 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeKuaishouIndustryVoucher(
|
||||||
|
voucher: KuaishouIndustryVoucherRow,
|
||||||
|
input: {
|
||||||
|
source?: string
|
||||||
|
token?: string
|
||||||
|
eticketType?: string
|
||||||
|
consumeType?: string
|
||||||
|
consumeTime?: number
|
||||||
|
storeName?: string
|
||||||
|
storeAddress?: string
|
||||||
|
expressCode?: string
|
||||||
|
expressNo?: string
|
||||||
|
task?: TaskRow
|
||||||
|
serialNum?: string
|
||||||
|
skipCallback?: boolean
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const currentStatus = normalizeVoucherStatus(voucher.status)
|
||||||
|
if (currentStatus === 'CONSUMED') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
voucher,
|
||||||
|
callbackSuccess: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStatus === 'DESTROYED') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
voucher,
|
||||||
|
callbackSuccess: false,
|
||||||
|
errorMessage: '电子凭证已销毁,不能核销',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const consumeTime = Number(input.consumeTime || Date.now()) || Date.now()
|
||||||
|
const consumeType = String(input.consumeType || 'delivery').trim() || 'delivery'
|
||||||
|
const serialNum = String(input.serialNum || voucher.consume_serial_num || `CONSUME-${voucher.voucher_code}`).trim()
|
||||||
|
const token = String(input.token || voucher.token || '').trim()
|
||||||
|
const consumeDetail = {
|
||||||
|
serialNum,
|
||||||
|
consumeType,
|
||||||
|
consumeTime,
|
||||||
|
storeName: input.storeName || undefined,
|
||||||
|
storeAddress: input.storeAddress || undefined,
|
||||||
|
expressCode: input.expressCode || undefined,
|
||||||
|
expressNo: input.expressNo || undefined,
|
||||||
|
source: input.source || 'system',
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackResult = input.skipCallback
|
||||||
|
? { success: true as const }
|
||||||
|
: await consumeCallback({
|
||||||
|
oid: voucher.oid,
|
||||||
|
etickets: [{
|
||||||
|
id: voucher.voucher_code,
|
||||||
|
num: 1,
|
||||||
|
status: 'CONSUMED',
|
||||||
|
}],
|
||||||
|
status: 'CONSUMED',
|
||||||
|
consumeType,
|
||||||
|
consumeTime,
|
||||||
|
token,
|
||||||
|
seriallNum: serialNum,
|
||||||
|
...(input.storeName ? { storeName: input.storeName } : {}),
|
||||||
|
...(input.storeAddress ? { storeAddress: input.storeAddress } : {}),
|
||||||
|
...(input.expressCode ? { expressCode: input.expressCode } : {}),
|
||||||
|
...(input.expressNo ? { expressNo: input.expressNo } : {}),
|
||||||
|
...(input.eticketType ? { eticketType: input.eticketType } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!callbackResult.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
voucher,
|
||||||
|
callbackSuccess: false,
|
||||||
|
errorMessage: callbackResult.error || '电子凭证核销回调失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDetails = appendConsumeDetail(resolveVoucherConsumeDetails(voucher), consumeDetail)
|
||||||
|
const updated = await updateKuaishouIndustryVoucherByCode(voucher.voucher_code, {
|
||||||
|
status: 'CONSUMED',
|
||||||
|
consumeSerialNum: serialNum,
|
||||||
|
consumeDetailsJson: nextDetails,
|
||||||
|
consumedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.task) {
|
||||||
|
await createTaskEvent(
|
||||||
|
input.task.id,
|
||||||
|
'kuaishou_industry_voucher_consumed',
|
||||||
|
{
|
||||||
|
oid: voucher.oid,
|
||||||
|
voucherCode: voucher.voucher_code,
|
||||||
|
serialNum,
|
||||||
|
consumeType,
|
||||||
|
consumeTime,
|
||||||
|
source: input.source || 'system',
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
voucher: updated || voucher,
|
||||||
|
callbackSuccess: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskVouchers(task: TaskRow): Promise<KuaishouIndustryVoucherRow[]> {
|
||||||
|
const taskId = Number(task?.id || 0)
|
||||||
|
if (taskId > 0) {
|
||||||
|
return listKuaishouIndustryVouchersByTaskId(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = parseJsonObject(task?.context_json)
|
||||||
|
const voucher = parseJsonObject(context.kuaishouIndustryVoucher)
|
||||||
|
const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim()
|
||||||
|
const oid = String(voucher.oid || task?.platform_order_id || '').trim()
|
||||||
|
if (!voucherCode) {
|
||||||
|
return Promise.resolve([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return findKuaishouIndustryVoucherByCode(voucherCode, oid)
|
||||||
|
.then((row) => row ? [row] : [])
|
||||||
|
.catch((error) => {
|
||||||
|
logWarn('[kuaishou-industry/voucher]', '按上下文查询电子凭证失败', {
|
||||||
|
taskId: task?.id || null,
|
||||||
|
voucherCode,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVoucherConsumeDetails(voucher: KuaishouIndustryVoucherRow): JsonObject[] {
|
||||||
|
const parsed = parseJsonArray(voucher.consume_details_json)
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeVoucherStatus(voucher.status) !== 'CONSUMED' || !voucher.consume_serial_num) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
serialNum: voucher.consume_serial_num,
|
||||||
|
consumeType: 'delivery',
|
||||||
|
consumeTime: voucher.consumed_at ? Date.parse(voucher.consumed_at) : 0,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendConsumeDetail(details: JsonObject[], nextDetail: JsonObject): JsonObject[] {
|
||||||
|
const serialNum = String(nextDetail.serialNum || '').trim()
|
||||||
|
if (serialNum && details.some((item) => String(item.serialNum || '').trim() === serialNum)) {
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...details, nextDetail]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVoucherStatus(value: unknown): string {
|
||||||
|
const normalized = String(value || '').trim().toUpperCase()
|
||||||
|
if (normalized === 'CONSUMED' || normalized === 'DESTROYED') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'UNUSED'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveTimestamp(value: unknown): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: unknown): JsonObject {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as JsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value || '{}'))
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(value: unknown): JsonObject[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is JsonObject =>
|
||||||
|
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value || '[]'))
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is JsonObject =>
|
||||||
|
Boolean(item && typeof item === 'object' && !Array.isArray(item)),
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } fr
|
|||||||
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js'
|
||||||
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../../repositories/task-repo.js'
|
||||||
import { upsertOrderFromSource } from '../../order/order-service.js'
|
import { upsertOrderFromSource } from '../../order/order-service.js'
|
||||||
|
import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js'
|
||||||
import {
|
import {
|
||||||
OPEN_91_PLATFORM,
|
OPEN_91_PLATFORM,
|
||||||
OPEN_91_PROVIDER,
|
OPEN_91_PROVIDER,
|
||||||
@@ -175,6 +176,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config:
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
await bindKuaishouIndustryVouchersToOrderTasks(order, [], {
|
||||||
|
source: 'open91_pending_order',
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
order,
|
order,
|
||||||
|
|||||||
@@ -127,3 +127,25 @@ export type TaskUpdatePatch = {
|
|||||||
artifacts_json?: string | Record<string, unknown>
|
artifacts_json?: string | Record<string, unknown>
|
||||||
state_json?: string | Record<string, unknown>
|
state_json?: string | Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KuaishouIndustryVoucherUpsertInput = {
|
||||||
|
oid: string
|
||||||
|
unitIndex: number
|
||||||
|
token?: string
|
||||||
|
orderId?: number | string | null
|
||||||
|
taskId?: number | string | null
|
||||||
|
status?: string
|
||||||
|
validStartTime?: number
|
||||||
|
validEndTime?: number
|
||||||
|
consumeSerialNum?: string
|
||||||
|
consumeDetailsJson?: string | Record<string, unknown> | unknown[]
|
||||||
|
consumedAt?: string | null
|
||||||
|
destroyedAt?: string | null
|
||||||
|
rawPayloadJson?: string | Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KuaishouIndustryVoucherUpdatePatch = Partial<
|
||||||
|
Omit<KuaishouIndustryVoucherUpsertInput, 'oid' | 'unitIndex' | 'createdAt'>
|
||||||
|
>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type TaskRow = {
|
|||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number
|
||||||
order_item_id: number
|
order_item_id: number
|
||||||
|
unit_index: number
|
||||||
platform_order_id: string
|
platform_order_id: string
|
||||||
profile_id: number
|
profile_id: number
|
||||||
task_no: string
|
task_no: string
|
||||||
@@ -103,6 +104,26 @@ export type TaskEventRow = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type KuaishouIndustryVoucherRow = {
|
||||||
|
id: number
|
||||||
|
voucher_code: string
|
||||||
|
oid: string
|
||||||
|
order_id: number | null
|
||||||
|
task_id: number | null
|
||||||
|
unit_index: number
|
||||||
|
token: string
|
||||||
|
status: string
|
||||||
|
valid_start_time: number | string
|
||||||
|
valid_end_time: number | string
|
||||||
|
consume_serial_num: string
|
||||||
|
consume_details_json: string | Record<string, unknown> | unknown[]
|
||||||
|
consumed_at: string | null
|
||||||
|
destroyed_at: string | null
|
||||||
|
raw_payload_json: string | Record<string, unknown>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export type OrderListQueryResult = {
|
export type OrderListQueryResult = {
|
||||||
items: OrderListRow[]
|
items: OrderListRow[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export type AdminDefaultUser = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type KuaishouFeifeiProductRule = {
|
||||||
|
productName: string;
|
||||||
|
productCode: string;
|
||||||
|
skuName?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeConfig = {
|
export type RuntimeConfig = {
|
||||||
server: {
|
server: {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -90,6 +97,14 @@ export type RuntimeConfig = {
|
|||||||
shopName: string;
|
shopName: string;
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
kuaishouFeifei: {
|
||||||
|
baseUrl: string;
|
||||||
|
appKey: string;
|
||||||
|
appSecret: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
notifyUrl: string;
|
||||||
|
productRules: KuaishouFeifeiProductRule[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: string[];
|
allowedOrigins: string[];
|
||||||
|
|||||||
Vendored
-3
@@ -17,7 +17,6 @@ declare module 'vue' {
|
|||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCard: typeof import('element-plus/es')['ElCard']
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
@@ -32,7 +31,6 @@ declare module 'vue' {
|
|||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
@@ -45,7 +43,6 @@ declare module 'vue' {
|
|||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
|||||||
@@ -135,15 +135,34 @@ export interface ClaimKuaishouCloudFlowInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClaimKuaishouFeifeiFlowInfo {
|
||||||
|
flowType: 'kuaishou_feifei'
|
||||||
|
productCode: string
|
||||||
|
productName: string
|
||||||
|
platformOrderNo: string
|
||||||
|
orderNo: string
|
||||||
|
rechargeStatus: number
|
||||||
|
rechargeStatusLabel: string
|
||||||
|
rechargeResultMessage: string
|
||||||
|
claimUrl: string
|
||||||
|
consumeStatus: string
|
||||||
|
h5: {
|
||||||
|
entryUrl: string
|
||||||
|
rechargeUrl: string
|
||||||
|
}
|
||||||
|
lastSyncedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClaimDetailData {
|
export interface ClaimDetailData {
|
||||||
tokenStatus: ClaimTokenStatus
|
tokenStatus: ClaimTokenStatus
|
||||||
claimUrl: string
|
claimUrl: string
|
||||||
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {})
|
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {})
|
||||||
task: ClaimTaskInfo
|
task: ClaimTaskInfo
|
||||||
order: ClaimOrderInfo
|
order: ClaimOrderInfo
|
||||||
orderItem: ClaimOrderItemInfo
|
orderItem: ClaimOrderItemInfo
|
||||||
product: ClaimProductInfo
|
product: ClaimProductInfo
|
||||||
session: unknown | null
|
session: unknown | null
|
||||||
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
|
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
|
||||||
|
kuaishouFeifei: ClaimKuaishouFeifeiFlowInfo | null
|
||||||
result: ClaimResultInfo | null
|
result: ClaimResultInfo | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,42 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
|||||||
<p>{{ claim.errorMessage.value }}</p>
|
<p>{{ claim.errorMessage.value }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="claim.detail.value?.flowType === 'kuaishou_feifei' && claim.feifei.value">
|
||||||
|
<section class="content-card feifei-card">
|
||||||
|
<div class="feifei-main">
|
||||||
|
<p class="feifei-label">kuaishou-feifei</p>
|
||||||
|
<h2>{{ claim.product.value?.title || claim.orderItem.value?.skuName || '商品领取' }}</h2>
|
||||||
|
<p class="feifei-status">
|
||||||
|
{{ claim.feifei.value.rechargeStatusLabel || '待领取' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="feifei-meta">
|
||||||
|
<div>
|
||||||
|
<dt>订单号</dt>
|
||||||
|
<dd>{{ claim.order.value?.platformOrderId || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>平台单号</dt>
|
||||||
|
<dd>{{ claim.feifei.value.orderNo || claim.feifei.value.platformOrderNo || '-' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:disabled="!claim.feifei.value.h5.rechargeUrl && !claim.feifei.value.h5.entryUrl"
|
||||||
|
@click="claim.openFeifeiUrl(false)"
|
||||||
|
>
|
||||||
|
打开领取链接
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<p v-if="claim.task.value?.lastError" class="feifei-error">
|
||||||
|
{{ claim.task.value.lastError }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="claim.detail.value && claim.flow.value">
|
<template v-else-if="claim.detail.value && claim.flow.value">
|
||||||
<ClaimTicketStep
|
<ClaimTicketStep
|
||||||
v-if="claim.currentStep.value === 1"
|
v-if="claim.currentStep.value === 1"
|
||||||
@@ -145,6 +181,60 @@ const claim = useKuaishouCloudClaim(() => props.token)
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feifei-card {
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-label {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-main h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-status {
|
||||||
|
margin: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-meta {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-meta div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-meta dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feifei-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.spinning {
|
.spinning {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
// ── derived data ────────────────────────────────────────
|
// ── derived data ────────────────────────────────────────
|
||||||
|
|
||||||
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
const flow = computed(() => detail.value?.kuaishouCloudFulfillment || null)
|
||||||
|
const feifei = computed(() => detail.value?.kuaishouFeifei || null)
|
||||||
const order = computed(() => detail.value?.order || null)
|
const order = computed(() => detail.value?.order || null)
|
||||||
const orderItem = computed(() => detail.value?.orderItem || null)
|
const orderItem = computed(() => detail.value?.orderItem || null)
|
||||||
const product = computed(() => detail.value?.product || null)
|
const product = computed(() => detail.value?.product || null)
|
||||||
@@ -55,6 +56,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
|
|
||||||
const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
|
const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '')
|
||||||
const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
|
const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '')
|
||||||
|
const isFeifeiFlow = computed(() => detail.value?.flowType === 'kuaishou_feifei')
|
||||||
const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
|
const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified')
|
||||||
|
|
||||||
const isBindUrlExpired = computed(() => {
|
const isBindUrlExpired = computed(() => {
|
||||||
@@ -125,6 +127,9 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const currentStep = computed(() => {
|
const currentStep = computed(() => {
|
||||||
|
if (isFeifeiFlow.value) {
|
||||||
|
return isCompleted.value ? 4 : 2
|
||||||
|
}
|
||||||
if (hasRedeemResult.value) {
|
if (hasRedeemResult.value) {
|
||||||
return 4
|
return 4
|
||||||
}
|
}
|
||||||
@@ -138,6 +143,9 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const progressText = computed(() => {
|
const progressText = computed(() => {
|
||||||
|
if (isFeifeiFlow.value) {
|
||||||
|
return feifei.value?.rechargeStatusLabel || '请打开领取链接'
|
||||||
|
}
|
||||||
if (hasRedeemResult.value) {
|
if (hasRedeemResult.value) {
|
||||||
return '兑换结果已生成'
|
return '兑换结果已生成'
|
||||||
}
|
}
|
||||||
@@ -365,6 +373,19 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
window.open(bindUrl, '_blank', 'noopener,noreferrer')
|
window.open(bindUrl, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFeifeiUrl(useCurrentPage = false) {
|
||||||
|
const url = String(feifei.value?.h5.rechargeUrl || feifei.value?.h5.entryUrl || '').trim()
|
||||||
|
if (!url) {
|
||||||
|
showError('领取链接还没准备好,请稍后刷新')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (useCurrentPage) {
|
||||||
|
window.location.assign(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
|
||||||
// ── polling ─────────────────────────────────────────────
|
// ── polling ─────────────────────────────────────────────
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
@@ -457,6 +478,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
// derived data
|
// derived data
|
||||||
flow,
|
flow,
|
||||||
|
feifei,
|
||||||
order,
|
order,
|
||||||
orderItem,
|
orderItem,
|
||||||
product,
|
product,
|
||||||
@@ -464,6 +486,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
// computed status
|
// computed status
|
||||||
roleName,
|
roleName,
|
||||||
roleId,
|
roleId,
|
||||||
|
isFeifeiFlow,
|
||||||
isTicketVerified,
|
isTicketVerified,
|
||||||
isBindUrlExpired,
|
isBindUrlExpired,
|
||||||
isBindingPrepared,
|
isBindingPrepared,
|
||||||
@@ -493,6 +516,7 @@ export function useKuaishouCloudClaim(token: () => string) {
|
|||||||
rebindRole,
|
rebindRole,
|
||||||
confirmRedeem,
|
confirmRedeem,
|
||||||
openBindUrl,
|
openBindUrl,
|
||||||
|
openFeifeiUrl,
|
||||||
// utility
|
// utility
|
||||||
formatAdminDateTime,
|
formatAdminDateTime,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# 多发货平台与电子凭证整体链路
|
||||||
|
|
||||||
|
## 1. 当前结论
|
||||||
|
|
||||||
|
当前系统需要把四类能力拆开理解:
|
||||||
|
|
||||||
|
| 模块 | 定位 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 91 卡券 | 订单来源 + 发链接通道 | 91 调用我方下单接口,我方生成领取链接;91 查询订单时拿到领取链接,并发送给用户。 |
|
||||||
|
| kuaishou-cloud | 发货平台 1 | 已对接,用于发放它支持的皮肤、道具。 |
|
||||||
|
| kuaishou-feifei | 发货平台 2 | 新增发货平台,作为 kuaishou-cloud 的补充,覆盖 kuaishou-cloud 没有的皮肤、道具。 |
|
||||||
|
| 快手电子凭证 | 领取流程里的自动核销能力 | 用来替代用户在领取页手动输入核销码的步骤,不替代 91,也不替代发货平台。 |
|
||||||
|
|
||||||
|
因此整体设计不是“新增订单来源”,而是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 负责进单和把领取链接发给用户
|
||||||
|
我方领取页负责统一承接用户
|
||||||
|
kuaishou-cloud / kuaishou-feifei 负责真实发货
|
||||||
|
快手电子凭证负责把领取页里的核销码输入步骤自动化
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 目标链路
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 卡券下单
|
||||||
|
-> 我方创建内部订单和履约任务
|
||||||
|
-> 按商品选择发货平台
|
||||||
|
-> kuaishou-cloud
|
||||||
|
-> kuaishou-feifei
|
||||||
|
-> 91 卡券查询订单
|
||||||
|
-> 我方返回统一 claimUrl
|
||||||
|
-> 91 卡券把 claimUrl 发给用户
|
||||||
|
-> 用户打开 claimUrl
|
||||||
|
-> 系统检查快手电子凭证状态
|
||||||
|
-> 已有关联电子凭证:跳过手动输入核销码
|
||||||
|
-> 未关联电子凭证:保留兜底处理
|
||||||
|
-> 用户继续完成绑定 / 跳转发货平台 H5
|
||||||
|
-> 发货平台完成履约
|
||||||
|
-> 我方更新任务状态
|
||||||
|
-> 我方完成快手电子凭证核销闭环
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 91 卡券职责边界
|
||||||
|
|
||||||
|
91 卡券仍然只承担两件事:
|
||||||
|
|
||||||
|
1. 调用我方异步下单接口,提供 `orderNo`、`productNo`、`buyNum` 等订单信息。
|
||||||
|
2. 调用我方查询接口,获取最终要发给用户的卡密内容。
|
||||||
|
|
||||||
|
这里的卡密内容继续保持为我方统一领取链接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
claimUrl = https://你的域名/#/claim/{token}
|
||||||
|
```
|
||||||
|
|
||||||
|
不要直接把 kuaishou-cloud 绑定链接或 kuaishou-feifei 的 H5 链接返回给 91。原因是领取页需要统一处理:
|
||||||
|
|
||||||
|
- 商品最终走哪个发货平台;
|
||||||
|
- 是否已经通过快手电子凭证完成自动核销;
|
||||||
|
- 是否需要展示绑定链接、二维码或 H5;
|
||||||
|
- 异常时如何兜底、重试、转人工。
|
||||||
|
|
||||||
|
## 4. 发货平台选择
|
||||||
|
|
||||||
|
履约任务创建时,需要根据商品配置选择发货平台:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 productNo
|
||||||
|
-> 商品匹配
|
||||||
|
-> 命中 kuaishou-cloud 商品
|
||||||
|
-> executor_key = kuaishou_ct_assisted
|
||||||
|
-> 命中 kuaishou-feifei 商品
|
||||||
|
-> executor_key = kuaishou_feifei
|
||||||
|
-> 未命中
|
||||||
|
-> 进入待补全 / 人工处理
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级建议先做成可配置规则,不在代码里硬编码。第一阶段可以采用:
|
||||||
|
|
||||||
|
1. 显式覆盖规则优先;
|
||||||
|
2. 再按商品名匹配 kuaishou-cloud;
|
||||||
|
3. 再按商品名或 `product_code` 匹配 kuaishou-feifei;
|
||||||
|
4. 都未命中则进入待补全队列。
|
||||||
|
|
||||||
|
## 5. kuaishou-feifei 接入方式
|
||||||
|
|
||||||
|
kuaishou-feifei 的主要交付物是 `h5.recharge_url`。
|
||||||
|
|
||||||
|
创建任务后,系统应向 kuaishou-feifei 创建订单,并把关键字段保存到任务上下文:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kuaishouFeifei": {
|
||||||
|
"platformOrderNo": "内部幂等单号",
|
||||||
|
"orderNo": "发货平台内部单号",
|
||||||
|
"productCode": "商品编码",
|
||||||
|
"rechargeStatus": 15,
|
||||||
|
"statusLabel": "待绑定",
|
||||||
|
"h5": {
|
||||||
|
"rechargeUrl": "http://..."
|
||||||
|
},
|
||||||
|
"lastSyncedAt": "2026-07-07T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
领取页根据当前任务的 executor 展示不同内容:
|
||||||
|
|
||||||
|
- `kuaishou_ct_assisted`:展示现有 kuaishou-cloud 绑定流程;
|
||||||
|
- `kuaishou_feifei`:展示或跳转 kuaishou-feifei 的 `h5.recharge_url`;
|
||||||
|
- `manual_dispatch`:进入人工兜底。
|
||||||
|
|
||||||
|
## 6. 快手电子凭证的嵌入点
|
||||||
|
|
||||||
|
旧流程中,用户打开领取链接后需要手动输入快手核销码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户打开 claimUrl
|
||||||
|
-> 输入核销码
|
||||||
|
-> 我方校验 / 核销
|
||||||
|
-> 继续绑定或发货
|
||||||
|
```
|
||||||
|
|
||||||
|
新流程中,快手电子凭证用于把这一步自动化:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户打开 claimUrl
|
||||||
|
-> 系统查任务上下文中的电子凭证信息
|
||||||
|
-> 如果已有关联的 oid / token / eticketId
|
||||||
|
-> 自动认为核销凭证已就绪
|
||||||
|
-> 跳过手动输入核销码
|
||||||
|
-> 继续进入发货平台绑定 / H5
|
||||||
|
```
|
||||||
|
|
||||||
|
电子凭证信息建议沉淀在 task context 中:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kuaishouIndustryVoucher": {
|
||||||
|
"oid": "快手订单号",
|
||||||
|
"token": "订单维度授权 token",
|
||||||
|
"eticketId": "电子凭证 id",
|
||||||
|
"status": "UNUSED",
|
||||||
|
"certActualStartTime": 0,
|
||||||
|
"certActualEndTime": 0,
|
||||||
|
"verifiedAt": null,
|
||||||
|
"consumedAt": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:快手电子凭证不是新的订单来源。它只服务领取流程中的核销自动化。
|
||||||
|
|
||||||
|
## 7. 核销完成时机
|
||||||
|
|
||||||
|
电子凭证不应该在用户刚打开领取页时就立刻标记为已消费。
|
||||||
|
|
||||||
|
推荐时机:
|
||||||
|
|
||||||
|
```text
|
||||||
|
发货平台实际履约成功
|
||||||
|
-> 我方更新 task 为 completed / redeemed
|
||||||
|
-> 我方触发快手电子凭证核销回调
|
||||||
|
-> 电子凭证状态更新为 CONSUMED
|
||||||
|
```
|
||||||
|
|
||||||
|
这样可以避免用户打开链接但未完成绑定时,快手侧已经显示核销成功。
|
||||||
|
|
||||||
|
不同发货平台的成功判定:
|
||||||
|
|
||||||
|
| 发货平台 | 成功判定 |
|
||||||
|
| --- | --- |
|
||||||
|
| kuaishou-cloud | 当前发货流程完成,任务进入完成态。 |
|
||||||
|
| kuaishou-feifei | 查询或异步通知返回 `recharge_status = 30`。 |
|
||||||
|
|
||||||
|
失败时:
|
||||||
|
|
||||||
|
- 发货平台失败:任务进入 `manual_review` 或 `failed`;
|
||||||
|
- 电子凭证保持 `UNUSED`,不要误核销;
|
||||||
|
- 需要人工确认时再做销毁、退款或补发。
|
||||||
|
|
||||||
|
## 8. 现有代码需要调整的方向
|
||||||
|
|
||||||
|
当前项目已经有以下基础能力:
|
||||||
|
|
||||||
|
- 91 卡券下单和查询;
|
||||||
|
- kuaishou-cloud 发货流程;
|
||||||
|
- 快手电子凭证 `send-code / query-code / destroy-code / consume-code` 接口骨架;
|
||||||
|
- 领取页手动输入核销码流程。
|
||||||
|
|
||||||
|
后续需要把它们串起来:
|
||||||
|
|
||||||
|
1. 91 查询仍返回统一 `claimUrl`。
|
||||||
|
2. 商品匹配支持多发货平台。
|
||||||
|
3. kuaishou-feifei 新增独立 executor 和 API client。
|
||||||
|
4. 领取页根据 task executor 展示对应发货流程。
|
||||||
|
5. 快手电子凭证状态进入 task context,用于跳过手动输入核销码。
|
||||||
|
6. 发货成功后再触发快手电子凭证核销。
|
||||||
|
7. 保留手动输入核销码作为异常兜底,而不是主路径。
|
||||||
|
|
||||||
|
## 9. 数据关联
|
||||||
|
|
||||||
|
91 订单和快手电子凭证之间需要稳定关联。
|
||||||
|
|
||||||
|
优先方案:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 orderNo == 快手电子凭证 oid
|
||||||
|
```
|
||||||
|
|
||||||
|
如果不相等,则需要额外映射关系,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 orderNo
|
||||||
|
-> 内部 order.id
|
||||||
|
-> task.id
|
||||||
|
-> 快手电子凭证 oid / eticketId / token
|
||||||
|
```
|
||||||
|
|
||||||
|
没有这层映射,领取页无法可靠判断当前用户对应哪一张快手电子凭证,也就无法稳定跳过手动核销码输入步骤。
|
||||||
|
|
||||||
|
## 10. 真实测试订单复盘
|
||||||
|
|
||||||
|
2026-07-07 的真实测试请求验证了优先关联方案成立。
|
||||||
|
|
||||||
|
### 10.1 请求时序
|
||||||
|
|
||||||
|
```text
|
||||||
|
10:16:43 91 卡券异步下单
|
||||||
|
orderNo = 2618800083429561
|
||||||
|
productNo = 套装-Alan Walker
|
||||||
|
buyNum = 1
|
||||||
|
|
||||||
|
10:16:46 快手电子凭证 send-code
|
||||||
|
oid = 2618800083429561
|
||||||
|
itemTitle = 测试连接1111
|
||||||
|
itemId = 26692927860114
|
||||||
|
skuId = 188718171010114
|
||||||
|
eticketId = 2038
|
||||||
|
|
||||||
|
10:17:13 91 卡券查询订单
|
||||||
|
orderStatus = 10
|
||||||
|
cards = ''
|
||||||
|
|
||||||
|
10:17:44 91 卡券再次查询订单
|
||||||
|
orderStatus = 10
|
||||||
|
cards = ''
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 已验证事实
|
||||||
|
|
||||||
|
这笔真实单中:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 orderNo = 2618800083429561
|
||||||
|
快手电子凭证 oid = 2618800083429561
|
||||||
|
```
|
||||||
|
|
||||||
|
因此可以用 `orderNo / oid` 作为 91 订单和快手电子凭证的主关联键。
|
||||||
|
|
||||||
|
同时也验证了另一个重要事实:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 productNo = 套装-Alan Walker
|
||||||
|
快手 itemTitle = 测试连接1111
|
||||||
|
```
|
||||||
|
|
||||||
|
两者不一定相同。因此,发什么皮肤、走哪个发货平台,应该以 91 的 `productNo` 为准;快手电子凭证的 `itemTitle / itemId / skuId` 主要用于凭证关系、快手侧订单信息和核销闭环,不应该直接用于决定发货商品。
|
||||||
|
|
||||||
|
### 10.3 当前断点
|
||||||
|
|
||||||
|
当前系统在这笔单上的表现:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 下单结果:
|
||||||
|
ignored = true
|
||||||
|
ignoreReason = unconfigured_items
|
||||||
|
orderId = 2487
|
||||||
|
orderStatus = 10
|
||||||
|
cards = ''
|
||||||
|
|
||||||
|
快手电子凭证结果:
|
||||||
|
result = 1
|
||||||
|
eticketId = 2038
|
||||||
|
status = UNUSED
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
1. 91 订单已经进入系统,但因为 `套装-Alan Walker` 没命中现有履约配置,所以进入待补全队列。
|
||||||
|
2. 快手电子凭证已经发码成功,但当前实现没有把 `eticketId = 2038` 绑定回 91 订单的履约上下文。
|
||||||
|
3. 91 查询只看 `provider = 91kaquan` 的订单和任务,因此看不到快手电子凭证链路创建或返回的状态。
|
||||||
|
4. 因为 91 订单没有可交付的 `claimUrl`,所以持续返回 `orderStatus = 10` 和空 `cards`。
|
||||||
|
|
||||||
|
### 10.4 实现修正方向
|
||||||
|
|
||||||
|
`kuaishou-industry/send-code` 收到请求后,不能只按 `provider = kuaishou-industry` 自己创建孤立订单。它需要优先做关联:
|
||||||
|
|
||||||
|
```text
|
||||||
|
send-code.oid
|
||||||
|
-> 查找 91 订单 platformOrderId = oid
|
||||||
|
-> 找到:把电子凭证信息写入该 91 订单对应 task / context
|
||||||
|
-> 未找到:暂存电子凭证待关联,或创建待关联记录
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 91 订单仍是 `pending_config`,则需要在后续补全商品履约配置后,同时带上已收到的电子凭证信息。
|
||||||
|
|
||||||
|
最终目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
91 订单 task
|
||||||
|
-> context.kuaishouIndustryVoucher.eticketId = 2038
|
||||||
|
-> context.kuaishouIndustryVoucher.oid = 2618800083429561
|
||||||
|
-> context.kuaishouIndustryVoucher.status = UNUSED
|
||||||
|
-> 领取页打开时跳过手动核销码输入
|
||||||
|
-> 发货完成后触发电子凭证核销
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 电子凭证券号生成与核销幂等
|
||||||
|
|
||||||
|
接入电子凭证的核心目的,是减少用户在领取页手动输入快手核销码的步骤。因此,系统返回给快手的电子凭证 `eticket.id` 需要成为后续查询、销毁、核销都能稳定使用的业务券号。
|
||||||
|
|
||||||
|
### 11.1 当前逻辑
|
||||||
|
|
||||||
|
当前实现中,`send-code` 返回的 `eticket.id` 来自本地 task id:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eticket.id = fulfillment_tasks.id
|
||||||
|
```
|
||||||
|
|
||||||
|
真实测试单中:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eticket.id = 2038
|
||||||
|
```
|
||||||
|
|
||||||
|
这说明当前“核销码/券号”不是快手生成的,而是我方创建 task 后,把 task id 当作电子凭证 id 返回给快手。
|
||||||
|
|
||||||
|
这个方式有两个问题:
|
||||||
|
|
||||||
|
1. task 属于当前实现里的 `kuaishou-industry` 孤立订单链,尚未绑定到 91 订单 task。
|
||||||
|
2. task id 虽然全局唯一,但它和任务生命周期耦合太强;后续如果任务重建、补配置、切换发货平台,会影响电子凭证反查和幂等。
|
||||||
|
|
||||||
|
### 11.2 推荐目标
|
||||||
|
|
||||||
|
电子凭证券号应该满足:
|
||||||
|
|
||||||
|
| 要求 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 全局唯一 | 不同订单、不同数量拆分出的券不能重复。 |
|
||||||
|
| 幂等稳定 | 同一个 `oid` 重复 `send-code`,必须返回同一批 `eticket.id`。 |
|
||||||
|
| 可反查 | `query-code / destroy-code / consume-code` 带回 `eticket.id` 时,能找到原始订单、task 和发货状态。 |
|
||||||
|
| 可延迟绑定 | 如果快手 `send-code` 早于 91 task 准备完成,券号也能先落库,后续再绑定 task。 |
|
||||||
|
| 可核销 | 发货完成后,能用同一张券更新为 `CONSUMED`,并记录核销流水。 |
|
||||||
|
|
||||||
|
### 11.3 推荐数据模型
|
||||||
|
|
||||||
|
建议新增独立的电子凭证券表,而不是继续只依赖 task id:
|
||||||
|
|
||||||
|
```text
|
||||||
|
kuaishou_industry_vouchers
|
||||||
|
id BIGSERIAL PRIMARY KEY
|
||||||
|
voucher_code TEXT UNIQUE NOT NULL
|
||||||
|
oid TEXT NOT NULL
|
||||||
|
order_id BIGINT NULL
|
||||||
|
task_id BIGINT NULL
|
||||||
|
unit_index INTEGER NOT NULL
|
||||||
|
token TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL -- UNUSED / CONSUMED / DESTROYED
|
||||||
|
valid_start_time BIGINT NOT NULL
|
||||||
|
valid_end_time BIGINT NOT NULL
|
||||||
|
consume_serial_num TEXT NOT NULL DEFAULT ''
|
||||||
|
consumed_at TIMESTAMPTZ NULL
|
||||||
|
destroyed_at TIMESTAMPTZ NULL
|
||||||
|
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
|
||||||
|
唯一约束:
|
||||||
|
UNIQUE(oid, unit_index)
|
||||||
|
UNIQUE(voucher_code)
|
||||||
|
```
|
||||||
|
|
||||||
|
券号生成建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
voucher_code = String(kuaishou_industry_vouchers.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
理由:
|
||||||
|
|
||||||
|
- 数字字符串兼容性最好;
|
||||||
|
- 数据库主键保证不重复;
|
||||||
|
- 重复 `send-code` 时通过 `UNIQUE(oid, unit_index)` 找回原记录,返回原 `voucher_code`;
|
||||||
|
- 不依赖 task 是否已经创建。
|
||||||
|
|
||||||
|
如果后续确认快手完全支持任意字符串,也可以升级为带前缀的业务券号,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
voucher_code = KSEV-{oid}-{unitIndex}
|
||||||
|
```
|
||||||
|
|
||||||
|
但第一阶段建议优先使用数字字符串,降低快手侧兼容风险。
|
||||||
|
|
||||||
|
### 11.4 send-code 幂等规则
|
||||||
|
|
||||||
|
`send-code` 处理流程应改为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
收到 oid + num
|
||||||
|
-> 按 oid 查 91 订单
|
||||||
|
-> 为 unitIndex = 1..num 创建或获取 voucher
|
||||||
|
-> 已存在:复用原 voucher_code
|
||||||
|
-> 不存在:新建 voucher
|
||||||
|
-> 如果 91 task 已存在:绑定 voucher.task_id
|
||||||
|
-> 如果 91 task 未存在:voucher 保持待绑定
|
||||||
|
-> 返回 etickets[{ id: voucher_code, status: voucher.status }]
|
||||||
|
```
|
||||||
|
|
||||||
|
重复通知时,必须返回相同结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
第一次 send-code:
|
||||||
|
oid = 2618800083429561
|
||||||
|
num = 1
|
||||||
|
eticket.id = 2038
|
||||||
|
|
||||||
|
第二次 send-code:
|
||||||
|
oid = 2618800083429561
|
||||||
|
num = 1
|
||||||
|
eticket.id 仍然 = 2038
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.5 有效期生成规则
|
||||||
|
|
||||||
|
真实测试单里快手没有传 `certActualStartTime / certActualEndTime`,并且 `certStartTime / certEndTime` 也可能都是 0。当前系统返回了:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validStartTime = 0
|
||||||
|
validEndTime = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
例如 2026-07-07 11:16:58 的真实请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
certExpireType = 3
|
||||||
|
certExpDays = 3
|
||||||
|
certStartTime = 0
|
||||||
|
certEndTime = 0
|
||||||
|
certActualStartTime = 0
|
||||||
|
certActualEndTime = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
这种场景应理解为“购买成功后固定有效天数”,用 `certExpDays` 生成有效期。建议按以下优先级生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validStartTime:
|
||||||
|
1. certActualStartTime > 0
|
||||||
|
2. certStartTime > 0
|
||||||
|
3. 当前时间毫秒
|
||||||
|
|
||||||
|
validEndTime:
|
||||||
|
1. certActualEndTime > 0
|
||||||
|
2. certEndTime > 0
|
||||||
|
3. certStartTime + certExpDays * 86400000
|
||||||
|
4. 当前时间 + certExpDays * 86400000
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `certExpDays` 也为空,则使用保守默认值,例如 3 天。
|
||||||
|
|
||||||
|
对于上面的真实请求,返回给快手的电子凭证应类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validStartTime = send-code 处理时的当前毫秒时间戳
|
||||||
|
validEndTime = validStartTime + 3 * 86400000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.6 后续核销规则
|
||||||
|
|
||||||
|
发货完成后,不再要求用户输入快手核销码,而是系统根据 voucher 主动完成核销闭环:
|
||||||
|
|
||||||
|
```text
|
||||||
|
发货平台履约成功
|
||||||
|
-> 找到 task 绑定的 voucher
|
||||||
|
-> 检查 voucher.status = UNUSED
|
||||||
|
-> 生成稳定 consume_serial_num
|
||||||
|
-> 调用快手电子凭证核销回调
|
||||||
|
-> 成功后更新 voucher.status = CONSUMED
|
||||||
|
-> 更新 task 为 completed / redeemed
|
||||||
|
```
|
||||||
|
|
||||||
|
核销流水号也必须幂等:
|
||||||
|
|
||||||
|
```text
|
||||||
|
consume_serial_num = CONSUME-{voucher_code}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样即使核销回调重试,也不会生成多条不同流水。
|
||||||
|
|
||||||
|
销毁规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
destroy-code
|
||||||
|
-> 按 oid + eticket.id 找 voucher
|
||||||
|
-> 未找到:按快手要求仍可返回成功
|
||||||
|
-> 已 CONSUMED:不应直接改为 DESTROYED,进入人工确认
|
||||||
|
-> UNUSED:更新为 DESTROYED
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user