Files
order_site/apps/backend/src/services/fulfillment/affiliate-dash/index.ts
T
yml2213 81ae04dae0 接入 ESLint 并清理未使用代码
- 前后端接入 ESLint 10 扁平配置(typescript-eslint 类型感知检查 + react-hooks/react-refresh),新增 lint / lint:check / lint:fix 脚本并纳入 check 链路
- 清理全部 no-unused-vars 与 no-useless-assignment 警告,修复 floating promises、正则转义等问题
- 删除确认无引用的死代码(markKuaishouCloudBindUrlRefreshFailed、未使用 helper 等)
2026-08-16 18:04:55 +08:00

478 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}