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

19 KiB
Raw Blame History

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

状态:现行。最后校准:2026-10-18(main @ 7542d547;仅基线更新,业务逻辑未变)。 来源口径:本系统只有三个订单来源——kuaishou-91、pdd、douyin-mashida。91 回调与快手 send-code 是同一个 kuaishou-91 来源的两条数据通道(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。

1. 当前结论

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

模块 定位 说明
kuaishou-91 订单来源 91 回调提供订单骨架;快手 send-code 提供商品/SKU 补全与兜底。两者共享 kuaishou-91 来源与同一店铺键空间。
affiliate-dash 现行发货平台(自动履约) 当前唯一自动履约通道,order_site 作为其商户走开放接口 /api/client/v1,发放皮肤、道具。
kuaishou-cloud / kuaishou-feifei 已下线发货平台 历史乐玩 / 飞飞通道已下线,executor 仅保留只读 legacy stub(legacy-retired-executor.ts),不再发货。
快手电子凭证 领取流程里的自动核销能力 用来替代用户在领取页手动输入核销码的步骤,不替代 91,也不替代发货平台。

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

kuaishou-91(91 回调 + 快手 send-code)负责进单与补全
我方领取页负责统一承接用户
kuaishou-cloud / kuaishou-feifei 通道已下线,affiliate-dash 负责真实发货
快手电子凭证负责把领取页里的核销码输入步骤自动化

1.1 执行载体三块

真正决定成本和监管方式的是订单项的执行载体:

载体 当前实现 说明
affiliate_dash 自动履约任务 上游固定成本,适合店铺/日期/皮肤销量统计
work_order 接单平台工单 变动成本,适合影子总账与打手结算
manual 目前没有显式 case 无自动任务且无工单的已支付订单项;当前最大监管缺口

详细设计见 ../成本中心与三块履约模型.md。

2. 目标链路

kuaishou-91 进单
  (91 回调 = 订单骨架权威源;快手 send-code = 商品/SKU 补全与兜底)
  -> 我方创建内部订单和履约任务
  -> 按商品选择发货平台
       -> affiliate-dash(自动履约)
       -> manual_dispatch(人工兜底)
  -> 91 卡券查询订单
  -> 我方返回统一 claimUrl
  -> 91 卡券把 claimUrl 发给用户
  -> 用户打开 claimUrl
  -> 系统检查快手电子凭证状态
       -> 已有关联电子凭证:跳过手动输入核销码
       -> 未关联电子凭证:保留兜底处理
  -> 用户继续完成绑定 / 提交发货
  -> 发货平台完成履约
  -> 我方更新任务状态
  -> 我方完成快手电子凭证核销闭环

3. 91 卡券职责边界

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

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

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

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

不要直接把 affiliate-dash 的绑定链接返回给 91。原因是领取页需要统一处理:

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

4. 发货平台选择

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

91 productNo
  -> 商品匹配(skuMapping 指定匹配:91 编码 / 商品名 -> affiliate-dash sku)
  -> 命中且 affiliate-dash 通道可用
       -> executor_key = affiliate_dash
  -> 未命中
       -> 按履约路由配置:商品路由规则 -> 全局优先级
       -> 仍未命中 -> 待补全 / 人工履约(manual_dispatch)

优先级由可配置规则决定(services/fulfillment/routing-config-service.ts),当前顺序:

  1. 指定匹配(显式映射)优先;
  2. 再按商品路由规则(商品名 exact / contains);
  3. 再按全局优先级 defaultExecutorPriority(当前默认仅 affiliate_dash);
  4. 都未命中则回落人工履约(manual_dispatch)或保持未配置。

5. kuaishou-feifei 接入方式(已下线)

kuaishou-feifei 通道已下线:历史交付物是 h5.recharge_url,相关 executor / API client / H5 对接均已移除,历史任务由只读 legacy stub(legacy-retired-executor.ts)承接,不再发货。

现行自动履约通道是 affiliate-dash:order_site 作为其商户走 /api/client/v1,关键字段保存到任务上下文 context_json.affiliateDash(具名类型 AffiliateDashFlow):

{
  "affiliateDash": {
    "flowType": "affiliate_dash",
    "sku": "affiliate-dash sku",
    "productName": "商品名",
    "orderNo": "affiliate-dash 平台单号",
    "clientOrderNo": "内部幂等单号",
    "orderStatus": "paid / delivering / delivered / ship_failed / cancelled",
    "canShip": true,
    "bindUrl": "http://...",
    "qrUrl": "http://...",
    "bound": false,
    "submitStatus": "",
    "consumeStatus": "",
    "lastSyncedAt": "2026-07-07T00:00:00.000Z"
  }
}

领取页根据当前任务的 executor 展示不同内容(services/claim/kuaishou-cloud-claim-context.ts):

  • affiliate_dash:本站领取页自建流程(查 delivery -> 填 UID -> bind -> 轮询 bind-result -> submit);
  • kuaishou_ct_assisted / kuaishou_feifei:通道已下线,仅展示历史信息与「该履约渠道已下线」提示;
  • 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. 核销完成时机

电子凭证的核销时机与领取页打开解耦:不因用户打开领取页就标记已消费。

当前实现(services/fulfillment/affiliate-dash/index.ts:87-117,212-241)在建单成功、91 侧可取到领取链接时即核销(91 卡信息一经消耗),不再等发货完成:

准备履约任务成功(拿到领取链接)
  -> 我方调用快手电子凭证核销
  -> 凭证状态更新为 CONSUMED(consumeStatus = success)
发货回调 delivered
  -> 只做状态收敛:consumeStatus 已 success / not_required 则短路
  -> failed / 空才补核销(兜底)
  -> 我方更新 task 为 completed / redeemed

核销失败不阻塞下单与链接发放(consumeStatus = failed + warn 日志),由 delivered 回调兜底。

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

发货平台 成功判定
affiliate-dash submit 成功 / 回调 order.shipping.updated 且 order_status = delivered,任务进入完成态。
kuaishou-cloud / kuaishou-feifei 已下线,不再产生新的成功判定;历史任务只读。

失败时:

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

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

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

  • 91 卡券下单和查询;
  • affiliate-dash 自动发货流程(唯一自动履约通道);
  • 快手电子凭证 send-code / query-code / destroy-code / consume-code 接口;
  • 领取页手动输入核销码流程(异常兜底)。

后续调整方向(当前状态):

  1. 91 查询仍返回统一 claimUrl(已完成)。
  2. 商品匹配:skuMapping 指定匹配 -> 商品路由规则 -> 全局优先级(默认仅 affiliate_dash),未命中回落人工履约(已完成)。
  3. kuaishou-feifei / kuaishou-cloud 已下线,executor 仅保留只读 legacy stub,不再新增独立 executor 和 API client(已处理)。
  4. 领取页根据 task executor 展示对应发货流程(affiliate_dash 自建流程;下线通道展示历史信息)(已完成)。
  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 是独立券表的业务券号 voucher_code,不再依赖 task id:

eticket.id = kuaishou_industry_vouchers.voucher_code
voucher_code = 'KSV' + Crockford Base32(10 字节随机数)

来源:services/platforms/kuaishou-industry/send-code-service.ts:86-116(按 oid + unit_index upsert 券记录后返回)、services/platforms/kuaishou-industry/voucher-service.ts:95(id / code = voucher_code)、repositories/kuaishou-industry-voucher-repo.ts:26-53(KSV 前缀 + Crockford Base32 生成,唯一冲突自动重试)。

历史测试单(2026-07-07)里 eticket.id = 2038,那是早期把 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 已落地数据模型

已新增独立的电子凭证券表 kuaishou_industry_vouchers(db/migrations/001_init.sql:272-297,eticket_type 见 db/migrations/003_kuaishou_industry_voucher_eticket_type.sql),不再只依赖 task id:

kuaishou_industry_vouchers
  id                     BIGSERIAL PRIMARY KEY
  voucher_code           TEXT NOT NULL UNIQUE
  oid                    TEXT NOT NULL
  order_id               BIGINT NULL REFERENCES orders(id) ON DELETE SET NULL
  task_id                BIGINT NULL REFERENCES fulfillment_tasks(id) ON DELETE SET NULL
  unit_index             INTEGER NOT NULL
  seller_id              TEXT NOT NULL DEFAULT ''
  token                  TEXT NOT NULL DEFAULT ''
  eticket_type           TEXT NOT NULL DEFAULT ''
  status                 TEXT NOT NULL DEFAULT 'UNUSED'   -- UNUSED / CONSUMED / DESTROYED
  valid_start_time       BIGINT NOT NULL DEFAULT 0
  valid_end_time         BIGINT NOT NULL DEFAULT 0
  consume_serial_num     TEXT NOT NULL DEFAULT ''
  consume_details_json   JSONB NOT NULL DEFAULT '[]'::jsonb
  consumed_at            TIMESTAMPTZ NULL
  destroyed_at           TIMESTAMPTZ NULL
  raw_payload_json       JSONB NOT NULL DEFAULT '{}'::jsonb
  send_callback_status   TEXT NOT NULL DEFAULT 'success'
  send_callback_attempt_count / send_callback_last_error / send_callback_response_json / send_callback_sent_at
  created_at             TIMESTAMPTZ NOT NULL
  updated_at             TIMESTAMPTZ NOT NULL

唯一约束:
  UNIQUE(voucher_code)
  UNIQUE(oid, unit_index)

券号生成(repositories/kuaishou-industry-voucher-repo.ts:26-53):

voucher_code = 'KSV' + Crockford Base32(10 字节随机数)

理由:

  • 业务券号与 task 生命周期解耦,任务重建 / 换发货通道不影响反查;
  • 数据库 UNIQUE(voucher_code) 保证不重复,生成冲突自动重试(最多 5 次);
  • 重复 send-code 时通过 UNIQUE(oid, unit_index) + ON CONFLICT ... DO UPDATE 找回原记录,返回原 voucher_code;
  • 不依赖 task 是否已经创建。

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 = KSVASXT6Q955WT4SS36

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

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 也为空,则使用默认值 30 天。

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

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

11.6 核销规则(已实现)

不再要求用户输入快手核销码,而是系统根据 voucher 主动完成核销闭环。affiliate-dash 在**建单成功(拿到领取链接)**时即核销,delivered 回调只做状态收敛与兜底补核销(services/fulfillment/affiliate-dash/index.ts:87-117,212-241 / services/platforms/kuaishou-industry/voucher-service.ts:241-291):

建单成功 / affiliate-dash 已消耗(91 卡信息到手)
  -> 找到 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