Files
ystp/src/api/admin.rs
237899745 64b1169e8c
Some checks failed
CI / verify (push) Has been cancelled
feat: add runtime policy and observability
2026-07-25 20:00:11 +08:00

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"), "🔑🔑🔑🔑🔑🔑🔑🔑...");
}
}