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 <[email protected]>
This commit is contained in:
yangrs
2026-05-18 23:51:14 +08:00
co-authored by Claude Opus 4.7
parent d51b44d642
commit 6c16f399d4
32 changed files with 2441 additions and 82 deletions
@@ -70,7 +70,7 @@ pub(super) async fn read_admin_provider_ops_balance_cache(
}
}
pub(super) async fn store_admin_provider_ops_balance_cache(
pub(crate) async fn store_admin_provider_ops_balance_cache(
state: &AdminAppState<'_>,
provider_id: &str,
payload: &Value,
@@ -1,4 +1,7 @@
use super::support::{AdminProviderOpsSaveConfigRequest, ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS};
use super::support::{
AdminProviderOpsQuotaAlertConfigRequest, AdminProviderOpsSaveConfigRequest,
ADMIN_PROVIDER_OPS_SENSITIVE_FIELDS,
};
use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_admin::provider::ops as admin_provider_ops_pure;
@@ -8,6 +11,10 @@ use aether_data_contracts::repository::provider_catalog::{
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
const PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS: u64 = 30;
const PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS: u64 = 86_400;
pub(super) fn admin_provider_ops_config_object(
provider: &StoredProviderCatalogProvider,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
@@ -276,6 +283,7 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
)
})
.collect::<serde_json::Map<String, serde_json::Value>>();
let quota_alert = normalize_admin_provider_ops_quota_alert(payload.quota_alert)?;
Ok(json!({
"architecture_id": payload.architecture_id,
@@ -287,9 +295,48 @@ pub(super) fn build_admin_provider_ops_saved_config_value(
},
"actions": actions,
"schedule": payload.schedule,
"quota_alert": quota_alert,
}))
}
fn normalize_admin_provider_ops_quota_alert(
request: Option<AdminProviderOpsQuotaAlertConfigRequest>,
) -> Result<serde_json::Value, String> {
let Some(request) = request else {
return Ok(default_admin_provider_ops_quota_alert());
};
let threshold_amount = request.threshold_amount.unwrap_or(0.0);
if threshold_amount < 0.0 {
return Err("quota_alert.threshold_amount 必须大于等于 0".to_string());
}
let fetch_interval_seconds = request
.fetch_interval_seconds
.unwrap_or(PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS);
if !(PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS
..=PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS)
.contains(&fetch_interval_seconds)
{
return Err(format!(
"quota_alert.fetch_interval_seconds 必须在 {} 到 {} 秒之间",
PROVIDER_OPS_QUOTA_ALERT_MIN_FETCH_INTERVAL_SECS,
PROVIDER_OPS_QUOTA_ALERT_MAX_FETCH_INTERVAL_SECS
));
}
Ok(json!({
"enabled": request.enabled,
"threshold_amount": threshold_amount,
"fetch_interval_seconds": fetch_interval_seconds,
}))
}
fn default_admin_provider_ops_quota_alert() -> serde_json::Value {
json!({
"enabled": false,
"threshold_amount": 0.0,
"fetch_interval_seconds": PROVIDER_OPS_QUOTA_ALERT_DEFAULT_FETCH_INTERVAL_SECS,
})
}
pub(super) fn resolve_admin_provider_ops_base_url(
provider: &StoredProviderCatalogProvider,
endpoints: &[StoredProviderCatalogEndpoint],
@@ -356,5 +403,10 @@ pub(super) fn build_admin_provider_ops_config_payload(
connector.and_then(|connector| connector.get("credentials")),
),
},
"quota_alert": provider_ops_config
.get("quota_alert")
.filter(|value| value.is_object())
.cloned()
.unwrap_or_else(default_admin_provider_ops_quota_alert),
})
}
@@ -4,4 +4,5 @@ mod config;
mod routes;
mod support;
mod verify;
pub(crate) use self::balance_cache::store_admin_provider_ops_balance_cache;
pub(super) use self::routes::maybe_build_local_admin_provider_ops_providers_response;
@@ -33,6 +33,8 @@ pub(super) struct AdminProviderOpsSaveConfigRequest {
pub(crate) actions: BTreeMap<String, AdminProviderOpsActionConfigRequest>,
#[serde(default)]
pub(crate) schedule: BTreeMap<String, String>,
#[serde(default)]
pub(crate) quota_alert: Option<AdminProviderOpsQuotaAlertConfigRequest>,
}
#[derive(Debug, Deserialize)]
@@ -52,6 +54,16 @@ pub(super) struct AdminProviderOpsActionConfigRequest {
pub(crate) config: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsQuotaAlertConfigRequest {
#[serde(default)]
pub(crate) enabled: bool,
#[serde(default, deserialize_with = "deserialize_optional_f64_from_number")]
pub(crate) threshold_amount: Option<f64>,
#[serde(default)]
pub(crate) fetch_interval_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub(super) struct AdminProviderOpsConnectRequest {
#[serde(default)]
@@ -71,3 +83,28 @@ fn default_admin_provider_ops_architecture_id() -> String {
fn default_admin_provider_ops_action_enabled() -> bool {
true
}
fn deserialize_optional_f64_from_number<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::Number(number)) => number
.as_f64()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| serde::de::Error::custom("expected a finite number")),
Some(serde_json::Value::String(raw)) => raw
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(Some)
.ok_or_else(|| serde::de::Error::custom("expected a finite number or numeric string")),
Some(_) => Err(serde::de::Error::custom(
"expected a finite number or numeric string",
)),
}
}
@@ -132,6 +132,13 @@ pub(crate) fn build_admin_provider_summary_value(
.and_then(|cfg| cfg.get("architecture_id"))
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
let ops_quota_alert_enabled = provider_ops_config
.and_then(serde_json::Value::as_object)
.and_then(|cfg| cfg.get("quota_alert"))
.and_then(serde_json::Value::as_object)
.and_then(|cfg| cfg.get("enabled"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let billing_type = quota_snapshot
.map(|quota| quota.billing_type.clone())
.or_else(|| provider.billing_type.clone());
@@ -190,6 +197,7 @@ pub(crate) fn build_admin_provider_summary_value(
"endpoint_health_details": endpoint_health_details,
"ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id,
"ops_quota_alert_enabled": ops_quota_alert_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
})