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:
@@ -2,34 +2,44 @@
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use crate::config::DatabaseConfig;
|
||||
use crate::error::SaasResult;
|
||||
|
||||
const SCHEMA_VERSION: i32 = 11;
|
||||
const SCHEMA_VERSION: i32 = 12;
|
||||
|
||||
/// 初始化数据库
|
||||
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
|
||||
// 连接池大小可通过环境变量配置,默认 100(relay 请求每次 10+ 串行查询,50 偏紧)
|
||||
pub async fn init_db(config: &DatabaseConfig) -> SaasResult<PgPool> {
|
||||
// 环境变量覆盖 URL(避免在配置文件中存储密码)
|
||||
let database_url = std::env::var("ZCLAW_DATABASE_URL")
|
||||
.unwrap_or_else(|_| config.url.clone());
|
||||
|
||||
// 环境变量覆盖连接数(向后兼容)
|
||||
let max_connections: u32 = std::env::var("ZCLAW_DB_MAX_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(100);
|
||||
.unwrap_or(config.max_connections);
|
||||
let min_connections: u32 = std::env::var("ZCLAW_DB_MIN_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5);
|
||||
.unwrap_or(config.min_connections);
|
||||
|
||||
tracing::info!("Database pool: max={}, min={}", max_connections, min_connections);
|
||||
tracing::info!(
|
||||
"Database pool: max={}, min={}, acquire_timeout={}s, idle_timeout={}s, max_lifetime={}s",
|
||||
max_connections, min_connections,
|
||||
config.acquire_timeout_secs, config.idle_timeout_secs, config.max_lifetime_secs
|
||||
);
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.min_connections(min_connections)
|
||||
.acquire_timeout(std::time::Duration::from_secs(8))
|
||||
.idle_timeout(std::time::Duration::from_secs(180))
|
||||
.max_lifetime(std::time::Duration::from_secs(900))
|
||||
.connect(database_url)
|
||||
.acquire_timeout(std::time::Duration::from_secs(config.acquire_timeout_secs))
|
||||
.idle_timeout(std::time::Duration::from_secs(config.idle_timeout_secs))
|
||||
.max_lifetime(std::time::Duration::from_secs(config.max_lifetime_secs))
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
|
||||
run_migrations(&pool).await?;
|
||||
ensure_security_columns(&pool).await?;
|
||||
seed_admin_account(&pool).await?;
|
||||
seed_builtin_prompts(&pool).await?;
|
||||
seed_demo_data(&pool).await?;
|
||||
@@ -884,6 +894,56 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 防御性检查:确保安全审计新增的列存在(即使 schema_version 显示已是最新)
|
||||
///
|
||||
/// 场景:旧数据库的 schema_version 已被手动更新但迁移文件未实际执行,
|
||||
/// 或者迁移文件在 version check 时被跳过。
|
||||
async fn ensure_security_columns(pool: &PgPool) -> SaasResult<()> {
|
||||
// 检查 password_version 列是否存在
|
||||
let col_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'accounts' AND column_name = 'password_version')"
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !col_exists {
|
||||
tracing::warn!("[DB] 'password_version' column missing — applying security fix migration");
|
||||
sqlx::query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS password_version INTEGER NOT NULL DEFAULT 1")
|
||||
.execute(pool).await?;
|
||||
sqlx::query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0")
|
||||
.execute(pool).await?;
|
||||
sqlx::query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ")
|
||||
.execute(pool).await?;
|
||||
tracing::info!("[DB] Security columns (password_version, failed_login_count, locked_until) applied");
|
||||
}
|
||||
|
||||
// 检查 rate_limit_events 表是否存在
|
||||
let table_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'rate_limit_events')"
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !table_exists {
|
||||
tracing::warn!("[DB] 'rate_limit_events' table missing — applying rate limit migration");
|
||||
if let Err(e) = sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS rate_limit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key TEXT NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 1,
|
||||
window_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)"
|
||||
).execute(pool).await {
|
||||
tracing::warn!("[DB] Failed to create rate_limit_events: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// PostgreSQL 单元测试需要真实数据库连接,此处保留接口兼容
|
||||
|
||||
Reference in New Issue
Block a user