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

1553
src/api/admin.rs Normal file

File diff suppressed because it is too large Load Diff

596
src/api/auth.rs Normal file
View File

@@ -0,0 +1,596 @@
use crate::auth;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::mail;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{
extract::State,
http::HeaderMap,
routing::post,
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 uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/send-verification", post(send_verification))
.route("/verify-email", post(verify_email))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
}
#[derive(Debug, Deserialize)]
struct RegisterRequest {
email: String,
password: String,
username: String,
}
#[derive(Debug, Serialize)]
struct RegisterResponse {
user: UserView,
token: String,
message: String,
}
#[derive(Debug, Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Debug, Serialize)]
struct LoginResponse {
token: String,
expires_at: DateTime<Utc>,
user: UserView,
}
#[derive(Debug, Serialize)]
struct UserView {
id: Uuid,
email: String,
username: String,
role: String,
email_verified: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: Uuid,
email: String,
username: String,
password_hash: String,
role: String,
is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<Envelope<RegisterResponse>>, AppError> {
validate_email(&req.email)?;
validate_username(&req.username)?;
validate_password(&req.password)?;
let password_hash = hash_password(&req.password)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
"#,
)
.bind(req.email.to_lowercase())
.bind(&req.username)
.bind(password_hash)
.fetch_one(&state.db)
.await
.map_err(map_unique_violation)?;
let (token, _expires_at) =
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
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)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
let body = RegisterResponse {
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
token,
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
};
Ok(Json(Envelope {
success: true,
data: body,
}))
}
async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Envelope<LoginResponse>>, AppError> {
let identity = req.email.trim();
if identity.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空"));
}
let user = if identity.contains('@') {
validate_email(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE email = $1
"#,
)
.bind(identity.to_lowercase())
.fetch_optional(&state.db)
.await
} else {
validate_username(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE username = $1
"#,
)
.bind(identity)
.fetch_optional(&state.db)
.await
}
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
if !user.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
verify_password(&req.password, &user.password_hash)?;
let (token, expires_at) =
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
Ok(Json(Envelope {
success: true,
data: LoginResponse {
token,
expires_at,
user: UserView {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
},
},
}))
}
#[derive(Debug, Serialize)]
struct MessageResponse {
message: String,
}
async fn send_verification(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
// Rate limit: 1 per minute per user
let key = format!("rate:send_verification:{}:{}", claims.sub, 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 > 1 {
return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试"));
}
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
if user.email_verified_at.is_some() {
return Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱已验证,无需重复验证".to_string(),
},
}));
}
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)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "验证邮件已发送,请查收".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct VerifyEmailRequest {
token: String,
}
async fn verify_email(
State(state): State<AppState>,
Json(req): Json<VerifyEmailRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
if req.token.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
}
let token_hash = sha256_hex(&req.token);
let now = Utc::now();
let updated = sqlx::query(
r#"
WITH v AS (
SELECT user_id
FROM email_verifications
WHERE token_hash = $1
AND verified_at IS NULL
AND expires_at > $2
LIMIT 1
),
u AS (
UPDATE users
SET email_verified_at = $2
WHERE id = (SELECT user_id FROM v)
AND email_verified_at IS NULL
RETURNING id
)
UPDATE email_verifications
SET verified_at = $2
WHERE token_hash = $1
AND verified_at IS NULL
AND expires_at > $2
AND user_id IN (SELECT id FROM u)
"#,
)
.bind(token_hash)
.bind(now)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "邮箱验证失败").with_source(err))?;
if updated.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
}
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱验证成功".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct ForgotPasswordRequest {
email: String,
}
async fn forgot_password(
State(state): State<AppState>,
Json(req): Json<ForgotPasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
validate_email(&req.email)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT
id,
email,
username,
password_hash,
role::text AS role,
is_active,
email_verified_at
FROM users
WHERE email = $1
"#,
)
.bind(req.email.to_lowercase())
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
if let Some(user) = user {
let reset_token = generate_token();
let token_hash = sha256_hex(&reset_token);
let expires_at_db = Utc::now() + Duration::hours(1);
let _ = sqlx::query(
r#"
INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
"#,
)
.bind(user.id)
.bind(token_hash)
.bind(expires_at_db)
.execute(&state.db)
.await;
let reset_url = format!(
"{}/reset-password?token={}",
state.config.public_base_url, reset_token
);
let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
}
Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "如果该邮箱已注册,您将收到重置邮件".to_string(),
},
}))
}
#[derive(Debug, Deserialize)]
struct ResetPasswordRequest {
token: String,
new_password: String,
}
async fn reset_password(
State(state): State<AppState>,
Json(req): Json<ResetPasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
if req.token.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
}
validate_password(&req.new_password)?;
let token_hash = sha256_hex(&req.token);
let now = Utc::now();
let user_id: Option<Uuid> = sqlx::query_scalar(
r#"
SELECT user_id
FROM password_resets
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > $2
"#,
)
.bind(&token_hash)
.bind(now)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
let Some(user_id) = user_id else {
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
};
let password_hash = hash_password(&req.new_password)?;
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(password_hash)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
sqlx::query("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL")
.bind(token_hash)
.bind(now)
.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: "密码重置成功".to_string(),
},
}))
}
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);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?
.to_string()
.pipe(Ok)
}
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)
}
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}

738
src/api/billing.rs Normal file
View File

@@ -0,0 +1,738 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::idempotency;
use crate::services::settings;
use crate::state::AppState;
use axum::extract::{ConnectInfo, State};
use axum::http::HeaderMap;
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::net::SocketAddr;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/billing/plans", get(list_plans))
.route("/billing/subscription", get(get_subscription))
.route("/billing/usage", get(get_usage))
.route("/billing/invoices", get(list_invoices))
.route("/billing/checkout", post(create_checkout))
.route("/billing/portal", post(create_portal))
}
#[derive(Debug, FromRow, Serialize)]
struct PlanView {
id: Uuid,
code: String,
name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct PlansResponse {
plans: Vec<PlanView>,
}
async fn list_plans(State(state): State<AppState>) -> Result<Json<Envelope<PlansResponse>>, AppError> {
let plans = sqlx::query_as::<_, PlanView>(
r#"
SELECT
id, code, name, currency, amount_cents, interval,
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
features
FROM plans
WHERE is_active = true
ORDER BY amount_cents ASC
"#,
)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
Ok(Json(Envelope {
success: true,
data: PlansResponse { plans },
}))
}
#[derive(Debug, Serialize)]
struct SubscriptionPlanView {
id: Uuid,
code: String,
name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct SubscriptionView {
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
cancel_at_period_end: bool,
plan: SubscriptionPlanView,
}
#[derive(Debug, Serialize)]
struct SubscriptionResponse {
subscription: SubscriptionView,
}
async fn get_subscription(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<SubscriptionResponse>>, 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 SubRow {
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
cancel_at_period_end: bool,
plan_id: Uuid,
plan_code: String,
plan_name: String,
currency: String,
amount_cents: i32,
interval: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
let sub = sqlx::query_as::<_, SubRow>(
r#"
SELECT
s.status::text AS status,
s.current_period_start,
s.current_period_end,
s.cancel_at_period_end,
p.id AS plan_id,
p.code AS plan_code,
p.name AS plan_name,
p.currency,
p.amount_cents,
p.interval,
p.included_units_per_period,
p.max_file_size_mb,
p.max_files_per_batch,
p.retention_days,
p.features
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')
ORDER BY s.current_period_end DESC
LIMIT 1
"#,
)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
let (status, period_start, period_end, cancel_at_period_end, plan) = if let Some(sub) = sub {
(
sub.status,
sub.current_period_start,
sub.current_period_end,
sub.cancel_at_period_end,
SubscriptionPlanView {
id: sub.plan_id,
code: sub.plan_code,
name: sub.plan_name,
currency: sub.currency,
amount_cents: sub.amount_cents,
interval: sub.interval,
included_units_per_period: sub.included_units_per_period,
max_file_size_mb: sub.max_file_size_mb,
max_files_per_batch: sub.max_files_per_batch,
retention_days: sub.retention_days,
features: sub.features,
},
)
} else {
let plan: PlanView = sqlx::query_as::<_, PlanView>(
r#"
SELECT
id, code, name, currency, amount_cents, interval,
included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days,
features
FROM plans
WHERE code = 'free'
LIMIT 1
"#,
)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
let (start, end) = billing::current_month_period_utc8(Utc::now());
(
"free".to_string(),
start,
end,
false,
SubscriptionPlanView {
id: plan.id,
code: plan.code,
name: plan.name,
currency: plan.currency,
amount_cents: plan.amount_cents,
interval: plan.interval,
included_units_per_period: plan.included_units_per_period,
max_file_size_mb: plan.max_file_size_mb,
max_files_per_batch: plan.max_files_per_batch,
retention_days: plan.retention_days,
features: plan.features,
},
)
};
Ok(Json(Envelope {
success: true,
data: SubscriptionResponse {
subscription: SubscriptionView {
status,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end,
plan,
},
},
}))
}
#[derive(Debug, Serialize)]
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,
}
async fn get_usage(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<UsageResponse>>, 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 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);
Ok(Json(Envelope {
success: true,
data: UsageResponse {
period_start: billing.period_start,
period_end: billing.period_end,
used_units: usage.used_units,
included_units: included,
bonus_units: usage.bonus_units,
total_units: total,
remaining_units: remaining,
},
}))
}
#[derive(Debug, Deserialize)]
struct PagingQuery {
page: Option<u32>,
limit: Option<u32>,
}
#[derive(Debug, FromRow, Serialize)]
struct InvoiceView {
invoice_number: String,
status: String,
currency: String,
total_amount_cents: i32,
period_start: Option<DateTime<Utc>>,
period_end: Option<DateTime<Utc>>,
hosted_invoice_url: Option<String>,
pdf_url: Option<String>,
paid_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct InvoicesResponse {
invoices: Vec<InvoiceView>,
page: u32,
limit: u32,
}
async fn list_invoices(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<PagingQuery>,
) -> Result<Json<Envelope<InvoicesResponse>>, 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 invoices = sqlx::query_as::<_, InvoiceView>(
r#"
SELECT
invoice_number,
status::text AS status,
currency,
total_amount_cents,
period_start,
period_end,
hosted_invoice_url,
pdf_url,
paid_at,
created_at
FROM invoices
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))?;
Ok(Json(Envelope {
success: true,
data: InvoicesResponse {
invoices,
page,
limit,
},
}))
}
#[derive(Debug, Deserialize)]
struct CheckoutRequest {
plan_id: Uuid,
}
#[derive(Debug, Serialize, Deserialize)]
struct CheckoutResponse {
checkout_url: String,
}
async fn create_checkout(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CheckoutRequest>,
) -> Result<Json<Envelope<CheckoutResponse>>, 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 idempotency_key = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
let request_hash = idempotency_key.as_ref().map(|_| {
let plan = req.plan_id.to_string();
idempotency::sha256_hex(&[b"billing_checkout", plan.as_bytes()])
});
let mut idem_acquired = false;
if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) {
match idempotency::begin(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
state.config.idempotency_ttl_hours as i64,
)
.await?
{
idempotency::BeginResult::Replay { response_body, .. } => {
let resp: CheckoutResponse = serde_json::from_value(response_body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok(Json(Envelope { success: true, data: resp }));
}
idempotency::BeginResult::InProgress => {
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
10_000,
)
.await?
{
let resp: CheckoutResponse = serde_json::from_value(body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok(Json(Envelope { success: true, data: resp }));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idem_acquired = true;
}
}
}
let session_result: Result<String, AppError> = (async {
let stripe_secret = settings::get_stripe_secret(&state)
.await
.map_err(|err| err.with_source("stripe secret not configured"))?;
#[derive(Debug, FromRow)]
struct PlanStripeRow {
stripe_price_id: Option<String>,
amount_cents: i32,
is_active: bool,
}
let plan = sqlx::query_as::<_, PlanStripeRow>(
r#"
SELECT stripe_price_id, amount_cents, is_active
FROM plans
WHERE id = $1
"#,
)
.bind(req.plan_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?;
if !plan.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用"));
}
let Some(price_id) = plan.stripe_price_id.filter(|v| !v.trim().is_empty()) else {
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
};
if plan.amount_cents <= 0 {
return Err(AppError::new(ErrorCode::InvalidRequest, "该套餐不可订阅"));
}
#[derive(Debug, FromRow)]
struct UserStripeRow {
email: String,
billing_customer_id: Option<String>,
}
let user = sqlx::query_as::<_, UserStripeRow>(
"SELECT email, billing_customer_id FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let customer_id = if let Some(cus) = user.billing_customer_id {
cus
} else {
let cus = stripe_create_customer(&stripe_secret, &user.email, user_id).await?;
let _ = sqlx::query("UPDATE users SET billing_customer_id = $2 WHERE id = $1")
.bind(user_id)
.bind(&cus)
.execute(&state.db)
.await;
cus
};
let success_url =
format!("{}/dashboard/billing?checkout=success", state.config.public_base_url);
let cancel_url = format!("{}/pricing?checkout=cancel", state.config.public_base_url);
stripe_create_checkout_session(
&stripe_secret,
&customer_id,
&price_id,
&success_url,
&cancel_url,
user_id,
)
.await
})
.await;
match session_result {
Ok(session) => {
if let (Some(idem), Some(request_hash)) =
(idempotency_key.as_deref(), request_hash.as_deref())
{
if idem_acquired {
let _ = idempotency::complete(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
200,
serde_json::to_value(&CheckoutResponse {
checkout_url: session.clone(),
})
.unwrap_or(serde_json::Value::Null),
)
.await;
}
}
Ok(Json(Envelope {
success: true,
data: CheckoutResponse {
checkout_url: session,
},
}))
}
Err(err) => {
if let (Some(idem), Some(request_hash)) =
(idempotency_key.as_deref(), request_hash.as_deref())
{
if idem_acquired {
let _ = idempotency::abort(
&state,
idempotency::Scope::User(user_id),
idem,
request_hash,
)
.await;
}
}
Err(err)
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct PortalResponse {
url: String,
}
async fn create_portal(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<PortalResponse>>, 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 stripe_secret = settings::get_stripe_secret(&state)
.await
.map_err(|err| err.with_source("stripe secret not configured"))?;
let customer_id: Option<String> =
sqlx::query_scalar("SELECT billing_customer_id FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(customer_id) = customer_id.filter(|v| !v.trim().is_empty()) else {
return Err(AppError::new(ErrorCode::InvalidRequest, "未找到 Stripe Customer"));
};
let return_url = format!("{}/dashboard/billing", state.config.public_base_url);
let url = stripe_create_portal_session(&stripe_secret, &customer_id, &return_url).await?;
Ok(Json(Envelope {
success: true,
data: PortalResponse { url },
}))
}
async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/customers",
vec![
("email".to_string(), email.to_string()),
("metadata[user_id]".to_string(), user_id.to_string()),
],
)
.await?;
let id = resp
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe customer 创建失败"))?;
Ok(id.to_string())
}
async fn stripe_create_checkout_session(
secret: &str,
customer_id: &str,
price_id: &str,
success_url: &str,
cancel_url: &str,
user_id: Uuid,
) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/checkout/sessions",
vec![
("mode".to_string(), "subscription".to_string()),
("customer".to_string(), customer_id.to_string()),
("line_items[0][price]".to_string(), price_id.to_string()),
("line_items[0][quantity]".to_string(), "1".to_string()),
("success_url".to_string(), success_url.to_string()),
("cancel_url".to_string(), cancel_url.to_string()),
("allow_promotion_codes".to_string(), "true".to_string()),
("client_reference_id".to_string(), user_id.to_string()),
("metadata[user_id]".to_string(), user_id.to_string()),
],
)
.await?;
let url = resp
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe checkout 创建失败"))?;
Ok(url.to_string())
}
async fn stripe_create_portal_session(
secret: &str,
customer_id: &str,
return_url: &str,
) -> Result<String, AppError> {
let resp: serde_json::Value = stripe_post_form(
secret,
"/v1/billing_portal/sessions",
vec![
("customer".to_string(), customer_id.to_string()),
("return_url".to_string(), return_url.to_string()),
],
)
.await?;
let url = resp
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::Internal, "Stripe portal 创建失败"))?;
Ok(url.to_string())
}
async fn stripe_post_form(
secret: &str,
path: &str,
form: Vec<(String, String)>,
) -> Result<serde_json::Value, AppError> {
let url = format!("https://api.stripe.com{path}");
let client = reqwest::Client::new();
let resp = client
.post(url)
.bearer_auth(secret)
.form(&form)
.send()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 请求失败").with_source(err))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应读取失败").with_source(err))?;
if !status.is_success() {
tracing::error!(status = %status, body = %body, "Stripe API error");
return Err(AppError::new(ErrorCode::Internal, "Stripe API 调用失败"));
}
serde_json::from_str(&body)
.map_err(|err| AppError::new(ErrorCode::Internal, "Stripe 响应解析失败").with_source(err))
}

1218
src/api/compress.rs Normal file

File diff suppressed because it is too large Load Diff

229
src/api/context.rs Normal file
View File

@@ -0,0 +1,229 @@
use crate::auth;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::Serialize;
use sha2::Sha256;
use sqlx::FromRow;
use std::net::IpAddr;
use time::Duration as TimeDuration;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Principal {
Anonymous { session_id: String },
User { user_id: Uuid, role: String, email_verified: bool },
ApiKey {
user_id: Uuid,
api_key_id: Uuid,
role: String,
email_verified: bool,
},
}
pub fn client_ip(headers: &HeaderMap, connect_ip: IpAddr) -> IpAddr {
if let Some(ip) = parse_forwarded_for(headers) {
return ip;
}
if let Some(ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<IpAddr>().ok())
{
return ip;
}
connect_ip
}
fn parse_forwarded_for(headers: &HeaderMap) -> Option<IpAddr> {
let value = headers.get("x-forwarded-for")?.to_str().ok()?;
value
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<IpAddr>().ok())
}
pub async fn authenticate(
state: &AppState,
jar: CookieJar,
headers: &HeaderMap,
ip: IpAddr,
) -> Result<(CookieJar, Principal), AppError> {
if let Some(principal) = try_jwt(state, headers).await? {
return Ok((jar, principal));
}
if let Some(principal) = try_api_key(state, headers, ip).await? {
return Ok((jar, principal));
}
if !state.config.allow_anonymous_upload {
return Err(AppError::new(ErrorCode::Unauthorized, "未登录"));
}
let (jar, session_id) = ensure_session_cookie(jar);
Ok((jar, Principal::Anonymous { session_id }))
}
async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result<Option<Principal>, AppError> {
let auth_header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
let claims = auth::require_jwt(&state.config.jwt_secret, headers)?;
#[derive(Debug, FromRow)]
struct UserAuthRow {
id: Uuid,
role: String,
is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
let user = sqlx::query_as::<_, UserAuthRow>(
r#"
SELECT id, role::text AS role, is_active, email_verified_at
FROM users
WHERE id = $1
"#,
)
.bind(claims.sub)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
if !user.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
Ok(Some(Principal::User {
user_id: user.id,
role: user.role,
email_verified: user.email_verified_at.is_some(),
}))
}
async fn try_api_key(
state: &AppState,
headers: &HeaderMap,
ip: IpAddr,
) -> Result<Option<Principal>, AppError> {
let key = headers
.get("x-api-key")
.or_else(|| headers.get("X-API-Key"))
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty());
let Some(full_key) = key else {
return Ok(None);
};
let key_prefix = full_key
.get(0..16)
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 格式错误"))?;
#[derive(Debug, FromRow)]
struct ApiKeyAuthRow {
id: Uuid,
user_id: Uuid,
key_hash: String,
is_active: bool,
user_role: String,
user_is_active: bool,
email_verified_at: Option<DateTime<Utc>>,
}
let row = sqlx::query_as::<_, ApiKeyAuthRow>(
r#"
SELECT
k.id,
k.user_id,
k.key_hash,
k.is_active,
u.role::text AS user_role,
u.is_active AS user_is_active,
u.email_verified_at
FROM api_keys k
JOIN users u ON u.id = k.user_id
WHERE k.key_prefix = $1
"#,
)
.bind(key_prefix)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "API Key 无效"))?;
if !row.user_is_active || !row.is_active {
return Err(AppError::new(ErrorCode::Forbidden, "API Key 已禁用"));
}
let expected = api_key_hash(full_key, &state.config.api_key_pepper)?;
if expected != row.key_hash {
return Err(AppError::new(ErrorCode::Unauthorized, "API Key 无效"));
}
let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW(), last_used_ip = $2 WHERE id = $1")
.bind(row.id)
.bind(ip.to_string())
.execute(&state.db)
.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(),
}))
}
pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) {
if let Some(cookie) = jar.get("if_session") {
let session_id = cookie.value().trim().to_string();
if !session_id.is_empty() {
return (jar, session_id);
}
}
let session_id = generate_session_id();
let cookie = Cookie::build(("if_session", session_id.clone()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(7))
.build();
(jar.add(cookie), session_id)
}
fn generate_session_id() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn api_key_hash(full_key: &str, pepper: &str) -> Result<String, AppError> {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(pepper.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "API Key pepper 错误").with_source(err))?;
mac.update(full_key.as_bytes());
let result = mac.finalize().into_bytes();
Ok(hex::encode(result))
}

343
src/api/downloads.rs Normal file
View File

@@ -0,0 +1,343 @@
use crate::api::context;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{header, HeaderMap};
use axum::body::Body;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use chrono::{DateTime, Utc};
use sqlx::FromRow;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use tokio_util::io::ReaderStream;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/tasks/{task_id}", get(download_task_zip))
.route("/{file_id}", get(download_file))
}
#[derive(Debug, FromRow)]
struct DownloadRow {
storage_path: Option<String>,
output_format: String,
original_name: String,
file_status: String,
task_user_id: Option<Uuid>,
task_session_id: Option<String>,
expires_at: DateTime<Utc>,
}
async fn download_file(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(file_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let row = sqlx::query_as::<_, DownloadRow>(
r#"
SELECT
f.storage_path,
f.output_format,
f.original_name,
f.status::text AS file_status,
t.user_id AS task_user_id,
t.session_id AS task_session_id,
t.expires_at
FROM task_files f
JOIN tasks t ON t.id = f.task_id
WHERE f.id = $1
"#,
)
.bind(file_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "文件不存在"))?;
if row.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "文件已过期或不存在"));
}
if row.file_status != "completed" {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
}
authorize_download(&principal, &row)?;
let Some(path) = &row.storage_path else {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let bytes = tokio::fs::read(path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?;
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
header::CONTENT_TYPE,
content_type(&row.output_format).parse().unwrap(),
);
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", sanitize_filename(&row.original_name))
.parse()
.unwrap(),
);
Ok((jar, (resp_headers, bytes).into_response()))
}
fn authorize_download(principal: &context::Principal, row: &DownloadRow) -> Result<(), AppError> {
if let Some(user_id) = row.task_user_id {
match principal {
context::Principal::User { user_id: me, .. } if *me == user_id => Ok(()),
context::Principal::ApiKey { user_id: me, .. } if *me == user_id => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")),
}
} else {
let expected = row.task_session_id.as_deref().unwrap_or("");
match principal {
context::Principal::Anonymous { session_id } if session_id == expected => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限下载该文件")),
}
}
}
fn content_type(format: &str) -> &'static str {
match format.trim().to_ascii_lowercase().as_str() {
"png" => "image/png",
"jpeg" | "jpg" => "image/jpeg",
"webp" => "image/webp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
fn sanitize_filename(name: &str) -> String {
let mut out = name.trim().to_string();
if out.is_empty() {
out = "download".to_string();
}
out = out.replace(['\r', '\n', '"', '\\'], "_");
if out.len() > 120 {
out.truncate(120);
}
out
}
#[derive(Debug, FromRow)]
struct TaskZipRow {
user_id: Option<Uuid>,
session_id: Option<String>,
status: String,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct TaskZipFileRow {
id: Uuid,
storage_path: Option<String>,
original_name: String,
output_format: String,
}
async fn download_task_zip(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskZipRow>(
r#"
SELECT
user_id,
session_id,
status::text AS status,
completed_at,
expires_at
FROM tasks
WHERE id = $1
"#,
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
if task.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在"));
}
if task.completed_at.is_none() || matches!(task.status.as_str(), "pending" | "processing") {
return Err(AppError::new(ErrorCode::InvalidRequest, "任务尚未完成"));
}
if let Some(user_id) = task.user_id {
match principal {
context::Principal::User { user_id: me, .. } if me == user_id => {}
context::Principal::ApiKey { user_id: me, .. } if me == user_id => {}
_ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")),
}
} else {
let expected = task.session_id.as_deref().unwrap_or("");
match principal {
context::Principal::Anonymous { session_id } if session_id == expected => {}
_ => return Err(AppError::new(ErrorCode::Forbidden, "无权限下载该任务")),
}
}
if state.config.storage_type.to_ascii_lowercase() != "local" {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let zip_dir = format!("{}/zips", state.config.storage_path);
tokio::fs::create_dir_all(&zip_dir)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?;
let zip_path = PathBuf::from(format!("{zip_dir}/{task_id}.zip"));
if tokio::fs::try_exists(&zip_path).await.unwrap_or(false) {
return stream_zip(jar, zip_path, task_id).await;
}
let rows = sqlx::query_as::<_, TaskZipFileRow>(
r#"
SELECT id, storage_path, original_name, output_format
FROM task_files
WHERE task_id = $1 AND status = 'completed'
ORDER BY created_at ASC
"#,
)
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
if rows.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let mut used_names: HashMap<String, usize> = HashMap::new();
let mut entries: Vec<(String, String)> = Vec::new();
for row in rows {
let Some(path) = row.storage_path else { continue };
let name = build_zip_entry_name(&row.original_name, &row.output_format, &mut used_names);
entries.push((name, path));
}
if entries.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let zip_path_cloned = zip_path.clone();
let task_id_str = task_id.to_string();
tokio::task::spawn_blocking(move || generate_zip_file(&zip_path_cloned, &task_id_str, &entries))
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?;
stream_zip(jar, zip_path, task_id).await
}
async fn stream_zip(
jar: axum_extra::extract::cookie::CookieJar,
zip_path: PathBuf,
task_id: Uuid,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let file = tokio::fs::File::open(&zip_path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取 ZIP 失败").with_source(err))?;
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(header::CONTENT_TYPE, "application/zip".parse().unwrap());
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"task_{task_id}.zip\"")
.parse()
.unwrap(),
);
Ok((jar, (resp_headers, body).into_response()))
}
fn build_zip_entry_name(
original_name: &str,
output_format: &str,
used: &mut HashMap<String, usize>,
) -> String {
let mut base = sanitize_zip_name(original_name);
if let Some((head, _ext)) = base.rsplit_once('.') {
base = head.to_string();
}
let ext = match output_format.trim().to_ascii_lowercase().as_str() {
"jpeg" | "jpg" => "jpg",
"png" => "png",
"webp" => "webp",
"avif" => "avif",
_ => "bin",
};
let base = if base.is_empty() { "file".to_string() } else { base };
let candidate = format!("{base}.{ext}");
let counter = used.entry(candidate.clone()).or_insert(0);
if *counter == 0 {
*counter = 1;
return candidate;
}
let name = format!("{base} ({counter}).{ext}");
*counter += 1;
name
}
fn sanitize_zip_name(name: &str) -> String {
let mut out = name.trim().to_string();
out = out.replace(['\r', '\n', '"', '\\', '/', ':'], "_");
if out.len() > 120 {
out.truncate(120);
}
out
}
fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, String)]) -> Result<(), String> {
let tmp = PathBuf::from(format!("{}.tmp", zip_path.to_string_lossy()));
let file = std::fs::File::create(&tmp).map_err(|e| format!("create zip: {e}"))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, path) in entries {
zip.start_file(name, options)
.map_err(|e| format!("zip start_file: {e}"))?;
let mut f = std::fs::File::open(path).map_err(|e| format!("open file: {e}"))?;
std::io::copy(&mut f, &mut zip).map_err(|e| format!("copy: {e}"))?;
}
zip.finish().map_err(|e| format!("finish: {e}"))?;
std::fs::rename(&tmp, zip_path).map_err(|e| format!("rename: {e}"))?;
tracing::info!(task_id = %task_id, path = %zip_path.to_string_lossy(), "ZIP generated");
Ok(())
}

8
src/api/envelope.rs Normal file
View File

@@ -0,0 +1,8 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Envelope<T> {
pub success: bool,
pub data: T,
}

39
src/api/health.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::state::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct HealthResponse {
status: &'static str,
database: &'static str,
redis: &'static str,
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
let database_ok = sqlx::query("SELECT 1")
.execute(&state.db)
.await
.is_ok();
let mut redis_conn = state.redis.clone();
let redis_ok = redis::cmd("PING")
.query_async::<_, String>(&mut redis_conn)
.await
.is_ok();
let status = if database_ok && redis_ok {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let body = HealthResponse {
status: if status == StatusCode::OK { "healthy" } else { "unhealthy" },
database: if database_ok { "connected" } else { "unavailable" },
redis: if redis_ok { "connected" } else { "unavailable" },
};
(status, Json(body))
}

66
src/api/mod.rs Normal file
View File

@@ -0,0 +1,66 @@
mod auth;
mod context;
mod envelope;
mod compress;
mod downloads;
mod billing;
mod webhooks;
mod user;
mod tasks;
mod admin;
mod health;
mod response;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
use axum::Router;
use std::net::SocketAddr;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
pub async fn run(state: AppState) -> Result<(), AppError> {
let addr = format!("{}:{}", state.config.host, state.config.port);
if let Err(err) = crate::services::bootstrap::ensure_schema(&state).await {
tracing::error!(error = %err, "数据库结构初始化失败");
}
if let Err(err) = crate::services::bootstrap::ensure_admin_user(&state).await {
tracing::error!(error = %err, "管理员账号初始化失败");
}
let static_service = ServeDir::new("static").not_found_service(ServeFile::new("static/index.html"));
let v1 = v1_router().layer(DefaultBodyLimit::max(100 * 1024 * 1024));
let app = Router::new()
.route("/health", axum::routing::get(health::health))
.nest("/downloads", downloads::router())
.nest("/api/v1", v1)
.fallback_service(static_service)
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "监听端口失败").with_source(err))?;
tracing::info!(addr = %addr, "API server listening");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "HTTP 服务异常退出").with_source(err))
}
fn v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.merge(compress::router())
.merge(tasks::router())
.merge(billing::router())
.merge(webhooks::router())
.merge(user::router())
.merge(admin::router())
.fallback(response::not_found)
}

6
src/api/response.rs Normal file
View File

@@ -0,0 +1,6 @@
use crate::error::{AppError, ErrorCode};
pub async fn not_found() -> AppError {
AppError::new(ErrorCode::NotFound, "接口不存在")
}

987
src/api/tasks.rs Normal file
View File

@@ -0,0 +1,987 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::billing::{BillingContext, Plan};
use crate::services::compress;
use crate::services::compress::{CompressionLevel, ImageFmt};
use crate::services::idempotency;
use crate::state::AppState;
use axum::extract::{ConnectInfo, Multipart, Path, State};
use axum::http::HeaderMap;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::net::{IpAddr, SocketAddr};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route("/compress/batch", post(create_batch_task))
.route("/compress/tasks/{task_id}", get(get_task))
.route("/compress/tasks/{task_id}/cancel", post(cancel_task))
.route("/compress/tasks/{task_id}", delete(delete_task))
}
#[derive(Debug, Serialize, Deserialize)]
struct BatchCreateResponse {
task_id: Uuid,
total_files: i32,
status: String,
status_url: String,
}
#[derive(Debug)]
struct BatchFileInput {
file_id: Uuid,
original_name: String,
original_format: ImageFmt,
output_format: ImageFmt,
original_size: u64,
storage_path: String,
}
#[derive(Debug)]
struct BatchOptions {
level: CompressionLevel,
compression_rate: Option<u8>,
output_format: Option<ImageFmt>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
}
async fn create_batch_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<BatchCreateResponse>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
if state.config.storage_type.to_ascii_lowercase() != "local" {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let idempotency_key = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
let idempotency_scope = match &principal {
context::Principal::User { user_id, .. } => Some(idempotency::Scope::User(*user_id)),
context::Principal::ApiKey { api_key_id, .. } => Some(idempotency::Scope::ApiKey(*api_key_id)),
_ => None,
};
let task_id = Uuid::new_v4();
let (files, opts, request_hash) = parse_batch_request(&state, task_id, &mut multipart).await?;
if files.is_empty() {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "缺少 files[]"));
}
let mut idem_acquired = false;
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
match idempotency::begin(
&state,
scope,
idem_key,
&request_hash,
state.config.idempotency_ttl_hours as i64,
)
.await?
{
idempotency::BeginResult::Replay { response_body, .. } => {
cleanup_file_paths(&files).await;
let resp: BatchCreateResponse =
serde_json::from_value(response_body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok((jar, Json(Envelope { success: true, data: resp })));
}
idempotency::BeginResult::InProgress => {
cleanup_file_paths(&files).await;
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
scope,
idem_key,
&request_hash,
10_000,
)
.await?
{
let resp: BatchCreateResponse =
serde_json::from_value(body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
return Ok((jar, Json(Envelope { success: true, data: resp })));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idem_acquired = true;
}
}
}
let create_result: Result<BatchCreateResponse, AppError> = (async {
let (retention, task_owner, source) = match &principal {
context::Principal::Anonymous { session_id } => {
enforce_batch_limits_anonymous(&state, &files)?;
let remaining = anonymous_remaining_units(&state, session_id, ip).await?;
if remaining < files.len() as i64 {
return Err(AppError::new(
ErrorCode::QuotaExceeded,
"匿名试用次数已用完(每日 10 次)",
));
}
Ok((
Duration::hours(state.config.anon_retention_hours as i64),
TaskOwner::Anonymous {
session_id: session_id.clone(),
},
"web",
))
}
context::Principal::User {
user_id,
email_verified,
..
} => {
if !email_verified {
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
}
let billing = billing::get_user_billing(&state, *user_id).await?;
enforce_batch_limits_plan(&billing.plan, &files)?;
ensure_quota_available(&state, &billing, files.len() as i32).await?;
Ok((
Duration::days(billing.plan.retention_days as i64),
TaskOwner::User { user_id: *user_id },
"web",
))
}
context::Principal::ApiKey {
user_id,
api_key_id,
email_verified,
..
} => {
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"));
}
enforce_batch_limits_plan(&billing.plan, &files)?;
ensure_quota_available(&state, &billing, files.len() as i32).await?;
Ok((
Duration::days(billing.plan.retention_days as i64),
TaskOwner::ApiKey {
user_id: *user_id,
api_key_id: *api_key_id,
},
"api",
))
}
}?;
tokio::fs::create_dir_all(&state.config.storage_path)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let expires_at = Utc::now() + retention;
let (user_id, session_id, api_key_id) = match &task_owner {
TaskOwner::Anonymous { session_id } => (None, Some(session_id.clone()), None),
TaskOwner::User { user_id } => (Some(*user_id), None, None),
TaskOwner::ApiKey { user_id, api_key_id } => (Some(*user_id), None, Some(*api_key_id)),
};
let total_original_size: i64 = files.iter().map(|f| f.original_size as i64).sum();
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
sqlx::query(
r#"
INSERT INTO tasks (
id, user_id, session_id, api_key_id, client_ip, source, status,
compression_level, output_format, max_width, max_height, preserve_metadata,
compression_rate,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
expires_at
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
$7::compression_level, $8, $9, $10, $11, $12,
$13, 0, 0,
$14, 0,
$15
)
"#,
)
.bind(task_id)
.bind(user_id)
.bind(session_id)
.bind(api_key_id)
.bind(ip.to_string())
.bind(source)
.bind(opts.level.as_str())
.bind(opts.output_format.map(|f| f.as_str()))
.bind(opts.max_width.map(|v| v as i32))
.bind(opts.max_height.map(|v| v as i32))
.bind(false)
.bind(opts.compression_rate.map(|v| v as i16))
.bind(files.len() as i32)
.bind(total_original_size)
.bind(expires_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?;
for file in &files {
sqlx::query(
r#"
INSERT INTO task_files (
id, task_id,
original_name, original_format, output_format,
original_size,
storage_path, status
) VALUES (
$1, $2,
$3, $4, $5,
$6,
$7, 'pending'
)
"#,
)
.bind(file.file_id)
.bind(task_id)
.bind(&file.original_name)
.bind(file.original_format.as_str())
.bind(file.output_format.as_str())
.bind(file.original_size as i64)
.bind(&file.storage_path)
.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))?;
if let Err(err) = enqueue_task(&state, task_id).await {
let _ = sqlx::query("UPDATE tasks SET status = 'failed', error_message = $2 WHERE id = $1")
.bind(task_id)
.bind("队列提交失败")
.execute(&state.db)
.await;
return Err(err);
}
Ok(BatchCreateResponse {
task_id,
total_files: files.len() as i32,
status: "pending".to_string(),
status_url: format!("/api/v1/compress/tasks/{task_id}"),
})
})
.await;
match create_result {
Ok(resp) => {
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
if idem_acquired {
let _ = idempotency::complete(
&state,
scope,
idem_key,
&request_hash,
200,
serde_json::to_value(&resp).unwrap_or(serde_json::Value::Null),
)
.await;
}
}
Ok((jar, Json(Envelope { success: true, data: resp })))
}
Err(err) => {
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
if idem_acquired {
let _ = idempotency::abort(&state, scope, idem_key, &request_hash).await;
}
}
cleanup_file_paths(&files).await;
Err(err)
}
}
}
#[derive(Debug)]
enum TaskOwner {
Anonymous { session_id: String },
User { user_id: Uuid },
ApiKey { user_id: Uuid, api_key_id: Uuid },
}
async fn enqueue_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
let mut conn = state.redis.clone();
let now = Utc::now().to_rfc3339();
redis::cmd("XADD")
.arg("stream:compress_jobs")
.arg("*")
.arg("task_id")
.arg(task_id.to_string())
.arg("created_at")
.arg(now)
.query_async::<_, redis::Value>(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "写入队列失败").with_source(err))?;
Ok(())
}
async fn parse_batch_request(
state: &AppState,
task_id: Uuid,
multipart: &mut Multipart,
) -> Result<(Vec<BatchFileInput>, BatchOptions, String), AppError> {
let mut files: Vec<BatchFileInput> = Vec::new();
let mut file_digests: Vec<String> = Vec::new();
let mut opts = BatchOptions {
level: CompressionLevel::Medium,
compression_rate: None,
output_format: None,
max_width: None,
max_height: None,
preserve_metadata: false,
};
let base_dir = format!("{}/orig/{task_id}", state.config.storage_path);
tokio::fs::create_dir_all(&base_dir)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err))?;
loop {
let next = multipart.next_field().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err)
});
let field = match next {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
};
let Some(field) = field else { break };
let name = field.name().unwrap_or("").to_string();
if name == "files" || name == "files[]" {
let file_id = Uuid::new_v4();
let original_name = field.file_name().unwrap_or("upload").to_string();
let bytes = match field.bytes().await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err)
);
}
};
let original_size = bytes.len() as u64;
let file_digest = {
let mut h = Sha256::new();
h.update(&bytes);
h.update(original_name.as_bytes());
hex::encode(h.finalize())
};
let original_format = match compress::detect_format(&bytes) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
};
let output_format = opts.output_format.unwrap_or(original_format);
let path = format!("{base_dir}/{file_id}.{}", original_format.extension());
let mut f = match tokio::fs::File::create(&path).await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err),
);
}
};
if let Err(err) = f.write_all(&bytes).await {
let _ = tokio::fs::remove_file(&path).await;
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err),
);
}
files.push(BatchFileInput {
file_id,
original_name,
original_format,
output_format,
original_size,
storage_path: path,
});
file_digests.push(file_digest);
continue;
}
let text = match field.text().await {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err),
);
}
};
match name.as_str() {
"level" => {
opts.level = match compress::parse_level(&text) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
}
}
"output_format" => {
let v = text.trim();
if !v.is_empty() {
opts.output_format = Some(match compress::parse_output_format(v) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
});
}
}
"compression_rate" | "quality" => {
let v = text.trim();
if !v.is_empty() {
opts.compression_rate = Some(match compress::parse_compression_rate(v) {
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(err);
}
});
}
}
"max_width" => {
let v = text.trim();
if !v.is_empty() {
opts.max_width = Some(match v.parse::<u32>() {
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"));
}
});
}
}
"max_height" => {
let v = text.trim();
if !v.is_empty() {
opts.max_height = Some(match v.parse::<u32>() {
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误"));
}
});
}
}
"preserve_metadata" => {
opts.preserve_metadata = matches!(
text.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "y" | "on"
);
}
_ => {}
}
}
if let Some(rate) = opts.compression_rate {
opts.level = compress::rate_to_level(rate);
}
if opts.output_format.is_some() {
cleanup_file_paths(&files).await;
return Err(AppError::new(
ErrorCode::InvalidRequest,
"当前仅支持保持原图片格式",
));
}
let mw = opts.max_width.map(|v| v.to_string()).unwrap_or_default();
let mh = opts.max_height.map(|v| v.to_string()).unwrap_or_default();
let out_fmt = opts.output_format.map(|f| f.as_str()).unwrap_or("");
let rate_key = opts
.compression_rate
.map(|v| v.to_string())
.unwrap_or_default();
let preserve = if opts.preserve_metadata { "1" } else { "0" };
let mut h = Sha256::new();
h.update(b"compress_batch_v1");
h.update(opts.level.as_str().as_bytes());
h.update(out_fmt.as_bytes());
h.update(rate_key.as_bytes());
h.update(mw.as_bytes());
h.update(mh.as_bytes());
h.update(preserve.as_bytes());
for d in &file_digests {
h.update(d.as_bytes());
}
let request_hash = hex::encode(h.finalize());
Ok((files, opts, request_hash))
}
fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> Result<(), AppError> {
let max_files = state.config.anon_max_files_per_batch as usize;
if files.len() > max_files {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("匿名试用单次最多 {} 个文件", max_files),
));
}
let max_bytes = state.config.anon_max_file_size_mb * 1024 * 1024;
for f in files {
if f.original_size > max_bytes {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb),
));
}
}
Ok(())
}
fn enforce_batch_limits_plan(plan: &Plan, files: &[BatchFileInput]) -> Result<(), AppError> {
let max_files = plan.max_files_per_batch as usize;
if files.len() > max_files {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("当前套餐单次最多 {} 个文件", plan.max_files_per_batch),
));
}
let max_bytes = (plan.max_file_size_mb as u64) * 1024 * 1024;
for f in files {
if f.original_size > max_bytes {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("当前套餐单文件最大 {} MB", plan.max_file_size_mb),
));
}
}
Ok(())
}
async fn ensure_quota_available(
state: &AppState,
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(())
}
async fn anonymous_remaining_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
) -> Result<i64, AppError> {
let date = utc8_date();
let session_key = format!("anon_quota:{session_id}:{date}");
let ip_key = format!("anon_quota_ip:{ip}:{date}");
let mut conn = state.redis.clone();
let v1: Option<i64> = redis::cmd("GET")
.arg(session_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let v2: Option<i64> = redis::cmd("GET")
.arg(ip_key)
.query_async(&mut conn)
.await
.unwrap_or(None);
let limit = state.config.anon_daily_units as i64;
Ok(std::cmp::min(limit - v1.unwrap_or(0), limit - v2.unwrap_or(0)))
}
fn utc8_date() -> String {
let now = Utc::now() + Duration::hours(8);
now.format("%Y-%m-%d").to_string()
}
#[derive(Debug, FromRow)]
struct TaskRow {
id: Uuid,
status: String,
total_files: i32,
completed_files: i32,
failed_files: i32,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
user_id: Option<Uuid>,
session_id: Option<String>,
}
#[derive(Debug, FromRow)]
struct TaskFileRow {
id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
}
#[derive(Debug, Serialize)]
struct TaskFileView {
file_id: Uuid,
original_name: String,
original_size: i64,
compressed_size: Option<i64>,
saved_percent: Option<f64>,
status: String,
download_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct TaskView {
task_id: Uuid,
status: String,
progress: i32,
total_files: i32,
completed_files: i32,
failed_files: i32,
files: Vec<TaskFileView>,
download_all_url: String,
created_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
}
async fn get_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<TaskView>>), AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let task = sqlx::query_as::<_, TaskRow>(
r#"
SELECT
id,
status::text AS status,
total_files,
completed_files,
failed_files,
created_at,
completed_at,
expires_at,
user_id,
session_id
FROM tasks
WHERE id = $1
"#,
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
if task.expires_at <= Utc::now() {
return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在"));
}
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
let files = sqlx::query_as::<_, TaskFileRow>(
r#"
SELECT
id,
original_name,
original_size,
compressed_size,
saved_percent::float8 AS saved_percent,
status::text AS status
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(|f| TaskFileView {
file_id: f.id,
original_name: f.original_name,
original_size: f.original_size,
compressed_size: f.compressed_size,
saved_percent: f.saved_percent,
status: f.status.clone(),
download_url: if f.status == "completed" {
Some(format!("/downloads/{}", f.id))
} else {
None
},
})
.collect::<Vec<_>>();
let processed = task.completed_files + task.failed_files;
let progress = if task.total_files <= 0 {
0
} else {
((processed as f64) * 100.0 / (task.total_files as f64))
.round()
.clamp(0.0, 100.0) as i32
};
Ok((
jar,
Json(Envelope {
success: true,
data: TaskView {
task_id,
status: task.status,
progress,
total_files: task.total_files,
completed_files: task.completed_files,
failed_files: task.failed_files,
files: file_views,
download_all_url: format!("/downloads/tasks/{task_id}"),
created_at: task.created_at,
completed_at: task.completed_at,
expires_at: task.expires_at,
},
}),
))
}
async fn cancel_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, 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 task = sqlx::query_as::<_, TaskRow>(
"SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") {
return Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "任务已结束" }),
}),
));
}
let updated = sqlx::query(
"UPDATE tasks SET status = 'cancelled', completed_at = NOW() WHERE id = $1 AND status IN ('pending', 'processing')",
)
.bind(task_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "取消任务失败").with_source(err))?;
if updated.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::InvalidRequest, "任务状态不可取消"));
}
Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已取消" }),
}),
))
}
async fn delete_task(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, 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 task = sqlx::query_as::<_, TaskRow>(
"SELECT id, status::text AS status, total_files, completed_files, failed_files, created_at, completed_at, expires_at, user_id, session_id FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "任务不存在"))?;
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
if task.status == "processing" {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"任务处理中,请先取消后再删除",
));
}
let paths: Vec<Option<String>> =
sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1")
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?;
for p in paths.into_iter().flatten() {
let _ = tokio::fs::remove_file(p).await;
}
if state.config.storage_type.to_ascii_lowercase() == "local" {
let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
}
let deleted = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(task_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "删除任务失败").with_source(err))?;
if deleted.rows_affected() == 0 {
return Err(AppError::new(ErrorCode::NotFound, "任务不存在"));
}
Ok((
jar,
Json(Envelope {
success: true,
data: serde_json::json!({ "message": "已删除" }),
}),
))
}
fn authorize_task(
principal: &context::Principal,
user_id: Option<Uuid>,
session_id: &str,
) -> Result<(), AppError> {
if let Some(owner) = user_id {
match principal {
context::Principal::User { user_id: me, .. } if *me == owner => Ok(()),
context::Principal::ApiKey { user_id: me, .. } if *me == owner => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")),
}
} else {
match principal {
context::Principal::Anonymous { session_id: sid } if sid == session_id => Ok(()),
_ => Err(AppError::new(ErrorCode::Forbidden, "无权限访问该任务")),
}
}
}
async fn cleanup_file_paths(files: &[BatchFileInput]) {
for f in files {
let _ = tokio::fs::remove_file(&f.storage_path).await;
}
}

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)
}

520
src/api/webhooks.rs Normal file
View File

@@ -0,0 +1,520 @@
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::settings;
use crate::state::AppState;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::routing::post;
use axum::{Json, Router};
use chrono::{TimeZone, Utc};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
pub fn router() -> Router<AppState> {
Router::new().route("/webhooks/stripe", post(stripe_webhook))
}
#[derive(Debug, Deserialize)]
struct StripeEvent {
id: String,
#[serde(rename = "type")]
type_: String,
data: StripeEventData,
}
#[derive(Debug, Deserialize)]
struct StripeEventData {
object: serde_json::Value,
}
async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
let secret = settings::get_stripe_webhook_secret(&state)
.await
.map_err(|err| err.with_source("stripe webhook secret not configured"))?;
let sig = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 Stripe-Signature"))?;
verify_stripe_signature(&body, sig, &secret)?;
let payload_str = std::str::from_utf8(&body)
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "Webhook payload 非 UTF-8"))?;
let event: StripeEvent = serde_json::from_str(payload_str)
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "Webhook JSON 解析失败").with_source(err))?;
let inserted: Option<String> = sqlx::query_scalar(
r#"
INSERT INTO webhook_events (provider, provider_event_id, event_type, payload)
VALUES ('stripe', $1, $2, $3)
ON CONFLICT (provider, provider_event_id) DO NOTHING
RETURNING provider_event_id
"#,
)
.bind(&event.id)
.bind(&event.type_)
.bind(&event.data.object)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook 入库失败").with_source(err))?;
if inserted.is_none() {
return Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "duplicate" }),
}));
}
if let Err(err) = process_stripe_event(&state, &event).await {
let _ = sqlx::query(
"UPDATE webhook_events SET status = 'failed', error_message = $2, processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
)
.bind(&event.id)
.bind(err.to_string())
.execute(&state.db)
.await;
return Err(err);
}
let _ = sqlx::query(
"UPDATE webhook_events SET status = 'processed', processed_at = NOW() WHERE provider = 'stripe' AND provider_event_id = $1",
)
.bind(&event.id)
.execute(&state.db)
.await;
Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "status": "ok" }),
}))
}
fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Result<(), AppError> {
let mut timestamp: Option<i64> = None;
let mut signatures = Vec::<String>::new();
for part in sig_header.split(',') {
let part = part.trim();
if let Some(v) = part.strip_prefix("t=") {
timestamp = v.parse::<i64>().ok();
} else if let Some(v) = part.strip_prefix("v1=") {
signatures.push(v.to_string());
}
}
let Some(ts) = timestamp else {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t"));
};
if signatures.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1"));
}
// 5 minutes tolerance
let now = Utc::now().timestamp();
if (now - ts).abs() > 300 {
return Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 时间戳过期"));
}
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "Webhook secret 错误").with_source(err))?;
mac.update(ts.to_string().as_bytes());
mac.update(b".");
mac.update(payload);
let expected = hex::encode(mac.finalize().into_bytes());
if signatures.iter().any(|sig| secure_eq(sig, &expected)) {
Ok(())
} else {
Err(AppError::new(ErrorCode::InvalidRequest, "Webhook 验签失败"))
}
}
fn secure_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut out = 0u8;
for (x, y) in a.as_bytes().iter().zip(b.as_bytes().iter()) {
out |= x ^ y;
}
out == 0
}
async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<(), AppError> {
match event.type_.as_str() {
"checkout.session.completed" => {
map_checkout_session_completed(state, &event.data.object).await
}
"customer.subscription.created" | "customer.subscription.updated" => {
upsert_subscription(state, &event.data.object).await
}
"customer.subscription.deleted" => cancel_subscription(state, &event.data.object).await,
"invoice.paid" | "invoice.payment_failed" => upsert_invoice(state, &event.data.object).await,
_ => Ok(()),
}
}
async fn map_checkout_session_completed(
state: &AppState,
object: &serde_json::Value,
) -> Result<(), AppError> {
let customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty());
let user_id = object
.get("client_reference_id")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
.or_else(|| {
object
.pointer("/metadata/user_id")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<uuid::Uuid>().ok())
});
let Some(customer_id) = customer_id else {
tracing::warn!("checkout.session.completed missing customer");
return Ok(());
};
let Some(user_id) = user_id else {
tracing::warn!(customer = %customer_id, "checkout.session.completed missing user_id");
return Ok(());
};
let updated = sqlx::query(
r#"
UPDATE users
SET billing_customer_id = $2,
updated_at = NOW()
WHERE id = $1
AND (billing_customer_id IS NULL OR billing_customer_id = '')
"#,
)
.bind(user_id)
.bind(customer_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?;
if updated.rows_affected() == 0 {
let existing: Option<String> = sqlx::query_scalar::<_, Option<String>>(
"SELECT billing_customer_id FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
.flatten();
if let Some(existing) = existing.filter(|v| !v.trim().is_empty()) {
if existing != customer_id {
tracing::warn!(
user_id = %user_id,
existing_customer = %existing,
new_customer = %customer_id,
"user already mapped to different stripe customer"
);
}
} else {
tracing::warn!(user_id = %user_id, "user not found for checkout.session.completed");
}
}
Ok(())
}
async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_subscription_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
let provider_customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.customer 缺失"))?;
let status = object
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("incomplete");
let mapped_status = map_subscription_status(status);
let cps = object
.get("current_period_start")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let cpe = object
.get("current_period_end")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let current_period_start = Utc.timestamp_opt(cps, 0).single().unwrap_or_else(Utc::now);
let current_period_end = Utc.timestamp_opt(cpe, 0).single().unwrap_or_else(Utc::now);
let cancel_at_period_end = object
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let price_id = object
.pointer("/items/data/0/price/id")
.and_then(|v| v.as_str())
.or_else(|| object.pointer("/items/data/0/plan/id").and_then(|v| v.as_str()))
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.price 缺失"))?;
let user_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
.bind(provider_customer_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(user_id) = user_id else {
tracing::warn!(customer = %provider_customer_id, "stripe customer not mapped to user");
return Ok(());
};
let plan_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM plans WHERE stripe_price_id = $1 LIMIT 1")
.bind(price_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
let Some(plan_id) = plan_id else {
tracing::warn!(price = %price_id, "stripe price not mapped to plan");
return Ok(());
};
let updated: Option<uuid::Uuid> = sqlx::query_scalar(
r#"
UPDATE subscriptions
SET user_id = $1,
plan_id = $2,
status = $3::subscription_status,
current_period_start = $4,
current_period_end = $5,
cancel_at_period_end = $6,
provider = 'stripe',
provider_customer_id = $7,
updated_at = NOW()
WHERE provider = 'stripe' AND provider_subscription_id = $8
RETURNING id
"#,
)
.bind(user_id)
.bind(plan_id)
.bind(mapped_status)
.bind(current_period_start)
.bind(current_period_end)
.bind(cancel_at_period_end)
.bind(provider_customer_id)
.bind(provider_subscription_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新订阅失败").with_source(err))?;
if updated.is_none() {
let _ = sqlx::query(
r#"
INSERT INTO subscriptions (
user_id, plan_id, status,
current_period_start, current_period_end,
cancel_at_period_end,
provider, provider_customer_id, provider_subscription_id
) VALUES (
$1, $2, $3::subscription_status,
$4, $5,
$6,
'stripe', $7, $8
)
"#,
)
.bind(user_id)
.bind(plan_id)
.bind(mapped_status)
.bind(current_period_start)
.bind(current_period_end)
.bind(cancel_at_period_end)
.bind(provider_customer_id)
.bind(provider_subscription_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?;
}
Ok(())
}
async fn cancel_subscription(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_subscription_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "subscription.id 缺失"))?;
let _ = sqlx::query(
r#"
UPDATE subscriptions
SET status = 'canceled',
cancel_at_period_end = false,
canceled_at = NOW(),
updated_at = NOW()
WHERE provider = 'stripe' AND provider_subscription_id = $1
"#,
)
.bind(provider_subscription_id)
.execute(&state.db)
.await;
Ok(())
}
async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<(), AppError> {
let provider_invoice_id = object
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.id 缺失"))?;
let provider_customer_id = object
.get("customer")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "invoice.customer 缺失"))?;
let user_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE billing_customer_id = $1 LIMIT 1")
.bind(provider_customer_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
let Some(user_id) = user_id else {
return Ok(());
};
let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open");
let status = map_invoice_status(stripe_status);
let invoice_number = object
.get("number")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.map(|v| v.to_string())
.unwrap_or_else(|| format!("stripe_{provider_invoice_id}"));
let currency = object
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("cny")
.to_uppercase();
let total_amount_cents = object.get("total").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let hosted_invoice_url = object.get("hosted_invoice_url").and_then(|v| v.as_str()).map(|v| v.to_string());
let pdf_url = object.get("invoice_pdf").and_then(|v| v.as_str()).map(|v| v.to_string());
let period_start = object.get("period_start").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let period_end = object.get("period_end").and_then(|v| v.as_i64()).and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let paid_at = object
.pointer("/status_transitions/paid_at")
.and_then(|v| v.as_i64())
.and_then(|ts| Utc.timestamp_opt(ts, 0).single());
let updated = sqlx::query(
r#"
UPDATE invoices
SET status = $1::invoice_status,
currency = $2,
total_amount_cents = $3,
hosted_invoice_url = $4,
pdf_url = $5,
period_start = $6,
period_end = $7,
paid_at = $8
WHERE provider = 'stripe' AND provider_invoice_id = $9
"#,
)
.bind(status)
.bind(&currency)
.bind(total_amount_cents)
.bind(hosted_invoice_url.as_deref())
.bind(pdf_url.as_deref())
.bind(period_start)
.bind(period_end)
.bind(paid_at)
.bind(provider_invoice_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新发票失败").with_source(err))?;
if updated.rows_affected() == 0 {
let invoice_number = truncate(invoice_number, 50);
let _ = sqlx::query(
r#"
INSERT INTO invoices (
user_id, invoice_number, status, currency, total_amount_cents,
period_start, period_end,
provider, provider_invoice_id, hosted_invoice_url, pdf_url,
paid_at
) VALUES (
$1, $2, $3::invoice_status, $4, $5,
$6, $7,
'stripe', $8, $9, $10,
$11
)
"#,
)
.bind(user_id)
.bind(invoice_number)
.bind(status)
.bind(&currency)
.bind(total_amount_cents)
.bind(period_start)
.bind(period_end)
.bind(provider_invoice_id)
.bind(hosted_invoice_url.as_deref())
.bind(pdf_url.as_deref())
.bind(paid_at)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建发票失败").with_source(err))?;
}
Ok(())
}
fn truncate(mut s: String, max: usize) -> String {
if s.len() > max {
s.truncate(max);
}
s
}
fn map_subscription_status(status: &str) -> &'static str {
match status {
"trialing" => "trialing",
"active" => "active",
"past_due" => "past_due",
"canceled" => "canceled",
_ => "incomplete",
}
}
fn map_invoice_status(status: &str) -> &'static str {
match status {
"draft" => "draft",
"paid" => "paid",
"void" => "void",
"uncollectible" => "uncollectible",
_ => "open",
}
}