perf: improve compression reliability and deployment safety

This commit is contained in:
237899745
2026-07-25 10:29:49 +08:00
parent 06220ca921
commit 9d7668bdee
34 changed files with 1391 additions and 1042 deletions

View File

@@ -11,7 +11,7 @@ use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::HeaderMap;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use chrono::{DateTime, Datelike, Duration, FixedOffset, Timelike, TimeZone, Utc};
use chrono::{DateTime, Datelike, Duration, FixedOffset, TimeZone, Timelike, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::net::{IpAddr, SocketAddr};
@@ -108,7 +108,10 @@ async fn resolve_user_id(state: &AppState, identifier: &str) -> Result<Uuid, App
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
if ids.len() > 1 {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户 ID 前缀不唯一"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"用户 ID 前缀不唯一",
));
}
}
@@ -260,7 +263,10 @@ async fn list_users(
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 search = query.search.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
let search = query
.search
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let total: i64 = if let Some(search) = &search {
let keyword = format!("%{}%", search);
@@ -414,7 +420,10 @@ async fn list_tasks(
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 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 status::text = $1")
@@ -723,7 +732,12 @@ async fn grant_credits(
.map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?;
let (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = sub {
(Some(sub.id), sub.current_period_start, sub.current_period_end, Some(sub.plan_id))
(
Some(sub.id),
sub.current_period_start,
sub.current_period_end,
Some(sub.plan_id),
)
} else {
let (start, end) = billing::current_month_period_utc8(Utc::now());
(None, start, end, None)
@@ -853,7 +867,10 @@ async fn create_manual_subscription(
let months = req.months.unwrap_or(1);
if months <= 0 || months > 24 {
return Err(AppError::new(ErrorCode::InvalidRequest, "months 需在 1-24 之间"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"months 需在 1-24 之间",
));
}
let user_id = resolve_user_id(&state, &req.user_id).await?;
@@ -1012,7 +1029,10 @@ fn days_in_month(tz: FixedOffset, year: i32, month: u32) -> u32 {
} else {
(year, month + 1)
};
let first_next = tz.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0).single().unwrap();
let first_next = tz
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
.single()
.unwrap();
let last = first_next - Duration::days(1);
last.day()
}
@@ -1155,29 +1175,30 @@ async fn get_stripe_config(
let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?;
let stored = settings::load_system_config::<StripeConfigStored>(&state, "stripe").await?;
let (secret_key_configured, webhook_secret_configured, secret_key_prefix) = if let Some(cfg) = stored {
(
cfg.secret_key_encrypted.as_ref().is_some(),
cfg.webhook_secret_encrypted.as_ref().is_some(),
cfg.secret_key_prefix,
)
} else {
let env_secret = state
.config
.stripe_secret_key
.as_ref()
.filter(|v| !v.trim().is_empty());
let env_webhook = state
.config
.stripe_webhook_secret
.as_ref()
.filter(|v| !v.trim().is_empty());
(
env_secret.is_some(),
env_webhook.is_some(),
env_secret.map(|value| mask_secret(value)),
)
};
let (secret_key_configured, webhook_secret_configured, secret_key_prefix) =
if let Some(cfg) = stored {
(
cfg.secret_key_encrypted.as_ref().is_some(),
cfg.webhook_secret_encrypted.as_ref().is_some(),
cfg.secret_key_prefix,
)
} else {
let env_secret = state
.config
.stripe_secret_key
.as_ref()
.filter(|v| !v.trim().is_empty());
let env_webhook = state
.config
.stripe_webhook_secret
.as_ref()
.filter(|v| !v.trim().is_empty());
(
env_secret.is_some(),
env_webhook.is_some(),
env_secret.map(|value| mask_secret(value)),
)
};
Ok(Json(Envelope {
success: true,
@@ -1227,7 +1248,8 @@ async fn update_stripe_config(
if webhook_secret.is_empty() {
stored.webhook_secret_encrypted = None;
} else {
stored.webhook_secret_encrypted = Some(settings::encrypt_secret(&state, &webhook_secret)?);
stored.webhook_secret_encrypted =
Some(settings::encrypt_secret(&state, &webhook_secret)?);
}
}
@@ -1343,10 +1365,16 @@ async fn update_mail_config(
if req.provider.eq_ignore_ascii_case("custom") {
let custom = req.custom_smtp.as_ref().ok_or_else(|| {
AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 需要填写 host/port/encryption")
AppError::new(
ErrorCode::InvalidRequest,
"自定义 SMTP 需要填写 host/port/encryption",
)
})?;
if custom.host.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "SMTP host 不能为空"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"SMTP host 不能为空",
));
}
}
@@ -1387,8 +1415,9 @@ async fn update_mail_config(
settings::upsert_system_config(
&state,
"mail",
serde_json::to_value(&stored)
.map_err(|err| AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err))?,
serde_json::to_value(&stored).map_err(|err| {
AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err)
})?,
Some("邮件服务配置"),
Some(admin_id),
)
@@ -1423,15 +1452,11 @@ async fn test_mail(
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let to = if let Some(to) = req.to.as_ref().map(|v| v.trim().to_string()) {
if to.is_empty() {
None
} else {
Some(to)
}
} else {
None
};
let to = req
.to
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let recipient = if let Some(to) = to {
to
@@ -1440,13 +1465,17 @@ async fn test_mail(
.bind(admin_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员邮箱失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "查询管理员邮箱失败").with_source(err)
})?;
email
};
mail::send_test_email(&state, &recipient)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "测试邮件发送失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "测试邮件发送失败").with_source(err)
})?;
Ok(Json(Envelope {
success: true,

View File

@@ -1,16 +1,11 @@
use crate::auth;
use crate::api::envelope::Envelope;
use crate::auth;
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 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;
@@ -106,8 +101,12 @@ async fn register(
.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 (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);
@@ -133,7 +132,9 @@ async fn register(
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
})?;
let body = RegisterResponse {
user: UserView {
@@ -159,7 +160,10 @@ async fn login(
) -> Result<Json<Envelope<LoginResponse>>, AppError> {
let identity = req.email.trim();
if identity.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"邮箱或用户名不能为空",
));
}
let user = if identity.contains('@') {
@@ -210,8 +214,12 @@ async fn login(
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)?;
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,
@@ -241,7 +249,11 @@ async fn send_verification(
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 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)
@@ -257,7 +269,10 @@ async fn send_verification(
.unwrap_or(());
}
if count > 1 {
return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试"));
return Err(AppError::new(
ErrorCode::RateLimited,
"发送过于频繁,请稍后再试",
));
}
let user = sqlx::query_as::<_, UserRow>(
@@ -313,7 +328,9 @@ async fn send_verification(
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
.await
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
})?;
Ok(Json(Envelope {
success: true,
@@ -434,7 +451,8 @@ async fn forgot_password(
state.config.public_base_url, reset_token
);
let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
let _ =
mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
}
Ok(Json(Envelope {
@@ -497,12 +515,14 @@ async fn reset_password(
.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))?;
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

View File

@@ -46,7 +46,9 @@ struct PlansResponse {
plans: Vec<PlanView>,
}
async fn list_plans(State(state): State<AppState>) -> Result<Json<Envelope<PlansResponse>>, AppError> {
async fn list_plans(
State(state): State<AppState>,
) -> Result<Json<Envelope<PlansResponse>>, AppError> {
let plans = sqlx::query_as::<_, PlanView>(
r#"
SELECT
@@ -422,7 +424,8 @@ async fn create_checkout(
});
let mut idem_acquired = false;
if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref()) {
if let (Some(idem), Some(request_hash)) = (idempotency_key.as_deref(), request_hash.as_deref())
{
match idempotency::begin(
&state,
idempotency::Scope::User(user_id),
@@ -433,10 +436,14 @@ async fn create_checkout(
.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 }));
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(
@@ -451,14 +458,17 @@ async fn create_checkout(
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 Ok(Json(Envelope {
success: true,
data: resp,
}));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idempotency::BeginResult::Acquired => {
idem_acquired = true;
}
}
@@ -525,8 +535,10 @@ async fn create_checkout(
cus
};
let success_url =
format!("{}/dashboard/billing?checkout=success", state.config.public_base_url);
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(
@@ -619,7 +631,10 @@ async fn create_portal(
.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"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"未找到 Stripe Customer",
));
};
let return_url = format!("{}/dashboard/billing", state.config.public_base_url);
@@ -631,7 +646,11 @@ async fn create_portal(
}))
}
async fn stripe_create_customer(secret: &str, email: &str, user_id: Uuid) -> Result<String, AppError> {
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",
@@ -723,10 +742,9 @@ async fn stripe_post_form(
.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))?;
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");

View File

@@ -54,7 +54,7 @@ struct CompressRequest {
file_bytes: Vec<u8>,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
output_format: Option<ImageFmt>,
max_width: Option<u32>,
max_height: Option<u32>,
@@ -99,11 +99,17 @@ async fn compress_json(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<CompressResponse>>), AppError> {
) -> Result<
(
axum_extra::extract::cookie::CookieJar,
Json<Envelope<CompressResponse>>,
),
AppError,
> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
let req = parse_single_file_request(&mut multipart).await?;
let mut req = parse_single_file_request(&mut multipart).await?;
let format_in = compress::detect_format(&req.file_bytes)?;
let format_out = req.output_format.unwrap_or(format_in);
@@ -121,7 +127,9 @@ async fn compress_json(
.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)),
context::Principal::ApiKey { api_key_id, .. } => {
Some(idempotency::Scope::ApiKey(*api_key_id))
}
_ => None,
};
@@ -139,7 +147,12 @@ async fn compress_json(
.compression_rate
.map(|v| v.to_string())
.unwrap_or_default();
let target_size_key = req
.target_size_bytes
.map(|v| v.to_string())
.unwrap_or_default();
h.update(rate_key.as_bytes());
h.update(target_size_key.as_bytes());
h.update(mw.as_bytes());
h.update(mh.as_bytes());
h.update(preserve.as_bytes());
@@ -211,32 +224,40 @@ async fn compress_json(
.await?
{
idempotency::BeginResult::Replay { response_body, .. } => {
let resp: CompressResponse = 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 })));
let resp: CompressResponse =
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 => {
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
scope,
idem_key,
request_hash,
10_000,
)
.await?
if let Some((_status, body)) =
idempotency::wait_for_replay(&state, scope, idem_key, request_hash, 10_000)
.await?
{
let resp: CompressResponse = 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 Ok((
jar,
Json(Envelope {
success: true,
data: resp,
}),
));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idempotency::BeginResult::Acquired => {
idem_acquired = true;
}
}
@@ -249,21 +270,22 @@ async fn compress_json(
QuotaContext::Anonymous { .. } => {}
}
let original_size = req.file_bytes.len() as u64;
let input = std::mem::take(&mut req.file_bytes);
let compressed = compress::compress_image_bytes(
&state,
&req.file_bytes,
input,
format_in,
format_out,
effective_level,
req.compression_rate,
req.target_size_bytes, // 新增:目标大小
req.target_size_bytes, // 新增:目标大小
req.max_width,
req.max_height,
req.preserve_metadata,
)
.await?;
let original_size = req.file_bytes.len() as u64;
let compressed_size = compressed.len() as u64;
let saved_bytes = original_size.saturating_sub(compressed_size);
let saved_percent = if original_size == 0 {
@@ -280,7 +302,7 @@ async fn compress_json(
}
}
if state.config.storage_type.to_ascii_lowercase() != "local" {
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
@@ -370,7 +392,13 @@ async fn compress_json(
.await;
}
}
Ok((jar, Json(Envelope { success: true, data: resp })))
Ok((
jar,
Json(Envelope {
success: true,
data: resp,
}),
))
}
Err(err) => {
if let (Some(scope), Some(idem_key), Some(request_hash)) = (
@@ -393,7 +421,13 @@ async fn compress_direct(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<(axum_extra::extract::cookie::CookieJar, axum::response::Response), AppError> {
) -> Result<
(
axum_extra::extract::cookie::CookieJar,
axum::response::Response,
),
AppError,
> {
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
@@ -404,7 +438,7 @@ async fn compress_direct(
));
}
let req = parse_single_file_request(&mut multipart).await?;
let mut req = parse_single_file_request(&mut multipart).await?;
let email_verified = match &principal {
context::Principal::User { email_verified, .. } => *email_verified,
@@ -431,7 +465,9 @@ async fn compress_direct(
.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)),
context::Principal::ApiKey { api_key_id, .. } => {
Some(idempotency::Scope::ApiKey(*api_key_id))
}
context::Principal::Anonymous { .. } => None,
};
@@ -449,7 +485,12 @@ async fn compress_direct(
.compression_rate
.map(|v| v.to_string())
.unwrap_or_default();
let target_size_key = req
.target_size_bytes
.map(|v| v.to_string())
.unwrap_or_default();
h.update(rate_key.as_bytes());
h.update(target_size_key.as_bytes());
h.update(mw.as_bytes());
h.update(mh.as_bytes());
h.update(preserve.as_bytes());
@@ -505,7 +546,8 @@ async fn compress_direct(
serde_json::from_value(response_body).map_err(|err| {
AppError::new(ErrorCode::Internal, "幂等结果解析失败").with_source(err)
})?;
let (bytes, fmt) = load_direct_replay_bytes(&state, &principal, data.file_id).await?;
let (bytes, fmt) =
load_direct_replay_bytes(&state, &principal, data.file_id).await?;
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
@@ -537,14 +579,9 @@ async fn compress_direct(
return Ok((jar, response));
}
idempotency::BeginResult::InProgress => {
if let Some((_status, body)) = idempotency::wait_for_replay(
&state,
scope,
idem_key,
request_hash,
10_000,
)
.await?
if let Some((_status, body)) =
idempotency::wait_for_replay(&state, scope, idem_key, request_hash, 10_000)
.await?
{
let data: DirectIdempotencyData =
serde_json::from_value(body).map_err(|err| {
@@ -587,7 +624,7 @@ async fn compress_direct(
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idempotency::BeginResult::Acquired => {
idem_acquired = true;
}
}
@@ -600,21 +637,22 @@ async fn compress_direct(
QuotaContext::Anonymous { .. } => {}
}
let original_size = req.file_bytes.len() as u64;
let input = std::mem::take(&mut req.file_bytes);
let compressed = compress::compress_image_bytes(
&state,
&req.file_bytes,
input,
format_in,
format_out,
effective_level,
req.compression_rate,
req.target_size_bytes, // 新增:目标大小
req.target_size_bytes, // 新增:目标大小
req.max_width,
req.max_height,
req.preserve_metadata,
)
.await?;
let original_size = req.file_bytes.len() as u64;
let compressed_size = compressed.len() as u64;
let saved_bytes = original_size.saturating_sub(compressed_size);
let saved_percent = if original_size == 0 {
@@ -625,7 +663,7 @@ async fn compress_direct(
let skip_charge = req.compression_rate == Some(100);
let charge_units = !skip_charge && compressed_size < original_size;
if state.config.storage_type.to_ascii_lowercase() != "local" {
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
@@ -689,7 +727,10 @@ async fn compress_direct(
"ImageForge-Compressed-Size",
compressed_size.to_string().parse().unwrap(),
);
resp_headers.insert("ImageForge-Saved-Bytes", saved_bytes.to_string().parse().unwrap());
resp_headers.insert(
"ImageForge-Saved-Bytes",
saved_bytes.to_string().parse().unwrap(),
);
resp_headers.insert(
"ImageForge-Saved-Percent",
format!("{saved_percent:.2}").parse().unwrap(),
@@ -752,12 +793,12 @@ async fn compress_direct(
}
async fn write_file(path: &str, bytes: &[u8]) -> Result<(), AppError> {
let mut file = tokio::fs::File::create(path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err))?;
file.write_all(bytes)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err))?;
let mut file = tokio::fs::File::create(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err)
})?;
file.write_all(bytes).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err)
})?;
Ok(())
}
@@ -811,7 +852,9 @@ async fn load_direct_replay_bytes(
.fetch_optional(&state.db)
.await
}
context::Principal::Anonymous { .. } => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
context::Principal::Anonymous { .. } => {
return Err(AppError::new(ErrorCode::Unauthorized, "未登录"))
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "文件不存在"))?;
@@ -826,9 +869,9 @@ async fn load_direct_replay_bytes(
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let bytes = tokio::fs::read(&path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?;
let bytes = tokio::fs::read(&path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err)
})?;
let fmt = compress::parse_output_format(&row.output_format)?;
Ok((bytes, fmt))
@@ -840,36 +883,27 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
let mut level = CompressionLevel::Medium;
let mut output_format: Option<ImageFmt> = None;
let mut compression_rate: Option<u8> = None;
let mut target_size_bytes: Option<u64> = None; // 新增
let mut target_size_bytes: Option<u64> = None; // 新增
let mut max_width: Option<u32> = None;
let mut max_height: Option<u32> = None;
let mut preserve_metadata = false;
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err))?
{
while let Some(field) = multipart.next_field().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取上传内容失败").with_source(err)
})? {
let name = field.name().unwrap_or("").to_string();
if name == "file" {
file_name = Some(
field
.file_name()
.unwrap_or("upload")
.to_string(),
);
let bytes = field
.bytes()
.await
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err))?;
file_name = Some(field.file_name().unwrap_or("upload").to_string());
let bytes = field.bytes().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取文件失败").with_source(err)
})?;
file_bytes = Some(bytes.to_vec());
continue;
}
let text = field
.text()
.await
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err))?;
let text = field.text().await.map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err)
})?;
match name.as_str() {
"level" => {
@@ -890,19 +924,17 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
"max_width" => {
let v = text.trim();
if !v.is_empty() {
max_width = Some(
v.parse::<u32>()
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"))?,
);
max_width = Some(v.parse::<u32>().map_err(|_| {
AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误")
})?);
}
}
"max_height" => {
let v = text.trim();
if !v.is_empty() {
max_height = Some(
v.parse::<u32>()
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误"))?,
);
max_height = Some(v.parse::<u32>().map_err(|_| {
AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误")
})?);
}
}
"preserve_metadata" => {
@@ -914,10 +946,12 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
"target_size_bytes" | "target_size" => {
let v = text.trim();
if !v.is_empty() {
target_size_bytes = Some(
v.parse::<u64>()
.map_err(|_| AppError::new(ErrorCode::InvalidRequest, "target_size_bytes 格式错误,需为正整数(字节)"))?,
);
target_size_bytes = Some(v.parse::<u64>().map_err(|_| {
AppError::new(
ErrorCode::InvalidRequest,
"target_size_bytes 格式错误,需为正整数(字节)",
)
})?);
// 最小目标大小限制1KB
if let Some(size) = target_size_bytes {
if size < 1024 {
@@ -933,7 +967,8 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
}
}
let file_bytes = file_bytes.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 file"))?;
let file_bytes =
file_bytes.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少 file"))?;
let file_name = file_name.unwrap_or_else(|| "upload".to_string());
Ok(CompressRequest {
@@ -941,7 +976,7 @@ async fn parse_single_file_request(multipart: &mut Multipart) -> Result<Compress
file_bytes,
level,
compression_rate,
target_size_bytes, // 新增
target_size_bytes, // 新增
output_format,
max_width,
max_height,
@@ -954,7 +989,10 @@ fn enforce_file_limits_anonymous(state: &AppState, bytes: &[u8]) -> Result<(), A
if bytes.len() as u64 > max {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb),
format!(
"匿名试用单文件最大 {} MB",
state.config.anon_max_file_size_mb
),
));
}
Ok(())
@@ -1020,6 +1058,7 @@ async fn ensure_quota_available(
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn record_task_and_metering(
state: &AppState,
principal: &context::Principal,
@@ -1042,9 +1081,15 @@ async fn record_task_and_metering(
charge_units: bool,
) -> Result<(), AppError> {
let (user_id, session_id, api_key_id, source) = match principal {
context::Principal::Anonymous { session_id } => (None, Some(session_id.clone()), None, "web"),
context::Principal::Anonymous { session_id } => {
(None, Some(session_id.clone()), None, "web")
}
context::Principal::User { user_id, .. } => (Some(*user_id), None, None, "web"),
context::Principal::ApiKey { user_id, api_key_id, .. } => (Some(*user_id), None, Some(*api_key_id), "api"),
context::Principal::ApiKey {
user_id,
api_key_id,
..
} => (Some(*user_id), None, Some(*api_key_id), "api"),
};
let mut tx = state
@@ -1161,6 +1206,7 @@ async fn record_task_and_metering(
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn charge_one_unit(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
billing: &BillingContext,

View File

@@ -18,8 +18,14 @@ 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 },
Anonymous {
session_id: String,
},
User {
user_id: Uuid,
role: String,
email_verified: bool,
},
ApiKey {
user_id: Uuid,
api_key_id: Uuid,
@@ -29,6 +35,14 @@ pub enum Principal {
}
pub fn client_ip(headers: &HeaderMap, connect_ip: IpAddr) -> IpAddr {
resolve_client_ip(headers, connect_ip, crate::config::trust_proxy_headers())
}
fn resolve_client_ip(headers: &HeaderMap, connect_ip: IpAddr, trust_proxy: bool) -> IpAddr {
if !trust_proxy {
return connect_ip;
}
if let Some(ip) = parse_forwarded_for(headers) {
return ip;
}
@@ -69,7 +83,13 @@ pub async fn authenticate(
return Err(AppError::new(ErrorCode::Unauthorized, "未登录"));
}
let (jar, session_id) = ensure_session_cookie(jar);
let cookie_secure = state
.config
.public_base_url
.trim()
.to_ascii_lowercase()
.starts_with("https://");
let (jar, session_id) = ensure_session_cookie(jar, cookie_secure);
Ok((jar, Principal::Anonymous { session_id }))
}
@@ -178,11 +198,12 @@ async fn try_api_key(
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;
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,
@@ -192,7 +213,7 @@ async fn try_api_key(
}))
}
pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) {
pub fn ensure_session_cookie(jar: CookieJar, secure: bool) -> (CookieJar, String) {
if let Some(cookie) = jar.get("if_session") {
let session_id = cookie.value().trim().to_string();
if !session_id.is_empty() {
@@ -204,6 +225,7 @@ pub fn ensure_session_cookie(jar: CookieJar) -> (CookieJar, String) {
let cookie = Cookie::build(("if_session", session_id.clone()))
.path("/")
.http_only(true)
.secure(secure)
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(7))
.build();
@@ -220,10 +242,37 @@ fn generate_session_id() -> String {
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))?;
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))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_proxy_headers_cannot_override_peer_ip() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-for", "203.0.113.9".parse().unwrap());
let peer = "192.0.2.10".parse().unwrap();
assert_eq!(resolve_client_ip(&headers, peer, false), peer);
}
#[test]
fn trusted_proxy_headers_use_forwarded_client_ip() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-for",
"203.0.113.9, 192.0.2.20".parse().unwrap(),
);
let peer = "192.0.2.10".parse().unwrap();
assert_eq!(
resolve_client_ip(&headers, peer, true),
"203.0.113.9".parse::<IpAddr>().unwrap()
);
}
}

View File

@@ -2,13 +2,14 @@ use crate::api::context;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use axum::body::Body;
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 percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use sqlx::FromRow;
use std::collections::HashMap;
use std::net::SocketAddr;
@@ -78,9 +79,11 @@ async fn download_file(
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let bytes = tokio::fs::read(path)
.await
.map_err(|err| AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err))?;
let file = tokio::fs::File::open(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err)
})?;
let content_length = file.metadata().await.ok().map(|metadata| metadata.len());
let body = Body::from_stream(ReaderStream::new(file));
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
@@ -89,12 +92,16 @@ async fn download_file(
);
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", sanitize_filename(&row.original_name))
.parse()
.unwrap(),
content_disposition(&row.original_name)?,
);
if let Some(content_length) = content_length {
resp_headers.insert(
header::CONTENT_LENGTH,
content_length.to_string().parse().unwrap(),
);
}
Ok((jar, (resp_headers, bytes).into_response()))
Ok((jar, (resp_headers, body).into_response()))
}
fn authorize_download(principal: &context::Principal, row: &DownloadRow) -> Result<(), AppError> {
@@ -129,12 +136,40 @@ fn sanitize_filename(name: &str) -> String {
out = "download".to_string();
}
out = out.replace(['\r', '\n', '"', '\\'], "_");
if out.len() > 120 {
out.truncate(120);
}
truncate_utf8(&mut out, 120);
out
}
fn truncate_utf8(value: &mut String, max_bytes: usize) {
if value.len() <= max_bytes {
return;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
}
fn content_disposition(name: &str) -> Result<axum::http::HeaderValue, AppError> {
let sanitized = sanitize_filename(name);
let ascii_fallback: String = sanitized
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect();
let encoded = utf8_percent_encode(&sanitized, NON_ALPHANUMERIC);
let value = format!("attachment; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}");
axum::http::HeaderValue::from_str(&value)
.map_err(|err| AppError::new(ErrorCode::Internal, "生成下载文件名失败").with_source(err))
}
#[derive(Debug, FromRow)]
struct TaskZipRow {
user_id: Option<Uuid>,
@@ -146,7 +181,6 @@ struct TaskZipRow {
#[derive(Debug, FromRow)]
struct TaskZipFileRow {
id: Uuid,
storage_path: Option<String>,
original_name: String,
output_format: String,
@@ -201,7 +235,7 @@ async fn download_task_zip(
}
}
if state.config.storage_type.to_ascii_lowercase() != "local" {
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
@@ -209,9 +243,9 @@ async fn download_task_zip(
}
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))?;
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) {
@@ -220,7 +254,7 @@ async fn download_task_zip(
let rows = sqlx::query_as::<_, TaskZipFileRow>(
r#"
SELECT id, storage_path, original_name, output_format
SELECT storage_path, original_name, output_format
FROM task_files
WHERE task_id = $1 AND status = 'completed'
ORDER BY created_at ASC
@@ -238,7 +272,9 @@ async fn download_task_zip(
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 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));
}
@@ -248,10 +284,12 @@ async fn download_task_zip(
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))?;
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
}
@@ -261,9 +299,9 @@ async fn stream_zip(
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 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);
@@ -298,7 +336,11 @@ fn build_zip_entry_name(
_ => "bin",
};
let base = if base.is_empty() { "file".to_string() } else { base };
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 {
@@ -314,19 +356,21 @@ fn build_zip_entry_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);
}
truncate_utf8(&mut out, 120);
out
}
fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, String)]) -> Result<(), String> {
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);
let options =
zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
for (name, path) in entries {
zip.start_file(name, options)
@@ -341,3 +385,30 @@ fn generate_zip_file(zip_path: &PathBuf, task_id: &str, entries: &[(String, Stri
tracing::info!(task_id = %task_id, path = %zip_path.to_string_lossy(), "ZIP generated");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_disposition_supports_unicode_names() {
let value = content_disposition("测试 图片.jpg").unwrap();
let value = value.to_str().unwrap();
assert!(value.contains("filename=\"_____.jpg\""));
assert!(value.contains("filename*=UTF-8''"));
assert!(value.contains("%E6%B5%8B%E8%AF%95"));
}
#[test]
fn sanitize_filename_blocks_header_injection() {
assert_eq!(sanitize_filename("a\r\n\"b\\c.png"), "a___b_c.png");
}
#[test]
fn sanitize_filename_truncates_at_utf8_boundary() {
let name = format!("{}中.png", "a".repeat(119));
let sanitized = sanitize_filename(&name);
assert_eq!(sanitized, "a".repeat(119));
assert!(sanitized.is_char_boundary(sanitized.len()));
}
}

View File

@@ -5,4 +5,3 @@ pub struct Envelope<T> {
pub success: bool,
pub data: T,
}

View File

@@ -2,6 +2,9 @@ use crate::state::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
use std::time::Duration;
const DEPENDENCY_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Serialize)]
struct HealthResponse {
@@ -11,16 +14,24 @@ struct HealthResponse {
}
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
let database_ok = sqlx::query("SELECT 1")
.execute(&state.db)
let database_check = async {
tokio::time::timeout(
DEPENDENCY_TIMEOUT,
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)
.is_ok_and(|result| result.is_ok())
};
let redis_check = async {
let mut redis_conn = state.redis.clone();
tokio::time::timeout(
DEPENDENCY_TIMEOUT,
redis::cmd("PING").query_async::<_, String>(&mut redis_conn),
)
.await
.is_ok();
.is_ok_and(|result| result.is_ok())
};
let (database_ok, redis_ok) = tokio::join!(database_check, redis_check);
let status = if database_ok && redis_ok {
StatusCode::OK
@@ -29,11 +40,18 @@ pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
};
let body = HealthResponse {
status: if status == StatusCode::OK { "healthy" } else { "unhealthy" },
database: if database_ok { "connected" } else { "unavailable" },
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))
}

View File

@@ -1,15 +1,15 @@
mod auth;
mod context;
mod envelope;
mod compress;
mod downloads;
mod billing;
mod webhooks;
mod user;
mod tasks;
mod admin;
mod auth;
mod billing;
mod compress;
mod context;
mod downloads;
mod envelope;
mod health;
mod response;
mod tasks;
mod user;
mod webhooks;
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
@@ -23,14 +23,11 @@ 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, "管理员账号初始化失败");
}
crate::services::bootstrap::ensure_schema(&state).await?;
crate::services::bootstrap::ensure_admin_user(&state).await?;
let static_service = ServeDir::new("static").not_found_service(ServeFile::new("static/index.html"));
let static_service =
ServeDir::new("static").not_found_service(ServeFile::new("static/index.html"));
let v1 = v1_router().layer(DefaultBodyLimit::max(100 * 1024 * 1024));
@@ -48,9 +45,12 @@ pub async fn run(state: AppState) -> Result<(), AppError> {
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))
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> {

View File

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

View File

@@ -62,11 +62,17 @@ async fn create_batch_task(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<BatchCreateResponse>>), AppError> {
) -> 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" {
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
@@ -81,7 +87,9 @@ async fn create_batch_task(
.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)),
context::Principal::ApiKey { api_key_id, .. } => {
Some(idempotency::Scope::ApiKey(*api_key_id))
}
_ => None,
};
@@ -110,31 +118,38 @@ async fn create_batch_task(
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 })));
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?
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 Ok((
jar,
Json(Envelope {
success: true,
data: resp,
}),
));
}
return Err(AppError::new(
ErrorCode::InvalidRequest,
"请求正在处理中,请稍后重试",
));
}
idempotency::BeginResult::Acquired { .. } => {
idempotency::BeginResult::Acquired => {
idem_acquired = true;
}
}
@@ -212,16 +227,18 @@ async fn create_batch_task(
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)),
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))?;
let mut tx =
state.db.begin().await.map_err(|err| {
AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err)
})?;
sqlx::query(
r#"
@@ -285,7 +302,9 @@ async fn create_batch_task(
.bind(&file.storage_path)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err)
})?;
}
tx.commit()
@@ -293,11 +312,12 @@ async fn create_batch_task(
.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;
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);
}
@@ -325,7 +345,13 @@ async fn create_batch_task(
.await;
}
}
Ok((jar, Json(Envelope { success: true, data: resp })))
Ok((
jar,
Json(Envelope {
success: true,
data: resp,
}),
))
}
Err(err) => {
if let (Some(scope), Some(idem_key)) = (idempotency_scope, idempotency_key.as_deref()) {
@@ -379,9 +405,9 @@ async fn parse_batch_request(
};
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))?;
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| {
@@ -434,16 +460,15 @@ async fn parse_batch_request(
Ok(v) => v,
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err),
);
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),
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err)
);
}
@@ -464,7 +489,7 @@ async fn parse_batch_request(
Err(err) => {
cleanup_file_paths(&files).await;
return Err(
AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err),
AppError::new(ErrorCode::InvalidRequest, "读取字段失败").with_source(err)
);
}
};
@@ -509,7 +534,10 @@ async fn parse_batch_request(
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_width 格式错误"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"max_width 格式错误",
));
}
});
}
@@ -521,7 +549,10 @@ async fn parse_batch_request(
Ok(n) => n,
Err(_) => {
cleanup_file_paths(&files).await;
return Err(AppError::new(ErrorCode::InvalidRequest, "max_height 格式错误"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"max_height 格式错误",
));
}
});
}
@@ -571,7 +602,10 @@ async fn parse_batch_request(
Ok((files, opts, request_hash))
}
fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) -> Result<(), AppError> {
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(
@@ -585,7 +619,10 @@ fn enforce_batch_limits_anonymous(state: &AppState, files: &[BatchFileInput]) ->
if f.original_size > max_bytes {
return Err(AppError::new(
ErrorCode::FileTooLarge,
format!("匿名试用单文件最大 {} MB", state.config.anon_max_file_size_mb),
format!(
"匿名试用单文件最大 {} MB",
state.config.anon_max_file_size_mb
),
));
}
}
@@ -679,7 +716,10 @@ async fn anonymous_remaining_units(
.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)))
Ok(std::cmp::min(
limit - v1.unwrap_or(0),
limit - v2.unwrap_or(0),
))
}
fn utc8_date() -> String {
@@ -689,7 +729,6 @@ fn utc8_date() -> String {
#[derive(Debug, FromRow)]
struct TaskRow {
id: Uuid,
status: String,
total_files: i32,
completed_files: i32,
@@ -743,14 +782,19 @@ async fn get_task(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<TaskView>>), AppError> {
) -> 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,
@@ -774,7 +818,11 @@ async fn get_task(
return Err(AppError::new(ErrorCode::NotFound, "任务已过期或不存在"));
}
authorize_task(&principal, task.user_id, task.session_id.as_deref().unwrap_or(""))?;
authorize_task(
&principal,
task.user_id,
task.session_id.as_deref().unwrap_or(""),
)?;
let files = sqlx::query_as::<_, TaskFileRow>(
r#"
@@ -848,12 +896,18 @@ async fn cancel_task(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<serde_json::Value>>), AppError> {
) -> 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",
"SELECT 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)
@@ -861,7 +915,11 @@ async fn cancel_task(
.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(""))?;
authorize_task(
&principal,
task.user_id,
task.session_id.as_deref().unwrap_or(""),
)?;
if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") {
return Ok((
@@ -900,12 +958,18 @@ async fn delete_task(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(task_id): Path<Uuid>,
) -> Result<(axum_extra::extract::cookie::CookieJar, Json<Envelope<serde_json::Value>>), AppError> {
) -> 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",
"SELECT 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)
@@ -913,7 +977,11 @@ async fn delete_task(
.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(""))?;
authorize_task(
&principal,
task.user_id,
task.session_id.as_deref().unwrap_or(""),
)?;
if task.status == "processing" {
return Err(AppError::new(
@@ -933,7 +1001,7 @@ async fn delete_task(
let _ = tokio::fs::remove_file(p).await;
}
if state.config.storage_type.to_ascii_lowercase() == "local" {
if state.config.storage_type.eq_ignore_ascii_case("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);

View File

@@ -205,7 +205,11 @@ async fn update_profile(
.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 email_verified_at = if email_changed {
None
} else {
user.email_verified_at
};
let updated = sqlx::query_as::<_, UserRow>(
r#"
@@ -243,7 +247,9 @@ async fn update_profile(
.bind(expires_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
})?;
verification_link = Some(format!(
"{}/verify-email?token={}",
@@ -258,7 +264,9 @@ async fn update_profile(
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))?;
.map_err(|err| {
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
})?;
}
let message = if email_changed {
@@ -396,17 +404,18 @@ async fn list_history(
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 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))?
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)
@@ -530,7 +539,10 @@ async fn list_history(
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 {
download_url: if file.status == "completed"
&& file.storage_path.is_some()
&& task.expires_at > now
{
Some(format!("/downloads/{}", file.id))
} else {
None
@@ -667,7 +679,10 @@ async fn create_api_key(
let billing = billing::get_user_billing(&state, user_id).await?;
if !billing.plan.feature_api_enabled {
return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key"));
return Err(AppError::new(
ErrorCode::Forbidden,
"当前套餐未开通 API Key",
));
}
let permissions = normalize_permissions(req.permissions)?;
@@ -718,12 +733,15 @@ async fn disable_api_key(
_ => 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))?;
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 不存在"));
@@ -813,7 +831,13 @@ fn generate_api_key() -> (String, String) {
}
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"];
let allowed = [
"compress",
"batch_compress",
"read_stats",
"billing_read",
"webhook_manage",
];
let mut perms = Vec::<String>::new();
if let Some(values) = input {

View File

@@ -48,8 +48,9 @@ async fn stripe_webhook(
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 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#"
@@ -112,21 +113,31 @@ fn verify_stripe_signature(payload: &[u8], sig_header: &str, secret: &str) -> Re
}
let Some(ts) = timestamp else {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 t"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"Stripe-Signature 缺少 t",
));
};
if signatures.is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Stripe-Signature 缺少 v1"));
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 时间戳过期"));
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))?;
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);
@@ -159,7 +170,9 @@ async fn process_stripe_event(state: &AppState, event: &StripeEvent) -> Result<(
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,
"invoice.paid" | "invoice.payment_failed" => {
upsert_invoice(state, &event.data.object).await
}
_ => Ok(()),
}
}
@@ -206,7 +219,9 @@ async fn map_checkout_session_completed(
.bind(customer_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 Stripe Customer 映射失败").with_source(err))?;
.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>>(
@@ -270,7 +285,11 @@ async fn upsert_subscription(state: &AppState, object: &serde_json::Value) -> Re
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()))
.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> =
@@ -401,7 +420,10 @@ async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<
return Ok(());
};
let stripe_status = object.get("status").and_then(|v| v.as_str()).unwrap_or("open");
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
@@ -418,11 +440,23 @@ async fn upsert_invoice(state: &AppState, object: &serde_json::Value) -> Result<
.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 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 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")