use crate::api::context; use crate::api::envelope::Envelope; use crate::error::{AppError, ErrorCode}; use crate::services::billing; use crate::services::mail; use crate::services::settings; use crate::services::settings::{ AuthConfigStored, MailConfigStored, MailCustomSmtp, StripeConfigStored, }; use crate::state::AppState; use axum::extract::{ConnectInfo, Path, Query, State}; use axum::http::HeaderMap; use axum::routing::{get, post, put}; use axum::{Json, Router}; use chrono::{DateTime, Datelike, Duration, FixedOffset, TimeZone, Timelike, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use std::net::{IpAddr, SocketAddr}; use uuid::Uuid; pub fn router() -> Router { Router::new() .route("/admin/stats", get(get_stats)) .route("/admin/users", get(list_users)) .route("/admin/tasks", get(list_tasks)) .route("/admin/tasks/{task_id}/cancel", post(cancel_task)) .route("/admin/billing/subscriptions", get(list_subscriptions)) .route( "/admin/billing/subscriptions/manual", post(create_manual_subscription), ) .route("/admin/billing/credits", post(grant_credits)) .route("/admin/plans", get(list_plans)) .route("/admin/plans/{plan_id}", put(update_plan)) .route("/admin/stripe", get(get_stripe_config)) .route("/admin/stripe", put(update_stripe_config)) .route("/admin/mail", get(get_mail_config)) .route("/admin/mail", put(update_mail_config)) .route("/admin/mail/test", post(test_mail)) .route("/admin/auth", get(get_auth_config)) .route("/admin/auth", put(update_auth_config)) .route("/admin/config", get(get_config)) .route("/admin/config", put(update_config)) } pub(super) async fn require_admin( state: &AppState, jar: axum_extra::extract::cookie::CookieJar, headers: &HeaderMap, ip: IpAddr, ) -> Result<(axum_extra::extract::cookie::CookieJar, Uuid), AppError> { let (jar, principal) = context::authenticate(state, jar, headers, ip).await?; match principal { context::Principal::User { user_id, role, .. } => { if role == "admin" { Ok((jar, user_id)) } else { Err(AppError::new(ErrorCode::Forbidden, "需要管理员权限")) } } _ => Err(AppError::new(ErrorCode::Unauthorized, "未登录")), } } async fn resolve_user_id(state: &AppState, identifier: &str) -> Result { let identifier = identifier.trim(); if identifier.is_empty() { return Err(AppError::new(ErrorCode::InvalidRequest, "用户 ID 不能为空")); } if let Ok(uuid) = Uuid::parse_str(identifier) { return sqlx::query_scalar("SELECT id FROM users WHERE id = $1") .bind(uuid) .fetch_optional(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? .ok_or_else(|| AppError::new(ErrorCode::NotFound, "用户不存在")); } let needle = identifier.to_lowercase(); let mut ids: Vec = sqlx::query_scalar( r#" SELECT id FROM users WHERE lower(email) = $1 OR lower(username) = $1 LIMIT 2 "#, ) .bind(&needle) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; if ids.len() > 1 { return Err(AppError::new(ErrorCode::InvalidRequest, "用户标识不唯一")); } if ids.is_empty() && identifier.len() >= 8 { let like = format!("{needle}%"); ids = sqlx::query_scalar( r#" SELECT id FROM users WHERE lower(id::text) LIKE $1 LIMIT 2 "#, ) .bind(&like) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?; if ids.len() > 1 { return Err(AppError::new( ErrorCode::InvalidRequest, "用户 ID 前缀不唯一", )); } } ids.pop() .ok_or_else(|| AppError::new(ErrorCode::NotFound, "用户不存在")) } #[derive(Debug, Serialize)] struct AdminStats { total_users: i64, active_users: i64, pending_tasks: i64, processing_tasks: i64, failed_tasks: i64, completed_tasks: i64, usage_events_24h: i64, active_subscriptions: i64, } async fn get_stats( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let total_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户统计失败").with_source(err))?; let active_users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_active = true") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户统计失败").with_source(err))?; #[derive(Debug, FromRow)] struct TaskStatsRow { pending: i64, processing: i64, failed: i64, completed: i64, } let task_stats = sqlx::query_as::<_, TaskStatsRow>( r#" SELECT COUNT(*) FILTER (WHERE status = 'pending') AS pending, COUNT(*) FILTER (WHERE status = 'processing') AS processing, COUNT(*) FILTER (WHERE status = 'failed') AS failed, COUNT(*) FILTER (WHERE status = 'completed') AS completed FROM tasks "#, ) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务统计失败").with_source(err))?; let usage_events_24h: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM usage_events WHERE occurred_at > NOW() - INTERVAL '24 hours'", ) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?; let active_subscriptions: i64 = sqlx::query_scalar( r#" SELECT COUNT(*) FROM subscriptions WHERE status IN ('active', 'trialing', 'past_due') AND current_period_start <= NOW() AND current_period_end > NOW() "#, ) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅统计失败").with_source(err))?; Ok(Json(Envelope { success: true, data: AdminStats { total_users, active_users, pending_tasks: task_stats.pending, processing_tasks: task_stats.processing, failed_tasks: task_stats.failed, completed_tasks: task_stats.completed, usage_events_24h, active_subscriptions, }, })) } #[derive(Debug, Deserialize)] struct PagingQuery { page: Option, limit: Option, } #[derive(Debug, Deserialize)] struct UserQuery { page: Option, limit: Option, search: Option, } #[derive(Debug, FromRow, Serialize)] struct AdminUserRow { id: Uuid, email: String, username: String, role: String, is_active: bool, email_verified_at: Option>, rate_limit_override: Option, storage_limit_mb: Option, created_at: DateTime, subscription_status: Option, } #[derive(Debug, Serialize)] struct AdminUserView { id: Uuid, email: String, username: String, role: String, is_active: bool, email_verified: bool, rate_limit_override: Option, storage_limit_mb: Option, created_at: DateTime, subscription_status: Option, } #[derive(Debug, Serialize)] struct AdminUsersResponse { users: Vec, page: u32, limit: u32, total: i64, } async fn list_users( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(query): Query, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let page = query.page.unwrap_or(1).max(1); let offset = (page - 1) * limit; let search = query .search .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let total: i64 = if let Some(search) = &search { let keyword = format!("%{}%", search); sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email ILIKE $1 OR username ILIKE $1") .bind(keyword) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? } else { sqlx::query_scalar("SELECT COUNT(*) FROM users") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? }; let users: Vec = if let Some(search) = &search { let keyword = format!("%{}%", search); sqlx::query_as::<_, AdminUserRow>( r#" SELECT u.id, u.email, u.username, u.role::text AS role, u.is_active, u.email_verified_at, u.rate_limit_override, u.storage_limit_mb, u.created_at, s.status::text AS subscription_status FROM users u LEFT JOIN LATERAL ( SELECT status FROM subscriptions WHERE user_id = u.id ORDER BY current_period_end DESC LIMIT 1 ) s ON true WHERE u.email ILIKE $1 OR u.username ILIKE $1 ORDER BY u.created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(keyword) .bind(limit as i64) .bind(offset as i64) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? } else { sqlx::query_as::<_, AdminUserRow>( r#" SELECT u.id, u.email, u.username, u.role::text AS role, u.is_active, u.email_verified_at, u.rate_limit_override, u.storage_limit_mb, u.created_at, s.status::text AS subscription_status FROM users u LEFT JOIN LATERAL ( SELECT status FROM subscriptions WHERE user_id = u.id ORDER BY current_period_end DESC LIMIT 1 ) s ON true ORDER BY u.created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(limit as i64) .bind(offset as i64) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))? }; let views = users .into_iter() .map(|row| AdminUserView { id: row.id, email: row.email, username: row.username, role: row.role, is_active: row.is_active, email_verified: row.email_verified_at.is_some(), rate_limit_override: row.rate_limit_override, storage_limit_mb: row.storage_limit_mb, created_at: row.created_at, subscription_status: row.subscription_status, }) .collect(); Ok(Json(Envelope { success: true, data: AdminUsersResponse { users: views, page, limit, total, }, })) } #[derive(Debug, Deserialize)] struct TaskQuery { page: Option, limit: Option, status: Option, } #[derive(Debug, FromRow, Serialize)] struct AdminTaskRow { id: Uuid, status: String, source: String, total_files: i32, completed_files: i32, failed_files: i32, error_message: Option, created_at: DateTime, completed_at: Option>, expires_at: DateTime, user_id: Option, user_email: Option, } #[derive(Debug, Serialize)] struct AdminTasksResponse { tasks: Vec, page: u32, limit: u32, total: i64, } async fn list_tasks( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(query): Query, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let page = query.page.unwrap_or(1).max(1); let offset = (page - 1) * limit; let status = query .status .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let total: i64 = if let Some(status) = &status { sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status::text = $1") .bind(status) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? } else { sqlx::query_scalar("SELECT COUNT(*) FROM tasks") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? }; let tasks: Vec = if let Some(status) = &status { sqlx::query_as::<_, AdminTaskRow>( r#" SELECT t.id, t.status::text AS status, t.source::text AS source, t.total_files, t.completed_files, t.failed_files, t.error_message, t.created_at, t.completed_at, t.expires_at, u.id AS user_id, u.email AS user_email FROM tasks t LEFT JOIN users u ON u.id = t.user_id WHERE t.status::text = $1 ORDER BY t.created_at DESC LIMIT $2 OFFSET $3 "#, ) .bind(status) .bind(limit as i64) .bind(offset as i64) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? } else { sqlx::query_as::<_, AdminTaskRow>( r#" SELECT t.id, t.status::text AS status, t.source::text AS source, t.total_files, t.completed_files, t.failed_files, t.error_message, t.created_at, t.completed_at, t.expires_at, u.id AS user_id, u.email AS user_email FROM tasks t LEFT JOIN users u ON u.id = t.user_id ORDER BY t.created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(limit as i64) .bind(offset as i64) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))? }; Ok(Json(Envelope { success: true, data: AdminTasksResponse { tasks, page, limit, total, }, })) } #[derive(Debug, Serialize)] struct MessageResponse { message: String, } async fn cancel_task( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(task_id): Path, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let status: Option = sqlx::query_scalar("SELECT status::text FROM tasks WHERE id = $1") .bind(task_id) .fetch_optional(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务失败").with_source(err))?; let Some(status) = status else { return Err(AppError::new(ErrorCode::NotFound, "任务不存在")); }; if matches!(status.as_str(), "completed" | "failed" | "cancelled") { return Ok(Json(Envelope { success: true, data: MessageResponse { message: "任务已结束,无需取消".to_string(), }, })); } let mut tx = state .db .begin() .await .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; sqlx::query( r#" UPDATE task_files SET status = 'failed', error_message = '任务已取消', completed_at = NOW() WHERE task_id = $1 AND status IN ('pending', 'processing') "#, ) .bind(task_id) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务文件失败").with_source(err))?; let updated = sqlx::query( r#" UPDATE tasks SET status = 'cancelled', error_message = '管理员取消任务', completed_at = NOW(), completed_files = (SELECT COUNT(*) FROM task_files WHERE task_id = $1 AND status = 'completed'), failed_files = (SELECT COUNT(*) FROM task_files WHERE task_id = $1 AND status = 'failed') WHERE id = $1 AND status IN ('pending', 'processing') "#, ) .bind(task_id) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新任务状态失败").with_source(err))?; if updated.rows_affected() == 0 { return Err(AppError::new(ErrorCode::InvalidRequest, "任务状态无法取消")); } tx.commit() .await .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; Ok(Json(Envelope { success: true, data: MessageResponse { message: "任务已取消".to_string(), }, })) } #[derive(Debug, FromRow, Serialize)] struct AdminSubscriptionRow { id: Uuid, status: String, current_period_start: DateTime, current_period_end: DateTime, cancel_at_period_end: bool, plan_name: String, plan_code: String, currency: String, amount_cents: i32, interval: String, user_id: Uuid, user_email: String, } #[derive(Debug, Serialize)] struct AdminSubscriptionsResponse { subscriptions: Vec, page: u32, limit: u32, total: i64, } async fn list_subscriptions( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(query): Query, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let limit = query.limit.unwrap_or(20).clamp(1, 100); let page = query.page.unwrap_or(1).max(1); let offset = (page - 1) * limit; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; let subscriptions = sqlx::query_as::<_, AdminSubscriptionRow>( r#" SELECT s.id, s.status::text AS status, s.current_period_start, s.current_period_end, s.cancel_at_period_end, p.name AS plan_name, p.code AS plan_code, p.currency, p.amount_cents, p.interval, u.id AS user_id, u.email AS user_email FROM subscriptions s JOIN plans p ON p.id = s.plan_id JOIN users u ON u.id = s.user_id ORDER BY s.created_at DESC LIMIT $1 OFFSET $2 "#, ) .bind(limit as i64) .bind(offset as i64) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询订阅失败").with_source(err))?; Ok(Json(Envelope { success: true, data: AdminSubscriptionsResponse { subscriptions, page, limit, total, }, })) } #[derive(Debug, Deserialize)] struct GrantCreditsRequest { user_id: String, units: i32, note: Option, } #[derive(Debug, Serialize)] struct GrantCreditsResponse { message: String, period_start: DateTime, period_end: DateTime, used_units: i32, bonus_units: i32, redeemed_units: i64, total_units: i64, remaining_units: i64, } async fn grant_credits( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; if req.units <= 0 { return Err(AppError::new(ErrorCode::InvalidRequest, "units 必须大于 0")); } let user_id = resolve_user_id(&state, &req.user_id).await?; #[derive(Debug, FromRow)] struct SubRow { id: Uuid, plan_id: Uuid, current_period_start: DateTime, current_period_end: DateTime, } let sub = sqlx::query_as::<_, SubRow>( r#" SELECT id, plan_id, current_period_start, current_period_end FROM subscriptions WHERE user_id = $1 AND status IN ('active', 'trialing') AND current_period_start <= NOW() AND current_period_end > NOW() 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) = sub { ( Some(sub.id), sub.current_period_start, sub.current_period_end, Some(sub.plan_id), ) } else { let (start, end) = billing::current_month_period_utc8(Utc::now()); (None, start, end, None) }; let plan_units: i32 = if let Some(plan_id) = plan_id { sqlx::query_scalar("SELECT included_units_per_period FROM plans WHERE id = $1") .bind(plan_id) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? } else { sqlx::query_scalar("SELECT included_units_per_period FROM plans WHERE code = 'free'") .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? }; let mut tx = state .db .begin() .await .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; sqlx::query( r#" INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, period_start, period_end) DO NOTHING "#, ) .bind(user_id) .bind(subscription_id) .bind(period_start) .bind(period_end) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; #[derive(Debug, FromRow)] struct UsageRow { used_units: i32, bonus_units: i32, grant_used_units: i32, } let usage = sqlx::query_as::<_, UsageRow>( r#" UPDATE usage_periods SET bonus_units = bonus_units + $1, updated_at = NOW() WHERE user_id = $2 AND period_start = $3 AND period_end = $4 RETURNING used_units, bonus_units, grant_used_units "#, ) .bind(req.units) .bind(user_id) .bind(period_start) .bind(period_end) .fetch_one(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新用量失败").with_source(err))?; sqlx::query( r#" INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address) VALUES ($1, 'billing_credit', 'user', $2, $3, $4::inet) "#, ) .bind(admin_id) .bind(user_id) .bind(serde_json::json!({ "units": req.units, "note": req.note })) .bind(ip.to_string()) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?; tx.commit() .await .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; let redeemed_units: i64 = sqlx::query_scalar( r#" SELECT COALESCE(SUM(remaining_units), 0)::bigint FROM unit_grants WHERE user_id = $1 AND starts_at <= NOW() AND expires_at > NOW() AND remaining_units > 0 "#, ) .bind(user_id) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?; let base_total = i64::from(plan_units.saturating_add(usage.bonus_units)); let base_used = i64::from(usage.used_units.saturating_sub(usage.grant_used_units)); let remaining = base_total .saturating_sub(base_used) .max(0) .saturating_add(redeemed_units); let total_units = i64::from(usage.used_units).saturating_add(remaining); Ok(Json(Envelope { success: true, data: GrantCreditsResponse { message: "额度已增加".to_string(), period_start, period_end, used_units: usage.used_units, bonus_units: usage.bonus_units, redeemed_units, total_units, remaining_units: remaining, }, })) } #[derive(Debug, Deserialize)] struct ManualSubscriptionRequest { user_id: String, plan_id: Uuid, months: Option, note: Option, } #[derive(Debug, Serialize)] struct ManualSubscriptionResponse { message: String, subscription_id: Uuid, user_id: Uuid, plan_id: Uuid, plan_name: String, period_start: DateTime, period_end: DateTime, status: String, } async fn create_manual_subscription( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; let months = req.months.unwrap_or(1); if months <= 0 || months > 24 { return Err(AppError::new( ErrorCode::InvalidRequest, "months 需在 1-24 之间", )); } let user_id = resolve_user_id(&state, &req.user_id).await?; #[derive(Debug, FromRow)] struct PlanRow { id: Uuid, name: String, is_active: bool, } let plan = sqlx::query_as::<_, PlanRow>( r#" SELECT id, name, is_active FROM plans WHERE id = $1 "#, ) .bind(req.plan_id) .fetch_optional(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))? .ok_or_else(|| AppError::new(ErrorCode::NotFound, "套餐不存在"))?; if !plan.is_active { return Err(AppError::new(ErrorCode::Forbidden, "套餐不可用")); } let period_start = Utc::now(); let period_end = add_months_utc8(period_start, months)?; let mut tx = state .db .begin() .await .map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?; let _ = sqlx::query( r#" UPDATE subscriptions SET status = 'canceled', cancel_at_period_end = false, canceled_at = NOW(), updated_at = NOW() WHERE user_id = $1 AND status IN ('active', 'trialing', 'past_due') "#, ) .bind(user_id) .execute(&mut *tx) .await; let subscription_id: Uuid = sqlx::query_scalar( r#" INSERT INTO subscriptions ( user_id, plan_id, status, current_period_start, current_period_end, cancel_at_period_end, provider, created_at, updated_at ) VALUES ( $1, $2, 'active', $3, $4, false, 'manual', NOW(), NOW() ) RETURNING id "#, ) .bind(user_id) .bind(plan.id) .bind(period_start) .bind(period_end) .fetch_one(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "创建订阅失败").with_source(err))?; sqlx::query( r#" INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, period_start, period_end) DO NOTHING "#, ) .bind(user_id) .bind(subscription_id) .bind(period_start) .bind(period_end) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?; sqlx::query( r#" INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address) VALUES ($1, 'manual_subscription', 'subscription', $2, $3, $4::inet) "#, ) .bind(admin_id) .bind(subscription_id) .bind(serde_json::json!({ "target_user_id": user_id, "plan_id": plan.id, "months": months, "note": req.note, })) .bind(ip.to_string()) .execute(&mut *tx) .await .map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?; tx.commit() .await .map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?; Ok(Json(Envelope { success: true, data: ManualSubscriptionResponse { message: "套餐已开通".to_string(), subscription_id, user_id, plan_id: plan.id, plan_name: plan.name, period_start, period_end, status: "active".to_string(), }, })) } fn add_months_utc8(start: DateTime, months: i32) -> Result, AppError> { let tz = FixedOffset::east_opt(8 * 3600).unwrap(); let local = start.with_timezone(&tz); let total_months = local.year() * 12 + (local.month() as i32 - 1) + months; let new_year = total_months / 12; let new_month = (total_months % 12) + 1; let max_day = days_in_month(tz, new_year, new_month as u32); let day = local.day().min(max_day); let next = tz .with_ymd_and_hms( new_year, new_month as u32, day, local.hour(), local.minute(), local.second(), ) .single() .ok_or_else(|| AppError::new(ErrorCode::Internal, "计算订阅周期失败"))?; Ok(next.with_timezone(&Utc)) } fn days_in_month(tz: FixedOffset, year: i32, month: u32) -> u32 { let (next_year, next_month) = if month == 12 { (year + 1, 1) } else { (year, month + 1) }; let first_next = tz .with_ymd_and_hms(next_year, next_month, 1, 0, 0, 0) .single() .unwrap(); let last = first_next - Duration::days(1); last.day() } #[derive(Debug, FromRow, Serialize)] struct AdminPlanRow { id: Uuid, code: String, name: String, currency: String, amount_cents: i32, interval: String, included_units_per_period: i32, max_file_size_mb: i32, max_files_per_batch: i32, retention_days: i32, stripe_product_id: Option, stripe_price_id: Option, is_active: bool, } #[derive(Debug, Serialize)] struct AdminPlansResponse { plans: Vec, } async fn list_plans( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let plans = sqlx::query_as::<_, AdminPlanRow>( r#" SELECT id, code, name, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, stripe_product_id, stripe_price_id, is_active FROM plans ORDER BY amount_cents ASC "#, ) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?; Ok(Json(Envelope { success: true, data: AdminPlansResponse { plans }, })) } #[derive(Debug, Deserialize)] struct UpdatePlanRequest { stripe_product_id: Option, stripe_price_id: Option, is_active: Option, } async fn update_plan( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(plan_id): Path, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; if req.stripe_product_id.is_none() && req.stripe_price_id.is_none() && req.is_active.is_none() { return Err(AppError::new(ErrorCode::InvalidRequest, "未提供更新字段")); } let row = sqlx::query_as::<_, AdminPlanRow>( r#" UPDATE plans SET stripe_product_id = COALESCE($2, stripe_product_id), stripe_price_id = COALESCE($3, stripe_price_id), is_active = COALESCE($4, is_active), updated_at = NOW() WHERE id = $1 RETURNING id, code, name, currency, amount_cents, interval, included_units_per_period, max_file_size_mb, max_files_per_batch, retention_days, stripe_product_id, stripe_price_id, is_active "#, ) .bind(plan_id) .bind(req.stripe_product_id.filter(|v| !v.trim().is_empty())) .bind(req.stripe_price_id.filter(|v| !v.trim().is_empty())) .bind(req.is_active) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新套餐失败").with_source(err))?; Ok(Json(Envelope { success: true, data: row, })) } #[derive(Debug, Serialize)] struct StripeConfigView { secret_key_configured: bool, webhook_secret_configured: bool, secret_key_prefix: Option, } async fn get_stripe_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let stored = settings::load_system_config::(&state, "stripe").await?; let (secret_key_configured, webhook_secret_configured, secret_key_prefix) = if let Some(cfg) = stored { ( cfg.secret_key_encrypted.as_ref().is_some(), cfg.webhook_secret_encrypted.as_ref().is_some(), cfg.secret_key_prefix, ) } else { let env_secret = state .config .stripe_secret_key .as_ref() .filter(|v| !v.trim().is_empty()); let env_webhook = state .config .stripe_webhook_secret .as_ref() .filter(|v| !v.trim().is_empty()); ( env_secret.is_some(), env_webhook.is_some(), env_secret.map(|value| mask_secret(value)), ) }; Ok(Json(Envelope { success: true, data: StripeConfigView { secret_key_configured, webhook_secret_configured, secret_key_prefix, }, })) } #[derive(Debug, Deserialize)] struct StripeConfigRequest { secret_key: Option, webhook_secret: Option, } async fn update_stripe_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; let mut stored = settings::load_system_config::(&state, "stripe") .await? .unwrap_or(StripeConfigStored { secret_key_encrypted: None, webhook_secret_encrypted: None, secret_key_prefix: None, }); if let Some(secret_key) = req.secret_key.as_ref().map(|v| v.trim().to_string()) { if secret_key.is_empty() { stored.secret_key_encrypted = None; stored.secret_key_prefix = None; } else { stored.secret_key_encrypted = Some(settings::encrypt_secret(&state, &secret_key)?); stored.secret_key_prefix = Some(mask_secret(&secret_key)); } } if let Some(webhook_secret) = req.webhook_secret.as_ref().map(|v| v.trim().to_string()) { if webhook_secret.is_empty() { stored.webhook_secret_encrypted = None; } else { stored.webhook_secret_encrypted = Some(settings::encrypt_secret(&state, &webhook_secret)?); } } settings::upsert_system_config( &state, "stripe", serde_json::to_value(&stored).map_err(|err| { AppError::new(ErrorCode::Internal, "序列化 Stripe 配置失败").with_source(err) })?, Some("Stripe 支付配置"), Some(admin_id), ) .await?; audit_config_action(&state, admin_id, "stripe", ip).await?; Ok(Json(Envelope { success: true, data: StripeConfigView { secret_key_configured: stored.secret_key_encrypted.is_some(), webhook_secret_configured: stored.webhook_secret_encrypted.is_some(), secret_key_prefix: stored.secret_key_prefix, }, })) } #[derive(Debug, Serialize)] struct AuthConfigView { email_verification_required: bool, } async fn get_auth_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let config = settings::load_system_config::(&state, "auth") .await? .unwrap_or(AuthConfigStored { email_verification_required: true, }); Ok(Json(Envelope { success: true, data: AuthConfigView { email_verification_required: config.email_verification_required, }, })) } #[derive(Debug, Deserialize)] struct AuthConfigRequest { email_verification_required: bool, } async fn update_auth_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; let config = AuthConfigStored { email_verification_required: req.email_verification_required, }; settings::upsert_system_config( &state, "auth", serde_json::to_value(&config).map_err(|err| { AppError::new(ErrorCode::Internal, "序列化认证配置失败").with_source(err) })?, Some("认证功能开关"), Some(admin_id), ) .await?; audit_config_action(&state, admin_id, "auth", ip).await?; Ok(Json(Envelope { success: true, data: AuthConfigView { email_verification_required: config.email_verification_required, }, })) } #[derive(Debug, Serialize)] struct MailConfigView { enabled: bool, provider: String, from: String, from_name: String, custom_smtp: Option, password_configured: bool, log_links_when_disabled: bool, } async fn get_mail_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let stored = settings::load_system_config::(&state, "mail").await?; if let Some(cfg) = stored { return Ok(Json(Envelope { success: true, data: MailConfigView { enabled: cfg.enabled, provider: cfg.provider, from: cfg.from, from_name: cfg.from_name, custom_smtp: cfg.custom_smtp, password_configured: cfg.password_encrypted.is_some(), log_links_when_disabled: cfg.log_links_when_disabled.unwrap_or(false), }, })); } let custom_smtp = if state.config.mail_provider.eq_ignore_ascii_case("custom") { Some(MailCustomSmtp { host: state.config.mail_smtp_host.clone().unwrap_or_default(), port: state.config.mail_smtp_port.unwrap_or(465), encryption: state .config .mail_smtp_encryption .clone() .unwrap_or_else(|| "ssl".to_string()), }) } else { None }; Ok(Json(Envelope { success: true, data: MailConfigView { enabled: state.config.mail_enabled, provider: state.config.mail_provider.clone(), from: state.config.mail_from.clone(), from_name: state.config.mail_from_name.clone(), custom_smtp, password_configured: !state.config.mail_password.trim().is_empty(), log_links_when_disabled: state.config.mail_log_links_when_disabled, }, })) } #[derive(Debug, Deserialize)] struct MailConfigRequest { enabled: bool, provider: String, from: String, from_name: String, password: Option, custom_smtp: Option, log_links_when_disabled: Option, } async fn update_mail_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; if req.provider.trim().is_empty() || req.from.trim().is_empty() { return Err(AppError::new(ErrorCode::InvalidRequest, "邮件配置不能为空")); } if req.provider.eq_ignore_ascii_case("custom") { let custom = req.custom_smtp.as_ref().ok_or_else(|| { AppError::new( ErrorCode::InvalidRequest, "自定义 SMTP 需要填写 host/port/encryption", ) })?; if custom.host.trim().is_empty() { return Err(AppError::new( ErrorCode::InvalidRequest, "SMTP host 不能为空", )); } } let mut stored = settings::load_system_config::(&state, "mail") .await? .unwrap_or(MailConfigStored { enabled: req.enabled, provider: req.provider.trim().to_string(), from: req.from.trim().to_string(), from_name: req.from_name.trim().to_string(), password_encrypted: None, custom_smtp: req.custom_smtp.clone(), log_links_when_disabled: req.log_links_when_disabled, }); stored.enabled = req.enabled; stored.provider = req.provider.trim().to_string(); stored.from = req.from.trim().to_string(); stored.from_name = req.from_name.trim().to_string(); stored.custom_smtp = req.custom_smtp.clone(); stored.log_links_when_disabled = req.log_links_when_disabled; if let Some(password) = req.password.as_ref().map(|v| v.trim().to_string()) { if password.is_empty() { stored.password_encrypted = None; } else { stored.password_encrypted = Some(settings::encrypt_secret(&state, &password)?); } } if stored.enabled && stored.password_encrypted.is_none() { return Err(AppError::new( ErrorCode::InvalidRequest, "邮件服务已启用但未配置授权码/密码", )); } settings::upsert_system_config( &state, "mail", serde_json::to_value(&stored).map_err(|err| { AppError::new(ErrorCode::Internal, "序列化邮件配置失败").with_source(err) })?, Some("邮件服务配置"), Some(admin_id), ) .await?; audit_config_action(&state, admin_id, "mail", ip).await?; Ok(Json(Envelope { success: true, data: MailConfigView { enabled: stored.enabled, provider: stored.provider, from: stored.from, from_name: stored.from_name, custom_smtp: stored.custom_smtp, password_configured: stored.password_encrypted.is_some(), log_links_when_disabled: stored.log_links_when_disabled.unwrap_or(false), }, })) } #[derive(Debug, Deserialize)] struct MailTestRequest { to: Option, } async fn test_mail( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; let to = req .to .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); let recipient = if let Some(to) = to { to } else { let email: String = sqlx::query_scalar("SELECT email FROM users WHERE id = $1") .bind(admin_id) .fetch_one(&state.db) .await .map_err(|err| { AppError::new(ErrorCode::Internal, "查询管理员邮箱失败").with_source(err) })?; email }; mail::send_test_email(&state, &recipient) .await .map_err(|err| { AppError::new(ErrorCode::MailSendFailed, "测试邮件发送失败").with_source(err) })?; Ok(Json(Envelope { success: true, data: MessageResponse { message: format!("测试邮件已发送至 {recipient}"), }, })) } fn mask_secret(secret: &str) -> String { let trimmed = secret.trim(); if trimmed.chars().count() <= 8 { return trimmed.to_string(); } format!("{}...", trimmed.chars().take(8).collect::()) } #[derive(Debug, FromRow, Serialize)] struct ConfigRow { key: String, value: serde_json::Value, description: Option, updated_at: DateTime, updated_by: Option, } #[derive(Debug, Serialize)] struct ConfigResponse { configs: Vec, } async fn get_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?; let configs = sqlx::query_as::<_, ConfigRow>( r#" SELECT key, value, description, updated_at, updated_by FROM system_config ORDER BY key ASC "#, ) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询配置失败").with_source(err))?; Ok(Json(Envelope { success: true, data: ConfigResponse { configs }, })) } #[derive(Debug, Deserialize)] struct UpdateConfigRequest { key: String, value: serde_json::Value, description: Option, } async fn update_config( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, AppError> { let ip = context::client_ip(&headers, addr.ip()); let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?; let key = req.key.trim(); if key.is_empty() { return Err(AppError::new(ErrorCode::InvalidRequest, "key 不能为空")); } if key.len() > 100 { return Err(AppError::new(ErrorCode::InvalidRequest, "key 过长")); } settings::validate_runtime_config_value(key, &req.value)?; let row = sqlx::query_as::<_, ConfigRow>( 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 RETURNING key, value, description, updated_at, updated_by "#, ) .bind(key) .bind(req.value) .bind(req.description) .bind(admin_id) .fetch_one(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "更新配置失败").with_source(err))?; if matches!(key, "auth" | "features" | "rate_limits" | "file_limits") { state.runtime_policy_cache.invalidate().await; } audit_config_action(&state, admin_id, key, ip).await?; Ok(Json(Envelope { success: true, data: row, })) } async fn audit_config_action( state: &AppState, admin_id: Uuid, key: &str, ip: IpAddr, ) -> Result<(), AppError> { sqlx::query( r#" INSERT INTO audit_logs (user_id, action, resource_type, details, ip_address) VALUES ($1, 'system_config_update', 'system_config', $2, $3::inet) "#, ) .bind(admin_id) .bind(serde_json::json!({ "key": key })) .bind(ip.to_string()) .execute(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "写入配置审计日志失败").with_source(err))?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn secret_masking_never_splits_utf8() { assert_eq!(mask_secret("12345678abcdef"), "12345678..."); assert_eq!(mask_secret("中文密钥测试内容"), "中文密钥测试内容"); assert_eq!(mask_secret("🔑🔑🔑🔑🔑🔑🔑🔑more"), "🔑🔑🔑🔑🔑🔑🔑🔑..."); } }