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,

View File

@@ -2,6 +2,7 @@ use crate::api::envelope::Envelope;
use crate::auth;
use crate::error::{AppError, ErrorCode};
use crate::services::mail;
use crate::services::settings;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
@@ -79,11 +80,13 @@ async fn register(
validate_password(&req.password)?;
let password_hash = hash_password(&req.password)?;
let verification_required = settings::email_verification_required(&state).await?;
let verified_at = (!verification_required).then(Utc::now);
let user = sqlx::query_as::<_, UserRow>(
r#"
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
INSERT INTO users (email, username, password_hash, email_verified_at)
VALUES ($1, $2, $3, $4)
RETURNING
id,
email,
@@ -97,6 +100,7 @@ async fn register(
.bind(req.email.to_lowercase())
.bind(&req.username)
.bind(password_hash)
.bind(verified_at)
.fetch_one(&state.db)
.await
.map_err(map_unique_violation)?;
@@ -108,34 +112,38 @@ async fn register(
&user.role,
)?;
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
let expires_at_db = Utc::now() + Duration::hours(24);
if verification_required {
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
let expires_at_db = Utc::now() + Duration::hours(24);
sqlx::query(
r#"
INSERT INTO email_verifications (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
let verification_url = format!(
"{}/verify-email?token={}",
state.config.public_base_url, verification_token
);
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
sqlx::query(
r#"
INSERT INTO email_verifications (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
})?;
let verification_url = format!(
"{}/verify-email?token={}",
state.config.public_base_url, verification_token
);
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
.await
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
})?;
}
let body = RegisterResponse {
user: UserView {
id: user.id,
@@ -145,7 +153,11 @@ async fn register(
email_verified: user.email_verified_at.is_some(),
},
token,
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
message: if verification_required {
"注册成功,验证邮件已发送至您的邮箱".to_string()
} else {
"注册成功".to_string()
},
};
Ok(Json(Envelope {
@@ -213,6 +225,7 @@ async fn login(
}
verify_password(&req.password, &user.password_hash)?;
let verification_required = settings::email_verification_required(&state).await?;
let (token, expires_at) = auth::issue_jwt(
&state.config.jwt_secret,
@@ -231,7 +244,7 @@ async fn login(
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
email_verified: user.email_verified_at.is_some() || !verification_required,
},
},
}))
@@ -248,6 +261,15 @@ async fn send_verification(
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
if !settings::email_verification_required(&state).await? {
return Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱验证功能当前已关闭,无需验证".to_string(),
},
}));
}
// Rate limit: 1 per minute per user
let key = format!(
"rate:send_verification:{}:{}",

View File

@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::idempotency;
use crate::services::quota;
use crate::services::settings;
use crate::state::AppState;
@@ -153,7 +154,9 @@ async fn get_subscription(
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.user_id = $1
AND s.status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete')
AND s.status IN ('active', 'trialing', 'past_due')
AND s.current_period_start <= NOW()
AND s.current_period_end > NOW()
ORDER BY s.current_period_end DESC
LIMIT 1
"#,
@@ -239,11 +242,12 @@ async fn get_subscription(
struct UsageResponse {
period_start: DateTime<Utc>,
period_end: DateTime<Utc>,
used_units: i32,
included_units: i32,
bonus_units: i32,
total_units: i32,
remaining_units: i32,
used_units: i64,
included_units: i64,
bonus_units: i64,
redeemed_units: i64,
total_units: i64,
remaining_units: i64,
}
async fn get_usage(
@@ -262,33 +266,7 @@ async fn get_usage(
let billing = billing::get_user_billing(&state, user_id).await?;
#[derive(Debug, FromRow)]
struct UsageRow {
used_units: i32,
bonus_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
r#"
SELECT used_units, bonus_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(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(UsageRow {
used_units: 0,
bonus_units: 0,
});
let included = billing.plan.included_units_per_period;
let total = included + usage.bonus_units;
let remaining = (total - usage.used_units).max(0);
let usage = quota::user_usage_balance(&state, &billing).await?;
Ok(Json(Envelope {
success: true,
@@ -296,10 +274,11 @@ async fn get_usage(
period_start: billing.period_start,
period_end: billing.period_end,
used_units: usage.used_units,
included_units: included,
included_units: usage.included_units,
bonus_units: usage.bonus_units,
total_units: total,
remaining_units: remaining,
redeemed_units: usage.redeemed_units,
total_units: usage.total_units,
remaining_units: usage.remaining_units,
},
}))
}

View File

@@ -1044,41 +1044,7 @@ async fn ensure_quota_available(
ctx: &BillingContext,
needed_units: i32,
) -> Result<(), AppError> {
if needed_units <= 0 {
return Ok(());
}
#[derive(Debug, FromRow)]
struct UsageRow {
used_units: i32,
bonus_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
r#"
SELECT used_units, bonus_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(ctx.user_id)
.bind(ctx.period_start)
.bind(ctx.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsageRow {
used_units: 0,
bonus_units: 0,
});
let total_units = ctx.plan.included_units_per_period + usage.bonus_units;
let remaining = total_units - usage.used_units;
if remaining < needed_units {
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
}
Ok(())
quota::ensure_user_units(state, ctx, needed_units).await
}
#[allow(clippy::too_many_arguments)]
@@ -1252,48 +1218,7 @@ async fn charge_one_unit(
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 updated: Option<i32> = sqlx::query_scalar(
r#"
UPDATE usage_periods
SET used_units = used_units + 1,
bytes_in = bytes_in + $1,
bytes_out = bytes_out + $2,
updated_at = NOW()
WHERE user_id = $3
AND period_start = $4
AND period_end = $5
AND used_units + 1 <= $6 + bonus_units
RETURNING used_units
"#,
)
.bind(bytes_in as i64)
.bind(bytes_out as i64)
.bind(billing.user_id)
.bind(billing.period_start)
.bind(billing.period_end)
.bind(billing.plan.included_units_per_period)
.fetch_optional(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减配额失败").with_source(err))?;
if updated.is_none() {
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
}
quota::consume_user_unit(tx, billing, bytes_in, bytes_out).await?;
sqlx::query(
r#"

View File

@@ -130,10 +130,13 @@ async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result<Option<Princip
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
let verification_required =
crate::services::settings::email_verification_required(state).await?;
Ok(Some(Principal::User {
user_id: user.id,
role: user.role,
email_verified: user.email_verified_at.is_some(),
email_verified: user.email_verified_at.is_some() || !verification_required,
}))
}
@@ -205,11 +208,14 @@ async fn try_api_key(
.execute(&state.db)
.await;
let verification_required =
crate::services::settings::email_verification_required(state).await?;
Ok(Some(Principal::ApiKey {
user_id: row.user_id,
api_key_id: row.id,
role: row.user_role,
email_verified: row.email_verified_at.is_some(),
email_verified: row.email_verified_at.is_some() || !verification_required,
}))
}

View File

@@ -7,6 +7,7 @@ mod context;
mod downloads;
mod envelope;
mod health;
mod redemption;
mod response;
mod tasks;
mod user;
@@ -62,6 +63,7 @@ fn v1_router() -> Router<AppState> {
.merge(billing::router())
.merge(webhooks::router())
.merge(user::router())
.merge(redemption::router())
.merge(admin::router())
.merge(admin_storage::router())
.fallback(response::not_found)

883
src/api/redemption.rs Normal file
View File

@@ -0,0 +1,883 @@
use crate::api::envelope::Envelope;
use crate::api::{admin, context};
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::HeaderMap;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
use std::net::SocketAddr;
use uuid::Uuid;
const CODE_PREFIX: &str = "IMG";
const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
pub fn router() -> Router<AppState> {
Router::new()
.route("/redemptions", get(list_user_redemptions))
.route("/redemptions/redeem", post(redeem_code))
.route(
"/admin/redemption-codes",
get(list_admin_codes).post(create_codes),
)
.route("/admin/redemption-codes/{code_id}", put(update_code_status))
}
#[derive(Debug, Deserialize)]
struct PagingQuery {
page: Option<u32>,
limit: Option<u32>,
}
#[derive(Debug, FromRow, Serialize)]
struct AdminCodeView {
id: Uuid,
code_hint: String,
benefit_kind: String,
plan_id: Option<Uuid>,
plan_name: Option<String>,
units: Option<i32>,
duration_days: i32,
redeem_before: Option<DateTime<Utc>>,
is_active: bool,
note: Option<String>,
created_at: DateTime<Utc>,
redeemed_at: Option<DateTime<Utc>>,
redeemed_by: Option<Uuid>,
redeemed_username: Option<String>,
}
#[derive(Debug, Serialize)]
struct AdminCodesResponse {
codes: Vec<AdminCodeView>,
page: u32,
limit: u32,
total: i64,
}
async fn list_admin_codes(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(query): Query<PagingQuery>,
) -> Result<Json<Envelope<AdminCodesResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, _admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(50).clamp(1, 200);
let offset = (page - 1) * limit;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM redemption_codes")
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
let codes = sqlx::query_as::<_, AdminCodeView>(
r#"
SELECT
c.id,
c.code_hint,
c.benefit_kind,
c.plan_id,
p.name AS plan_name,
c.units,
c.duration_days,
c.redeem_before,
c.is_active,
c.note,
c.created_at,
r.redeemed_at,
r.user_id AS redeemed_by,
u.username AS redeemed_username
FROM redemption_codes c
LEFT JOIN plans p ON p.id = c.plan_id
LEFT JOIN redemption_records r ON r.code_id = c.id
LEFT JOIN users u ON u.id = r.user_id
ORDER BY c.created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: AdminCodesResponse {
codes,
page,
limit,
total,
},
}))
}
#[derive(Debug, Deserialize)]
struct CreateCodesRequest {
benefit_kind: String,
plan_id: Option<Uuid>,
units: Option<i32>,
duration_days: i32,
redeem_before: Option<DateTime<Utc>>,
quantity: Option<u32>,
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct GeneratedCodeView {
id: Uuid,
code: String,
code_hint: String,
}
#[derive(Debug, Serialize)]
struct CreateCodesResponse {
message: String,
codes: Vec<GeneratedCodeView>,
}
async fn create_codes(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateCodesRequest>,
) -> Result<Json<Envelope<CreateCodesResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
let quantity = req.quantity.unwrap_or(1);
if !(1..=200).contains(&quantity) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"quantity 需在 1-200 之间",
));
}
if !(1..=3650).contains(&req.duration_days) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"duration_days 需在 1-3650 之间",
));
}
if req
.redeem_before
.is_some_and(|deadline| deadline <= Utc::now())
{
return Err(AppError::new(
ErrorCode::InvalidRequest,
"兑换截止时间必须晚于当前时间",
));
}
let benefit_kind = req.benefit_kind.trim().to_ascii_lowercase();
let (plan_id, units) = match benefit_kind.as_str() {
"plan" => {
let plan_id = req
.plan_id
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "套餐卡必须选择套餐"))?;
let available: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM plans WHERE id = $1 AND is_active = true)",
)
.bind(plan_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
if !available {
return Err(AppError::new(ErrorCode::NotFound, "套餐不存在或已停用"));
}
(Some(plan_id), None)
}
"units" => {
let units = req
.units
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "次数卡必须填写次数"))?;
if !(1..=10_000_000).contains(&units) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"units 需在 1-10000000 之间",
));
}
(None, Some(units))
}
_ => {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"benefit_kind 仅支持 plan/units",
))
}
};
let note = req
.note
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
if note
.as_ref()
.is_some_and(|value| value.chars().count() > 500)
{
return Err(AppError::new(
ErrorCode::InvalidRequest,
"备注不能超过 500 字",
));
}
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let mut generated = Vec::with_capacity(quantity as usize);
for _ in 0..quantity {
let code = generate_code();
let compact = normalize_code(&code)
.ok_or_else(|| AppError::new(ErrorCode::Internal, "生成兑换码格式失败"))?;
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
let code_hint = code_hint(&compact);
let id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO redemption_codes (
code_hash, code_hint, benefit_kind, plan_id, units,
duration_days, redeem_before, note, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
"#,
)
.bind(code_hash)
.bind(&code_hint)
.bind(&benefit_kind)
.bind(plan_id)
.bind(units)
.bind(req.duration_days)
.bind(req.redeem_before)
.bind(&note)
.bind(admin_id)
.fetch_one(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "生成兑换码失败").with_source(err))?;
generated.push(GeneratedCodeView {
id,
code,
code_hint,
});
}
sqlx::query(
r#"
INSERT INTO audit_logs (user_id, action, resource_type, details, ip_address)
VALUES ($1, 'redemption_codes_created', 'redemption_code', $2, $3::inet)
"#,
)
.bind(admin_id)
.bind(serde_json::json!({
"benefit_kind": benefit_kind,
"plan_id": plan_id,
"units": units,
"duration_days": req.duration_days,
"redeem_before": req.redeem_before,
"quantity": quantity,
"note": note,
}))
.bind(ip.to_string())
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: CreateCodesResponse {
message: "兑换码已生成,完整码仅显示本次".to_string(),
codes: generated,
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdateCodeStatusRequest {
is_active: bool,
}
#[derive(Debug, Serialize)]
struct MessageResponse {
message: String,
}
async fn update_code_status(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(code_id): Path<Uuid>,
Json(req): Json<UpdateCodeStatusRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let updated =
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
.bind(code_id)
.bind(req.is_active)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
if updated.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
}
sqlx::query(
r#"
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
VALUES ($1, 'redemption_code_status', 'redemption_code', $2, $3, $4::inet)
"#,
)
.bind(admin_id)
.bind(code_id)
.bind(serde_json::json!({ "is_active": req.is_active }))
.bind(ip.to_string())
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: if req.is_active {
"兑换码已启用".to_string()
} else {
"兑换码已停用".to_string()
},
},
}))
}
#[derive(Debug, Deserialize)]
struct RedeemCodeRequest {
code: String,
}
#[derive(Debug, Serialize)]
struct RedeemCodeResponse {
message: String,
benefit_kind: String,
plan_id: Option<Uuid>,
plan_name: Option<String>,
units: Option<i32>,
benefit_starts_at: DateTime<Utc>,
benefit_expires_at: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct RedeemableCodeRow {
id: Uuid,
benefit_kind: String,
plan_id: Option<Uuid>,
plan_name: Option<String>,
plan_is_active: Option<bool>,
units: Option<i32>,
duration_days: i32,
redeem_before: Option<DateTime<Utc>>,
is_active: bool,
redemption_id: Option<Uuid>,
}
async fn redeem_code(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<RedeemCodeRequest>,
) -> Result<Json<Envelope<RedeemCodeResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "请先登录")),
};
enforce_redeem_rate_limit(&state, user_id).await?;
if req.code.len() > 64 {
return Err(AppError::new(ErrorCode::InvalidRequest, "兑换码无效"));
}
let compact = normalize_code(&req.code)
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "兑换码无效"))?;
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE")
.bind(user_id)
.fetch_one(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换用户失败").with_source(err))?;
let code = sqlx::query_as::<_, RedeemableCodeRow>(
r#"
SELECT
c.id,
c.benefit_kind,
c.plan_id,
p.name AS plan_name,
p.is_active AS plan_is_active,
c.units,
c.duration_days,
c.redeem_before,
c.is_active,
r.id AS redemption_id
FROM redemption_codes c
LEFT JOIN plans p ON p.id = c.plan_id
LEFT JOIN redemption_records r ON r.code_id = c.id
WHERE c.code_hash = $1
FOR UPDATE OF c
"#,
)
.bind(code_hash)
.fetch_optional(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "兑换码无效"))?;
let already_redeemed: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM redemption_records WHERE code_id = $1)")
.bind(code.id)
.fetch_one(&mut *tx)
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "检查兑换码状态失败").with_source(err)
})?;
let now = Utc::now();
if !code.is_active
|| code.redemption_id.is_some()
|| already_redeemed
|| code.redeem_before.is_some_and(|deadline| deadline <= now)
{
return Err(AppError::new(
ErrorCode::InvalidRequest,
"兑换码无效、已使用或已过期",
));
}
let duration = Duration::days(i64::from(code.duration_days));
let (benefit_starts_at, benefit_expires_at) = match code.benefit_kind.as_str() {
"plan" => {
if code.plan_is_active != Some(true) {
return Err(AppError::new(ErrorCode::Forbidden, "兑换码对应套餐已停用"));
}
let plan_id = code
.plan_id
.ok_or_else(|| AppError::new(ErrorCode::Internal, "套餐兑换码数据不完整"))?;
apply_plan_benefit(&mut tx, user_id, plan_id, duration, now).await?
}
"units" => (now, now + duration),
_ => return Err(AppError::new(ErrorCode::Internal, "兑换码类型错误")),
};
let record_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO redemption_records (
code_id, user_id, benefit_kind, plan_id, units,
benefit_starts_at, benefit_expires_at, redeemed_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::inet)
RETURNING id
"#,
)
.bind(code.id)
.bind(user_id)
.bind(&code.benefit_kind)
.bind(code.plan_id)
.bind(code.units)
.bind(benefit_starts_at)
.bind(benefit_expires_at)
.bind(ip.to_string())
.fetch_one(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "记录兑换结果失败").with_source(err))?;
if code.benefit_kind == "units" {
let units = code
.units
.ok_or_else(|| AppError::new(ErrorCode::Internal, "次数兑换码数据不完整"))?;
sqlx::query(
r#"
INSERT INTO unit_grants (
user_id, redemption_record_id, total_units, remaining_units,
starts_at, expires_at
) VALUES ($1, $2, $3, $3, $4, $5)
"#,
)
.bind(user_id)
.bind(record_id)
.bind(units)
.bind(benefit_starts_at)
.bind(benefit_expires_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "发放次数额度失败").with_source(err))?;
}
sqlx::query(
r#"
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
VALUES ($1, 'redemption_code_redeemed', 'redemption_code', $2, $3, $4::inet)
"#,
)
.bind(user_id)
.bind(code.id)
.bind(serde_json::json!({
"benefit_kind": code.benefit_kind,
"plan_id": code.plan_id,
"units": code.units,
"benefit_expires_at": benefit_expires_at,
}))
.bind(ip.to_string())
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
let message = if code.benefit_kind == "plan" {
"套餐兑换成功"
} else {
"次数额度兑换成功"
};
Ok(Json(Envelope {
success: true,
data: RedeemCodeResponse {
message: message.to_string(),
benefit_kind: code.benefit_kind,
plan_id: code.plan_id,
plan_name: code.plan_name,
units: code.units,
benefit_starts_at,
benefit_expires_at,
},
}))
}
#[derive(Debug, FromRow)]
struct ActiveSubscriptionRow {
id: Uuid,
plan_id: Uuid,
provider: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
}
async fn apply_plan_benefit(
tx: &mut Transaction<'_, Postgres>,
user_id: Uuid,
plan_id: Uuid,
duration: Duration,
now: DateTime<Utc>,
) -> Result<(DateTime<Utc>, DateTime<Utc>), AppError> {
let active = sqlx::query_as::<_, ActiveSubscriptionRow>(
r#"
SELECT id, plan_id, provider, current_period_start, current_period_end
FROM subscriptions
WHERE user_id = $1
AND status IN ('active', 'trialing', 'past_due')
AND current_period_start <= $2
AND current_period_end > $2
ORDER BY current_period_end DESC
FOR UPDATE
LIMIT 1
"#,
)
.bind(user_id)
.bind(now)
.fetch_optional(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询当前套餐失败").with_source(err))?;
if let Some(active) = active {
if active.provider == "stripe" {
return Err(AppError::new(
ErrorCode::Forbidden,
"当前存在有效的 Stripe 订阅,请在订阅到期后兑换套餐卡",
));
}
let new_end = active.current_period_end + duration;
if active.plan_id == plan_id {
sqlx::query(
r#"
UPDATE usage_periods
SET period_end = $2, updated_at = NOW()
WHERE subscription_id = $1
AND period_start = $3
AND period_end = $4
"#,
)
.bind(active.id)
.bind(new_end)
.bind(active.current_period_start)
.bind(active.current_period_end)
.execute(&mut **tx)
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "延长用量周期失败").with_source(err)
})?;
sqlx::query(
"UPDATE subscriptions SET current_period_end = $2, updated_at = NOW() WHERE id = $1",
)
.bind(active.id)
.bind(new_end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "延长套餐失败").with_source(err))?;
return Ok((now, new_end));
}
sqlx::query(
r#"
UPDATE subscriptions
SET status = 'canceled', canceled_at = NOW(), updated_at = NOW()
WHERE id = $1
"#,
)
.bind(active.id)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "切换套餐失败").with_source(err))?;
create_redemption_subscription(tx, user_id, plan_id, now, new_end).await?;
return Ok((now, new_end));
}
let end = now + duration;
create_redemption_subscription(tx, user_id, plan_id, now, end).await?;
Ok((now, end))
}
async fn create_redemption_subscription(
tx: &mut Transaction<'_, Postgres>,
user_id: Uuid,
plan_id: Uuid,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<Uuid, AppError> {
let subscription_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO subscriptions (
user_id, plan_id, status, current_period_start, current_period_end,
cancel_at_period_end, provider, created_at, updated_at
) VALUES ($1, $2, 'active', $3, $4, false, 'redemption', NOW(), NOW())
RETURNING id
"#,
)
.bind(user_id)
.bind(plan_id)
.bind(start)
.bind(end)
.fetch_one(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开通兑换套餐失败").with_source(err))?;
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(user_id)
.bind(subscription_id)
.bind(start)
.bind(end)
.execute(&mut **tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化兑换套餐用量失败").with_source(err))?;
Ok(subscription_id)
}
#[derive(Debug, FromRow, Serialize)]
struct UserRedemptionView {
id: Uuid,
code_hint: String,
benefit_kind: String,
plan_name: Option<String>,
units: Option<i32>,
remaining_units: Option<i32>,
benefit_starts_at: DateTime<Utc>,
benefit_expires_at: DateTime<Utc>,
redeemed_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct UserRedemptionsResponse {
redemptions: Vec<UserRedemptionView>,
}
async fn list_user_redemptions(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<UserRedemptionsResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let user_id = match principal {
context::Principal::User { user_id, .. } => user_id,
_ => return Err(AppError::new(ErrorCode::Unauthorized, "请先登录")),
};
let redemptions = sqlx::query_as::<_, UserRedemptionView>(
r#"
SELECT
r.id,
c.code_hint,
r.benefit_kind,
p.name AS plan_name,
r.units,
CASE WHEN g.expires_at > NOW() THEN g.remaining_units ELSE 0 END AS remaining_units,
r.benefit_starts_at,
r.benefit_expires_at,
r.redeemed_at
FROM redemption_records r
JOIN redemption_codes c ON c.id = r.code_id
LEFT JOIN plans p ON p.id = r.plan_id
LEFT JOIN unit_grants g ON g.redemption_record_id = r.id
WHERE r.user_id = $1
ORDER BY r.redeemed_at DESC
LIMIT 100
"#,
)
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换记录失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: UserRedemptionsResponse { redemptions },
}))
}
fn generate_code() -> String {
let mut random = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut random);
let payload = random
.iter()
.map(|byte| CODE_ALPHABET[usize::from(*byte & 31)] as char)
.collect::<String>();
format!(
"{CODE_PREFIX}-{}-{}-{}-{}",
&payload[0..4],
&payload[4..8],
&payload[8..12],
&payload[12..16]
)
}
fn normalize_code(input: &str) -> Option<String> {
let compact = input
.chars()
.filter(|ch| *ch != '-' && !ch.is_ascii_whitespace())
.collect::<String>()
.to_ascii_uppercase();
if compact.len() != CODE_PREFIX.len() + 16 || !compact.starts_with(CODE_PREFIX) {
return None;
}
if !compact[CODE_PREFIX.len()..]
.bytes()
.all(|byte| CODE_ALPHABET.contains(&byte))
{
return None;
}
Some(compact)
}
fn code_hint(compact: &str) -> String {
format!("{}-...-{}", CODE_PREFIX, &compact[15..19])
}
async fn enforce_redeem_rate_limit(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
let key = format!(
"rate:redemption:{}:{}",
user_id,
Utc::now().format("%Y%m%d%H%M")
);
let mut redis = state.redis.clone();
let count: i64 = redis::cmd("INCR")
.arg(&key)
.query_async(&mut redis)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?;
if count == 1 {
let _: () = redis::cmd("EXPIRE")
.arg(&key)
.arg(60)
.query_async(&mut redis)
.await
.unwrap_or(());
}
if count > 20 {
return Err(AppError::new(
ErrorCode::RateLimited,
"兑换尝试过于频繁,请稍后再试",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_code_round_trips_through_normalization() {
let code = generate_code();
let compact = normalize_code(&code).unwrap();
assert_eq!(compact.len(), 19);
assert!(code.starts_with("IMG-"));
}
#[test]
fn normalization_accepts_lowercase_and_spaces() {
assert_eq!(
normalize_code(" img-abcd-efgh-jkmn-pqrs "),
Some("IMGABCDEFGHJKMNPQRS".to_string())
);
}
#[test]
fn normalization_rejects_ambiguous_characters() {
assert!(normalize_code("IMG-ABCI-EFGH-JKMN-PQRS").is_none());
assert!(normalize_code("IMG-ABCO-EFGH-JKMN-PQRS").is_none());
}
#[test]
fn code_hint_only_exposes_the_last_group() {
assert_eq!(code_hint("IMGABCDEFGHJKMNPQRS"), "IMG-...-PQRS");
}
}

View File

@@ -6,6 +6,7 @@ use crate::services::billing::{BillingContext, Plan};
use crate::services::compress;
use crate::services::compress::{CompressionLevel, ImageFmt};
use crate::services::idempotency;
use crate::services::quota;
use crate::services::storage;
use crate::state::AppState;
@@ -647,41 +648,7 @@ async fn ensure_quota_available(
ctx: &BillingContext,
needed_units: i32,
) -> Result<(), AppError> {
if needed_units <= 0 {
return Ok(());
}
#[derive(Debug, FromRow)]
struct UsageRow {
used_units: i32,
bonus_units: i32,
}
let usage = sqlx::query_as::<_, UsageRow>(
r#"
SELECT used_units, bonus_units
FROM usage_periods
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
"#,
)
.bind(ctx.user_id)
.bind(ctx.period_start)
.bind(ctx.period_end)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
.unwrap_or(UsageRow {
used_units: 0,
bonus_units: 0,
});
let total_units = ctx.plan.included_units_per_period + usage.bonus_units;
let remaining = total_units - usage.used_units;
if remaining < needed_units {
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
}
Ok(())
quota::ensure_user_units(state, ctx, needed_units).await
}
async fn anonymous_remaining_units(

View File

@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::mail;
use crate::services::settings;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
@@ -98,6 +99,8 @@ async fn get_profile(
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let verification_required = settings::email_verification_required(&state).await?;
Ok(Json(Envelope {
success: true,
data: UserView {
@@ -105,7 +108,7 @@ async fn get_profile(
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
email_verified: user.email_verified_at.is_some() || !verification_required,
},
}))
}
@@ -165,6 +168,7 @@ async fn update_profile(
let mut next_email = user.email.clone();
let mut next_username = user.username.clone();
let mut email_changed = false;
let verification_required = settings::email_verification_required(&state).await?;
if let Some(email) = req.email.as_ref() {
let email = email.trim().to_lowercase();
@@ -192,7 +196,7 @@ async fn update_profile(
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
email_verified: user.email_verified_at.is_some() || !verification_required,
},
message: "暂无更新".to_string(),
},
@@ -205,8 +209,10 @@ async fn update_profile(
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let email_verified_at = if email_changed {
let email_verified_at = if email_changed && verification_required {
None
} else if email_changed {
Some(Utc::now())
} else {
user.email_verified_at
};
@@ -231,7 +237,7 @@ async fn update_profile(
.map_err(map_unique_violation)?;
let mut verification_link: Option<String> = None;
if email_changed {
if email_changed && verification_required {
let token = generate_token();
let token_hash = sha256_hex(&token);
let expires_at = Utc::now() + Duration::hours(24);
@@ -269,7 +275,7 @@ async fn update_profile(
})?;
}
let message = if email_changed {
let message = if email_changed && verification_required {
"资料已更新,请验证新邮箱".to_string()
} else {
"资料已更新".to_string()
@@ -283,7 +289,7 @@ async fn update_profile(
email: updated.email,
username: updated.username,
role: updated.role,
email_verified: updated.email_verified_at.is_some(),
email_verified: updated.email_verified_at.is_some() || !verification_required,
},
message,
},