91kaquan 初步介入

This commit is contained in:
yml
2026-05-04 22:05:54 +08:00
parent f4cc2ce623
commit b120924263
12 changed files with 1273 additions and 3 deletions
+315
View File
@@ -0,0 +1,315 @@
# 91卡券接入开发设计
## 1. 目标
基于以下对外接口文档完成当前项目接入:
- [3.异步卡密下单.md](/Users/yml/codes/order-site-workspace/docs/91卡券/3.异步卡密下单.md:1)
- [4.查询订单接口.md](/Users/yml/codes/order-site-workspace/docs/91卡券/4.查询订单接口.md:1)
目标能力:
1. `91卡券` 调用我方异步下单接口;
2. 我方将订单接入当前项目,走现有快手 Cloud 履约链路;
3. 我方生成领取链接 `https://221329.cc.cd/#/claim/{token}`
4. `91卡券` 调用查询订单接口;
5. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。
## 2. 设计原则
- `91卡券` 作为**新的订单来源**,不复用 `agiso``khhao`
- `91卡券` 不是新的履约执行器,而是外部售卖和自动发货通道。
- 当前项目真正的交付物仍然是 `claimUrl`
- 查询接口返回 `20` 的判断标准是“领取链接已生成并可发”,不是“快手最终兑换完成”。
- `buyNum > 1` 时,必须拆成多个 task,并返回多个 card。
## 3. 来源建模
新增独立来源标识:
- `provider = '91kaquan'`
- `platform = 'kuaishou'`
- `shopId = '91kaquan'`
- `shopName = '91卡券'`
原因:
- 避免与 `agiso` webhook 语义混淆;
- 避免复用 `khhao` 的后台拉单来源;
- 便于后续单独做日志、排障和绑定配置。
## 4. 接口设计
### 4.1 路由
新增独立公开路由组:
- `POST /api/v1/open/91/orders/create`
- `POST /api/v1/open/91/orders/query`
不放进现有 `/api/v1/webhooks`,因为:
- `91` 不是 webhook 来源;
- `91` 需要自己的响应结构:`code/message/data`
- 与现有项目 `buildSuccessPayload(code=0)` 不兼容。
### 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'
}
}
```
建议环境变量:
- `NINETYONE_USER_ID`
- `NINETYONE_SECRET`
- `NINETYONE_VERSION`
- `NINETYONE_SHOP_ID`
- `NINETYONE_SHOP_NAME`
- `NINETYONE_TIMESTAMP_TOLERANCE_SECONDS`
- `NINETYONE_CARDS_ENCODING`
## 6. 签名设计
签名规则沿用文档约定:
1.`sign` 外,所有参数按 ASCII 升序排序;
2. 拼接为 QueryString
3. 前后加商户密钥;
4. 计算大写 MD5
5. `userId` 不在请求体中,但参与签名;
6. 空值参数参与签名。
需要实现:
- 生成签名原串;
- 验签;
- 校验 `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`
这样可直接复用现有:
- 商品匹配;
- 履约绑定;
- 快手 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](/Users/yml/codes/order-site-workspace/docs/91卡券/卡密加密说明.md:1) 实现:
- 算法:`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. 商品匹配设计
当前项目的订单商品匹配依赖:
- `provider`
- `platform`
- `shopId`
- `externalSkuCode` / `externalItemId` / `externalSkuName`
因此接入 `91` 后,运营侧需要新增对应绑定规则,使:
- `provider = '91kaquan'`
- `platform = 'kuaishou'`
- `shopId = '91kaquan'`
- `externalSkuCode = productNo`
最终匹配到现有快手 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/config/default.cjs`
- `apps/backend/src/config/runtime.js`
- `apps/backend/src/types/runtime-config.js`
- `apps/backend/src/index.js`
## 13. 测试范围
至少补这些单测:
1. 验签:
- 正常签名通过;
- 错误签名失败;
- 空值参数参与签名。
2. 下单:
- 业务请求映射为内部 source event
- 商品未配置时返回 `30`
3. 查询:
- 全部 task 都有链接时返回 `20`
- 部分 task 缺链接时返回 `10`
- 无订单时返回 `30`
4. cards
- 单卡编码正确;
- 多卡数量与 task 数量一致。
## 14. 当前阶段不做的事
- 不实现 `callbackUrl` 主动回调;
- 不在 `91` 查询成功后回写快手侧最终兑换结果;
- 不实现除 `aes-256-ecb-base64` 之外的卡密加密算法;
- 不新增专门的后台管理页面。
+124
View File
@@ -0,0 +1,124 @@
### 简要描述:
- AES/加密模式ECB/填充PKCS7Padding/数据块128位 <font color='red'>注意:加密密钥为开放平台的AppSecret(从<a href='https://open.agiso.com/#/my/application/app-list' target='_blank'>open.agiso.com</a>上查看)</font>
**假设cards为:**
```
[{"cardNo":"10001","cardPwd":"123456","expireTime":"2023-06-19 17:16:01"}]
```
**假设开放平台的appSecret为:** <font color='red'>0a091b3aa4324435aab703142518a8f7</font>(从<a href='https://open.agiso.com/#/my/application/app-list' target='_blank'>open.agiso.com</a>上查看)
### .NET加密示例:
```csharp
var encryptCards = AES.EncryptAes(cards, appSecret);
//加密后的数据
encryptCards = "34APdvSJhWfr5wnzE4YzxAsI0gfI6pI/Njrj1UQMHJrZ8Dq9EWsDnln0pp08gn4KES4iQu9/f0Y8UMN4dGdSK67oi0Bc12/V8hrNxqoC3Lk=";
public class AES
{
/// <summary>
/// 获取Aes32位密钥
/// </summary>
/// <param name="key">Aes密钥字符串</param>
/// <returns>Aes32位密钥</returns>
static byte[] GetAesKey(string key)
{
if (string.IsNullOrEmpty(key))
{
throw new ArgumentNullException("key", "Aes密钥不能为空");
}
if (key.Length != 32)
{
throw new ArgumentNullException("key", "Aes密钥长度必须是32个字符");
}
return Encoding.UTF8.GetBytes(key);
}
/// <summary>
/// Aes加密
/// </summary>
/// <param name="source">源字符串</param>
/// <param name="key">aes密钥</param>
/// <returns>加密后的字符串</returns>
public static string EncryptAes(string source, string key)
{
try
{
using (AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider())
{
aesProvider.Key = GetAesKey(key);
aesProvider.Mode = CipherMode.ECB;
aesProvider.Padding = PaddingMode.PKCS7;
using (ICryptoTransform cryptoTransform = aesProvider.CreateEncryptor())
{
byte[] inputBuffers = Encoding.UTF8.GetBytes(source);
byte[] results = cryptoTransform.TransformFinalBlock(inputBuffers, 0, inputBuffers.Length);
return Convert.ToBase64String(results, 0, results.Length);
}
}
}
catch (Exception e)
{
return string.Empty;
}
}
}
```
### Java示例
```java
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.Security;
import java.util.Base64;
public class Main {
static {
Security.addProvider(new BouncyCastleProvider());
}
/**
* AES/ECB/PKCS7Padding 加密
*
* @param plainText 明文字符串
* @param secretKey 密钥(32 字符)
* @return 加密后的 Base64 字符串
* @throws Exception 加密过程中可能抛出的异常
*/
public static String encrypt(String plainText, String secretKey) throws Exception {
if (secretKey.length() != 32) {
throw new IllegalArgumentException("密钥长度必须为 32 个字符");
}
byte[] keyBytes = secretKey.getBytes("UTF-8");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
// 打印密钥信息
System.out.println("使用的密钥 (Base64): " + Base64.getEncoder().encodeToString(keyBytes));
System.out.println("密钥长度 (字节): " + keyBytes.length);
// 创建加密实例,使用 AES/ECB/PKCS7Padding 模式
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS7Padding");
System.out.println("使用的加密提供者: " + cipher.getProvider());
// 初始化加密模式
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
// 执行加密并返回 Base64 编码结果
byte[] encryptedBytes = cipher.doFinal(plainText.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public static void main(String[] args) throws Exception {
String plainText = "[{\"cardNo\":\"10001\",\"cardPwd\":\"123456\",\"expireTime\":\"2023-06-19 17:16:01\"}]";
String secretKey = "0a091b3aa4324435aab703142518a8f7";
String encrypted = encrypt(plainText, secretKey);
System.out.println("最终加密结果: " + encrypted);
}
}
```