113 lines
3.8 KiB
Markdown
113 lines
3.8 KiB
Markdown
# CDR Minimal Billing Runbook
|
||
|
||
> 任务:S23 - 最小计费与余额扣减
|
||
> 完成时间:2026-06-21 19:55 +08:00
|
||
|
||
## 1. 目标
|
||
|
||
S23 在 S22 的 Redis Stream 消费基础上完成最小计费闭环:
|
||
|
||
- 成功通话 CDR 写入 `raw_cdrs` 和 `rated_cdrs`。
|
||
- 按周期秒数和周期费率计算费用。
|
||
- 扣减客户余额,并写入不可变客户余额流水。
|
||
- 同一 `event_id` 或同一 `raw_cdr_id` 不重复扣费。
|
||
|
||
当前系统尚未建立客户侧费率表,因此 S23 使用落地网关的 `billingCycleSec` 和 `cycleRate` 同时作为供应商成本和临时客户费用,`grossProfit` 为 `0.000000`。正式客户费率和利润计算留给后续计费版本。
|
||
|
||
## 2. 计费规则
|
||
|
||
`apps/worker-cdr/src/billing.ts` 提供周期计费函数:
|
||
|
||
```text
|
||
cycles = ceil(duration_sec / billing_cycle_sec)
|
||
bill_sec = cycles * billing_cycle_sec
|
||
amount = cycles * cycle_rate
|
||
```
|
||
|
||
约束:
|
||
|
||
- `duration_sec` 必须为非负整数。
|
||
- `billing_cycle_sec` 必须为 1 到 60 的整数。
|
||
- 金额统一保留 6 位小数。
|
||
- `sip_code` 为 `2xx` 且 `duration_sec > 0` 时才进入计费。
|
||
|
||
失败 CDR、零时长 CDR、缺少客户或落地网关 ID 的 CDR 只写 `raw_cdrs`,`ratingStatus` 标记为 `SKIPPED`,不扣余额。
|
||
|
||
## 3. 写库与幂等
|
||
|
||
`apps/worker-cdr/src/rating.ts` 的 `CdrRatingService` 在单个数据库事务内处理:
|
||
|
||
1. 按 `raw_cdrs.event_id` 查询是否已处理。
|
||
2. 新建 `raw_cdrs`。
|
||
3. 锁定客户余额行:`SELECT ... FOR UPDATE`。
|
||
4. 读取落地网关周期费率。
|
||
5. 新建 `rated_cdrs`。
|
||
6. 新建一条负数 `customer_recharges` 作为扣费流水。
|
||
7. 更新 `customers.balance`。
|
||
8. 将 `raw_cdrs.ratingStatus` 标记为 `RATED`。
|
||
|
||
扣费流水约定:
|
||
|
||
| 字段 | 值 |
|
||
| --- | --- |
|
||
| `amount` | 负数费用 |
|
||
| `idempotencyKey` | `cdr:<event_id>` |
|
||
| `remark` | `CDR_CHARGE:<raw_cdr_id>` |
|
||
| `createdBy` | `worker-cdr` |
|
||
|
||
数据库唯一约束共同防止重复扣费:
|
||
|
||
- `raw_cdrs.event_id`
|
||
- `rated_cdrs.raw_cdr_id`
|
||
- `customer_recharges.idempotencyKey`
|
||
|
||
## 4. Worker 行为
|
||
|
||
`apps/worker-cdr/src/main.ts` 现在连接 Redis 和 MySQL:
|
||
|
||
1. 从 `stream:cdr_payload` 的 `billing-workers` Consumer Group 读取 CDR。
|
||
2. 解析成功后调用 `CdrRatingService.rate(event)`。
|
||
3. 处理成功或重复消息后 ACK Redis 消息。
|
||
4. 处理异常时沿用 S22 死信机制写入 `stream:cdr_deadletter`。
|
||
|
||
必需环境变量:
|
||
|
||
```text
|
||
REDIS_URL
|
||
DATABASE_URL
|
||
```
|
||
|
||
可选环境变量:
|
||
|
||
```text
|
||
CDR_CONSUMER_NAME
|
||
CDR_BLOCK_MS
|
||
CDR_BATCH_SIZE
|
||
CDR_PENDING_IDLE_MS
|
||
LISGLOSIPS_LOG_LEVEL
|
||
```
|
||
|
||
## 5. 验证结果
|
||
|
||
- `corepack pnpm@10.33.0 exec vitest run apps/worker-cdr/src/rating.spec.ts` 通过,3 条测试覆盖周期计费、成功 CDR 扣费幂等、失败 CDR 不扣费。
|
||
- `corepack pnpm@10.33.0 --filter @lisglosips/worker-cdr build` 通过。
|
||
- `corepack pnpm@10.33.0 typecheck` 通过。
|
||
- `corepack pnpm@10.33.0 lint` 通过。
|
||
- `corepack pnpm@10.33.0 build` 通过。
|
||
|
||
本次未连接 B 执行服务部署或真实库写入验证,因为 B sudo 凭据仍未通过校验。S23 仅完成本地代码闭环和构建验收。
|
||
|
||
## 6. 回滚
|
||
|
||
代码回滚:
|
||
|
||
- 恢复 `apps/worker-cdr/src/main.ts` 到 S22 只记录 CDR 的版本。
|
||
- 删除或恢复 `apps/worker-cdr/src/billing.ts`、`apps/worker-cdr/src/rating.ts`、`apps/worker-cdr/src/rating.spec.ts`。
|
||
- 恢复 `apps/worker-cdr/package.json` 和 `apps/worker-cdr/tsconfig.json`。
|
||
- 重新执行 `corepack pnpm@10.33.0 build`。
|
||
|
||
数据回滚:
|
||
|
||
- S23 未执行真实数据库写入验证,无需清理远端数据。
|
||
- 若后续环境已运行 worker 并产生扣费数据,回滚前必须先停止 worker,记录待回滚的 `event_id`、`raw_cdr_id`、`rated_cdr_id` 和客户流水;不得直接删除余额流水,应通过受控反向流水或备份恢复方案处理。
|