Files
sundynix-agentix/SAAS_P2_DESIGN.md
T
Blizzard d032c198c8 feat: SaaS P2 计量·增量1 —— 用量按租户落持久明细 + 积分/成本折算
链路打通 tenant → 计费事实源:
- 契约:Task.Meta 加 MetaTenantID;UsageEvent 加 TenantID。
- 提交:网关 task.Meta[MetaTenantID]=tenantID(c);dispatcher emitUsage 带租户。
- 明细表 sundynix_usage_event(追加式,task_id 唯一→幂等防重投重复计费):
  tenant/owner/model/tokens + credits_micro + cost_micros。
- 折算:credits=total_tok/TOKENS_PER_CREDIT×credit_weight(token 基准,设 1 即 token 直计);
  cost 按 Pricing 折算;Pricing 加 credit_weight 列(每模型积分权重,缺省 1)。
  模型名空则回退激活 chat 模型(近似,忽略 failover 备用模型,已在设计标注)。
- 网关 SubscribeUsage 折算落明细(保留 Redis 日计数作快速配额校验)。

live 验证:提交任务→一行 usage_event,tenant 匹配用户租户、
credits=89tok/1000×2×1e6=178000 微积分、cost=45/1000×1+44/1000×2=133000 微元 CNY,
折算数学与幂等键均正确。

设计见 SAAS_P2_DESIGN.md。增量2(credit_ledger 余额软扣 + rollup)待做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:54:29 +08:00

143 lines
8.2 KiB
Markdown
Raw 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.
# SaaS P2 —— 用量计量设计(Usage Metering
> 承接 P1(tenant_id 全链路已闭合,见 `SAAS_DESIGN.md` / DEPTH_ROADMAP T4.A)。
> 本阶段目标:让 token 用量可**按租户持久归集**,成为**计费 / 配额**的事实源。
> 纯后端、自包含;桌面端不改;不接支付(P5)。
---
## 1. 目标与非目标
**目标**
- token 用量按 **tenant** 维度持久落库(不再只有易失的 Redis 日计数)。
- 计量单位为**积分(credits)**:可配 token↔积分汇率,扣量走扣积分;
**「按 token 直计」= 汇率 1:1 的退化情形**(同一条代码路径,配置切换,不做两套)。
- 每条用量同时按 **Pricing 折算成金额**(内部账/毛利),可对账、可回溯重算。
-**租户 / 天** rollup,供 admin 查用量 + 积分消耗 + 成本趋势。
**非目标(划清边界,不过早做)**
- ❌ 支付 / 发票 / 订阅扣款 —— P5。
- ❌ 套餐配额 enforcement(超额拒绝)—— P4,需先有 plan→quota 的产品定义。
- ❌ 实时流式计量 —— 收尾一次事件已够,不做 per-token 流。
- ❌ 前端计费页 —— 本阶段只出接口 + 数据,页面后续。
---
## 2. 现状(已有的一半)
```
提交(gateway) task.Meta[MetaUserID]=userID ──PublishTask──▶ dispatcher
dispatcher 收尾 emitUsage ── UsageEvent{task,user,model,tok...} ──▶ gateway
gateway SubscribeUsage ── AddUsage(user,day,tok) ──▶ Redis 计数(48h TTL)
门控 提交前 GetUsage(user,today) vs USER_DAILY_TOKEN_BUDGET
```
**够什么**:单用户当日预算门控。
**缺什么**:① 无 tenant 维度;② Redis 48h 易失,计费要永久可审计;③ 有 `Pricing`input/output per 1K)但没折算金额;④ 无聚合,查趋势要现算。
---
## 3. 设计
### 3.1 tenant_id 打通链路(接 P1
- **提交**`task.Meta[MetaTenantID] = tenantID(c)`(网关已有 `tenantID(c)` 助手)。
- **契约**`contract.UsageEvent``TenantID string`
- **dispatcher**`emitUsage``t.Meta[MetaTenantID]` 读租户,带进事件。
- 一处新增 Meta 键、一个契约字段、一行读取 —— 复用今天刚打通的 owner/tenant 提交链。
### 3.2 持久明细表 `sundynix_usage_event`(追加式,只增不改)
| 列 | 说明 |
|---|---|
| id / created_at | 雪花 + 时间(BaseModel |
| tenant_id / owner | 租户 + 提交者 user.id(隔离 + 归属) |
| task_id | 关联任务 |
| model | 计费模型名 |
| prompt_tok / comp_tok / total_tok | 输入 / 输出 / 合计 |
| credits_micro | 折算积分,**int64 微积分**(×10⁻⁶),避取整损耗;展示时约整 |
| cost_micros | 折算金额,**int64 微单位**(币种最小单位 ×10⁻⁶),避浮点累积误差 |
| currency | 币种(跟 Pricing |
| exceeded | 是否触顶单任务预算被中止 |
- 每条 `UsageEvent` 落一行 —— **审计 / 对账 / 重算的事实源**
- 为何不复用 Redis:Redis 48h 就没了;计费要永久、可审计、可按新价重算历史。
- 受租户表(标 `isTenantScoped()`);但消费在 background ctxNATS 回写)→ tenant 从事件显式带,不靠插件自动填(与 Eval 同模式)。
### 3.3 成本折算
- 落库时查 `Pricing[model]``cost = prompt/1000*input_per_1k + comp/1000*output_per_1k`,存 `cost_micros`(整数)。
- **Pricing 缺失** → cost=0 + 照落事件(**计量不因缺价而丢量**;缺价可事后补价重算)。
- tokens 是「估算」(契约原注释)→ 计费口径认这个近似;要精确则后续接 provider 真实 usage(已知项,非本轮)。
### 3.4 聚合表 `sundynix_usage_rollup`(租户 / 天 快照)
| 列 | 说明 |
|---|---|
| tenant_id + day | 联合唯一(`20060102` |
| total_tok / credits_micro / cost_micros / task_count | 当天累加 |
| currency | 币种 |
- 消费 `UsageEvent` 时**同步 upsert**`ON CONFLICT(tenant_id,day) DO UPDATE ... = 原值 + 增量`)。
- 明细表(event)给审计/重算,快照表(rollup)给快速读 —— 经典 append + materialized。
- 配额/账单查 rollup **一行**,不扫明细。
### 3.6 计量单位:积分(credits)与 token 汇率
**核心**:积分是租户面唯一计量/扣量单位;token 直计 = 汇率 1:1 的退化。一条路径,不做两套。
**汇率配置**admin 可配)
- 全局默认 `TOKENS_PER_CREDIT`(如 1000 token = 1 积分;设 1 即 token 直计)。
- 可选 **每模型权重** `credit_weight`premium 模型每 token 烧更多积分)——复用 `Pricing`
表加一列 `credit_weight float64`(缺省 1.0),不新建表。
- 落库:`credits_micro = total_tok * 1e6 / TOKENS_PER_CREDIT * credit_weight`(整数运算,保精度)。
**积分账本 `sundynix_credit_ledger`append-only,扣量的事实源)**
| 列 | 说明 |
|---|---|
| tenant_id | 租户 |
| kind | `grant`(充值/发放,+ / `usage`(消耗,−) / `adjust`(人工校正) |
| credits_micro | 带符号增量 |
| ref | 关联(usage→task_idgrant→订单/发放单号) |
| memo | 备注 |
- 租户当前余额 = `SUM(credits_micro)`;为快取在 `sundynix_tenant.credit_balance_micro`
维护物化余额(消费/充值时增量更新),查询读一列,不 SUM 全账本。
- **一致性**:先写 ledger 明细,再增量改 balancebalance 可由 ledger 随时重算兜底。
- 幂等:`usage` 分录对 `(tenant_id, ref=task_id, kind)` 唯一,防重投重复扣分。
**扣量 vs 拦截(分级,别过早强制)**
- **P2(本阶段,记账)**:每条 usage 记 `usage` 分录 + 扣物化余额。余额可为负(软扣,先记账不拦)。
- **P4enforcement**:提交前查余额≤0 则拒绝新任务(硬闸)。需先有**套餐→发放积分**的产品定义,故 enforcement 留后。
开关 `CREDIT_ENFORCE`(默认 off) 控制软/硬,schema 现在就备好,改行为时不改表。
### 3.5 观测接口(admin,系统级)
- `GET /api/v1/admin/usage?tenant=&from=&to=` → 从 rollup 出某租户(或全平台)用量 + 积分消耗 + 成本趋势,附租户当前积分余额。
-`store.WithoutTenant`(系统级跨租户,与 admin/overview 同口径)。
- 前端页后续;本阶段先接口 + 数据。
---
## 4. 落地顺序(增量)
- **增量1 — 链路 + 明细 + 折算**:`MetaTenantID` + `UsageEvent.TenantID` + dispatcher 带租户;
`Pricing.credit_weight` 列 + `TOKENS_PER_CREDIT` 配置;`usage_event` 表 + 消费时折算 credits/cost 落明细。
live 验证:跑一个任务 → 一行带 tenant + owner + credits + cost。
- **增量2 — 账本 + 余额 + rollup**`credit_ledger` 表(usage 分录,幂等)+ `tenant.credit_balance_micro`
物化余额(软扣,可负);`usage_rollup` 表 + upsert。
live 验证:多任务 → 余额递减正确、rollup 累加正确。
- **增量3 — 观测接口**`GET /admin/usage`(用量 + 积分 + 成本 + 余额)。
- (前端用量页 / P4 硬拦截 `CREDIT_ENFORCE` / 充值发放 —— 后续,需求拉动)
---
## 5. 权衡与已知风险(诚实标注,不一次做满)
1. **投递可靠性**`UsageEvent` 现走普通 NATS、收尾 best-effort**丢事件=丢计费**。
→ 本轮先落表(已远强于 Redis)。若要**严格计费**,把 usage 升级到 **JetStream 持久投递**(像入库队列那样带重投/幂等)——列为已知项,等"要精确对账"的需求真来了再上,不提前做。
→ 幂等兜底:`usage_event` 可对 `task_id` 加唯一约束(一任务一计量),防重投重复计费。
2. **成本依赖 Pricing 配全**:缺价记 0 不丢量,可事后补价重算明细 → 修 rollup。
3. **token 估算**:计费认近似值;要精确接 provider usage 是后续。
4. **rollup 与 event 一致性**:先写 event 再 upsert rollup;若 rollup 失败,event 仍在 → 可由 event 重建 rollup(提供一个重算命令兜底)。
---
## 6. 与 GPT 重构方案的关系
- 采纳其正确诊断:**usage 先落事件、不接支付**(P2 与 P5 解耦)。
- 不引入独立计费服务 / 消息中间件重构 —— 复用现有 NATS + PG,按需求拉动。