Implement compression quota refunds and admin manual subscription

This commit is contained in:
2025-12-19 23:28:32 +08:00
commit 11f48fd3dd
106 changed files with 27848 additions and 0 deletions

912
src/api/user.rs Normal file
View File

@@ -0,0 +1,912 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::mail;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::HeaderMap;
use axum::routing::{delete, get, post, put};
use axum::{Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::net::SocketAddr;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/user/profile", get(get_profile))
.route("/user/profile", put(update_profile))
.route("/user/password", put(update_password))
.route("/user/history", get(list_history))
.route("/user/api-keys", get(list_api_keys))
.route("/user/api-keys", post(create_api_key))
.route("/user/api-keys/{key_id}/rotate", post(rotate_api_key))
.route("/user/api-keys/{key_id}", delete(disable_api_key))
}
#[derive(Debug, FromRow, Serialize)]
struct ApiKeyView {
id: Uuid,
name: String,
key_prefix: String,
permissions: serde_json::Value,
rate_limit: i32,
is_active: bool,
last_used_at: Option<chrono::DateTime<chrono::Utc>>,
last_used_ip: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
struct ApiKeyListResponse {
api_keys: Vec<ApiKeyView>,
}
#[derive(Debug, Serialize)]
struct UserView {
id: Uuid,
email: String,
username: String,
role: String,
email_verified: bool,
}
#[derive(Debug, Serialize)]
struct MessageResponse {
message: String,
}
async fn get_profile(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<UserView>>, 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, "未登录")),
};
#[derive(Debug, FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
role: String,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT id, email, username, role::text AS role, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdateProfileRequest {
email: Option<String>,
username: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateProfileResponse {
user: UserView,
message: String,
}
async fn update_profile(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<Envelope<UpdateProfileResponse>>, 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, "未登录")),
};
if req.email.is_none() && req.username.is_none() {
return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段"));
}
#[derive(Debug, FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
role: String,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT id, email, username, role::text AS role, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let mut next_email = user.email.clone();
let mut next_username = user.username.clone();
let mut email_changed = false;
if let Some(email) = req.email.as_ref() {
let email = email.trim().to_lowercase();
validate_email(&email)?;
if email != user.email {
next_email = email;
email_changed = true;
}
}
if let Some(username) = req.username.as_ref() {
let username = username.trim().to_string();
validate_username(&username)?;
if username != user.username {
next_username = username;
}
}
if next_email == user.email && next_username == user.username {
return Ok(Json(Envelope {
success: true,
data: UpdateProfileResponse {
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
message: "暂无更新".to_string(),
},
}));
}
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
let email_verified_at = if email_changed { None } else { user.email_verified_at };
let updated = sqlx::query_as::<_, UserRow>(
r#"
UPDATE users
SET email = $2,
username = $3,
email_verified_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, email, username, role::text AS role, email_verified_at
"#,
)
.bind(user_id)
.bind(&next_email)
.bind(&next_username)
.bind(email_verified_at)
.fetch_one(&mut *tx)
.await
.map_err(map_unique_violation)?;
let mut verification_link: Option<String> = None;
if email_changed {
let token = generate_token();
let token_hash = sha256_hex(&token);
let expires_at = 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)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
verification_link = Some(format!(
"{}/verify-email?token={}",
state.config.public_base_url, token
));
}
tx.commit()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
if let Some(link) = verification_link.as_deref() {
mail::send_verification_email(&state, &updated.email, &updated.username, link)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
}
let message = if email_changed {
"资料已更新,请验证新邮箱".to_string()
} else {
"资料已更新".to_string()
};
Ok(Json(Envelope {
success: true,
data: UpdateProfileResponse {
user: UserView {
id: updated.id,
email: updated.email,
username: updated.username,
role: updated.role,
email_verified: updated.email_verified_at.is_some(),
},
message,
},
}))
}
#[derive(Debug, Deserialize)]
struct UpdatePasswordRequest {
current_password: String,
new_password: String,
}
async fn update_password(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<UpdatePasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, 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, "未登录")),
};
validate_password(&req.new_password)?;
#[derive(Debug, FromRow)]
struct PasswordRow {
password_hash: String,
}
let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
verify_password(&req.current_password, &row.password_hash)?;
let new_hash = hash_password(&req.new_password)?;
sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1")
.bind(user_id)
.bind(new_hash)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "密码已更新,请重新登录以确保安全".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
page: Option<u32>,
limit: Option<u32>,
status: Option<String>,
}
#[derive(Debug, Serialize)]
struct HistoryFileView {
file_id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
output_format: String,
error_message: Option<String>,
download_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct HistoryTaskView {
task_id: Uuid,
status: String,
source: String,
progress: i32,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
download_all_url: Option<String>,
files: Vec<HistoryFileView>,
}
#[derive(Debug, Serialize)]
struct HistoryResponse {
tasks: Vec<HistoryTaskView>,
page: u32,
limit: u32,
total: i64,
}
async fn list_history(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(query): Query<HistoryQuery>,
) -> Result<Json<Envelope<HistoryResponse>>, 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 limit = query.limit.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * limit;
let status = query.status.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let total: i64 = if let Some(status) = &status {
sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status::text = $2",
)
.bind(user_id)
.bind(status)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
} else {
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE user_id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
};
#[derive(Debug, FromRow)]
struct TaskRow {
id: Uuid,
status: String,
source: String,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
let tasks: Vec<TaskRow> = if let Some(status) = &status {
sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
source::text AS source,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at
FROM tasks
WHERE user_id = $1 AND status::text = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(user_id)
.bind(status)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
} else {
sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
source::text AS source,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at
FROM tasks
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
};
#[derive(Debug, FromRow)]
struct FileRow {
id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
output_format: String,
error_message: Option<String>,
storage_path: Option<String>,
}
let now = Utc::now();
let mut result_tasks = Vec::with_capacity(tasks.len());
for task in tasks {
let files: Vec<FileRow> = sqlx::query_as::<_, FileRow>(
r#"
SELECT
id,
original_name,
original_size,
compressed_size,
saved_percent::float8 AS saved_percent,
status::text AS status,
output_format,
error_message,
storage_path
FROM task_files
WHERE task_id = $1
ORDER BY created_at ASC
"#,
)
.bind(task.id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
let file_views = files
.into_iter()
.map(|file| HistoryFileView {
file_id: file.id,
original_name: file.original_name,
original_size: file.original_size,
compressed_size: file.compressed_size,
saved_percent: file.saved_percent,
status: file.status.clone(),
output_format: file.output_format,
error_message: file.error_message,
download_url: if file.status == "completed" && file.storage_path.is_some() && task.expires_at > now {
Some(format!("/downloads/{}", file.id))
} else {
None
},
})
.collect::<Vec<_>>();
let progress = if task.total_files > 0 {
((task.completed_files + task.failed_files) * 100 / task.total_files).clamp(0, 100)
} else {
0
};
let download_all_url = if task.status == "completed" && task.expires_at > now {
Some(format!("/downloads/tasks/{}", task.id))
} else {
None
};
result_tasks.push(HistoryTaskView {
task_id: task.id,
status: task.status,
source: task.source,
progress,
total_files: task.total_files,
completed_files: task.completed_files,
failed_files: task.failed_files,
created_at: task.created_at,
completed_at: task.completed_at,
expires_at: task.expires_at,
download_all_url,
files: file_views,
});
}
Ok(Json(Envelope {
success: true,
data: HistoryResponse {
tasks: result_tasks,
page,
limit,
total,
},
}))
}
async fn list_api_keys(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<ApiKeyListResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, _email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
let rows = sqlx::query_as::<_, ApiKeyView>(
r#"
SELECT
id,
name,
key_prefix,
permissions,
rate_limit,
is_active,
last_used_at,
last_used_ip::text AS last_used_ip,
created_at
FROM api_keys
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: ApiKeyListResponse { api_keys: rows },
}))
}
#[derive(Debug, Deserialize)]
struct CreateApiKeyRequest {
name: String,
permissions: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct CreateApiKeyResponse {
id: Uuid,
name: String,
key_prefix: String,
key: String,
message: String,
}
async fn create_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateApiKeyRequest>,
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
if req.name.trim().is_empty() || req.name.len() > 100 {
return Err(AppError::new(ErrorCode::InvalidRequest, "name 不合法"));
}
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let billing = billing::get_user_billing(&state, user_id).await?;
if !billing.plan.feature_api_enabled {
return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key"));
}
let permissions = normalize_permissions(req.permissions)?;
let (full_key, key_prefix) = generate_api_key();
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
let row_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, permissions, rate_limit)
VALUES ($1, $2, $3, $4, $5, 100)
RETURNING id
"#,
)
.bind(user_id)
.bind(req.name.trim())
.bind(&key_prefix)
.bind(key_hash)
.bind(&permissions)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建 API Key 失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: CreateApiKeyResponse {
id: row_id,
name: req.name.trim().to_string(),
key_prefix,
key: full_key,
message: "请保存此 Key它只会显示一次".to_string(),
},
}))
}
async fn disable_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(key_id): Path<Uuid>,
) -> Result<Json<Envelope<serde_json::Value>>, 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 result = sqlx::query("UPDATE api_keys SET is_active = false WHERE id = $1 AND user_id = $2")
.bind(key_id)
.bind(user_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?;
if result.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::NotFound, "API Key 不存在"));
}
Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已禁用" }),
}))
}
async fn rotate_api_key(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(key_id): Path<Uuid>,
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let (user_id, email_verified) = match principal {
context::Principal::User {
user_id,
email_verified,
..
} => (user_id, email_verified),
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
};
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let (full_key, key_prefix) = generate_api_key();
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
#[derive(Debug, FromRow)]
struct RotateRow {
id: Uuid,
name: String,
}
let row = sqlx::query_as::<_, RotateRow>(
r#"
UPDATE api_keys
SET key_prefix = $1,
key_hash = $2,
is_active = true
WHERE id = $3 AND user_id = $4
RETURNING id, name
"#,
)
.bind(&key_prefix)
.bind(key_hash)
.bind(key_id)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "API Key 不存在"))?;
Ok(Json(Envelope {
success: true,
data: CreateApiKeyResponse {
id: row.id,
name: row.name,
key_prefix,
key: full_key,
message: "请保存此 Key它只会显示一次".to_string(),
},
}))
}
fn generate_api_key() -> (String, String) {
let mut prefix_bytes = [0u8; 4];
rand::rngs::OsRng.fill_bytes(&mut prefix_bytes);
let prefix = hex::encode(prefix_bytes);
let key_prefix = format!("if_live_{prefix}");
let mut secret_bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret_bytes);
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
let full = format!("{key_prefix}_{secret}");
(full, key_prefix)
}
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"];
let mut perms = Vec::<String>::new();
if let Some(values) = input {
for value in values {
let v = value.trim().to_ascii_lowercase();
if v.is_empty() {
continue;
}
if !allowed.contains(&v.as_str()) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("不支持的权限: {v}"),
));
}
if !perms.contains(&v) {
perms.push(v);
}
}
}
if perms.is_empty() {
perms.push("compress".to_string());
}
Ok(serde_json::json!(perms))
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
}
Ok(())
}
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
}
Ok(())
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
let hashed = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?;
Ok(hashed.to_string())
}
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
let parsed = PasswordHash::new(password_hash)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "密码错误"))?;
Ok(())
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn sha256_hex(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn map_unique_violation(err: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &err {
if let Some(code) = db_err.code() {
if code == "23505" {
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
}
}
}
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
}