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

135
src/services/billing.rs Normal file
View File

@@ -0,0 +1,135 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{DateTime, Datelike, TimeZone, Utc};
use sqlx::FromRow;
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,
pub retention_days: i32,
pub feature_api_enabled: bool,
}
#[derive(Debug, Clone)]
pub struct BillingContext {
pub user_id: Uuid,
pub subscription_id: Option<Uuid>,
pub plan: Plan,
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct SubscriptionRow {
id: Uuid,
status: String,
current_period_start: DateTime<Utc>,
current_period_end: DateTime<Utc>,
plan_id: Uuid,
}
#[derive(Debug, FromRow)]
struct PlanRow {
id: Uuid,
code: String,
included_units_per_period: i32,
max_file_size_mb: i32,
max_files_per_batch: i32,
retention_days: i32,
features: serde_json::Value,
}
pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<BillingContext, AppError> {
let subscription = sqlx::query_as::<_, SubscriptionRow>(
r#"
SELECT id, status::text AS status, current_period_start, current_period_end, plan_id
FROM subscriptions
WHERE user_id = $1
AND status IN ('active', 'trialing', 'past_due')
ORDER BY 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 (subscription_id, period_start, period_end, plan_id) = if let Some(sub) = subscription {
if sub.status == "past_due" {
return Err(AppError::new(
ErrorCode::Forbidden,
"订阅欠费,请先完成支付",
));
}
(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))?;
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
FROM plans
WHERE id = $1
"#,
)
.bind(plan_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
let feature_api_enabled = plan_row
.features
.get("api")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(BillingContext {
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,
retention_days: plan_row.retention_days,
feature_api_enabled,
},
period_start,
period_end,
})
}
pub fn current_month_period_utc8(now_utc: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let tz = chrono::FixedOffset::east_opt(8 * 3600).unwrap();
let now = now_utc.with_timezone(&tz);
let year = now.year();
let month = now.month();
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)
} else {
(year, month + 1)
};
let end = tz
.with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0)
.single()
.unwrap();
(start.with_timezone(&Utc), end.with_timezone(&Utc))
}

204
src/services/bootstrap.rs Normal file
View File

@@ -0,0 +1,204 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use argon2::{Argon2, PasswordHasher};
use chrono::Utc;
use sqlx::FromRow;
use tracing::{info, warn};
use uuid::Uuid;
#[derive(Debug, FromRow)]
struct AdminRow {
id: Uuid,
username: String,
role: String,
}
pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
let Some(admin_email) = env_string("ADMIN_EMAIL") else {
return Ok(());
};
let Some(admin_password) = env_string("ADMIN_PASSWORD") else {
return Ok(());
};
let admin_email = admin_email.trim().to_lowercase();
let admin_password = admin_password.trim().to_string();
if admin_email.is_empty() || admin_password.is_empty() {
return Ok(());
}
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)?;
validate_username(&admin_username)?;
validate_password(&admin_password)?;
let existing = sqlx::query_as::<_, AdminRow>(
r#"
SELECT id, username, role::text AS role
FROM users
WHERE email = $1
"#,
)
.bind(&admin_email)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?;
let password_hash = hash_password(&admin_password)?;
if let Some(row) = existing {
sqlx::query(
r#"
UPDATE users
SET password_hash = $1,
role = 'admin',
is_active = true,
email_verified_at = COALESCE(email_verified_at, NOW()),
updated_at = NOW()
WHERE id = $2
"#,
)
.bind(&password_hash)
.bind(row.id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员账号失败").with_source(err))?;
if row.username != admin_username {
let name_taken: bool = sqlx::query_scalar(
r#"
SELECT EXISTS(
SELECT 1 FROM users WHERE username = $1 AND id <> $2
)
"#,
)
.bind(&admin_username)
.bind(row.id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "校验管理员用户名失败").with_source(err))?;
if name_taken {
warn!(
admin_email = %admin_email,
admin_username = %admin_username,
"管理员用户名已被占用,保留原用户名"
);
} else {
sqlx::query(
r#"
UPDATE users
SET username = $1, updated_at = NOW()
WHERE id = $2
"#,
)
.bind(&admin_username)
.bind(row.id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新管理员用户名失败").with_source(err))?;
}
}
if row.role != "admin" {
info!(admin_email = %admin_email, "管理员权限已启用");
}
} else {
sqlx::query(
r#"
INSERT INTO users (email, username, password_hash, role, email_verified_at)
VALUES ($1, $2, $3, 'admin', $4)
"#,
)
.bind(&admin_email)
.bind(&admin_username)
.bind(&password_hash)
.bind(Utc::now())
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建管理员账号失败").with_source(err))?;
info!(
admin_email = %admin_email,
admin_username = %admin_username,
"管理员账号已创建"
);
}
Ok(())
}
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;
Ok(())
}
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 env_string(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.trim().is_empty())
}

533
src/services/compress.rs Normal file
View File

@@ -0,0 +1,533 @@
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 oxipng::StripChunks;
use rgb::FromSlice;
use std::io::Cursor;
#[derive(Debug, Clone, Copy)]
pub enum CompressionLevel {
High,
Medium,
Low,
}
impl CompressionLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFmt {
Png,
Jpeg,
Webp,
Avif,
Gif,
Bmp,
Tiff,
Ico,
}
impl ImageFmt {
pub fn as_str(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Gif => "gif",
Self::Bmp => "bmp",
Self::Tiff => "tiff",
Self::Ico => "ico",
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Webp => "webp",
Self::Avif => "avif",
Self::Gif => "gif",
Self::Bmp => "bmp",
Self::Tiff => "tiff",
Self::Ico => "ico",
}
}
pub fn content_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Avif => "image/avif",
Self::Gif => "image/gif",
Self::Bmp => "image/bmp",
Self::Tiff => "image/tiff",
Self::Ico => "image/x-icon",
}
}
}
pub fn parse_level(value: &str) -> Result<CompressionLevel, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"" | "medium" => Ok(CompressionLevel::Medium),
"high" => Ok(CompressionLevel::High),
"low" => Ok(CompressionLevel::Low),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"level 仅支持 high/medium/low",
)),
}
}
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 的整数"))?;
if !(1..=100).contains(&rate) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"compression_rate 需在 1-100 之间",
));
}
Ok(rate)
}
pub fn rate_to_level(rate: u8) -> CompressionLevel {
match rate {
1..=33 => CompressionLevel::Low,
34..=66 => CompressionLevel::Medium,
_ => CompressionLevel::High,
}
}
pub fn parse_output_format(value: &str) -> Result<ImageFmt, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"png" => Ok(ImageFmt::Png),
"jpeg" | "jpg" => Ok(ImageFmt::Jpeg),
"webp" => Ok(ImageFmt::Webp),
"avif" => Ok(ImageFmt::Avif),
"gif" => Ok(ImageFmt::Gif),
"bmp" => Ok(ImageFmt::Bmp),
"tif" | "tiff" => Ok(ImageFmt::Tiff),
"ico" => Ok(ImageFmt::Ico),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"output_format 仅支持 png/jpeg/webp/avif/gif/bmp/tiff/ico",
)),
}
}
pub fn detect_format(bytes: &[u8]) -> Result<ImageFmt, AppError> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Ok(ImageFmt::Png);
}
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
return Ok(ImageFmt::Jpeg);
}
if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
return Ok(ImageFmt::Webp);
}
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
if bytes[8..12].eq_ignore_ascii_case(b"avif")
|| bytes[8..12].eq_ignore_ascii_case(b"avis")
{
return Ok(ImageFmt::Avif);
}
}
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
return Ok(ImageFmt::Gif);
}
if bytes.len() >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D {
return Ok(ImageFmt::Bmp);
}
if bytes.len() >= 4 {
if &bytes[0..4] == b"II*\x00" || &bytes[0..4] == b"MM\x00*" {
return Ok(ImageFmt::Tiff);
}
if bytes[0] == 0x00
&& bytes[1] == 0x00
&& (bytes[2] == 0x01 || bytes[2] == 0x02)
&& bytes[3] == 0x00
{
return Ok(ImageFmt::Ico);
}
}
Err(AppError::new(
ErrorCode::UnsupportedFormat,
"不支持的图片格式",
))
}
pub async fn compress_image_bytes(
state: &AppState,
input: &[u8],
format_in: ImageFmt,
format_out: ImageFmt,
level: CompressionLevel,
compression_rate: Option<u8>,
max_width: Option<u32>,
max_height: Option<u32>,
preserve_metadata: bool,
) -> Result<Vec<u8>, AppError> {
if format_in == ImageFmt::Gif {
if is_animated_gif(input)? {
return Err(AppError::new(
ErrorCode::UnsupportedFormat,
"暂不支持动图 GIF",
));
}
}
let rate = effective_rate(compression_rate, level);
let (icc_profile, exif) = if preserve_metadata {
extract_metadata(input)
} else {
(None, None)
};
let mut resized = false;
let mut output = if format_in == ImageFmt::Png
&& format_out == ImageFmt::Png
&& max_width.is_none()
&& max_height.is_none()
{
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
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))?;
enforce_pixel_limit(state, &image)?;
let (image, did_resize) = resize_if_needed(image, max_width, max_height);
resized = did_resize;
match format_out {
ImageFmt::Png => encode_png(image, rate, preserve_metadata)?,
ImageFmt::Jpeg => encode_jpeg(image, rate)?,
ImageFmt::Webp => encode_webp(image, rate)?,
ImageFmt::Avif => encode_avif(image, rate)?,
ImageFmt::Gif => encode_gif(image, rate)?,
ImageFmt::Bmp => encode_bmp(image)?,
ImageFmt::Tiff => encode_tiff(image)?,
ImageFmt::Ico => encode_ico(image)?,
}
};
if preserve_metadata {
output = apply_metadata(output, icc_profile, exif)?;
}
if !resized && output.len() >= input.len() {
if preserve_metadata {
return Ok(input.to_vec());
}
let stripped = strip_metadata(input).unwrap_or_else(|_| input.to_vec());
return Ok(if stripped.len() <= input.len() {
stripped
} else {
input.to_vec()
});
}
Ok(output)
}
fn enforce_pixel_limit(state: &AppState, 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 {
return Err(AppError::new(
ErrorCode::TooManyPixels,
format!("图片像素过大({}x{}", w, h),
));
}
Ok(())
}
fn resize_if_needed(
image: DynamicImage,
max_width: Option<u32>,
max_height: Option<u32>,
) -> (DynamicImage, bool) {
if max_width.is_none() && max_height.is_none() {
return (image, false);
}
let (w, h) = image.dimensions();
let (target_w, target_h) = fit_within(w, h, max_width, max_height);
if target_w == w && target_h == h {
return (image, false);
}
(
image.resize(target_w, target_h, image::imageops::FilterType::Lanczos3),
true,
)
}
fn fit_within(w: u32, h: u32, max_width: Option<u32>, max_height: Option<u32>) -> (u32, u32) {
let mut scale = 1.0_f64;
if let Some(mw) = max_width.filter(|v| *v > 0) {
scale = scale.min(mw as f64 / w as f64);
}
if let Some(mh) = max_height.filter(|v| *v > 0) {
scale = scale.min(mh as f64 / h as f64);
}
if scale >= 1.0 {
return (w, h);
}
let nw = (w as f64 * scale).round().max(1.0) as u32;
let nh = (h as f64 * scale).round().max(1.0) as u32;
(nw, nh)
}
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();
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))?;
let preset = png_preset_from_rate(rate);
let mut opts = oxipng::Options::from_preset(preset);
if !preserve_metadata {
opts.strip = StripChunks::Safe;
}
oxipng::optimize_from_memory(&out, &opts)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "PNG 优化失败").with_source(err))
}
fn encode_jpeg(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgb = image.to_rgb8();
let (w, h) = rgb.dimensions();
let mut out = Vec::new();
let quality = jpeg_quality_from_rate(rate);
let mut encoder = JpegEncoder::new_with_quality(&mut out, quality);
encoder
.encode(rgb.as_raw(), w, h, ExtendedColorType::Rgb8)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "JPEG 编码失败").with_source(err))?;
Ok(out)
}
fn encode_webp(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let encoder = webp::Encoder::from_rgba(rgba.as_raw(), w, h);
let bytes = if rate <= 10 {
encoder.encode_lossless()
} else {
encoder.encode(webp_quality_from_rate(rate))
};
Ok(bytes.to_vec())
}
fn encode_avif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let quality = avif_quality_from_rate(rate);
let raw = rgba.into_raw();
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);
let encoded = encoder
.encode_rgba(img)
.map_err(|err| AppError::new(ErrorCode::CompressionFailed, "AVIF 编码失败").with_source(err))?;
Ok(encoded.avif_file)
}
fn encode_gif(image: DynamicImage, rate: u8) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
let speed = gif_speed_from_rate(rate);
{
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))?;
}
Ok(out)
}
fn encode_bmp(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
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))?;
Ok(out)
}
fn encode_tiff(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Cursor::new(Vec::new());
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))?;
Ok(out.into_inner())
}
fn encode_ico(image: DynamicImage) -> Result<Vec<u8>, AppError> {
let rgba = image.to_rgba8();
let (w, h) = rgba.dimensions();
let mut out = Vec::new();
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))?;
Ok(out)
}
fn extract_metadata(input: &[u8]) -> (Option<ImgBytes>, Option<ImgBytes>) {
let bytes = ImgBytes::copy_from_slice(input);
match DynImage::from_bytes(bytes) {
Ok(Some(img)) => (img.icc_profile(), img.exif()),
_ => (None, None),
}
}
fn apply_metadata(
output: Vec<u8>,
icc_profile: Option<ImgBytes>,
exif: Option<ImgBytes>,
) -> Result<Vec<u8>, AppError> {
if icc_profile.is_none() && exif.is_none() {
return Ok(output);
}
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 Some(mut img) = dyn_img else {
return Ok(out_bytes.to_vec());
};
img.set_icc_profile(icc_profile);
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))?;
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 Some(mut img) = dyn_img else {
return Ok(bytes.to_vec());
};
img.set_icc_profile(None);
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))?;
Ok(buf)
}
fn effective_rate(rate: Option<u8>, level: CompressionLevel) -> u8 {
match rate {
Some(value) => value.clamp(1, 100),
None => match level {
CompressionLevel::Low => 25,
CompressionLevel::Medium => 55,
CompressionLevel::High => 80,
},
}
}
fn png_preset_from_rate(rate: u8) -> u8 {
(((rate.saturating_sub(1)) as f32 / 99.0) * 6.0).round() as u8
}
fn jpeg_quality_from_rate(rate: u8) -> u8 {
quality_from_rate(rate, 35, 95)
}
fn webp_quality_from_rate(rate: u8) -> f32 {
quality_from_rate(rate, 40, 92) as f32
}
fn avif_quality_from_rate(rate: u8) -> f32 {
quality_from_rate(rate, 35, 90) as f32
}
fn quality_from_rate(rate: u8, min_quality: u8, max_quality: u8) -> u8 {
let min_q = min_quality as i32;
let max_q = max_quality as i32;
let rate = rate.clamp(1, 100) as i32;
let span = max_q - min_q;
let q = max_q - ((rate - 1) * span / 99);
q.clamp(min_q, max_q) as u8
}
fn gif_speed_from_rate(rate: u8) -> i32 {
let rate = rate.clamp(1, 100) as i32;
1 + ((rate - 1) * 29 / 99)
}
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();
if let Some(frame) = frames.next() {
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))?;
return Ok(true);
}
Ok(false)
}

341
src/services/idempotency.rs Normal file
View File

@@ -0,0 +1,341 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Copy)]
pub enum Scope {
User(Uuid),
ApiKey(Uuid),
}
#[derive(Debug)]
pub enum BeginResult {
Acquired { expires_at: DateTime<Utc> },
Replay { response_status: i32, response_body: JsonValue },
InProgress,
}
#[derive(Debug, FromRow)]
struct IdemRow {
request_hash: String,
response_status: i32,
response_body: Option<JsonValue>,
expires_at: DateTime<Utc>,
}
pub fn sha256_hex(parts: &[&[u8]]) -> String {
let mut hasher = Sha256::new();
for p in parts {
hasher.update(p);
hasher.update([0u8]); // separator
}
hex::encode(hasher.finalize())
}
pub async fn begin(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
ttl_hours: i64,
) -> Result<BeginResult, AppError> {
if idempotency_key.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 不能为空"));
}
if idempotency_key.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "Idempotency-Key 过长"));
}
if request_hash.len() != 64 {
return Err(AppError::new(ErrorCode::InvalidRequest, "request_hash 不合法"));
}
let now = Utc::now();
let expires_at = now + Duration::hours(ttl_hours.max(1));
cleanup_expired_for_key(state, scope, idempotency_key, now).await?;
let inserted = match scope {
Scope::User(user_id) => {
sqlx::query(
r#"
INSERT INTO idempotency_keys (
user_id, idempotency_key, request_hash,
response_status, response_body,
expires_at
) VALUES (
$1, $2, $3,
0, NULL,
$4
)
ON CONFLICT DO NOTHING
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(expires_at)
.execute(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query(
r#"
INSERT INTO idempotency_keys (
api_key_id, idempotency_key, request_hash,
response_status, response_body,
expires_at
) VALUES (
$1, $2, $3,
0, NULL,
$4
)
ON CONFLICT DO NOTHING
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(expires_at)
.execute(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等记录失败").with_source(err))?;
if inserted.rows_affected() > 0 {
return Ok(BeginResult::Acquired { expires_at });
}
let row = get_row(state, scope, idempotency_key, now).await?;
let Some(row) = row else {
return Ok(BeginResult::Acquired { expires_at });
};
if row.request_hash != request_hash {
return Err(AppError::new(
ErrorCode::IdempotencyConflict,
"同一个 Idempotency-Key 的请求参数不一致",
));
}
if row.response_status == 0 || row.response_body.is_none() {
return Ok(BeginResult::InProgress);
}
Ok(BeginResult::Replay {
response_status: row.response_status,
response_body: row.response_body.unwrap_or(JsonValue::Null),
})
}
pub async fn wait_for_replay(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
max_wait_ms: u64,
) -> Result<Option<(i32, JsonValue)>, AppError> {
let started = tokio::time::Instant::now();
let now = Utc::now();
loop {
let row = get_row(state, scope, idempotency_key, now).await?;
let Some(row) = row else { return Ok(None) };
if row.request_hash != request_hash {
return Err(AppError::new(
ErrorCode::IdempotencyConflict,
"同一个 Idempotency-Key 的请求参数不一致",
));
}
if row.response_status != 0 {
return Ok(Some((
row.response_status,
row.response_body.unwrap_or(JsonValue::Null),
)));
}
if started.elapsed().as_millis() as u64 >= max_wait_ms {
return Ok(None);
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
pub async fn complete(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
response_status: i32,
response_body: JsonValue,
) -> Result<(), AppError> {
let updated = match scope {
Scope::User(user_id) => {
sqlx::query(
r#"
UPDATE idempotency_keys
SET response_status = $4,
response_body = $5
WHERE user_id = $1
AND idempotency_key = $2
AND request_hash = $3
AND response_status = 0
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(response_status)
.bind(response_body)
.execute(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query(
r#"
UPDATE idempotency_keys
SET response_status = $4,
response_body = $5
WHERE api_key_id = $1
AND idempotency_key = $2
AND request_hash = $3
AND response_status = 0
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.bind(response_status)
.bind(response_body)
.execute(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "写入幂等结果失败").with_source(err))?;
if updated.rows_affected() == 0 {
tracing::warn!("idempotency record not updated (maybe already completed?)");
}
Ok(())
}
pub async fn abort(
state: &AppState,
scope: Scope,
idempotency_key: &str,
request_hash: &str,
) -> Result<(), AppError> {
match scope {
Scope::User(user_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0",
)
.bind(user_id)
.bind(idempotency_key)
.bind(request_hash)
.execute(&state.db)
.await;
}
Scope::ApiKey(api_key_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND request_hash = $3 AND response_status = 0",
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(request_hash)
.execute(&state.db)
.await;
}
}
Ok(())
}
async fn cleanup_expired_for_key(
state: &AppState,
scope: Scope,
idempotency_key: &str,
now: DateTime<Utc>,
) -> Result<(), AppError> {
match scope {
Scope::User(user_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE user_id = $1 AND idempotency_key = $2 AND expires_at < $3",
)
.bind(user_id)
.bind(idempotency_key)
.bind(now)
.execute(&state.db)
.await;
}
Scope::ApiKey(api_key_id) => {
let _ = sqlx::query(
"DELETE FROM idempotency_keys WHERE api_key_id = $1 AND idempotency_key = $2 AND expires_at < $3",
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(now)
.execute(&state.db)
.await;
}
}
Ok(())
}
async fn get_row(
state: &AppState,
scope: Scope,
idempotency_key: &str,
now: DateTime<Utc>,
) -> Result<Option<IdemRow>, AppError> {
let row = match scope {
Scope::User(user_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
FROM idempotency_keys
WHERE user_id = $1
AND idempotency_key = $2
AND expires_at > $3
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(user_id)
.bind(idempotency_key)
.bind(now)
.fetch_optional(&state.db)
.await
}
Scope::ApiKey(api_key_id) => {
sqlx::query_as::<_, IdemRow>(
r#"
SELECT request_hash, response_status, response_body, expires_at
FROM idempotency_keys
WHERE api_key_id = $1
AND idempotency_key = $2
AND expires_at > $3
ORDER BY created_at DESC
LIMIT 1
"#,
)
.bind(api_key_id)
.bind(idempotency_key)
.bind(now)
.fetch_optional(&state.db)
.await
}
}
.map_err(|err| AppError::new(ErrorCode::Internal, "查询幂等记录失败").with_source(err))?;
Ok(row)
}

344
src/services/mail.rs Normal file
View File

@@ -0,0 +1,344 @@
use crate::config::Config;
use crate::error::{AppError, ErrorCode};
use crate::services::settings;
use crate::state::AppState;
use chrono::Datelike;
use lettre::message::{header::ContentType, MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
#[derive(Clone)]
pub struct Mailer {
enabled: bool,
log_links_when_disabled: bool,
from: String,
from_name: String,
transport: Option<AsyncSmtpTransport<Tokio1Executor>>,
}
#[derive(Debug, Clone)]
pub struct MailSettings {
pub enabled: bool,
pub log_links_when_disabled: bool,
pub provider: String,
pub from: String,
pub from_name: String,
pub password: String,
pub smtp_host: Option<String>,
pub smtp_port: Option<u16>,
pub smtp_encryption: Option<String>,
}
impl MailSettings {
pub fn from_env(config: &Config) -> Self {
Self {
enabled: config.mail_enabled,
log_links_when_disabled: config.mail_log_links_when_disabled,
provider: config.mail_provider.clone(),
from: config.mail_from.clone(),
from_name: config.mail_from_name.clone(),
password: config.mail_password.clone(),
smtp_host: config.mail_smtp_host.clone(),
smtp_port: config.mail_smtp_port,
smtp_encryption: config.mail_smtp_encryption.clone(),
}
}
}
impl Mailer {
pub fn new(config: &Config) -> Result<Self, AppError> {
Self::from_settings(MailSettings::from_env(config))
}
pub fn from_settings(settings: MailSettings) -> Result<Self, AppError> {
if !settings.enabled {
return Ok(Self {
enabled: false,
log_links_when_disabled: settings.log_links_when_disabled,
from: settings.from,
from_name: settings.from_name,
transport: None,
});
}
if settings.password.trim().is_empty() {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"邮件服务已启用但未配置授权码/密码",
));
}
let smtp = SmtpConfig::from_settings(&settings)?;
let creds = Credentials::new(settings.from.clone(), settings.password.clone());
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))?,
)
};
let tls = match (smtp.encryption, tls_params) {
(SmtpEncryption::Ssl, Some(params)) => Tls::Wrapper(params),
(SmtpEncryption::StartTls, Some(params)) => Tls::Required(params),
(SmtpEncryption::None, _) => Tls::None,
_ => Tls::None,
};
let transport = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
.port(smtp.port)
.tls(tls)
.credentials(creds)
.build();
Ok(Self {
enabled: true,
log_links_when_disabled: false,
from: settings.from,
from_name: settings.from_name,
transport: Some(transport),
})
}
pub async fn send_verification_email(
&self,
to: &str,
username: &str,
verification_url: &str,
) -> Result<(), AppError> {
if !self.enabled {
if self.log_links_when_disabled {
tracing::info!(
to = %to,
verification_url = %verification_url,
"MAIL_ENABLED=false, verification email link"
);
} else {
tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending verification email");
}
return Ok(());
}
let year = chrono::Utc::now().year().to_string();
let html = render_template(
include_str!("../../templates/email_verification.html"),
&[
("{{username}}", username),
("{{verification_url}}", verification_url),
("{{year}}", &year),
],
);
let text = render_template(
include_str!("../../templates/email_verification.txt"),
&[
("{{username}}", username),
("{{verification_url}}", verification_url),
("{{year}}", &year),
],
);
self.send_email(to, "验证您的 ImageForge 账号", &text, &html)
.await
}
pub async fn send_password_reset_email(
&self,
to: &str,
username: &str,
reset_url: &str,
) -> Result<(), AppError> {
if !self.enabled {
if self.log_links_when_disabled {
tracing::info!(to = %to, reset_url = %reset_url, "MAIL_ENABLED=false, password reset link");
} else {
tracing::info!(to = %to, "MAIL_ENABLED=false, skip sending password reset email");
}
return Ok(());
}
let year = chrono::Utc::now().year().to_string();
let html = render_template(
include_str!("../../templates/password_reset.html"),
&[
("{{username}}", username),
("{{reset_url}}", reset_url),
("{{year}}", &year),
],
);
let text = render_template(
include_str!("../../templates/password_reset.txt"),
&[
("{{username}}", username),
("{{reset_url}}", reset_url),
("{{year}}", &year),
],
);
self.send_email(to, "重置您的 ImageForge 密码", &text, &html)
.await
}
async fn send_email(
&self,
to: &str,
subject: &str,
text_body: &str,
html_body: &str,
) -> Result<(), AppError> {
if !self.enabled {
tracing::info!(to = %to, subject = %subject, "MAIL_ENABLED=false, skip sending email");
return Ok(());
}
let Some(transport) = &self.transport else {
return Err(AppError::new(ErrorCode::Internal, "邮件服务未初始化"));
};
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)
})?)
.to(to.parse().map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, "收件人邮箱格式错误").with_source(err)
})?)
.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())),
)
.map_err(|err| AppError::new(ErrorCode::Internal, "构建邮件失败").with_source(err))?;
transport
.send(email)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "邮件发送失败").with_source(err))?;
Ok(())
}
}
#[derive(Debug, Clone)]
struct SmtpConfig {
host: String,
port: u16,
encryption: SmtpEncryption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SmtpEncryption {
Ssl,
StartTls,
None,
}
impl SmtpConfig {
fn from_settings(settings: &MailSettings) -> Result<Self, AppError> {
if settings.provider.eq_ignore_ascii_case("custom") {
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 provider = settings.provider.to_ascii_lowercase();
let (host, port, encryption) = match provider.as_str() {
"qq" => ("smtp.qq.com", 465, SmtpEncryption::Ssl),
"163" => ("smtp.163.com", 465, SmtpEncryption::Ssl),
"aliyun_enterprise" => ("smtp.qiye.aliyun.com", 465, SmtpEncryption::Ssl),
"tencent_enterprise" => ("smtp.exmail.qq.com", 465, SmtpEncryption::Ssl),
"gmail" => ("smtp.gmail.com", 587, SmtpEncryption::StartTls),
"outlook" => ("smtp.office365.com", 587, SmtpEncryption::StartTls),
other => {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("未知 MAIL_PROVIDER: {other}"),
))
}
};
Ok(Self {
host: host.to_string(),
port,
encryption,
})
}
}
fn parse_encryption(value: &str) -> Result<SmtpEncryption, AppError> {
match value.trim().to_ascii_lowercase().as_str() {
"ssl" => Ok(SmtpEncryption::Ssl),
"starttls" => Ok(SmtpEncryption::StartTls),
"none" => Ok(SmtpEncryption::None),
_ => Err(AppError::new(
ErrorCode::InvalidRequest,
"MAIL_SMTP_ENCRYPTION 仅支持 ssl/starttls/none",
)),
}
}
fn render_template(template: &str, vars: &[(&str, &str)]) -> String {
let mut out = template.to_string();
for (key, value) in vars {
out = out.replace(key, value);
}
out
}
pub async fn send_verification_email(
state: &AppState,
to: &str,
username: &str,
verification_url: &str,
) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
mailer
.send_verification_email(to, username, verification_url)
.await
}
pub async fn send_password_reset_email(
state: &AppState,
to: &str,
username: &str,
reset_url: &str,
) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
mailer.send_password_reset_email(to, username, reset_url).await
}
pub async fn send_test_email(state: &AppState, to: &str) -> Result<(), AppError> {
let mailer = resolve_mailer(state).await?;
let year = chrono::Utc::now().year().to_string();
let html = format!(
"<h2>ImageForge 邮件测试</h2><p>这是一封测试邮件。</p><p>{}</p>",
year
);
let text = format!("ImageForge 邮件测试\n\n这是一封测试邮件。\n{}\n", year);
mailer
.send_email(to, "ImageForge 邮件测试", &text, &html)
.await
}
async fn resolve_mailer(state: &AppState) -> Result<Mailer, AppError> {
if let Some(settings) = settings::load_mail_settings(state).await? {
return Mailer::from_settings(settings);
}
Ok(state.mailer.as_ref().clone())
}

7
src/services/mod.rs Normal file
View File

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

73
src/services/quota.rs Normal file
View File

@@ -0,0 +1,73 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use chrono::{Duration, Utc};
use std::net::IpAddr;
pub async fn consume_anonymous_units(
state: &AppState,
session_id: &str,
ip: IpAddr,
units: u32,
) -> Result<(), AppError> {
if units == 0 {
return Ok(());
}
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 limit = state.config.anon_daily_units as i64;
let ttl_seconds = 48 * 60 * 60;
let inc = units as i64;
let script = redis::Script::new(
r#"
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local inc = tonumber(ARGV[3])
local v1 = tonumber(redis.call('GET', KEYS[1]) or '0')
local v2 = tonumber(redis.call('GET', KEYS[2]) or '0')
if v1 + inc > limit or v2 + inc > limit then
return -1
end
v1 = redis.call('INCRBY', KEYS[1], inc)
v2 = redis.call('INCRBY', KEYS[2], inc)
if v1 == inc then redis.call('EXPIRE', KEYS[1], ttl) end
if v2 == inc then redis.call('EXPIRE', KEYS[2], ttl) end
return v1
"#,
);
let new_value: i64 = script
.key(session_key)
.key(ip_key)
.arg(limit)
.arg(ttl_seconds)
.arg(inc)
.invoke_async(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "匿名配额检查失败").with_source(err))?;
if new_value < 0 {
return Err(AppError::new(
ErrorCode::QuotaExceeded,
"匿名试用次数已用完(每日 10 次)",
));
}
Ok(())
}
fn utc8_date() -> String {
let now = Utc::now() + Duration::hours(8);
now.format("%Y-%m-%d").to_string()
}

227
src/services/settings.rs Normal file
View File

@@ -0,0 +1,227 @@
use crate::error::{AppError, ErrorCode};
use crate::services::mail::MailSettings;
use crate::state::AppState;
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 sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailCustomSmtp {
pub host: String,
pub port: u16,
pub encryption: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailConfigStored {
pub enabled: bool,
pub provider: String,
pub from: String,
pub from_name: String,
pub password_encrypted: Option<String>,
pub custom_smtp: Option<MailCustomSmtp>,
pub log_links_when_disabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StripeConfigStored {
pub secret_key_encrypted: Option<String>,
pub webhook_secret_encrypted: Option<String>,
pub secret_key_prefix: Option<String>,
}
#[derive(Debug, Clone)]
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>(
state: &AppState,
key: &str,
) -> Result<Option<T>, AppError> {
let value: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM system_config WHERE key = $1")
.bind(key)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询系统配置失败").with_source(err))?;
let Some(value) = value else {
return Ok(None);
};
let parsed = serde_json::from_value::<T>(value)
.map_err(|err| AppError::new(ErrorCode::Internal, "解析系统配置失败").with_source(err))?;
Ok(Some(parsed))
}
pub async fn upsert_system_config(
state: &AppState,
key: &str,
value: serde_json::Value,
description: Option<&str>,
updated_by: Option<uuid::Uuid>,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO system_config (key, value, description, updated_at, updated_by)
VALUES ($1, $2, $3, NOW(), $4)
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
description = COALESCE(EXCLUDED.description, system_config.description),
updated_at = NOW(),
updated_by = $4
"#,
)
.bind(key)
.bind(value)
.bind(description)
.bind(updated_by)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新系统配置失败").with_source(err))?;
Ok(())
}
pub async fn load_mail_settings(state: &AppState) -> Result<Option<MailSettings>, AppError> {
let Some(cfg) = load_system_config::<MailConfigStored>(state, "mail").await? else {
return Ok(None);
};
let password = match cfg.password_encrypted.as_deref() {
Some(value) => decrypt_secret(state, value)?,
None => String::new(),
};
Ok(Some(MailSettings {
enabled: cfg.enabled,
log_links_when_disabled: cfg
.log_links_when_disabled
.unwrap_or(state.config.mail_log_links_when_disabled),
provider: cfg.provider,
from: cfg.from,
from_name: cfg.from_name,
password,
smtp_host: cfg.custom_smtp.as_ref().map(|v| v.host.clone()),
smtp_port: cfg.custom_smtp.as_ref().map(|v| v.port),
smtp_encryption: cfg.custom_smtp.as_ref().map(|v| v.encryption.clone()),
}))
}
pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecrets>, AppError> {
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
return Ok(None);
};
let secret_key = match cfg.secret_key_encrypted.as_deref() {
Some(value) => decrypt_secret(state, value)?,
None => String::new(),
};
if secret_key.is_empty() {
return Ok(None);
}
let webhook_secret = match cfg.webhook_secret_encrypted.as_deref() {
Some(value) if !value.is_empty() => Some(decrypt_secret(state, value)?),
_ => None,
};
Ok(Some(StripeSecrets {
secret_key,
webhook_secret,
secret_key_prefix: cfg.secret_key_prefix,
}))
}
pub fn encrypt_secret(state: &AppState, plain: &str) -> Result<String, AppError> {
let key = derive_key(&state.config.api_key_pepper);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|err| AppError::new(ErrorCode::Internal, "加密密钥初始化失败").with_source(err))?;
let mut nonce_bytes = [0u8; 12];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plain.as_bytes())
.map_err(|err| AppError::new(ErrorCode::Internal, "加密失败").with_source(err))?;
let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(URL_SAFE_NO_PAD.encode(out))
}
pub fn decrypt_secret(state: &AppState, encoded: &str) -> Result<String, AppError> {
let key = derive_key(&state.config.api_key_pepper);
let cipher = Aes256Gcm::new_from_slice(&key)
.map_err(|err| AppError::new(ErrorCode::Internal, "解密密钥初始化失败").with_source(err))?;
let decoded = URL_SAFE_NO_PAD
.decode(encoded.as_bytes())
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文格式错误").with_source(err))?;
if decoded.len() < 12 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密文长度错误"));
}
let (nonce_bytes, cipher_bytes) = decoded.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let plain = cipher
.decrypt(nonce, cipher_bytes)
.map_err(|err| AppError::new(ErrorCode::InvalidRequest, "密文解密失败").with_source(err))?;
String::from_utf8(plain)
.map_err(|err| AppError::new(ErrorCode::Internal, "解密文本编码错误").with_source(err))
}
fn derive_key(secret: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
pub async fn get_stripe_secret(state: &AppState) -> Result<String, AppError> {
if let Some(cfg) = load_stripe_secrets(state).await? {
if !cfg.secret_key.trim().is_empty() {
return Ok(cfg.secret_key);
}
}
state
.config
.stripe_secret_key
.clone()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Secret Key"))
}
pub async fn get_stripe_webhook_secret(state: &AppState) -> Result<String, AppError> {
if let Some(cfg) = load_stripe_secrets(state).await? {
if let Some(secret) = cfg.webhook_secret {
if !secret.trim().is_empty() {
return Ok(secret);
}
}
}
state
.config
.stripe_webhook_secret
.clone()
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret"))
}