Files
yml2213 7bacb7d5eb docs: 重构项目文档并补充三块载体、成本中心与人工支出方案
合并本轮全部文档改动(原 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 约束)。
2026-10-17 22:03:03 +08:00

324 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 标记失败),前端暂无专属页面。