接入多发货平台与电子凭证

This commit is contained in:
yml2213
2026-07-07 12:06:19 +08:00
parent c101b5f359
commit ddfa6c278f
35 changed files with 2627 additions and 365 deletions
@@ -0,0 +1,509 @@
# 多发货平台与电子凭证整体链路
## 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` 也为空,则使用保守默认值,例如 3 天。
对于上面的真实请求,返回给快手的电子凭证应类似:
```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
```