Files
order_site/docs/履约配置/多发货平台与电子凭证整体链路.md
T

15 KiB
Raw Blame History

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

1. 当前结论

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

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

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

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

2. 目标链路

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

3. 91 卡券职责边界

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

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

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

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

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

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

4. 发货平台选择

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

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 创建订单,并把关键字段保存到任务上下文:

{
  "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. 快手电子凭证的嵌入点

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

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

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

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

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

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

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

7. 核销完成时机

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

推荐时机:

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

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

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

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

失败时:

  • 发货平台失败:任务进入 manual_reviewfailed
  • 电子凭证保持 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 订单和快手电子凭证之间需要稳定关联。

优先方案:

91 orderNo == 快手电子凭证 oid

如果不相等,则需要额外映射关系,例如:

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

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

10. 真实测试订单复盘

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

10.1 请求时序

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 已验证事实

这笔真实单中:

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

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

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

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

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

10.3 当前断点

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

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 自己创建孤立订单。它需要优先做关联:

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

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

最终目标:

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

eticket.id = fulfillment_tasks.id

真实测试单中:

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:

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)

券号生成建议:

voucher_code = String(kuaishou_industry_vouchers.id)

理由:

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

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

voucher_code = KSEV-{oid}-{unitIndex}

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

11.4 send-code 幂等规则

send-code 处理流程应改为:

收到 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 }]

重复通知时,必须返回相同结果:

第一次 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。当前系统返回了:

validStartTime = 0
validEndTime = 0

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

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

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

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

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

如果 certExpDays 也为空,则使用保守默认值,例如 3 天。

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

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

11.6 后续核销规则

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

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

核销流水号也必须幂等:

consume_serial_num = CONSUME-{voucher_code}

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

销毁规则:

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