优化 affiliate-dash 商户单号
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { buildAffiliateDashClientOrderNo } from './index.js'
|
||||||
|
import type { TaskRow } from '../../../types/repository/rows.js'
|
||||||
|
|
||||||
|
function buildTask(overrides: Partial<TaskRow> = {}): TaskRow {
|
||||||
|
return {
|
||||||
|
id: 101,
|
||||||
|
order_id: 10,
|
||||||
|
order_item_id: 20,
|
||||||
|
unit_index: 1,
|
||||||
|
platform_order_id: '2622300001260431',
|
||||||
|
profile_id: 1,
|
||||||
|
task_no: 'DTeb37abafd722',
|
||||||
|
executor_key: 'affiliate_dash',
|
||||||
|
task_status: 'pending_payment',
|
||||||
|
delivery_status: 'pending',
|
||||||
|
result_code: '',
|
||||||
|
result_message: '',
|
||||||
|
automation_mode: 'automatic',
|
||||||
|
requires_claim: true,
|
||||||
|
user_action_status: 'pending_claim',
|
||||||
|
attempt_count: 0,
|
||||||
|
runtime_session_id: '',
|
||||||
|
login_type: '',
|
||||||
|
nickname: '',
|
||||||
|
role_name: '',
|
||||||
|
role_id: '',
|
||||||
|
area: '',
|
||||||
|
partition_name: '',
|
||||||
|
claim_token: '',
|
||||||
|
primary_claim_token: '',
|
||||||
|
primary_claim_token_id: null,
|
||||||
|
primary_claim_token_status: '',
|
||||||
|
artifacts_json: '{}',
|
||||||
|
context_json: '{}',
|
||||||
|
screenshot_path: '',
|
||||||
|
last_error: '',
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: '2026-08-11T00:00:00.000Z',
|
||||||
|
updated_at: '2026-08-11T00:00:00.000Z',
|
||||||
|
claimed_at: null,
|
||||||
|
role_confirmed_at: null,
|
||||||
|
redeemed_at: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildAffiliateDashClientOrderNo uses the platform order number for a single task', () => {
|
||||||
|
const task = buildTask()
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
buildAffiliateDashClientOrderNo(task, [task]),
|
||||||
|
'2622300001260431',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAffiliateDashClientOrderNo appends a stable position for split affiliate-dash tasks', () => {
|
||||||
|
const firstTask = buildTask({ id: 101, unit_index: 1 })
|
||||||
|
const secondTask = buildTask({ id: 102, order_item_id: 21, unit_index: 1 })
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
buildAffiliateDashClientOrderNo(firstTask, [secondTask, firstTask]),
|
||||||
|
'2622300001260431-1',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
buildAffiliateDashClientOrderNo(secondTask, [secondTask, firstTask]),
|
||||||
|
'2622300001260431-2',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAffiliateDashClientOrderNo falls back to the task number without a platform order number', () => {
|
||||||
|
const task = buildTask({ platform_order_id: '' })
|
||||||
|
|
||||||
|
assert.equal(buildAffiliateDashClientOrderNo(task, [task]), 'DTeb37abafd722')
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
|
||||||
import { updateTask } from '../../../repositories/task-repo.js'
|
import { listTasksByOrderId, updateTask } from '../../../repositories/task-repo.js'
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { createHttpError } from '../../../utils/http.js'
|
||||||
import { logIntegration } from '../../../utils/logger.js'
|
import { logIntegration } from '../../../utils/logger.js'
|
||||||
import { parseTaskContext } from '../../../utils/task-json.js'
|
import { parseTaskContext } from '../../../utils/task-json.js'
|
||||||
@@ -58,7 +58,8 @@ export async function prepareAffiliateDashTask(task: TaskRow) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientOrderNo = buildAffiliateDashClientOrderNo(task)
|
const siblingTasks = await listTasksByOrderId(task.order_id)
|
||||||
|
const clientOrderNo = buildAffiliateDashClientOrderNo(task, siblingTasks)
|
||||||
const data = buildAffiliateDashOrderData(task, taskContext)
|
const data = buildAffiliateDashOrderData(task, taskContext)
|
||||||
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
|
const buyerReference = String(task.platform_order_id || task.task_no || '').trim()
|
||||||
|
|
||||||
@@ -356,9 +357,26 @@ export function normalizeAffiliateDashFlow(value: unknown): AffiliateDashFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** client_order_no = task_no(每 task 唯一,幂等键;勿用 91 单号——拆单会冲突)。 */
|
/**
|
||||||
export function buildAffiliateDashClientOrderNo(task: TaskRow) {
|
* 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()
|
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) {
|
async function preflightAffiliateDashWallet(sku: string) {
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ order_site 处理要求:
|
|||||||
|
|
||||||
## 10. 幂等与一致性设计
|
## 10. 幂等与一致性设计
|
||||||
|
|
||||||
- **下单幂等**:`client_order_no` = order_site 的 `task_no`(格式 `DT` + 12 hex,唯一且稳定,见 §14.2),重复请求安全;
|
- **下单幂等**:单笔 affiliate-dash 任务的 `client_order_no` 使用平台订单号,方便商户侧对账;同一订单拆成多笔 affiliate-dash 任务时使用 `平台订单号-序号`,保持唯一且稳定,重复请求安全;
|
||||||
- **回调幂等**:`X-Event-ID` 去重;
|
- **回调幂等**:`X-Event-ID` 去重;
|
||||||
- **提交发货幂等**:重复 `delivery/submit` 返回既有结果;
|
- **提交发货幂等**:重复 `delivery/submit` 返回既有结果;
|
||||||
- **对账兜底**:order_site 定时用 `GET /orders/{order_no}` 对账(可选,回调为主);
|
- **对账兜底**:order_site 定时用 `GET /orders/{order_no}` 对账(可选,回调为主);
|
||||||
@@ -371,7 +371,7 @@ order_site 处理要求:
|
|||||||
|
|
||||||
### 14.2 order_site 落地要点
|
### 14.2 order_site 落地要点
|
||||||
|
|
||||||
- **client_order_no 用 `task_no`**(`utils/random.ts` `randomId('DT', 6)` → `DT` + 12 hex,≤64 字符满足 affiliate_dash 限制)。order_site 自身无 client_order_no 字段,本地下单幂等由 `orders UNIQUE(provider, platform, shop_id, platform_order_id)` + `listTasks(order.id)` 已建不重建承担;affiliate_dash 侧幂等由 `client_order_no=task_no` 承担,preparePaidTask 失败重试可安全重放同一 task_no。
|
- **client_order_no 优先用平台订单号**:单笔任务直接使用订单号;拆单使用 `订单号-序号`,均在 affiliate-dash 的 64 字符限制内。订单和任务集合创建后保持稳定,因此 `preparePaidTask` 失败重试会重放同一个幂等单号。
|
||||||
- **preparePaidTask 同步拿到 order_no**(201 响应即含),写入 `context_json.affiliateDash.orderNo`;无需等回调。失败必须降级 `MANUAL_REVIEW` + `deps.notifyTaskAutoManualReview`(范式:kuaishou-feifei-executor.ts:36-51)。
|
- **preparePaidTask 同步拿到 order_no**(201 响应即含),写入 `context_json.affiliateDash.orderNo`;无需等回调。失败必须降级 `MANUAL_REVIEW` + `deps.notifyTaskAutoManualReview`(范式:kuaishou-feifei-executor.ts:36-51)。
|
||||||
- **状态迁移路径**:见 §8 注 —— 领取页打开 `link_generated → claimed`,绑定 `→ waiting_binding`,submit 成功/回调 delivering `→ redeeming`,delivered `→ redeemed`(+核销),ship_failed `→ retry_pending`,cancelled `→ manual_review → closed`。
|
- **状态迁移路径**:见 §8 注 —— 领取页打开 `link_generated → claimed`,绑定 `→ waiting_binding`,submit 成功/回调 delivering `→ redeeming`,delivered `→ redeemed`(+核销),ship_failed `→ retry_pending`,cancelled `→ manual_review → closed`。
|
||||||
- **回调幂等**:affiliate_dash 投递带 `X-Event-ID`,order_site 现无独立去重表 —— 建议 migration `013_affiliate_dash.sql` 新建 `webhook_events(event_id UNIQUE)` 去重表;事件处理用**状态合并式更新**(仿 `syncKuaishouFeifeiTaskStatus`,重复通知可重入)。接收方注意 `app.ts:31-38` 的 `express.json` 已保存 `req.rawBody`(验签必需),挂载回调路由时确认 rawBody 可用。
|
- **回调幂等**:affiliate_dash 投递带 `X-Event-ID`,order_site 现无独立去重表 —— 建议 migration `013_affiliate_dash.sql` 新建 `webhook_events(event_id UNIQUE)` 去重表;事件处理用**状态合并式更新**(仿 `syncKuaishouFeifeiTaskStatus`,重复通知可重入)。接收方注意 `app.ts:31-38` 的 `express.json` 已保存 `req.rawBody`(验签必需),挂载回调路由时确认 rawBody 可用。
|
||||||
@@ -417,7 +417,7 @@ order_site 处理要求:
|
|||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 自建页 vs delivery_url 兜底 | 方案 B 以自建页为主,`delivery-link` 作降级跳转;需确认前端是否允许 iframe 内嵌 affiliate_dash H5(扫码场景无碍,跳转场景有跨域限制) |
|
| 自建页 vs delivery_url 兜底 | 方案 B 以自建页为主,`delivery-link` 作降级跳转;需确认前端是否允许 iframe 内嵌 affiliate_dash H5(扫码场景无碍,跳转场景有跨域限制) |
|
||||||
| 91 拆单 | 一个 91 单拆多 unit → 每 task 独立 `client_order_no`(=task_no),互不影响 |
|
| 91 拆单 | 一个 91 单拆多 unit → 使用 `91单号-序号` 作为每笔 `client_order_no`,互不冲突且可按订单对账 |
|
||||||
| 对账任务 | 回调为主 + 可选定时 `GET /orders/{order_no}` 对账(回调丢失兜底) |
|
| 对账任务 | 回调为主 + 可选定时 `GET /orders/{order_no}` 对账(回调丢失兜底) |
|
||||||
| admin 手动重试 | ship_failed / manual_review 的手动「重新 submit」入口按 executor 分发(文件清单 #16) |
|
| admin 手动重试 | ship_failed / manual_review 的手动「重新 submit」入口按 executor 分发(文件清单 #16) |
|
||||||
| 回调订阅确认 | 阶段 0 配置回调时确认 affiliate_dash 后台事件订阅粒度(order.created 是否必须订阅,或仅 shipping.updated 即可) |
|
| 回调订阅确认 | 阶段 0 配置回调时确认 affiliate_dash 后台事件订阅粒度(order.created 是否必须订阅,或仅 shipping.updated 即可) |
|
||||||
@@ -455,7 +455,7 @@ order_site 处理要求:
|
|||||||
| `executors/types.ts` | `FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH='affiliate_dash'` + `isAffiliateDashExecutor` 守卫 |
|
| `executors/types.ts` | `FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH='affiliate_dash'` + `isAffiliateDashExecutor` 守卫 |
|
||||||
| `executors/affiliate-dash-executor.ts`(新) | `{ key, preparePaidTask, resolveDeliveryLink }` 仿 feifei:补 claim token → `prepareAffiliateDashTask`;失败降级 `MANUAL_REVIEW` + `notifyTaskAutoManualReview`;`resolveDeliveryLink` 返回本站统一 claimUrl |
|
| `executors/affiliate-dash-executor.ts`(新) | `{ key, preparePaidTask, resolveDeliveryLink }` 仿 feifei:补 claim token → `prepareAffiliateDashTask`;失败降级 `MANUAL_REVIEW` + `notifyTaskAutoManualReview`;`resolveDeliveryLink` 返回本站统一 claimUrl |
|
||||||
| `executors/registry.ts` | `EXECUTORS` Map 注册 affiliateDashExecutor |
|
| `executors/registry.ts` | `EXECUTORS` Map 注册 affiliateDashExecutor |
|
||||||
| `affiliate-dash/index.ts`(新) | `isAffiliateDashTask`、`normalizeAffiliateDashFlow`(context_json `affiliateDash` 块)、`prepareAffiliateDashTask`(幂等建单、`client_order_no=task_no`、`data` 透传 91单号/game_account、写 `LINK_GENERATED` + task event)、`syncAffiliateDashTaskStatus`(状态合并:delivering→redeeming、delivered→核销→redeemed/manual_review、ship_failed→retry_pending、cancelled→closed)、`buildAffiliateDashClientOrderNo` |
|
| `affiliate-dash/index.ts`(新) | `isAffiliateDashTask`、`normalizeAffiliateDashFlow`(context_json `affiliateDash` 块)、`prepareAffiliateDashTask`(幂等建单、`client_order_no` 优先使用平台订单号,拆单追加序号、`data` 透传 91单号/game_account、写 `LINK_GENERATED` + task event)、`syncAffiliateDashTaskStatus`(状态合并:delivering→redeeming、delivered→核销→redeemed/manual_review、ship_failed→retry_pending、cancelled→closed)、`buildAffiliateDashClientOrderNo` |
|
||||||
| 测试 | `registry.test.ts` 新增 affiliate_dash 映射 + 动作暴露断言(4→4 项) |
|
| 测试 | `registry.test.ts` 新增 affiliate_dash 映射 + 动作暴露断言(4→4 项) |
|
||||||
|
|
||||||
**真实建单联调(线上 skin.khhao.com)**:
|
**真实建单联调(线上 skin.khhao.com)**:
|
||||||
|
|||||||
Reference in New Issue
Block a user