# 多发货平台与电子凭证整体链路 ## 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 ```