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

@@ -7,8 +7,6 @@ use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct Plan {
pub id: Uuid,
pub code: String,
pub included_units_per_period: i32,
pub max_file_size_mb: i32,
pub max_files_per_batch: i32,
@@ -36,8 +34,6 @@ struct SubscriptionRow {
#[derive(Debug, FromRow)]
struct PlanRow {
id: Uuid,
code: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
@@ -68,19 +64,26 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<Billing
"订阅欠费,请先完成支付",
));
}
(Some(sub.id), sub.current_period_start, sub.current_period_end, sub.plan_id)
(
Some(sub.id),
sub.current_period_start,
sub.current_period_end,
sub.plan_id,
)
} else {
let plan_id: Uuid = sqlx::query_scalar("SELECT id FROM plans WHERE code = 'free' LIMIT 1")
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "未找到 Free 套餐").with_source(err)
})?;
let (start, end) = current_month_period_utc8(Utc::now());
(None, start, end, plan_id)
};
let plan_row = sqlx::query_as::<_, PlanRow>(
r#"
SELECT id, code, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features
SELECT included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, features
FROM plans
WHERE id = $1
"#,
@@ -100,8 +103,6 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<Billing
user_id,
subscription_id,
plan: Plan {
id: plan_row.id,
code: plan_row.code,
included_units_per_period: plan_row.included_units_per_period,
max_file_size_mb: plan_row.max_file_size_mb,
max_files_per_batch: plan_row.max_files_per_batch,
@@ -119,7 +120,10 @@ pub fn current_month_period_utc8(now_utc: DateTime<Utc>) -> (DateTime<Utc>, Date
let year = now.year();
let month = now.month();
let start = tz.with_ymd_and_hms(year, month, 1, 0, 0, 0).single().unwrap();
let start = tz
.with_ymd_and_hms(year, month, 1, 0, 0, 0)
.single()
.unwrap();
let (next_year, next_month) = if month == 12 {
(year + 1, 1)

View File

@@ -7,6 +7,8 @@ use sqlx::FromRow;
use tracing::{info, warn};
use uuid::Uuid;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
#[derive(Debug, FromRow)]
struct AdminRow {
id: Uuid,
@@ -28,13 +30,8 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
return Ok(());
}
let admin_username = env_string("ADMIN_USERNAME").unwrap_or_else(|| {
admin_email
.split('@')
.next()
.unwrap_or("admin")
.to_string()
});
let admin_username = env_string("ADMIN_USERNAME")
.unwrap_or_else(|| admin_email.split('@').next().unwrap_or("admin").to_string());
let admin_username = admin_username.trim().to_string();
validate_email(&admin_email)?;
@@ -85,7 +82,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
.bind(row.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)
})?;
if name_taken {
warn!(
@@ -105,7 +104,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
.bind(row.id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err)
})?;
}
}
@@ -138,32 +139,20 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
}
pub async fn ensure_schema(state: &AppState) -> Result<(), AppError> {
sqlx::query(
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS compression_rate SMALLINT",
)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?;
sqlx::query(
"ALTER TABLE usage_periods ADD COLUMN IF NOT EXISTS bonus_units INTEGER NOT NULL DEFAULT 0",
)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化数据库结构失败").with_source(err))?;
let _ = sqlx::query(
"UPDATE usage_periods SET bonus_units = bonus_units + ABS(used_units), used_units = 0 WHERE used_units < 0",
)
.execute(&state.db)
.await;
MIGRATOR
.run(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "执行数据库迁移失败").with_source(err))?;
Ok(())
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱格式不正确"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"管理员邮箱格式不正确",
));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员邮箱过长"));
@@ -173,7 +162,10 @@ fn validate_email(email: &str) -> Result<(), AppError> {
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名不能为空"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"管理员用户名不能为空",
));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员用户名过长"));
@@ -183,7 +175,10 @@ fn validate_username(username: &str) -> Result<(), AppError> {
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码至少 8 位"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"管理员密码至少 8 位",
));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "管理员密码过长"));
@@ -200,5 +195,7 @@ fn hash_password(password: &str) -> Result<String, AppError> {
}
fn env_string(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.trim().is_empty())
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}

View File

@@ -1,15 +1,15 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC};
use image::codecs::bmp::BmpEncoder;
use image::codecs::gif::{GifDecoder, GifEncoder};
use image::codecs::ico::IcoEncoder;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::codecs::tiff::TiffEncoder;
use image::{DynamicImage, ExtendedColorType, ImageEncoder};
use image::{AnimationDecoder, GenericImageView};
use image::{DynamicImage, ExtendedColorType, ImageEncoder};
use img_parts::{Bytes as ImgBytes, DynImage, ImageEXIF, ImageICC};
use oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
@@ -105,15 +105,12 @@ pub fn parse_level(value: &str) -> Result<CompressionLevel, AppError> {
}
pub fn parse_compression_rate(value: &str) -> Result<u8, AppError> {
let rate: u8 = value
.trim()
.parse()
.map_err(|_| {
AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需为 1-100 的整数(压缩后体积占比)",
)
})?;
let rate: u8 = value.trim().parse().map_err(|_| {
AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需为 1-100 的整数(压缩后体积占比)",
)
})?;
if !(1..=100).contains(&rate) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
@@ -199,8 +196,7 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
if has_brand(
&brands,
&[
*b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1",
*b"msf1",
*b"heic", *b"heix", *b"hevc", *b"hevx", *b"heis", *b"heim", *b"mif1", *b"msf1",
],
) {
return Err(AppError::new(
@@ -233,27 +229,69 @@ pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
))
}
#[allow(clippy::too_many_arguments)]
pub async fn compress_image_bytes(
state: &AppState,
input: &[u8],
input: Vec<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
target_size_bytes: Option<u64>, // 新增:直接指定目标大小(字节)
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
let max_image_pixels = state.config.max_image_pixels;
let permit = state
.image_processing_semaphore
.clone()
.acquire_owned()
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "图片处理并发控制器已关闭").with_source(err)
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
compress_image_bytes_sync(
input,
format_in,
format_out,
level,
compression_rate,
target_size_bytes,
max_width,
max_height,
preserve_metadata,
max_image_pixels,
)
})
.await
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "图片处理任务异常退出").with_source(err)
})?
}
#[allow(clippy::too_many_arguments)]
fn compress_image_bytes_sync(
input: Vec<u8>,
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
target_size_bytes: Option<u64>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
max_image_pixels: u64,
) -> Result<Vec<u8>, AppError> {
let original_size = input.len() as u64;
if format_in == ImageFmt::Gif {
if is_animated_gif(input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
if format_in == ImageFmt::Gif && is_animated_gif(&input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
let retention_rate = effective_rate(compression_rate, level);
@@ -269,14 +307,14 @@ pub async fn compress_image_bytes(
&& max_height.is_none()
{
if preserve_metadata {
return Ok(input.to_vec());
return Ok(input);
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone());
return Ok(stripped);
}
let (icc_profile, exif) = if preserve_metadata {
extract_metadata(input)
extract_metadata(&input)
} else {
(None, None)
};
@@ -294,13 +332,23 @@ pub async fn compress_image_bytes(
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
oxipng::optimize_from_memory(input, &opts)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err))?
oxipng::optimize_from_memory(&input, &opts).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "PNG 压缩失败").with_source(err)
})?
} else {
let image = image::load_from_memory(input)
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err))?;
#[cfg(not(target_os = "linux"))]
if format_in == ImageFmt::Avif {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"当前平台构建不支持 AVIF 解码,请转换为 PNG/JPEG/WebP 后重试",
));
}
enforce_pixel_limit(state, &image)?;
let image = image::load_from_memory(&input).map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "图片解码失败").with_source(err)
})?;
enforce_pixel_limit(max_image_pixels, &image)?;
let (image, did_resize) = resize_if_needed(image, max_width, max_height);
resized = did_resize;
@@ -332,23 +380,23 @@ pub async fn compress_image_bytes(
if !resized && output.len() >= input.len() {
if preserve_metadata {
return Ok(input.to_vec());
return Ok(input);
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
let stripped = strip_metadata(&input).unwrap_or_else(|_| input.clone());
return Ok(if stripped.len() <= input.len() {
stripped
} else {
input.to_vec()
input
});
}
Ok(output)
}
fn enforce_pixel_limit(state: &AppState, image: &DynamicImage) -> Result<(), AppError> {
fn enforce_pixel_limit(max_image_pixels: u64, image: &DynamicImage) -> Result<(), AppError> {
let (w, h) = image.dimensions();
let pixels = (w as u64).saturating_mul(h as u64);
if pixels > state.config.max_image_pixels {
if pixels > max_image_pixels {
return Err(AppError::new(
ErrorCode::TooManyPixels,
format!("图片像素过大({}x{}", w, h),
@@ -394,11 +442,7 @@ fn fit_within(w: u32, h: u32, max_width: Option<u32>, max_height: Option<u32>) -
(nw, nh)
}
fn encode_png(
image: DynamicImage,
rate: u8,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
fn encode_png(image: DynamicImage, rate: u8, preserve_metadata: bool) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
@@ -406,7 +450,9 @@ fn encode_png(
let encoder = PngEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "PNG 编码失败").with_source(err)
})?;
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
@@ -433,7 +479,9 @@ fn encode_jpeg_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, A
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
encoder
.encode(raw, w, h, ExtendedColorType::Rgb8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -451,12 +499,6 @@ fn encode_webp(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
Ok(bytes.to_vec())
}
fn encode_webp_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
encode_webp_raw(rgba.as_raw(), w, h, quality)
}
fn encode_webp_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let encoder = webp::Encoder::from_rgba(raw, w, h);
Ok(encoder.encode(quality as f32).to_vec())
@@ -473,26 +515,20 @@ fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let img = ravif::Img::new(pixels, w as usize, h as usize);
let encoder = ravif::Encoder::new().with_quality(quality);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
let encoded = encoder.encode_rgba(img).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err)
})?;
Ok(encoded.avif_file)
}
fn encode_avif_with_quality(image: DynamicImage, quality: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
encode_avif_raw(rgba.as_raw(), w, h, quality)
}
fn encode_avif_raw(raw: &[u8], w: u32, h: u32, quality: u8) -> Result<Vec<u8>, AppError> {
let pixels = raw.as_rgba();
let img = ravif::Img::new(pixels, w as usize, h as usize);
let encoder = ravif::Encoder::new().with_quality(quality as f32);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
let encoded = encoder.encode_rgba(img).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err)
})?;
Ok(encoded.avif_file)
}
@@ -579,7 +615,8 @@ where
Some((_bytes, best_w, best_h, best_size)) => {
let new_pixels = (new_w as u64).saturating_mul(new_h as u64);
let best_pixels = (*best_w as u64).saturating_mul(*best_h as u64);
new_pixels > best_pixels || (new_pixels == best_pixels && result_size > *best_size)
new_pixels > best_pixels
|| (new_pixels == best_pixels && result_size > *best_size)
}
};
@@ -587,9 +624,7 @@ where
best_under = Some((result, new_w, new_h, result_size));
}
if new_w == orig_w
&& new_h == orig_h
&& target_size.saturating_sub(result_size) <= 1024
if new_w == orig_w && new_h == orig_h && target_size.saturating_sub(result_size) <= 1024
{
break;
}
@@ -645,11 +680,7 @@ where
let mut consider = |bytes: Vec<u8>| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size {
size - target_size
} else {
target_size - size
};
let diff = size.abs_diff(target_size);
let should_update = match (best_is_under, is_under) {
(false, true) => true,
@@ -689,87 +720,6 @@ where
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
fn encode_target_quality<F>(
min_q: u8,
max_q: u8,
target_size: u64,
mut encode: F,
) -> Result<Vec<u8>, AppError>
where
F: FnMut(u8) -> Result<Vec<u8>, AppError>,
{
let mut best: Option<Vec<u8>> = None;
let mut best_diff = u64::MAX;
let mut best_is_under = false; // 记录最佳结果是否小于目标
let mut best_size = 0u64;
// 考虑一个候选结果
let consider = |bytes: Vec<u8>, best: &mut Option<Vec<u8>>, best_diff: &mut u64, best_is_under: &mut bool, best_size: &mut u64| {
let size = bytes.len() as u64;
let is_under = size <= target_size;
let diff = if size > target_size {
size - target_size
} else {
target_size - size
};
// 优先选择不超过目标大小的结果
let should_update = match (*best_is_under, is_under) {
(false, true) => true, // 当前小于目标,之前大于目标 -> 更新
(true, false) => false, // 当前大于目标,之前小于目标 -> 不更新
_ => diff < *best_diff, // 同类情况,选择更接近的
};
if should_update {
*best_diff = diff;
*best_is_under = is_under;
*best_size = size;
*best = Some(bytes);
}
};
// 先尝试两端
let bytes = encode(min_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if min_q != max_q {
let bytes = encode(max_q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
}
// 二分查找,增加迭代次数到 12 次以提高精度
let mut low = min_q;
let mut high = max_q;
for _ in 0..12 {
if low > high {
break;
}
let mid = (low + high) / 2;
let bytes = encode(mid)?;
let size = bytes.len() as u64;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if size > target_size {
high = mid.saturating_sub(1);
} else {
low = mid.saturating_add(1);
}
}
// 精细调整:如果当前结果超出目标太多,尝试更低质量
if best_size > target_size {
let mut q = min_q;
while q <= min_q.saturating_add(5) && q <= max_q {
let bytes = encode(q)?;
consider(bytes, &mut best, &mut best_diff, &mut best_is_under, &mut best_size);
if best_size <= target_size {
break; // 已找到满足条件的结果
}
q = q.saturating_add(1);
}
}
best.ok_or_else(|| AppError::new(ErrorCode::CompressionFailed, "压缩失败"))
}
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
@@ -780,7 +730,9 @@ fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let mut encoder = GifEncoder::new_with_speed(&mut out, speed);
encoder
.encode(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "GIF 编码失败").with_source(err)
})?;
}
Ok(out)
@@ -793,7 +745,9 @@ fn encode_bmp(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = BmpEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "BMP 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -804,7 +758,9 @@ fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = TiffEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "TIFF 编码失败").with_source(err)
})?;
Ok(out.into_inner())
}
@@ -815,7 +771,9 @@ fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let encoder = IcoEncoder::new(&mut out);
encoder
.write_image(rgba.as_raw(), w, h, ExtendedColorType::Rgba8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "ICO 编码失败").with_source(err)
})?;
Ok(out)
}
@@ -837,8 +795,9 @@ fn apply_metadata(
}
let out_bytes = ImgBytes::from(output);
let dyn_img = DynImage::from_bytes(out_bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err))?;
let dyn_img = DynImage::from_bytes(out_bytes.clone()).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "解析输出图片元数据失败").with_source(err)
})?;
let Some(mut img) = dyn_img else {
return Ok(out_bytes.to_vec());
@@ -848,16 +807,17 @@ fn apply_metadata(
img.set_exif(exif);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
img.encoder().write_to(&mut buf).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err)
})?;
Ok(buf)
}
fn strip_metadata(input: &[u8]) -> Result<Vec<u8>, AppError> {
let bytes = ImgBytes::copy_from_slice(input);
let dyn_img = DynImage::from_bytes(bytes.clone())
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err))?;
let dyn_img = DynImage::from_bytes(bytes.clone()).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "解析图片元数据失败").with_source(err)
})?;
let Some(mut img) = dyn_img else {
return Ok(bytes.to_vec());
};
@@ -866,9 +826,9 @@ fn strip_metadata(input: &[u8]) -> Result<Vec<u8>, AppError> {
img.set_exif(None);
let mut buf = Vec::new();
img.encoder()
.write_to(&mut buf)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err))?;
img.encoder().write_to(&mut buf).map_err(|err| {
AppError::new(ErrorCode::CompressionFailed, "写入图片元数据失败").with_source(err)
})?;
Ok(buf)
}
@@ -927,13 +887,58 @@ fn strength_from_rate(rate: u8) -> u8 {
fn is_animated_gif(input: &[u8]) -> Result<bool, AppError> {
let decoder = GifDecoder::new(Cursor::new(input))
.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
let mut frames = decoder.into_frames().into_iter();
let mut frames = decoder.into_frames();
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
frame.map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err)
})?;
}
if let Some(frame) = frames.next() {
frame.map_err(|err| AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err))?;
frame.map_err(|err| {
AppError::new(ErrorCode::InvalidImage, "GIF 解码失败").with_source(err)
})?;
return Ok(true);
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_supported_formats_from_signatures() {
assert_eq!(detect_format(b"\x89PNG\r\n\x1a\n").unwrap(), ImageFmt::Png);
assert_eq!(detect_format(b"\xff\xd8").unwrap(), ImageFmt::Jpeg);
assert_eq!(
detect_format(b"RIFF\x00\x00\x00\x00WEBP").unwrap(),
ImageFmt::Webp
);
assert_eq!(detect_format(b"GIF89a").unwrap(), ImageFmt::Gif);
assert_eq!(detect_format(b"BM").unwrap(), ImageFmt::Bmp);
}
#[test]
fn detects_avif_and_rejects_heic() {
let avif = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avif";
assert_eq!(detect_format(avif).unwrap(), ImageFmt::Avif);
let heic = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1";
let error = detect_format(heic).unwrap_err();
assert_eq!(error.code, ErrorCode::UnsupportedFormat);
}
#[test]
fn fit_within_preserves_aspect_ratio_and_never_upscales() {
assert_eq!(fit_within(4000, 2000, Some(1000), None), (1000, 500));
assert_eq!(fit_within(4000, 2000, None, Some(250)), (500, 250));
assert_eq!(fit_within(400, 200, Some(800), Some(800)), (400, 200));
}
#[test]
fn compression_rate_maps_to_expected_target_size() {
assert_eq!(target_size_from_rate(10_000, 1), 100);
assert_eq!(target_size_from_rate(10_000, 55), 5_500);
assert_eq!(target_size_from_rate(10_000, 100), 10_000);
}
}

View File

@@ -15,8 +15,8 @@ pub enum Scope {
#[derive(Debug)]
pub enum BeginResult {
Acquired { expires_at: DateTime<Utc> },
Replay { response_status: i32, response_body: JsonValue },
Acquired,
Replay { response_body: JsonValue },
InProgress,
}
@@ -25,7 +25,6 @@ struct IdemRow {
request_hash: String,
response_status: i32,
response_body: Option<JsonValue>,
expires_at: DateTime<Utc>,
}
pub fn sha256_hex(parts: &[&[u8]]) -> String {
@@ -45,13 +44,22 @@ pub async fn begin(
ttl_hours: i64,
) -> Result<BeginResult, AppError> {
if idempotency_key.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 不能为空"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"Idempotency-Key 不能为空",
));
}
if idempotency_key.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 过长"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"Idempotency-Key 过长",
));
}
if request_hash.len() != 64 {
return Err(AppError::new(ErrorCode::InvalidRequest, "request_hash 不合法"));
return Err(AppError::new(
ErrorCode::InvalidRequest,
"request_hash 不合法",
));
}
let now = Utc::now();
@@ -108,12 +116,12 @@ pub async fn begin(
.map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等记录失败").with_source(err))?;
if inserted.rows_affected() > 0 {
return Ok(BeginResult::Acquired { expires_at });
return Ok(BeginResult::Acquired);
}
let row = get_row(state, scope, idempotency_key, now).await?;
let Some(row) = row else {
return Ok(BeginResult::Acquired { expires_at });
return Ok(BeginResult::Acquired);
};
if row.request_hash != request_hash {
@@ -128,7 +136,6 @@ pub async fn begin(
}
Ok(BeginResult::Replay {
response_status: row.response_status,
response_body: row.response_body.unwrap_or(JsonValue::Null),
})
}
@@ -300,7 +307,7 @@ async fn get_row(
Scope::User(user_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
SELECT request_hash, response_status, response_body
FROM idempotency_keys
WHERE user_id = $1
AND idempotency_key = $2
@@ -318,7 +325,7 @@ async fn get_row(
Scope::ApiKey(api_key_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
SELECT request_hash, response_status, response_body
FROM idempotency_keys
WHERE api_key_id = $1
AND idempotency_key = $2
@@ -338,4 +345,3 @@ async fn get_row(
Ok(row)
}

View File

@@ -76,10 +76,9 @@ impl Mailer {
let tls_params = if smtp.encryption == SmtpEncryption::None {
None
} else {
Some(
TlsParameters::new(smtp.host.clone())
.map_err(|err| AppError::new(ErrorCode::Internal, "SMTP TLS 参数错误").with_source(err))?,
)
Some(TlsParameters::new(smtp.host.clone()).map_err(|err| {
AppError::new(ErrorCode::Internal, "SMTP TLS 参数错误").with_source(err)
})?)
};
let tls = match (smtp.encryption, tls_params) {
@@ -203,8 +202,11 @@ impl Mailer {
let from = format!("{} <{}>", self.from_name, self.from);
let email = Message::builder()
.from(from.parse().map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "MAIL_FROM/MAIL_FROM_NAME 格式错误")
.with_source(err)
AppError::new(
ErrorCode::InvalidRequest,
"MAIL_FROM/MAIL_FROM_NAME 格式错误",
)
.with_source(err)
})?)
.to(to.parse().map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "收件人邮箱格式错误").with_source(err)
@@ -212,12 +214,16 @@ impl Mailer {
.subject(subject)
.multipart(
MultiPart::alternative()
.singlepart(SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text_body.to_string()))
.singlepart(SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())),
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(text_body.to_string()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string()),
),
)
.map_err(|err| AppError::new(ErrorCode::Internal, "构建邮件失败").with_source(err))?;
@@ -250,11 +256,16 @@ impl SmtpConfig {
let host = settings.smtp_host.clone().ok_or_else(|| {
AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置 host")
})?;
let port = settings
.smtp_port
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置端口"))?;
let encryption = parse_encryption(settings.smtp_encryption.as_deref().unwrap_or("ssl"))?;
return Ok(Self { host, port, encryption });
let port = settings.smtp_port.ok_or_else(|| {
AppError::new(ErrorCode::InvalidRequest, "自定义 SMTP 必须配置端口")
})?;
let encryption =
parse_encryption(settings.smtp_encryption.as_deref().unwrap_or("ssl"))?;
return Ok(Self {
host,
port,
encryption,
});
}
let provider = settings.provider.to_ascii_lowercase();
@@ -320,7 +331,9 @@ pub async fn send_password_reset_email(
reset_url: &str,
) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
mailer.send_password_reset_email(to, username, reset_url).await
mailer
.send_password_reset_email(to, username, reset_url)
.await
}
pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> {

View File

@@ -1,7 +1,7 @@
pub mod mail;
pub mod billing;
pub mod quota;
pub mod bootstrap;
pub mod compress;
pub mod idempotency;
pub mod mail;
pub mod quota;
pub mod settings;
pub mod bootstrap;

View File

@@ -6,8 +6,8 @@ use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -39,7 +39,6 @@ pub struct StripeConfigStored {
pub struct StripeSecrets {
pub secret_key: String,
pub webhook_secret: Option<String>,
pub secret_key_prefix: Option<String>,
}
pub async fn load_system_config<T: DeserializeOwned>(
@@ -51,7 +50,9 @@ pub async fn load_system_config<T: DeserializeOwned>(
.bind(key)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err))?;
.map_err(|err| {
AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err)
})?;
let Some(value) = value else {
return Ok(None);
@@ -138,7 +139,6 @@ pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecret
Ok(Some(StripeSecrets {
secret_key,
webhook_secret,
secret_key_prefix: cfg.secret_key_prefix,
}))
}