# 多发货平台与电子凭证整体链路

## 1. 当前结论

当前系统需要把四类能力拆开理解：

| 模块 | 定位 | 说明 |
| --- | --- | --- |
| 91 卡券 | 订单来源 + 发链接通道 | 91 调用我方下单接口，我方生成领取链接；91 查询订单时拿到领取链接，并发送给用户。 |
| kuaishou-cloud | 发货平台 1 | 已对接，用于发放它支持的皮肤、道具。 |
| kuaishou-feifei | 发货平台 2 | 新增发货平台，作为 kuaishou-cloud 的补充，覆盖 kuaishou-cloud 没有的皮肤、道具。 |
| 快手电子凭证 | 领取流程里的自动核销能力 | 用来替代用户在领取页手动输入核销码的步骤，不替代 91，也不替代发货平台。 |

因此整体设计不是“新增订单来源”，而是：

```text
91 负责进单和把领取链接发给用户
我方领取页负责统一承接用户
kuaishou-cloud / kuaishou-feifei 负责真实发货
快手电子凭证负责把领取页里的核销码输入步骤自动化
```

## 2. 目标链路

```text
91 卡券下单
  -> 我方创建内部订单和履约任务
  -> 按商品选择发货平台
       -> kuaishou-cloud
       -> kuaishou-feifei
  -> 91 卡券查询订单
  -> 我方返回统一 claimUrl
  -> 91 卡券把 claimUrl 发给用户
  -> 用户打开 claimUrl
  -> 系统检查快手电子凭证状态
       -> 已有关联电子凭证：跳过手动输入核销码
       -> 未关联电子凭证：保留兜底处理
  -> 用户继续完成绑定 / 跳转发货平台 H5
  -> 发货平台完成履约
  -> 我方更新任务状态
  -> 我方完成快手电子凭证核销闭环
```

## 3. 91 卡券职责边界

91 卡券仍然只承担两件事：

1. 调用我方异步下单接口，提供 `orderNo`、`productNo`、`buyNum` 等订单信息。
2. 调用我方查询接口，获取最终要发给用户的卡密内容。

这里的卡密内容继续保持为我方统一领取链接：

```text
claimUrl = https://你的域名/#/claim/{token}
```

不要直接把 kuaishou-cloud 绑定链接或 kuaishou-feifei 的 H5 链接返回给 91。原因是领取页需要统一处理：

- 商品最终走哪个发货平台；
- 是否已经通过快手电子凭证完成自动核销；
- 是否需要展示绑定链接、二维码或 H5；
- 异常时如何兜底、重试、转人工。

## 4. 发货平台选择

履约任务创建时，需要根据商品配置选择发货平台：

```text
91 productNo
  -> 商品匹配
  -> 命中 kuaishou-cloud 商品
       -> executor_key = kuaishou_ct_assisted
  -> 命中 kuaishou-feifei 商品
       -> executor_key = kuaishou_feifei
  -> 未命中
       -> 进入待补全 / 人工处理
```

优先级建议先做成可配置规则，不在代码里硬编码。第一阶段可以采用：

1. 显式覆盖规则优先；
2. 再按商品名匹配 kuaishou-cloud；
3. 再按商品名或 `product_code` 匹配 kuaishou-feifei；
4. 都未命中则进入待补全队列。

## 5. kuaishou-feifei 接入方式

kuaishou-feifei 的主要交付物是 `h5.recharge_url`。

创建任务后，系统应向 kuaishou-feifei 创建订单，并把关键字段保存到任务上下文：

```json
{
  "kuaishouFeifei": {
    "platformOrderNo": "内部幂等单号",
    "orderNo": "发货平台内部单号",
    "productCode": "商品编码",
    "rechargeStatus": 15,
    "statusLabel": "待绑定",
    "h5": {
      "rechargeUrl": "http://..."
    },
    "lastSyncedAt": "2026-07-07T00:00:00.000Z"
  }
}
```

领取页根据当前任务的 executor 展示不同内容：

- `kuaishou_ct_assisted`：展示现有 kuaishou-cloud 绑定流程；
- `kuaishou_feifei`：展示或跳转 kuaishou-feifei 的 `h5.recharge_url`；
- `manual_dispatch`：进入人工兜底。

## 6. 快手电子凭证的嵌入点

旧流程中，用户打开领取链接后需要手动输入快手核销码：

```text
用户打开 claimUrl
  -> 输入核销码
  -> 我方校验 / 核销
  -> 继续绑定或发货
```

新流程中，快手电子凭证用于把这一步自动化：

```text
用户打开 claimUrl
  -> 系统查任务上下文中的电子凭证信息
  -> 如果已有关联的 oid / token / eticketId
       -> 自动认为核销凭证已就绪
       -> 跳过手动输入核销码
  -> 继续进入发货平台绑定 / H5
```

电子凭证信息建议沉淀在 task context 中：

```json
{
  "kuaishouIndustryVoucher": {
    "oid": "快手订单号",
    "token": "订单维度授权 token",
    "eticketId": "电子凭证 id",
    "status": "UNUSED",
    "certActualStartTime": 0,
    "certActualEndTime": 0,
    "verifiedAt": null,
    "consumedAt": null
  }
}
```

注意：快手电子凭证不是新的订单来源。它只服务领取流程中的核销自动化。

## 7. 核销完成时机

电子凭证不应该在用户刚打开领取页时就立刻标记为已消费。

推荐时机：

```text
发货平台实际履约成功
  -> 我方更新 task 为 completed / redeemed
  -> 我方触发快手电子凭证核销回调
  -> 电子凭证状态更新为 CONSUMED
```

这样可以避免用户打开链接但未完成绑定时，快手侧已经显示核销成功。

不同发货平台的成功判定：

| 发货平台 | 成功判定 |
| --- | --- |
| kuaishou-cloud | 当前发货流程完成，任务进入完成态。 |
| kuaishou-feifei | 查询或异步通知返回 `recharge_status = 30`。 |

失败时：

- 发货平台失败：任务进入 `manual_review` 或 `failed`；
- 电子凭证保持 `UNUSED`，不要误核销；
- 需要人工确认时再做销毁、退款或补发。

## 8. 现有代码需要调整的方向

当前项目已经有以下基础能力：

- 91 卡券下单和查询；
- kuaishou-cloud 发货流程；
- 快手电子凭证 `send-code / query-code / destroy-code / consume-code` 接口骨架；
- 领取页手动输入核销码流程。

后续需要把它们串起来：

1. 91 查询仍返回统一 `claimUrl`。
2. 商品匹配支持多发货平台。
3. kuaishou-feifei 新增独立 executor 和 API client。
4. 领取页根据 task executor 展示对应发货流程。
5. 快手电子凭证状态进入 task context，用于跳过手动输入核销码。
6. 发货成功后再触发快手电子凭证核销。
7. 保留手动输入核销码作为异常兜底，而不是主路径。

## 9. 数据关联

91 订单和快手电子凭证之间需要稳定关联。

优先方案：

```text
91 orderNo == 快手电子凭证 oid
```

如果不相等，则需要额外映射关系，例如：

```text
91 orderNo
  -> 内部 order.id
  -> task.id
  -> 快手电子凭证 oid / eticketId / token
```

没有这层映射，领取页无法可靠判断当前用户对应哪一张快手电子凭证，也就无法稳定跳过手动核销码输入步骤。

## 10. 真实测试订单复盘

2026-07-07 的真实测试请求验证了优先关联方案成立。

### 10.1 请求时序

```text
10:16:43  91 卡券异步下单
          orderNo = 2618800083429561
          productNo = 套装-Alan Walker
          buyNum = 1

10:16:46  快手电子凭证 send-code
          oid = 2618800083429561
          itemTitle = 测试连接1111
          itemId = 26692927860114
          skuId = 188718171010114
          eticketId = 2038

10:17:13  91 卡券查询订单
          orderStatus = 10
          cards = ''

10:17:44  91 卡券再次查询订单
          orderStatus = 10
          cards = ''
```

### 10.2 已验证事实

这笔真实单中：

```text
91 orderNo = 2618800083429561
快手电子凭证 oid = 2618800083429561
```

因此可以用 `orderNo / oid` 作为 91 订单和快手电子凭证的主关联键。

同时也验证了另一个重要事实：

```text
91 productNo = 套装-Alan Walker
快手 itemTitle = 测试连接1111
```

两者不一定相同。因此，发什么皮肤、走哪个发货平台，应该以 91 的 `productNo` 为准；快手电子凭证的 `itemTitle / itemId / skuId` 主要用于凭证关系、快手侧订单信息和核销闭环，不应该直接用于决定发货商品。

### 10.3 当前断点

当前系统在这笔单上的表现：

```text
91 下单结果：
ignored = true
ignoreReason = unconfigured_items
orderId = 2487
orderStatus = 10
cards = ''

快手电子凭证结果：
result = 1
eticketId = 2038
status = UNUSED
```

说明：

1. 91 订单已经进入系统，但因为 `套装-Alan Walker` 没命中现有履约配置，所以进入待补全队列。
2. 快手电子凭证已经发码成功，但当前实现没有把 `eticketId = 2038` 绑定回 91 订单的履约上下文。
3. 91 查询只看 `provider = 91kaquan` 的订单和任务，因此看不到快手电子凭证链路创建或返回的状态。
4. 因为 91 订单没有可交付的 `claimUrl`，所以持续返回 `orderStatus = 10` 和空 `cards`。

### 10.4 实现修正方向

`kuaishou-industry/send-code` 收到请求后，不能只按 `provider = kuaishou-industry` 自己创建孤立订单。它需要优先做关联：

```text
send-code.oid
  -> 查找 91 订单 platformOrderId = oid
  -> 找到：把电子凭证信息写入该 91 订单对应 task / context
  -> 未找到：暂存电子凭证待关联，或创建待关联记录
```

如果 91 订单仍是 `pending_config`，则需要在后续补全商品履约配置后，同时带上已收到的电子凭证信息。

最终目标：

```text
91 订单 task
  -> context.kuaishouIndustryVoucher.eticketId = 2038
  -> context.kuaishouIndustryVoucher.oid = 2618800083429561
  -> context.kuaishouIndustryVoucher.status = UNUSED
  -> 领取页打开时跳过手动核销码输入
  -> 发货完成后触发电子凭证核销
```

## 11. 电子凭证券号生成与核销幂等

接入电子凭证的核心目的，是减少用户在领取页手动输入快手核销码的步骤。因此，系统返回给快手的电子凭证 `eticket.id` 需要成为后续查询、销毁、核销都能稳定使用的业务券号。

### 11.1 当前逻辑

当前实现中，`send-code` 返回的 `eticket.id` 来自本地 task id：

```text
eticket.id = fulfillment_tasks.id
```

真实测试单中：

```text
eticket.id = 2038
```

这说明当前“核销码/券号”不是快手生成的，而是我方创建 task 后，把 task id 当作电子凭证 id 返回给快手。

这个方式有两个问题：

1. task 属于当前实现里的 `kuaishou-industry` 孤立订单链，尚未绑定到 91 订单 task。
2. task id 虽然全局唯一，但它和任务生命周期耦合太强；后续如果任务重建、补配置、切换发货平台，会影响电子凭证反查和幂等。

### 11.2 推荐目标

电子凭证券号应该满足：

| 要求 | 说明 |
| --- | --- |
| 全局唯一 | 不同订单、不同数量拆分出的券不能重复。 |
| 幂等稳定 | 同一个 `oid` 重复 `send-code`，必须返回同一批 `eticket.id`。 |
| 可反查 | `query-code / destroy-code / consume-code` 带回 `eticket.id` 时，能找到原始订单、task 和发货状态。 |
| 可延迟绑定 | 如果快手 `send-code` 早于 91 task 准备完成，券号也能先落库，后续再绑定 task。 |
| 可核销 | 发货完成后，能用同一张券更新为 `CONSUMED`，并记录核销流水。 |

### 11.3 推荐数据模型

建议新增独立的电子凭证券表，而不是继续只依赖 task id：

```text
kuaishou_industry_vouchers
  id                     BIGSERIAL PRIMARY KEY
  voucher_code           TEXT UNIQUE NOT NULL
  oid                    TEXT NOT NULL
  order_id               BIGINT NULL
  task_id                BIGINT NULL
  unit_index             INTEGER NOT NULL
  token                  TEXT NOT NULL
  status                 TEXT NOT NULL      -- UNUSED / CONSUMED / DESTROYED
  valid_start_time       BIGINT NOT NULL
  valid_end_time         BIGINT NOT NULL
  consume_serial_num     TEXT NOT NULL DEFAULT ''
  consumed_at            TIMESTAMPTZ NULL
  destroyed_at           TIMESTAMPTZ NULL
  raw_payload_json       JSONB NOT NULL DEFAULT '{}'::jsonb
  created_at             TIMESTAMPTZ NOT NULL
  updated_at             TIMESTAMPTZ NOT NULL

唯一约束：
  UNIQUE(oid, unit_index)
  UNIQUE(voucher_code)
```

券号生成建议：

```text
voucher_code = String(kuaishou_industry_vouchers.id)
```

理由：

- 数字字符串兼容性最好；
- 数据库主键保证不重复；
- 重复 `send-code` 时通过 `UNIQUE(oid, unit_index)` 找回原记录，返回原 `voucher_code`；
- 不依赖 task 是否已经创建。

如果后续确认快手完全支持任意字符串，也可以升级为带前缀的业务券号，例如：

```text
voucher_code = KSEV-{oid}-{unitIndex}
```

但第一阶段建议优先使用数字字符串，降低快手侧兼容风险。

### 11.4 send-code 幂等规则

`send-code` 处理流程应改为：

```text
收到 oid + num
  -> 按 oid 查 91 订单
  -> 为 unitIndex = 1..num 创建或获取 voucher
       -> 已存在：复用原 voucher_code
       -> 不存在：新建 voucher
  -> 如果 91 task 已存在：绑定 voucher.task_id
  -> 如果 91 task 未存在：voucher 保持待绑定
  -> 返回 etickets[{ id: voucher_code, status: voucher.status }]
```

重复通知时，必须返回相同结果：

```text
第一次 send-code:
  oid = 2618800083429561
  num = 1
  eticket.id = 2038

第二次 send-code:
  oid = 2618800083429561
  num = 1
  eticket.id 仍然 = 2038
```

### 11.5 有效期生成规则

真实测试单里快手没有传 `certActualStartTime / certActualEndTime`，并且 `certStartTime / certEndTime` 也可能都是 0。当前系统返回了：

```text
validStartTime = 0
validEndTime = 0
```

例如 2026-07-07 11:16:58 的真实请求：

```text
certExpireType = 3
certExpDays = 3
certStartTime = 0
certEndTime = 0
certActualStartTime = 0
certActualEndTime = 0
```

这种场景应理解为“购买成功后固定有效天数”，用 `certExpDays` 生成有效期。建议按以下优先级生成：

```text
validStartTime:
  1. certActualStartTime > 0
  2. certStartTime > 0
  3. 当前时间毫秒

validEndTime:
  1. certActualEndTime > 0
  2. certEndTime > 0
  3. certStartTime + certExpDays * 86400000
  4. 当前时间 + certExpDays * 86400000
```

如果 `certExpDays` 也为空，则使用默认值 30 天。

对于上面的真实请求，返回给快手的电子凭证应类似：

```text
validStartTime = send-code 处理时的当前毫秒时间戳
validEndTime = validStartTime + 3 * 86400000
```

### 11.6 后续核销规则

发货完成后，不再要求用户输入快手核销码，而是系统根据 voucher 主动完成核销闭环：

```text
发货平台履约成功
  -> 找到 task 绑定的 voucher
  -> 检查 voucher.status = UNUSED
  -> 生成稳定 consume_serial_num
  -> 调用快手电子凭证核销回调
  -> 成功后更新 voucher.status = CONSUMED
  -> 更新 task 为 completed / redeemed
```

核销流水号也必须幂等：

```text
consume_serial_num = CONSUME-{voucher_code}
```

这样即使核销回调重试，也不会生成多条不同流水。

销毁规则：

```text
destroy-code
  -> 按 oid + eticket.id 找 voucher
  -> 未找到：按快手要求仍可返回成功
  -> 已 CONSUMED：不应直接改为 DESTROYED，进入人工确认
  -> UNUSED：更新为 DESTROYED
```
