- 全量替换文档里的旧迁移编号:106→107、107→105、108/109/111→104、112→106、110→107;
- 删除 113/114 的残留引用(这两个编号从未落地);
- 分支状态校准为 `dev @ 465a9f65 + 迁移收敛`,本地库按「清 103–112 记录后重跑」对齐;
- 成本中心与履约模型 §一 重写迁移基线表,§八 改为迁移基线与本地库同步;
- 归档原地改造脚本的索引补登记。
20 KiB
多发货平台与电子凭证整体链路
状态:现行。最后校准:2026-10-23(代码基线
dev @ 5d1ba115 + 迁移103–107;履约方式已统一落库)。 财务与履约总口径见 [财务与履约口径总纲-2026-10-23.md](../财务与履约口径总纲-2026-10-23.md)。 来源口径:本系统只有三个订单来源——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 |
接单平台工单 | 变动成本,适合打手结算与毛利 |
legacy_platform |
kuaishou_ct_assisted / kuaishou_feifei 历史任务 |
通道已下线,只保留历史报表;不得混入人工 |
manual |
无工单、无自动发货任务、无历史平台任务,人工处理 | 逐单实际金额;有没有核销码不影响判定 |
voucher_state |
快手电子凭证 | 只是核销码状态,不参与上表判定 |
判定顺序:work_order → affiliate_dash → legacy_platform → manual。
当前真实数据:work_order 118,053 / affiliate_dash 41,676 / legacy_platform 53,901 / manual 102,955,合计 316,585。
详细设计见 ../财务与履约口径总纲-2026-10-23.md 与 ../成本中心与履约模型.md。
2. 目标链路
kuaishou-91 进单
(91 回调 = 订单骨架权威源;快手 send-code = 商品/SKU 补全与兜底)
-> 我方创建内部订单和履约任务
-> 按商品选择发货平台
-> affiliate-dash(自动履约)
-> manual_dispatch(人工兜底)
-> 91 卡券查询订单
-> 我方返回统一 claimUrl
-> 91 卡券把 claimUrl 发给用户
-> 用户打开 claimUrl
-> 系统检查快手电子凭证状态
-> 已有关联电子凭证:跳过手动输入核销码
-> 未关联电子凭证:保留兜底处理
-> 用户继续完成绑定 / 提交发货
-> 发货平台完成履约
-> 我方更新任务状态
-> 我方完成快手电子凭证核销闭环
3. 91 卡券职责边界
91 卡券仍然只承担两件事:
- 调用我方异步下单接口,提供
orderNo、productNo、buyNum等订单信息。 - 调用我方查询接口,获取最终要发给用户的卡密内容。
这里的卡密内容继续保持为我方统一领取链接:
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),当前顺序:
- 指定匹配(显式映射)优先;
- 再按商品路由规则(商品名 exact / contains);
- 再按全局优先级
defaultExecutorPriority(当前默认仅affiliate_dash); - 都未命中则回落人工履约(
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接口; - 领取页手动输入核销码流程(异常兜底)。
后续调整方向(当前状态):
- 91 查询仍返回统一
claimUrl(已完成)。 - 商品匹配:skuMapping 指定匹配 -> 商品路由规则 -> 全局优先级(默认仅
affiliate_dash),未命中回落人工履约(已完成)。 - kuaishou-feifei / kuaishou-cloud 已下线,executor 仅保留只读 legacy stub,不再新增独立 executor 和 API client(已处理)。
- 领取页根据 task executor 展示对应发货流程(affiliate_dash 自建流程;下线通道展示历史信息)(已完成)。
- 快手电子凭证状态进入 task context,用于跳过手动输入核销码(已完成)。
- 建单成功(拿到领取链接)即触发快手电子凭证核销,发货回调只做状态收敛(已完成)。
- 保留手动输入核销码作为异常兜底,而不是主路径。
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
说明:
- 91 订单已经进入系统,但因为
套装-Alan Walker没命中现有履约配置,所以进入待补全队列。 - 快手电子凭证已经发码成功,但当前实现没有把
eticketId = 2038绑定回 91 订单的履约上下文。 - 91 查询只看
provider = 91kaquan的订单和任务,因此看不到快手电子凭证链路创建或返回的状态。 - 因为 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