# 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=<仅路径>&timestamp=<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)>&timestamp=<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=<..>&timestamp=<..>`、头 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=<仅路径>` 属实）。

---

## 16. 阶段 2 落地记录（v2.2 · 已完成）

产出文件（`apps/backend/src/services/fulfillment/`）：

| 文件 | 内容 |
| --- | --- |
| `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/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` |
| 测试 | `registry.test.ts` 新增 affiliate_dash 映射 + 动作暴露断言（4→4 项） |

**真实建单联调（线上 skin.khhao.com）**：

| 步骤 | 结果 |
| --- | --- |
| 建单 `lucky_coin_x2`（20 积分） | `FO20260805145602108a92eac8d`、`paid`、`can_ship=true` ✅ |
| 同 client_order_no 幂等重试 | 同单号 + `idempotent=true`，不重复扣款 ✅ |
| GET /orders/{order_no} 查询 | `paid`，金额/状态正确 ✅ |
| GET /orders/{order_no}/delivery | `data` 透传（91单号 + game_account）正确 ✅ |

**联调中发现并修复**：`POST /orders` 响应是 `data.order`（非 `data` 直接），首次实现 map 错了层级导致 orderNo 全空；已改为取 `data.order` 并暴露 `idempotent` 标志。幂等校验要求**同单号参数完全一致**，否则 400「参数与原订单不一致」——重试时必须复用原参数。

---

## 17. 阶段 3 落地记录（v2.3 · 已完成）

产出文件（`apps/backend/src/` + `apps/frontend/src/`）：

| 文件 | 内容 |
| --- | --- |
| `services/bootstrap/fulfillment-bootstrap-service.ts` | `CORE_PROFILES` 追加 affiliate_dash profile（requiresClaim: true, external_platform） |
| `services/fulfillment/routing-config-service.ts` | `ROUTABLE_EXECUTOR_KEYS` + `DEFAULT_EXECUTOR_PRIORITY` 加 `AFFILIATE_DASH`（路由可命中） |
| `services/fulfillment/planner.ts` | `buildFulfillmentTaskContext` 加 `affiliateDash` 块（flowType/sku/productName/orderNo/... 与 `AffiliateDashFlow` 对齐）；`resolveDynamicFulfillmentProfile` 加 AFFILIATE_DASH 分支 + 新增 `resolveDynamicAffiliateDashProfile`（configId `affiliate_dash:${sku}`） |
| `services/fulfillment/product-resolution-service.ts` | `resolveConfiguredItemCandidate` 91 分支加 affiliate_dash 候选（`resolveAffiliateDashSkuByProductNo`，key=snapshot.productNo）+ resolvedSkuCode/resolvedSkuName/snapshot.affiliateDash/matchMode |
| `services/fulfillment/affiliate-dash/index.ts` | `prepareAffiliateDashTask` 下单前 `preflightAffiliateDashWallet`（余额 ≤0 → `affiliate_dash_wallet_not_enough` 提前拦截；查询失败不阻塞） |
| `config/env-overrides.ts` | `AFFILIATE_DASH_SKU_MAPPING_JSON` env 注入（仿 KUAISHOU_FEIFEI_PRODUCT_RULES_JSON） |
| `routes/admin/platform-config/affiliate-dash.ts`（新）+ `platform-config.ts` | GET/POST 配置、POST match（productNo→sku）、POST products、POST wallet |
| `services/admin/platform-config/affiliate-dash-service.ts`（新） | get/update/match/listProducts/wallet；audit 不落密钥 |
| 前端 `types/admin/.../affiliate-dash.ts` + `services/admin/platform-config/affiliate-dash.ts` + 两处 index export | 类型与 API 封装 |
| 前端 `AdminPlatformFulfillmentPage.tsx` | `ROUTING_EXECUTORS` 加 affiliate_dash（gold）+ Tabs 面板 `AffiliateDashFulfillmentPanel`（配置表单/密钥、skuMapping 行编辑、映射测试、商品目录、钱包余额） |

**联调验证（模拟 91 进单 → resolveOrderItemForFulfillment）**：
- `productNo='X91-LUCKY-90'` + skuMapping 命中 → `selectedExecutorKey='affiliate_dash'`、`matchMode='affiliate_dash_sku_mapping'`、`resolvedSkuCode='lucky_coin_x90'`、snapshot.affiliateDash 完整 ✅
- 未命中映射的商品 → 不选 affiliate_dash，回退 ✅

**验证**：前后端 typecheck 通过；后端全量 212 测试通过。

---

## 18. 阶段 4 落地记录（v2.4 · 已完成）

产出文件：

**后端（`apps/backend/src/`）**：

| 文件 | 内容 |
| --- | --- |
| `services/claim/kuaishou-cloud-claim-context.ts` | `buildClaimDetailPayload` 加 `affiliate_dash` 分支 + `buildAffiliateDashClaimDetailPayload`（flowType/affiliateDash 块/product/task/order/orderItem/result） |
| `services/claim/kuaishou-cloud-claim-service.ts` | `getKuaishouCloudClaimDetail` affiliate_dash 分支：`syncAffiliateDashTaskStatus` + `refreshAffiliateDashBindState`（有 bindUuid 时拉 bind-result 实时刷新 bound/账号/mismatch，可重入）；`submitClaimUid` affiliate_dash 分支 → `bindAffiliateDashClaimForTask`（bind 拿 bindUuid/二维码，task → WAITING_BINDING）；新增 `submitAffiliateDashClaim`（submit → REDEEMING + task event） |
| `services/fulfillment/affiliate-dash/index.ts` | `AffiliateDashFlow` 加 `bound/boundAccount/gameChannel` 字段（bind-result 状态） |
| `routes/claims.ts` | 新增 `POST /:token/affiliate-dash/submit`（claimWriteRateLimit） |

**前端（`apps/frontend/src/`）**：

| 文件 | 内容 |
| --- | --- |
| `types/claim.ts` | `ClaimAffiliateDashFlowInfo` + `ClaimDetailData.affiliateDash` |
| `services/claim.ts` | `submitAffiliateDashClaim(token, {gameAccount, bindUuid})` |
| `pages/claim/claim-snapshot.ts` | `isAffiliateDashFlow`、hasRedeemResult/currentStep/progressText/resultTitle/resultDescription 的 affiliate_dash 分支（step: 无UID=1/绑定中=2/已绑定待提交=3/结果=4） |
| `pages/claim/claim-poll.ts` | affiliate_dash 轮询（未绑定用 BINDING_PREPARE_POLL_MS，已绑定用 FEIFEI_POLL_MS） |
| `pages/claim/ClaimAffiliateDashSteps.tsx`（新） | `AffiliateDashClaimPanel`（二维码/绑定链接、bound 状态、mismatch 警告、提交发货按钮）+ `AffiliateDashResultStep` |
| `pages/claim/ClaimPage.tsx` | 分发分支 + `handleSubmitAffiliateDash` + `openBindUrl`/二维码生成支持 affiliateDash.bindUrl |

**验证**：前后端 typecheck 通过；后端全量 212 测试通过；claim 详情分流脚本验证（flowType=affiliate_dash、affiliateDash 块完整、product 正确、其他 flow 为 null）。

**领取闭环状态迁移（与 §8 状态机一致）**：输 UID → submitClaimUid（bind 触发，task `link_generated → waiting_binding`）→ 轮询详情（bind-result 刷新 bound）→ 提交发货（submitAffiliateDashClaim，task `waiting_binding → redeeming`）→ 回调 delivered → redeemed + 核销。

---

## 19. 核销时机优化（v2.4.1 · 已完成）

**需求**：核销时机从「delivered 回调」提前到「给用户链接（91 卡信息到手、建单成功）」——参考 kuaishou-feifei 的「给用户链接即核销」模式。

**改动**（`apps/backend/src/services/fulfillment/affiliate-dash/index.ts`）：

| 位置 | 行为 |
| --- | --- |
| `prepareAffiliateDashTask` | 建单成功（orderNo 拿到、task → link_generated、91 侧即可取 claimUrl）后**立即**调用 `consumeKuaishouIndustryVouchersForTask`：有凭证 → 核销并写回 `kuaishouIndustryVoucher` 上下文（`consumeStatus=success`）；无凭证 → `not_required`；**核销失败不阻塞**下单与链接发放（`consumeStatus=failed` + warn 日志，delivered 兜底） |
| `syncAffiliateDashTaskStatus`（delivered 分支） | `alreadyConsumed` 短路：`consumeStatus ∈ {success, not_required}` 时不再重复调核销，直接 redeemed；`failed/空` 才补核销（原逻辑保留为兜底） |

**效果**：核销由「发货完成后（分钟~小时级）」提前到「建单成功（秒级）」；91 卡信息一经消耗即核销，delivered 回调只做状态收敛。已核销成功的凭证在 delivered 时 `resolveTaskVouchers` 返回空，自然走 redeemed。

**验证**：typecheck 通过；后端全量 212 测试通过。

---

## 20. 阶段 5 落地记录（v2.5 · 已完成）

**回调路由打通**：挂载 `POST /api/v1/open/affiliate-dash`（与线上回调 URL `https://ks.khhao.com/api/v1/open/affiliate-dash` 一致）。

| 文件 | 内容 |
| --- | --- |
| `db/migrations/013_affiliate_dash_webhook_events.sql` | `webhook_events` 表 + `(provider, event_id)` 唯一索引（回调幂等去重） |
| `repositories/webhook-event-repo.ts` | `insertWebhookEventOnce`：`ON CONFLICT (provider, event_id) DO NOTHING`，冲突返回 `inserted=false` |
| `repositories/task-repo.ts` | `findAffiliateDashTaskByOrder`：按 `context_json #>> '{affiliateDash,orderNo}'` / `clientOrderNo` 查任务（仿 `findKuaishouFeifeiTaskByOrder` 同构 SQL） |
| `services/platforms/affiliate-dash/notify-service.ts` | `handleAffiliateDashNotify`：验签（X-Timestamp 容差→重算签名）→ 查任务（未匹配 404 触发重试）→ 幂等闸门（重复 eventId 直接 200 跳过）→ `createTaskEvent('affiliate_dash_notify_received')` → `syncAffiliateDashTaskStatus`（状态合并式可重入） |
| `routes/affiliate-dash.ts` | `POST /`，rate limit 300/min，`req.rawBody` 验签（express.json verify 已存），全链路日志 |
| `app.ts` | 挂载 `/api/v1/open/affiliate-dash` |

**处理顺序设计**：去重闸门放在「找到任务」之后 —— 任务未建/已删时返回 404 让对方重试，而不是被 eventId 去重永久拦截；找到任务后冲突才视为重复投递直接 200。

**验证**：typecheck 通过；后端全量 212 测试通过；脚本验证前置错误分支——缺签名头 401 / 时间戳超容差 401 / 验签失败 401 / 缺订单号 400 全部正确；「任务不存在→404」及幂等/同步路径依赖真实 DB，留待阶段 6 端到端演练（本沙箱无 postgres）。

---

## 21. 履约匹配逻辑深度分析（v2.6）

### 21.1 匹配链路全貌

```
91 订单 item（ninetyone/order-service.ts:40-95）
  ├─ externalSkuCode = productNo      ← 91 商品编码（可能含 "----店铺ID" 后缀，parseOpen91ProductNo 拆分）
  ├─ externalSkuName = productName    ← 91 商品名
  ├─ snapshot.productNo = productNo
  └─ snapshot.productName = productName
        │
        ▼ resolveConfiguredItemCandidate（product-resolution-service.ts:212）
  ├─ cloudtentaclesMatch = 商品名归一化模糊匹配（网络 listSku）
  ├─ kuaishouFeifeiMatch = 商品名规则 contains
  └─ affiliateDashMatch  = skuMapping[productNo] 精确映射（resolveAffiliateDashSkuByProductNo）
        │
        ▼ resolveFulfillmentRoute（routing-config-service.ts:136）
  ① 商品路由规则 rules（exact/contains，按商品名）→ ② 全局优先级
     kuaishou_cloud → kuaishou_feifei → affiliate_dash → ③ manual_review 兜底
        │
        ▼ planner.resolveDynamicAffiliateDashProfile（planner.ts:397）
  snapshot.affiliateDash.sku → profile（configId: "affiliate_dash:<sku>"）
```

### 21.2 痛点清单

| # | 痛点 | 影响 | 证据 |
| --- | --- | --- | --- |
| P1 | **优先级与可信度倒挂**：affiliate_dash 是精确编码映射（运营显式配置、高可信），却排在 cloudtentacles / feifei 的**名字模糊匹配**之后 | 91 商品名若同时像 cloudtentacles 商品，会被 cloud 抢单走错通道；映射白配 | `DEFAULT_EXECUTOR_PRIORITY`（routing-config-service.ts:74-78）；阶段 3 联调能走对仅因当时 cloud/feifei 未命中 |
| P2 | **productNo 未拆分 `----` 后缀**：`resolveAffiliateDashSkuByProductNo` 直接用 `snapshot.productNo` 原值查映射，而 open-91 的 productNo 可能为 `商品名----店铺ID`（`parseOpen91ProductNo` 专门拆它，但该函数未进解析链） | 带后缀的 91 编码精确匹配 miss → 直接落 manual_review | product-resolution-service.ts:305-330；ninetyone/order-service.ts:30 |
| P3 | **匹配键无归一化**：skuMapping key 只做原样精确匹配，无 trim/大小写/变体处理 | 编码大小写变体（`x91-lucky-90` vs `X91-LUCKY-90`）miss | product-resolution-service.ts:317-318 |
| P4 | **缺名字兜底**：affiliate_dash 有 33 个商品（含 displayName），但匹配只有编码精确映射；cloudtentacles / feifei 都有名字匹配，唯独 affiliate_dash 没有 | 新 91 商品未配映射即 manual_review，无法像 feifei 那样按名字自动兜底 | product-service.ts:58-75（displayName 未被用于匹配） |
| P5 | **余额/健康度不参与路由决策**：affiliate_dash 候选 available 只看映射命中；余额不足时选中 → prepare 预检失败 → manual_review，不会自动切下一通道 | 单通道阻塞，无自动降级 | 阶段 3 仅在 prepare 前预检（getAffiliateDashWallet） |
| P6 | **双轨配置冲突**：路由规则（商品名→executor）与 skuMapping（编码→sku）两套独立配置，同一商品可能被两种规则重复描述、运营维护两处 | 配置漂移、规则打架 | routing-config-service.ts:159-177 vs product-resolution-service.ts:305-330 |
| P7 | **重复计算**：`hasConfiguredOrderItems`（下单前）与 `resolveOrderItemForFulfillment`（履约时）各跑一次 `resolveConfiguredItemCandidate`；cloudtentacles 名字匹配是**网络调用**（listSku） | 同一订单解析两次、重复拉商品列表 | product-resolution-service.ts:181-195；cloudtentacles-name-match-service.ts:111 |
| P8 | **命名语义误导**：`skuMapping`（productNo→sku）实际 key 是 **91 外部编码**、value 是 **affiliate_dash sku**；文档/代码里 productNo 一词混用两套体系 | 运营配置时易搞反 | product-resolution-service.ts:40-45；文档 §12 |

### 21.3 优化建议（分档）

- **快赢档（P1/P2/P3，改动集中 product-resolution-service.ts，低风险）**
  - P1：`resolveConfiguredItemCandidate` 中 affiliateDashMatch 精确命中时**直接优先选择 affiliate_dash**（映射命中视为运营显式意图），不受全局优先级影响；或把 `DEFAULT_EXECUTOR_PRIORITY` 调整为 affiliate_dash 前置。建议加开关「映射命中优先」（默认开）。
  - P2：productNo 先走 `parseOpen91ProductNo` 拆掉 `----店铺ID` 再用 productName 段查映射。
  - P3：映射查询前 trim + 大小写归一；可选支持 contains 前缀匹配。
- **中档（P4）**：affiliate_dash 商品 displayName 归一化名字匹配兜底（仿 cloudtentacles 匹配器），开关控制，避免误配。
- **后档（P5-P8）**：候选层余额/健康度（带缓存）、路由与映射配置合一、解析结果缓存去重、命名修正。

### 21.4 待验证点

- 91 卡券（91kaquan/kuaishou）真实 productNo 是否带 `----店铺ID` 后缀（决定 P2 是否为实际故障）。
- 33 个 affiliate_dash 商品 displayName 与 91 商品名的重合度（决定 P4 兜底收益）。

---

## 22. 履约匹配优化实施（v2.7 · 已完成）

**背景确认**：91 商品编码全部为**中文名**（如「幸运币90个」「星际漫游服装礼包」），无独立编码体系；运营按「中文名 → affiliate_dash sku」配置显式映射表（33 条，见 §22.3）。

### 22.1 匹配策略（两级）

1. **指定匹配（显式映射）最高优先**：skuMapping 命中（91 中文名 → affiliate_dash sku）即**强制走 affiliate_dash**，不受 cloudtentacles / feifei 名字匹配抢占、不受全局优先级影响。受通道 `enabled` 约束：affiliate_dash 停用或商品不可用时回退原逻辑（规则 → 全局优先级）。
2. **默认匹配（名字匹配）**：无指定匹配时，三平台按商品名匹配，按可配置优先级选择（`defaultExecutorPriority`，现状 cloud → feifei → affiliate_dash，管理后台可改顺序）。

### 22.2 改动清单

| 文件 | 改动 |
| --- | --- |
| `routing-config-service.ts` | `resolveFulfillmentRoute` 新增 `preferredExecutorKey` 参数：指定匹配在**规则匹配之前**优先尝试，命中即选；停用/不可用回退 |
| `product-resolution-service.ts` | 91 分支：映射命中且 `preferredMatchEnabled!==false` 时传 `preferredExecutorKey=AFFILIATE_DASH`；`matchAffiliateDashSku` 归一化匹配（NFKC 全角→半角、去空白、小写），先原样精确再归一化遍历 |
| 配置层 | `preferredMatchEnabled` 字段（默认 true）：types/runtime-config.ts、defaults.ts、source-config-service.ts、config.ts merge 全链 |
| 测试 | routing-config-service.test.ts +4（preferred 优先/停用回退/不可用回退/无 preferred 走优先级）；product-resolution-service.test.ts 新建 +5（精确/全角/空格/大小写/未命中） |

**安全性**：「不耽误生产」保证——现有 cloud/feifei 商品未配置映射表（或映射表为空），`preferredExecutorKey` 为空走原逻辑，行为零变化；映射表只影响显式配置的 91 商品，且可通过 `preferredMatchEnabled=false` 一键回退。

### 22.3 91 中文名 → affiliate_dash sku 映射表（可直接导入配置）

```json
{
  "套装-Alan Walker": "suit_alan_walker",
  "套装-暗影哥特": "suit_shadow_gothic",
  "黑色高级特训官上衣": "top_black_elite_trainer",
  "M416-仓鼠灰灰": "m416_hamster_gray",
  "萌熊伴侣背包": "bag_cute_bear",
  "套装-双彩绵绵": "suit_dual_fluffy",
  "套装-糯粉咩咩": "suit_pink_sheep",
  "套装-恋恋初桃": "suit_first_peach",
  "套装-浪漫天命": "suit_romantic_destiny",
  "西部牛仔大礼包": "pack_western_cowboy",
  "烟雾弹-糯粉咩咩": "smoke_pink_sheep",
  "破片手榴弹-糯粉咩咩": "frag_pink_sheep",
  "套装-仓鼠灰灰": "suit_hamster_gray",
  "套装-萌熊伴侣": "suit_cute_bear",
  "糯粉咩咩背包": "bag_pink_sheep",
  "糯粉咩咩头盔": "helmet_pink_sheep",
  "仓鼠灰灰背包": "bag_hamster_gray",
  "仓鼠灰灰头盔": "helmet_hamster_gray",
  "套装-西部谜踪": "suit_western_mystery",
  "国宝胖达头盔": "helmet_panda_treasure",
  "套装-胖达圆圆": "suit_panda_round",
  "套装-胖达团团": "suit_panda_tuan",
  "熔岩游骑兵礼包": "pack_lava_ranger",
  "套装-狂沙舞者": "suit_sand_dancer",
  "星际漫游服装礼包": "pack_star_roam_outfit",
  "星际漫游枪械礼包": "pack_star_roam_weapon",
  "套装-绵云熊熊": "suit_cloud_bear",
  "荣耀勋章2个": "honor_medal_x2",
  "荣耀勋章30个": "honor_medal_x30",
  "荣耀勋章90个": "honor_medal_x90",
  "幸运币2个": "lucky_coin_x2",
  "幸运币30个": "lucky_coin_x30",
  "幸运币90个": "lucky_coin_x90"
}
```

### 22.4 行为验证

用上述映射表模拟 91 商品名解析，全部符合预期：

| 输入 | executor | sku |
| --- | --- | --- |
| 幸运币90个 | affiliate_dash | lucky_coin_x90 |
| 星际漫游服装礼包 | affiliate_dash | pack_star_roam_outfit |
| 幸运币９０个（全角） | affiliate_dash | lucky_coin_x90 |
| 幸运币 90 个（带空格） | affiliate_dash | lucky_coin_x90 |
| 神秘新商品（未映射） | （无）回退 | — |

**验证**：typecheck 通过；后端全量 221 测试通过（新增 9 个）。

---

## 23. 透传优化：91 编码 == affiliate_dash sku 自动命中（v2.8 · 已完成）

**需求**：91 商品编码可直接设置为英文（= affiliate_dash sku，如 `suit_alan_walker`），映射表未命中时**自动透传**，无需在 order_site 维护映射表。

### 23.1 匹配层级（`resolveAffiliateDashSkuByProductNo`，改为 async）

1. **① 指定映射**（`matchAffiliateDashSku`）：skuMapping 精确/归一命中 → `matchMode='affiliate_dash_sku_mapping'`
2. **② 透传**（`matchAffiliateDashSkuPassthrough`）：映射未命中时，productNo 与 affiliate_dash 商品 sku 完全一致 → `matchMode='affiliate_dash_sku_passthrough'`
3. 两层任一命中都触发「指定匹配最高优先」（`preferredExecutorKey=affiliate_dash`），规则/全局优先级不抢占

**商品列表缓存**：`getAffiliateDashSkuSet()` 内存缓存 5 分钟（`listAllAffiliateDashProducts` 拉全量 33 个 sku）；拉取失败返回上次缓存或空集合——匹配 miss 走其他通道，**不阻塞下单**。

### 23.2 行为验证（线上商品列表）

| 输入（91 编码） | executor | sku | matchMode |
| --- | --- | --- | --- |
| `suit_alan_walker`（真实商品） | affiliate_dash | suit_alan_walker | affiliate_dash_sku_passthrough |
| `suit_cloud_bear`（真实商品） | affiliate_dash | suit_cloud_bear | affiliate_dash_sku_passthrough |
| `not_a_real_sku` | （无）回退 | — | — |

**验证**：typecheck 通过；后端全量 224 测试通过（透传纯函数测试 +3）。

---

## 24. affiliate-dash 配置位置重构（v2.9 · 已完成）

**需求**：affiliate_dash 平台配置原先杂糅在「履约配置」页（`AdminPlatformFulfillmentPage` 的 affiliate-dash tab），与履约路由/通道混在一起；项目已有独立的「平台配置」页（`AdminPlatformShopsPage`，按平台管理来源接入、凭据与店铺能力），应归位。

**改动**（纯前端 UI 位置移动，后端与数据通路零变化——后端路由与前端 API 本就挂在 `/api/v1/admin/platform-config/affiliate-dash/*`）：

| 文件 | 改动 |
| --- | --- |
| `AdminPlatformShopsPage.tsx` | 新增 `affiliateDash` tab（label「affiliate-dash」）；`AffiliateDashPlatformPanel` 组件整体迁入（自管理状态：对接配置/密钥/钱包/商品目录/映射表/映射测试），加载函数并入 `loadConfigs` 的 `Promise.all` |
| `AdminPlatformFulfillmentPage.tsx` | 移除 affiliate-dash tab、`AffiliateDashFulfillmentPanel` 组件及全部 affiliate 相关 import/类型 |

**页面归属**：
- **平台配置**（`AdminPlatformShopsPage`）：affiliate-dash、kuaishou-feifei、kuaishou-lewan、行业电子凭证、内部通知 —— 平台接入凭据
- **履约配置**（`AdminPlatformFulfillmentPage`）：履约路由、kuaishou-feifei 履约、kuaishou-lewan 履约 —— 通道路由与履约参数

**验证**：前端 `tsc -b --noEmit` 通过；后端未改动。

---

## 25. 履约配置页 affiliate-dash 覆盖分析（v2.10 · 已完成）

**需求**：确认履约配置页是否也需要 affiliate-dash 相关内容（账号归平台配置页）。

### 分析结论

| 配置项 | 归属 | 现状 |
| --- | --- | --- |
| 对接凭据（appKey/appSecret/callbackSecret/baseUrl/timeout/容差） | 平台配置 | ✅ 平台配置页 affiliate-dash tab |
| 钱包 / 商品目录 / 映射测试 | 平台配置 | ✅ 平台配置页 affiliate-dash tab |
| 91 编码 → sku 映射表 | 平台配置 | ✅ 平台配置页 affiliate-dash tab |
| **履约路由**：通道开关（affiliate_dash enabled） | 履约配置 | ✅ 履约路由面板已含（ROUTING_EXECUTORS） |
| **通道优先级**（defaultExecutorPriority 含 affiliate_dash，可上移/下移） | 履约配置 | ✅ 履约路由面板已含 |
| **商品路由规则**（商品名 → affiliate_dash） | 履约配置 | ✅ 履约路由面板规则编辑器已含 |
| **指定匹配优先开关**（preferredMatchEnabled） | 平台配置 | ⚠️ 新增字段未暴露 UI → **本次已补** |

**结论**：「账号在平台配置、路由在履约配置」的职责划分**已成立**——履约路由面板在阶段 3 就已纳入 affiliate_dash（通道开关/优先级/规则）。本次仅修复一个真实缺口：`preferredMatchEnabled`（映射命中优先，默认 true）此前只能靠默认值或环境变量，后台改不了；现已在平台配置 affiliate-dash tab 增加开关，前后端 effective/source 均透出。

**未新增**：履约配置页不加独立「affiliate-dash 履约面板」——feifei/lewan 有独立面板是因为履约参数多（productRules、deliveryItems 等），affiliate_dash 履约参数只有路由+优先级+规则，已覆盖，加面板属重复。

**验证**：前端 `tsc -b --noEmit` 通过；后端 typecheck 通过；224 测试全绿。

---

## 26. affiliate-dash 领取界面优化（v2.11 · 已完成）

**需求**：精简 affiliate_dash 领取页四步的信息展示。

| 步骤 | 改动 |
| --- | --- |
| 第 1 步 | 不动（与其他平台一致） |
| 第 2 步（绑定领取账号） | 信息区只保留三项：**affiliate-dash 单号 / 领取商品 / 金额**（移除「填写 UID」「订单号」tile）；二维码右侧「打开绑定链接」按钮**移除**（二维码直接展示） |
| 第 3 步（确认并提交发货） | 移除信息区（含三项），仅保留已绑定提示 + 提交按钮 |
| 第 4 步（结果页） | 移除信息区（领取商品/单号/订单状态/发货状态/金额全部移除），仅保留结果标题 + 描述 |

**实现**：`ClaimAffiliateDashSteps.tsx` —— `AffiliateDashClaimPanel` 移除 `onOpenBindUrl` prop 与按钮、info grid 精简为三项且仅在未绑定（第 2 步）显示；`AffiliateDashResultStep` 移除 info grid；`ClaimPage.tsx` 同步移除 `onOpenBindUrl` 传参（`openBindUrl` 函数保留，lewan 仍用）。

**附带修复**：`product-resolution-service.ts` 透传拉取失败 warn 日志补 `logIntegration` import（上一轮遗漏导致后端 typecheck 报错）。

**验证**：后端 typecheck 通过、224 测试全绿；前端 `tsc -b --noEmit` 通过。

---

## 27. 开发 Mock 支持 affiliate-dash（v2.12 · 已完成）

**需求**：开发 Mock 页新增 affiliate-dash 领取 mock，无需真实 affiliate-dash 平台即可走完领取页四步。

### 能力

后台「开发 Mock」新增 **3. affiliate-dash 领取**（5 个步骤可生成）：

| step | 领取页表现 |
| --- | --- |
| `uid` | 第一步：待填 UID（无二维码） |
| `bind` | 第二步：已填 UID + mock 绑定二维码（前端用 bindUrl 现生成） |
| `submitted` | 第三步：已绑定待「提交发货」（点击后 mock 短路直接模拟发货成功） |
| `completed` | 第四步：发货成功结果 |
| `failed` | 第四步：发货失败结果（ship_failed） |

### 实现

- **mock 标记**：`AffiliateDashFlow` 新增 `mock: { enabled, orderNo, createdAt } | null`（normalize 透传），dev-mock 创建任务时写入。
- **三处平台调用短路**（mock 任务不请求真实 affiliate-dash）：
  - `syncAffiliateDashTaskStatus`：直接用本地快照（flow 自身 orderStatus/failureReason）收敛状态，不走 `getAffiliateDashOrder`；
  - `refreshAffiliateDashBindState`（claim-service）：直接返回 task，不查 bind-result；
  - `submitAffiliateDashClaim`：直接置 `delivered` + `providerOrderNo=MOCKAD-*`，不调 `submitAffiliateDashDelivery`。
- **dev-mock-service**：`createAffiliateDashMockClaim`（仿 feifei：建 order/item/task + claim token + mock 上下文）；`getDevMockStatus` platforms 增补；`createBaseOrderItem.executor` 支持 `'affiliate_dash'`。
- **路由/前端**：`POST /api/v1/admin/dev-mock/affiliate-dash`；Mock 页新增 Card + `AffiliateDashMockForm`（step/uid/productName/productSku/orderNo）。

**验证**：后端 typecheck 通过、224 测试全绿；前端 `tsc -b --noEmit` 通过；`normalizeAffiliateDashFlow` mock 字段透传脚本验证通过（mock 保留 / 非 mock 为 null）。
