# 快手电子凭证对接 技术方案

## 一、对接流程总览

```
┌────────────────────────────────────────────────────────────────┐
│                        五大阶段                                 │
├──────────┬──────────┬──────────┬──────────┬────────────────────┤
│ 一、入驻  │ 二、测试  │ 三、上线  │ 四、验证  │ 五、运维            │
│ 运营拉群  │ 技术评估  │ 提交数据  │ 功能验证  │ Token 管理           │
│ 发文档    │ 创建应用  │ 发布配置  │ 性能验证  │ 退款处理            │
│          │ 联调     │          │          │ 问题排查            │
└──────────┴──────────┴──────────┴──────────┴────────────────────┘
```

| 阶段 | 操作方 | 关键动作 |
|------|--------|---------|
| 入驻 | 快手运营 + 商家 | 业务评估、拉群、获取 appKey/appSecret/signSecret |
| 测试 | 商家技术 + 快手技术 | 技术评估、创建应用、白名单、接口对接、联调 |
| 上线 | 商家 + 快手技术 | 提交测试用例、快手审核、发布配置 |
| 验证 | 商家 | 功能验证、性能验证（100 TPS / 500 QPS） |
| 运维 | 商家 | Token 刷新（48h 过期）、退款、日常问题排查 |

## 二、完整业务链路

### 链路 1：正常购买 → 发码 → 核销

```
买家下单快手
    │
    ▼
快手通知商家发码 (send-code)        ← 商家接收
  ├─ oid, num, token, certExpireType, certActualStart/End
  └─ result=1 收单成功
    │
    ▼
商家发码回调 快手 (eticket/send)     ← 商家主动
  ├─ oid, etickets[{id,num,validStart/End}], token
  └─ result=1 或 result=2004「订单不存在」= 通过
    │
    ▼
快手查询发码结果 (query-code)        ← 快手轮询
  ├─ oid, eticketId(可选), sendType
  └─ result=1, etickets[{status:UNUSED|CONSUMED|DESTROYED}]
    │
    ▼
买家到店核销 / 线上核销
    │
    ▼
快手通知核销 (consume-code)          ← 商家接收
  ├─ oid, etickets[{id,num}], status, consumeType
  ├─ storeName/Address, seriallNum
  └─ result=1 成功
    │
    ▼
商家核销回调 快手 (eticket/consume)   ← 商家主动
  ├─ oid, etickets[{id,num,goodsValue}], status, consumeType
  ├─ token, seriallNum, consumeTime
  └─ result=1 或 result=2004 = 通过
```

### 链路 2：卡券过期销毁

```
卡券有效期到期（商家侧判断或快手通知）
    │
    ▼
快手通知销毁 (destroy-code)         ← 商家接收
  ├─ oid, reason=ETICKET_EXPIRED
  ├─ etickets[{id,num,goodsValue}] (可选)
  └─ result=1 (不存在的订单也返回成功！)
    │
    ▼
商家销毁回调 快手 (eticket/destroy)  ← 商家主动
  ├─ oid, etickets[{id,num,goodsValue}], reason=ETICKET_EXPIRED
  └─ result=1 或 result=2004 = 通过
```

### 链路 3：用户申请退款

```
买家在快手申请退款
    │
    ▼
快手通知销毁 (destroy-code)         ← 商家接收
  ├─ oid, reason=USER_APPLY_REFUND
  ├─ etickets[{id,num,goodsValue}]
  └─ result=1 (订单不存在也返回成功！)
    │
    ▼
商家销毁回调 快手 (eticket/destroy)  ← 商家主动
  ├─ oid, etickets[{id,num,goodsValue}], reason=USER_APPLY_REFUND
  └─ result=1
```

## 三、接口协议规范

### 3.1 双向接口清单

| 方向 | 数量 | 接口 | 路径 |
|------|------|------|------|
| 快手 → 商家 | 4 个 | 通知发码、查询发码、通知销毁、通知核销 | `/api/v1/open/kuaishou-industry/*` |
| 商家 → 快手 | 3 个 | 发码回调、核销回调、销毁回调 | `openapi.kwaixiaodian.com/integration/callback/virtual/eticket/*` |

### 3.2 签名算法（双向统一）

```
签名步骤：
1. 收集所有参数（appkey, method, version, param, timestamp, signMethod, access_token）
2. 排除 sign 和 signSecret
3. 按 key 字典序排序
4. 用 & 拼接 key=value
5. 末尾追加 &signSecret=xxx
6. MD5( whole_string ) 或 HMAC_SHA256 → sign
```

```json
// 签名源码示例
"access_token=xxx&appkey=ks660621772091030245&method=integration.callback.virtual.eticket.send&param={\"oid\":\"2319200000001216\",...}&signMethod=MD5&timestamp=1782966971471&version=1&signSecret=OZR3AUW2..."
```

**注意**：param 内 JSON 对象的 key 也需要字典序排序。

### 3.3 错误码规范

| 错误码 | 含义 | 触发场景 |
|--------|------|---------|
| 1 | 成功 | 正常返回 |
| 4010003 | 系统异常/拒单 | 签名错误、参数缺失、appkey 不匹配 |
| 4012002 | 订单不存在 | 查询/核销不存在的订单 |
| 4012005 | 卡券不存在 | 查询不存在的卡券 ID |

### 3.4 卡券状态流转

```
                  ┌─→ CONSUMED (已核销)
                  │
UNUSED (未使用) ──┼─→ DESTROYED (已销毁)
                  │
                  └─→ CANCELLED (订单关闭)
```

## 四、对接中遇到的问题和注意事项

### 4.1 签名问题

| 问题 | 原因 | 解决 |
|------|------|------|
| "签名验证失败" | param JSON 内部 key 未排序 | JSON.stringify 时按 key 排序 |
| "签名验证失败" | signSecret 夹带在请求参数中 | signSecret 只用于签名计算，不能传给快手 |
| "签名验证失败" | param 字段 encode 方式不一致 | 先签名再 URL encode，不是先 encode 再签名 |
| param 为空 | GET 请求 URL 过长被截断 | POST 请求 param 放 body 里 |

### 4.2 参数问题

| 问题 | 原因 | 解决 |
|------|------|------|
| "需要纯数字oid" | oid 传入了含字母的测试值 | oid 必须是快手生成的纯数字订单号（如 `2319200000001216`） |
| "appkey 不匹配" | 测试/生产环境 appKey 混用 | 两套环境 appKey 独立，不可混用 |
| "缺少 oid" | 必填参数缺失 | 校验每个接口的必填参数 |
| eticketId 类型不匹配 | 传字符串 vs 数据库 Long | 统一用 String 传递 |

### 4.3 Token / 鉴权问题

| 问题 | 原因 | 解决 |
|------|------|------|
| "access_token 过期" | token 有效期 48h | 实现 refreshToken 自动刷新机制 |
| "权限不足" | 子账号授权导致 sellerId 错误 | 必须用店主主账号授权 |
| 回调接口 401 | 测试环境 IP 未加白名单 | 联系快手对接人添加出口 IP |
| "fetch failed" | 代理/VPN 劫持了 DNS | 关闭代理或配置直连 openapi.kwaixiaodian.com |

### 4.4 业务逻辑问题

| 问题 | 原因 | 解决 |
|------|------|------|
| 销毁订单返回失败 | 快手要求"不存在的订单也返回成功" | 找不到订单时 result=1 而非错误码 |
| 重复发码创建重复任务 | 未做幂等校验 | 按 oid 查已有订单和任务数，只补建缺少的 |
| 发码时 num 与 etickets 数量不一致 | 并发或逻辑错误 | sendNum 必须等于 etickets[num] 之和 |
| 销毁已核销卡券 | 状态流转未校验 | 销毁前检查卡券状态，已核销不应允许销毁 |

### 4.5 环境问题

| 问题 | 原因 | 解决 |
|------|------|------|
| 测试环境调不通 | 测试环境需要出口 IP 白名单 | 提供 IP → 快手对接人（屈国庆/王哲/杨天问） |
| 测试环境 token 和生产混淆 | 两套独立 token | 测试用 `gw-merchant-staging.test.gifshow.com`，生产用 `openapi.kwaixiaodian.com` |
| 回调被限流 | 默认 120 req/min | 压测时设 `KUASHOU_INDUSTRY_RATE_LIMIT_MAX=60000` |

### 4.6 重试策略

快手对所有回调失败按**指数退避**重试：

```
10s → 30s → 1min → 2min → 3min → 4min → 5min → 6min → 7min → 8min → 9min → 10min → 20min → 30min → 1h → 2h
最多 16 次
```

**因此商家服务必须保证幂等性**——同一订单重复通知不应产生副作用。

## 五、测试方法

### 5.1 本地功能测试

```bash
cd apps/backend

# 直接调 handler（不需启动 HTTP 服务）
npm run test:industry

# HTTP 端到端测试（9 项检查）
npm run test:industry:http -- --baseUrl=http://127.0.0.1
```

### 5.2 远程联调测试

```bash
# 测试线上环境
npm run test:industry:http -- --baseUrl=https://ks.khhao.com

# 生成快手回调 curl（手动测试）
npm run test:industry:curl                    # 发码回调
npm run test:industry:curl -- --mode=consume  # 核销回调
npm run test:industry:curl -- --mode=destroy  # 销毁回调
npm run test:industry:curl -- --mode=destroy --oid=2319200000001216
```

### 5.3 压力测试

```bash
# 发码/销毁 100 TPS
npm run test:industry:http -- --baseUrl=https://ks.khhao.com --load --tps=100 --duration=30

# 查询 500 QPS
npm run test:industry:http -- --baseUrl=https://ks.khhao.com --load --endpoint=query-code --tps=500 --duration=30
```

## 六、上线前 Checklist

- [ ] 所有接口错误码按快手规范返回（1 = 成功，4010003/4012002/4012005）
- [ ] 发码接口幂等——重复通知不重复创建任务
- [ ] 销毁接口——不存在的订单返回 result=1
- [ ] 查询接口——不存在订单返回 4012002，不存在卡券返回 4012005
- [ ] 三个出站回调（发码/核销/销毁）已实现并配置 `KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=true`
- [ ] access_token 已通过店主主账号 OAuth 获取，48h 自动刷新
- [ ] 生产环境 appKey / signSecret 与测试环境独立配置
- [ ] 测试用例文档（`测试用例.md`）已填写完整提交快手审核
- [ ] 压力测试通过：发码 100 TPS、查询 500 QPS、销毁 100 TPS，平均耗时 < 100ms
- [ ] 所有错误场景（签名错/参数缺失/appkey 错）均能正确处理
- [ ] signSecret 未提交到 git 仓库，仅通过环境变量注入
- [ ] 出口 IP 已加入快手生产环境白名单

## 七、配置参考

```bash
# .env 生产配置
KUASHOU_INDUSTRY_APP_KEY=ks66xxx
KUASHOU_INDUSTRY_SIGN_SECRET=OZR3AUW2...
KUASHOU_INDUSTRY_ACCESS_TOKEN=ChFvYXV0aC5hY2Nlc3NUb2tlbh...   # OAuth 获取
KUASHOU_INDUSTRY_SEND_CALLBACK_ENABLED=true
KUASHOU_INDUSTRY_RATE_LIMIT_MAX=60000                           # 足够大的限流值
```
