新增 affiliate_dash 发货平台客户端与签名(阶段 1)
- 平台配置接入:runtime-config/types/defaults/env-overrides/app-config-keys 增加 affiliateDash - affiliate-dash 平台客户端:请求签名(§3.1 app_key+body_sha256)、回调验签(§3.2 timingSafeEqual)、 http-client 封装(超时/错误归一/全链路日志)、订单/交付/钱包/商品接口封装 - 单测 12/12 + 全量 211 通过,真实联调拉取线上商品 33 个验证签名链路 - 新增 affiliate_dash 对接文档 v2(含契约验证结论与深度分析)
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
# affiliate_dash 商户接入对接文档(方案 B:统一领取页深度集成)
|
||||
|
||||
> 文档对象:order_site 后端 / 前端对接开发
|
||||
> 版本:v1(评审稿)
|
||||
> 对接方向:order_site 作为 **affiliate_dash 的一个商户**,走商户开放接口 `/api/client/v1`,由 affiliate_dash 完成皮肤/道具履约,order_site 保持自己的统一领取页并自建发货交互。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
order_site 在 affiliate_dash 开通一个商户账号 + API 客户端,下单时调用 `POST /api/client/v1/orders` 幂等建单扣款,领取页通过 `delivery` 系列接口(查询 → 绑定 → 轮询 → 提交)在**本站页面内**完成发货交互,最终由 affiliate_dash 回调 `order.shipping.updated` 驱动 order_site 任务状态与快手电子凭证核销闭环。
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色与职责边界
|
||||
|
||||
| 方 | 承担 | 不承担 |
|
||||
| --- | --- | --- |
|
||||
| **order_site** | 91 进单、商品匹配、履约任务、统一领取页、玩家信息收集、回调接收、电子凭证核销、人工兜底 | 不直接对接皮肤源头;不管理 affiliate_dash 钱包 |
|
||||
| **affiliate_dash** | 商户建单扣款、发货数据查询、绑定、对源头履约、回调推送 | 不接触 91 卡券,不生成 order_site 的 claimUrl |
|
||||
|
||||
> 对接方式与现有 `kuaishou-feifei` 执行器同构:order_site 新增一个 `affiliate_dash` executor,复用一个 `preparePaidTask` + `resolveDeliveryLink`,对外仍然只返回本站统一领取链接。
|
||||
|
||||
---
|
||||
|
||||
## 2. 对接前准备(affiliate_dash 侧,管理员手动完成)
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 商户账号 | affiliate_dash 后台新建商户(如 `order_site`),状态 active |
|
||||
| API 客户端 | 商户后台「API 密钥」新建,scopes 至少含 `products:read`、`orders:read`、`orders:write`、`shipping:read`、`wallet:read`;保存 `app_key` / `secret`(secret 仅创建时展示一次) |
|
||||
| 回调配置 | 商户后台「回调」配置接收 URL(指向 order_site 回调路由),订阅事件 `order.created`、`order.shipping.updated`、`order.cancelled`,保存回调 secret |
|
||||
| 钱包充值 | affiliate_dash 无真实支付,**下单即扣商户钱包积分**;上线前需充值足够积分 |
|
||||
| 环境 | 记录 `BASE_URL`(如 `https://affiliate.example.com`),确认网络可达;时间偏差容差 ±300 秒,服务器需 NTP 同步 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 鉴权与签名
|
||||
|
||||
### 3.1 请求签名(order_site → affiliate_dash `/api/client/v1`)
|
||||
|
||||
Header:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `X-App-Key` | 是 | 商户 API 客户端 app_key |
|
||||
| `X-Timestamp` | 是 | Unix 秒时间戳 |
|
||||
| `X-Nonce` | 是 | 随机串 8~96 位;同 Key 有效期内不可重复(服务端持久化防重放) |
|
||||
| `X-Sign` | 是 | 见下 |
|
||||
|
||||
签名字符串(**body 先 sha256**,与源头侧 `/api/open/v1` 的原始 body 拼串不同,勿混用;参数名是 **`app_key`** 而非旧接口的 `api_key`,写错会 401):
|
||||
|
||||
```text
|
||||
app_key=<app_key>&body_sha256=<sha256hex(body)>&method=<GET|POST>&nonce=<nonce>&path=<仅路径>×tamp=<unix秒>
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
1. 参数按 ASCII 字典序排序(固定为上面顺序);
|
||||
2. value 原样拼接不做 URL encode;GET 请求 body 为空字符串(`body_sha256` 为空串的 sha256);
|
||||
3. `X-Sign = hex(HMAC-SHA256(secret, 签名字符串))`,小写。
|
||||
|
||||
TypeScript 参考:
|
||||
|
||||
```ts
|
||||
import { createHmac, createHash } from 'node:crypto'
|
||||
|
||||
function sha256Hex(input: string): string {
|
||||
return createHash('sha256').update(input, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function hmacSha256Hex(secret: string, content: string): string {
|
||||
return createHmac('sha256', secret).update(content, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export function buildClientSign(params: {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
method: string
|
||||
path: string
|
||||
body: string
|
||||
timestamp: string
|
||||
nonce: string
|
||||
}): string {
|
||||
const content = [
|
||||
`app_key=${params.appKey}`,
|
||||
`body_sha256=${sha256Hex(params.body)}`,
|
||||
`method=${params.method.toUpperCase()}`,
|
||||
`nonce=${params.nonce}`,
|
||||
`path=${params.path}`,
|
||||
`timestamp=${params.timestamp}`,
|
||||
].sort().join('&')
|
||||
return hmacSha256Hex(params.appSecret, content)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 回调验签(affiliate_dash → order_site)
|
||||
|
||||
Header:`X-Event-ID`、`X-Timestamp`、`X-Sign`
|
||||
|
||||
```text
|
||||
content = body_sha256=<sha256hex(原始body)>×tamp=<X-Timestamp>
|
||||
X-Sign = hex(HMAC-SHA256(回调secret, content))
|
||||
```
|
||||
|
||||
校验顺序:`X-Timestamp` 在 ±300 秒内 → 按 `X-Event-ID` 幂等去重 → 重算签名比对(`timingSafeEqual`)→ 处理业务。校验失败返回 4xx,成功尽快返回 2xx(affiliate_dash 会重试最多 16 次,间隔为固定递增序列 `15s/15s/30s/3m/10m/20m/30m/30m/30m/1h/3h/3h/3h/6h/6h`,非严格指数退避)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 商品映射
|
||||
|
||||
- 拉取:`GET /api/client/v1/products`(分页 `page`/`size`),关键字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `list[].sku` | 下单时作为 `sku` 传入 |
|
||||
| `list[].display_name` | 商户展示名 |
|
||||
| `list[].price_amount` | 单价(积分) |
|
||||
| `list[].stock` | 库存,`-1` 不限 |
|
||||
| `list[].status` | `active` 可售 |
|
||||
|
||||
- order_site 侧在履约配置(`fulfillment_profiles.config_json`)维护映射:**91 productNo / order_site 商品 → affiliate_dash sku**。
|
||||
- 建议:admin 配置页支持手动关联 + 定时同步(商品下架/缺货在领取前拦截,转人工)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整时序(方案 B)
|
||||
|
||||
```text
|
||||
91 卡券下单
|
||||
└→ order_site 建内部订单 + 履约 task(executor_key = affiliate_dash)
|
||||
└→ preparePaidTask:
|
||||
POST /api/client/v1/orders
|
||||
{ client_order_no: <order_site 内部单号>, sku, quantity, buyer_reference, data: { 91单号, 预期游戏账号 } }
|
||||
→ affiliate_dash 扣钱包积分,建单(order_no, order_status=paid)
|
||||
→ order_site 从 201 响应**同步**拿到 order_no,写入 task 上下文,task → link_generated
|
||||
→ 异步回调 order.created 仅作对账/补发(幂等,不改变 task 状态)
|
||||
└→ resolveDeliveryLink:返回本站统一 claimUrl
|
||||
91 把 claimUrl 发给用户
|
||||
用户打开 order_site 领取页(executor_key=affiliate_dash 分支)
|
||||
└→ 展示商品/预期账号
|
||||
└→ 用户输入/确认游戏 UID → POST /orders/{order_no}/delivery/bind
|
||||
→ 展示 bind_url / qr_url(或跳转绑定)
|
||||
└→ 轮询 GET /orders/{order_no}/delivery/bind-result(2~3 秒)
|
||||
→ bound=true(含 mismatch 提示)→ 展示角色信息
|
||||
└→ POST /orders/{order_no}/delivery/submit { game_account, bind_uuid }
|
||||
→ affiliate_dash 进入 delivering,内部对源头发货
|
||||
affiliate_dash 履约完成(delivered / ship_failed)
|
||||
└→ 回调 order.shipping.updated
|
||||
→ order_site 验签 + 幂等 → 更新 task 状态
|
||||
→ delivered:task → redeemed/completed,触发快手电子凭证核销
|
||||
→ ship_failed:task → retry_pending/manual_review,展示失败原因
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 接口明细(order_site 实际使用的端点)
|
||||
|
||||
### 6.1 创建订单 `POST /api/client/v1/orders`(scope `orders:write`)
|
||||
|
||||
| body | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `client_order_no` | string | 是 | **幂等单号**,同一商户下唯一;建议用 order_site 内部单号 |
|
||||
| `sku` | string | 是 | affiliate_dash 商品 sku |
|
||||
| `quantity` | int | 否 | 默认 1 |
|
||||
| `buyer_reference` | string | 否 | 买家标识/备注(可填 91 单号或用户标识) |
|
||||
| `data` | object | 否 | 透传业务数据(≤2048B);约定字段 `game_account`/`game_channel`/`role_name` 会用于发货处理;回调原样带回 |
|
||||
|
||||
响应 `data.order`:`order_no`、`client_order_no`、`order_status`(=paid)、`can_ship`、`cannot_ship_reason`、`amount`、`base_amount`、`service_fee_amount`、`currency` 等。
|
||||
|
||||
注意:
|
||||
|
||||
- 成功创建 HTTP **201**;命中幂等返回 **200** 且 `idempotent=true` + 既有订单,**不重复扣款**;
|
||||
- **无真实支付,下单即扣钱包积分**,余额不足返回 400 —— order_site 必须处理该降级;
|
||||
- 下单后以 `order_no` 为准进行后续查询/发货/回调匹配。
|
||||
|
||||
### 6.2 查询订单 `GET /api/client/v1/orders/{order_no}`(`orders:read`)
|
||||
|
||||
返回订单当前状态、金额、失败原因等(⚠️ 实际响应**不含**「发货链接有效期」字段,有效期仅在 delivery-link 接口返回);用于 order_site 主动对账/兜底轮询。
|
||||
|
||||
### 6.3 获取发货链接 `GET /api/client/v1/orders/{order_no}/delivery-link`(`orders:read`/`shipping:read`)
|
||||
|
||||
返回 `delivery_url`(可直接打开的 affiliate_dash Web 发货页)。方案 B 下作为**备选**(如自建页临时不可用时直接跳转)。
|
||||
|
||||
### 6.4 查询发货数据 `GET /api/client/v1/orders/{order_no}/delivery`(`orders:read`/`shipping:read`)
|
||||
|
||||
自建发货页的渲染数据:`status`、`can_ship`、`cannot_ship_reason`、`product`、`buyer_name`、`game_channel`、`game_uid`、`role_name`、`pay_score`、`data`(下单透传,用于预填玩家账号)、`good`(商品展示详情)。
|
||||
|
||||
### 6.5 发起绑定 `POST /api/client/v1/orders/{order_no}/delivery/bind`(`orders:write`)
|
||||
|
||||
| body | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `game_account` | string | 是 | 玩家编号 / UID |
|
||||
|
||||
返回 `bind_uuid`(提交发货凭证)、`bind_url`(绑定跳转地址)、`qr_url`(绑定二维码)。同一订单重复绑定以最新凭证为准。
|
||||
|
||||
### 6.6 查询绑定结果 `GET /api/client/v1/orders/{order_no}/delivery/bind-result?bind_uuid=..`(`orders:read`/`shipping:read`)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `bound` | 是否完成绑定;未绑定 `false`,自建页每 2~3 秒轮询 |
|
||||
| `game_account` / `role_name` / `game_channel` | 绑定成功后返回 |
|
||||
| `expected_game_account` | 下单 `data.game_account` 透传的预期账号(有传才返回) |
|
||||
| `mismatch` | 绑定账号与预期不一致时为 `true`;**提交发货会被拒绝** |
|
||||
|
||||
### 6.7 提交发货 `POST /api/client/v1/orders/{order_no}/delivery/submit`(`orders:write`)
|
||||
|
||||
| body | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `game_account` | string | 是 | 玩家编号 / UID |
|
||||
| `bind_uuid` | string | 是 | 绑定凭证 |
|
||||
|
||||
返回 `order_no`、`status`(=delivering)、`message`、`provider_order_no`。同一订单重复提交直接返回既有结果。**最终成败以回调为准**。
|
||||
|
||||
### 6.8 余额查询 `GET /api/client/v1/wallet`(`wallet:read`)
|
||||
|
||||
可选:下单前预检余额,或在订单创建 400 时告警。返回 `available_balance`、`currency` 等。
|
||||
|
||||
---
|
||||
|
||||
## 7. 回调契约
|
||||
|
||||
| 事件 | 触发时机 |
|
||||
| --- | --- |
|
||||
| `order.created` | 商户下单成功(扣款完成,order_status=paid) |
|
||||
| `order.shipping.updated` | 发货状态变化(delivering / delivered / ship_failed) |
|
||||
| `order.cancelled` | 订单取消/退款 |
|
||||
|
||||
payload(统一结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "…",
|
||||
"event": "order.shipping.updated",
|
||||
"occurred_at": "…", // 真实实现含此字段
|
||||
"data": {
|
||||
"order_no": "FO20260730000123",
|
||||
"client_order_no": "shop-10001",
|
||||
"product_sku": "suit_pink_sheep",
|
||||
"quantity": 1,
|
||||
"base_amount": 100,
|
||||
"service_fee_amount": 1,
|
||||
"amount": 101,
|
||||
"currency": "POINT",
|
||||
"order_status": "delivered",
|
||||
"can_ship": false,
|
||||
"cannot_ship_reason": "",
|
||||
"provider_order_no": "…",
|
||||
"failure_reason": "",
|
||||
"data": { "91单号": "…", "game_account": "4808146277" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
order_site 处理要求:
|
||||
|
||||
1. 验签(见 3.2);
|
||||
2. 按 `X-Event-ID` / `event_id` 幂等(DB 唯一键或去重表);
|
||||
3. `order_status` → task 状态迁移(见下);
|
||||
4. 尽快返回 2xx;失败重试可能重复投递(最多 16 次指数退避)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 状态映射
|
||||
|
||||
| affiliate_dash `order_status` | order_site task 状态 / 动作 |
|
||||
| --- | --- |
|
||||
| `paid` | 已建单待履约(task `link_generated`,领取页可开始发货) |
|
||||
| `delivering` | 履约中(task `redeeming`,领取页展示处理中) |
|
||||
| `delivered` | 履约成功(task → `redeemed` / `completed`;触发快手电子凭证核销) |
|
||||
| `ship_failed` | 履约失败(task → `retry_pending`;可重新 `delivery/submit` 或转 `manual_review`) |
|
||||
| `cancelled` | 已取消/退款(task → `closed`,通知 91/用户) |
|
||||
|
||||
> ⚠️ **状态机合法性**(对照 `apps/backend/src/domain/task-status.ts` 的 `TASK_TRANSITIONS`):
|
||||
> - affiliate_dash 流程中 task 须经过 `link_generated → claimed → waiting_binding → redeeming` 才能进入履约态:`link_generated`/`claimed` **不能**直接跳 `redeeming`(不在转移表)。落地时:领取页打开置 `claimed`、提交绑定置 `waiting_binding`、`delivery/submit` 成功或收到 delivering 回调置 `redeeming`。
|
||||
> - `redeeming → redeemed/completed/retry_pending/manual_review` 均合法;`redeeming → closed` 不在转移表(`updateTask` 实际不校验转移表,属软约束,仍建议先经 `manual_review` 再 `closed`)。
|
||||
> - `retry_pending/manual_review → redeeming` 合法,支持 ship_failed 重试闭环。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误与降级
|
||||
|
||||
| 场景 | affiliate_dash 返回 | order_site 处理 |
|
||||
| --- | --- | --- |
|
||||
| 钱包余额不足 | 创建订单 400 | task → `manual_review` + 告警「affiliate_dash 余额不足」,充值后重试 |
|
||||
| 商品下架/缺货 | 创建订单 400 | 匹配阶段拦截,转人工 |
|
||||
| 下单超时/网络失败 | 无响应 | 用同 `client_order_no` 幂等重试;仍失败转 `manual_dispatch` |
|
||||
| 绑定账号不匹配 | `mismatch=true`,submit 被拒 | 领取页提示买家绑回预期账号或重新下单 |
|
||||
| `ship_failed` | 回调 failure_reason | 领取页/后台展示原因,支持重试或转人工 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 幂等与一致性设计
|
||||
|
||||
- **下单幂等**:`client_order_no` = order_site 的 `task_no`(格式 `DT` + 12 hex,唯一且稳定,见 §14.2),重复请求安全;
|
||||
- **回调幂等**:`X-Event-ID` 去重;
|
||||
- **提交发货幂等**:重复 `delivery/submit` 返回既有结果;
|
||||
- **对账兜底**:order_site 定时用 `GET /orders/{order_no}` 对账(可选,回调为主);
|
||||
- **时间**:双方服务器 NTP 同步,容差 ±300 秒。
|
||||
|
||||
---
|
||||
|
||||
## 11. order_site 配置清单(履约配置扩展)
|
||||
|
||||
`fulfillment_profiles` 新增 profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_key": "affiliate_dash",
|
||||
"name": "affiliate_dash 履约",
|
||||
"executor_key": "affiliate_dash",
|
||||
"requires_claim": true,
|
||||
"auto_dispatch": false,
|
||||
"config_json": {
|
||||
"baseUrl": "https://affiliate.example.com",
|
||||
"appKey": "ak_…",
|
||||
"appSecret": "sk_…",
|
||||
"callbackSecret": "…",
|
||||
"skuMapping": { "91商品编码": "affiliate_dash sku" },
|
||||
"fallbackExecutor": "manual_dispatch",
|
||||
"precheckBalance": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
商品匹配规则(复用 `fulfillment-routing.ts`):91 productNo → 命中 affiliate_dash 映射 → `executor_key = affiliate_dash`;未命中走现有 kuaishou / manual 流程。
|
||||
|
||||
---
|
||||
|
||||
## 12. 对接计划(分阶段)
|
||||
|
||||
| 阶段 | 内容 | 交付物 | 验收点 |
|
||||
| --- | --- | --- | --- |
|
||||
| **0. affiliate_dash 侧准备**(手动) | 建商户、API 客户端、回调配置、充值 | 商户可登录、app_key/secret 可用 | 用 curl 调 `GET /products` 通 |
|
||||
| **1. 平台客户端与签名** | `services/platforms/affiliate-dash/`:签名、请求封装、验签 | `client.ts`、`verify-callback.ts` | 单元测试:签名向量、验签通过/篡改拒绝 |
|
||||
| **2. executor** | `affiliate-dash-executor.ts`:`preparePaidTask`(建单+存 order_no)+ `resolveDeliveryLink`(统一 claimUrl);注册进 `registry.ts` | executor + 注册 | 测试订单:91 进单 → task 状态 `link_generated`,affiliate_dash 出现订单 |
|
||||
| **3. 履约配置与商品映射** | 新增 profile、admin 配置页(sku 映射/余额预检) | 配置页 + 迁移 | 配置页可保存,路由规则生效 |
|
||||
| **4. 领取页(方案 B 自建)** | 前端按 executor 分发到 affiliate-dash 流程:`delivery` 查数据 → 输入 UID → `bind` → 轮询 `bind-result` → `submit`;异常分支(mismatch/失败重试) | 领取页分支 | 端到端:用户领取 → 绑定 → 提交 → affiliate_dash 显示 delivering |
|
||||
| **5. 回调路由与状态同步** | `POST /webhooks/affiliate-dash`:验签 + 幂等 + task 状态迁移 + 核销触发 | 回调路由 | 发货完成回调 → task `redeemed` + 电子凭证核销闭环 |
|
||||
| **6. 联调与上线** | 错误/降级演练、对账、监控告警 | 上线 checklist | 全链路绿灯,余额不足/绑定不匹配演练通过 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 参考代码位置
|
||||
|
||||
- order_site executor 模式:`apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts`(最接近,同为「建单 + 统一领取链接」)、`registry.ts`(注册)、`types.ts`(接口定义)
|
||||
- order_site 平台对接样例:`apps/backend/src/services/platforms/kuaishou-feifei/`
|
||||
- order_site 履约配置:`apps/backend/src/repositories/fulfillment-profile-repo.ts`、`apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts`
|
||||
- affiliate_dash 接口契约(在线文档/字段定义):`frontend/src/openapi/endpoints.ts`;鉴权实现:`backend/internal/middleware/open_auth.go`(商户侧)、`backend/internal/service/callback.go`(回调签名 `BuildCallbackSign`)
|
||||
|
||||
---
|
||||
|
||||
## 14. 深度分析(契约验证结论 · v1 → v2)
|
||||
|
||||
> 本节基于对 affiliate_dash 实际代码(`backend/internal/middleware/open_auth.go`、`backend/internal/service/callback.go`、`backend/internal/handler/open_v1.go`、`backend/internal/service/delivery.go`、`frontend/src/openapi/endpoints.ts` 等)与 order_site 履约体系(`domain/task-status.ts`、`services/fulfillment/executors/*`、`services/claim/*`)的逐项核对。以下结论是阶段 1~6 实现的直接依据。
|
||||
|
||||
### 14.1 契约验证对照(文档 vs affiliate_dash 实际实现)
|
||||
|
||||
| # | 契约项 | 结论 |
|
||||
|---| --- | --- |
|
||||
| 1 | 签名串参数名:文档原写 `api_key=`,实现为 `app_key=`(open_auth.go:194 `BuildOpenV1Sign`;前端在线文档 OpenApiDocs.tsx:425-449 与调试页 ApiDebugger.tsx:65 均用 `app_key=`) | ❌ 已在本版修正;按旧写法签名必然 401 |
|
||||
| 2 | body 先 sha256、参数字典序、四头、nonce 持久化防重放(长度 8~96)、±300s(OPEN_SIGN_SKEW=300)、scopes(products:read/orders:read/orders:write/shipping:read/wallet:read) | ✅ 一致 |
|
||||
| 3 | 回调 `content=body_sha256=<..>×tamp=<..>`、头 X-Event-ID/X-Timestamp/X-Sign、事件枚举 order.created/order.shipping.updated/order.cancelled | ✅ 一致 |
|
||||
| 4 | 回调重试 16 次 | ⚠️ 次数一致(CALLBACK_MAX_ATTEMPTS=16),但为固定递增间隔序列而非严格指数退避(callback.go:30-46) |
|
||||
| 5 | POST /orders 字段、新建 201 / 幂等 200 + idempotent=true、余额不足 400、order 响应字段(order_no/order_status/can_ship/cannot_ship_reason/amount/base_amount/service_fee_amount/currency + client_order_no/product/quantity/fee_type/buyer_reference/provider_order_no/failure_reason/data/result/created_at/delivered_at/cancelled_at) | ✅ 一致 |
|
||||
| 6 | GET /products 字段与分页 page/size(status 恒为 active);GET /orders/{order_no};delivery-link → delivery_url/expires_at;delivery → status/can_ship/cannot_ship_reason/product/buyer_name/game_channel/game_uid/role_name/pay_score/data/good;bind → bind_uuid/bind_url/qr_url;bind-result → bound/game_account/role_name/game_channel/expected_game_account/mismatch;submit → order_no/status/message/provider_order_no;wallet → available_balance/currency | ✅ 一致(唯一修正:查询订单响应不含「发货链接有效期」,见 §6.2) |
|
||||
| 7 | 回调 payload 字段 + 顶层 `occurred_at`(真实存在,§7 已补) | ✅ 一致 |
|
||||
| 8 | order_status 五枚举 paid/delivering/delivered/ship_failed/cancelled;mismatch 提交被拒为 HTTP 400 | ✅ 一致(语义细节见下) |
|
||||
|
||||
**mismatch 语义细节**:`bind-result.mismatch=true` 判定的是「绑定账号 ≠ 下单 `data.game_account`(预期账号)」(delivery.go:227-233);`submit` 的拒绝条件是「绑定账号 ≠ **请求体提交的** game_account」(delivery.go:354-357)。正常流程提交预期账号时两者等价;若页面让用户提交的是绑定账号本身,则 mismatch=true 时也能提交通过 —— 对账/风控时留意。
|
||||
|
||||
### 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。
|
||||
- **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`。
|
||||
- **回调幂等**: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 可用。
|
||||
- **核销联动**:`delivered` 回调 → 复用 `consumeKuaishouIndustryVouchersForTask`(`platforms/kuaishou-industry/voucher-service.ts:143`),成功 → `redeemed/completed`,失败 → `manual_review`。
|
||||
- **领取页分发**:`flowType` 扩展 `'affiliate_dash'`;`buildClaimDetailPayload`(`services/claim/kuaishou-cloud-claim-context.ts:89-92`)按 executor_key 增加分支,返回 affiliate_dash 专用 payload(商品/预期账号/当前状态/二维码相关);前端 `ClaimPage.tsx:337-373` 增加渲染分支。
|
||||
|
||||
### 14.3 改动文件清单
|
||||
|
||||
**后端(16 项)**:
|
||||
|
||||
| # | 文件 | 改动 |
|
||||
|---|------|------|
|
||||
| 1 | `services/fulfillment/executors/types.ts` | `FULFILLMENT_EXECUTOR_KEYS` 加 `AFFILIATE_DASH`;按需加 `isAffiliateDashExecutor` 守卫 |
|
||||
| 2 | `services/fulfillment/executors/affiliate-dash-executor.ts`(新) | 仿 kuaishou-feifei-executor.ts:`{ key, preparePaidTask, resolveDeliveryLink }` |
|
||||
| 3 | `services/fulfillment/executors/registry.ts` | `EXECUTORS` Map 注册 |
|
||||
| 4 | `services/fulfillment/affiliate-dash/index.ts`(新) | 业务实现:prepare / sync 状态合并 + flow normalize |
|
||||
| 5 | `services/platforms/affiliate-dash/`(新) | `config.ts` + `http-client.ts`(签名 §3.1 + 请求封装 + 超时)+ `order-service.ts` + `notify-service.ts`(验签,`timingSafeEqual` 模式) |
|
||||
| 6 | `services/fulfillment/routing-config-service.ts` | `ROUTABLE_EXECUTOR_KEYS` / `DEFAULT_EXECUTOR_PRIORITY` 加入 affiliate_dash |
|
||||
| 7 | `services/fulfillment/product-resolution-service.ts` | candidates 加 affiliate_dash 匹配项 + item_snapshot 上下文 |
|
||||
| 8 | `services/bootstrap/fulfillment-bootstrap-service.ts` | `CORE_PROFILES` 加 affiliate_dash profile |
|
||||
| 9 | `services/fulfillment/planner.ts` | 动态 profile 解析 + `buildFulfillmentTaskContext` 加 `affiliateDash` 块 |
|
||||
| 10 | `services/claim/kuaishou-cloud-claim-context.ts` | 详情 payload 加 affiliate_dash 分支 |
|
||||
| 11 | `services/claim/kuaishou-cloud-claim-service.ts` | executor_key 分发加分支 |
|
||||
| 12 | `repositories/task-repo.ts` | 仿 `findKuaishouFeifeiTaskByOrder` 加按 `context_json #>> '{affiliateDash,orderNo}'` 查任务 |
|
||||
| 13 | `routes/affiliate-dash.ts`(新)+ `app.ts` | webhook 路由,挂载 `/api/v1/open/affiliate-dash` |
|
||||
| 14 | `db/migrations/013_affiliate_dash.sql`(新) | `webhook_events` 去重表(可选:affiliate 流水表) |
|
||||
| 15 | `routes/admin/platform-config/` + `services/admin/platform-config/` | 平台配置读写(appKey/secret 等) |
|
||||
| 16 | `services/admin/write/` | admin 手动重试/转人工按 executor 分发分支 |
|
||||
|
||||
**前端(6 项)**:
|
||||
|
||||
| # | 文件 | 改动 |
|
||||
|---|------|------|
|
||||
| 17 | `types/claim.ts` | `ClaimAffiliateDashFlowInfo` + flowType 扩展 |
|
||||
| 18 | `pages/claim/ClaimPage.tsx` + `claim-snapshot.ts` | `isAffiliateDashFlow` 判定与渲染分支 |
|
||||
| 19 | `pages/claim/ClaimAffiliateDashSteps.tsx`(新) | 领取步骤(查 delivery → 输 UID → bind → 轮询 bind-result → submit) |
|
||||
| 20 | `services/claim.ts` | claim 侧操作 API |
|
||||
| 21 | `pages/admin/AdminTasksPage.tsx` 等 | executor label/color 显示 |
|
||||
| 22 | `domain/task-status.ts` | 如需新增状态/转移同步(按 §8 注已有路径则无需) |
|
||||
|
||||
### 14.4 剩余风险与待确认决策点
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 自建页 vs delivery_url 兜底 | 方案 B 以自建页为主,`delivery-link` 作降级跳转;需确认前端是否允许 iframe 内嵌 affiliate_dash H5(扫码场景无碍,跳转场景有跨域限制) |
|
||||
| 91 拆单 | 一个 91 单拆多 unit → 每 task 独立 `client_order_no`(=task_no),互不影响 |
|
||||
| 对账任务 | 回调为主 + 可选定时 `GET /orders/{order_no}` 对账(回调丢失兜底) |
|
||||
| admin 手动重试 | ship_failed / manual_review 的手动「重新 submit」入口按 executor 分发(文件清单 #16) |
|
||||
| 回调订阅确认 | 阶段 0 配置回调时确认 affiliate_dash 后台事件订阅粒度(order.created 是否必须订阅,或仅 shipping.updated 即可) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段 1 落地记录(v2.1 · 已完成)
|
||||
|
||||
产出文件(`apps/backend/src/`):
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `services/platforms/affiliate-dash/config.ts` | `get/assertAffiliateDashConfig`(runtime + saved 合并);常量 `AFFILIATE_DASH_EXECUTOR_KEY='affiliate_dash'`、`AFFILIATE_DASH_WEBHOOK_PATH='/api/v1/open/affiliate-dash'` |
|
||||
| `services/platforms/affiliate-dash/source-config-service.ts` | saved config 读写(`APP_CONFIG_KEYS.affiliateDash`,密钥存 DB 不进 git;含 `skuMapping` 归一) |
|
||||
| `services/platforms/affiliate-dash/sign.ts` | `buildClientSign`(§3.1,参数名 `app_key=`)+ `buildCallbackSign`/`verifyCallbackSign`(§3.2,`timingSafeEqual`)+ `createClientSignHeaders` |
|
||||
| `services/platforms/affiliate-dash/http-client.ts` | `affiliateDashRequest`(GET/POST、四头自动签名、AbortController 超时 502/504、错误归一、`logExternalHttpPacket` 全链路日志) |
|
||||
| `services/platforms/affiliate-dash/verify-callback.ts` | `verifyAffiliateDashCallback`(时间容差 ±300s → 验签 → 事件解析;X-Event-ID 幂等留待阶段 5) |
|
||||
| `services/platforms/affiliate-dash/order-service.ts` | 建单/查询/delivery-link/delivery/bind/bind-result/submit/wallet 全端点 + 字段映射 |
|
||||
| `services/platforms/affiliate-dash/product-service.ts` | `listAffiliateDashProducts` + `listAllAffiliateDashProducts`(翻页拉全) |
|
||||
| 配置接入 | `runtime-config.ts`、`defaults.ts`、`env-overrides.ts`(`AFFILIATE_DASH_*`)、`app-config-keys.ts` |
|
||||
| 测试 | `sign.test.ts`(黄金向量)、`verify-callback.test.ts`(通过/篡改/超容差/缺头)、`http-client.test.ts`;12/12 通过,全量 211 通过,`tsc --noEmit` 通过 |
|
||||
|
||||
**真实联调验收**:`listAffiliateDashProducts` 直连线上 `https://skin.khhao.com` → total=33 商品,字段映射正确,签名链路与 affiliate_dash `BuildOpenV1Sign` 一致。
|
||||
|
||||
**联调中发现并修复**:签名 `path` 必须为**纯路径**(不含 query string);首次实现把 `/products?page=1&size=5` 整串参与签名导致线上 401「签名校验失败」,已改为 `URL.pathname` 参与签名(§3.1 表头 `path=<仅路径>` 属实)。
|
||||
Reference in New Issue
Block a user