mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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,
|
provider_quota_refresh_endpoint_for_provider, provider_type_supports_quota_refresh,
|
||||||
reconcile_admin_fixed_provider_template_endpoints,
|
reconcile_admin_fixed_provider_template_endpoints,
|
||||||
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
|
refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
|
||||||
update_existing_provider_oauth_catalog_key, AdminAppState,
|
store_admin_provider_ops_balance_cache, update_existing_provider_oauth_catalog_key,
|
||||||
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
|
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError,
|
||||||
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,
|
AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
|
||||||
AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REQUEST_FAILED_PREFIX,
|
AdminStatsTimeRange, AdminStatsUsageFilter, OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||||
|
OAUTH_REQUEST_FAILED_PREFIX,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::handlers::admin::{
|
use crate::handlers::admin::{
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ pub(super) fn classify_admin_system_family_route(
|
|||||||
"admin:system",
|
"admin:system",
|
||||||
false,
|
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" {
|
} else if method == http::Method::POST && normalized_path == "/api/admin/system/cleanup" {
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"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/users/import", "users_import"),
|
||||||
("/api/admin/system/data/import", "data_import"),
|
("/api/admin/system/data/import", "data_import"),
|
||||||
("/api/admin/system/smtp/test", "smtp_test"),
|
("/api/admin/system/smtp/test", "smtp_test"),
|
||||||
|
(
|
||||||
|
"/api/admin/system/important-notification/test",
|
||||||
|
"important_notification_test",
|
||||||
|
),
|
||||||
("/api/admin/system/cleanup", "cleanup"),
|
("/api/admin/system/cleanup", "cleanup"),
|
||||||
("/api/admin/system/purge/config", "purge_config"),
|
("/api/admin/system/purge/config", "purge_config"),
|
||||||
("/api/admin/system/purge/users", "purge_users"),
|
("/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,
|
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::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::config::admin_provider_pool_config;
|
||||||
pub(crate) use self::provider::pool_admin::maybe_build_local_admin_pool_response;
|
pub(crate) use self::provider::pool_admin::maybe_build_local_admin_pool_response;
|
||||||
pub(crate) use self::provider::shared::payloads::{
|
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<'_>,
|
state: &AdminAppState<'_>,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
payload: &Value,
|
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::handlers::admin::request::AdminAppState;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::provider::ops as admin_provider_ops_pure;
|
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 serde_json::json;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
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(
|
pub(super) fn admin_provider_ops_config_object(
|
||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
) -> 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>>();
|
.collect::<serde_json::Map<String, serde_json::Value>>();
|
||||||
|
let quota_alert = normalize_admin_provider_ops_quota_alert(payload.quota_alert)?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"architecture_id": payload.architecture_id,
|
"architecture_id": payload.architecture_id,
|
||||||
@@ -287,9 +295,48 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
|
|||||||
},
|
},
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
"schedule": payload.schedule,
|
"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(
|
pub(super) fn resolve_admin_provider_ops_base_url(
|
||||||
provider: &StoredProviderCatalogProvider,
|
provider: &StoredProviderCatalogProvider,
|
||||||
endpoints: &[StoredProviderCatalogEndpoint],
|
endpoints: &[StoredProviderCatalogEndpoint],
|
||||||
@@ -356,5 +403,10 @@ pub(super) fn build_admin_provider_ops_config_payload(
|
|||||||
connector.and_then(|connector| connector.get("credentials")),
|
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 routes;
|
||||||
mod support;
|
mod support;
|
||||||
mod verify;
|
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;
|
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>,
|
pub(crate) actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) schedule: BTreeMap<String, String>,
|
pub(crate) schedule: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) quota_alert: Option<AdminProviderOpsQuotaAlertConfigRequest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -52,6 +54,16 @@ pub(super) struct AdminProviderOpsActionConfigRequest {
|
|||||||
pub(crate) config: serde_json::Map<String, serde_json::Value>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct AdminProviderOpsConnectRequest {
|
pub(super) struct AdminProviderOpsConnectRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -71,3 +83,28 @@ fn default_admin_provider_ops_architecture_id() -> String {
|
|||||||
fn default_admin_provider_ops_action_enabled() -> bool {
|
fn default_admin_provider_ops_action_enabled() -> bool {
|
||||||
true
|
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(|cfg| cfg.get("architecture_id"))
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToOwned::to_owned);
|
.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
|
let billing_type = quota_snapshot
|
||||||
.map(|quota| quota.billing_type.clone())
|
.map(|quota| quota.billing_type.clone())
|
||||||
.or_else(|| provider.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,
|
"endpoint_health_details": endpoint_health_details,
|
||||||
"ops_configured": ops_configured,
|
"ops_configured": ops_configured,
|
||||||
"ops_architecture_id": ops_architecture_id,
|
"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),
|
"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),
|
"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,
|
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::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::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_data_contracts::repository::usage::UsageCleanupTargets;
|
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 {
|
if decision.route_kind.as_deref() == Some("cleanup") && request_method == http::Method::POST {
|
||||||
return Ok(Some(attach_admin_audit_response(
|
return Ok(Some(attach_admin_audit_response(
|
||||||
Json(build_admin_system_cleanup_payload(state).await?).into_response(),
|
Json(build_admin_system_cleanup_payload(state).await?).into_response(),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use crate::handlers::admin::request::AdminAppState;
|
use crate::handlers::admin::request::AdminAppState;
|
||||||
use crate::handlers::shared::{module_available_from_env, system_config_bool};
|
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::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
use aether_admin::system as admin_system_kernel;
|
use aether_admin::system as admin_system_kernel;
|
||||||
@@ -68,14 +72,14 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
|||||||
admin_menu_order: 59,
|
admin_menu_order: 59,
|
||||||
},
|
},
|
||||||
AdminModuleDefinition {
|
AdminModuleDefinition {
|
||||||
name: "notification_email",
|
name: "important_notification",
|
||||||
display_name: "异常通知",
|
display_name: "重要通知",
|
||||||
description: "为 5xx 异常发送邮件通知,可在模块管理中启用或禁用",
|
description: "统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用",
|
||||||
category: "integration",
|
category: "integration",
|
||||||
env_key: "NOTIFICATION_EMAIL_AVAILABLE",
|
env_key: "NOTIFICATION_EMAIL_AVAILABLE",
|
||||||
default_available: true,
|
default_available: true,
|
||||||
admin_route: None,
|
admin_route: Some("/admin/modules/important-notification"),
|
||||||
admin_menu_icon: Some("Mail"),
|
admin_menu_icon: Some("BellRing"),
|
||||||
admin_menu_group: Some("system"),
|
admin_menu_group: Some("system"),
|
||||||
admin_menu_order: 58,
|
admin_menu_order: 58,
|
||||||
},
|
},
|
||||||
@@ -126,10 +130,15 @@ pub(crate) struct AdminModuleRuntimeState {
|
|||||||
oauth_providers: Vec<aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig>,
|
oauth_providers: Vec<aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig>,
|
||||||
ldap_config: Option<aether_data::repository::auth_modules::StoredLdapModuleConfig>,
|
ldap_config: Option<aether_data::repository::auth_modules::StoredLdapModuleConfig>,
|
||||||
gemini_files_has_capable_key: bool,
|
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> {
|
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
|
ADMIN_MODULE_DEFINITIONS
|
||||||
.iter()
|
.iter()
|
||||||
.find(|module| module.name == name)
|
.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 {
|
pub(crate) fn admin_module_enabled_config_key(module: &AdminModuleDefinition) -> String {
|
||||||
if module.name == "model_directives" {
|
if module.name == "model_directives" {
|
||||||
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
|
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
|
||||||
|
} else if module.name == "important_notification" {
|
||||||
|
IMPORTANT_NOTIFICATION_ENABLED_KEY.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("module.{}.enabled", module.name)
|
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 notification_configured = important_notification_configured(state.app()).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();
|
|
||||||
|
|
||||||
Ok(AdminModuleRuntimeState {
|
Ok(AdminModuleRuntimeState {
|
||||||
oauth_providers,
|
oauth_providers,
|
||||||
ldap_config,
|
ldap_config,
|
||||||
gemini_files_has_capable_key,
|
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.oauth_providers,
|
||||||
runtime.ldap_config.as_ref(),
|
runtime.ldap_config.as_ref(),
|
||||||
runtime.gemini_files_has_capable_key,
|
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> {
|
) -> Result<serde_json::Value, GatewayError> {
|
||||||
let available = module_available_from_env(module.env_key, module.default_available);
|
let available = module_available_from_env(module.env_key, module.default_available);
|
||||||
let enabled = if available {
|
let enabled = if available {
|
||||||
let enabled = state
|
let enabled_value = state
|
||||||
.read_system_config_json_value(&admin_module_enabled_config_key(module))
|
.read_system_config_json_value(&admin_module_enabled_config_key(module))
|
||||||
.await?;
|
.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 {
|
} else {
|
||||||
false
|
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("config_set"))
|
||||||
| (Some("system_manage"), http::Method::PUT, Some("email_template_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("email_template_preview"))
|
||||||
|
| (
|
||||||
|
Some("system_manage"),
|
||||||
|
http::Method::POST,
|
||||||
|
Some("important_notification_test"),
|
||||||
|
)
|
||||||
| (
|
| (
|
||||||
Some("provider_models_manage"),
|
Some("provider_models_manage"),
|
||||||
http::Method::POST,
|
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 control;
|
||||||
mod data;
|
mod data;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
|
mod email_delivery;
|
||||||
mod error;
|
mod error;
|
||||||
mod execution_runtime;
|
mod execution_runtime;
|
||||||
mod executor;
|
mod executor;
|
||||||
@@ -45,6 +46,7 @@ mod frontdoor_loop_guard;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod headers;
|
mod headers;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
mod important_notification;
|
||||||
mod log_ids;
|
mod log_ids;
|
||||||
mod maintenance;
|
mod maintenance;
|
||||||
pub(crate) mod middleware;
|
pub(crate) mod middleware;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ pub(crate) use runtime::{
|
|||||||
ensure_provider_key_pool_scores_for_keys, inspect_proxy_upgrade_rollout,
|
ensure_provider_key_pool_scores_for_keys, inspect_proxy_upgrade_rollout,
|
||||||
list_admin_cleanup_run_records, perform_account_self_check_once,
|
list_admin_cleanup_run_records, perform_account_self_check_once,
|
||||||
perform_oauth_token_refresh_once, perform_pool_quota_probe_once, perform_provider_checkin_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,
|
perform_provider_quota_alert_once, pool_quota_probe_target_count, preview_manual_usage_cleanup,
|
||||||
record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
|
rebuild_admin_stats_once, record_completed_cleanup_run, record_proxy_upgrade_traffic_success,
|
||||||
restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
|
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,
|
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,
|
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_pending_cleanup_worker, spawn_pool_monitor_worker,
|
||||||
spawn_pool_quota_probe_replenish_for_request, spawn_pool_quota_probe_worker,
|
spawn_pool_quota_probe_replenish_for_request, spawn_pool_quota_probe_worker,
|
||||||
spawn_pool_score_rebuild_worker, spawn_provider_checkin_worker,
|
spawn_pool_score_rebuild_worker, spawn_provider_checkin_worker,
|
||||||
spawn_proxy_node_metrics_cleanup_worker, spawn_proxy_node_stale_cleanup_worker,
|
spawn_provider_quota_alert_worker, spawn_proxy_node_metrics_cleanup_worker,
|
||||||
spawn_proxy_upgrade_rollout_worker, spawn_request_candidate_cleanup_worker,
|
spawn_proxy_node_stale_cleanup_worker, spawn_proxy_upgrade_rollout_worker,
|
||||||
spawn_stats_aggregation_worker, spawn_stats_hourly_aggregation_worker,
|
spawn_request_candidate_cleanup_worker, spawn_stats_aggregation_worker,
|
||||||
spawn_usage_cleanup_worker, spawn_wallet_daily_usage_aggregation_worker,
|
spawn_stats_hourly_aggregation_worker, spawn_usage_cleanup_worker,
|
||||||
start_admin_request_body_cleanup_task, start_admin_system_purge_task,
|
spawn_wallet_daily_usage_aggregation_worker, start_admin_request_body_cleanup_task,
|
||||||
start_manual_usage_cleanup_task, start_proxy_upgrade_rollout, AccountSelfCheckRunSummary,
|
start_admin_system_purge_task, start_manual_usage_cleanup_task, start_proxy_upgrade_rollout,
|
||||||
AdminCleanupRunRecord, AdminCleanupTaskKind, AdminStatsRebuildSummary,
|
AccountSelfCheckRunSummary, AdminCleanupRunRecord, AdminCleanupTaskKind,
|
||||||
AdminSystemCleanupSummary, ManualUsageCleanupError, ManualUsageCleanupMode,
|
AdminStatsRebuildSummary, AdminSystemCleanupSummary, ManualUsageCleanupError,
|
||||||
ManualUsageCleanupOptions, OAuthTokenRefreshRunSummary, PoolQuotaProbeRunSummary,
|
ManualUsageCleanupMode, ManualUsageCleanupOptions, OAuthTokenRefreshRunSummary,
|
||||||
PoolQuotaProbeWorkerConfig, ProviderCheckinRunSummary, ProxyUpgradeRolloutCancelSummary,
|
PoolQuotaProbeRunSummary, PoolQuotaProbeWorkerConfig, ProviderCheckinRunSummary,
|
||||||
|
ProviderQuotaAlertRunSummary, ProxyUpgradeRolloutCancelSummary,
|
||||||
ProxyUpgradeRolloutConflictClearSummary, ProxyUpgradeRolloutNodeActionSummary,
|
ProxyUpgradeRolloutConflictClearSummary, ProxyUpgradeRolloutNodeActionSummary,
|
||||||
ProxyUpgradeRolloutProbeConfig, ProxyUpgradeRolloutSkippedRestoreSummary,
|
ProxyUpgradeRolloutProbeConfig, ProxyUpgradeRolloutSkippedRestoreSummary,
|
||||||
ProxyUpgradeRolloutStatus, ProxyUpgradeRolloutTrackedNodeState,
|
ProxyUpgradeRolloutStatus, ProxyUpgradeRolloutTrackedNodeState,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ mod pool_quota_probe;
|
|||||||
mod pool_score_rebuild;
|
mod pool_score_rebuild;
|
||||||
#[path = "runtime/provider_checkin.rs"]
|
#[path = "runtime/provider_checkin.rs"]
|
||||||
mod provider_checkin;
|
mod provider_checkin;
|
||||||
|
#[path = "runtime/provider_quota_alert.rs"]
|
||||||
|
mod provider_quota_alert;
|
||||||
#[path = "runtime/proxy_node_metrics_cleanup.rs"]
|
#[path = "runtime/proxy_node_metrics_cleanup.rs"]
|
||||||
mod proxy_node_metrics_cleanup;
|
mod proxy_node_metrics_cleanup;
|
||||||
#[path = "runtime/proxy_node_staleness.rs"]
|
#[path = "runtime/proxy_node_staleness.rs"]
|
||||||
@@ -83,6 +85,9 @@ pub(crate) use pool_score_rebuild::{
|
|||||||
PoolScoreRebuildRunSummary, PoolScoreRebuildWorkerConfig,
|
PoolScoreRebuildRunSummary, PoolScoreRebuildWorkerConfig,
|
||||||
};
|
};
|
||||||
pub(crate) use provider_checkin::{perform_provider_checkin_once, ProviderCheckinRunSummary};
|
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_metrics_cleanup::*;
|
||||||
use proxy_node_staleness::*;
|
use proxy_node_staleness::*;
|
||||||
use proxy_upgrade_rollout::*;
|
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 POOL_MONITOR_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
const OAUTH_TOKEN_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
const OAUTH_TOKEN_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
const PROVIDER_CHECKIN_CONCURRENCY: usize = 3;
|
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 PROVIDER_CHECKIN_DEFAULT_TIME: &str = "01:05";
|
||||||
const REQUEST_CANDIDATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
const REQUEST_CANDIDATE_CLEANUP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
const STATS_DAILY_AGGREGATION_HOUR: u32 = 0;
|
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_daily_run, duration_until_next_db_maintenance_run,
|
||||||
duration_until_next_stats_aggregation_run, duration_until_next_stats_hourly_aggregation_run,
|
duration_until_next_stats_aggregation_run, duration_until_next_stats_hourly_aggregation_run,
|
||||||
maintenance_timezone, parse_hhmm_time, perform_oauth_token_refresh_once,
|
maintenance_timezone, parse_hhmm_time, perform_oauth_token_refresh_once,
|
||||||
provider_checkin_schedule, run_audit_cleanup_once, run_db_maintenance_once,
|
perform_provider_quota_alert_once, provider_checkin_schedule, run_audit_cleanup_once,
|
||||||
run_gemini_file_mapping_cleanup_once, run_pending_cleanup_once, run_pool_monitor_once,
|
run_db_maintenance_once, run_gemini_file_mapping_cleanup_once, run_pending_cleanup_once,
|
||||||
run_provider_checkin_once, run_proxy_node_metrics_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_proxy_node_stale_cleanup_once, run_proxy_upgrade_rollout_once,
|
||||||
run_request_candidate_cleanup_once, run_stats_aggregation_once,
|
run_request_candidate_cleanup_once, run_stats_aggregation_once,
|
||||||
run_stats_hourly_aggregation_once, run_usage_cleanup_once,
|
run_stats_hourly_aggregation_once, run_usage_cleanup_once,
|
||||||
run_wallet_daily_usage_aggregation_once, AUDIT_LOG_CLEANUP_INTERVAL,
|
run_wallet_daily_usage_aggregation_once, AUDIT_LOG_CLEANUP_INTERVAL,
|
||||||
GEMINI_FILE_MAPPING_CLEANUP_INTERVAL, OAUTH_TOKEN_REFRESH_INTERVAL, PENDING_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,
|
POOL_MONITOR_INTERVAL, PROVIDER_CHECKIN_DEFAULT_TIME, PROVIDER_QUOTA_ALERT_INTERVAL,
|
||||||
PROXY_NODE_METRICS_CLEANUP_MINUTE, PROXY_NODE_STALE_SWEEP_INTERVAL,
|
PROXY_NODE_METRICS_CLEANUP_HOUR, PROXY_NODE_METRICS_CLEANUP_MINUTE,
|
||||||
PROXY_UPGRADE_ROLLOUT_INTERVAL, REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR,
|
PROXY_NODE_STALE_SWEEP_INTERVAL, PROXY_UPGRADE_ROLLOUT_INTERVAL,
|
||||||
USAGE_CLEANUP_MINUTE, WALLET_DAILY_USAGE_AGGREGATION_HOUR,
|
REQUEST_CANDIDATE_CLEANUP_INTERVAL, USAGE_CLEANUP_HOUR, USAGE_CLEANUP_MINUTE,
|
||||||
WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
WALLET_DAILY_USAGE_AGGREGATION_HOUR, WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14;
|
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(
|
pub(crate) fn spawn_oauth_token_refresh_worker(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
) -> Option<tokio::task::JoinHandle<()>> {
|
) -> 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_monitor_worker;
|
||||||
use crate::maintenance::spawn_pool_score_rebuild_worker;
|
use crate::maintenance::spawn_pool_score_rebuild_worker;
|
||||||
use crate::maintenance::spawn_provider_checkin_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_metrics_cleanup_worker;
|
||||||
use crate::maintenance::spawn_proxy_node_stale_cleanup_worker;
|
use crate::maintenance::spawn_proxy_node_stale_cleanup_worker;
|
||||||
use crate::maintenance::spawn_proxy_upgrade_rollout_worker;
|
use crate::maintenance::spawn_proxy_upgrade_rollout_worker;
|
||||||
@@ -1204,6 +1205,10 @@ impl AppState {
|
|||||||
crate::task_runtime::TASK_KEY_PROVIDER_CHECKIN,
|
crate::task_runtime::TASK_KEY_PROVIDER_CHECKIN,
|
||||||
spawn_provider_checkin_worker(self.clone()),
|
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(
|
supervise_worker(
|
||||||
crate::task_runtime::TASK_KEY_OAUTH_TOKEN_REFRESH,
|
crate::task_runtime::TASK_KEY_OAUTH_TOKEN_REFRESH,
|
||||||
spawn_oauth_token_refresh_worker(self.clone()),
|
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";
|
"maintenance.proxy.node.metrics.cleanup";
|
||||||
pub(crate) const TASK_KEY_PROXY_UPGRADE_ROLLOUT: &str = "maintenance.proxy.upgrade.rollout";
|
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_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_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_WALLET_DAILY_USAGE_AGG: &str = "maintenance.wallet.daily.usage.agg";
|
||||||
pub(crate) const TASK_KEY_STATS_DAILY_AGG: &str = "maintenance.stats.daily.agg";
|
pub(crate) const TASK_KEY_STATS_DAILY_AGG: &str = "maintenance.stats.daily.agg";
|
||||||
@@ -198,6 +199,14 @@ const TASK_DEFINITIONS: &[TaskDefinition] = &[
|
|||||||
true,
|
true,
|
||||||
RETRY_ONCE,
|
RETRY_ONCE,
|
||||||
),
|
),
|
||||||
|
TaskDefinition::new(
|
||||||
|
TASK_KEY_PROVIDER_QUOTA_ALERT,
|
||||||
|
TaskKind::Scheduled,
|
||||||
|
"interval",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
RETRY_ONCE,
|
||||||
|
),
|
||||||
TaskDefinition::new(
|
TaskDefinition::new(
|
||||||
TASK_KEY_USAGE_CLEANUP,
|
TASK_KEY_USAGE_CLEANUP,
|
||||||
TaskKind::Scheduled,
|
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![
|
.with_system_config_values_for_tests(vec![
|
||||||
("module.oauth.enabled".to_string(), json!(true)),
|
("module.oauth.enabled".to_string(), json!(true)),
|
||||||
("module.management_tokens.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_host".to_string(), json!("smtp.example.com")),
|
||||||
("smtp_from_email".to_string(), json!("ops@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"
|
"/admin/modules/chat-pii-redaction"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["notification_email"]["config_validated"],
|
payload["important_notification"]["config_validated"],
|
||||||
json!(true)
|
json!(true)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["important_notification"]["admin_route"],
|
||||||
|
"/admin/modules/important-notification"
|
||||||
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
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![
|
.with_system_config_values_for_tests(vec![
|
||||||
("module.oauth.enabled".to_string(), json!(true)),
|
("module.oauth.enabled".to_string(), json!(true)),
|
||||||
("module.management_tokens.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_host".to_string(), json!("smtp.example.com")),
|
||||||
("smtp_from_email".to_string(), json!("ops@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["oauth"]["config_validated"], json!(true));
|
||||||
assert_eq!(payload["management_tokens"]["active"], json!(true));
|
assert_eq!(payload["management_tokens"]["active"], json!(true));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["notification_email"]["config_validated"],
|
payload["important_notification"]["config_validated"],
|
||||||
json!(true)
|
json!(true)
|
||||||
);
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
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_success(
|
||||||
admin_provider_ops_verify_user_payload(
|
admin_provider_ops_verify_user_payload(
|
||||||
user_data
|
username_or_email.clone(),
|
||||||
.get("username")
|
username_or_email,
|
||||||
.or_else(|| user_data.get("email"))
|
admin_provider_ops_sub2api_non_empty_string(user_data, "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),
|
|
||||||
Some(balance + points),
|
Some(balance + points),
|
||||||
Some(extra),
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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]
|
#[test]
|
||||||
fn anyrouter_verify_payload_uses_cookie_auth_messages_and_usage_fields() {
|
fn anyrouter_verify_payload_uses_cookie_auth_messages_and_usage_fields() {
|
||||||
let payload = admin_provider_ops_anyrouter_verify_payload(
|
let payload = admin_provider_ops_anyrouter_verify_payload(
|
||||||
|
|||||||
@@ -667,7 +667,11 @@ struct AdminApiFormatDefinition {
|
|||||||
|
|
||||||
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
||||||
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_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] = &[
|
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||||
AdminApiFormatDefinition {
|
AdminApiFormatDefinition {
|
||||||
value: "openai:chat",
|
value: "openai:chat",
|
||||||
@@ -1160,7 +1164,7 @@ pub fn build_admin_module_validation_result(
|
|||||||
oauth_providers: &[StoredOAuthProviderModuleConfig],
|
oauth_providers: &[StoredOAuthProviderModuleConfig],
|
||||||
ldap_config: Option<&StoredLdapModuleConfig>,
|
ldap_config: Option<&StoredLdapModuleConfig>,
|
||||||
gemini_files_has_capable_key: bool,
|
gemini_files_has_capable_key: bool,
|
||||||
smtp_configured: bool,
|
important_notification_configured: bool,
|
||||||
) -> (bool, Option<String>) {
|
) -> (bool, Option<String>) {
|
||||||
match module_name {
|
match module_name {
|
||||||
"oauth" => {
|
"oauth" => {
|
||||||
@@ -1231,11 +1235,11 @@ pub fn build_admin_module_validation_result(
|
|||||||
}
|
}
|
||||||
(true, None)
|
(true, None)
|
||||||
}
|
}
|
||||||
"notification_email" => {
|
"important_notification" | "notification_email" => {
|
||||||
if smtp_configured {
|
if important_notification_configured {
|
||||||
(true, None)
|
(true, None)
|
||||||
} else {
|
} else {
|
||||||
(false, Some("请先完成邮件配置(SMTP)".to_string()))
|
(false, Some("请先完成重要通知通道配置".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"gemini_files" => {
|
"gemini_files" => {
|
||||||
@@ -1258,7 +1262,9 @@ pub fn build_admin_module_health(
|
|||||||
gemini_files_has_capable_key: bool,
|
gemini_files_has_capable_key: bool,
|
||||||
) -> &'static str {
|
) -> &'static str {
|
||||||
match module_name {
|
match module_name {
|
||||||
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
|
"management_tokens" | "model_directives" | "proxy_nodes" | "important_notification" => {
|
||||||
|
"healthy"
|
||||||
|
}
|
||||||
"gemini_files" => {
|
"gemini_files" => {
|
||||||
if gemini_files_has_capable_key {
|
if gemini_files_has_capable_key {
|
||||||
"healthy"
|
"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_email" => Some(serde_json::Value::Null),
|
||||||
"smtp_from_name" => Some(json!("Aether")),
|
"smtp_from_name" => Some(json!("Aether")),
|
||||||
"enable_oauth_token_refresh" => Some(json!(true)),
|
"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.enabled" => Some(json!(false)),
|
||||||
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
|
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
|
||||||
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
|
"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))
|
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(
|
pub fn parse_admin_system_config_update(
|
||||||
requested_key: &str,
|
requested_key: &str,
|
||||||
request_body: &[u8],
|
request_body: &[u8],
|
||||||
@@ -1771,6 +1821,48 @@ pub fn parse_admin_system_config_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match normalized_key.as_str() {
|
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() {
|
"module.chat_pii_redaction.enabled" => match value.as_bool() {
|
||||||
Some(enabled) => value = json!(enabled),
|
Some(enabled) => value = json!(enabled),
|
||||||
None if value.is_null() => {
|
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("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("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"));
|
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -912,6 +912,19 @@ export const adminApi = {
|
|||||||
return response.data
|
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> {
|
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
|
||||||
|
|||||||
@@ -661,6 +661,7 @@ export interface ProviderWithEndpointsSummary {
|
|||||||
failover_rules?: FailoverRulesConfig | null
|
failover_rules?: FailoverRulesConfig | null
|
||||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||||
|
ops_quota_alert_enabled?: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_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 {
|
export interface SaveConfigRequest {
|
||||||
architecture_id: string
|
architecture_id: string
|
||||||
base_url?: string
|
base_url?: string
|
||||||
connector: ConnectorConfigRequest
|
connector: ConnectorConfigRequest
|
||||||
actions: Record<string, ActionConfigRequest>
|
actions: Record<string, ActionConfigRequest>
|
||||||
schedule: Record<string, string>
|
schedule: Record<string, string>
|
||||||
|
quota_alert?: QuotaAlertConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 连接请求 */
|
/** 连接请求 */
|
||||||
@@ -190,6 +197,7 @@ export interface ProviderOpsConfigResponse {
|
|||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
credentials: 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'
|
import type { LucideIcon } from 'lucide-vue-next'
|
||||||
|
|
||||||
export interface BuiltinTool {
|
export interface BuiltinTool {
|
||||||
@@ -15,6 +15,12 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
|
|||||||
href: '/admin/email',
|
href: '/admin/email',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Server 酱',
|
||||||
|
description: '配置 Server 酱 SendKey 与通知模板',
|
||||||
|
href: '/admin/server-chan',
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'IP 安全',
|
name: 'IP 安全',
|
||||||
description: '管理 IP 黑白名单,控制系统访问权限',
|
description: '管理 IP 黑白名单,控制系统访问权限',
|
||||||
|
|||||||
@@ -239,6 +239,47 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -305,6 +346,7 @@ import {
|
|||||||
getProviderOpsConfig,
|
getProviderOpsConfig,
|
||||||
deleteProviderOpsConfig,
|
deleteProviderOpsConfig,
|
||||||
type ArchitectureInfo,
|
type ArchitectureInfo,
|
||||||
|
type QuotaAlertConfig,
|
||||||
} from '@/api/providerOps'
|
} from '@/api/providerOps'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
@@ -375,6 +417,12 @@ const architecturesLoaded = ref(false)
|
|||||||
const selectedArchitectureId = ref('new_api')
|
const selectedArchitectureId = ref('new_api')
|
||||||
const selectedAuthType = ref('')
|
const selectedAuthType = ref('')
|
||||||
const formData = ref<Record<string, unknown>>({})
|
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(() => {
|
const currentAuthTypes = computed(() => {
|
||||||
@@ -421,8 +469,15 @@ const canVerify = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 保存按钮是否可用:验证成功且表单未变动
|
// 保存按钮是否可用:验证成功且表单未变动
|
||||||
|
const quotaAlertChanged = computed(() => {
|
||||||
|
return quotaAlertSignature(quotaAlert.value) !== savedQuotaAlertSignature.value
|
||||||
|
})
|
||||||
|
|
||||||
const canSave = computed(() => {
|
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()
|
return quota.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | null {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : null
|
||||||
|
}
|
||||||
|
|
||||||
async function handleVerify() {
|
async function handleVerify() {
|
||||||
const schema = currentSchema.value
|
const schema = currentSchema.value
|
||||||
if (!schema) return
|
if (!schema) return
|
||||||
@@ -565,8 +625,10 @@ async function handleVerify() {
|
|||||||
const displayName = result.data?.display_name || result.data?.username
|
const displayName = result.data?.display_name || result.data?.username
|
||||||
const extra = result.data?.extra
|
const extra = result.data?.extra
|
||||||
let balanceText = `余额: ${formatQuota(quota)}`
|
let balanceText = `余额: ${formatQuota(quota)}`
|
||||||
if (extra && extra.balance !== undefined && extra.points !== undefined) {
|
const extraBalance = finiteNumber(extra?.balance)
|
||||||
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
|
const extraPoints = finiteNumber(extra?.points)
|
||||||
|
if (extraBalance !== null && extraPoints !== null) {
|
||||||
|
balanceText = `余额: ${formatQuota(extraBalance)} | 积分: ${formatQuota(extraPoints)}`
|
||||||
}
|
}
|
||||||
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
|
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
|
||||||
}
|
}
|
||||||
@@ -624,8 +686,10 @@ async function handleSave() {
|
|||||||
formData.value,
|
formData.value,
|
||||||
props.providerWebsite,
|
props.providerWebsite,
|
||||||
)
|
)
|
||||||
|
request.quota_alert = normalizedQuotaAlert()
|
||||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
savedQuotaAlertSignature.value = quotaAlertSignature(quotaAlert.value)
|
||||||
showSuccess(result.message || '配置已保存', '保存成功')
|
showSuccess(result.message || '配置已保存', '保存成功')
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
@@ -660,6 +724,7 @@ async function handleClear() {
|
|||||||
formChanged.value = false
|
formChanged.value = false
|
||||||
selectedArchitectureId.value = 'new_api'
|
selectedArchitectureId.value = 'new_api'
|
||||||
selectedAuthType.value = ''
|
selectedAuthType.value = ''
|
||||||
|
loadQuotaAlert(null)
|
||||||
resetFormData()
|
resetFormData()
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('update:open', false)
|
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>) {
|
function loadFromConfig(config: Record<string, unknown>) {
|
||||||
if (!config?.connector) return
|
const connector = isRecord(config.connector) ? config.connector : null
|
||||||
|
if (!connector) return
|
||||||
|
|
||||||
hasExistingConfig.value = true
|
hasExistingConfig.value = true
|
||||||
|
|
||||||
// 根据已保存的 architecture_id 选择对应架构
|
// 根据已保存的 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)
|
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
|
||||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||||
|
|
||||||
// 从已保存的 connector auth_type 恢复认证方式选择
|
// 从已保存的 connector auth_type 恢复认证方式选择
|
||||||
const savedAuthType = config.connector?.auth_type
|
const savedAuthType = stringOrDefault(connector.auth_type, '')
|
||||||
const authTypes = currentAuthTypes.value
|
const authTypes = currentAuthTypes.value
|
||||||
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
|
if (savedAuthType && authTypes.some((t) => t.type === savedAuthType)) {
|
||||||
selectedAuthType.value = savedAuthType
|
selectedAuthType.value = savedAuthType
|
||||||
@@ -694,7 +768,10 @@ function loadFromConfig(config: Record<string, unknown>) {
|
|||||||
|
|
||||||
const schema = currentSchema.value
|
const schema = currentSchema.value
|
||||||
if (schema) {
|
if (schema) {
|
||||||
const parsedData = parseConfigFromSchema(schema, config)
|
const parsedData = parseConfigFromSchema(schema, {
|
||||||
|
...config,
|
||||||
|
connector,
|
||||||
|
})
|
||||||
|
|
||||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||||
sensitivePlaceholders.value = {}
|
sensitivePlaceholders.value = {}
|
||||||
@@ -707,6 +784,46 @@ function loadFromConfig(config: Record<string, unknown>) {
|
|||||||
|
|
||||||
formData.value = parsedData
|
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,
|
architecture_id: config.architecture_id,
|
||||||
base_url: config.base_url,
|
base_url: config.base_url,
|
||||||
connector: config.connector,
|
connector: config.connector,
|
||||||
|
quota_alert: config.quota_alert,
|
||||||
}
|
}
|
||||||
loadFromConfig(configData)
|
loadFromConfig(configData)
|
||||||
} else {
|
} else {
|
||||||
hasExistingConfig.value = false
|
hasExistingConfig.value = false
|
||||||
sensitivePlaceholders.value = {}
|
sensitivePlaceholders.value = {}
|
||||||
|
loadQuotaAlert(null)
|
||||||
selectedArchitectureId.value = 'new_api'
|
selectedArchitectureId.value = 'new_api'
|
||||||
selectedAuthType.value = ''
|
selectedAuthType.value = ''
|
||||||
resetFormData()
|
resetFormData()
|
||||||
@@ -759,6 +878,7 @@ watch(
|
|||||||
} catch {
|
} catch {
|
||||||
hasExistingConfig.value = false
|
hasExistingConfig.value = false
|
||||||
sensitivePlaceholders.value = {}
|
sensitivePlaceholders.value = {}
|
||||||
|
loadQuotaAlert(null)
|
||||||
selectedArchitectureId.value = 'new_api'
|
selectedArchitectureId.value = 'new_api'
|
||||||
selectedAuthType.value = ''
|
selectedAuthType.value = ''
|
||||||
resetFormData()
|
resetFormData()
|
||||||
@@ -768,6 +888,7 @@ watch(
|
|||||||
} else {
|
} else {
|
||||||
hasExistingConfig.value = false
|
hasExistingConfig.value = false
|
||||||
sensitivePlaceholders.value = {}
|
sensitivePlaceholders.value = {}
|
||||||
|
loadQuotaAlert(null)
|
||||||
selectedArchitectureId.value = 'new_api'
|
selectedArchitectureId.value = 'new_api'
|
||||||
selectedAuthType.value = ''
|
selectedAuthType.value = ''
|
||||||
resetFormData()
|
resetFormData()
|
||||||
|
|||||||
@@ -252,6 +252,17 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
|
||||||
meta: { module: 'chat_pii_redaction' }
|
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',
|
path: 'email',
|
||||||
name: 'EmailSettings',
|
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