下面我把 **Phase 1 最关键的「计费系统」** 完整展开，并给你一份**可直接下载/复制的 Markdown 文档**，你可以直接放进项目 `docs/` 目录，也可以直接粘贴给 Qoder Quest 生成 Spec 和代码骨架。

---

# AI PaaS 计费系统 · 技术规格文档（Qoder 投喂版）

> **文档定位**：本文档是 Phase 1 MVP 中「计费系统」的完整技术规格，供 Qoder Quest 生成 Story / Spec / 代码骨架使用，也供人工 Review 时对照。
>
> **安全等级**：🔴 **人 Design + 人写核心，AI 只补测试和胶水代码**
>
> **文件路径建议**：`docs/billing-spec.md`

---

## 一、计费系统总览

### 1.1 业务目标

- 平台向用户（租户）提供 AI 能力（Agent / MCP / Skill / LLM 调用）
- 每次调用按 **Token 消耗量** 实时扣费
- 支持 **订阅套餐（月度/年度）+ 按量超出（Pay-as-you-go）** 混合模式
- 支持 **Skill 交易分成**（平台抽成 20%~30%，Phase 2 再做，本期预留字段）
- 所有金额/余额以 **Credit（积分）** 为内部统一单位，1 Credit = 1000 tokens（不同模型有系数折算）

### 1.2 核心设计原则

| 原则 | 说明 |
|---|---|
| **不超卖** | 先扣费后服务，原子操作，绝不允许"调用成功但余额为负" |
| **不丢数** | 即使服务重启、网络抖动，计量数据最终一致，对账可修复 |
| **幂等** | 同一请求多次回调/重试，绝不重复记账 |
| **可审计** | 每一笔 Credit 变动都有 Trace_ID + 原因 + 关联资源 |
| **实时可见** | Dashboard 上 Token 消耗和余额接近实时（秒级延迟可接受） |

### 1.3 计费架构图

```
┌─────────────────────────────────────────────────────────────┐
│                     API Gateway (Higress)                    │
│              注入 tenant_id / user_id / trace_id             │
└──────────────────────────┬──────────────────────────────────┘
                           │ 转发请求
┌──────────────────────────▼──────────────────────────────────┐
│              LLM Router (LiteLLM / 自研适配层)               │
│         调用完成后返回 usage: {prompt, completion, total}     │
└──────────────────────────┬──────────────────────────────────┘
                           │ 上报用量事件
┌──────────────────────────▼──────────────────────────────────┐
│              计费服务 (billing-service)                      │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ Redis Lua   │  │  Kafka Topic │  │  日终对账 Cron   │   │
│  │ 实时计量    │─▶│  usage.events│─▶│  修复不一致数据  │   │
│  └─────────────┘  └──────────────┘  └──────────────────┘   │
│         │                                  │                │
│         ▼                                  ▼                │
│  ┌─────────────┐                  ┌──────────────────┐     │
│  │ Redis 余额  │                  │ PostgreSQL       │     │
│  │ + 流水缓存 │                  │ 永久账单 + 对账  │     │
│  └─────────────┘                  └──────────────────┘     │
└─────────────────────────────────────────────────────────────┘
```

---

## 二、Credit 模型与定价

### 2.1 统一内部单位：Credit

```yaml
base_unit: "Credit"
conversion:
  default: "1 Credit = 1000 tokens"
  per_model_coefficient:
    deepseek-chat:    1.0   # 1 Credit / 1000 tokens
    deepseek-reasoner: 2.0  # 2 Credits / 1000 tokens
    qwen-plus:        1.5
    gpt-4o:           10.0
    claude-sonnet:    8.0
```

### 2.2 订阅套餐（示例）

| 套餐 | 月费 | 包含 Credits | 超出单价 | QPS 限制 |
|---|---|---|---|---|
| Free | ¥0 | 100 | 不可用 | 2 |
| Pro | ¥99 | 50,000 | ¥0.01/Credit | 20 |
| Team | ¥499 | 300,000 | ¥0.008/Credit | 100 |

### 2.3 Skill 交易定价（Phase 2 预留）

```yaml
skill_pricing:
  model: "per_call" | "per_token" | "subscription"
  platform_fee_rate: 0.25  # 平台抽成 25%
  developer_share: 0.75
```

---

## 三、实时计量：Redis + Lua

### 3.1 为什么用 Redis Lua

- **原子性**：扣余额 + 记流水在一个 Lua 脚本里完成，不存在"扣了余额没记流水"的中间态
- **高性能**：单次调用 < 1ms，支撑高并发
- **可重入**：通过 `idempotency_key` 实现幂等，重复请求直接返回之前的结果

### 3.2 Redis Key 设计规范

```
# 租户余额（实时）
billing:balance:{tenant_id}  →  String, Credit 数量（整数，单位：毫 Credit 避免浮点）

# 幂等去重（24h TTL）
billing:idempotency:{tenant_id}:{idempotency_key}  →  Hash {result, credit_change, remaining}

# 今日用量统计（按天聚合，TTL 48h）
billing:daily:{tenant_id}:{YYYY-MM-DD}  →  Hash {total_tokens, total_credits, call_count, by_model:json}

# 订阅配额（如有）
billing:subscription:{tenant_id}  →  Hash {plan, credits_included, credits_used, period_end}
```

### 3.3 核心 Lua 脚本：`metering.lua`

```lua
-- KEYS[1]: balance_key
-- KEYS[2]: idempotency_key
-- KEYS[3]: daily_stats_key
-- KEYS[4]: subscription_key (可选，为空则跳过)
--
-- ARGV[1]: tenant_id
-- ARGV[2]: idempotency_key
-- ARGV[3]: credit_cost (毫 Credit)
-- ARGV[4]: token_count
-- ARGV[5]: model_name
-- ARGV[6]: current_timestamp
-- ARGV[7]: subscription_check ("1" = check, "0" = skip)

-- 1. 幂等检查
if redis.call("EXISTS", KEYS[2]) == 1 then
  return redis.call("GET", KEYS[2])
end

-- 2. 订阅配额检查（如果需要）
if ARGV[7] == "1" and KEYS[4] ~= "" then
  local sub = redis.call("HGETALL", KEYS[4])
  -- 简化逻辑：检查剩余订阅额度是否足够
  -- 实际实现需解析 hash 字段
end

-- 3. 余额检查
local current_balance = tonumber(redis.call("GET", KEYS[1]) or "0")
local cost = tonumber(ARGV[3])

if current_balance < cost then
  return cjson.encode({error = "INSUFFICIENT_BALANCE", balance = current_balance})
end

-- 4. 扣减余额
redis.call("DECRBY", KEYS[1], cost)

-- 5. 记录幂等结果
local result = cjson.encode({
  success = true,
  credit_cost = cost,
  remaining_balance = current_balance - cost,
  timestamp = ARGV[6]
})
redis.call("SET", KEYS[2], result, "EX", 86400)

-- 6. 更新今日统计
redis.call("HINCRBY", KEYS[3], "total_tokens", tonumber(ARGV[4]))
redis.call("HINCRBY", KEYS[3], "total_credits", cost)
redis.call("HINCRBY", KEYS[3], "call_count", 1)
redis.call("EXPIRE", KEYS[3], 172800)  -- 48h TTL

-- 7. 按模型统计
local by_model = redis.call("HGET", KEYS[3], "by_model") or "{}"
local model_stats = cjson.decode(by_model)
model_stats[ARGV[5]] = (model_stats[ARGV[5]] or 0) + tonumber(ARGV[4])
redis.call("HSET", KEYS[3], "by_model", cjson.encode(model_stats))

return result
```

### 3.4 Go 调用侧伪代码

```go
func (s *BillingService) MeterUsage(ctx context.Context, req MeterRequest) (*MeterResult, error) {
    // 构造 Redis Keys
    keys := []string{
        fmt.Sprintf("billing:balance:%s", req.TenantID),
        fmt.Sprintf("billing:idempotency:%s:%s", req.TenantID, req.IdempotencyKey),
        fmt.Sprintf("billing:daily:%s:%s", req.TenantID, time.Now().Format("2006-01-02")),
        fmt.Sprintf("billing:subscription:%s", req.TenantID),
    }

    args := []interface{}{
        req.TenantID,
        req.IdempotencyKey,
        req.CreditCostMilli, // 毫 Credit
        req.TokenCount,
        req.ModelName,
        time.Now().Unix(),
        "1", // subscription_check
    }

    result, err := s.redis.Eval(ctx, meteringLuaScript, keys, args...).Result()
    if err != nil {
        return nil, fmt.Errorf("redis eval failed: %w", err)
    }

    // 解析结果，发送 Kafka 事件供异步落库
    s.kafkaProducer.Publish(ctx, "usage.events", UsageEvent{
        TenantID:        req.TenantID,
        TraceID:         req.TraceID,
        CreditCost:      req.CreditCostMilli,
        TokenCount:      req.TokenCount,
        Model:           req.ModelName,
        Timestamp:       time.Now(),
        IdempotencyKey:  req.IdempotencyKey,
    })

    return parseResult(result), nil
}
```

---

## 四、异步持久化：Kafka → PostgreSQL

### 4.1 Kafka Topic 设计

```yaml
topic: usage.events
partition_key: tenant_id  # 保证同一租户的事件顺序
retention: 7d
replication_factor: 3
```

### 4.2 PostgreSQL 表结构

```sql
-- 租户余额表（实时从 Redis 同步，也可作为兜底）
CREATE TABLE tenant_balances (
    tenant_id       UUID PRIMARY KEY,
    credit_balance  BIGINT NOT NULL DEFAULT 0,  -- 毫 Credit
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    version         BIGINT NOT NULL DEFAULT 0   -- 乐观锁
);

-- 账单流水表（不可变，追加写）
CREATE TABLE credit_transactions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES tenants(id),
    transaction_type TEXT NOT NULL, -- 'usage', 'topup', 'refund', 'subscription_renew'
    amount          BIGINT NOT NULL, -- 正=收入，负=扣费（毫 Credit）
    balance_after   BIGINT NOT NULL,
    reason          TEXT NOT NULL,
    ref_type        TEXT,            -- 'capability_invocation', 'subscription', 'skill_purchase'
    ref_id          UUID,
    trace_id        TEXT,
    idempotency_key TEXT UNIQUE NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_credit_txns_tenant_time ON credit_transactions(tenant_id, created_at DESC);
CREATE INDEX idx_credit_txns_trace ON credit_transactions(trace_id);

-- Token 用量明细表（按月分区）
CREATE TABLE token_usage (
    id              UUID NOT NULL DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    capability_id   UUID,
    capability_type TEXT NOT NULL, -- 'agent', 'mcp', 'skill', 'llm_direct'
    model           TEXT NOT NULL,
    prompt_tokens   INT NOT NULL,
    completion_tokens INT NOT NULL,
    total_tokens    INT NOT NULL,
    cost_credits    BIGINT NOT NULL, -- 毫 Credit
    trace_id        TEXT NOT NULL,
    invoked_by      UUID, -- user_id
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- 按月分区示例
CREATE TABLE token_usage_y2026m01 PARTITION OF token_usage
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE INDEX idx_token_usage_tenant ON token_usage(tenant_id, created_at DESC);
CREATE INDEX idx_token_usage_capability ON token_usage(capability_id, created_at DESC);

-- RLS 策略
ALTER TABLE credit_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE token_usage ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON credit_transactions
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

CREATE POLICY tenant_isolation ON token_usage
    USING (tenant_id = current_setting('app.current_tenant')::UUID);
```

### 4.3 Consumer 写入逻辑（Go）

```go
func (c *UsageConsumer) Consume(event UsageEvent) error {
    tx := c.db.Begin()

    // 1. 幂等检查（双保险，Redis 已有但 DB 也要防）
    var exists bool
    tx.Raw("SELECT EXISTS(SELECT 1 FROM credit_transactions WHERE idempotency_key = ?)", event.IdempotencyKey).Scan(&exists)
    if exists {
        return nil // 已处理，跳过
    }

    // 2. 更新余额
    tx.Exec("UPDATE tenant_balances SET credit_balance = credit_balance - ?, updated_at = NOW() WHERE tenant_id = ?", event.CreditCost, event.TenantID)

    // 3. 插入流水
    tx.Exec("INSERT INTO credit_transactions (tenant_id, transaction_type, amount, reason, trace_id, idempotency_key) VALUES (?, 'usage', ?, ?, ?, ?)",
        event.TenantID, -event.CreditCost, "API invocation", event.TraceID, event.IdempotencyKey)

    // 4. 插入用量明细
    tx.Exec("INSERT INTO token_usage (tenant_id, model, total_tokens, cost_credits, trace_id) VALUES (?, ?, ?, ?, ?)",
        event.TenantID, event.Model, event.TokenCount, event.CreditCost, event.TraceID)

    return tx.Commit().Error
}
```

---

## 五、日终对账（Daily Reconciliation）

### 5.1 为什么需要对账

- Redis Lua 扣费成功，但 Kafka 消息丢失 → DB 少记
- Consumer 崩溃 → 部分事件未落库
- 网络分区 → 双写不一致

### 5.2 对账流程

```
每日 02:00 AM（低峰期）
    │
    ├── 1. 快照 Redis 当日汇总（billing:daily:{tenant}:{yesterday}）
    │
    ├── 2. 查询 PG 昨日流水（credit_transactions WHERE DATE(created_at) = yesterday）
    │
    ├── 3. 逐租户比对：
    │     Redis.total_credits ≈ SUM(PG.amount WHERE type='usage')
    │
    ├── 4. 差异处理：
    │     ├── Redis > PG → 补写 PG 流水 + 用量明细（标记为 reconciled）
    │     ├── Redis < PG → 告警人工介入（不应发生，可能是 Redis 过期）
    │     └── 余额不一致 → 以 PG 为准修复 Redis（SET balance = PG.balance）
    │
    └── 5. 生成对账报告（存 S3 / 发邮件给管理员）
```

### 5.3 对账 SQL 示例

```sql
-- 找出 Redis 和 PG 不一致的租户
WITH redis_summary AS (
    -- 从 Redis 导出的 CSV 临时表（通过外部脚本导入）
    SELECT tenant_id, total_credits, total_tokens
    FROM redis_daily_snapshot
    WHERE date = '2026-01-15'
),
pg_summary AS (
    SELECT
        tenant_id,
        ABS(SUM(amount)) as total_credits,
        SUM(total_tokens) as total_tokens
    FROM credit_transactions ct
    LEFT JOIN token_usage tu ON ct.trace_id = tu.trace_id
    WHERE DATE(ct.created_at) = '2026-01-15'
      AND ct.transaction_type = 'usage'
    GROUP BY tenant_id
)
SELECT
    r.tenant_id,
    r.total_credits as redis_credits,
    COALESCE(p.total_credits, 0) as pg_credits,
    r.total_credits - COALESCE(p.total_credits, 0) as diff
FROM redis_summary r
LEFT JOIN pg_summary p ON r.tenant_id = p.tenant_id
WHERE ABS(r.total_credits - COALESCE(p.total_credits, 0)) > 0;
```

### 5.4 修复脚本（Go）

```go
func (r *Reconciler) FixDiscrepancy(ctx context.Context, diff Discrepancy) error {
    // Redis > PG：补写 PG
    if diff.RedisCredits > diff.PGCredits {
        missing := diff.RedisCredits - diff.PGCredits
        r.db.Exec("INSERT INTO credit_transactions (...) VALUES (?, 'usage_reconciled', ?, 'Reconciliation fix', ?, ?)",
            diff.TenantID, -missing, diff.TraceID, fmt.Sprintf("reconcile_%s", uuid.New()))

        // 修复余额
        r.db.Exec("UPDATE tenant_balances SET credit_balance = credit_balance - ? WHERE tenant_id = ?", missing, diff.TenantID)
    }
    return nil
}
```

---

## 六、支付对接（Stripe + 支付宝）

### 6.1 充值流程

```
用户点击充值 → 选择金额 → 创建 PaymentIntent（Stripe）/ 预下单（支付宝）
    │
    ├── Stripe: payment_intents.create → client_secret → 前端确认支付
    │
    ├── 支付宝: alipay.trade.precreate → QR Code → 用户扫码
    │
    └── Webhook 回调（🔴 幂等关键）
          │
          ├── idempotency_key = "payment_{provider}_{payment_id}"
          │
          ├── 验证签名（Stripe signature / 支付宝公钥）
          │
          ├── 状态机校验（pending → succeeded，不允许倒退）
          │
          ├── Redis INCRBY balance
          │
          └── INSERT credit_transactions (type='topup', amount=+, idempotency_key=...)
```

### 6.2 Webhook 幂等代码骨架

```go
func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("Stripe-Signature")

    event, err := webhook.ConstructEvent(payload, sig, h.webhookSecret)
    if err != nil { ... }

    // 幂等：同一 event.ID 绝不处理两次
    idempotencyKey := fmt.Sprintf("stripe_%s", event.ID)
    if h.isProcessed(idempotencyKey) {
        w.WriteHeader(http.StatusOK)
        return
    }

    switch event.Type {
    case "payment_intent.succeeded":
        pi := parsePaymentIntent(event.Data)
        h.billingService.TopUp(context.Background(), TopUpRequest{
            TenantID:        pi.Metadata["tenant_id"],
            Amount:          pi.Amount, // 注意货币单位转换
            Provider:        "stripe",
            ProviderTxID:    pi.ID,
            IdempotencyKey:  idempotencyKey,
        })
    }
    w.WriteHeader(http.StatusOK)
}
```

---

## 七、Qoder Quest Story 拆分（直接可用）

```
Epic: mvp:billing-system
│
├── Story 1: 🔴 billing:redis-lua-metering
│   ├── Task: 设计 Redis Key 规范（见第三节）
│   ├── Task: 编写 metering.lua 脚本（见 3.3）
│   ├── Task: Go 调用侧封装 + 单元测试
│   └── Task: 压力测试（10k QPS 下余额正确性）
│
├── Story 2: 🔴 billing:kafka-persistence
│   ├── Task: Kafka topic 创建 + Go Producer
│   ├── Task: Go Consumer + PG 写入（见第四节）
│   ├── Task: 幂等双保险（DB 层 idempotency_key UNIQUE）
│   └── Task: 消费延迟监控
│
├── Story 3: 🔴 billing:daily-reconciliation
│   ├── Task: 对账 SQL 编写（见第五节）
│   ├── Task: Go Cron Job（每日 02:00）
│   ├── Task: 差异修复逻辑
│   └── Task: 对账报告生成 + 告警
│
├── Story 4: 🔴 billing:stripe-alipay-webhook
│   ├── Task: Stripe PaymentIntent 创建 + 前端确认
│   ├── Task: 支付宝预下单 + 轮询/回调
│   ├── Task: Webhook Handler（签名验证 + 幂等）
│   └── Task: 充值 → Redis 余额 + PG 流水
│
└── Story 5: 🟡 billing:dashboard-analytics
    ├── Task: 用量聚合 API（按天/模型/能力）
    ├── Task: Dashboard 图表组件（消耗曲线、延迟 P99）
    └── Task: 余额预警（低于阈值邮件通知）
```

---

## 八、验收标准（Definition of Done）

| 场景 | 预期结果 |
|---|---|
| 正常调用 | Redis 余额扣减，PG 流水 + 用量明细均写入，Dashboard 实时可见 |
| 重复请求（同 idempotency_key） | 第二次返回缓存结果，不重复扣费 |
| Redis 宕机 | 服务返回 503，不执行调用（不超卖） |
| Kafka 积压 | 消费者追上后数据最终一致，对账修复微小偏差 |
| 日终对账 | 差异 < 0.01% 自动修复，> 0.01% 告警人工 |
| 支付回调重放 | 同一 payment_id 只记账一次 |
| 掉电恢复 | 重启后从 PG 恢复 Redis 余额（启动时 sync） |