- 91 商品编码全部为中文名(幸运币90个等),运营按中文名→affiliate_dash sku 配置显式映射 - resolveFulfillmentRoute 新增 preferredExecutorKey:映射命中强制优先(规则之前),停用/不可用回退原逻辑 - matchAffiliateDashSku 归一化:NFKC 全角→半角、去空白、小写,先精确再遍历 - preferredMatchEnabled 配置(默认 true,可一键回退,不耽误现有 cloud/feifei 生产) - 测试 +9(221 全绿);行为验证:映射命中/全角/空格命中,未映射回退
47 KiB
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_dashexecutor,复用一个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):
app_key=<app_key>&body_sha256=<sha256hex(body)>&method=<GET|POST>&nonce=<nonce>&path=<仅路径>×tamp=<unix秒>
规则:
- 参数按 ASCII 字典序排序(固定为上面顺序);
- value 原样拼接不做 URL encode;GET 请求 body 为空字符串(
body_sha256为空串的 sha256); X-Sign = hex(HMAC-SHA256(secret, 签名字符串)),小写。
TypeScript 参考:
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
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)
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(统一结构):
{
"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 处理要求:
- 验签(见 3.2);
- 按
X-Event-ID/event_id幂等(DB 唯一键或去重表); order_status→ task 状态迁移(见下);- 尽快返回 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:
{
"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.tsrandomId('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 现无独立去重表 —— 建议 migration013_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 前缀匹配。
- P1:
- 中档(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 匹配策略(两级)
- 指定匹配(显式映射)最高优先:skuMapping 命中(91 中文名 → affiliate_dash sku)即强制走 affiliate_dash,不受 cloudtentacles / feifei 名字匹配抢占、不受全局优先级影响。受通道
enabled约束:affiliate_dash 停用或商品不可用时回退原逻辑(规则 → 全局优先级)。 - 默认匹配(名字匹配):无指定匹配时,三平台按商品名匹配,按可配置优先级选择(
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 映射表(可直接导入配置)
{
"套装-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 |
| 幸运币90个(全角) | affiliate_dash | lucky_coin_x90 |
| 幸运币 90 个(带空格) | affiliate_dash | lucky_coin_x90 |
| 神秘新商品(未映射) | (无)回退 | — |
验证:typecheck 通过;后端全量 221 测试通过(新增 9 个)。