1726 lines
52 KiB
Rust
1726 lines
52 KiB
Rust
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<AppState> {
|
|
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<Uuid, AppError> {
|
|
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<Uuid> = 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<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<AdminStats>>, 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<u32>,
|
|
limit: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UserQuery {
|
|
page: Option<u32>,
|
|
limit: Option<u32>,
|
|
search: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, FromRow, Serialize)]
|
|
struct AdminUserRow {
|
|
id: Uuid,
|
|
email: String,
|
|
username: String,
|
|
role: String,
|
|
is_active: bool,
|
|
email_verified_at: Option<DateTime<Utc>>,
|
|
rate_limit_override: Option<i32>,
|
|
storage_limit_mb: Option<i32>,
|
|
created_at: DateTime<Utc>,
|
|
subscription_status: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AdminUserView {
|
|
id: Uuid,
|
|
email: String,
|
|
username: String,
|
|
role: String,
|
|
is_active: bool,
|
|
email_verified: bool,
|
|
rate_limit_override: Option<i32>,
|
|
storage_limit_mb: Option<i32>,
|
|
created_at: DateTime<Utc>,
|
|
subscription_status: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AdminUsersResponse {
|
|
users: Vec<AdminUserView>,
|
|
page: u32,
|
|
limit: u32,
|
|
total: i64,
|
|
}
|
|
|
|
async fn list_users(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<UserQuery>,
|
|
) -> Result<Json<Envelope<AdminUsersResponse>>, 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<AdminUserRow> = 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<u32>,
|
|
limit: Option<u32>,
|
|
status: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, FromRow, Serialize)]
|
|
struct AdminTaskRow {
|
|
id: Uuid,
|
|
status: String,
|
|
source: String,
|
|
total_files: i32,
|
|
completed_files: i32,
|
|
failed_files: i32,
|
|
error_message: Option<String>,
|
|
created_at: DateTime<Utc>,
|
|
completed_at: Option<DateTime<Utc>>,
|
|
expires_at: DateTime<Utc>,
|
|
user_id: Option<Uuid>,
|
|
user_email: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AdminTasksResponse {
|
|
tasks: Vec<AdminTaskRow>,
|
|
page: u32,
|
|
limit: u32,
|
|
total: i64,
|
|
}
|
|
|
|
async fn list_tasks(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<TaskQuery>,
|
|
) -> Result<Json<Envelope<AdminTasksResponse>>, 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<AdminTaskRow> = 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<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Path(task_id): Path<Uuid>,
|
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
|
|
|
let status: Option<String> = 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<Utc>,
|
|
current_period_end: DateTime<Utc>,
|
|
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<AdminSubscriptionRow>,
|
|
page: u32,
|
|
limit: u32,
|
|
total: i64,
|
|
}
|
|
|
|
async fn list_subscriptions(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<PagingQuery>,
|
|
) -> Result<Json<Envelope<AdminSubscriptionsResponse>>, 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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct GrantCreditsResponse {
|
|
message: String,
|
|
period_start: DateTime<Utc>,
|
|
period_end: DateTime<Utc>,
|
|
used_units: i32,
|
|
bonus_units: i32,
|
|
redeemed_units: i64,
|
|
total_units: i64,
|
|
remaining_units: i64,
|
|
}
|
|
|
|
async fn grant_credits(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<GrantCreditsRequest>,
|
|
) -> Result<Json<Envelope<GrantCreditsResponse>>, 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<Utc>,
|
|
current_period_end: DateTime<Utc>,
|
|
}
|
|
|
|
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<i32>,
|
|
note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ManualSubscriptionResponse {
|
|
message: String,
|
|
subscription_id: Uuid,
|
|
user_id: Uuid,
|
|
plan_id: Uuid,
|
|
plan_name: String,
|
|
period_start: DateTime<Utc>,
|
|
period_end: DateTime<Utc>,
|
|
status: String,
|
|
}
|
|
|
|
async fn create_manual_subscription(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ManualSubscriptionRequest>,
|
|
) -> Result<Json<Envelope<ManualSubscriptionResponse>>, 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<Utc>, months: i32) -> Result<DateTime<Utc>, 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<String>,
|
|
stripe_price_id: Option<String>,
|
|
is_active: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AdminPlansResponse {
|
|
plans: Vec<AdminPlanRow>,
|
|
}
|
|
|
|
async fn list_plans(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<AdminPlansResponse>>, 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<String>,
|
|
stripe_price_id: Option<String>,
|
|
is_active: Option<bool>,
|
|
}
|
|
|
|
async fn update_plan(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Path(plan_id): Path<Uuid>,
|
|
Json(req): Json<UpdatePlanRequest>,
|
|
) -> Result<Json<Envelope<AdminPlanRow>>, 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<String>,
|
|
}
|
|
|
|
async fn get_stripe_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<StripeConfigView>>, 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::<StripeConfigStored>(&state, "stripe").await?;
|
|
let (secret_key_configured, webhook_secret_configured, secret_key_prefix) =
|
|
if let Some(cfg) = stored {
|
|
(
|
|
cfg.secret_key_encrypted.as_ref().is_some(),
|
|
cfg.webhook_secret_encrypted.as_ref().is_some(),
|
|
cfg.secret_key_prefix,
|
|
)
|
|
} else {
|
|
let env_secret = state
|
|
.config
|
|
.stripe_secret_key
|
|
.as_ref()
|
|
.filter(|v| !v.trim().is_empty());
|
|
let env_webhook = state
|
|
.config
|
|
.stripe_webhook_secret
|
|
.as_ref()
|
|
.filter(|v| !v.trim().is_empty());
|
|
(
|
|
env_secret.is_some(),
|
|
env_webhook.is_some(),
|
|
env_secret.map(|value| mask_secret(value)),
|
|
)
|
|
};
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: StripeConfigView {
|
|
secret_key_configured,
|
|
webhook_secret_configured,
|
|
secret_key_prefix,
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct StripeConfigRequest {
|
|
secret_key: Option<String>,
|
|
webhook_secret: Option<String>,
|
|
}
|
|
|
|
async fn update_stripe_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<StripeConfigRequest>,
|
|
) -> Result<Json<Envelope<StripeConfigView>>, 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::<StripeConfigStored>(&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<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<AuthConfigView>>, 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::<AuthConfigStored>(&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<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<AuthConfigRequest>,
|
|
) -> Result<Json<Envelope<AuthConfigView>>, 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<MailCustomSmtp>,
|
|
password_configured: bool,
|
|
log_links_when_disabled: bool,
|
|
}
|
|
|
|
async fn get_mail_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<MailConfigView>>, 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::<MailConfigStored>(&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<String>,
|
|
custom_smtp: Option<MailCustomSmtp>,
|
|
log_links_when_disabled: Option<bool>,
|
|
}
|
|
|
|
async fn update_mail_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<MailConfigRequest>,
|
|
) -> Result<Json<Envelope<MailConfigView>>, 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::<MailConfigStored>(&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<String>,
|
|
}
|
|
|
|
async fn test_mail(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<MailTestRequest>,
|
|
) -> Result<Json<Envelope<MessageResponse>>, 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::<String>())
|
|
}
|
|
|
|
#[derive(Debug, FromRow, Serialize)]
|
|
struct ConfigRow {
|
|
key: String,
|
|
value: serde_json::Value,
|
|
description: Option<String>,
|
|
updated_at: DateTime<Utc>,
|
|
updated_by: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ConfigResponse {
|
|
configs: Vec<ConfigRow>,
|
|
}
|
|
|
|
async fn get_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<ConfigResponse>>, 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<String>,
|
|
}
|
|
|
|
async fn update_config(
|
|
State(state): State<AppState>,
|
|
jar: axum_extra::extract::cookie::CookieJar,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<UpdateConfigRequest>,
|
|
) -> Result<Json<Envelope<ConfigRow>>, 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"), "🔑🔑🔑🔑🔑🔑🔑🔑...");
|
|
}
|
|
}
|