Merge branch 'pr-503'

# Conflicts:
#	apps/aether-gateway/src/handlers/admin/provider/summary/value.rs
#	apps/aether-gateway/src/lib.rs
#	apps/aether-gateway/src/maintenance/mod.rs
#	apps/aether-gateway/src/maintenance/runtime/workers.rs
#	frontend/src/api/endpoints/types/provider.ts
This commit is contained in:
fawney19
2026-05-22 00:19:23 +08:00
36 changed files with 2595 additions and 604 deletions

View File

@@ -7,10 +7,11 @@ pub(crate) use crate::handlers::admin::{
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh,
reconcile_admin_fixed_provider_template_endpoints,
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
update_existing_provider_oauth_catalog_key, AdminAppState,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
store_admin_provider_ops_balance_cache, update_existing_provider_oauth_catalog_key,
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
AdminStatsTimeRange, AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX,
OAUTH_REQUEST_FAILED_PREFIX,
};
use crate::handlers::admin::{

View File

@@ -103,6 +103,16 @@ pub(super) fn classify_admin_system_family_route(
"admin:system",
false,
))
} else if method == http::Method::POST
&& normalized_path == "/api/admin/system/important-notification/test"
{
Some(classified(
"admin_proxy",
"system_manage",
"important_notification_test",
"admin:system",
false,
))
} else if method == http::Method::POST && normalized_path == "/api/admin/system/cleanup" {
Some(classified(
"admin_proxy",

View File

@@ -183,6 +183,10 @@ fn classifies_admin_system_maintenance_write_routes_as_admin_proxy_route() {
("/api/admin/system/users/import", "users_import"),
("/api/admin/system/data/import", "data_import"),
("/api/admin/system/smtp/test", "smtp_test"),
(
"/api/admin/system/important-notification/test",
"important_notification_test",
),
("/api/admin/system/cleanup", "cleanup"),
("/api/admin/system/purge/config", "purge_config"),
("/api/admin/system/purge/users", "purge_users"),

View File

@@ -0,0 +1,381 @@
use base64::Engine;
use crate::handlers::shared::{
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
};
use crate::{AppState, GatewayError};
const SMTP_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub(crate) struct SmtpDeliveryConfig {
pub(crate) host: String,
pub(crate) port: u16,
pub(crate) user: Option<String>,
pub(crate) password: Option<String>,
pub(crate) use_tls: bool,
pub(crate) use_ssl: bool,
pub(crate) from_email: String,
pub(crate) from_name: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ComposedEmail {
pub(crate) to_email: String,
pub(crate) subject: String,
pub(crate) html_body: String,
pub(crate) text_body: String,
}
pub(crate) async fn read_smtp_delivery_config(
state: &AppState,
) -> Result<Option<SmtpDeliveryConfig>, GatewayError> {
let smtp_host = state.read_system_config_json_value("smtp_host").await?;
let smtp_from_email = state
.read_system_config_json_value("smtp_from_email")
.await?;
let Some(host) = system_config_string(smtp_host.as_ref()) else {
return Ok(None);
};
let Some(from_email) = system_config_string(smtp_from_email.as_ref()) else {
return Ok(None);
};
let smtp_port = state.read_system_config_json_value("smtp_port").await?;
let smtp_user = state.read_system_config_json_value("smtp_user").await?;
let smtp_password = state.read_system_config_json_value("smtp_password").await?;
let smtp_use_tls = state.read_system_config_json_value("smtp_use_tls").await?;
let smtp_use_ssl = state.read_system_config_json_value("smtp_use_ssl").await?;
let smtp_from_name = state
.read_system_config_json_value("smtp_from_name")
.await?;
let password = system_config_string(smtp_password.as_ref()).map(|value| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
});
Ok(Some(SmtpDeliveryConfig {
host,
port: system_config_u16(smtp_port.as_ref(), 587),
user: system_config_string(smtp_user.as_ref()),
password,
use_tls: system_config_bool(smtp_use_tls.as_ref(), true),
use_ssl: system_config_bool(smtp_use_ssl.as_ref(), false),
from_email,
from_name: system_config_string(smtp_from_name.as_ref())
.unwrap_or_else(|| "Aether".to_string()),
}))
}
pub(crate) async fn send_smtp_email(
config: SmtpDeliveryConfig,
email: ComposedEmail,
) -> Result<(), GatewayError> {
tokio::task::spawn_blocking(move || send_smtp_email_blocking(config, email))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
}
pub(crate) async fn probe_smtp_connection(config: SmtpDeliveryConfig) -> Result<(), GatewayError> {
tokio::task::spawn_blocking(move || probe_smtp_connection_blocking(config))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
}
pub(crate) fn system_config_u16(value: Option<&serde_json::Value>, default: u16) -> u16 {
match value {
Some(serde_json::Value::Number(value)) => value
.as_u64()
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(default),
Some(serde_json::Value::String(value)) => value.trim().parse::<u16>().unwrap_or(default),
_ => default,
}
}
fn encode_mime_header(value: &str) -> String {
if value.is_ascii() {
return value.to_string();
}
format!(
"=?UTF-8?B?{}?=",
base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
)
}
fn wrap_base64(value: &str) -> String {
let mut wrapped = String::new();
for chunk in value.as_bytes().chunks(76) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push_str("\r\n");
}
wrapped
}
fn build_tls_config() -> std::sync::Arc<rustls::ClientConfig> {
let _ = rustls::crypto::ring::default_provider().install_default();
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
std::sync::Arc::new(config)
}
fn resolve_server_name(host: &str) -> Result<rustls::pki_types::ServerName<'static>, GatewayError> {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(rustls::pki_types::ServerName::from(ip));
}
rustls::pki_types::ServerName::try_from(host.to_string())
.map_err(|err| GatewayError::Internal(err.to_string()))
}
fn connect_tcp_stream(config: &SmtpDeliveryConfig) -> Result<std::net::TcpStream, GatewayError> {
let stream = std::net::TcpStream::connect((config.host.as_str(), config.port))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
stream
.set_write_timeout(Some(std::time::Duration::from_secs(SMTP_TIMEOUT_SECS)))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(stream)
}
fn wrap_tls_stream(
stream: std::net::TcpStream,
host: &str,
) -> Result<rustls::StreamOwned<rustls::ClientConnection, std::net::TcpStream>, GatewayError> {
let server_name = resolve_server_name(host)?;
let connection = rustls::ClientConnection::new(build_tls_config(), server_name)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(rustls::StreamOwned::new(connection, stream))
}
fn smtp_read_response<T: std::io::BufRead>(reader: &mut T) -> Result<(u16, String), GatewayError> {
let mut message = String::new();
let code = loop {
let parsed_code;
let continuation;
let trimmed;
{
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if bytes == 0 {
return Err(GatewayError::Internal(
"smtp connection closed unexpectedly".to_string(),
));
}
trimmed = line.trim_end_matches(['\r', '\n']).to_string();
if trimmed.len() < 3 {
return Err(GatewayError::Internal("invalid smtp response".to_string()));
}
parsed_code = trimmed[..3]
.parse::<u16>()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
continuation = trimmed.as_bytes().get(3).copied() == Some(b'-');
}
if !message.is_empty() {
message.push('\n');
}
message.push_str(&trimmed);
if !continuation {
break parsed_code;
}
};
Ok((code, message))
}
fn smtp_expect<T: std::io::BufRead>(
reader: &mut T,
allowed_codes: &[u16],
) -> Result<String, GatewayError> {
let (code, message) = smtp_read_response(reader)?;
if allowed_codes.contains(&code) {
return Ok(message);
}
Err(GatewayError::Internal(format!(
"unexpected smtp response {code}: {message}"
)))
}
fn smtp_write_line<T: std::io::Write>(writer: &mut T, line: &str) -> Result<(), GatewayError> {
writer
.write_all(line.as_bytes())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
writer
.write_all(b"\r\n")
.map_err(|err| GatewayError::Internal(err.to_string()))?;
writer
.flush()
.map_err(|err| GatewayError::Internal(err.to_string()))
}
fn smtp_send_command<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
command: &str,
allowed_codes: &[u16],
) -> Result<String, GatewayError> {
smtp_write_line(reader.get_mut(), command)?;
smtp_expect(reader, allowed_codes)
}
fn build_email_message(config: &SmtpDeliveryConfig, email: &ComposedEmail) -> String {
let boundary = format!("aether-{}", uuid::Uuid::new_v4().simple());
let text_body =
wrap_base64(&base64::engine::general_purpose::STANDARD.encode(email.text_body.as_bytes()));
let html_body =
wrap_base64(&base64::engine::general_purpose::STANDARD.encode(email.html_body.as_bytes()));
let from_header = if config.from_name.trim().is_empty() {
format!("<{}>", config.from_email)
} else {
format!(
"{} <{}>",
encode_mime_header(config.from_name.trim()),
config.from_email
)
};
format!(
"From: {from_header}\r\nTo: <{to_email}>\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\nContent-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{text_body}--{boundary}\r\nContent-Type: text/html; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{html_body}--{boundary}--\r\n",
to_email = email.to_email,
subject = encode_mime_header(&email.subject),
)
}
fn smtp_authenticate<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &SmtpDeliveryConfig,
) -> Result<(), GatewayError> {
let Some(username) = config
.user
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let password = config.password.as_deref().unwrap_or("");
smtp_send_command(reader, "AUTH LOGIN", &[334])?;
smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(username.as_bytes()),
&[334],
)?;
smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(password.as_bytes()),
&[235],
)?;
Ok(())
}
fn smtp_deliver_message<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &SmtpDeliveryConfig,
email: &ComposedEmail,
) -> Result<(), GatewayError> {
smtp_send_command(
reader,
&format!("MAIL FROM:<{}>", config.from_email),
&[250],
)?;
smtp_send_command(
reader,
&format!("RCPT TO:<{}>", email.to_email),
&[250, 251],
)?;
smtp_send_command(reader, "DATA", &[354])?;
let message = build_email_message(config, email);
reader
.get_mut()
.write_all(message.as_bytes())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
reader
.get_mut()
.write_all(b"\r\n.\r\n")
.map_err(|err| GatewayError::Internal(err.to_string()))?;
reader
.get_mut()
.flush()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let _ = smtp_expect(reader, &[250])?;
let _ = smtp_send_command(reader, "QUIT", &[221]);
Ok(())
}
fn smtp_send_message<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &SmtpDeliveryConfig,
email: &ComposedEmail,
) -> Result<(), GatewayError> {
smtp_send_command(reader, "EHLO aether.local", &[250])?;
smtp_authenticate(reader, config)?;
smtp_deliver_message(reader, config, email)
}
fn smtp_probe_connection<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &SmtpDeliveryConfig,
) -> Result<(), GatewayError> {
smtp_send_command(reader, "EHLO aether.local", &[250])?;
smtp_authenticate(reader, config)?;
let _ = smtp_send_command(reader, "QUIT", &[221]);
Ok(())
}
fn send_smtp_email_blocking(
config: SmtpDeliveryConfig,
email: ComposedEmail,
) -> Result<(), GatewayError> {
if config.use_ssl {
let stream = connect_tcp_stream(&config)?;
let tls_stream = wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
let _ = smtp_expect(&mut reader, &[220])?;
return smtp_send_message(&mut reader, &config, &email);
}
let stream = connect_tcp_stream(&config)?;
let mut reader = std::io::BufReader::new(stream);
let _ = smtp_expect(&mut reader, &[220])?;
let _ = smtp_send_command(&mut reader, "EHLO aether.local", &[250])?;
if config.use_tls {
let _ = smtp_send_command(&mut reader, "STARTTLS", &[220])?;
let stream = reader.into_inner();
let tls_stream = wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
return smtp_send_message(&mut reader, &config, &email);
}
smtp_authenticate(&mut reader, &config)?;
smtp_deliver_message(&mut reader, &config, &email)
}
fn probe_smtp_connection_blocking(config: SmtpDeliveryConfig) -> Result<(), GatewayError> {
if config.use_ssl {
let stream = connect_tcp_stream(&config)?;
let tls_stream = wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
let _ = smtp_expect(&mut reader, &[220])?;
return smtp_probe_connection(&mut reader, &config);
}
let stream = connect_tcp_stream(&config)?;
let mut reader = std::io::BufReader::new(stream);
let _ = smtp_expect(&mut reader, &[220])?;
let _ = smtp_send_command(&mut reader, "EHLO aether.local", &[250])?;
if config.use_tls {
let _ = smtp_send_command(&mut reader, "STARTTLS", &[220])?;
let stream = reader.into_inner();
let tls_stream = wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
return smtp_probe_connection(&mut reader, &config);
}
smtp_authenticate(&mut reader, &config)?;
let _ = smtp_send_command(&mut reader, "QUIT", &[221]);
Ok(())
}

View File

@@ -37,6 +37,7 @@ pub(crate) use self::provider::oauth::runtime::{
refresh_provider_oauth_account_state_after_update,
};
pub(crate) use self::provider::ops::providers::actions::admin_provider_ops_local_action_response;
pub(crate) use self::provider::ops::providers::store_admin_provider_ops_balance_cache;
pub(crate) use self::provider::pool::config::admin_provider_pool_config;
pub(crate) use self::provider::pool_admin::maybe_build_local_admin_pool_response;
pub(crate) use self::provider::shared::payloads::{

View File

@@ -70,7 +70,7 @@ pub(super) async fn read_admin_provider_ops_balance_cache(
}
}
pub(super) async fn store_admin_provider_ops_balance_cache(
pub(crate) async fn store_admin_provider_ops_balance_cache(
state: &AdminAppState<'_>,
provider_id: &str,
payload: &Value,

View File

@@ -1,4 +1,7 @@
use super::support::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
use super::support::{
AdminProviderOpsQuotaAlertConfigRequest, AdminProviderOpsSaveConfigRequest,
ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS,
};
use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_admin::provider::ops as admin_provider_ops_pure;
@@ -8,6 +11,10 @@ use aether_data_contracts::repository::provider_catalog::{
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
const PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS: u64 = 86_400;
pub(super) fn admin_provider_ops_config_object(
provider: &StoredProviderCatalogProvider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -276,6 +283,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
)
})
.collect::<serde_json::Map<String, serde_json::Value>>();
let quota_alert = normalize_admin_provider_ops_quota_alert(payload.quota_alert)?;
Ok(json!({
"architecture_id": payload.architecture_id,
@@ -287,9 +295,48 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
},
"actions": actions,
"schedule": payload.schedule,
"quota_alert": quota_alert,
}))
}
fn normalize_admin_provider_ops_quota_alert(
request: Option<AdminProviderOpsQuotaAlertConfigRequest>,
) -> Result<serde_json::Value, String> {
let Some(request) = request else {
return Ok(default_admin_provider_ops_quota_alert());
};
let threshold_amount = request.threshold_amount.unwrap_or(0.0);
if threshold_amount < 0.0 {
return Err("quota_alert.threshold_amount 必须大于等于 0".to_string());
}
let fetch_interval_seconds = request
.fetch_interval_seconds
.unwrap_or(PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS);
if !(PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS
..=PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS)
.contains(&fetch_interval_seconds)
{
return Err(format!(
"quota_alert.fetch_interval_seconds 必须在 {}{} 秒之间",
PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS,
PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS
));
}
Ok(json!({
"enabled": request.enabled,
"threshold_amount": threshold_amount,
"fetch_interval_seconds": fetch_interval_seconds,
}))
}
fn default_admin_provider_ops_quota_alert() -> serde_json::Value {
json!({
"enabled": false,
"threshold_amount": 0.0,
"fetch_interval_seconds": PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS,
})
}
pub(super) fn resolve_admin_provider_ops_base_url(
provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint],
@@ -356,5 +403,10 @@ pub(super) fn build_admin_provider_ops_config_payload(
connector.and_then(|connector| connector.get("credentials")),
),
},
"quota_alert": provider_ops_config
.get("quota_alert")
.filter(|value| value.is_object())
.cloned()
.unwrap_or_else(default_admin_provider_ops_quota_alert),
})
}

View File

@@ -4,4 +4,5 @@ mod config;
mod routes;
mod support;
mod verify;
pub(crate) use self::balance_cache::store_admin_provider_ops_balance_cache;
pub(super) use self::routes::maybe_build_local_admin_provider_ops_providers_response;

View File

@@ -33,6 +33,8 @@ pub(super) struct AdminProviderOpsSaveConfigRequest {
pub(crate) actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
#[serde(default)]
pub(crate) schedule: BTreeMap<String, String>,
#[serde(default)]
pub(crate) quota_alert: Option<AdminProviderOpsQuotaAlertConfigRequest>,
}
#[derive(Debug, Deserialize)]
@@ -52,6 +54,16 @@ pub(super) struct AdminProviderOpsActionConfigRequest {
pub(crate) config: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsQuotaAlertConfigRequest {
#[serde(default)]
pub(crate) enabled: bool,
#[serde(default, deserialize_with = "deserialize_optional_f64_from_number")]
pub(crate) threshold_amount: Option<f64>,
#[serde(default)]
pub(crate) fetch_interval_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsConnectRequest {
#[serde(default)]
@@ -71,3 +83,28 @@ fn default_admin_provider_ops_architecture_id() -> String {
fn default_admin_provider_ops_action_enabled() -> bool {
true
}
fn deserialize_optional_f64_from_number<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::Number(number)) => number
.as_f64()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| serde::de::Error::custom("expected a finite number")),
Some(serde_json::Value::String(raw)) => raw
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| serde::de::Error::custom("expected a finite number or numeric string")),
Some(_) => Err(serde::de::Error::custom(
"expected a finite number or numeric string",
)),
}
}

View File

@@ -138,6 +138,13 @@ pub(crate) fn build_admin_provider_summary_value(
.and_then(|cfg| cfg.get("simulated_cache_enabled"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let ops_quota_alert_enabled = provider_ops_config
.and_then(serde_json::Value::as_object)
.and_then(|cfg| cfg.get("quota_alert"))
.and_then(serde_json::Value::as_object)
.and_then(|cfg| cfg.get("enabled"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let billing_type = quota_snapshot
.map(|quota| quota.billing_type.clone())
.or_else(|| provider.billing_type.clone());
@@ -197,6 +204,7 @@ pub(crate) fn build_admin_provider_summary_value(
"ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
"ops_quota_alert_enabled": ops_quota_alert_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
})

View File

@@ -17,6 +17,7 @@ use crate::handlers::admin::system::shared::settings::{
build_admin_system_stats_payload, current_aether_version, fetch_latest_admin_system_release,
};
use crate::handlers::admin::system::shared::smtp::build_admin_smtp_test_payload;
use crate::important_notification::build_important_notification_test_payload;
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
use crate::GatewayError;
use aether_data_contracts::repository::usage::UsageCleanupTargets;
@@ -241,6 +242,16 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
));
}
if decision.route_kind.as_deref() == Some("important_notification_test")
&& request_method == http::Method::POST
&& request_path == "/api/admin/system/important-notification/test"
{
return Ok(Some(
Json(build_important_notification_test_payload(state, request_body).await?)
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("cleanup") && request_method == http::Method::POST {
return Ok(Some(attach_admin_audit_response(
Json(build_admin_system_cleanup_payload(state).await?).into_response(),

View File

@@ -1,5 +1,9 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::{module_available_from_env, system_config_bool};
use crate::important_notification::{
important_notification_configured, IMPORTANT_NOTIFICATION_ENABLED_KEY,
LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY,
};
use crate::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY;
use crate::GatewayError;
use aether_admin::system as admin_system_kernel;
@@ -68,14 +72,14 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
admin_menu_order: 59,
},
AdminModuleDefinition {
name: "notification_email",
display_name: "异常通知",
description: "为 5xx 异常发送邮件通知,可在模块管理中启用或禁",
name: "important_notification",
display_name: "重要通知",
description: "统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使",
category: "integration",
env_key: "NOTIFICATION_EMAIL_AVAILABLE",
env_key: "IMPORTANT_NOTIFICATION_AVAILABLE",
default_available: true,
admin_route: None,
admin_menu_icon: Some("Mail"),
admin_route: Some("/admin/modules/important-notification"),
admin_menu_icon: Some("BellRing"),
admin_menu_group: Some("system"),
admin_menu_order: 58,
},
@@ -150,10 +154,15 @@ pub(crate) struct AdminModuleRuntimeState {
oauth_providers: Vec<aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig>,
ldap_config: Option<aether_data::repository::auth_modules::StoredLdapModuleConfig>,
gemini_files_has_capable_key: bool,
smtp_configured: bool,
important_notification_configured: bool,
}
pub(crate) fn admin_module_by_name(name: &str) -> Option<&'static AdminModuleDefinition> {
let name = if name == "notification_email" {
"important_notification"
} else {
name
};
ADMIN_MODULE_DEFINITIONS
.iter()
.find(|module| module.name == name)
@@ -170,11 +179,22 @@ pub(crate) fn admin_module_name_from_enabled_path(request_path: &str) -> Option<
pub(crate) fn admin_module_enabled_config_key(module: &AdminModuleDefinition) -> String {
if module.name == "model_directives" {
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
} else if module.name == "important_notification" {
IMPORTANT_NOTIFICATION_ENABLED_KEY.to_string()
} else {
format!("module.{}.enabled", module.name)
}
}
fn admin_module_available(module: &AdminModuleDefinition) -> bool {
if module.name == "important_notification" {
let legacy_default =
module_available_from_env("NOTIFICATION_EMAIL_AVAILABLE", module.default_available);
return module_available_from_env(module.env_key, legacy_default);
}
module_available_from_env(module.env_key, module.default_available)
}
pub(crate) fn oauth_module_config_is_valid(
providers: &[aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig],
) -> bool {
@@ -221,28 +241,13 @@ pub(crate) async fn build_admin_module_runtime_state(
})
};
let smtp_host = state.read_system_config_json_value("smtp_host").await?;
let smtp_from_email = state
.read_system_config_json_value("smtp_from_email")
.await?;
let smtp_configured = smtp_host
.as_ref()
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& smtp_from_email
.as_ref()
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let notification_configured = important_notification_configured(state.app()).await?;
Ok(AdminModuleRuntimeState {
oauth_providers,
ldap_config,
gemini_files_has_capable_key,
smtp_configured,
important_notification_configured: notification_configured,
})
}
@@ -255,7 +260,7 @@ pub(crate) fn build_admin_module_validation_result(
&runtime.oauth_providers,
runtime.ldap_config.as_ref(),
runtime.gemini_files_has_capable_key,
runtime.smtp_configured,
runtime.important_notification_configured,
)
}
@@ -274,12 +279,19 @@ pub(crate) async fn build_admin_module_status_payload(
module: &AdminModuleDefinition,
runtime: &AdminModuleRuntimeState,
) -> Result<serde_json::Value, GatewayError> {
let available = module_available_from_env(module.env_key, module.default_available);
let available = admin_module_available(module);
let enabled = if available {
let enabled = state
let enabled_value = state
.read_system_config_json_value(&admin_module_enabled_config_key(module))
.await?;
system_config_bool(enabled.as_ref(), false)
let enabled_value = if module.name == "important_notification" && enabled_value.is_none() {
state
.read_system_config_json_value(LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY)
.await?
} else {
enabled_value
};
system_config_bool(enabled_value.as_ref(), false)
} else {
false
};

View File

@@ -1,14 +1,10 @@
use crate::email_delivery::{probe_smtp_connection, system_config_u16, SmtpDeliveryConfig};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::{system_config_bool, system_config_string};
use crate::GatewayError;
use axum::body::Bytes;
use base64::Engine;
use serde::Deserialize;
use serde_json::json;
use std::io::{BufRead, Write};
use std::time::Duration;
const SMTP_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Default, Deserialize)]
struct AdminSmtpTestRequest {
@@ -53,12 +49,12 @@ pub(crate) async fn build_admin_smtp_test_payload(
}));
}
let result = tokio::task::spawn_blocking(move || test_smtp_connection_blocking(config))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let result = probe_smtp_connection(config.into_delivery_config()).await;
Ok(match result {
Ok(()) => json!({ "success": true, "message": "SMTP 连接测试成功" }),
Err(error) => json!({ "success": false, "message": translate_smtp_error(&error) }),
Err(error) => {
json!({ "success": false, "message": translate_smtp_error(&smtp_gateway_error_message(&error)) })
}
})
}
@@ -94,8 +90,8 @@ async fn resolve_admin_smtp_config(
port: request
.smtp_port
.as_ref()
.map(|value| system_config_u16(value, 587))
.unwrap_or_else(|| system_config_u16_opt(smtp_port.as_ref(), 587)),
.map(|value| system_config_u16(Some(value), 587))
.unwrap_or_else(|| system_config_u16(smtp_port.as_ref(), 587)),
user: request
.smtp_user
.as_ref()
@@ -130,6 +126,21 @@ async fn resolve_admin_smtp_config(
})
}
impl ResolvedSmtpConfig {
fn into_delivery_config(self) -> SmtpDeliveryConfig {
SmtpDeliveryConfig {
host: self.host.unwrap_or_default(),
port: self.port,
user: self.user,
password: self.password,
use_tls: self.use_tls,
use_ssl: self.use_ssl,
from_email: self.from_email.unwrap_or_default(),
from_name: self.from_name,
}
}
}
fn missing_smtp_fields(config: &ResolvedSmtpConfig) -> Vec<&'static str> {
let mut fields = Vec::new();
if config
@@ -171,178 +182,13 @@ fn missing_smtp_fields(config: &ResolvedSmtpConfig) -> Vec<&'static str> {
fields
}
fn system_config_u16_opt(value: Option<&serde_json::Value>, default: u16) -> u16 {
value
.map(|value| system_config_u16(value, default))
.unwrap_or(default)
}
fn system_config_u16(value: &serde_json::Value, default: u16) -> u16 {
match value {
serde_json::Value::Number(value) => value
.as_u64()
.and_then(|value| u16::try_from(value).ok())
.unwrap_or(default),
serde_json::Value::String(value) => value.trim().parse::<u16>().unwrap_or(default),
_ => default,
fn smtp_gateway_error_message(error: &GatewayError) -> String {
match error {
GatewayError::Internal(message) => message.clone(),
_ => format!("{error:?}"),
}
}
fn build_tls_config() -> std::sync::Arc<rustls::ClientConfig> {
let _ = rustls::crypto::ring::default_provider().install_default();
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
std::sync::Arc::new(
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth(),
)
}
fn resolve_server_name(host: &str) -> Result<rustls::pki_types::ServerName<'static>, String> {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(rustls::pki_types::ServerName::from(ip));
}
rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|err| err.to_string())
}
fn connect_tcp_stream(config: &ResolvedSmtpConfig) -> Result<std::net::TcpStream, String> {
let host = config.host.as_deref().unwrap_or_default();
let stream =
std::net::TcpStream::connect((host, config.port)).map_err(|err| err.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(SMTP_TIMEOUT_SECS)))
.map_err(|err| err.to_string())?;
stream
.set_write_timeout(Some(Duration::from_secs(SMTP_TIMEOUT_SECS)))
.map_err(|err| err.to_string())?;
Ok(stream)
}
fn wrap_tls_stream(
stream: std::net::TcpStream,
host: &str,
) -> Result<rustls::StreamOwned<rustls::ClientConnection, std::net::TcpStream>, String> {
let server_name = resolve_server_name(host)?;
let connection = rustls::ClientConnection::new(build_tls_config(), server_name)
.map_err(|err| err.to_string())?;
Ok(rustls::StreamOwned::new(connection, stream))
}
fn smtp_read_response<T: BufRead>(reader: &mut T) -> Result<(u16, String), String> {
let mut message = String::new();
let code = loop {
let mut line = String::new();
let bytes = reader.read_line(&mut line).map_err(|err| err.to_string())?;
if bytes == 0 {
return Err("smtp connection closed unexpectedly".to_string());
}
let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
if trimmed.len() < 3 {
return Err("invalid smtp response".to_string());
}
let parsed_code = trimmed[..3].parse::<u16>().map_err(|err| err.to_string())?;
let continuation = trimmed.as_bytes().get(3).copied() == Some(b'-');
if !message.is_empty() {
message.push('\n');
}
message.push_str(&trimmed);
if !continuation {
break parsed_code;
}
};
Ok((code, message))
}
fn smtp_expect<T: BufRead>(reader: &mut T, allowed_codes: &[u16]) -> Result<String, String> {
let (code, message) = smtp_read_response(reader)?;
if allowed_codes.contains(&code) {
return Ok(message);
}
Err(format!("unexpected smtp response {code}: {message}"))
}
fn smtp_write_line<T: Write>(writer: &mut T, line: &str) -> Result<(), String> {
writer
.write_all(line.as_bytes())
.map_err(|err| err.to_string())?;
writer.write_all(b"\r\n").map_err(|err| err.to_string())?;
writer.flush().map_err(|err| err.to_string())
}
fn smtp_send_command<S: std::io::Read + Write>(
reader: &mut std::io::BufReader<S>,
command: &str,
allowed_codes: &[u16],
) -> Result<String, String> {
smtp_write_line(reader.get_mut(), command)?;
smtp_expect(reader, allowed_codes)
}
fn smtp_authenticate<S: std::io::Read + Write>(
reader: &mut std::io::BufReader<S>,
config: &ResolvedSmtpConfig,
) -> Result<(), String> {
let Some(username) = config
.user
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let password = config.password.as_deref().unwrap_or_default();
smtp_send_command(reader, "AUTH LOGIN", &[334])?;
smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(username.as_bytes()),
&[334],
)?;
smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(password.as_bytes()),
&[235],
)?;
Ok(())
}
fn smtp_probe<S: std::io::Read + Write>(
reader: &mut std::io::BufReader<S>,
config: &ResolvedSmtpConfig,
) -> Result<(), String> {
smtp_send_command(reader, "EHLO aether.local", &[250])?;
smtp_authenticate(reader, config)?;
let _ = smtp_send_command(reader, "QUIT", &[221]);
Ok(())
}
fn test_smtp_connection_blocking(config: ResolvedSmtpConfig) -> Result<(), String> {
if config.use_ssl {
let stream = connect_tcp_stream(&config)?;
let tls_stream = wrap_tls_stream(stream, config.host.as_deref().unwrap_or_default())?;
let mut reader = std::io::BufReader::new(tls_stream);
smtp_expect(&mut reader, &[220])?;
return smtp_probe(&mut reader, &config);
}
let stream = connect_tcp_stream(&config)?;
let mut reader = std::io::BufReader::new(stream);
smtp_expect(&mut reader, &[220])?;
smtp_send_command(&mut reader, "EHLO aether.local", &[250])?;
if config.use_tls {
smtp_send_command(&mut reader, "STARTTLS", &[220])?;
let stream = reader.into_inner();
let tls_stream = wrap_tls_stream(stream, config.host.as_deref().unwrap_or_default())?;
let mut reader = std::io::BufReader::new(tls_stream);
return smtp_probe(&mut reader, &config);
}
smtp_authenticate(&mut reader, &config)?;
let _ = smtp_send_command(&mut reader, "QUIT", &[221]);
Ok(())
}
fn translate_smtp_error(error: &str) -> String {
let error_lower = error.to_ascii_lowercase();

View File

@@ -1,11 +1,11 @@
use super::{
decrypt_catalog_secret_with_fallbacks, escape_admin_email_template_html, json,
read_admin_email_template_payload, render_admin_email_template_html, system_config_bool,
system_config_string, system_config_u16, AppState, GatewayError,
escape_admin_email_template_html, json, read_admin_email_template_payload,
render_admin_email_template_html, system_config_string, AppState, GatewayError,
AUTH_EMAIL_VERIFICATION_PREFIX, AUTH_EMAIL_VERIFIED_PREFIX, AUTH_EMAIL_VERIFIED_TTL_SECS,
AUTH_SMTP_TIMEOUT_SECS,
};
use base64::Engine;
use crate::email_delivery::{
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(super) struct StoredAuthEmailVerificationCode {
@@ -13,25 +13,8 @@ pub(super) struct StoredAuthEmailVerificationCode {
pub(super) created_at: String,
}
#[derive(Debug, Clone)]
pub(super) struct AuthSmtpConfig {
pub(super) host: String,
pub(super) port: u16,
pub(super) user: Option<String>,
pub(super) password: Option<String>,
pub(super) use_tls: bool,
pub(super) use_ssl: bool,
pub(super) from_email: String,
pub(super) from_name: String,
}
#[derive(Debug, Clone)]
pub(super) struct AuthComposedEmail {
pub(super) to_email: String,
pub(super) subject: String,
pub(super) html_body: String,
pub(super) text_body: String,
}
pub(super) type AuthSmtpConfig = SmtpDeliveryConfig;
pub(super) type AuthComposedEmail = ComposedEmail;
pub(super) fn auth_email_verification_key(email: &str) -> String {
format!("{AUTH_EMAIL_VERIFICATION_PREFIX}{email}")
@@ -84,25 +67,6 @@ fn render_auth_template_string(
Ok(rendered)
}
fn auth_encode_mime_header(value: &str) -> String {
if value.is_ascii() {
return value.to_string();
}
format!(
"=?UTF-8?B?{}?=",
base64::engine::general_purpose::STANDARD.encode(value.as_bytes())
)
}
fn auth_wrap_base64(value: &str) -> String {
let mut wrapped = String::new();
for chunk in value.as_bytes().chunks(76) {
wrapped.push_str(std::str::from_utf8(chunk).unwrap_or_default());
wrapped.push_str("\r\n");
}
wrapped
}
fn auth_build_verification_text_body(
app_name: &str,
email: &str,
@@ -114,244 +78,6 @@ fn auth_build_verification_text_body(
)
}
fn auth_build_tls_config() -> std::sync::Arc<rustls::ClientConfig> {
let _ = rustls::crypto::ring::default_provider().install_default();
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
std::sync::Arc::new(config)
}
fn auth_resolve_server_name(
host: &str,
) -> Result<rustls::pki_types::ServerName<'static>, GatewayError> {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(rustls::pki_types::ServerName::from(ip));
}
rustls::pki_types::ServerName::try_from(host.to_string())
.map_err(|err| GatewayError::Internal(err.to_string()))
}
fn auth_connect_tcp_stream(config: &AuthSmtpConfig) -> Result<std::net::TcpStream, GatewayError> {
let stream = std::net::TcpStream::connect((config.host.as_str(), config.port))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(AUTH_SMTP_TIMEOUT_SECS)))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
stream
.set_write_timeout(Some(std::time::Duration::from_secs(AUTH_SMTP_TIMEOUT_SECS)))
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(stream)
}
fn auth_wrap_tls_stream(
stream: std::net::TcpStream,
host: &str,
) -> Result<rustls::StreamOwned<rustls::ClientConnection, std::net::TcpStream>, GatewayError> {
let server_name = auth_resolve_server_name(host)?;
let connection = rustls::ClientConnection::new(auth_build_tls_config(), server_name)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(rustls::StreamOwned::new(connection, stream))
}
fn auth_smtp_read_response<T: std::io::BufRead>(
reader: &mut T,
) -> Result<(u16, String), GatewayError> {
let mut message = String::new();
let code = loop {
let parsed_code;
let continuation;
let trimmed;
{
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if bytes == 0 {
return Err(GatewayError::Internal(
"smtp connection closed unexpectedly".to_string(),
));
}
trimmed = line.trim_end_matches(['\r', '\n']).to_string();
if trimmed.len() < 3 {
return Err(GatewayError::Internal("invalid smtp response".to_string()));
}
parsed_code = trimmed[..3]
.parse::<u16>()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
continuation = trimmed.as_bytes().get(3).copied() == Some(b'-');
}
if !message.is_empty() {
message.push('\n');
}
message.push_str(&trimmed);
if !continuation {
break parsed_code;
}
};
Ok((code, message))
}
fn auth_smtp_expect<T: std::io::BufRead>(
reader: &mut T,
allowed_codes: &[u16],
) -> Result<String, GatewayError> {
let (code, message) = auth_smtp_read_response(reader)?;
if allowed_codes.contains(&code) {
return Ok(message);
}
Err(GatewayError::Internal(format!(
"unexpected smtp response {code}: {message}"
)))
}
fn auth_smtp_write_line<T: std::io::Write>(writer: &mut T, line: &str) -> Result<(), GatewayError> {
writer
.write_all(line.as_bytes())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
writer
.write_all(b"\r\n")
.map_err(|err| GatewayError::Internal(err.to_string()))?;
writer
.flush()
.map_err(|err| GatewayError::Internal(err.to_string()))
}
fn auth_smtp_send_command<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
command: &str,
allowed_codes: &[u16],
) -> Result<String, GatewayError> {
auth_smtp_write_line(reader.get_mut(), command)?;
auth_smtp_expect(reader, allowed_codes)
}
fn auth_build_email_message(config: &AuthSmtpConfig, email: &AuthComposedEmail) -> String {
let boundary = format!("aether-{}", uuid::Uuid::new_v4().simple());
let text_body = auth_wrap_base64(
&base64::engine::general_purpose::STANDARD.encode(email.text_body.as_bytes()),
);
let html_body = auth_wrap_base64(
&base64::engine::general_purpose::STANDARD.encode(email.html_body.as_bytes()),
);
let from_header = if config.from_name.trim().is_empty() {
format!("<{}>", config.from_email)
} else {
format!(
"{} <{}>",
auth_encode_mime_header(config.from_name.trim()),
config.from_email
)
};
format!(
"From: {from_header}\r\nTo: <{to_email}>\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n--{boundary}\r\nContent-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{text_body}--{boundary}\r\nContent-Type: text/html; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\n\r\n{html_body}--{boundary}--\r\n",
to_email = email.to_email,
subject = auth_encode_mime_header(&email.subject),
)
}
fn auth_smtp_authenticate<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &AuthSmtpConfig,
) -> Result<(), GatewayError> {
let Some(username) = config
.user
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let password = config.password.as_deref().unwrap_or("");
auth_smtp_send_command(reader, "AUTH LOGIN", &[334])?;
auth_smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(username.as_bytes()),
&[334],
)?;
auth_smtp_send_command(
reader,
&base64::engine::general_purpose::STANDARD.encode(password.as_bytes()),
&[235],
)?;
Ok(())
}
fn auth_smtp_deliver_message<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &AuthSmtpConfig,
email: &AuthComposedEmail,
) -> Result<(), GatewayError> {
auth_smtp_send_command(
reader,
&format!("MAIL FROM:<{}>", config.from_email),
&[250],
)?;
auth_smtp_send_command(
reader,
&format!("RCPT TO:<{}>", email.to_email),
&[250, 251],
)?;
auth_smtp_send_command(reader, "DATA", &[354])?;
let message = auth_build_email_message(config, email);
reader
.get_mut()
.write_all(message.as_bytes())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
reader
.get_mut()
.write_all(b"\r\n.\r\n")
.map_err(|err| GatewayError::Internal(err.to_string()))?;
reader
.get_mut()
.flush()
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let _ = auth_smtp_expect(reader, &[250])?;
let _ = auth_smtp_send_command(reader, "QUIT", &[221]);
Ok(())
}
fn auth_smtp_send_message<S: std::io::Read + std::io::Write>(
reader: &mut std::io::BufReader<S>,
config: &AuthSmtpConfig,
email: &AuthComposedEmail,
) -> Result<(), GatewayError> {
auth_smtp_send_command(reader, "EHLO aether.local", &[250])?;
auth_smtp_authenticate(reader, config)?;
auth_smtp_deliver_message(reader, config, email)
}
fn send_auth_email_blocking(
config: AuthSmtpConfig,
email: AuthComposedEmail,
) -> Result<(), GatewayError> {
if config.use_ssl {
let stream = auth_connect_tcp_stream(&config)?;
let tls_stream = auth_wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
let _ = auth_smtp_expect(&mut reader, &[220])?;
return auth_smtp_send_message(&mut reader, &config, &email);
}
let stream = auth_connect_tcp_stream(&config)?;
let mut reader = std::io::BufReader::new(stream);
let _ = auth_smtp_expect(&mut reader, &[220])?;
let _ = auth_smtp_send_command(&mut reader, "EHLO aether.local", &[250])?;
if config.use_tls {
let _ = auth_smtp_send_command(&mut reader, "STARTTLS", &[220])?;
let stream = reader.into_inner();
let tls_stream = auth_wrap_tls_stream(stream, &config.host)?;
let mut reader = std::io::BufReader::new(tls_stream);
return auth_smtp_send_message(&mut reader, &config, &email);
}
auth_smtp_authenticate(&mut reader, &config)?;
auth_smtp_deliver_message(&mut reader, &config, &email)
}
pub(super) async fn read_auth_email_verification_code(
state: &AppState,
email: &str,
@@ -423,40 +149,7 @@ pub(super) async fn store_auth_email_verification_code(
pub(super) async fn read_auth_smtp_config(
state: &AppState,
) -> Result<Option<AuthSmtpConfig>, GatewayError> {
let smtp_host = state.read_system_config_json_value("smtp_host").await?;
let smtp_from_email = state
.read_system_config_json_value("smtp_from_email")
.await?;
let Some(host) = system_config_string(smtp_host.as_ref()) else {
return Ok(None);
};
let Some(from_email) = system_config_string(smtp_from_email.as_ref()) else {
return Ok(None);
};
let smtp_port = state.read_system_config_json_value("smtp_port").await?;
let smtp_user = state.read_system_config_json_value("smtp_user").await?;
let smtp_password = state.read_system_config_json_value("smtp_password").await?;
let smtp_use_tls = state.read_system_config_json_value("smtp_use_tls").await?;
let smtp_use_ssl = state.read_system_config_json_value("smtp_use_ssl").await?;
let smtp_from_name = state
.read_system_config_json_value("smtp_from_name")
.await?;
let password = system_config_string(smtp_password.as_ref()).map(|value| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
});
Ok(Some(AuthSmtpConfig {
host,
port: system_config_u16(smtp_port.as_ref(), 587),
user: system_config_string(smtp_user.as_ref()),
password,
use_tls: system_config_bool(smtp_use_tls.as_ref(), true),
use_ssl: system_config_bool(smtp_use_ssl.as_ref(), false),
from_email,
from_name: system_config_string(smtp_from_name.as_ref())
.unwrap_or_else(|| "Aether".to_string()),
}))
read_smtp_delivery_config(state).await
}
pub(super) async fn auth_email_app_name(state: &AppState) -> Result<String, GatewayError> {
@@ -516,27 +209,20 @@ pub(super) async fn send_auth_email(
if record_auth_email_delivery_for_tests(
state,
json!({
"to_email": email.to_email,
"subject": email.subject,
"html_body": email.html_body,
"text_body": email.text_body,
"to_email": email.to_email.clone(),
"subject": email.subject.clone(),
"html_body": email.html_body.clone(),
"text_body": email.text_body.clone(),
}),
) {
return Ok(());
}
tokio::task::spawn_blocking(move || send_auth_email_blocking(config, email))
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
send_smtp_email(config, email).await
}
pub(super) async fn auth_registration_email_configured(
state: &AppState,
) -> Result<bool, GatewayError> {
let smtp_host = state.read_system_config_json_value("smtp_host").await?;
let smtp_from_email = state
.read_system_config_json_value("smtp_from_email")
.await?;
Ok(system_config_string(smtp_host.as_ref()).is_some()
&& system_config_string(smtp_from_email.as_ref()).is_some())
Ok(read_smtp_delivery_config(state).await?.is_some())
}

View File

@@ -121,7 +121,6 @@ pub(super) const AUTH_REFRESH_TOKEN_EXPIRATION_DAYS: i64 = 7;
pub(super) const AUTH_EMAIL_VERIFICATION_PREFIX: &str = "email:verification:";
pub(super) const AUTH_EMAIL_VERIFIED_PREFIX: &str = "email:verified:";
pub(super) const AUTH_EMAIL_VERIFIED_TTL_SECS: u64 = 3600;
pub(super) const AUTH_SMTP_TIMEOUT_SECS: u64 = 30;
pub(crate) fn build_auth_json_response(
status: http::StatusCode,

View File

@@ -254,6 +254,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("system_manage"), http::Method::PUT, Some("config_set"))
| (Some("system_manage"), http::Method::PUT, Some("email_template_set"))
| (Some("system_manage"), http::Method::POST, Some("email_template_preview"))
| (
Some("system_manage"),
http::Method::POST,
Some("important_notification_test"),
)
| (
Some("provider_models_manage"),
http::Method::POST,

View File

@@ -0,0 +1,548 @@
use crate::admin_api::AdminAppState;
use crate::email_delivery::{
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
};
use crate::handlers::shared::{
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
};
use crate::{AppState, GatewayError};
use axum::body::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::warn;
pub(crate) const IMPORTANT_NOTIFICATION_ENABLED_KEY: &str = "module.important_notification.enabled";
pub(crate) const LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY: &str = "module.notification_email.enabled";
pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY: &str =
"module.important_notification.email_enabled";
pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY: &str =
"module.important_notification.email_recipients";
pub(crate) const IMPORTANT_NOTIFICATION_SERVER_CHAN_ENABLED_KEY: &str =
"module.important_notification.server_chan_enabled";
pub(crate) const IMPORTANT_NOTIFICATION_SERVER_CHAN_SEND_KEY_KEY: &str =
"module.important_notification.server_chan_send_key";
pub(crate) const IMPORTANT_NOTIFICATION_SERVER_CHAN_TEMPLATE_KEY: &str =
"module.important_notification.server_chan_template";
const SERVER_CHAN_API_BASE: &str = "https://sctapi.ftqq.com";
#[derive(Debug, Clone)]
pub(crate) struct ImportantNotification {
pub(crate) title: String,
pub(crate) markdown_body: String,
pub(crate) text_body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ImportantNotificationChannelFilter {
All,
Email,
ServerChan,
}
#[derive(Debug, Clone)]
struct ImportantNotificationConfig {
module_enabled: bool,
email_enabled: bool,
email_recipients: Vec<String>,
server_chan_enabled: bool,
server_chan_send_key: Option<String>,
server_chan_template: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ImportantNotificationChannelReport {
pub(crate) channel: &'static str,
pub(crate) success: bool,
pub(crate) message: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ImportantNotificationDeliveryReport {
pub(crate) success: bool,
pub(crate) channels: Vec<ImportantNotificationChannelReport>,
}
#[derive(Debug, Deserialize)]
struct ImportantNotificationTestRequest {
#[serde(default)]
channel: Option<String>,
}
pub(crate) async fn important_notification_module_enabled(
state: &AppState,
) -> Result<bool, GatewayError> {
let canonical = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_ENABLED_KEY)
.await?;
if canonical.is_some() {
return Ok(system_config_bool(canonical.as_ref(), false));
}
let legacy = state
.read_system_config_json_value(LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY)
.await?;
Ok(system_config_bool(legacy.as_ref(), false))
}
pub(crate) async fn important_notification_configured(
state: &AppState,
) -> Result<bool, GatewayError> {
let config = read_important_notification_config(state).await?;
important_notification_has_configured_channel(state, &config).await
}
pub(crate) async fn important_notification_dispatch_ready(
state: &AppState,
) -> Result<bool, GatewayError> {
let config = read_important_notification_config(state).await?;
if !config.module_enabled {
return Ok(false);
}
important_notification_has_configured_channel(state, &config).await
}
async fn important_notification_has_configured_channel(
state: &AppState,
config: &ImportantNotificationConfig,
) -> Result<bool, GatewayError> {
let smtp_config = read_smtp_delivery_config(state).await?;
Ok(
(config.email_enabled && !config.email_recipients.is_empty() && smtp_config.is_some())
|| (config.server_chan_enabled && config.server_chan_send_key.is_some()),
)
}
pub(crate) async fn send_important_notification(
state: &AppState,
notification: ImportantNotification,
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
send_important_notification_with_filter(
state,
notification,
ImportantNotificationChannelFilter::All,
)
.await
}
pub(crate) async fn send_important_notification_with_filter(
state: &AppState,
notification: ImportantNotification,
channel_filter: ImportantNotificationChannelFilter,
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
dispatch_important_notification(state, notification, channel_filter, false).await
}
async fn dispatch_important_notification(
state: &AppState,
notification: ImportantNotification,
channel_filter: ImportantNotificationChannelFilter,
bypass_enable_checks: bool,
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
let config = read_important_notification_config(state).await?;
if !bypass_enable_checks && !config.module_enabled {
return Ok(ImportantNotificationDeliveryReport {
success: false,
channels: vec![ImportantNotificationChannelReport {
channel: "module",
success: false,
message: "重要通知模块未启用".to_string(),
}],
});
}
let mut reports = Vec::new();
if matches!(
channel_filter,
ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::Email
) {
maybe_send_email_notification(
state,
&config,
&notification,
bypass_enable_checks,
&mut reports,
)
.await;
}
if matches!(
channel_filter,
ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::ServerChan
) {
maybe_send_server_chan_notification(
state,
&config,
&notification,
bypass_enable_checks,
&mut reports,
)
.await;
}
if reports.is_empty() {
reports.push(ImportantNotificationChannelReport {
channel: "none",
success: false,
message: "未启用可用的通知通道".to_string(),
});
}
let success = reports.iter().any(|report| report.success);
Ok(ImportantNotificationDeliveryReport {
success,
channels: reports,
})
}
pub(crate) async fn build_important_notification_test_payload(
state: &AdminAppState<'_>,
request_body: Option<&Bytes>,
) -> Result<Value, GatewayError> {
let request = match request_body.filter(|body| !body.is_empty()) {
Some(body) => serde_json::from_slice::<ImportantNotificationTestRequest>(body)
.unwrap_or(ImportantNotificationTestRequest { channel: None }),
None => ImportantNotificationTestRequest { channel: None },
};
let filter = match request
.channel
.as_deref()
.map(str::trim)
.unwrap_or("all")
.to_ascii_lowercase()
.as_str()
{
"email" => ImportantNotificationChannelFilter::Email,
"server_chan" | "serverchan" | "serve_chan" => {
ImportantNotificationChannelFilter::ServerChan
}
_ => ImportantNotificationChannelFilter::All,
};
let report = dispatch_important_notification(
state.app(),
ImportantNotification {
title: "Aether 重要通知测试".to_string(),
markdown_body: "这是一条来自 Aether 的重要通知测试。".to_string(),
text_body: "这是一条来自 Aether 的重要通知测试。".to_string(),
},
filter,
true,
)
.await?;
Ok(json!({
"success": report.success,
"message": if report.success { "测试通知已发送" } else { "测试通知发送失败" },
"channels": report.channels,
}))
}
async fn read_important_notification_config(
state: &AppState,
) -> Result<ImportantNotificationConfig, GatewayError> {
let module_enabled = important_notification_module_enabled(state).await?;
let email_enabled = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY)
.await?;
let email_recipients = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY)
.await?;
let server_chan_enabled = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_ENABLED_KEY)
.await?;
let server_chan_send_key = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_SEND_KEY_KEY)
.await?;
let server_chan_template = state
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_TEMPLATE_KEY)
.await?;
Ok(ImportantNotificationConfig {
module_enabled,
email_enabled: system_config_bool(email_enabled.as_ref(), false),
email_recipients: parse_recipient_list(email_recipients.as_ref()),
server_chan_enabled: system_config_bool(server_chan_enabled.as_ref(), false),
server_chan_send_key: system_config_string(server_chan_send_key.as_ref()).map(|value| {
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
}),
server_chan_template: system_config_string(server_chan_template.as_ref()),
})
}
async fn maybe_send_email_notification(
state: &AppState,
config: &ImportantNotificationConfig,
notification: &ImportantNotification,
bypass_channel_toggle: bool,
reports: &mut Vec<ImportantNotificationChannelReport>,
) {
if !bypass_channel_toggle && !config.email_enabled {
return;
}
if config.email_recipients.is_empty() {
reports.push(ImportantNotificationChannelReport {
channel: "email",
success: false,
message: "未配置邮件收件人".to_string(),
});
return;
}
let smtp_config = match read_smtp_delivery_config(state).await {
Ok(Some(config)) => config,
Ok(None) => {
reports.push(ImportantNotificationChannelReport {
channel: "email",
success: false,
message: "SMTP 配置不完整".to_string(),
});
return;
}
Err(err) => {
reports.push(ImportantNotificationChannelReport {
channel: "email",
success: false,
message: format!("读取 SMTP 配置失败: {err:?}"),
});
return;
}
};
let mut sent = 0usize;
let mut failed = 0usize;
for recipient in &config.email_recipients {
match send_single_email_notification(smtp_config.clone(), recipient, notification).await {
Ok(()) => sent += 1,
Err(err) => {
failed += 1;
warn!(
error = ?err,
recipient = %recipient,
"failed to send important notification email"
);
}
}
}
reports.push(ImportantNotificationChannelReport {
channel: "email",
success: sent > 0,
message: if failed == 0 {
format!("邮件通知已发送给 {sent} 个收件人")
} else {
format!("邮件通知成功 {sent} 个,失败 {failed}")
},
});
}
async fn send_single_email_notification(
smtp_config: SmtpDeliveryConfig,
recipient: &str,
notification: &ImportantNotification,
) -> Result<(), GatewayError> {
send_smtp_email(
smtp_config,
ComposedEmail {
to_email: recipient.to_string(),
subject: notification.title.clone(),
html_body: build_notification_html(notification),
text_body: notification.text_body.clone(),
},
)
.await
}
async fn maybe_send_server_chan_notification(
state: &AppState,
config: &ImportantNotificationConfig,
notification: &ImportantNotification,
bypass_channel_toggle: bool,
reports: &mut Vec<ImportantNotificationChannelReport>,
) {
if !bypass_channel_toggle && !config.server_chan_enabled {
return;
}
let Some(send_key) = config.server_chan_send_key.as_deref() else {
reports.push(ImportantNotificationChannelReport {
channel: "server_chan",
success: false,
message: "未配置 Server 酱 SendKey".to_string(),
});
return;
};
match send_server_chan_notification(
state,
send_key,
config.server_chan_template.as_deref(),
notification,
)
.await
{
Ok(()) => reports.push(ImportantNotificationChannelReport {
channel: "server_chan",
success: true,
message: "Server 酱通知已发送".to_string(),
}),
Err(err) => {
warn!(error = ?err, "failed to send server chan important notification");
reports.push(ImportantNotificationChannelReport {
channel: "server_chan",
success: false,
message: format!("Server 酱通知发送失败: {err:?}"),
});
}
}
}
async fn send_server_chan_notification(
state: &AppState,
send_key: &str,
template: Option<&str>,
notification: &ImportantNotification,
) -> Result<(), GatewayError> {
let send_key = send_key.trim();
if send_key.is_empty() {
return Err(GatewayError::Internal(
"Server 酱 SendKey 不能为空".to_string(),
));
}
let desp = render_server_chan_desp(template, notification);
let url = format!("{SERVER_CHAN_API_BASE}/{send_key}.send");
let response = state
.client
.post(url)
.form(&[
("title", notification.title.as_str()),
("desp", desp.as_str()),
])
.send()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if !status.is_success() {
return Err(GatewayError::Internal(format!(
"Server 酱返回 HTTP {status}: {text}"
)));
}
if let Ok(payload) = serde_json::from_str::<Value>(&text) {
let code_is_ok = payload
.get("code")
.and_then(|value| {
value
.as_i64()
.map(|code| code == 0)
.or_else(|| value.as_str().map(|code| code.trim() == "0"))
})
.unwrap_or(true);
if !code_is_ok {
return Err(GatewayError::Internal(format!(
"Server 酱返回失败: {payload}"
)));
}
}
Ok(())
}
fn render_server_chan_desp(template: Option<&str>, notification: &ImportantNotification) -> String {
match template {
Some(template) if !template.trim().is_empty() => template
.replace("{title}", &notification.title)
.replace("{body}", &notification.markdown_body),
_ => notification.markdown_body.clone(),
}
}
fn parse_recipient_list(value: Option<&Value>) -> Vec<String> {
let mut recipients = Vec::new();
match value {
Some(Value::Array(items)) => {
for item in items {
if let Some(raw) = item.as_str() {
push_recipient_parts(&mut recipients, raw);
}
}
}
Some(Value::String(raw)) => push_recipient_parts(&mut recipients, raw),
_ => {}
}
recipients.sort();
recipients.dedup();
recipients
}
fn push_recipient_parts(recipients: &mut Vec<String>, raw: &str) {
for item in raw
.split([',', ';', '\n', '\r'])
.map(str::trim)
.filter(|value| !value.is_empty())
{
recipients.push(item.to_string());
}
}
fn build_notification_html(notification: &ImportantNotification) -> String {
format!(
"<!doctype html><html><body><h2>{}</h2><pre style=\"font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;line-height:1.6\">{}</pre></body></html>",
escape_html(&notification.title),
escape_html(&notification.text_body),
)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::{parse_recipient_list, render_server_chan_desp, ImportantNotification};
use serde_json::json;
#[test]
fn parse_recipient_list_accepts_arrays_and_delimiters() {
assert_eq!(
parse_recipient_list(Some(&json!([
"ops@example.com, admin@example.com",
"ops@example.com"
]))),
vec![
"admin@example.com".to_string(),
"ops@example.com".to_string()
]
);
}
fn sample_notification() -> ImportantNotification {
ImportantNotification {
title: "告警".to_string(),
markdown_body: "原始正文".to_string(),
text_body: "原始正文".to_string(),
}
}
#[test]
fn server_chan_desp_uses_template_when_provided() {
let rendered = render_server_chan_desp(
Some("**{title}**\n\n{body}\n\n--end--"),
&sample_notification(),
);
assert_eq!(rendered, "**告警**\n\n原始正文\n\n--end--");
}
#[test]
fn server_chan_desp_falls_back_to_markdown_body_for_empty_template() {
assert_eq!(
render_server_chan_desp(None, &sample_notification()),
"原始正文"
);
assert_eq!(
render_server_chan_desp(Some(" "), &sample_notification()),
"原始正文"
);
}
}

View File

@@ -37,6 +37,7 @@ mod constants;
mod control;
mod data;
mod dispatch;
mod email_delivery;
mod error;
mod execution_runtime;
mod executor;
@@ -46,6 +47,7 @@ mod handlers;
mod headers;
mod hooks;
mod image_capabilities;
mod important_notification;
mod log_ids;
mod maintenance;
pub(crate) mod middleware;

View File

@@ -7,8 +7,8 @@ pub(crate) use runtime::{
ensure_provider_key_pool_scores_for_keys, inspect_proxy_upgrade_rollout,
list_admin_cleanup_run_records, perform_account_self_check_once,
perform_oauth_token_refresh_once, perform_pool_quota_probe_once, perform_provider_checkin_once,
pool_quota_probe_target_count, preview_manual_usage_cleanup, rebuild_admin_stats_once,
record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
perform_provider_quota_alert_once, pool_quota_probe_target_count, preview_manual_usage_cleanup,
rebuild_admin_stats_once, record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
run_admin_system_cleanup_once, run_manual_usage_cleanup_once, skip_proxy_upgrade_rollout_node,
spawn_account_self_check_worker, spawn_audit_cleanup_worker, spawn_db_maintenance_worker,
@@ -16,16 +16,17 @@ pub(crate) use runtime::{
spawn_pending_cleanup_worker, spawn_pool_monitor_worker,
spawn_pool_quota_probe_replenish_for_request, spawn_pool_quota_probe_worker,
spawn_pool_score_rebuild_worker, spawn_provider_checkin_worker,
spawn_proxy_node_metrics_cleanup_worker, spawn_proxy_node_stale_cleanup_worker,
spawn_proxy_upgrade_rollout_worker, spawn_request_candidate_cleanup_worker,
spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker,
spawn_usage_cleanup_worker, spawn_usage_counter_flush_worker,
spawn_wallet_daily_usage_aggregation_worker, start_admin_request_body_cleanup_task,
start_admin_system_purge_task, start_manual_usage_cleanup_task, start_proxy_upgrade_rollout,
AccountSelfCheckRunSummary, AdminCleanupRunRecord, AdminCleanupTaskKind,
AdminStatsRebuildSummary, AdminSystemCleanupSummary, ManualUsageCleanupError,
ManualUsageCleanupMode, ManualUsageCleanupOptions, OAuthTokenRefreshRunSummary,
PoolQuotaProbeRunSummary, PoolQuotaProbeWorkerConfig, ProviderCheckinRunSummary,
spawn_provider_quota_alert_worker, spawn_proxy_node_metrics_cleanup_worker,
spawn_proxy_node_stale_cleanup_worker, spawn_proxy_upgrade_rollout_worker,
spawn_request_candidate_cleanup_worker, spawn_stats_aggregation_worker,
spawn_stats_hourly_aggregation_worker, spawn_usage_cleanup_worker,
spawn_usage_counter_flush_worker, spawn_wallet_daily_usage_aggregation_worker,
start_admin_request_body_cleanup_task, start_admin_system_purge_task,
start_manual_usage_cleanup_task, start_proxy_upgrade_rollout, AccountSelfCheckRunSummary,
AdminCleanupRunRecord, AdminCleanupTaskKind, AdminStatsRebuildSummary,
AdminSystemCleanupSummary, ManualUsageCleanupError, ManualUsageCleanupMode,
ManualUsageCleanupOptions, OAuthTokenRefreshRunSummary, PoolQuotaProbeRunSummary,
PoolQuotaProbeWorkerConfig, ProviderCheckinRunSummary, ProviderQuotaAlertRunSummary,
ProxyUpgradeRolloutCancelSummary, ProxyUpgradeRolloutConflictClearSummary,
ProxyUpgradeRolloutNodeActionSummary, ProxyUpgradeRolloutProbeConfig,
ProxyUpgradeRolloutSkippedRestoreSummary, ProxyUpgradeRolloutStatus,

View File

@@ -26,6 +26,8 @@ mod pool_quota_probe;
mod pool_score_rebuild;
#[path = "runtime/provider_checkin.rs"]
mod provider_checkin;
#[path = "runtime/provider_quota_alert.rs"]
mod provider_quota_alert;
#[path = "runtime/proxy_node_metrics_cleanup.rs"]
mod proxy_node_metrics_cleanup;
#[path = "runtime/proxy_node_staleness.rs"]
@@ -85,6 +87,9 @@ pub(crate) use pool_score_rebuild::{
PoolScoreRebuildRunSummary, PoolScoreRebuildWorkerConfig,
};
pub(crate) use provider_checkin::{perform_provider_checkin_once, ProviderCheckinRunSummary};
pub(crate) use provider_quota_alert::{
perform_provider_quota_alert_once, ProviderQuotaAlertRunSummary,
};
use proxy_node_metrics_cleanup::*;
use proxy_node_staleness::*;
use proxy_upgrade_rollout::*;
@@ -138,6 +143,8 @@ const USAGE_COUNTER_DELTA_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
const USAGE_COUNTER_DELTA_CLEANUP_BATCH_SIZE: usize = 5_000;
const USAGE_COUNTER_DELTA_RETENTION_SECS: u64 = 7 * 24 * 60 * 60;
const PROVIDER_CHECKIN_CONCURRENCY: usize = 3;
const PROVIDER_QUOTA_ALERT_CONCURRENCY: usize = 3;
const PROVIDER_QUOTA_ALERT_INTERVAL: Duration = Duration::from_secs(5);
const PROVIDER_CHECKIN_DEFAULT_TIME: &str = "01:05";
const REQUEST_CANDIDATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const STATS_DAILY_AGGREGATION_HOUR: u32 = 0;

View File

@@ -0,0 +1,463 @@
use std::collections::HashMap;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
};
use futures_util::stream::{self, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::warn;
use crate::admin_api::{
admin_provider_ops_local_action_response, store_admin_provider_ops_balance_cache, AdminAppState,
};
use crate::important_notification::{
important_notification_dispatch_ready, send_important_notification, ImportantNotification,
};
use crate::{AppState, GatewayError};
use super::PROVIDER_QUOTA_ALERT_CONCURRENCY;
const PROVIDER_QUOTA_ALERT_STATE_PREFIX: &str = "provider_ops:quota_alert:";
const PROVIDER_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_QUOTA_ALERT_REPEAT_COOLDOWN_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ProviderQuotaAlertRunSummary {
pub(crate) checked: usize,
pub(crate) alerted: usize,
pub(crate) skipped: usize,
pub(crate) failed: usize,
}
#[derive(Debug, Clone)]
struct ProviderQuotaAlertTarget {
provider: StoredProviderCatalogProvider,
config: ProviderQuotaAlertConfig,
}
#[derive(Debug, Clone, Copy)]
struct ProviderQuotaAlertConfig {
enabled: bool,
threshold_amount: f64,
fetch_interval_seconds: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ProviderQuotaAlertRuntimeState {
#[serde(default)]
last_checked_at: u64,
#[serde(default)]
last_available: Option<f64>,
#[serde(default)]
below_threshold: bool,
#[serde(default)]
last_notified_at: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderQuotaAlertStatus {
Checked,
Alerted,
Skipped,
Failed,
}
pub(crate) async fn perform_provider_quota_alert_once(
state: &AppState,
) -> Result<ProviderQuotaAlertRunSummary, GatewayError> {
if !state.has_provider_catalog_data_reader() {
return Ok(ProviderQuotaAlertRunSummary {
checked: 0,
alerted: 0,
skipped: 0,
failed: 0,
});
}
if !important_notification_dispatch_ready(state).await? {
return Ok(ProviderQuotaAlertRunSummary {
checked: 0,
alerted: 0,
skipped: 0,
failed: 0,
});
}
let now_unix_secs = now_unix_secs();
let targets = select_provider_quota_alert_targets(state, now_unix_secs).await?;
if targets.is_empty() {
return Ok(ProviderQuotaAlertRunSummary {
checked: 0,
alerted: 0,
skipped: 0,
failed: 0,
});
}
let provider_ids = targets
.iter()
.map(|target| target.provider.id.clone())
.collect::<Vec<_>>();
let mut endpoints_by_provider = HashMap::<String, Vec<StoredProviderCatalogEndpoint>>::new();
for endpoint in state
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
.await?
{
endpoints_by_provider
.entry(endpoint.provider_id.clone())
.or_default()
.push(endpoint);
}
let mut results = stream::iter(targets.into_iter().map(|target| {
let state = state.clone();
let provider_id = target.provider.id.clone();
let endpoints = endpoints_by_provider
.get(&provider_id)
.cloned()
.unwrap_or_default();
async move { run_provider_quota_alert_for_provider(&state, target, endpoints).await }
}))
.buffer_unordered(PROVIDER_QUOTA_ALERT_CONCURRENCY);
let mut summary = ProviderQuotaAlertRunSummary {
checked: 0,
alerted: 0,
skipped: 0,
failed: 0,
};
while let Some(status) = results.next().await {
match status {
ProviderQuotaAlertStatus::Checked => summary.checked += 1,
ProviderQuotaAlertStatus::Alerted => {
summary.checked += 1;
summary.alerted += 1;
}
ProviderQuotaAlertStatus::Skipped => summary.skipped += 1,
ProviderQuotaAlertStatus::Failed => summary.failed += 1,
}
}
Ok(summary)
}
async fn select_provider_quota_alert_targets(
state: &AppState,
now_unix_secs: u64,
) -> Result<Vec<ProviderQuotaAlertTarget>, GatewayError> {
let providers = state
.list_provider_catalog_providers(true)
.await?
.into_iter()
.filter_map(|provider| {
let config = provider_quota_alert_config(&provider)?;
(config.enabled).then_some(ProviderQuotaAlertTarget { provider, config })
})
.collect::<Vec<_>>();
let mut due = Vec::new();
for target in providers {
let runtime = read_quota_alert_runtime_state(state, &target.provider.id).await;
let last_checked_at = runtime
.as_ref()
.map(|state| state.last_checked_at)
.unwrap_or(0);
if now_unix_secs.saturating_sub(last_checked_at)
>= target
.config
.fetch_interval_seconds
.max(PROVIDER_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS)
{
due.push(target);
}
}
Ok(due)
}
async fn run_provider_quota_alert_for_provider(
state: &AppState,
target: ProviderQuotaAlertTarget,
endpoints: Vec<StoredProviderCatalogEndpoint>,
) -> ProviderQuotaAlertStatus {
let provider_id = target.provider.id.clone();
let admin_state = AdminAppState::new(state);
let payload = admin_provider_ops_local_action_response(
&admin_state,
&provider_id,
Some(&target.provider),
&endpoints,
"query_balance",
None,
)
.await;
store_admin_provider_ops_balance_cache(&admin_state, &provider_id, &payload).await;
let now_unix_secs = now_unix_secs();
let payload_status_success = payload.get("status").and_then(Value::as_str) == Some("success");
let Some(total_available) = extract_total_available(&payload) else {
warn!(
provider_id = %provider_id,
payload = %payload,
"provider quota alert skipped because balance payload has no total_available"
);
write_checked_runtime_state_without_balance(state, &provider_id, now_unix_secs).await;
return if payload_status_success {
ProviderQuotaAlertStatus::Skipped
} else {
ProviderQuotaAlertStatus::Failed
};
};
let previous = read_quota_alert_runtime_state(state, &provider_id).await;
let should_notify = provider_quota_alert_should_notify(
now_unix_secs,
total_available,
target.config.threshold_amount,
previous.as_ref(),
);
let mut next = ProviderQuotaAlertRuntimeState {
last_checked_at: now_unix_secs,
last_available: Some(total_available),
below_threshold: total_available <= target.config.threshold_amount,
last_notified_at: previous.and_then(|state| state.last_notified_at),
};
if should_notify {
let report = send_important_notification(
state,
build_provider_quota_alert_notification(
&target.provider,
total_available,
target.config.threshold_amount,
),
)
.await;
let delivered = match &report {
Ok(report) if report.success => true,
Ok(report) => {
warn!(
provider_id = %provider_id,
report = ?report,
"provider quota alert notification did not reach any channel"
);
false
}
Err(err) => {
warn!(
provider_id = %provider_id,
error = ?err,
"provider quota alert notification failed"
);
false
}
};
if delivered {
next.last_notified_at = Some(now_unix_secs);
write_quota_alert_runtime_state(state, &provider_id, &next).await;
return ProviderQuotaAlertStatus::Alerted;
}
write_quota_alert_runtime_state(state, &provider_id, &next).await;
return ProviderQuotaAlertStatus::Failed;
}
write_quota_alert_runtime_state(state, &provider_id, &next).await;
ProviderQuotaAlertStatus::Checked
}
fn provider_quota_alert_config(
provider: &StoredProviderCatalogProvider,
) -> Option<ProviderQuotaAlertConfig> {
let quota_alert = provider
.config
.as_ref()
.and_then(Value::as_object)
.and_then(|config| config.get("provider_ops"))
.and_then(Value::as_object)
.and_then(|provider_ops| provider_ops.get("quota_alert"))
.and_then(Value::as_object)?;
let enabled = quota_alert
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let threshold_amount = quota_alert
.get("threshold_amount")
.and_then(value_as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0);
let fetch_interval_seconds = quota_alert
.get("fetch_interval_seconds")
.and_then(Value::as_u64)
.unwrap_or(PROVIDER_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS)
.max(PROVIDER_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS);
Some(ProviderQuotaAlertConfig {
enabled,
threshold_amount,
fetch_interval_seconds,
})
}
fn provider_quota_alert_should_notify(
now_unix_secs: u64,
total_available: f64,
threshold_amount: f64,
previous: Option<&ProviderQuotaAlertRuntimeState>,
) -> bool {
if total_available > threshold_amount {
return false;
}
let Some(previous) = previous else {
return true;
};
if !previous.below_threshold {
return true;
}
previous
.last_notified_at
.map(|last| now_unix_secs.saturating_sub(last) >= PROVIDER_QUOTA_ALERT_REPEAT_COOLDOWN_SECS)
.unwrap_or(true)
}
fn extract_total_available(payload: &Value) -> Option<f64> {
if payload.get("status").and_then(Value::as_str) != Some("success") {
return None;
}
payload
.get("data")
.and_then(|data| data.get("total_available"))
.and_then(value_as_f64)
.filter(|value| value.is_finite())
}
fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| {
value
.as_str()
.and_then(|raw| raw.trim().parse::<f64>().ok())
})
}
fn build_provider_quota_alert_notification(
provider: &StoredProviderCatalogProvider,
total_available: f64,
threshold_amount: f64,
) -> ImportantNotification {
let title = format!("提供商额度提醒:{}", provider.name);
let body = format!(
"提供商 `{}` 当前剩余额度为 `{:.4}`,已低于或等于提醒阈值 `{:.4}`。\n\nProvider ID: `{}`",
provider.name, total_available, threshold_amount, provider.id
);
let text_body = format!(
"提供商 {} 当前剩余额度为 {:.4},已低于或等于提醒阈值 {:.4}\n\nProvider ID: {}",
provider.name, total_available, threshold_amount, provider.id
);
ImportantNotification {
title,
markdown_body: body,
text_body,
}
}
async fn read_quota_alert_runtime_state(
state: &AppState,
provider_id: &str,
) -> Option<ProviderQuotaAlertRuntimeState> {
let key = provider_quota_alert_state_key(provider_id);
state
.runtime_kv_get(&key)
.await
.ok()
.flatten()
.and_then(|raw| serde_json::from_str::<ProviderQuotaAlertRuntimeState>(&raw).ok())
}
async fn write_checked_runtime_state_without_balance(
state: &AppState,
provider_id: &str,
now_unix_secs: u64,
) {
let mut next = read_quota_alert_runtime_state(state, provider_id)
.await
.unwrap_or_default();
next.last_checked_at = now_unix_secs;
write_quota_alert_runtime_state(state, provider_id, &next).await;
}
async fn write_quota_alert_runtime_state(
state: &AppState,
provider_id: &str,
runtime_state: &ProviderQuotaAlertRuntimeState,
) {
let Ok(serialized) = serde_json::to_string(runtime_state) else {
return;
};
if let Err(err) = state
.runtime_state()
.kv_set(
&provider_quota_alert_state_key(provider_id),
serialized,
None,
)
.await
{
warn!(
error = %err,
provider_id,
"failed to write provider quota alert runtime state"
);
}
}
fn provider_quota_alert_state_key(provider_id: &str) -> String {
format!("{PROVIDER_QUOTA_ALERT_STATE_PREFIX}{provider_id}")
}
fn now_unix_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
#[cfg(test)]
mod tests {
use super::{
provider_quota_alert_should_notify, ProviderQuotaAlertRuntimeState,
PROVIDER_QUOTA_ALERT_REPEAT_COOLDOWN_SECS,
};
#[test]
fn quota_alert_notifies_on_first_drop_and_after_cooldown() {
assert!(provider_quota_alert_should_notify(100, 3.0, 5.0, None));
assert!(provider_quota_alert_should_notify(
100,
3.0,
5.0,
Some(&ProviderQuotaAlertRuntimeState {
below_threshold: false,
..ProviderQuotaAlertRuntimeState::default()
})
));
assert!(!provider_quota_alert_should_notify(
100,
3.0,
5.0,
Some(&ProviderQuotaAlertRuntimeState {
below_threshold: true,
last_notified_at: Some(90),
..ProviderQuotaAlertRuntimeState::default()
})
));
assert!(provider_quota_alert_should_notify(
100 + PROVIDER_QUOTA_ALERT_REPEAT_COOLDOWN_SECS,
3.0,
5.0,
Some(&ProviderQuotaAlertRuntimeState {
below_threshold: true,
last_notified_at: Some(100),
..ProviderQuotaAlertRuntimeState::default()
})
));
}
#[test]
fn quota_alert_does_not_notify_above_threshold() {
assert!(!provider_quota_alert_should_notify(100, 6.0, 5.0, None));
}
}

View File

@@ -10,22 +10,22 @@ use super::{
cleanup_processed_usage_counter_deltas_once, duration_until_next_daily_run,
duration_until_next_db_maintenance_run, duration_until_next_stats_aggregation_run,
duration_until_next_stats_hourly_aggregation_run, maintenance_timezone, parse_hhmm_time,
perform_oauth_token_refresh_once, provider_checkin_schedule, run_audit_cleanup_once,
run_db_maintenance_once, run_gemini_file_mapping_cleanup_once, run_pending_cleanup_once,
run_pool_monitor_once, run_provider_checkin_once, run_proxy_node_metrics_cleanup_once,
run_proxy_node_stale_cleanup_once, run_proxy_upgrade_rollout_once,
run_request_candidate_cleanup_once, run_stats_aggregation_once,
perform_oauth_token_refresh_once, perform_provider_quota_alert_once, provider_checkin_schedule,
run_audit_cleanup_once, run_db_maintenance_once, run_gemini_file_mapping_cleanup_once,
run_pending_cleanup_once, run_pool_monitor_once, run_provider_checkin_once,
run_proxy_node_metrics_cleanup_once, run_proxy_node_stale_cleanup_once,
run_proxy_upgrade_rollout_once, run_request_candidate_cleanup_once, run_stats_aggregation_once,
run_stats_hourly_aggregation_once, run_usage_cleanup_once, run_usage_counter_flush_once,
run_wallet_daily_usage_aggregation_once, AUDIT_LOG_CLEANUP_INTERVAL,
GEMINI_FILE_MAPPING_CLEANUP_INTERVAL, OAUTH_TOKEN_REFRESH_INTERVAL, PENDING_CLEANUP_INTERVAL,
POOL_MONITOR_INTERVAL, PROVIDER_CHECKIN_DEFAULT_TIME, PROXY_NODE_METRICS_CLEANUP_HOUR,
PROXY_NODE_METRICS_CLEANUP_MINUTE, PROXY_NODE_STALE_SWEEP_INTERVAL,
PROXY_UPGRADE_ROLLOUT_INTERVAL, REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR,
USAGE_CLEANUP_MINUTE, USAGE_COUNTER_DELTA_CLEANUP_BATCH_SIZE,
USAGE_COUNTER_DELTA_CLEANUP_INTERVAL, USAGE_COUNTER_DELTA_RETENTION_SECS,
USAGE_COUNTER_FLUSH_BATCH_SIZE, USAGE_COUNTER_FLUSH_CATCH_UP_BURST_LIMIT,
USAGE_COUNTER_FLUSH_INTERVAL, WALLET_DAILY_USAGE_AGGREGATION_HOUR,
WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
POOL_MONITOR_INTERVAL, PROVIDER_CHECKIN_DEFAULT_TIME, PROVIDER_QUOTA_ALERT_INTERVAL,
PROXY_NODE_METRICS_CLEANUP_HOUR, PROXY_NODE_METRICS_CLEANUP_MINUTE,
PROXY_NODE_STALE_SWEEP_INTERVAL, PROXY_UPGRADE_ROLLOUT_INTERVAL,
REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE,
USAGE_COUNTER_DELTA_CLEANUP_BATCH_SIZE, USAGE_COUNTER_DELTA_CLEANUP_INTERVAL,
USAGE_COUNTER_DELTA_RETENTION_SECS, USAGE_COUNTER_FLUSH_BATCH_SIZE,
USAGE_COUNTER_FLUSH_CATCH_UP_BURST_LIMIT, USAGE_COUNTER_FLUSH_INTERVAL,
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
};
const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14;
@@ -254,6 +254,26 @@ pub(crate) fn spawn_provider_checkin_worker(
}))
}
pub(crate) fn spawn_provider_quota_alert_worker(
state: AppState,
) -> Option<tokio::task::JoinHandle<()>> {
if !state.has_provider_catalog_data_reader() {
return None;
}
Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(PROVIDER_QUOTA_ALERT_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
interval.tick().await;
loop {
interval.tick().await;
if let Err(err) = perform_provider_quota_alert_once(&state).await {
log_maintenance_worker_failure("provider_quota_alert", "tick", &err);
}
}
}))
}
pub(crate) fn spawn_oauth_token_refresh_worker(
state: AppState,
) -> Option<tokio::task::JoinHandle<()>> {

View File

@@ -49,6 +49,7 @@ use crate::maintenance::spawn_pending_cleanup_worker;
use crate::maintenance::spawn_pool_monitor_worker;
use crate::maintenance::spawn_pool_score_rebuild_worker;
use crate::maintenance::spawn_provider_checkin_worker;
use crate::maintenance::spawn_provider_quota_alert_worker;
use crate::maintenance::spawn_proxy_node_metrics_cleanup_worker;
use crate::maintenance::spawn_proxy_node_stale_cleanup_worker;
use crate::maintenance::spawn_proxy_upgrade_rollout_worker;
@@ -1203,6 +1204,10 @@ impl AppState {
crate::task_runtime::TASK_KEY_PROVIDER_CHECKIN,
spawn_provider_checkin_worker(self.clone()),
);
supervise_worker(
crate::task_runtime::TASK_KEY_PROVIDER_QUOTA_ALERT,
spawn_provider_quota_alert_worker(self.clone()),
);
supervise_worker(
crate::task_runtime::TASK_KEY_OAUTH_TOKEN_REFRESH,
spawn_oauth_token_refresh_worker(self.clone()),

View File

@@ -36,6 +36,7 @@ pub(crate) const TASK_KEY_PROXY_NODE_METRICS_CLEANUP: &str =
"maintenance.proxy.node.metrics.cleanup";
pub(crate) const TASK_KEY_PROXY_UPGRADE_ROLLOUT: &str = "maintenance.proxy.upgrade.rollout";
pub(crate) const TASK_KEY_PROVIDER_CHECKIN: &str = "maintenance.provider.checkin";
pub(crate) const TASK_KEY_PROVIDER_QUOTA_ALERT: &str = "maintenance.provider.quota_alert";
pub(crate) const TASK_KEY_USAGE_CLEANUP: &str = "maintenance.usage.cleanup";
pub(crate) const TASK_KEY_WALLET_DAILY_USAGE_AGG: &str = "maintenance.wallet.daily.usage.agg";
pub(crate) const TASK_KEY_STATS_DAILY_AGG: &str = "maintenance.stats.daily.agg";
@@ -207,6 +208,14 @@ const TASK_DEFINITIONS: &[TaskDefinition] = &[
true,
RETRY_ONCE,
),
TaskDefinition::new(
TASK_KEY_PROVIDER_QUOTA_ALERT,
TaskKind::Scheduled,
"interval",
true,
true,
RETRY_ONCE,
),
TaskDefinition::new(
TASK_KEY_USAGE_CLEANUP,
TaskKind::Scheduled,

View File

@@ -754,6 +754,14 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
.with_system_config_values_for_tests(vec![
("module.oauth.enabled".to_string(), json!(true)),
("module.management_tokens.enabled".to_string(), json!(true)),
(
"module.important_notification.email_enabled".to_string(),
json!(true),
),
(
"module.important_notification.email_recipients".to_string(),
json!("ops@example.com"),
),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("ops@example.com")),
]);
@@ -796,9 +804,13 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
"/admin/modules/chat-pii-redaction"
);
assert_eq!(
payload["notification_email"]["config_validated"],
payload["important_notification"]["config_validated"],
json!(true)
);
assert_eq!(
payload["important_notification"]["admin_route"],
"/admin/modules/important-notification"
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
@@ -828,6 +840,14 @@ async fn gateway_handles_admin_modules_status_locally_with_bearer_admin_session(
.with_system_config_values_for_tests(vec![
("module.oauth.enabled".to_string(), json!(true)),
("module.management_tokens.enabled".to_string(), json!(true)),
(
"module.important_notification.email_enabled".to_string(),
json!(true),
),
(
"module.important_notification.email_recipients".to_string(),
json!("ops@example.com"),
),
("smtp_host".to_string(), json!("smtp.example.com")),
("smtp_from_email".to_string(), json!("ops@example.com")),
]);
@@ -855,7 +875,7 @@ async fn gateway_handles_admin_modules_status_locally_with_bearer_admin_session(
assert_eq!(payload["oauth"]["config_validated"], json!(true));
assert_eq!(payload["management_tokens"]["active"], json!(true));
assert_eq!(
payload["notification_email"]["config_validated"],
payload["important_notification"]["config_validated"],
json!(true)
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);

View File

@@ -797,22 +797,13 @@ pub fn admin_provider_ops_sub2api_verify_payload(
}
}
let username_or_email = admin_provider_ops_sub2api_non_empty_string(user_data, "username")
.or_else(|| admin_provider_ops_sub2api_non_empty_string(user_data, "email"));
admin_provider_ops_verify_success(
admin_provider_ops_verify_user_payload(
user_data
.get("username")
.or_else(|| user_data.get("email"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
user_data
.get("username")
.or_else(|| user_data.get("email"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
user_data
.get("email")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
username_or_email.clone(),
username_or_email,
admin_provider_ops_sub2api_non_empty_string(user_data, "email"),
Some(balance + points),
Some(extra),
),
@@ -820,6 +811,17 @@ pub fn admin_provider_ops_sub2api_verify_payload(
)
}
fn admin_provider_ops_sub2api_non_empty_string(
map: &Map<String, Value>,
key: &str,
) -> Option<String> {
map.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::{
@@ -913,6 +915,28 @@ mod tests {
);
}
#[test]
fn sub2api_verify_payload_falls_back_to_email_when_username_is_null() {
let payload = admin_provider_ops_sub2api_verify_payload(
StatusCode::OK,
&json!({
"code": 0,
"data": {
"username": null,
"email": "user@example.com",
"balance": 2.0,
"points": 0.0
}
}),
None,
);
assert_eq!(payload["success"], json!(true));
assert_eq!(payload["data"]["username"], json!("user@example.com"));
assert_eq!(payload["data"]["display_name"], json!("user@example.com"));
assert_eq!(payload["data"]["email"], json!("user@example.com"));
}
#[test]
fn anyrouter_verify_payload_uses_cookie_auth_messages_and_usage_fields() {
let payload = admin_provider_ops_anyrouter_verify_payload(

View File

@@ -667,7 +667,11 @@ struct AdminApiFormatDefinition {
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password", "turnstile_secret_key"];
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
"smtp_password",
"turnstile_secret_key",
"module.important_notification.server_chan_send_key",
];
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
AdminApiFormatDefinition {
value: "openai:chat",
@@ -1162,7 +1166,7 @@ pub fn build_admin_module_validation_result(
oauth_providers: &[StoredOAuthProviderModuleConfig],
ldap_config: Option<&StoredLdapModuleConfig>,
gemini_files_has_capable_key: bool,
smtp_configured: bool,
important_notification_configured: bool,
) -> (bool, Option<String>) {
match module_name {
"oauth" => {
@@ -1233,11 +1237,11 @@ pub fn build_admin_module_validation_result(
}
(true, None)
}
"notification_email" => {
if smtp_configured {
"important_notification" | "notification_email" => {
if important_notification_configured {
(true, None)
} else {
(false, Some("请先完成邮件配置SMTP".to_string()))
(false, Some("请先完成重要通知通道配置".to_string()))
}
}
"gemini_files" => {
@@ -1260,7 +1264,9 @@ pub fn build_admin_module_health(
gemini_files_has_capable_key: bool,
) -> &'static str {
match module_name {
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
"management_tokens" | "model_directives" | "proxy_nodes" | "important_notification" => {
"healthy"
}
"gemini_files" => {
if gemini_files_has_capable_key {
"healthy"
@@ -1437,6 +1443,8 @@ pub fn normalize_admin_system_config_key(requested_key: &str) -> String {
let trimmed = requested_key.trim();
if trimmed.eq_ignore_ascii_case(LEGACY_REQUEST_LOG_LEVEL_KEY) {
REQUEST_RECORD_LEVEL_KEY.to_string()
} else if trimmed.eq_ignore_ascii_case("module.notification_email.enabled") {
"module.important_notification.enabled".to_string()
} else {
trimmed.to_string()
}
@@ -1449,6 +1457,11 @@ pub fn admin_system_config_delete_keys(requested_key: &str) -> Vec<String> {
REQUEST_RECORD_LEVEL_KEY.to_string(),
LEGACY_REQUEST_LOG_LEVEL_KEY.to_string(),
]
} else if normalized == "module.important_notification.enabled" {
vec![
"module.important_notification.enabled".to_string(),
"module.notification_email.enabled".to_string(),
]
} else {
vec![normalized]
}
@@ -1570,6 +1583,12 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"smtp_from_email" => Some(serde_json::Value::Null),
"smtp_from_name" => Some(json!("Aether")),
"enable_oauth_token_refresh" => Some(json!(true)),
"module.important_notification.enabled" => Some(json!(false)),
"module.important_notification.email_enabled" => Some(json!(false)),
"module.important_notification.email_recipients" => Some(json!("")),
"module.important_notification.server_chan_enabled" => Some(json!(false)),
"module.important_notification.server_chan_send_key" => Some(serde_json::Value::Null),
"module.important_notification.server_chan_template" => Some(json!("")),
"module.chat_pii_redaction.enabled" => Some(json!(false)),
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
@@ -1720,6 +1739,44 @@ fn normalize_chat_pii_redaction_rule_features(
Ok(Value::Object(features))
}
fn normalize_string_list_config_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
match value {
Value::Null => Ok(json!("")),
Value::String(raw) => Ok(json!(raw.trim())),
Value::Array(items) => {
let mut normalized = Vec::with_capacity(items.len());
for item in items {
let Some(raw) = item.as_str() else {
return Err(());
};
let raw = raw.trim();
if !raw.is_empty() {
normalized.push(raw.to_string());
}
}
Ok(json!(normalized))
}
_ => Err(()),
}
}
fn normalize_nullable_string_config_value(
value: serde_json::Value,
) -> Result<serde_json::Value, ()> {
match value {
Value::Null => Ok(Value::Null),
Value::String(raw) => {
let raw = raw.trim();
if raw.is_empty() {
Ok(Value::Null)
} else {
Ok(json!(raw))
}
}
_ => Err(()),
}
}
pub fn parse_admin_system_config_update(
requested_key: &str,
request_body: &[u8],
@@ -1773,6 +1830,48 @@ pub fn parse_admin_system_config_update(
}
match normalized_key.as_str() {
"module.important_notification.enabled"
| "module.important_notification.email_enabled"
| "module.important_notification.server_chan_enabled" => match value.as_bool() {
Some(enabled) => value = json!(enabled),
None if value.is_null() => {
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
}
None => {
return Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
));
}
},
"module.important_notification.email_recipients" => {
value = normalize_string_list_config_value(value).map_err(|_| {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
)
})?;
}
"module.important_notification.server_chan_send_key" => {
value = normalize_nullable_string_config_value(value).map_err(|_| {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
)
})?;
}
"module.important_notification.server_chan_template" => {
value = match value {
Value::Null => json!(""),
Value::String(raw) => json!(raw),
_ => {
return Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
));
}
};
}
"module.chat_pii_redaction.enabled" => match value.as_bool() {
Some(enabled) => value = json!(enabled),
None if value.is_null() => {
@@ -2829,9 +2928,27 @@ mod tests {
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
assert!(is_sensitive_admin_system_config_key("turnstile_secret_key"));
assert!(is_sensitive_admin_system_config_key("TURNSTILE_SECRET_KEY"));
assert!(is_sensitive_admin_system_config_key(
"module.important_notification.server_chan_send_key"
));
assert!(!is_sensitive_admin_system_config_key("site_name"));
}
#[test]
fn legacy_notification_email_config_key_normalizes_to_important_notification() {
assert_eq!(
normalize_admin_system_config_key("module.notification_email.enabled"),
"module.important_notification.enabled"
);
assert_eq!(
admin_system_config_delete_keys("module.important_notification.enabled"),
vec![
"module.important_notification.enabled".to_string(),
"module.notification_email.enabled".to_string(),
]
);
}
#[test]
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
let payload = build_admin_system_config_detail_payload(

View File

@@ -918,6 +918,19 @@ export const adminApi = {
return response.data
},
async testImportantNotification(channel: 'all' | 'email' | 'server_chan' = 'all'): Promise<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}> {
const response = await apiClient.post<{
success: boolean
message: string
channels: Array<{ channel: string; success: boolean; message: string }>
}>('/api/admin/system/important-notification/test', { channel })
return response.data
},
// 邮件模板相关
// 获取所有邮件模板
async getEmailTemplates(): Promise<EmailTemplatesResponse> {

View File

@@ -712,6 +712,7 @@ export interface ProviderWithEndpointsSummary {
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
kiro_simulated_cache_enabled?: boolean
ops_quota_alert_enabled?: boolean
created_at: string
updated_at: string
}

View File

@@ -127,12 +127,19 @@ export interface ActionConfigRequest {
}
/** 保存配置请求 */
export interface QuotaAlertConfig {
enabled: boolean
threshold_amount: number
fetch_interval_seconds: number
}
export interface SaveConfigRequest {
architecture_id: string
base_url?: string
connector: ConnectorConfigRequest
actions: Record<string, ActionConfigRequest>
schedule: Record<string, string>
quota_alert?: QuotaAlertConfig
}
/** 连接请求 */
@@ -190,6 +197,7 @@ export interface ProviderOpsConfigResponse {
config: Record<string, unknown>
credentials: Record<string, unknown>
}
quota_alert?: QuotaAlertConfig
}
/**

View File

@@ -1,4 +1,4 @@
import { Mail, Shield, AlertTriangle } from 'lucide-vue-next'
import { Mail, Shield, AlertTriangle, Send } from 'lucide-vue-next'
import type { LucideIcon } from 'lucide-vue-next'
export interface BuiltinTool {
@@ -15,6 +15,12 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
href: '/admin/email',
icon: Mail,
},
{
name: 'Server 酱',
description: '配置 Server 酱 SendKey 与通知模板',
href: '/admin/server-chan',
icon: Send,
},
{
name: 'IP 安全',
description: '管理 IP 黑白名单,控制系统访问权限',

View File

@@ -239,6 +239,47 @@
</template>
</template>
</template>
<div class="rounded-lg border border-border bg-muted/20 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
额度提醒
</Label>
<p class="mt-1 text-xs text-muted-foreground">
余额低于阈值时通过重要通知发送提醒
</p>
</div>
<Switch v-model="quotaAlert.enabled" />
</div>
<div
v-if="quotaAlert.enabled"
class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3"
>
<div class="space-y-2">
<Label>提醒阈值</Label>
<Input
v-model.number="quotaAlert.threshold_amount"
type="number"
min="0"
step="0.0001"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label>获取频率(秒)</Label>
<Input
v-model.number="quotaAlert.fetch_interval_seconds"
type="number"
min="30"
max="86400"
step="1"
placeholder="30"
/>
</div>
</div>
</div>
</div>
</form>
@@ -305,6 +346,7 @@ import {
getProviderOpsConfig,
deleteProviderOpsConfig,
type ArchitectureInfo,
type QuotaAlertConfig,
} from '@/api/providerOps'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
@@ -375,6 +417,12 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
})
const savedQuotaAlertSignature = ref(quotaAlertSignature(quotaAlert.value))
// 当前架构支持的认证方式
const currentAuthTypes = computed(() => {
@@ -421,8 +469,15 @@ const canVerify = computed(() => {
})
// 保存按钮是否可用:验证成功且表单未变动
const quotaAlertChanged = computed(() => {
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
})
const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value
return (
(verifyStatus.value === 'success' && !formChanged.value)
|| (hasExistingConfig.value && quotaAlertChanged.value && !formChanged.value)
)
})
// 字段分组
@@ -495,6 +550,11 @@ function formatQuota(quota: number): string {
return quota.toLocaleString()
}
function finiteNumber(value: unknown): number | null {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : null
}
async function handleVerify() {
const schema = currentSchema.value
if (!schema) return
@@ -565,8 +625,10 @@ async function handleVerify() {
const displayName = result.data?.display_name || result.data?.username
const extra = result.data?.extra
let balanceText = `余额: ${formatQuota(quota)}`
if (extra && extra.balance !== undefined && extra.points !== undefined) {
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
const extraBalance = finiteNumber(extra?.balance)
const extraPoints = finiteNumber(extra?.points)
if (extraBalance !== null && extraPoints !== null) {
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
}
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
}
@@ -624,8 +686,10 @@ async function handleSave() {
formData.value,
props.providerWebsite,
)
request.quota_alert = normalizedQuotaAlert()
const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) {
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
showSuccess(result.message || '配置已保存', '保存成功')
emit('saved')
emit('update:open', false)
@@ -660,6 +724,7 @@ async function handleClear() {
formChanged.value = false
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
loadQuotaAlert(null)
resetFormData()
emit('saved')
emit('update:open', false)
@@ -673,18 +738,27 @@ async function handleClear() {
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringOrDefault(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback
}
function loadFromConfig(config: Record<string, unknown>) {
if (!config?.connector) return
const connector = isRecord(config.connector) ? config.connector : null
if (!connector) return
hasExistingConfig.value = true
// 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api'
const architectureId = stringOrDefault(config.architecture_id, 'new_api')
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
// 从已保存的 connector auth_type 恢复认证方式选择
const savedAuthType = config.connector?.auth_type
const savedAuthType = stringOrDefault(connector.auth_type, '')
const authTypes = currentAuthTypes.value
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
selectedAuthType.value = savedAuthType
@@ -694,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
const schema = currentSchema.value
if (schema) {
const parsedData = parseConfigFromSchema(schema, config)
const parsedData = parseConfigFromSchema(schema, {
...config,
connector,
})
// 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {}
@@ -707,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
formData.value = parsedData
}
loadQuotaAlert(config.quota_alert)
}
function defaultQuotaAlert(): QuotaAlertConfig {
return {
enabled: false,
threshold_amount: 0,
fetch_interval_seconds: 30,
}
}
function normalizeQuotaAlert(value: unknown): QuotaAlertConfig {
if (!value || typeof value !== 'object') return defaultQuotaAlert()
const item = value as Record<string, unknown>
const threshold = Number(item.threshold_amount)
const interval = Number(item.fetch_interval_seconds)
return {
enabled: item.enabled === true,
threshold_amount: Number.isFinite(threshold) && threshold >= 0 ? threshold : 0,
fetch_interval_seconds: Number.isFinite(interval) && interval >= 30 ? Math.min(Math.floor(interval), 86400) : 30,
}
}
function normalizedQuotaAlert(): QuotaAlertConfig {
return normalizeQuotaAlert(quotaAlert.value)
}
function quotaAlertSignature(value: QuotaAlertConfig): string {
const normalized = normalizeQuotaAlert(value)
return JSON.stringify([
normalized.enabled,
normalized.threshold_amount,
normalized.fetch_interval_seconds,
])
}
function loadQuotaAlert(value: unknown) {
const normalized = normalizeQuotaAlert(value)
quotaAlert.value = normalized
savedQuotaAlertSignature.value = quotaAlertSignature(normalized)
}
/** 确保架构列表已加载 */
@@ -747,11 +864,13 @@ watch(
architecture_id: config.architecture_id,
base_url: config.base_url,
connector: config.connector,
quota_alert: config.quota_alert,
}
loadFromConfig(configData)
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -759,6 +878,7 @@ watch(
} catch {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()
@@ -768,6 +888,7 @@ watch(
} else {
hasExistingConfig.value = false
sensitivePlaceholders.value = {}
loadQuotaAlert(null)
selectedArchitectureId.value = 'new_api'
selectedAuthType.value = ''
resetFormData()

View File

@@ -959,16 +959,16 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
admin_menu_order: 55,
},
{
name: 'notification_email',
display_name: '异常通知',
description: '为 5xx 异常发送邮件通知,可在模块管理中启用或禁用',
name: 'important_notification',
display_name: '重要通知',
description: '统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先完成邮件配置SMTP',
admin_route: null,
admin_menu_icon: 'Mail',
config_error: '请先完成重要通知通道配置',
admin_route: '/admin/modules/important-notification',
admin_menu_icon: 'BellRing',
admin_menu_group: 'system',
admin_menu_order: 58,
},

View File

@@ -274,6 +274,18 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
meta: { module: 'chat_pii_redaction' }
},
{
path: 'modules/important-notification',
name: 'ImportantNotificationModule',
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
meta: { module: 'important_notification' }
},
{
path: 'server-chan',
name: 'ServerChanSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
meta: { module: 'important_notification' }
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -0,0 +1,355 @@
<template>
<PageContainer>
<PageHeader
title="重要通知"
description="配置后台任务使用的邮件和 Server 酱通知通道"
/>
<div class="mt-6 space-y-6">
<CardSection
title="模块开关"
description="启用后,额度提醒等后台任务可以发送重要通知"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用重要通知
</Label>
<p class="mt-1 text-xs text-muted-foreground">
{{ anyChannelConfigurable
? '至少配置一个可用通道后再启用'
: '请先完成邮件或 Server 酱通道配置后再启用'
}}
</p>
</div>
<Switch
v-model="config.enabled"
:disabled="!anyChannelConfigurable"
/>
</div>
</CardSection>
<CardSection
title="邮件通知"
description="使用系统 SMTP 配置向固定收件人发送提醒"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用邮件通道
</Label>
<p
v-if="emailChannelConfigurable"
class="mt-1 text-xs text-muted-foreground"
>
SMTP 服务在邮件配置中维护
</p>
<p
v-else
class="mt-1 text-xs text-destructive"
>
<template v-if="!smtpConfigured">
请先在
<RouterLink
to="/admin/email"
class="hover:underline"
>
邮件配置
</RouterLink>
中配置 SMTP
</template>
<template v-else>
请先
</template>
填写至少一个收件人后再启用
</p>
</div>
<Switch
v-model="config.email_enabled"
:disabled="!emailChannelConfigurable"
/>
</div>
<div>
<Label
for="important-notification-recipients"
class="block text-sm font-medium"
>
收件人
</Label>
<Textarea
id="important-notification-recipients"
v-model="config.email_recipients"
rows="4"
placeholder="ops@example.com&#10;admin@example.com"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
支持换行逗号或分号分隔
</p>
</div>
</div>
</CardSection>
<CardSection
title="Server 酱"
description="通过 Server 酱 Turbo SendKey 推送微信提醒"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<Label class="text-sm font-medium">
启用 Server 酱通道
</Label>
<p
v-if="serverChanKeyIsSet"
class="mt-1 text-xs text-muted-foreground"
>
请求地址使用 Server Turbo 官方接口
</p>
<p
v-else
class="mt-1 text-xs text-destructive"
>
请先前往
<RouterLink
to="/admin/server-chan"
class="hover:underline"
>
Server
</RouterLink>
配置 SendKey 后再启用
</p>
</div>
<Switch
v-model="config.server_chan_enabled"
:disabled="!serverChanKeyIsSet"
/>
</div>
<p
v-if="serverChanKeyIsSet"
class="text-xs text-muted-foreground"
>
前往
<RouterLink
to="/admin/server-chan"
class="text-primary hover:underline"
>
Server
</RouterLink>
配置 SendKey 与通知模板
</p>
</div>
</CardSection>
<CardSection
title="测试通知"
description="按当前已保存配置发送一条重要通知测试"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testingAll || !anyChannelConfigurable"
@click="testChannel('all')"
>
{{ testingAll ? '发送中...' : '测试全部通道' }}
</Button>
<Button
variant="outline"
:disabled="testingEmail || !emailChannelConfigurable"
@click="testChannel('email')"
>
{{ testingEmail ? '发送中...' : '测试邮件' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import Textarea from '@/components/ui/textarea.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { modulesApi } from '@/api/modules'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
enabled: 'module.important_notification.enabled',
email_enabled: 'module.important_notification.email_enabled',
email_recipients: 'module.important_notification.email_recipients',
server_chan_enabled: 'module.important_notification.server_chan_enabled',
server_chan_send_key: 'module.important_notification.server_chan_send_key',
} as const
interface ImportantNotificationConfig {
enabled: boolean
email_enabled: boolean
email_recipients: string
server_chan_enabled: boolean
}
const { success, error } = useToast()
const saving = ref(false)
const testingAll = ref(false)
const testingEmail = ref(false)
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
const smtpConfigured = ref(false)
const serverChanKeyIsSet = ref(false)
const config = ref<ImportantNotificationConfig>({
enabled: false,
email_enabled: false,
email_recipients: '',
server_chan_enabled: false,
})
const emailChannelConfigurable = computed(() => {
return smtpConfigured.value && config.value.email_recipients.trim() !== ''
})
const anyChannelConfigurable = computed(() => {
return emailChannelConfigurable.value || serverChanKeyIsSet.value
})
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [
moduleStatus,
emailEnabled,
recipients,
serverChanEnabled,
serverChanKey,
smtpHost,
smtpFromEmail,
] = await Promise.all([
modulesApi.getStatus('important_notification'),
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_enabled),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
adminApi.getSystemConfig('smtp_host'),
adminApi.getSystemConfig('smtp_from_email'),
])
config.value.enabled = moduleStatus.enabled === true
config.value.email_enabled = emailEnabled.value === true
config.value.email_recipients = normalizeRecipients(recipients.value)
config.value.server_chan_enabled = serverChanEnabled.value === true
serverChanKeyIsSet.value = serverChanKey.is_set === true
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
} catch (err) {
error(parseApiError(err, '加载重要通知配置失败'))
log.error('加载重要通知配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
if (!config.value.enabled) {
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, false, '重要通知模块总开关')
}
await Promise.all([
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '重要通知邮件通道开关'),
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '重要通知邮件收件人'),
adminApi.updateSystemConfig(CONFIG_KEYS.server_chan_enabled, config.value.server_chan_enabled, '重要通知 Server 酱通道开关'),
])
if (config.value.enabled) {
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, true, '重要通知模块总开关')
}
success('重要通知配置已保存')
} catch (err) {
error(parseApiError(err, '保存重要通知配置失败'))
log.error('保存重要通知配置失败:', err)
} finally {
saving.value = false
}
}
async function testChannel(channel: 'all' | 'email') {
setTesting(channel, true)
try {
const result = await adminApi.testImportantNotification(channel)
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试重要通知失败:', err)
} finally {
setTesting(channel, false)
}
}
function setTesting(channel: 'all' | 'email', value: boolean) {
if (channel === 'all') testingAll.value = value
if (channel === 'email') testingEmail.value = value
}
function isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim() !== ''
}
function normalizeRecipients(value: unknown): string {
if (Array.isArray(value)) {
return value
.map(item => String(item).trim())
.filter(Boolean)
.join('\n')
}
return typeof value === 'string' ? value : ''
}
function formatChannel(channel: string): string {
if (channel === 'email') return '邮件'
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'module') return '模块'
return channel
}
</script>

View File

@@ -0,0 +1,205 @@
<template>
<PageContainer>
<PageHeader
title="Server 酱"
description="配置 Server 酱 Turbo SendKey 与微信通知模板"
/>
<div class="mt-6 space-y-6">
<CardSection
title="SendKey"
description="使用 Server 酱 Turbo 官方 SendKey 推送微信通知"
>
<template #actions>
<Button
size="sm"
:disabled="saving"
@click="saveConfig"
>
{{ saving ? '保存中...' : '保存' }}
</Button>
</template>
<div>
<Label
for="server-chan-send-key"
class="block text-sm font-medium"
>
SendKey
</Label>
<Input
id="server-chan-send-key"
v-model="sendKeyInput"
masked
:placeholder="sendKeyIsSet ? '已设置(留空保持不变)' : 'SCTxxxxxxxxxxxxxxxxxxxxxxxx'"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
可在 <span class="font-mono">sct.ftqq.com</span> 控制台获取
</p>
</div>
</CardSection>
<CardSection
title="通知模板"
description="可选 Markdown 模板,支持 {title} 和 {body} 变量;留空则使用默认正文"
>
<div>
<Label
for="server-chan-template"
class="block text-sm font-medium"
>
模板内容
</Label>
<textarea
id="server-chan-template"
v-model="templateInput"
rows="10"
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
placeholder="**{title}**&#10;&#10;{body}"
spellcheck="false"
/>
<p class="mt-2 text-xs text-muted-foreground">
示例<span class="font-mono">**{title}**\n\n{body}\n\n来自 Aether</span>
</p>
</div>
</CardSection>
<CardSection
title="测试 Server 酱"
description="按当前已保存配置向微信发送一条测试通知"
>
<div class="flex flex-wrap gap-2">
<Button
variant="outline"
:disabled="testing"
@click="handleTest"
>
{{ testing ? '发送中...' : '测试 Server 酱' }}
</Button>
</div>
<div
v-if="lastTestResult.length > 0"
class="mt-4 space-y-2"
>
<div
v-for="item in lastTestResult"
:key="item.channel"
class="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
>
<span>{{ formatChannel(item.channel) }}</span>
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
{{ item.message }}
</span>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const CONFIG_KEYS = {
server_chan_send_key: 'module.important_notification.server_chan_send_key',
server_chan_template: 'module.important_notification.server_chan_template',
} as const
const { success, error } = useToast()
const saving = ref(false)
const testing = ref(false)
const sendKeyIsSet = ref(false)
const sendKeyInput = ref('')
const templateInput = ref('')
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
onMounted(() => {
loadConfig()
})
async function loadConfig() {
try {
const [sendKey, template] = await Promise.all([
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_template),
])
sendKeyIsSet.value = sendKey.is_set === true
sendKeyInput.value = ''
templateInput.value = typeof template.value === 'string' ? template.value : ''
} catch (err) {
error(parseApiError(err, '加载 Server 酱配置失败'))
log.error('加载 Server 酱配置失败:', err)
}
}
async function saveConfig() {
saving.value = true
try {
const updates: Array<Promise<unknown>> = [
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_template,
templateInput.value,
'重要通知 Server 酱 通知模板',
),
]
const trimmedKey = sendKeyInput.value.trim()
if (trimmedKey) {
updates.push(
adminApi.updateSystemConfig(
CONFIG_KEYS.server_chan_send_key,
trimmedKey,
'重要通知 Server 酱 SendKey',
),
)
}
await Promise.all(updates)
if (trimmedKey) {
sendKeyIsSet.value = true
sendKeyInput.value = ''
}
success('Server 酱配置已保存')
} catch (err) {
error(parseApiError(err, '保存 Server 酱配置失败'))
log.error('保存 Server 酱配置失败:', err)
} finally {
saving.value = false
}
}
async function handleTest() {
testing.value = true
try {
const result = await adminApi.testImportantNotification('server_chan')
lastTestResult.value = result.channels || []
if (result.success) {
success(result.message || '测试通知已发送')
} else {
error(result.message || '测试通知发送失败')
}
} catch (err) {
error(parseApiError(err, '测试通知发送失败'))
log.error('测试 Server 酱失败:', err)
} finally {
testing.value = false
}
}
function formatChannel(channel: string): string {
if (channel === 'server_chan') return 'Server 酱'
if (channel === 'email') return '邮件'
if (channel === 'module') return '模块'
return channel
}
</script>