feat(saas): add billing infrastructure — tables, types, service, handlers

B1.1 Billing database:
- 5 tables: billing_plans, billing_subscriptions, billing_invoices,
  billing_payments, billing_usage_quotas
- Seed data: Free(¥0)/Pro(¥49)/Team(¥199) plans
- JSONB limits for flexible plan configuration

Billing module (crates/zclaw-saas/src/billing/):
- types.rs: BillingPlan, Subscription, Invoice, Payment, UsageQuota
- service.rs: plan CRUD, subscription lookup, usage tracking, quota check
- handlers.rs: REST API (plans list/detail, subscription, usage)
- mod.rs: routes registered at /api/v1/billing/*

Cargo.toml: added chrono feature to sqlx for DateTime<Utc> support
This commit is contained in:
iven
2026-04-01 23:59:46 +08:00
parent c6bd4aea27
commit 9487cd7f72
9 changed files with 743 additions and 25 deletions

View File

@@ -0,0 +1,206 @@
//! 计费服务层 — 计划查询、订阅管理、用量检查
use chrono::{Datelike, Timelike};
use sqlx::PgPool;
use crate::error::SaasResult;
use super::types::*;
/// 获取所有活跃计划
pub async fn list_plans(pool: &PgPool) -> SaasResult<Vec<BillingPlan>> {
let plans = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE status = 'active' ORDER BY sort_order"
)
.fetch_all(pool)
.await?;
Ok(plans)
}
/// 获取单个计划
pub async fn get_plan(pool: &PgPool, plan_id: &str) -> SaasResult<Option<BillingPlan>> {
let plan = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE id = $1"
)
.bind(plan_id)
.fetch_optional(pool)
.await?;
Ok(plan)
}
/// 获取账户当前有效订阅
pub async fn get_active_subscription(
pool: &PgPool,
account_id: &str,
) -> SaasResult<Option<Subscription>> {
let sub = sqlx::query_as::<_, Subscription>(
"SELECT * FROM billing_subscriptions \
WHERE account_id = $1 AND status IN ('trial', 'active', 'past_due') \
ORDER BY created_at DESC LIMIT 1"
)
.bind(account_id)
.fetch_optional(pool)
.await?;
Ok(sub)
}
/// 获取账户当前计划(有订阅返回订阅计划,否则返回 Free
pub async fn get_account_plan(pool: &PgPool, account_id: &str) -> SaasResult<BillingPlan> {
if let Some(sub) = get_active_subscription(pool, account_id).await? {
if let Some(plan) = get_plan(pool, &sub.plan_id).await? {
return Ok(plan);
}
}
// 回退到 Free 计划
let free = sqlx::query_as::<_, BillingPlan>(
"SELECT * FROM billing_plans WHERE name = 'free' AND status = 'active' LIMIT 1"
)
.fetch_optional(pool)
.await?;
Ok(free.unwrap_or_else(|| BillingPlan {
id: "plan-free".into(),
name: "free".into(),
display_name: "免费版".into(),
description: Some("基础功能".into()),
price_cents: 0,
currency: "CNY".into(),
interval: "month".into(),
features: serde_json::json!({}),
limits: serde_json::json!({
"max_input_tokens_monthly": 500000,
"max_output_tokens_monthly": 500000,
"max_relay_requests_monthly": 100,
"max_hand_executions_monthly": 20,
"max_pipeline_runs_monthly": 5,
}),
is_default: true,
sort_order: 0,
status: "active".into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}))
}
/// 获取或创建当月用量记录
pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<UsageQuota> {
let now = chrono::Utc::now();
let period_start = now
.with_day(1).unwrap_or(now)
.with_hour(0).unwrap_or(now)
.with_minute(0).unwrap_or(now)
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
// 尝试获取现有记录
let existing = sqlx::query_as::<_, UsageQuota>(
"SELECT * FROM billing_usage_quotas \
WHERE account_id = $1 AND period_start = $2"
)
.bind(account_id)
.bind(period_start)
.fetch_optional(pool)
.await?;
if let Some(usage) = existing {
return Ok(usage);
}
// 获取当前计划限额
let plan = get_account_plan(pool, account_id).await?;
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
.unwrap_or_else(|_| PlanLimits::free());
// 计算月末
let period_end = if now.month() == 12 {
now.with_year(now.year() + 1).and_then(|d| d.with_month(1))
} else {
now.with_month(now.month() + 1)
}.unwrap_or(now)
.with_day(1).unwrap_or(now)
.with_hour(0).unwrap_or(now)
.with_minute(0).unwrap_or(now)
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
let id = uuid::Uuid::new_v4().to_string();
let usage = sqlx::query_as::<_, UsageQuota>(
"INSERT INTO billing_usage_quotas \
(id, account_id, period_start, period_end, \
max_input_tokens, max_output_tokens, max_relay_requests, \
max_hand_executions, max_pipeline_runs) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
RETURNING *"
)
.bind(&id)
.bind(account_id)
.bind(period_start)
.bind(period_end)
.bind(limits.max_input_tokens_monthly)
.bind(limits.max_output_tokens_monthly)
.bind(limits.max_relay_requests_monthly)
.bind(limits.max_hand_executions_monthly)
.bind(limits.max_pipeline_runs_monthly)
.fetch_one(pool)
.await?;
Ok(usage)
}
/// 增加用量计数
pub async fn increment_usage(
pool: &PgPool,
account_id: &str,
input_tokens: i64,
output_tokens: i64,
) -> SaasResult<()> {
let usage = get_or_create_usage(pool, account_id).await?;
sqlx::query(
"UPDATE billing_usage_quotas \
SET input_tokens = input_tokens + $1, \
output_tokens = output_tokens + $2, \
relay_requests = relay_requests + 1, \
updated_at = NOW() \
WHERE id = $3"
)
.bind(input_tokens)
.bind(output_tokens)
.bind(&usage.id)
.execute(pool)
.await?;
Ok(())
}
/// 检查用量配额
pub async fn check_quota(
pool: &PgPool,
account_id: &str,
quota_type: &str,
) -> SaasResult<QuotaCheck> {
let usage = get_or_create_usage(pool, account_id).await?;
let (current, limit) = match quota_type {
"input_tokens" => (usage.input_tokens, usage.max_input_tokens),
"output_tokens" => (usage.output_tokens, usage.max_output_tokens),
"relay_requests" => (usage.relay_requests as i64, usage.max_relay_requests.map(|v| v as i64)),
"hand_executions" => (usage.hand_executions as i64, usage.max_hand_executions.map(|v| v as i64)),
"pipeline_runs" => (usage.pipeline_runs as i64, usage.max_pipeline_runs.map(|v| v as i64)),
_ => return Ok(QuotaCheck {
allowed: true,
reason: None,
current: 0,
limit: None,
remaining: None,
}),
};
let allowed = limit.map_or(true, |lim| current < lim);
let remaining = limit.map(|lim| (lim - current).max(0));
Ok(QuotaCheck {
allowed,
reason: if !allowed { Some(format!("{} 配额已用尽", quota_type)) } else { None },
current,
limit,
remaining,
})
}