Implement compression quota refunds and admin manual subscription
This commit is contained in:
135
src/services/billing.rs
Normal file
135
src/services/billing.rs
Normal 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
204
src/services/bootstrap.rs
Normal 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
533
src/services/compress.rs
Normal 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
341
src/services/idempotency.rs
Normal 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
344
src/services/mail.rs
Normal 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
7
src/services/mod.rs
Normal 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
73
src/services/quota.rs
Normal 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
227
src/services/settings.rs
Normal 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"))
|
||||
}
|
||||
Reference in New Issue
Block a user