- 前后端接入 ESLint 10 扁平配置(typescript-eslint 类型感知检查 + react-hooks/react-refresh),新增 lint / lint:check / lint:fix 脚本并纳入 check 链路 - 清理全部 no-unused-vars 与 no-useless-assignment 警告,修复 floating promises、正则转义等问题 - 删除确认无引用的死代码(markKuaishouCloudBindUrlRefreshFailed、未使用 helper 等)
478 lines
16 KiB
TypeScript
478 lines
16 KiB
TypeScript
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
||
import { createHttpError } from '../../../utils/http.js'
|
||
import { logIntegration } from '../../../utils/logger.js'
|
||
import { parseTaskContext } from '../../../utils/task-json.js'
|
||
import { nowIso } from '../../../utils/time.js'
|
||
import { TASK_STATUS } from '../../../domain/task-status.js'
|
||
import { getClaimIdentityFromContext } from '../../claim/claim-identity.js'
|
||
import type { JsonObject } from '../../../types/json.js'
|
||
import {
|
||
createAffiliateDashOrder,
|
||
getAffiliateDashOrder,
|
||
getAffiliateDashWallet,
|
||
type AffiliateDashOrder,
|
||
} from '../../platforms/affiliate-dash/order-service.js'
|
||
import { getAffiliateDashConfig } from '../../platforms/affiliate-dash/config.js'
|
||
import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js'
|
||
import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js'
|
||
import type { TaskRow } from '../../../types/repository/rows.js'
|
||
|
||
export function isAffiliateDashTask(task: Partial<TaskRow> | null | undefined) {
|
||
return String(task?.executor_key || '').trim() === 'affiliate_dash'
|
||
}
|
||
|
||
/**
|
||
* affiliate_dash 履约任务:下单建单(preparePaidTask)→ 用户在统一领取页完成
|
||
* delivery/bind/submit(阶段 4)→ 回调 order.shipping.updated 驱动状态(阶段 5)。
|
||
*/
|
||
export async function prepareAffiliateDashTask(task: TaskRow) {
|
||
if (!isAffiliateDashTask(task)) {
|
||
throw createHttpError('当前任务不是 affiliate-dash 履约任务', {
|
||
statusCode: 409,
|
||
errorCode: 'affiliate_dash_task_invalid',
|
||
})
|
||
}
|
||
|
||
const now = nowIso()
|
||
const taskContext = parseTaskContext(task)
|
||
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
|
||
|
||
// 幂等:已建单直接收敛到 link_generated(不重复扣款)
|
||
if (flow.orderNo) {
|
||
return updateTask(task.id, {
|
||
task_status: TASK_STATUS.LINK_GENERATED,
|
||
user_action_status: 'pending_claim',
|
||
context_json: JSON.stringify({
|
||
...taskContext,
|
||
affiliateDash: flow,
|
||
}),
|
||
updated_at: now,
|
||
})
|
||
}
|
||
|
||
if (!flow.sku) {
|
||
throw createHttpError(
|
||
'affiliate-dash 商品 sku 缺失,请配置 91 商品 → affiliate_dash sku 映射',
|
||
{
|
||
statusCode: 409,
|
||
errorCode: 'affiliate_dash_sku_missing',
|
||
},
|
||
)
|
||
}
|
||
|
||
const siblingTasks = await listTasksByOrderId(task.order_id)
|
||
const clientOrderNo = buildAffiliateDashClientOrderNo(task, siblingTasks)
|
||
const data = buildAffiliateDashOrderData(task, taskContext)
|
||
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
|
||
|
||
// 余额预检:钱包余额不足提前拦截(避免建单 400 后才降级);预检失败不阻塞下单
|
||
await preflightAffiliateDashWallet(flow.sku)
|
||
|
||
const order = await createAffiliateDashOrder({
|
||
clientOrderNo,
|
||
sku: flow.sku,
|
||
buyerReference: buyerReference || undefined,
|
||
data: Object.keys(data).length ? data : undefined,
|
||
})
|
||
|
||
const nextFlow = mergeAffiliateDashOrder(flow, order, {
|
||
clientOrderNo,
|
||
syncedAt: now,
|
||
})
|
||
|
||
// 核销时机优化:建单成功(91 卡信息到手、领取链接可发放)即核销行业电子凭证,
|
||
// 不再等 delivered 回调(参考 kuaishou-feifei「给用户链接即核销」)。
|
||
// 核销失败不阻塞下单与链接发放:consumeStatus=failed,delivered 回调兜底补核销。
|
||
const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, {
|
||
source: 'affiliate_dash_order_created',
|
||
consumeTime: Date.now(),
|
||
})
|
||
const consumedVoucher = consumeResult.consumed[0] || null
|
||
let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
|
||
if (consumeResult.vouchers.length > 0) {
|
||
nextFlow.consumeStatus = consumeResult.ok ? 'success' : 'failed'
|
||
if (consumedVoucher) {
|
||
nextIndustryVoucher = buildKuaishouIndustryVoucherContext(
|
||
consumedVoucher,
|
||
taskContext.kuaishouIndustryVoucher,
|
||
now,
|
||
)
|
||
}
|
||
if (!consumeResult.ok) {
|
||
logIntegration(
|
||
'[affiliate-dash]',
|
||
'建单成功但电子凭证核销失败,delivered 回调将兜底重试',
|
||
{
|
||
taskId: task.id,
|
||
orderNo: order.orderNo,
|
||
voucherCount: consumeResult.vouchers.length,
|
||
failedCount: consumeResult.failed.length,
|
||
errorMessage: consumeResult.failed[0]?.errorMessage || '',
|
||
},
|
||
{ level: 'warn' },
|
||
)
|
||
}
|
||
} else {
|
||
nextFlow.consumeStatus = 'not_required'
|
||
}
|
||
|
||
const updatedTask = await updateTask(task.id, {
|
||
task_status: TASK_STATUS.LINK_GENERATED,
|
||
user_action_status: 'pending_claim',
|
||
result_code: 'affiliate_dash_order_created',
|
||
result_message: 'affiliate-dash 订单已创建',
|
||
context_json: JSON.stringify({
|
||
...taskContext,
|
||
affiliateDash: nextFlow,
|
||
kuaishouIndustryVoucher: nextIndustryVoucher,
|
||
}),
|
||
updated_at: now,
|
||
})
|
||
|
||
await createTaskEvent(
|
||
task.id,
|
||
'affiliate_dash_order_created',
|
||
{
|
||
orderNo: order.orderNo,
|
||
clientOrderNo,
|
||
sku: flow.sku,
|
||
amount: order.amount,
|
||
currency: order.currency,
|
||
orderStatus: order.orderStatus,
|
||
canShip: order.canShip,
|
||
},
|
||
now,
|
||
)
|
||
|
||
logIntegration('[affiliate-dash]', 'affiliate-dash 订单已创建', {
|
||
taskId: task.id,
|
||
orderNo: order.orderNo,
|
||
clientOrderNo,
|
||
sku: flow.sku,
|
||
amount: order.amount,
|
||
currency: order.currency,
|
||
})
|
||
|
||
return updatedTask
|
||
}
|
||
|
||
/**
|
||
* 状态合并(回调 / 主动对账共用,可重入):
|
||
* paid → link_generated;delivering → redeeming;
|
||
* delivered → 核销行业电子凭证 → redeemed / manual_review;
|
||
* ship_failed → retry_pending;cancelled → closed。
|
||
*/
|
||
export async function syncAffiliateDashTaskStatus(
|
||
task: TaskRow,
|
||
options: { snapshotOnly?: boolean } = {},
|
||
) {
|
||
if (!isAffiliateDashTask(task)) {
|
||
return task
|
||
}
|
||
|
||
const taskContext = parseTaskContext(task)
|
||
const flow = normalizeAffiliateDashFlow(taskContext.affiliateDash)
|
||
if (!flow.orderNo) {
|
||
return task
|
||
}
|
||
|
||
const now = nowIso()
|
||
// dev-mock 任务或 snapshotOnly(如 submit 已拿到 delivered)不请求 affiliate-dash 平台,
|
||
// 直接用本地快照(flow 自身)收敛状态。
|
||
const useSnapshot = Boolean(flow.mock?.enabled) || options.snapshotOnly
|
||
const order = useSnapshot
|
||
? ({
|
||
orderNo: flow.orderNo,
|
||
orderStatus: flow.orderStatus,
|
||
failureReason: flow.failureReason,
|
||
canShip: flow.canShip,
|
||
raw: null,
|
||
} as unknown as AffiliateDashOrder)
|
||
: await getAffiliateDashOrder(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 = mergeAffiliateDashOrder(flow, order, { syncedAt: now })
|
||
const nextIndustryVoucher = taskContext.kuaishouIndustryVoucher
|
||
|
||
switch (order.orderStatus) {
|
||
case 'paid':
|
||
break
|
||
case 'delivering':
|
||
nextTaskStatus = TASK_STATUS.REDEEMING
|
||
deliveryStatus = 'delivering'
|
||
resultCode = 'affiliate_dash_delivering'
|
||
resultMessage = order.failureReason || 'affiliate-dash 发货处理中'
|
||
lastError = ''
|
||
break
|
||
case 'delivered': {
|
||
const alreadyConsumed =
|
||
nextFlow.consumeStatus === 'success' || nextFlow.consumeStatus === 'not_required'
|
||
const consumeResult = alreadyConsumed
|
||
? { ok: true, vouchers: [], consumed: [], failed: [] }
|
||
: await consumeKuaishouIndustryVouchersForTask(task, {
|
||
source: 'affiliate_dash_delivered',
|
||
consumeTime: Date.now(),
|
||
})
|
||
if (consumeResult.ok || consumeResult.vouchers.length === 0) {
|
||
nextTaskStatus = TASK_STATUS.REDEEMED
|
||
deliveryStatus = 'delivered'
|
||
redeemedAt = redeemedAt || now
|
||
resultCode = 'affiliate_dash_delivered'
|
||
resultMessage = 'affiliate-dash 履约成功'
|
||
lastError = ''
|
||
nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required'
|
||
logIntegration('[affiliate-dash]', 'affiliate-dash 履约完成,行业电子凭证核销完成', {
|
||
taskId: task.id,
|
||
orderNo: flow.orderNo,
|
||
voucherCount: consumeResult.vouchers.length,
|
||
consumedCount: consumeResult.consumed.length,
|
||
consumeStatus: nextFlow.consumeStatus,
|
||
})
|
||
} else {
|
||
nextTaskStatus = TASK_STATUS.MANUAL_REVIEW
|
||
resultCode = 'affiliate_dash_industry_consume_failed'
|
||
resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理'
|
||
lastError = resultMessage
|
||
nextFlow.consumeStatus = 'failed'
|
||
logIntegration(
|
||
'[affiliate-dash]',
|
||
'affiliate-dash 履约完成但核销失败',
|
||
{
|
||
taskId: task.id,
|
||
orderNo: flow.orderNo,
|
||
errorMessage: resultMessage,
|
||
},
|
||
{ level: 'warn' },
|
||
)
|
||
}
|
||
break
|
||
}
|
||
case 'ship_failed':
|
||
nextTaskStatus = TASK_STATUS.RETRY_PENDING
|
||
resultCode = 'affiliate_dash_ship_failed'
|
||
resultMessage = order.failureReason || 'affiliate-dash 发货失败,可重试'
|
||
lastError = resultMessage
|
||
break
|
||
case 'cancelled':
|
||
nextTaskStatus = TASK_STATUS.CLOSED
|
||
resultCode = 'affiliate_dash_cancelled'
|
||
resultMessage = order.failureReason || 'affiliate-dash 订单已取消'
|
||
lastError = ''
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
|
||
const isAlreadyTerminal = (
|
||
[
|
||
TASK_STATUS.REDEEMED,
|
||
TASK_STATUS.MANUAL_REVIEW,
|
||
TASK_STATUS.CLOSED,
|
||
TASK_STATUS.FAILED,
|
||
] as string[]
|
||
).includes(task.task_status)
|
||
// 终态保护:已收敛成功的任务,非 delivered 状态不允许降级(如轮询时平台详情短暂返回
|
||
// delivering/paid 会把 REDEEMED 打回 REDEEMING,导致结果页闪烁/倒退)。
|
||
const shouldApply = !isAlreadyTerminal || order.orderStatus === 'delivered'
|
||
if (!shouldApply) {
|
||
return task
|
||
}
|
||
|
||
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,
|
||
affiliateDash: nextFlow,
|
||
kuaishouIndustryVoucher: nextIndustryVoucher,
|
||
}),
|
||
updated_at: now,
|
||
})
|
||
|
||
return updatedTask || task
|
||
}
|
||
|
||
export type AffiliateDashFlow = {
|
||
flowType: 'affiliate_dash'
|
||
sku: string
|
||
productName: string
|
||
orderNo: string
|
||
clientOrderNo: string
|
||
orderStatus: string
|
||
canShip: boolean
|
||
cannotShipReason: string
|
||
providerOrderNo: string
|
||
failureReason: string
|
||
amount: number
|
||
currency: string
|
||
/** 阶段 4 交付交互数据(bind/submit) */
|
||
bindUuid: string
|
||
bindUrl: string
|
||
qrUrl: string
|
||
gameAccount: string
|
||
expectedGameAccount: string
|
||
bindMismatch: boolean
|
||
/** 是否已在 affiliate-dash 侧完成绑定(bind-result.bound) */
|
||
bound: boolean
|
||
boundAccount: string
|
||
gameChannel: string
|
||
submitStatus: string
|
||
consumeStatus: string
|
||
lastSyncedAt: unknown
|
||
raw: unknown
|
||
/** dev-mock 标记:enabled 时所有 affiliate-dash 平台调用短路(建单/绑定/提交/同步均不请求外部) */
|
||
mock: { enabled: boolean; orderNo: string; createdAt: string } | null
|
||
}
|
||
|
||
export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow {
|
||
const source =
|
||
value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
|
||
|
||
return {
|
||
flowType: 'affiliate_dash',
|
||
sku: String(source.sku || '').trim(),
|
||
productName: String(source.productName || '').trim(),
|
||
orderNo: String(source.orderNo || '').trim(),
|
||
clientOrderNo: String(source.clientOrderNo || '').trim(),
|
||
orderStatus: String(source.orderStatus || '').trim(),
|
||
canShip: Boolean(source.canShip),
|
||
cannotShipReason: String(source.cannotShipReason || '').trim(),
|
||
providerOrderNo: String(source.providerOrderNo || '').trim(),
|
||
failureReason: String(source.failureReason || '').trim(),
|
||
amount: Number(source.amount || 0) || 0,
|
||
currency: String(source.currency || '').trim(),
|
||
bindUuid: String(source.bindUuid || '').trim(),
|
||
bindUrl: String(source.bindUrl || '').trim(),
|
||
qrUrl: String(source.qrUrl || '').trim(),
|
||
gameAccount: String(source.gameAccount || '').trim(),
|
||
expectedGameAccount: String(source.expectedGameAccount || '').trim(),
|
||
bindMismatch: Boolean(source.bindMismatch),
|
||
bound: Boolean(source.bound),
|
||
boundAccount: String(source.boundAccount || '').trim(),
|
||
gameChannel: String(source.gameChannel || '').trim(),
|
||
submitStatus: String(source.submitStatus || '').trim(),
|
||
consumeStatus: String(source.consumeStatus || 'pending').trim(),
|
||
lastSyncedAt: source.lastSyncedAt || null,
|
||
raw: source.raw && typeof source.raw === 'object' ? source.raw : null,
|
||
mock:
|
||
source.mock && typeof source.mock === 'object'
|
||
? {
|
||
enabled: Boolean((source.mock as JsonObject).enabled),
|
||
orderNo: String((source.mock as JsonObject).orderNo || '').trim(),
|
||
createdAt: String((source.mock as JsonObject).createdAt || '').trim(),
|
||
}
|
||
: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* client_order_no 优先使用平台订单号,方便商户侧直接按订单对账。
|
||
* affiliate-dash 要求该字段唯一;同一订单拆成多笔 affiliate-dash 任务时追加稳定序号。
|
||
*/
|
||
export function buildAffiliateDashClientOrderNo(task: TaskRow, siblingTasks: TaskRow[] = []) {
|
||
const platformOrderId = String(task.platform_order_id || '').trim()
|
||
if (!platformOrderId) {
|
||
return String(task.task_no || `OS-AD-${task.id}`).trim()
|
||
}
|
||
|
||
const affiliateDashTasks = siblingTasks
|
||
.filter((candidate) => isAffiliateDashTask(candidate))
|
||
.sort((left, right) => Number(left.id) - Number(right.id))
|
||
|
||
if (affiliateDashTasks.length <= 1) {
|
||
return platformOrderId
|
||
}
|
||
|
||
const position = affiliateDashTasks.findIndex((candidate) => candidate.id === task.id) + 1
|
||
return `${platformOrderId}-${position > 0 ? position : task.id}`
|
||
}
|
||
|
||
async function preflightAffiliateDashWallet(sku: string) {
|
||
try {
|
||
const wallet = await getAffiliateDashWallet()
|
||
if (wallet.availableBalance <= 0) {
|
||
throw createHttpError('affiliate-dash 钱包余额不足,请先充值', {
|
||
statusCode: 409,
|
||
errorCode: 'affiliate_dash_wallet_not_enough',
|
||
context: {
|
||
sku,
|
||
availableBalance: wallet.availableBalance,
|
||
currency: wallet.currency,
|
||
},
|
||
})
|
||
}
|
||
} catch (error) {
|
||
if ((error as { errorCode?: string })?.errorCode === 'affiliate_dash_wallet_not_enough') {
|
||
throw error
|
||
}
|
||
logIntegration(
|
||
'[affiliate-dash]',
|
||
'余额预检失败(忽略,继续下单)',
|
||
{
|
||
sku,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
},
|
||
{ level: 'warn' },
|
||
)
|
||
}
|
||
}
|
||
|
||
function buildAffiliateDashOrderData(task: TaskRow, context: Record<string, unknown>): JsonObject {
|
||
const identity = getClaimIdentityFromContext(context)
|
||
const data: JsonObject = {}
|
||
|
||
const platformOrderId = String(task.platform_order_id || '').trim()
|
||
if (platformOrderId) {
|
||
data['91单号'] = platformOrderId
|
||
}
|
||
if (identity.expectedUid) {
|
||
data.game_account = identity.expectedUid
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
function mergeAffiliateDashOrder(
|
||
flow: AffiliateDashFlow,
|
||
order: AffiliateDashOrder,
|
||
patch: {
|
||
clientOrderNo?: string
|
||
syncedAt: string
|
||
},
|
||
): AffiliateDashFlow {
|
||
return {
|
||
...flow,
|
||
sku: order.sku || flow.sku,
|
||
productName: order.productName || flow.productName,
|
||
orderNo: order.orderNo || flow.orderNo,
|
||
clientOrderNo: patch.clientOrderNo || flow.clientOrderNo,
|
||
orderStatus: order.orderStatus || flow.orderStatus,
|
||
canShip: order.canShip,
|
||
cannotShipReason: order.cannotShipReason || flow.cannotShipReason,
|
||
providerOrderNo: order.providerOrderNo || flow.providerOrderNo,
|
||
failureReason: order.failureReason || flow.failureReason,
|
||
amount: order.amount || flow.amount,
|
||
currency: order.currency || flow.currency,
|
||
lastSyncedAt: patch.syncedAt,
|
||
raw: order.raw || flow.raw,
|
||
}
|
||
}
|
||
|
||
/** 供阶段 4 领取页使用:读取 flow(context_json #>> '{affiliateDash,...}')。 */
|
||
export function getAffiliateDashConfigSnapshot() {
|
||
const config = getAffiliateDashConfig()
|
||
return {
|
||
enabled: config.enabled,
|
||
baseUrl: config.baseUrl,
|
||
}
|
||
}
|