新增 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:
@@ -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]'
|
||||
}
|
||||
Reference in New Issue
Block a user