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

@@ -4,7 +4,9 @@ use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::mail;
use crate::services::settings;
use crate::services::settings::{MailConfigStored, MailCustomSmtp, StripeConfigStored};
use crate::services::settings::{
AuthConfigStored, MailConfigStored, MailCustomSmtp, StripeConfigStored,
};
use crate::state::AppState;
use axum::extract::{ConnectInfo, Path, Query, State};
@@ -36,6 +38,8 @@ pub fn router() -> Router<AppState> {
.route("/admin/mail", get(get_mail_config))
.route("/admin/mail", put(update_mail_config))
.route("/admin/mail/test", post(test_mail))
.route("/admin/auth", get(get_auth_config))
.route("/admin/auth", put(update_auth_config))
.route("/admin/config", get(get_config))
.route("/admin/config", put(update_config))
}
@@ -180,7 +184,13 @@ async fn get_stats(
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?;
let active_subscriptions: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM subscriptions WHERE status IN ('active', 'trialing', 'past_due')",
r#"
SELECT COUNT(*)
FROM subscriptions
WHERE status IN ('active', 'trialing', 'past_due')
AND current_period_start <= NOW()
AND current_period_end > NOW()
"#,
)
.fetch_one(&state.db)
.await
@@ -689,8 +699,9 @@ struct GrantCreditsResponse {
period_end: DateTime<Utc>,
used_units: i32,
bonus_units: i32,
total_units: i32,
remaining_units: i32,
redeemed_units: i64,
total_units: i64,
remaining_units: i64,
}
async fn grant_credits(
@@ -722,6 +733,9 @@ async fn grant_credits(
SELECT id, plan_id, current_period_start, current_period_end
FROM subscriptions
WHERE user_id = $1
AND status IN ('active', 'trialing')
AND current_period_start <= NOW()
AND current_period_end > NOW()
ORDER BY current_period_end DESC
LIMIT 1
"#,
@@ -781,6 +795,7 @@ async fn grant_credits(
struct UsageRow {
used_units: i32,
bonus_units: i32,
grant_used_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
@@ -789,7 +804,7 @@ async fn grant_credits(
SET bonus_units = bonus_units + $1,
updated_at = NOW()
WHERE user_id = $2 AND period_start = $3 AND period_end = $4
RETURNING used_units, bonus_units
RETURNING used_units, bonus_units, grant_used_units
"#,
)
.bind(req.units)
@@ -818,8 +833,27 @@ async fn grant_credits(
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
let total_units = plan_units + usage.bonus_units;
let remaining = (total_units - usage.used_units).max(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(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?;
let base_total = i64::from(plan_units.saturating_add(usage.bonus_units));
let base_used = i64::from(usage.used_units.saturating_sub(usage.grant_used_units));
let remaining = base_total
.saturating_sub(base_used)
.max(0)
.saturating_add(redeemed_units);
let total_units = i64::from(usage.used_units).saturating_add(remaining);
Ok(Json(Envelope {
success: true,
@@ -829,6 +863,7 @@ async fn grant_credits(
period_end,
used_units: usage.used_units,
bonus_units: usage.bonus_units,
redeemed_units,
total_units,
remaining_units: remaining,
},
@@ -1274,6 +1309,69 @@ async fn update_stripe_config(
}))
}
#[derive(Debug, Serialize)]
struct AuthConfigView {
email_verification_required: bool,
}
async fn get_auth_config(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<AuthConfigView>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?;
let config = settings::load_system_config::<AuthConfigStored>(&state, "auth")
.await?
.unwrap_or(AuthConfigStored {
email_verification_required: true,
});
Ok(Json(Envelope {
success: true,
data: AuthConfigView {
email_verification_required: config.email_verification_required,
},
}))
}
#[derive(Debug, Deserialize)]
struct AuthConfigRequest {
email_verification_required: bool,
}
async fn update_auth_config(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<AuthConfigRequest>,
) -> Result<Json<Envelope<AuthConfigView>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let config = AuthConfigStored {
email_verification_required: req.email_verification_required,
};
settings::upsert_system_config(
&state,
"auth",
serde_json::to_value(&config).map_err(|err| {
AppError::new(ErrorCode::Internal, "序列化认证配置失败").with_source(err)
})?,
Some("认证功能开关"),
Some(admin_id),
)
.await?;
Ok(Json(Envelope {
success: true,
data: AuthConfigView {
email_verification_required: config.email_verification_required,
},
}))
}
#[derive(Debug, Serialize)]
struct MailConfigView {
enabled: bool,