合并本轮全部文档改动(原 5 个 docs 提交压成 1 个)。 口径与索引: - 新增 docs/README.md 文档总索引:三来源口径(91 + 快手 send-code = kuaishou-91)、 「一个订单 = 骨架 + 买家凭证 + 一个执行载体」、成本只改一处、文档状态机 - 过期阶段文档移入 docs/archive/2026-10-08/(保留内容,不删除) 分析报告: - docs/项目全量分析报告-2026-10-17.md:架构、五条业务链路、P0/P1/P2 风险与改进路线 - docs/载体判定与成本中心落地-复核与草案-2026-10-17.md:生产只读克隆量化、上一轮结论复核与 4 处修正 方案文档: - docs/成本中心与三块履约模型.md:三块执行载体、标准商品、成本版本与快照、人工支出登记 - docs/财务系统集成方案.md:复式记账模型、分录表、分期落地 - docs/影子总账说明.md:影子期与转正期的边界、对账与转正标准 SQL 草案(docs/drafts/,未接入迁移): - 载体判定与人工块量化.sql:量化查询、carrier_type 判定与回填、守恒校验 - 成本中心与销售事实-DDL草案.sql:product_catalog / channel_product_mappings / cost_items / cost_versions / cost_snapshots / cost_exceptions / sales_daily_facts - 人工支出登记-DDL草案.sql:manual_fulfillment_cases / manual_expense_records / manual_expense_events 业务口径确认(2026-10-17): - 收入 = 用户实付(orders.total_amount),与快手 paymentFen 数据核验 100% 一致 - 打手报酬 = 支出/成本(最终提现打款),按总额法 - 人工块以「支出可追溯」为先,SLA 监管延后 - 退款/仲裁暂未接入,先预留字段与反向冲正关系 三份草案已在一次性沙箱库按依赖顺序零错误应用(10 张新表、27 个 CHECK 约束)。
324 lines
9.8 KiB
Markdown
324 lines
9.8 KiB
Markdown
# 91卡券接入开发设计
|
||
|
||
## 1. 目标
|
||
|
||
> 来源口径(2026-10-17):`91卡券` 是 **`kuaishou-91` 来源的订单骨架通道**;快手 send-code 是同一来源的数据补全与兜底通道。二者共享匹配上下文与店铺键空间,**不能拆成两个来源或两个适配器**。
|
||
|
||
基于以下对外接口文档完成当前项目接入:
|
||
|
||
- [3.异步卡密下单.md](./3.异步卡密下单.md)
|
||
- [4.查询订单接口.md](./4.查询订单接口.md)
|
||
|
||
目标能力:
|
||
|
||
1. `91卡券` 调用我方异步下单接口;
|
||
2. 我方将订单作为独立接入平台订单写入当前项目;
|
||
3. 已配置商品自动走现有 affiliate-dash 履约链路;
|
||
4. 未配置商品进入后台待补全队列,可手动补齐履约信息后重试;
|
||
5. 我方生成领取链接 `https://你的域名/#/claim/{token}`;
|
||
6. `91卡券` 调用查询订单接口;
|
||
7. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。
|
||
|
||
## 2. 设计原则
|
||
|
||
- `91卡券` 是 `kuaishou-91` 来源的**订单骨架通道**,不是新的履约执行器;快手 send-code 是同来源的补全/兜底通道。
|
||
- 外部售卖、订单输入和自动发货通道仍由 91 承担;匹配层的来源键统一为 `kuaishou-91`。
|
||
- 当前项目真正的交付物仍然是 `claimUrl`。
|
||
- 查询接口返回 `20` 的判断标准是“领取链接已生成并可发”,不是“快手最终兑换完成”。
|
||
- `buyNum > 1` 时,必须拆成多个 task,并返回多个 card。
|
||
- 未命中履约配置时不丢单,先返回 `10` 并沉淀到后台待补全队列。
|
||
|
||
## 3. 来源建模
|
||
|
||
新增独立来源标识:
|
||
|
||
- `provider = '91kaquan'`
|
||
- `platform = 'kuaishou'`
|
||
- `shopId = '91kaquan'`
|
||
- `shopName = '91卡券'`
|
||
|
||
原因:
|
||
|
||
- 避免与其他平台回调语义混淆;
|
||
- 避免复用历史后台拉单来源;
|
||
- 便于后续单独做日志、排障和绑定配置。
|
||
|
||
## 4. 接口设计
|
||
|
||
### 4.1 路由
|
||
|
||
新增独立公开路由组:
|
||
|
||
- `POST /api/v1/open/91/orders/create`
|
||
- `POST /api/v1/open/91/orders/query`
|
||
|
||
接口使用独立公开路由组,原因:
|
||
|
||
- `91` 需要自己的响应结构:`code/message/data`;
|
||
- 与后台管理接口的统一响应结构隔离;
|
||
- 便于按 91 的签名、时间戳和错误码规范单独演进。
|
||
|
||
### 4.2 返回格式
|
||
|
||
对 `91` 路由统一返回:
|
||
|
||
```json
|
||
{
|
||
"code": 200,
|
||
"message": "接口调用成功",
|
||
"data": {}
|
||
}
|
||
```
|
||
|
||
业务失败也返回 `data.orderStatus = 30` 的业务响应。
|
||
|
||
仅在真正的协议级错误下返回:
|
||
|
||
```json
|
||
{
|
||
"code": 400,
|
||
"message": "验签失败",
|
||
"data": null
|
||
}
|
||
```
|
||
|
||
## 5. 配置设计
|
||
|
||
新增运行时配置项:
|
||
|
||
```js
|
||
platforms: {
|
||
ninetyone: {
|
||
userId: '',
|
||
secret: '',
|
||
version: '1.0',
|
||
shopId: '91kaquan',
|
||
shopName: '91卡券',
|
||
timestampToleranceSeconds: 600,
|
||
cardsEncoding: 'aes-256-ecb-base64'
|
||
}
|
||
}
|
||
```
|
||
|
||
建议环境变量:
|
||
|
||
- `KAQUAN91_USER_ID`
|
||
- `KAQUAN91_SECRET`
|
||
- `KAQUAN91_VERSION`
|
||
- `KAQUAN91_SHOP_ID`
|
||
- `KAQUAN91_SHOP_NAME`
|
||
- `KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS`
|
||
- `KAQUAN91_CARDS_ENCODING`
|
||
|
||
## 6. 签名设计
|
||
|
||
签名规则沿用文档约定:
|
||
|
||
1. 仅使用请求 JSON body 中实际传入的参数参与签名;
|
||
2. 除 `sign` 外,所有 body 参数按 ASCII 升序排序;
|
||
3. 拼接为 QueryString;
|
||
4. 前后加商户密钥;
|
||
5. 计算大写 MD5;
|
||
6. 已传入的空值参数参与签名;
|
||
7. 不额外加入 `userId`、商户号、内部配置项或未传入的可选字段。
|
||
|
||
需要实现:
|
||
|
||
- 生成签名原串;
|
||
- 验签;
|
||
- 校验 `timestamp` 是否超出容忍窗口。
|
||
|
||
## 7. 下单设计
|
||
|
||
### 7.1 输入映射
|
||
|
||
`91` 下单请求映射为内部 source event:
|
||
|
||
- `platformOrderId = orderNo`
|
||
- `provider = '91kaquan'`
|
||
- `platform = 'kuaishou'`
|
||
- `shopId = runtimeConfig.platforms.ninetyone.shopId`
|
||
- `shopName = runtimeConfig.platforms.ninetyone.shopName`
|
||
- `payStatus = 'paid'`
|
||
- `orderStatus = 'paid'`
|
||
- `items = [{ externalSkuCode: productNo, externalItemId: productNo, externalSkuName: productNo, quantity: buyNum }]`
|
||
|
||
### 7.2 为什么直接标记 paid
|
||
|
||
`91` 调用异步卡密下单接口时,业务上已经代表买家付款完成,当前项目需要立即进入履约任务创建。
|
||
|
||
### 7.3 对现有链路的复用
|
||
|
||
调用现有:
|
||
|
||
- `upsertOrderFromSource`
|
||
- `resolveOrderItemForFulfillment`
|
||
- `syncDeliveryTasksForOrder`
|
||
|
||
这样可直接复用现有:
|
||
|
||
- 91 商品名到 affiliate-dash sku 的匹配(`resolveAffiliateDashSkuByProductNo`,含 sku 透传兜底);
|
||
- affiliate-dash 履约 task 创建;
|
||
- 按数量拆 task(`delivery-task-service.ts` 按 `unit_index` 为每个数量各建一个 task);
|
||
- claim token / 领取链接生成。
|
||
|
||
### 7.4 成功判定
|
||
|
||
下单接口成功判定标准:
|
||
|
||
- 请求合法;
|
||
- 订单已成功写入;
|
||
- 商品已匹配时至少创建出 1 个 task;
|
||
- 商品未匹配时进入待补全队列。
|
||
|
||
满足以上条件即返回:
|
||
|
||
- `orderStatus = 10`
|
||
|
||
不在下单接口返回 `20`。
|
||
|
||
## 8. 查询设计
|
||
|
||
### 8.1 查询目标
|
||
|
||
查询接口的职责不是看订单是否最终兑换完成,而是判断是否已经具备“可自动发货给买家”的内容。
|
||
|
||
这里的可交付内容就是 `claimUrl`。
|
||
|
||
### 8.2 claimUrl 判定
|
||
|
||
对订单下的每个 task,调用 `resolveTaskDeliveryLink(task)`(`apps/backend/src/services/fulfillment/delivery-link-service.ts`),由该 task 对应的履约执行器解析可交付链接:
|
||
|
||
1. 读取 `primary_claim_token`,为空则回退 `claim_token`;
|
||
2. 同时读取 `primary_claim_expires_at` / `claim_expires_at` 作为过期时间;
|
||
3. 有 token 才返回 `claimUrl = buildClaimUrl(token)`,否则返回 `null`(视为暂不可交付)。
|
||
|
||
说明:`kuaishou_ct_assisted`(乐玩)/ kuaishou-feifei 通道已下线,其历史 task 由只读 stub 处理,不再生成领取链接;领取链接现由 affiliate-dash 执行器的 `resolveDeliveryLink` 提供。
|
||
|
||
### 8.3 查询状态判定
|
||
|
||
- `30`:
|
||
- 订单不存在;
|
||
- 没有匹配到任务;
|
||
- task 进入失败/人工处理态且无法生成领取链接。
|
||
- `10`:
|
||
- 订单存在;
|
||
- 商品履约配置尚未补齐;
|
||
- 任务已创建;
|
||
- 但并非所有 task 都已准备好 `claimUrl`。
|
||
- `20`:
|
||
- 所有 task 都已有可交付的领取链接。
|
||
|
||
## 9. cards 设计
|
||
|
||
### 9.1 card 字段映射
|
||
|
||
每个 task 映射为一个 card:
|
||
|
||
- `cardNo = claimUrl`
|
||
- `cardPwd = ''`
|
||
- `expireTime = claim_expires_at`
|
||
- `jumpLink = claimUrl`
|
||
|
||
### 9.2 编码方案
|
||
|
||
按 [卡密加密说明.md](./卡密加密说明.md) 实现:
|
||
|
||
- 算法:`AES`
|
||
- 模式:`ECB`
|
||
- 填充:`PKCS7Padding`
|
||
- 数据块:`128 位`
|
||
- 输出:`Base64`
|
||
- 密钥:开放平台 `AppSecret`,长度固定 `32` 个字符
|
||
|
||
Node 侧实现等价为:
|
||
|
||
- `aes-256-ecb`
|
||
- 开启自动填充
|
||
- 明文为 `JSON.stringify(cards)`
|
||
- 输出 Base64 字符串
|
||
|
||
编码层仍然保持独立模块,后续如果联调方要求兼容别的编码方式,只替换该模块即可。
|
||
|
||
### 9.3 多数量处理
|
||
|
||
若 `buyNum = N`:
|
||
|
||
- 内部创建 `N` 个 task;
|
||
- 查询成功时返回 `N` 个 card;
|
||
- 每个 card 对应一个独立领取链接。
|
||
|
||
## 10. 商品匹配设计
|
||
|
||
当前项目的 `91` 下单流程直接使用 `productNo` 做履约匹配:
|
||
|
||
- 默认把 `productNo` 当作商品名(中文),经 affiliate-dash 的 SKU 映射表(`apps/backend/src/services/platforms/affiliate-dash/sku-mapping.ts`)转成 affiliate-dash 商品的英文 sku;
|
||
- 若映射表未命中,再用 `productNo` 与 affiliate-dash 商品列表的 sku 做透传兜底匹配(`matchAffiliateDashSkuPassthrough`);
|
||
- 如果 `productNo` 使用 `商品名----店铺编号` 格式,前半段作为商品名匹配、后半段作为快手小店 `shopId`(`parseOpen91ProductNo`);
|
||
- 匹配成功后由履约路由选中 affiliate-dash 执行器,多数量在 `delivery-task-service.ts` 里按数量拆成多个 task。
|
||
|
||
最终目标是让 91 卡券只传商品名,系统按 SKU 映射与透传兜底自动创建 affiliate-dash 履约任务。
|
||
|
||
## 11. 日志与排障
|
||
|
||
建议所有 `91` 请求单独打日志,至少记录:
|
||
|
||
- requestId
|
||
- orderNo
|
||
- productNo
|
||
- query/create
|
||
- 验签结果
|
||
- 命中 SKU
|
||
- orderId
|
||
- task 数量
|
||
- 最终返回的 `orderStatus`
|
||
|
||
## 12. 代码落点
|
||
|
||
建议新增:
|
||
|
||
- `apps/backend/src/routes/open-91.ts`(挂载于 `apps/backend/src/app.ts:103`,前缀 `/api/v1/open/91`)
|
||
- `apps/backend/src/services/open-91/config.ts`
|
||
- `apps/backend/src/services/open-91/payload.ts`
|
||
- `apps/backend/src/services/open-91/crypto.ts`
|
||
- `apps/backend/src/services/open-91/response.ts`
|
||
- `apps/backend/src/services/open-91/order-create-service.ts`
|
||
- `apps/backend/src/services/open-91/order-query-service.ts`
|
||
- `apps/backend/src/services/open-91/consume-on-deliver.ts`
|
||
|
||
需要修改:
|
||
|
||
- `apps/backend/src/config/defaults.ts`
|
||
- `apps/backend/src/config/runtime.ts`
|
||
- `apps/backend/src/config/env-overrides.ts`
|
||
- `apps/backend/src/types/runtime-config.ts`
|
||
- `apps/backend/src/services/platforms/ninetyone/order-service.ts`
|
||
- `apps/backend/src/services/fulfillment/product-resolution-service.ts`
|
||
- `apps/backend/src/index.ts`
|
||
|
||
## 13. 测试范围
|
||
|
||
至少补这些单测:
|
||
|
||
1. 验签:
|
||
- 正常签名通过;
|
||
- 错误签名失败;
|
||
- 空值参数参与签名。
|
||
2. 下单:
|
||
- 业务请求映射为内部 source event;
|
||
- 商品未配置时返回 `10`,并进入后台待补全队列。
|
||
3. 查询:
|
||
- 全部 task 都有链接时返回 `20`;
|
||
- 部分 task 缺链接时返回 `10`;
|
||
- 无订单时返回 `30`。
|
||
4. cards:
|
||
- 单卡编码正确;
|
||
- 多卡数量与 task 数量一致。
|
||
|
||
## 14. 当前阶段不做的事
|
||
|
||
- 不实现 `callbackUrl` 主动回调;
|
||
- 不在 `91` 查询成功后回写快手侧最终兑换结果;
|
||
- 不实现除 `aes-256-ecb-base64` 之外的卡密加密算法;
|
||
- 不新增独立顶级菜单;91 待补全订单目前只有后端管理接口(`/api/v1/admin/platform-config/ninetyone/orders` 查询、`.../:id/retry` 重试、`.../:id/fail` 标记失败),前端暂无专属页面。
|