新增 affiliate_dash 回调路由与幂等去重(阶段 5)

- POST /api/v1/open/affiliate-dash:验签 → 查任务(404 重试) → webhook_events 幂等闸门 → 状态同步
- migration 013:webhook_events(provider+event_id 唯一)
- task-repo findAffiliateDashTaskByOrder(仿 feifei 同构 SQL)
- notify-service:去重闸门在找到任务之后,任务未匹配不被 eventId 永久拦截
- typecheck + 212 测试通过;前置错误分支脚本验证通过
This commit is contained in:
yml2213
2026-08-05 16:07:33 +08:00
parent 00d490a2cd
commit 7fc3d60588
7 changed files with 294 additions and 0 deletions
@@ -0,0 +1,124 @@
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { findAffiliateDashTaskByOrder } from '../../../repositories/task-repo.js'
import { insertWebhookEventOnce } from '../../../repositories/webhook-event-repo.js'
import type { JsonObject } from '../../../types/json.js'
import { syncAffiliateDashTaskStatus } from '../../fulfillment/affiliate-dash/index.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { verifyAffiliateDashCallback } from './verify-callback.js'
type NotifyHeaders = Record<string, string | string[] | undefined>
/**
* affiliate-dash 回调入口(验签 + 幂等去重 + 状态同步)。
*
* 处理顺序:
* 1. 验签(X-Timestamp 容差 → 重算签名)并解析事件;
* 2. 按 order_no / client_order_no 查本地任务(未匹配 → 404,触发对方重试);
* 3. 幂等闸门:webhook_events(provider + event_id 唯一),重复回调直接 200 跳过;
* 4. 记录任务事件 + 状态合并式同步(可重入)。
*/
export async function handleAffiliateDashNotify(input: {
body: unknown
headers: NotifyHeaders
rawBody?: string | undefined
}) {
const verified = verifyAffiliateDashCallback({
headers: input.headers,
rawBody: input.rawBody,
})
const data = isPlainObject(verified.data) ? verified.data : {}
const orderNo = String(data.order_no || '').trim()
const clientOrderNo = String(data.client_order_no || '').trim()
const orderStatus = String(data.order_status || '').trim()
if (!orderNo && !clientOrderNo) {
throw createHttpError('affiliate-dash 回调订单号缺失', {
statusCode: 400,
errorCode: 'affiliate_dash_callback_order_no_missing',
})
}
const task = await findAffiliateDashTaskByOrder({
orderNo,
clientOrderNo,
})
if (!task) {
throw createHttpError('affiliate-dash 回调未匹配到本地任务', {
statusCode: 404,
errorCode: 'affiliate_dash_callback_task_not_found',
context: {
orderNo,
clientOrderNo,
},
})
}
const rawBody = String(input.rawBody || '')
let payload: JsonObject = {}
try {
const parsed = JSON.parse(rawBody)
if (isPlainObject(parsed)) {
payload = parsed
}
} catch {
payload = { order_no: orderNo, client_order_no: clientOrderNo, order_status: orderStatus }
}
const dedupe = await insertWebhookEventOnce({
provider: 'affiliate_dash',
eventId: verified.eventId,
eventType: verified.event,
taskId: task.id,
payload,
})
if (!dedupe.inserted) {
return {
ok: true,
duplicate: true,
eventId: verified.eventId,
event: verified.event,
taskId: task.id,
orderNo,
orderStatus,
}
}
const now = nowIso()
await createTaskEvent(
task.id,
'affiliate_dash_notify_received',
{
event: verified.event,
eventId: verified.eventId,
occurredAt: verified.occurredAt,
orderNo,
clientOrderNo,
orderStatus,
raw: data,
},
now,
)
const updatedTask = await syncAffiliateDashTaskStatus(task)
return {
ok: true,
duplicate: false,
eventId: verified.eventId,
event: verified.event,
taskId: task.id,
orderNo,
clientOrderNo,
orderStatus,
taskStatus: updatedTask.task_status,
deliveryStatus: updatedTask.delivery_status,
}
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}