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:
55
crates/zclaw-saas/src/billing/handlers.rs
Normal file
55
crates/zclaw-saas/src/billing/handlers.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! 计费 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, State},
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::error::SaasResult;
|
||||
use crate::state::AppState;
|
||||
use super::service;
|
||||
use super::types::*;
|
||||
|
||||
/// GET /api/v1/billing/plans — 列出所有活跃计划
|
||||
pub async fn list_plans(
|
||||
State(state): State<AppState>,
|
||||
) -> SaasResult<Json<Vec<BillingPlan>>> {
|
||||
let plans = service::list_plans(&state.db).await?;
|
||||
Ok(Json(plans))
|
||||
}
|
||||
|
||||
/// GET /api/v1/billing/plans/:id — 获取单个计划详情
|
||||
pub async fn get_plan(
|
||||
State(state): State<AppState>,
|
||||
Path(plan_id): Path<String>,
|
||||
) -> SaasResult<Json<BillingPlan>> {
|
||||
let plan = service::get_plan(&state.db, &plan_id).await?
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("计划不存在".into()))?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
/// GET /api/v1/billing/subscription — 获取当前订阅
|
||||
pub async fn get_subscription(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let plan = service::get_account_plan(&state.db, &ctx.account_id).await?;
|
||||
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
|
||||
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"plan": plan,
|
||||
"subscription": sub,
|
||||
"usage": usage,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/billing/usage — 获取当月用量
|
||||
pub async fn get_usage(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<UsageQuota>> {
|
||||
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
||||
Ok(Json(usage))
|
||||
}
|
||||
15
crates/zclaw-saas/src/billing/mod.rs
Normal file
15
crates/zclaw-saas/src/billing/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 计费模块 — 计划管理、订阅、用量配额
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::get;
|
||||
|
||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/billing/plans", get(handlers::list_plans))
|
||||
.route("/api/v1/billing/plans/{id}", get(handlers::get_plan))
|
||||
.route("/api/v1/billing/subscription", get(handlers::get_subscription))
|
||||
.route("/api/v1/billing/usage", get(handlers::get_usage))
|
||||
}
|
||||
206
crates/zclaw-saas/src/billing/service.rs
Normal file
206
crates/zclaw-saas/src/billing/service.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
161
crates/zclaw-saas/src/billing/types.rs
Normal file
161
crates/zclaw-saas/src/billing/types.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
//! 计费类型定义
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 计费计划定义 — 对应 billing_plans 表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct BillingPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub price_cents: i32,
|
||||
pub currency: String,
|
||||
pub interval: String,
|
||||
pub features: serde_json::Value,
|
||||
pub limits: serde_json::Value,
|
||||
pub is_default: bool,
|
||||
pub sort_order: i32,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 计划限额(从 limits JSON 反序列化)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanLimits {
|
||||
#[serde(default)]
|
||||
pub max_input_tokens_monthly: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub max_output_tokens_monthly: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub max_relay_requests_monthly: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub max_hand_executions_monthly: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub max_pipeline_runs_monthly: Option<i32>,
|
||||
}
|
||||
|
||||
impl PlanLimits {
|
||||
pub fn free() -> Self {
|
||||
Self {
|
||||
max_input_tokens_monthly: Some(500_000),
|
||||
max_output_tokens_monthly: Some(500_000),
|
||||
max_relay_requests_monthly: Some(100),
|
||||
max_hand_executions_monthly: Some(20),
|
||||
max_pipeline_runs_monthly: Some(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 账户订阅 — 对应 billing_subscriptions 表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Subscription {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub plan_id: String,
|
||||
pub status: String,
|
||||
pub current_period_start: DateTime<Utc>,
|
||||
pub current_period_end: DateTime<Utc>,
|
||||
pub trial_end: Option<DateTime<Utc>>,
|
||||
pub canceled_at: Option<DateTime<Utc>>,
|
||||
pub cancel_at_period_end: bool,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 发票 — 对应 billing_invoices 表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub subscription_id: Option<String>,
|
||||
pub plan_id: Option<String>,
|
||||
pub amount_cents: i32,
|
||||
pub currency: String,
|
||||
pub description: Option<String>,
|
||||
pub status: String,
|
||||
pub due_at: Option<DateTime<Utc>>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
pub voided_at: Option<DateTime<Utc>>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 支付记录 — 对应 billing_payments 表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Payment {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub account_id: String,
|
||||
pub amount_cents: i32,
|
||||
pub currency: String,
|
||||
pub method: String,
|
||||
pub status: String,
|
||||
pub external_trade_no: Option<String>,
|
||||
pub paid_at: Option<DateTime<Utc>>,
|
||||
pub refunded_at: Option<DateTime<Utc>>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 月度用量配额 — 对应 billing_usage_quotas 表
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct UsageQuota {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub period_start: DateTime<Utc>,
|
||||
pub period_end: DateTime<Utc>,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub relay_requests: i32,
|
||||
pub hand_executions: i32,
|
||||
pub pipeline_runs: i32,
|
||||
pub max_input_tokens: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub max_relay_requests: Option<i32>,
|
||||
pub max_hand_executions: Option<i32>,
|
||||
pub max_pipeline_runs: Option<i32>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 用量检查结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuotaCheck {
|
||||
pub allowed: bool,
|
||||
pub reason: Option<String>,
|
||||
pub current: i64,
|
||||
pub limit: Option<i64>,
|
||||
pub remaining: Option<i64>,
|
||||
}
|
||||
|
||||
/// 支付方式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PaymentMethod {
|
||||
Alipay,
|
||||
Wechat,
|
||||
}
|
||||
|
||||
/// 创建支付请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePaymentRequest {
|
||||
pub plan_id: String,
|
||||
pub payment_method: PaymentMethod,
|
||||
}
|
||||
|
||||
/// 支付结果
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaymentResult {
|
||||
pub payment_id: String,
|
||||
pub trade_no: String,
|
||||
pub pay_url: String,
|
||||
pub amount_cents: i32,
|
||||
}
|
||||
Reference in New Issue
Block a user