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:
yangrs
2026-05-18 23:51:14 +08:00
parent d51b44d642
commit 6c16f399d4
32 changed files with 2441 additions and 82 deletions

View File

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

View File

@@ -667,7 +667,11 @@ struct AdminApiFormatDefinition {
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &["smtp_password", "turnstile_secret_key"];
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
"smtp_password",
"turnstile_secret_key",
"module.important_notification.server_chan_send_key",
];
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
AdminApiFormatDefinition {
value: "openai:chat",
@@ -1160,7 +1164,7 @@ pub fn build_admin_module_validation_result(
oauth_providers: &[StoredOAuthProviderModuleConfig],
ldap_config: Option<&StoredLdapModuleConfig>,
gemini_files_has_capable_key: bool,
smtp_configured: bool,
important_notification_configured: bool,
) -> (bool, Option<String>) {
match module_name {
"oauth" => {
@@ -1231,11 +1235,11 @@ pub fn build_admin_module_validation_result(
}
(true, None)
}
"notification_email" => {
if smtp_configured {
"important_notification" | "notification_email" => {
if important_notification_configured {
(true, None)
} else {
(false, Some("请先完成邮件配置SMTP".to_string()))
(false, Some("请先完成重要通知通道配置".to_string()))
}
}
"gemini_files" => {
@@ -1258,7 +1262,9 @@ pub fn build_admin_module_health(
gemini_files_has_capable_key: bool,
) -> &'static str {
match module_name {
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
"management_tokens" | "model_directives" | "proxy_nodes" | "important_notification" => {
"healthy"
}
"gemini_files" => {
if gemini_files_has_capable_key {
"healthy"
@@ -1568,6 +1574,12 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"smtp_from_email" => Some(serde_json::Value::Null),
"smtp_from_name" => Some(json!("Aether")),
"enable_oauth_token_refresh" => Some(json!(true)),
"module.important_notification.enabled" => Some(json!(false)),
"module.important_notification.email_enabled" => Some(json!(false)),
"module.important_notification.email_recipients" => Some(json!("")),
"module.important_notification.server_chan_enabled" => Some(json!(false)),
"module.important_notification.server_chan_send_key" => Some(serde_json::Value::Null),
"module.important_notification.server_chan_template" => Some(json!("")),
"module.chat_pii_redaction.enabled" => Some(json!(false)),
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
@@ -1718,6 +1730,44 @@ fn normalize_chat_pii_redaction_rule_features(
Ok(Value::Object(features))
}
fn normalize_string_list_config_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
match value {
Value::Null => Ok(json!("")),
Value::String(raw) => Ok(json!(raw.trim())),
Value::Array(items) => {
let mut normalized = Vec::with_capacity(items.len());
for item in items {
let Some(raw) = item.as_str() else {
return Err(());
};
let raw = raw.trim();
if !raw.is_empty() {
normalized.push(raw.to_string());
}
}
Ok(json!(normalized))
}
_ => Err(()),
}
}
fn normalize_nullable_string_config_value(
value: serde_json::Value,
) -> Result<serde_json::Value, ()> {
match value {
Value::Null => Ok(Value::Null),
Value::String(raw) => {
let raw = raw.trim();
if raw.is_empty() {
Ok(Value::Null)
} else {
Ok(json!(raw))
}
}
_ => Err(()),
}
}
pub fn parse_admin_system_config_update(
requested_key: &str,
request_body: &[u8],
@@ -1771,6 +1821,48 @@ pub fn parse_admin_system_config_update(
}
match normalized_key.as_str() {
"module.important_notification.enabled"
| "module.important_notification.email_enabled"
| "module.important_notification.server_chan_enabled" => match value.as_bool() {
Some(enabled) => value = json!(enabled),
None if value.is_null() => {
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
}
None => {
return Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
));
}
},
"module.important_notification.email_recipients" => {
value = normalize_string_list_config_value(value).map_err(|_| {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
)
})?;
}
"module.important_notification.server_chan_send_key" => {
value = normalize_nullable_string_config_value(value).map_err(|_| {
(
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
)
})?;
}
"module.important_notification.server_chan_template" => {
value = match value {
Value::Null => json!(""),
Value::String(raw) => json!(raw),
_ => {
return Err((
http::StatusCode::BAD_REQUEST,
json!({ "detail": "请求数据验证失败" }),
));
}
};
}
"module.chat_pii_redaction.enabled" => match value.as_bool() {
Some(enabled) => value = json!(enabled),
None if value.is_null() => {
@@ -2827,6 +2919,9 @@ mod tests {
assert!(is_sensitive_admin_system_config_key("SMTP_PASSWORD"));
assert!(is_sensitive_admin_system_config_key("turnstile_secret_key"));
assert!(is_sensitive_admin_system_config_key("TURNSTILE_SECRET_KEY"));
assert!(is_sensitive_admin_system_config_key(
"module.important_notification.server_chan_send_key"
));
assert!(!is_sensitive_admin_system_config_key("site_name"));
}