billing-service/docs/design.md
2026-04-23 15:59:41 +08:00

267 lines
8.5 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.

# billing-service 设计说明
本文档面向维护 `billing-service` 的后端工程师,描述当前代码实现下的系统设计、模块边界、主执行流程,以及回放安全与幂等约束。
补充阅读:
- [architecture.md](architecture.md):部署拓扑、系统边界、目标架构
- [api.md](api.md)HTTP 任务接口、上下游契约
- [reference/cmd.md](reference/cmd.md):进程入口与依赖装配
- [reference/service.md](reference/service.md):服务层与核心业务函数
## 1. 定位与职责
`billing-service` 是 Cloud Network Billing & Control Plane 里的计费写模型。它不承担面向用户的查询职责,只负责把 exporter 提供的累计流量快照转换成可回放、可幂等的分钟计费事实,并写入 PostgreSQL。
当前职责边界:
- 上游:从一个或多个 `xray-exporter` 拉取窗口化快照
- 中间:做来源校验、累计值差分、分钟桶写入、账本写入、配额状态更新、同步状态推进
- 下游:写入与 `accounts.svc.plus` 共享的 `account` 数据库
- 对外:暴露运维任务接口和状态接口,不提供用户账单查询接口
## 2. 运行入口与模块装配
唯一进程入口是 [cmd/billing-service/main.go](/Users/shenlan/workspaces/cloud-neutral-toolkit/billing-service/cmd/billing-service/main.go)。
启动顺序:
1. `config.Load()` 读取环境变量并构造 `config.Config`
2. `sql.Open("pgx", cfg.DatabaseURL)` 建立 PostgreSQL 连接
3. `service.New(cfg, exporter.NewClient(...), repository.NewPostgres(db))` 装配核心依赖
4. `svc.Start(ctx)` 启动后台定时采集循环
5. `httpapi.New(svc).Routes()` 注册 HTTP 路由
6. `http.Server.ListenAndServe()` 启动服务
7. 收到 `SIGINT` / `SIGTERM` 后触发 `Shutdown`
### 模块依赖图
```mermaid
flowchart LR
Main["cmd/billing-service/main.go"]
Config["internal/config"]
HTTP["internal/httpapi"]
Service["internal/service"]
Exporter["internal/exporter"]
Repo["internal/repository"]
Model["internal/model"]
DB["PostgreSQL"]
Upstream["xray-exporter"]
Main --> Config
Main --> Exporter
Main --> Repo
Main --> Service
Main --> HTTP
Service --> Config
Service --> Repo
Service --> Model
Service --> Exporter
HTTP --> Service
Exporter --> Config
Exporter --> Model
Repo --> Model
Repo --> DB
Exporter --> Upstream
```
模块职责:
- `internal/config`:环境变量解析、默认值、来源列表装配、镜像信息拆解
- `internal/model`:上游快照、持久化实体、状态返回对象
- `internal/exporter`:对 `xray-exporter` 的 HTTP 客户端
- `internal/repository`PostgreSQL 读写适配层
- `internal/service`:窗口推进、差分、计费、幂等与状态机
- `internal/httpapi`:任务触发和状态查询 HTTP 层
## 3. 一次 collect-and-rate 的主流程
HTTP `POST /v1/jobs/collect-and-rate` 和后台 ticker 都会进入 `service.Service.RunCollectAndRate`。当前主路径如下:
```mermaid
sequenceDiagram
participant API as httpapi / ticker
participant S as service.Service
participant R as repository.Repository
participant E as exporter.Client
API->>S: RunCollectAndRate(ctx, "collect-and-rate")
S->>S: 初始化 JobResult
loop 每个 enabled source
S->>R: GetSourceSyncState(sourceID)
S->>R: UpsertSourceSyncState(记录 attempted_at)
S->>E: FetchWindow(source, since, until, limit, cursor)
loop 每个 snapshot
S->>S: validateSnapshotSource
loop 每个 sample
S->>S: validateSample
S->>R: GetCheckpoint
S->>R: GetBillingProfile
S->>R: UpsertMinuteBucket
S->>R: GetQuotaState
S->>R: UpsertLedger
opt 新账本
S->>R: UpsertQuotaState
end
S->>R: UpsertCheckpoint
end
end
S->>R: UpsertSourceSyncState(记录 completed_until / succeeded_at)
end
S->>S: record(result)
```
### 主流程中的关键决定
1. 按来源串行处理
`RunCollectAndRate` 逐个遍历 `cfg.ExporterSources`,当前实现没有并发采集来源。
2. 以 source sync state 推进窗口
`collectSource` 基于 `billing_source_sync_state.last_completed_until` 计算下一次拉取窗口,并固定带 2 分钟重叠。
3. 以 checkpoint 做累计值差分
`processSample``traffic_stat_checkpoints` 读取上次累计值,计算本次分钟增量。
4. 先写分钟桶,再写账本,再更新配额
这保证数据层面先有流量事实,再有收费结果,最后才更新账户余额与剩余额度。
5. 以幂等写控制回放
分钟桶主键和账本主键都可重复 upsert账本命中已有记录时不再次扣减余额或配额。
## 4. 回放安全与幂等约束
当前实现依赖以下约束保证“重复拉取窗口不会重复扣费”:
### 4.1 来源窗口重叠
- 常量:`sourceWindowOverlap = 2 * time.Minute`
- 每次采集窗口都会与上一轮完成位置重叠 2 分钟
- 目的:容忍边界分钟重复返回或轻微时钟漂移
### 4.2 分钟桶主键去重
`traffic_minute_buckets` 主键:
- `bucket_start`
- `node_id`
- `account_uuid`
- `region`
- `line_code`
同一分钟、同节点、同账户、同地域、同线路的桶再次写入只会更新,不会生成第二条记录。
### 4.3 账本 ID 确定性生成
`deterministicLedgerID(bucket)` 基于以下字段生成 SHA-1 UUID
- `bucket_start`
- `node_id`
- `account_uuid`
- `region`
- `line_code`
因此同一个分钟桶重复处理时会命中同一条账本记录。
### 4.4 配额只在“新账本”时变更
`processSample` 只有在 `UpsertLedger` 返回 `ledgerExisted == false` 时才会:
- 扣减 `RemainingIncludedQuota`
- 更新 `CurrentBalance`
- 推进 `LastRatedBucketAt`
- 写回 `account_quota_states`
这避免回放窗口重复扣费。
### 4.5 负差分重置保护
如果 exporter 返回的累计值比 checkpoint 更小,当前实现认为上游累计计数器发生了重置:
- 增加 `reset_epoch`
- 直接更新 checkpoint
- 不生成分钟桶、不生成账本、不更新配额
这条路径的目标是防止负流量差分污染账本。
## 5. 当前配置来源
配置由 `internal/config.Load()` 从环境变量读取。关键项:
- 必填:`DATABASE_URL`、`INTERNAL_SERVICE_TOKEN`
- 来源配置主路径:`EXPORTER_SOURCES_JSON`
- 当前兼容路径:`EXPORTER_BASE_URL`
- 监听与计费默认值:`LISTEN_ADDR`、`COLLECT_INTERVAL`、`SOURCE_REVISION`、`PRICE_PER_BYTE`、`INITIAL_INCLUDED_QUOTA_BYTES`、`INITIAL_BALANCE`
当前建议:
- 使用 `EXPORTER_SOURCES_JSON` 明确声明一个或多个来源
- 仅把 `EXPORTER_BASE_URL` 视为当前仍保留的兼容入口,不作为主设计
详细字段见 [reference/config.md](reference/config.md)。
## 6. 数据持久化设计
当前实现直接依赖以下 PostgreSQL 表:
- `traffic_stat_checkpoints`
- `traffic_minute_buckets`
- `billing_ledger`
- `account_quota_states`
- `account_billing_profiles`
- `billing_source_sync_state`
表结构参考:
- [../sql/billing-service-schema.sql](../sql/billing-service-schema.sql)
- [reference/repository.md](reference/repository.md)
数据职责分层:
- checkpoint累计值差分基线
- minute bucket分钟级流量事实
- ledger收费事实与余额快照
- quota state账户剩余额度、余额、欠费与节流状态
- billing profile按账户覆盖默认定价
- source sync state来源窗口推进与失败信息
## 7. 上下游边界
### 上游边界
当前代码事实:
- 使用 `GET /v1/snapshots/window`
- 通过 `Authorization: Bearer <INTERNAL_SERVICE_TOKEN>` 认证
- 查询参数:`since`、`until`、`limit`、`cursor`
`service.collectSource` 还会基于 `ExpectedNodeID``ExpectedEnv` 校验来源是否与配置匹配。
### 下游边界
`billing-service` 只写 PostgreSQL不承担用户查询。
读路径固定为:
`console.svc.plus` -> `accounts.svc.plus` -> PostgreSQL
因此 `billing-service``/v1/status` 仅用于运维,不是用户账单查询 API。
## 8. 当前实现与后续演进
### 当前实现
- 服务内串行遍历来源
- 依赖 exporter 提供窗口接口
- 定价规则以默认配置 + `account_billing_profiles` 覆盖为主
- 写路径与 `accounts.svc.plus` 共享同一个 `account` 数据库
### 后续演进
未来目标和跨节点 HTTPS 架构,继续以以下文档为准:
- [architecture.md](architecture.md)
- [api.md](api.md)
- [multi-node-https-plan.md](multi-node-https-plan.md)
本设计文档不把目标态混入当前实现说明;如果未来代码演进到目标态,应同步更新本文件与 `docs/reference/*`