# 91卡券接入开发设计 ## 1. 目标 > 来源口径(2026-10-17):`91卡券` 是 **`kuaishou-91` 来源的订单骨架通道**;快手 send-code 是同一来源的数据补全与兜底通道。二者共享匹配上下文与店铺键空间,**不能拆成两个来源或两个适配器**。 基于以下对外接口文档完成当前项目接入: - [3.异步卡密下单.md](./3.异步卡密下单.md) - [4.查询订单接口.md](./4.查询订单接口.md) 目标能力: 1. `91卡券` 调用我方异步下单接口; 2. 我方将订单作为独立接入平台订单写入当前项目; 3. 已配置商品自动走现有 affiliate-dash 履约链路; 4. 未配置商品进入后台待补全队列,可手动补齐履约信息后重试; 5. 我方生成领取链接 `https://你的域名/#/claim/{token}`; 6. `91卡券` 调用查询订单接口; 7. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。 ## 2. 设计原则 - `91卡券` 是 `kuaishou-91` 来源的**订单骨架通道**,不是新的履约执行器;快手 send-code 是同来源的补全/兜底通道。 - 外部售卖、订单输入和自动发货通道仍由 91 承担;匹配层的来源键统一为 `kuaishou-91`。 - 当前项目真正的交付物仍然是 `claimUrl`。 - 查询接口返回 `20` 的判断标准是“领取链接已生成并可发”,不是“快手最终兑换完成”。 - `buyNum > 1` 时,必须拆成多个 task,并返回多个 card。 - 未命中履约配置时不丢单,先返回 `10` 并沉淀到后台待补全队列。 ## 3. 来源建模 新增独立来源标识: - `provider = '91kaquan'` - `platform = 'kuaishou'` - `shopId = '91kaquan'` - `shopName = '91卡券'` 原因: - 避免与其他平台回调语义混淆; - 避免复用历史后台拉单来源; - 便于后续单独做日志、排障和绑定配置。 ## 4. 接口设计 ### 4.1 路由 新增独立公开路由组: - `POST /api/v1/open/91/orders/create` - `POST /api/v1/open/91/orders/query` 接口使用独立公开路由组,原因: - `91` 需要自己的响应结构:`code/message/data`; - 与后台管理接口的统一响应结构隔离; - 便于按 91 的签名、时间戳和错误码规范单独演进。 ### 4.2 返回格式 对 `91` 路由统一返回: ```json { "code": 200, "message": "接口调用成功", "data": {} } ``` 业务失败也返回 `data.orderStatus = 30` 的业务响应。 仅在真正的协议级错误下返回: ```json { "code": 400, "message": "验签失败", "data": null } ``` ## 5. 配置设计 新增运行时配置项: ```js platforms: { ninetyone: { userId: '', secret: '', version: '1.0', shopId: '91kaquan', shopName: '91卡券', timestampToleranceSeconds: 600, cardsEncoding: 'aes-256-ecb-base64' } } ``` 建议环境变量: - `KAQUAN91_USER_ID` - `KAQUAN91_SECRET` - `KAQUAN91_VERSION` - `KAQUAN91_SHOP_ID` - `KAQUAN91_SHOP_NAME` - `KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS` - `KAQUAN91_CARDS_ENCODING` ## 6. 签名设计 签名规则沿用文档约定: 1. 仅使用请求 JSON body 中实际传入的参数参与签名; 2. 除 `sign` 外,所有 body 参数按 ASCII 升序排序; 3. 拼接为 QueryString; 4. 前后加商户密钥; 5. 计算大写 MD5; 6. 已传入的空值参数参与签名; 7. 不额外加入 `userId`、商户号、内部配置项或未传入的可选字段。 需要实现: - 生成签名原串; - 验签; - 校验 `timestamp` 是否超出容忍窗口。 ## 7. 下单设计 ### 7.1 输入映射 `91` 下单请求映射为内部 source event: - `platformOrderId = orderNo` - `provider = '91kaquan'` - `platform = 'kuaishou'` - `shopId = runtimeConfig.platforms.ninetyone.shopId` - `shopName = runtimeConfig.platforms.ninetyone.shopName` - `payStatus = 'paid'` - `orderStatus = 'paid'` - `items = [{ externalSkuCode: productNo, externalItemId: productNo, externalSkuName: productNo, quantity: buyNum }]` ### 7.2 为什么直接标记 paid `91` 调用异步卡密下单接口时,业务上已经代表买家付款完成,当前项目需要立即进入履约任务创建。 ### 7.3 对现有链路的复用 调用现有: - `upsertOrderFromSource` - `resolveOrderItemForFulfillment` - `syncDeliveryTasksForOrder` 这样可直接复用现有: - 91 商品名到 affiliate-dash sku 的匹配(`resolveAffiliateDashSkuByProductNo`,含 sku 透传兜底); - affiliate-dash 履约 task 创建; - 按数量拆 task(`delivery-task-service.ts` 按 `unit_index` 为每个数量各建一个 task); - claim token / 领取链接生成。 ### 7.4 成功判定 下单接口成功判定标准: - 请求合法; - 订单已成功写入; - 商品已匹配时至少创建出 1 个 task; - 商品未匹配时进入待补全队列。 满足以上条件即返回: - `orderStatus = 10` 不在下单接口返回 `20`。 ## 8. 查询设计 ### 8.1 查询目标 查询接口的职责不是看订单是否最终兑换完成,而是判断是否已经具备“可自动发货给买家”的内容。 这里的可交付内容就是 `claimUrl`。 ### 8.2 claimUrl 判定 对订单下的每个 task,调用 `resolveTaskDeliveryLink(task)`(`apps/backend/src/services/fulfillment/delivery-link-service.ts`),由该 task 对应的履约执行器解析可交付链接: 1. 读取 `primary_claim_token`,为空则回退 `claim_token`; 2. 同时读取 `primary_claim_expires_at` / `claim_expires_at` 作为过期时间; 3. 有 token 才返回 `claimUrl = buildClaimUrl(token)`,否则返回 `null`(视为暂不可交付)。 说明:`kuaishou_ct_assisted`(乐玩)/ kuaishou-feifei 通道已下线,其历史 task 由只读 stub 处理,不再生成领取链接;领取链接现由 affiliate-dash 执行器的 `resolveDeliveryLink` 提供。 ### 8.3 查询状态判定 - `30`: - 订单不存在; - 没有匹配到任务; - task 进入失败/人工处理态且无法生成领取链接。 - `10`: - 订单存在; - 商品履约配置尚未补齐; - 任务已创建; - 但并非所有 task 都已准备好 `claimUrl`。 - `20`: - 所有 task 都已有可交付的领取链接。 ## 9. cards 设计 ### 9.1 card 字段映射 每个 task 映射为一个 card: - `cardNo = claimUrl` - `cardPwd = ''` - `expireTime = claim_expires_at` - `jumpLink = claimUrl` ### 9.2 编码方案 按 [卡密加密说明.md](./卡密加密说明.md) 实现: - 算法:`AES` - 模式:`ECB` - 填充:`PKCS7Padding` - 数据块:`128 位` - 输出:`Base64` - 密钥:开放平台 `AppSecret`,长度固定 `32` 个字符 Node 侧实现等价为: - `aes-256-ecb` - 开启自动填充 - 明文为 `JSON.stringify(cards)` - 输出 Base64 字符串 编码层仍然保持独立模块,后续如果联调方要求兼容别的编码方式,只替换该模块即可。 ### 9.3 多数量处理 若 `buyNum = N`: - 内部创建 `N` 个 task; - 查询成功时返回 `N` 个 card; - 每个 card 对应一个独立领取链接。 ## 10. 商品匹配设计 当前项目的 `91` 下单流程直接使用 `productNo` 做履约匹配: - 默认把 `productNo` 当作商品名(中文),经 affiliate-dash 的 SKU 映射表(`apps/backend/src/services/platforms/affiliate-dash/sku-mapping.ts`)转成 affiliate-dash 商品的英文 sku; - 若映射表未命中,再用 `productNo` 与 affiliate-dash 商品列表的 sku 做透传兜底匹配(`matchAffiliateDashSkuPassthrough`); - 如果 `productNo` 使用 `商品名----店铺编号` 格式,前半段作为商品名匹配、后半段作为快手小店 `shopId`(`parseOpen91ProductNo`); - 匹配成功后由履约路由选中 affiliate-dash 执行器,多数量在 `delivery-task-service.ts` 里按数量拆成多个 task。 最终目标是让 91 卡券只传商品名,系统按 SKU 映射与透传兜底自动创建 affiliate-dash 履约任务。 ## 11. 日志与排障 建议所有 `91` 请求单独打日志,至少记录: - requestId - orderNo - productNo - query/create - 验签结果 - 命中 SKU - orderId - task 数量 - 最终返回的 `orderStatus` ## 12. 代码落点 建议新增: - `apps/backend/src/routes/open-91.ts`(挂载于 `apps/backend/src/app.ts:103`,前缀 `/api/v1/open/91`) - `apps/backend/src/services/open-91/config.ts` - `apps/backend/src/services/open-91/payload.ts` - `apps/backend/src/services/open-91/crypto.ts` - `apps/backend/src/services/open-91/response.ts` - `apps/backend/src/services/open-91/order-create-service.ts` - `apps/backend/src/services/open-91/order-query-service.ts` - `apps/backend/src/services/open-91/consume-on-deliver.ts` 需要修改: - `apps/backend/src/config/defaults.ts` - `apps/backend/src/config/runtime.ts` - `apps/backend/src/config/env-overrides.ts` - `apps/backend/src/types/runtime-config.ts` - `apps/backend/src/services/platforms/ninetyone/order-service.ts` - `apps/backend/src/services/fulfillment/product-resolution-service.ts` - `apps/backend/src/index.ts` ## 13. 测试范围 至少补这些单测: 1. 验签: - 正常签名通过; - 错误签名失败; - 空值参数参与签名。 2. 下单: - 业务请求映射为内部 source event; - 商品未配置时返回 `10`,并进入后台待补全队列。 3. 查询: - 全部 task 都有链接时返回 `20`; - 部分 task 缺链接时返回 `10`; - 无订单时返回 `30`。 4. cards: - 单卡编码正确; - 多卡数量与 task 数量一致。 ## 14. 当前阶段不做的事 - 不实现 `callbackUrl` 主动回调; - 不在 `91` 查询成功后回写快手侧最终兑换结果; - 不实现除 `aes-256-ecb-base64` 之外的卡密加密算法; - 不新增独立顶级菜单;91 待补全订单目前只有后端管理接口(`/api/v1/admin/platform-config/ninetyone/orders` 查询、`.../:id/retry` 重试、`.../:id/fail` 标记失败),前端暂无专属页面。