feat: add configurable verification and redemption codes

This commit is contained in:
237899745
2026-07-25 13:57:39 +08:00
parent d1f093685d
commit c6e96464d3
32 changed files with 2108 additions and 261 deletions

View File

@@ -48,6 +48,8 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<Billing
FROM subscriptions
WHERE user_id = $1
AND status IN ('active', 'trialing', 'past_due')
AND current_period_start <= NOW()
AND current_period_end > NOW()
ORDER BY current_period_end DESC
LIMIT 1
"#,

View File

@@ -12,44 +12,65 @@ static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
#[derive(Debug, FromRow)]
struct AdminRow {
id: Uuid,
email: String,
username: String,
role: String,
}
pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
let Some(admin_email) = env_string("ADMIN_EMAIL") else {
return Ok(());
};
let Some(admin_password) = env_string("ADMIN_PASSWORD") else {
return Ok(());
};
let configured_email = env_string("ADMIN_EMAIL");
let configured_username = env_string("ADMIN_USERNAME");
if configured_email.is_none() && configured_username.is_none() {
return Ok(());
}
let admin_username = configured_username.unwrap_or_else(|| {
configured_email
.as_deref()
.and_then(|email| email.split('@').next())
.unwrap_or("admin")
.to_string()
});
let admin_username = admin_username.trim().to_string();
let admin_email = configured_email
.unwrap_or_else(|| format!("{}@local.invalid", admin_username.to_ascii_lowercase()));
let admin_email = admin_email.trim().to_lowercase();
let admin_password = admin_password.trim().to_string();
if admin_email.is_empty() || admin_password.is_empty() {
return Ok(());
}
let admin_username = env_string("ADMIN_USERNAME")
.unwrap_or_else(|| admin_email.split('@').next().unwrap_or("admin").to_string());
let admin_username = admin_username.trim().to_string();
validate_email(&admin_email)?;
validate_username(&admin_username)?;
validate_password(&admin_password)?;
let existing = sqlx::query_as::<_, AdminRow>(
let mut matching = sqlx::query_as::<_, AdminRow>(
r#"
SELECT id, username, role::text AS role
SELECT id, email, username, role::text AS role
FROM users
WHERE email = $1
WHERE email = $1 OR username = $2
ORDER BY (email = $1) DESC
LIMIT 2
"#,
)
.bind(&admin_email)
.fetch_optional(&state.db)
.bind(&admin_username)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?;
if matching.len() > 1 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"管理员邮箱和用户名分别属于不同账号",
));
}
let existing = matching.pop();
let password_hash = hash_password(&admin_password)?;
if let Some(row) = existing {
@@ -113,6 +134,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
if row.role != "admin" {
info!(admin_email = %admin_email, "管理员权限已启用");
}
if row.email != admin_email {
info!(admin_username = %admin_username, "按用户名匹配到已有管理员,保留原邮箱");
}
} else {
sqlx::query(
r#"

View File

@@ -1,8 +1,247 @@
use crate::error::{AppError, ErrorCode};
use crate::services::billing::BillingContext;
use crate::state::AppState;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use sqlx::{FromRow, Postgres, Transaction};
use std::net::IpAddr;
use uuid::Uuid;
#[derive(Debug, Clone, Copy)]
pub struct UserUsageBalance {
pub used_units: i64,
pub included_units: i64,
pub bonus_units: i64,
pub redeemed_units: i64,
pub total_units: i64,
pub remaining_units: i64,
}
#[derive(Debug, FromRow)]
struct UsagePeriodRow {
used_units: i32,
bonus_units: i32,
grant_used_units: i32,
}
#[derive(Debug, FromRow)]
struct AvailableGrantRow {
id: Uuid,
expires_at: DateTime<Utc>,
}
pub async fn user_usage_balance(
state: &AppState,
billing: &BillingContext,
) -> Result<UserUsageBalance, AppError> {
let usage = sqlx::query_as::<_, UsagePeriodRow>(
r#"
SELECT used_units, bonus_units, grant_used_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsagePeriodRow {
used_units: 0,
bonus_units: 0,
grant_used_units: 0,
});
let redeemed_units: i64 = sqlx::query_scalar(
r#"
SELECT COALESCE(SUM(remaining_units), 0)::bigint
FROM unit_grants
WHERE user_id = $1
AND starts_at <= NOW()
AND expires_at > NOW()
AND remaining_units > 0
"#,
)
.bind(billing.user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?;
Ok(calculate_user_balance(
billing.plan.included_units_per_period,
usage.used_units,
usage.bonus_units,
usage.grant_used_units,
redeemed_units,
))
}
pub async fn ensure_user_units(
state: &AppState,
billing: &BillingContext,
needed_units: i32,
) -> Result<(), AppError> {
if needed_units <= 0 {
return Ok(());
}
let balance = user_usage_balance(state, billing).await?;
if balance.remaining_units < i64::from(needed_units) {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
Ok(())
}
pub async fn consume_user_unit(
tx: &mut Transaction<'_, Postgres>,
billing: &BillingContext,
bytes_in: u64,
bytes_out: u64,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
"#,
)
.bind(billing.user_id)
.bind(billing.subscription_id)
.bind(billing.period_start)
.bind(billing.period_end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?;
let usage = sqlx::query_as::<_, UsagePeriodRow>(
r#"
SELECT used_units, bonus_units, grant_used_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
FOR UPDATE
"#,
)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.fetch_one(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用量周期失败").with_source(err))?;
let plan_used = usage.used_units.saturating_sub(usage.grant_used_units);
let plan_capacity = billing
.plan
.included_units_per_period
.saturating_add(usage.bonus_units);
let plan_available = plan_used < plan_capacity;
let grant = sqlx::query_as::<_, AvailableGrantRow>(
r#"
SELECT id, expires_at
FROM unit_grants
WHERE user_id = $1
AND starts_at <= NOW()
AND expires_at > NOW()
AND remaining_units > 0
ORDER BY expires_at ASC, created_at ASC
FOR UPDATE
LIMIT 1
"#,
)
.bind(billing.user_id)
.fetch_optional(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换额度失败").with_source(err))?;
let use_grant = should_consume_grant(
plan_available,
grant.as_ref().map(|grant| grant.expires_at),
billing.period_end,
);
if !plan_available && !use_grant {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
if use_grant {
let grant_id = grant
.ok_or_else(|| AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"))?
.id;
let updated = sqlx::query(
r#"
UPDATE unit_grants
SET remaining_units = remaining_units - 1,
updated_at = NOW()
WHERE id = $1
AND remaining_units > 0
AND expires_at > NOW()
"#,
)
.bind(grant_id)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减兑换额度失败").with_source(err))?;
if updated.rows_affected() != 1 {
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
}
}
sqlx::query(
r#"
UPDATE usage_periods
SET used_units = used_units + 1,
grant_used_units = grant_used_units + $1,
bytes_in = bytes_in + $2,
bytes_out = bytes_out + $3,
updated_at = NOW()
WHERE user_id = $4 AND period_start = $5 AND period_end = $6
"#,
)
.bind(use_grant as i32)
.bind(bytes_in as i64)
.bind(bytes_out as i64)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "记录用量失败").with_source(err))?;
Ok(())
}
fn calculate_user_balance(
included_units: i32,
used_units: i32,
bonus_units: i32,
grant_used_units: i32,
redeemed_units: i64,
) -> UserUsageBalance {
let base_capacity = i64::from(included_units.saturating_add(bonus_units));
let base_used = i64::from(used_units.saturating_sub(grant_used_units));
let base_remaining = base_capacity.saturating_sub(base_used).max(0);
let remaining_units = base_remaining.saturating_add(redeemed_units.max(0));
let used_units = i64::from(used_units.max(0));
UserUsageBalance {
used_units,
included_units: i64::from(included_units),
bonus_units: i64::from(bonus_units),
redeemed_units: redeemed_units.max(0),
total_units: used_units.saturating_add(remaining_units),
remaining_units,
}
}
fn should_consume_grant(
plan_available: bool,
grant_expires_at: Option<DateTime<Utc>>,
plan_expires_at: DateTime<Utc>,
) -> bool {
grant_expires_at.is_some_and(|expires_at| !plan_available || expires_at <= plan_expires_at)
}
pub async fn consume_anonymous_units(
state: &AppState,
@@ -71,3 +310,42 @@ fn utc8_date() -> String {
let now = Utc::now() + Duration::hours(8);
now.format("%Y-%m-%d").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_keeps_redeemed_units_separate_from_plan_usage() {
let balance = calculate_user_balance(10, 13, 0, 3, 7);
assert_eq!(balance.remaining_units, 7);
assert_eq!(balance.total_units, 20);
}
#[test]
fn balance_uses_plan_capacity_before_redeemed_units() {
let balance = calculate_user_balance(10, 4, 2, 0, 5);
assert_eq!(balance.remaining_units, 13);
assert_eq!(balance.total_units, 17);
}
#[test]
fn earlier_expiring_entitlement_is_consumed_first() {
let now = Utc::now();
assert!(should_consume_grant(
true,
Some(now + Duration::days(2)),
now + Duration::days(20),
));
assert!(!should_consume_grant(
true,
Some(now + Duration::days(30)),
now + Duration::days(20),
));
assert!(should_consume_grant(
false,
Some(now + Duration::days(30)),
now + Duration::days(20),
));
}
}

View File

@@ -28,6 +28,16 @@ pub struct MailConfigStored {
pub log_links_when_disabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfigStored {
#[serde(default = "default_true")]
pub email_verification_required: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeConfigStored {
pub secret_key_encrypted: Option<String>,
@@ -117,6 +127,13 @@ pub async fn load_mail_settings(state: &AppState) -> Result<Option<MailSettings>
}))
}
pub async fn email_verification_required(state: &AppState) -> Result<bool, AppError> {
Ok(load_system_config::<AuthConfigStored>(state, "auth")
.await?
.map(|config| config.email_verification_required)
.unwrap_or(true))
}
pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecrets>, AppError> {
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
return Ok(None);
@@ -225,3 +242,14 @@ pub async fn get_stripe_webhook_secret(state: &AppState) -> Result<String, AppEr
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_config_defaults_to_requiring_verification() {
let config: AuthConfigStored = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(config.email_verification_required);
}
}