# 91卡券接入开发设计

## 1. 目标

基于以下对外接口文档完成当前项目接入：

- [3.异步卡密下单.md](./3.异步卡密下单.md)
- [4.查询订单接口.md](./4.查询订单接口.md)

目标能力：

1. `91卡券` 调用我方异步下单接口；
2. 我方将订单作为独立接入平台订单写入当前项目；
3. 已配置商品自动走现有快手 Cloud 履约链路；
4. 未配置商品进入后台待补全队列，可手动补齐履约信息后重试；
5. 我方生成领取链接 `https://你的域名/#/claim/{token}`；
6. `91卡券` 调用查询订单接口；
7. 我方在 `cards` 中返回领取链接，由 `91卡券` 自动发货给买家。

## 2. 设计原则

- `91卡券` 作为**独立订单来源**。
- `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: 'base64json'
  }
}
```

建议环境变量：

- `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 商品名到 cloudtentacles 商品名的匹配；
- cloudtentacles 覆盖规则（套装、多数量发货）；
- 快手 Cloud task 创建；
- claim token 生成。

### 7.4 成功判定

下单接口成功判定标准：

- 请求合法；
- 订单已成功写入；
- 商品已匹配时至少创建出 1 个 task；
- 商品未匹配时进入待补全队列。

满足以上条件即返回：

- `orderStatus = 10`

不在下单接口返回 `20`。

## 8. 查询设计

### 8.1 查询目标

查询接口的职责不是看订单是否最终兑换完成，而是判断是否已经具备“可自动发货给买家”的内容。

这里的可交付内容就是 `claimUrl`。

### 8.2 claimUrl 判定

对订单下的每个 task：

1. 优先读取 `primary_claim_token`；
2. 若没有，则读取 `claim_token`；
3. 若仍没有，且是 `kuaishou_ct_assisted`，调用现有 `ensureTaskClaimLink(task)` 补生成；
4. 用 `buildClaimUrl(token)` 组装最终链接。

### 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` 当作商品名，匹配 cloudtentacles 商品列表里的同名商品；
- 如果 `productNo` 使用 `商品名----店铺编号` 格式，前半段用于商品匹配，后半段作为快手小店 `shopId` 用于核销；
- 特殊商品通过 cloudtentacles 覆盖规则处理，例如一个 91 商品发多个 cloudtentacles 商品，或某个商品发多次。

最终目标是让 91 卡券只传商品名，系统按商品名和覆盖规则自动创建快手 Cloud 履约任务。

## 11. 日志与排障

建议所有 `91` 请求单独打日志，至少记录：

- requestId
- orderNo
- productNo
- query/create
- 验签结果
- 命中 SKU
- orderId
- task 数量
- 最终返回的 `orderStatus`

## 12. 代码落点

建议新增：

- `apps/backend/src/routes/open-91.js`
- `apps/backend/src/services/open-91/shared.js`
- `apps/backend/src/services/open-91/order-create-service.js`
- `apps/backend/src/services/open-91/order-query-service.js`

需要修改：

- `apps/backend/src/config/defaults.ts`
- `apps/backend/src/config/runtime.js`
- `apps/backend/src/types/runtime-config.js`
- `apps/backend/src/index.js`

## 13. 测试范围

至少补这些单测：

1. 验签：
   - 正常签名通过；
   - 错误签名失败；
   - 空值参数参与签名。
2. 下单：
   - 业务请求映射为内部 source event；
   - 商品未配置时返回 `10`，并进入后台待补全队列。
3. 查询：
   - 全部 task 都有链接时返回 `20`；
   - 部分 task 缺链接时返回 `10`；
   - 无订单时返回 `30`。
4. cards：
   - 单卡编码正确；
   - 多卡数量与 task 数量一致。

## 14. 当前阶段不做的事

- 不实现 `callbackUrl` 主动回调；
- 不在 `91` 查询成功后回写快手侧最终兑换结果；
- 不实现除 `aes-256-ecb-base64` 之外的卡密加密算法；
- 不新增独立顶级菜单，后台入口复用“平台配置 -> 91卡券接入”。
