mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 重要通知模块、Server 酱独立配置与额度提醒
- 新增重要通知统一模块(邮件 + Server 酱)作为后台任务通知出口
- 拆出独立的 Server 酱 配置页(SendKey + Markdown 模板,支持 {title}/{body} 变量替换),通过仪表盘内置工具入口进入
- 新增提供商额度提醒后台 worker:余额低于阈值时通过重要通知推送,提供商配置页加入额度提醒开关与阈值
- 重要通知页加入配置可用性守卫:未配置任一通道时禁用总开关,未配置邮件/SendKey 时禁用对应通道开关
- 测试通知端点支持 channel 过滤(all/email/server_chan),并绕过总开关与通道开关,便于配置阶段先验证通道
- 修复:测试通知路由未在 buffered-body 白名单导致 channel 参数丢失、测试时邮件分支被误触发
- 修复:sub2api 验证响应中 username 为 null 时正确回退到 email,避免误报"验证响应缺少: 用户信息"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
339
apps/aether-gateway/src/email_delivery.rs
Normal file
339
apps/aether-gateway/src/email_delivery.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
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 = 15;
|
||||
|
||||
#[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()))?
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
@@ -36,6 +36,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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,13 @@ pub(crate) fn build_admin_provider_summary_value(
|
||||
.and_then(|cfg| cfg.get("architecture_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
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());
|
||||
@@ -190,6 +197,7 @@ pub(crate) fn build_admin_provider_summary_value(
|
||||
"endpoint_health_details": endpoint_health_details,
|
||||
"ops_configured": ops_configured,
|
||||
"ops_architecture_id": ops_architecture_id,
|
||||
"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),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
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,
|
||||
},
|
||||
@@ -126,10 +130,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)
|
||||
@@ -146,6 +155,8 @@ 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)
|
||||
}
|
||||
@@ -197,28 +208,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,7 +227,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,10 +248,17 @@ pub(crate) async fn build_admin_module_status_payload(
|
||||
) -> Result<serde_json::Value, GatewayError> {
|
||||
let available = module_available_from_env(module.env_key, module.default_available);
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
531
apps/aether-gateway/src/important_notification.rs
Normal file
531
apps/aether-gateway/src/important_notification.rs
Normal file
@@ -0,0 +1,531 @@
|
||||
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?;
|
||||
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,
|
||||
¬ification,
|
||||
bypass_enable_checks,
|
||||
&mut reports,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
channel_filter,
|
||||
ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::ServerChan
|
||||
) {
|
||||
maybe_send_server_chan_notification(
|
||||
state,
|
||||
&config,
|
||||
¬ification,
|
||||
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}", ¬ification.title)
|
||||
.replace("{body}", ¬ification.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(¬ification.title),
|
||||
escape_html(¬ification.text_body),
|
||||
)
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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()),
|
||||
"原始正文"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ mod constants;
|
||||
mod control;
|
||||
mod data;
|
||||
mod dispatch;
|
||||
mod email_delivery;
|
||||
mod error;
|
||||
mod execution_runtime;
|
||||
mod executor;
|
||||
@@ -45,6 +46,7 @@ mod frontdoor_loop_guard;
|
||||
mod handlers;
|
||||
mod headers;
|
||||
mod hooks;
|
||||
mod important_notification;
|
||||
mod log_ids;
|
||||
mod maintenance;
|
||||
pub(crate) mod middleware;
|
||||
|
||||
@@ -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_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, ProxyUpgradeRolloutCancelSummary,
|
||||
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_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, ProxyUpgradeRolloutTrackedNodeState,
|
||||
|
||||
@@ -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"]
|
||||
@@ -83,6 +85,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::*;
|
||||
@@ -129,6 +134,8 @@ const PROXY_NODE_STALE_MISSED_HEARTBEATS: u64 = 3;
|
||||
const POOL_MONITOR_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const OAUTH_TOKEN_REFRESH_INTERVAL: Duration = Duration::from_secs(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;
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
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::{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,
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,19 @@ use super::{
|
||||
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,
|
||||
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_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, 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,
|
||||
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
||||
};
|
||||
|
||||
const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14;
|
||||
@@ -202,6 +202,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<()>> {
|
||||
|
||||
@@ -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;
|
||||
@@ -1204,6 +1205,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()),
|
||||
|
||||
@@ -35,6 +35,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";
|
||||
@@ -198,6 +199,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
@@ -1160,7 +1164,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" => {
|
||||
@@ -1231,11 +1235,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" => {
|
||||
@@ -1258,7 +1262,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"
|
||||
@@ -1568,6 +1574,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)),
|
||||
@@ -1718,6 +1730,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],
|
||||
@@ -1771,6 +1821,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() => {
|
||||
@@ -2827,6 +2919,9 @@ 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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -912,6 +912,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> {
|
||||
|
||||
@@ -661,6 +661,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
ops_quota_alert_enabled?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 黑白名单,控制系统访问权限',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -252,6 +252,17 @@ 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'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
355
frontend/src/views/admin/modules/ImportantNotification.vue
Normal file
355
frontend/src/views/admin/modules/ImportantNotification.vue
Normal 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 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>
|
||||
205
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal file
205
frontend/src/views/admin/modules/ServerChanSettings.vue
Normal 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}** {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>
|
||||
Reference in New Issue
Block a user