Files
order_site/docs/履约配置/多发货平台与电子凭证整体链路.md
T
2026-07-27 13:08:48 +08:00

510 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多发货平台与电子凭证整体链路
## 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
```