接入多发货平台与电子凭证
This commit is contained in:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user