feat: add configurable verification and redemption codes
This commit is contained in:
@@ -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
|
||||
"#,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user