diff --git a/docs/order_site-affiliate-dash对接.md b/docs/order_site-affiliate-dash对接.md new file mode 100644 index 0000000..146ef5e --- /dev/null +++ b/docs/order_site-affiliate-dash对接.md @@ -0,0 +1,343 @@ +# 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 拼串不同,勿混用): + +```text +api_key=&body_sha256=&method=&nonce=&path=<仅路径>×tamp= +``` + +规则: + +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 = [ + `api_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=×tamp= +X-Sign = hex(HMAC-SHA256(回调secret, content)) +``` + +校验顺序:`X-Timestamp` 在 ±300 秒内 → 按 `X-Event-ID` 幂等去重 → 重算签名比对(`timingSafeEqual`)→ 处理业务。校验失败返回 4xx,成功尽快返回 2xx(affiliate_dash 会指数退避重试最多 16 次)。 + +--- + +## 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: , sku, quantity, buyer_reference, data: { 91单号, 预期游戏账号 } } + → affiliate_dash 扣钱包积分,建单(order_no, order_status=paid) + → 异步回调 order.created + → order_site 保存 order_no 到 task 上下文,task → link_generated + └→ 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`) + +返回订单当前状态、金额、发货链接有效期、失败原因等;用于 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", + "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/用户) | + +--- + +## 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 关联单号),重复请求安全; +- **回调幂等**:`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`)