mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: restructure notification services
This commit is contained in:
@@ -33,6 +33,21 @@ fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value> {
|
|||||||
admin_system_config_default_value_pure(key)
|
admin_system_config_default_value_pure(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_admin_system_config_fallback_key(normalized_key: &str) -> Option<&'static str> {
|
||||||
|
match normalized_key {
|
||||||
|
"module.server_chan_push.enabled" => {
|
||||||
|
Some("module.important_notification.server_chan_enabled")
|
||||||
|
}
|
||||||
|
"module.server_chan_push.send_key" => {
|
||||||
|
Some("module.important_notification.server_chan_send_key")
|
||||||
|
}
|
||||||
|
"module.server_chan_push.template" => {
|
||||||
|
Some("module.important_notification.server_chan_template")
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_admin_system_configs_payload(
|
pub(crate) fn build_admin_system_configs_payload(
|
||||||
entries: &[aether_data::repository::system::StoredSystemConfigEntry],
|
entries: &[aether_data::repository::system::StoredSystemConfigEntry],
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
@@ -44,12 +59,14 @@ pub(crate) async fn build_admin_system_config_detail_payload(
|
|||||||
requested_key: &str,
|
requested_key: &str,
|
||||||
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
) -> Result<Result<serde_json::Value, (http::StatusCode, serde_json::Value)>, GatewayError> {
|
||||||
let requested_key = requested_key.trim();
|
let requested_key = requested_key.trim();
|
||||||
let value = state
|
let normalized_key = normalize_admin_system_config_key(requested_key);
|
||||||
.read_system_config_json_value(&normalize_admin_system_config_key(requested_key))
|
let mut value = state.read_system_config_json_value(&normalized_key).await?;
|
||||||
.await?
|
if value.is_none() {
|
||||||
.or_else(|| {
|
if let Some(legacy_key) = legacy_admin_system_config_fallback_key(&normalized_key) {
|
||||||
admin_system_config_default_value(&normalize_admin_system_config_key(requested_key))
|
value = state.read_system_config_json_value(legacy_key).await?;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
let value = value.or_else(|| admin_system_config_default_value(&normalized_key));
|
||||||
Ok(build_admin_system_config_detail_payload_pure(
|
Ok(build_admin_system_config_detail_payload_pure(
|
||||||
requested_key,
|
requested_key,
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::important_notification::{
|
|||||||
important_notification_configured, IMPORTANT_NOTIFICATION_ENABLED_KEY,
|
important_notification_configured, IMPORTANT_NOTIFICATION_ENABLED_KEY,
|
||||||
LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY,
|
LEGACY_NOTIFICATION_EMAIL_ENABLED_KEY,
|
||||||
};
|
};
|
||||||
|
use crate::server_chan_push::server_chan_push_configured;
|
||||||
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;
|
||||||
@@ -73,16 +74,28 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
|||||||
},
|
},
|
||||||
AdminModuleDefinition {
|
AdminModuleDefinition {
|
||||||
name: "important_notification",
|
name: "important_notification",
|
||||||
display_name: "重要通知",
|
display_name: "通知服务",
|
||||||
description: "统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用",
|
description: "统一管理通知项、模板和推送服务选择,供后台任务和用户通知使用",
|
||||||
category: "integration",
|
category: "integration",
|
||||||
env_key: "IMPORTANT_NOTIFICATION_AVAILABLE",
|
env_key: "IMPORTANT_NOTIFICATION_AVAILABLE",
|
||||||
default_available: true,
|
default_available: true,
|
||||||
admin_route: Some("/admin/modules/important-notification"),
|
admin_route: Some("/admin/notification-service"),
|
||||||
admin_menu_icon: Some("BellRing"),
|
admin_menu_icon: Some("BellRing"),
|
||||||
admin_menu_group: Some("system"),
|
admin_menu_group: None,
|
||||||
admin_menu_order: 58,
|
admin_menu_order: 58,
|
||||||
},
|
},
|
||||||
|
AdminModuleDefinition {
|
||||||
|
name: "server_chan_push",
|
||||||
|
display_name: "Server 酱推送",
|
||||||
|
description: "第三方推送服务,配置 Server 酱 Turbo SendKey 并测试微信推送",
|
||||||
|
category: "integration",
|
||||||
|
env_key: "SERVER_CHAN_PUSH_AVAILABLE",
|
||||||
|
default_available: true,
|
||||||
|
admin_route: Some("/admin/modules/server-chan"),
|
||||||
|
admin_menu_icon: Some("Send"),
|
||||||
|
admin_menu_group: Some("system"),
|
||||||
|
admin_menu_order: 59,
|
||||||
|
},
|
||||||
AdminModuleDefinition {
|
AdminModuleDefinition {
|
||||||
name: "model_directives",
|
name: "model_directives",
|
||||||
display_name: "模型后缀参数",
|
display_name: "模型后缀参数",
|
||||||
@@ -155,6 +168,7 @@ pub(crate) struct AdminModuleRuntimeState {
|
|||||||
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,
|
||||||
important_notification_configured: bool,
|
important_notification_configured: bool,
|
||||||
|
server_chan_push_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> {
|
||||||
@@ -242,12 +256,14 @@ pub(crate) async fn build_admin_module_runtime_state(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let notification_configured = important_notification_configured(state.app()).await?;
|
let notification_configured = important_notification_configured(state.app()).await?;
|
||||||
|
let server_chan_configured = server_chan_push_configured(state.app()).await?;
|
||||||
|
|
||||||
Ok(AdminModuleRuntimeState {
|
Ok(AdminModuleRuntimeState {
|
||||||
oauth_providers,
|
oauth_providers,
|
||||||
ldap_config,
|
ldap_config,
|
||||||
gemini_files_has_capable_key,
|
gemini_files_has_capable_key,
|
||||||
important_notification_configured: notification_configured,
|
important_notification_configured: notification_configured,
|
||||||
|
server_chan_push_configured: server_chan_configured,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +277,7 @@ pub(crate) fn build_admin_module_validation_result(
|
|||||||
runtime.ldap_config.as_ref(),
|
runtime.ldap_config.as_ref(),
|
||||||
runtime.gemini_files_has_capable_key,
|
runtime.gemini_files_has_capable_key,
|
||||||
runtime.important_notification_configured,
|
runtime.important_notification_configured,
|
||||||
|
runtime.server_chan_push_configured,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::handlers::shared::{deserialize_optional_json_patch, normalize_feature_settings};
|
use crate::handlers::shared::{
|
||||||
|
deserialize_optional_json_patch, normalize_user_self_feature_settings_update,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
|
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
|
||||||
@@ -65,12 +67,24 @@ pub(super) async fn handle_users_me_detail_put(
|
|||||||
let email = normalize_users_me_optional_non_empty_string(payload.email);
|
let email = normalize_users_me_optional_non_empty_string(payload.email);
|
||||||
let username = normalize_users_me_optional_non_empty_string(payload.username);
|
let username = normalize_users_me_optional_non_empty_string(payload.username);
|
||||||
let feature_settings = match payload.feature_settings {
|
let feature_settings = match payload.feature_settings {
|
||||||
Some(value) => match normalize_feature_settings(value) {
|
Some(value) => {
|
||||||
Ok(value) => Some(value),
|
let current = match state.read_user_feature_settings(&auth.user.id).await {
|
||||||
Err(detail) => {
|
Ok(value) => value,
|
||||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
Err(err) => {
|
||||||
|
return build_auth_error_response(
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("user feature settings lookup failed: {err:?}"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match normalize_user_self_feature_settings_update(value, current) {
|
||||||
|
Ok(value) => Some(value),
|
||||||
|
Err(detail) => {
|
||||||
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
|
|||||||
pub(crate) use self::normalize::{
|
pub(crate) use self::normalize::{
|
||||||
deserialize_optional_json_patch, deserialize_optional_string_list_patch, ip_rules_allow,
|
deserialize_optional_json_patch, deserialize_optional_string_list_patch, ip_rules_allow,
|
||||||
json_ip_rules_allow, normalize_feature_settings, normalize_ip_rules, normalize_json_array,
|
json_ip_rules_allow, normalize_feature_settings, normalize_ip_rules, normalize_json_array,
|
||||||
normalize_json_object, normalize_string_list, parse_json_ip_rules,
|
normalize_json_object, normalize_string_list, normalize_user_self_feature_settings_update,
|
||||||
|
parse_json_ip_rules,
|
||||||
};
|
};
|
||||||
pub(crate) use self::payloads::{
|
pub(crate) use self::payloads::{
|
||||||
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<
|
|||||||
Value::Null => Ok(None),
|
Value::Null => Ok(None),
|
||||||
Value::Object(ref mut settings) => {
|
Value::Object(ref mut settings) => {
|
||||||
normalize_chat_pii_redaction_feature_settings(settings)?;
|
normalize_chat_pii_redaction_feature_settings(settings)?;
|
||||||
|
normalize_notification_push_service_feature_settings(settings)?;
|
||||||
if settings.is_empty() {
|
if settings.is_empty() {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -64,6 +65,41 @@ pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_user_self_feature_settings_update(
|
||||||
|
value: Option<Value>,
|
||||||
|
current: Option<Value>,
|
||||||
|
) -> Result<Option<Value>, String> {
|
||||||
|
let mut normalized = normalize_feature_settings(value)?;
|
||||||
|
let current_notification_push_service = current
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Value::Object(mut settings) => settings.remove("notification_push_service"),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.and_then(|value| {
|
||||||
|
let mut wrapper = Map::new();
|
||||||
|
wrapper.insert("notification_push_service".to_string(), value);
|
||||||
|
normalize_notification_push_service_feature_settings(&mut wrapper)
|
||||||
|
.ok()
|
||||||
|
.and_then(|_| wrapper.remove("notification_push_service"))
|
||||||
|
});
|
||||||
|
|
||||||
|
match (&mut normalized, current_notification_push_service) {
|
||||||
|
(Some(Value::Object(settings)), Some(value)) => {
|
||||||
|
settings.insert("notification_push_service".to_string(), value);
|
||||||
|
}
|
||||||
|
(Some(Value::Object(settings)), None) => {
|
||||||
|
settings.remove("notification_push_service");
|
||||||
|
}
|
||||||
|
(None, Some(value)) => {
|
||||||
|
let mut settings = Map::new();
|
||||||
|
settings.insert("notification_push_service".to_string(), value);
|
||||||
|
normalized = Some(Value::Object(settings));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_ip_rules(
|
pub(crate) fn normalize_ip_rules(
|
||||||
values: Option<Vec<String>>,
|
values: Option<Vec<String>>,
|
||||||
) -> Result<Option<Vec<String>>, String> {
|
) -> Result<Option<Vec<String>>, String> {
|
||||||
@@ -326,9 +362,47 @@ fn normalize_chat_pii_redaction_feature_object(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_push_service_feature_settings(
|
||||||
|
settings: &mut Map<String, Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(value) = settings.get_mut("notification_push_service") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match value {
|
||||||
|
Value::Null => {
|
||||||
|
settings.remove("notification_push_service");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Value::Object(feature) => {
|
||||||
|
normalize_notification_push_service_feature_object(feature)?;
|
||||||
|
if feature.is_empty() {
|
||||||
|
settings.remove("notification_push_service");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err("notification_push_service 必须是对象".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_push_service_feature_object(
|
||||||
|
feature: &mut Map<String, Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for key in ["enabled"] {
|
||||||
|
if let Some(value) = feature.get(key) {
|
||||||
|
if !value.is_boolean() {
|
||||||
|
return Err(format!("notification_push_service.{key} 必须是布尔值"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ip_rules_allow, json_ip_rules_allow, normalize_ip_rules, parse_json_ip_rules};
|
use super::{
|
||||||
|
ip_rules_allow, json_ip_rules_allow, normalize_feature_settings, normalize_ip_rules,
|
||||||
|
normalize_user_self_feature_settings_update, parse_json_ip_rules,
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
@@ -358,6 +432,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_feature_settings_accepts_notification_push_service_permission() {
|
||||||
|
let normalized = normalize_feature_settings(Some(json!({
|
||||||
|
"notification_push_service": {"enabled": true}
|
||||||
|
})))
|
||||||
|
.expect("feature settings should normalize")
|
||||||
|
.expect("feature settings should remain set");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalized["notification_push_service"]["enabled"],
|
||||||
|
json!(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_self_feature_update_preserves_notification_push_permission() {
|
||||||
|
let normalized = normalize_user_self_feature_settings_update(
|
||||||
|
Some(json!({
|
||||||
|
"chat_pii_redaction": {"enabled": true, "inject_model_instruction": false},
|
||||||
|
"notification_push_service": {"enabled": false}
|
||||||
|
})),
|
||||||
|
Some(json!({
|
||||||
|
"notification_push_service": {"enabled": true}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.expect("feature settings should normalize")
|
||||||
|
.expect("feature settings should remain set");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
normalized["notification_push_service"]["enabled"],
|
||||||
|
json!(true)
|
||||||
|
);
|
||||||
|
assert_eq!(normalized["chat_pii_redaction"]["enabled"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ip_rules_allow_applies_allow_rules_and_deny_overrides() {
|
fn ip_rules_allow_applies_allow_rules_and_deny_overrides() {
|
||||||
let rules = vec![
|
let rules = vec![
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use crate::admin_api::AdminAppState;
|
|||||||
use crate::email_delivery::{
|
use crate::email_delivery::{
|
||||||
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
|
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
|
||||||
};
|
};
|
||||||
use crate::handlers::shared::{
|
use crate::handlers::shared::{system_config_bool, system_config_string};
|
||||||
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
use crate::server_chan_push::{
|
||||||
|
read_server_chan_push_config, send_server_chan_push, ServerChanPushConfig,
|
||||||
};
|
};
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
@@ -17,14 +18,10 @@ pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_ENABLED_KEY: &str =
|
|||||||
"module.important_notification.email_enabled";
|
"module.important_notification.email_enabled";
|
||||||
pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY: &str =
|
pub(crate) const IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY: &str =
|
||||||
"module.important_notification.email_recipients";
|
"module.important_notification.email_recipients";
|
||||||
pub(crate) const IMPORTANT_NOTIFICATION_SERVER_CHAN_ENABLED_KEY: &str =
|
pub(crate) const IMPORTANT_NOTIFICATION_DEFAULT_CHANNEL_KEY: &str =
|
||||||
"module.important_notification.server_chan_enabled";
|
"module.important_notification.default_channel";
|
||||||
pub(crate) const IMPORTANT_NOTIFICATION_SERVER_CHAN_SEND_KEY_KEY: &str =
|
pub(crate) const IMPORTANT_NOTIFICATION_ITEMS_KEY: &str = "module.important_notification.items";
|
||||||
"module.important_notification.server_chan_send_key";
|
pub(crate) const PROVIDER_QUOTA_ALERT_ITEM_KEY: &str = "provider_quota_alert";
|
||||||
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ImportantNotification {
|
pub(crate) struct ImportantNotification {
|
||||||
@@ -45,9 +42,27 @@ struct ImportantNotificationConfig {
|
|||||||
module_enabled: bool,
|
module_enabled: bool,
|
||||||
email_enabled: bool,
|
email_enabled: bool,
|
||||||
email_recipients: Vec<String>,
|
email_recipients: Vec<String>,
|
||||||
server_chan_enabled: bool,
|
default_channel: ImportantNotificationChannelFilter,
|
||||||
server_chan_send_key: Option<String>,
|
items: Vec<ImportantNotificationItemConfig>,
|
||||||
server_chan_template: Option<String>,
|
server_chan: ServerChanPushConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ImportantNotificationItemConfig {
|
||||||
|
key: String,
|
||||||
|
name: String,
|
||||||
|
enabled: bool,
|
||||||
|
channel: Option<ImportantNotificationChannelFilter>,
|
||||||
|
title_template: Option<String>,
|
||||||
|
markdown_template: Option<String>,
|
||||||
|
text_template: Option<String>,
|
||||||
|
user_email_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct NotificationChannelReadiness {
|
||||||
|
email: bool,
|
||||||
|
server_chan: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -67,6 +82,8 @@ pub(crate) struct ImportantNotificationDeliveryReport {
|
|||||||
struct ImportantNotificationTestRequest {
|
struct ImportantNotificationTestRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
item_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn important_notification_module_enabled(
|
pub(crate) async fn important_notification_module_enabled(
|
||||||
@@ -91,25 +108,24 @@ pub(crate) async fn important_notification_configured(
|
|||||||
important_notification_has_configured_channel(state, &config).await
|
important_notification_has_configured_channel(state, &config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn important_notification_dispatch_ready(
|
pub(crate) async fn important_notification_dispatch_ready_for_item(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
item_key: &str,
|
||||||
) -> Result<bool, GatewayError> {
|
) -> Result<bool, GatewayError> {
|
||||||
let config = read_important_notification_config(state).await?;
|
let config = read_important_notification_config(state).await?;
|
||||||
if !config.module_enabled {
|
if !config.module_enabled {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
important_notification_has_configured_channel(state, &config).await
|
if let Some(item) = find_notification_item(&config, item_key) {
|
||||||
}
|
if !item.enabled {
|
||||||
|
return Ok(false);
|
||||||
async fn important_notification_has_configured_channel(
|
}
|
||||||
state: &AppState,
|
}
|
||||||
config: &ImportantNotificationConfig,
|
let readiness = read_notification_channel_readiness(state, &config).await?;
|
||||||
) -> Result<bool, GatewayError> {
|
Ok(channel_filter_has_ready_channel(
|
||||||
let smtp_config = read_smtp_delivery_config(state).await?;
|
notification_item_channel_filter(&config, item_key),
|
||||||
Ok(
|
readiness,
|
||||||
(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(
|
pub(crate) async fn send_important_notification(
|
||||||
@@ -124,32 +140,129 @@ pub(crate) async fn send_important_notification(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send_important_notification_for_item(
|
||||||
|
state: &AppState,
|
||||||
|
item_key: &str,
|
||||||
|
notification: ImportantNotification,
|
||||||
|
variables: &[(&str, String)],
|
||||||
|
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
||||||
|
dispatch_important_notification(state, Some(item_key), notification, variables, None, false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn send_important_notification_with_filter(
|
pub(crate) async fn send_important_notification_with_filter(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
notification: ImportantNotification,
|
notification: ImportantNotification,
|
||||||
channel_filter: ImportantNotificationChannelFilter,
|
channel_filter: ImportantNotificationChannelFilter,
|
||||||
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
||||||
dispatch_important_notification(state, notification, channel_filter, false).await
|
dispatch_important_notification(state, None, notification, &[], Some(channel_filter), false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send_user_important_notification_email(
|
||||||
|
state: &AppState,
|
||||||
|
item_key: &str,
|
||||||
|
user_email: &str,
|
||||||
|
notification: ImportantNotification,
|
||||||
|
variables: &[(&str, String)],
|
||||||
|
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
||||||
|
let config = read_important_notification_config(state).await?;
|
||||||
|
if !config.module_enabled {
|
||||||
|
return Ok(single_report("module", false, "通知服务未启用"));
|
||||||
|
}
|
||||||
|
let Some(item) = find_notification_item(&config, item_key) else {
|
||||||
|
return Ok(single_report("item", false, "通知项未定义"));
|
||||||
|
};
|
||||||
|
if !item.enabled {
|
||||||
|
return Ok(single_report("item", false, "通知项未启用"));
|
||||||
|
}
|
||||||
|
if !item.user_email_enabled {
|
||||||
|
return Ok(single_report("user_email", false, "通知项未启用用户邮件"));
|
||||||
|
}
|
||||||
|
let notification = apply_notification_item_template(Some(item), notification, variables);
|
||||||
|
let smtp_config = match read_smtp_delivery_config(state).await? {
|
||||||
|
Some(config) => config,
|
||||||
|
None => return Ok(single_report("user_email", false, "SMTP 配置不完整")),
|
||||||
|
};
|
||||||
|
let user_email = user_email.trim();
|
||||||
|
if user_email.is_empty() {
|
||||||
|
return Ok(single_report("user_email", false, "用户邮箱为空"));
|
||||||
|
}
|
||||||
|
match send_single_email_notification(smtp_config, user_email, ¬ification).await {
|
||||||
|
Ok(()) => Ok(single_report("user_email", true, "用户邮件通知已发送")),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = ?err, user_email = %user_email, "failed to send user notification email");
|
||||||
|
Ok(single_report(
|
||||||
|
"user_email",
|
||||||
|
false,
|
||||||
|
format!("用户邮件通知发送失败: {err:?}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn important_notification_has_configured_channel(
|
||||||
|
state: &AppState,
|
||||||
|
config: &ImportantNotificationConfig,
|
||||||
|
) -> Result<bool, GatewayError> {
|
||||||
|
let readiness = read_notification_channel_readiness(state, config).await?;
|
||||||
|
if channel_filter_has_ready_channel(config.default_channel, readiness) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(config.items.iter().any(|item| {
|
||||||
|
item.enabled
|
||||||
|
&& channel_filter_has_ready_channel(
|
||||||
|
item.channel.unwrap_or(config.default_channel),
|
||||||
|
readiness,
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_notification_channel_readiness(
|
||||||
|
state: &AppState,
|
||||||
|
config: &ImportantNotificationConfig,
|
||||||
|
) -> Result<NotificationChannelReadiness, GatewayError> {
|
||||||
|
let smtp_config = read_smtp_delivery_config(state).await?;
|
||||||
|
Ok(NotificationChannelReadiness {
|
||||||
|
email: config.email_enabled && !config.email_recipients.is_empty() && smtp_config.is_some(),
|
||||||
|
server_chan: config.server_chan.enabled && config.server_chan.send_key.is_some(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_filter_has_ready_channel(
|
||||||
|
filter: ImportantNotificationChannelFilter,
|
||||||
|
readiness: NotificationChannelReadiness,
|
||||||
|
) -> bool {
|
||||||
|
match filter {
|
||||||
|
ImportantNotificationChannelFilter::All => readiness.email || readiness.server_chan,
|
||||||
|
ImportantNotificationChannelFilter::Email => readiness.email,
|
||||||
|
ImportantNotificationChannelFilter::ServerChan => readiness.server_chan,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch_important_notification(
|
async fn dispatch_important_notification(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
item_key: Option<&str>,
|
||||||
notification: ImportantNotification,
|
notification: ImportantNotification,
|
||||||
channel_filter: ImportantNotificationChannelFilter,
|
variables: &[(&str, String)],
|
||||||
|
channel_override: Option<ImportantNotificationChannelFilter>,
|
||||||
bypass_enable_checks: bool,
|
bypass_enable_checks: bool,
|
||||||
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
) -> Result<ImportantNotificationDeliveryReport, GatewayError> {
|
||||||
let config = read_important_notification_config(state).await?;
|
let config = read_important_notification_config(state).await?;
|
||||||
if !bypass_enable_checks && !config.module_enabled {
|
if !bypass_enable_checks && !config.module_enabled {
|
||||||
return Ok(ImportantNotificationDeliveryReport {
|
return Ok(single_report("module", false, "通知服务未启用"));
|
||||||
success: false,
|
|
||||||
channels: vec![ImportantNotificationChannelReport {
|
|
||||||
channel: "module",
|
|
||||||
success: false,
|
|
||||||
message: "重要通知模块未启用".to_string(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let item = item_key.and_then(|key| find_notification_item(&config, key));
|
||||||
|
if !bypass_enable_checks && item.is_some_and(|item| !item.enabled) {
|
||||||
|
return Ok(single_report("item", false, "通知项未启用"));
|
||||||
|
}
|
||||||
|
let notification = apply_notification_item_template(item, notification, variables);
|
||||||
|
let channel_filter = channel_override.unwrap_or_else(|| {
|
||||||
|
item.and_then(|item| item.channel)
|
||||||
|
.unwrap_or(config.default_channel)
|
||||||
|
});
|
||||||
|
|
||||||
let mut reports = Vec::new();
|
let mut reports = Vec::new();
|
||||||
if matches!(
|
if matches!(
|
||||||
channel_filter,
|
channel_filter,
|
||||||
@@ -198,31 +311,40 @@ pub(crate) async fn build_important_notification_test_payload(
|
|||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Result<Value, GatewayError> {
|
) -> Result<Value, GatewayError> {
|
||||||
let request = match request_body.filter(|body| !body.is_empty()) {
|
let request = match request_body.filter(|body| !body.is_empty()) {
|
||||||
Some(body) => serde_json::from_slice::<ImportantNotificationTestRequest>(body)
|
Some(body) => serde_json::from_slice::<ImportantNotificationTestRequest>(body).unwrap_or(
|
||||||
.unwrap_or(ImportantNotificationTestRequest { channel: None }),
|
ImportantNotificationTestRequest {
|
||||||
None => ImportantNotificationTestRequest { channel: None },
|
channel: None,
|
||||||
|
item_key: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
None => ImportantNotificationTestRequest {
|
||||||
|
channel: None,
|
||||||
|
item_key: None,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let filter = match request
|
let filter = request.channel.as_deref().and_then(parse_channel_filter);
|
||||||
.channel
|
let item_key = request
|
||||||
|
.item_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.unwrap_or("all")
|
.filter(|value| !value.is_empty());
|
||||||
.to_ascii_lowercase()
|
let variables = vec![
|
||||||
.as_str()
|
("provider_name", "示例 Provider".to_string()),
|
||||||
{
|
("provider_id", "provider-demo".to_string()),
|
||||||
"email" => ImportantNotificationChannelFilter::Email,
|
("total_available", "8.0000".to_string()),
|
||||||
"server_chan" | "serverchan" | "serve_chan" => {
|
("threshold_amount", "10.0000".to_string()),
|
||||||
ImportantNotificationChannelFilter::ServerChan
|
("user_email", "user@example.com".to_string()),
|
||||||
}
|
("balance", "1.0000".to_string()),
|
||||||
_ => ImportantNotificationChannelFilter::All,
|
];
|
||||||
};
|
|
||||||
let report = dispatch_important_notification(
|
let report = dispatch_important_notification(
|
||||||
state.app(),
|
state.app(),
|
||||||
|
item_key,
|
||||||
ImportantNotification {
|
ImportantNotification {
|
||||||
title: "Aether 重要通知测试".to_string(),
|
title: "Aether 通知服务测试".to_string(),
|
||||||
markdown_body: "这是一条来自 Aether 的重要通知测试。".to_string(),
|
markdown_body: "这是一条来自 Aether 的通知服务测试。".to_string(),
|
||||||
text_body: "这是一条来自 Aether 的重要通知测试。".to_string(),
|
text_body: "这是一条来自 Aether 的通知服务测试。".to_string(),
|
||||||
},
|
},
|
||||||
|
&variables,
|
||||||
filter,
|
filter,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
@@ -245,28 +367,195 @@ async fn read_important_notification_config(
|
|||||||
let email_recipients = state
|
let email_recipients = state
|
||||||
.read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY)
|
.read_system_config_json_value(IMPORTANT_NOTIFICATION_EMAIL_RECIPIENTS_KEY)
|
||||||
.await?;
|
.await?;
|
||||||
let server_chan_enabled = state
|
let default_channel = state
|
||||||
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_ENABLED_KEY)
|
.read_system_config_json_value(IMPORTANT_NOTIFICATION_DEFAULT_CHANNEL_KEY)
|
||||||
.await?;
|
.await?;
|
||||||
let server_chan_send_key = state
|
let items = state
|
||||||
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_SEND_KEY_KEY)
|
.read_system_config_json_value(IMPORTANT_NOTIFICATION_ITEMS_KEY)
|
||||||
.await?;
|
|
||||||
let server_chan_template = state
|
|
||||||
.read_system_config_json_value(IMPORTANT_NOTIFICATION_SERVER_CHAN_TEMPLATE_KEY)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(ImportantNotificationConfig {
|
Ok(ImportantNotificationConfig {
|
||||||
module_enabled,
|
module_enabled,
|
||||||
email_enabled: system_config_bool(email_enabled.as_ref(), false),
|
email_enabled: system_config_bool(email_enabled.as_ref(), false),
|
||||||
email_recipients: parse_recipient_list(email_recipients.as_ref()),
|
email_recipients: parse_recipient_list(email_recipients.as_ref()),
|
||||||
server_chan_enabled: system_config_bool(server_chan_enabled.as_ref(), false),
|
default_channel: default_channel
|
||||||
server_chan_send_key: system_config_string(server_chan_send_key.as_ref()).map(|value| {
|
.as_ref()
|
||||||
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
.and_then(|value| value.as_str())
|
||||||
}),
|
.and_then(parse_channel_filter)
|
||||||
server_chan_template: system_config_string(server_chan_template.as_ref()),
|
.unwrap_or(ImportantNotificationChannelFilter::All),
|
||||||
|
items: parse_notification_items(items.as_ref()),
|
||||||
|
server_chan: read_server_chan_push_config(state).await?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_channel_filter(raw: &str) -> Option<ImportantNotificationChannelFilter> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"all" => Some(ImportantNotificationChannelFilter::All),
|
||||||
|
"email" => Some(ImportantNotificationChannelFilter::Email),
|
||||||
|
"server_chan" | "serverchan" | "serve_chan" => {
|
||||||
|
Some(ImportantNotificationChannelFilter::ServerChan)
|
||||||
|
}
|
||||||
|
"global" | "" => None,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_notification_items(value: Option<&Value>) -> Vec<ImportantNotificationItemConfig> {
|
||||||
|
let Some(Value::Array(items)) = value else {
|
||||||
|
return default_notification_items();
|
||||||
|
};
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(parse_notification_item)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_notification_item(value: &Value) -> Option<ImportantNotificationItemConfig> {
|
||||||
|
let item = value.as_object()?;
|
||||||
|
let key = item.get("key")?.as_str()?.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = item
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(key);
|
||||||
|
Some(ImportantNotificationItemConfig {
|
||||||
|
key: key.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
enabled: item.get("enabled").and_then(Value::as_bool).unwrap_or(true),
|
||||||
|
channel: item
|
||||||
|
.get("channel")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(parse_channel_filter),
|
||||||
|
title_template: optional_non_empty_string(item.get("title_template")),
|
||||||
|
markdown_template: optional_non_empty_string(item.get("markdown_template")),
|
||||||
|
text_template: optional_non_empty_string(item.get("text_template")),
|
||||||
|
user_email_enabled: item
|
||||||
|
.get("user_email_enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_notification_items() -> Vec<ImportantNotificationItemConfig> {
|
||||||
|
vec![
|
||||||
|
ImportantNotificationItemConfig {
|
||||||
|
key: PROVIDER_QUOTA_ALERT_ITEM_KEY.to_string(),
|
||||||
|
name: "号池额度不足".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
channel: None,
|
||||||
|
title_template: None,
|
||||||
|
markdown_template: None,
|
||||||
|
text_template: None,
|
||||||
|
user_email_enabled: false,
|
||||||
|
},
|
||||||
|
ImportantNotificationItemConfig {
|
||||||
|
key: "provider_pool_abnormal".to_string(),
|
||||||
|
name: "号池异常".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
channel: None,
|
||||||
|
title_template: Some("号池异常:{provider_name}".to_string()),
|
||||||
|
markdown_template: Some(
|
||||||
|
"号池 `{provider_name}` 出现异常,请检查服务状态。".to_string(),
|
||||||
|
),
|
||||||
|
text_template: Some("号池 {provider_name} 出现异常,请检查服务状态。".to_string()),
|
||||||
|
user_email_enabled: false,
|
||||||
|
},
|
||||||
|
ImportantNotificationItemConfig {
|
||||||
|
key: "user_balance_low".to_string(),
|
||||||
|
name: "用户余额不足".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
channel: Some(ImportantNotificationChannelFilter::Email),
|
||||||
|
title_template: Some("余额不足提醒".to_string()),
|
||||||
|
markdown_template: Some("你的账户余额已低于提醒阈值,请及时处理。".to_string()),
|
||||||
|
text_template: Some("你的账户余额已低于提醒阈值,请及时处理。".to_string()),
|
||||||
|
user_email_enabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_non_empty_string(value: Option<&Value>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_notification_item<'a>(
|
||||||
|
config: &'a ImportantNotificationConfig,
|
||||||
|
item_key: &str,
|
||||||
|
) -> Option<&'a ImportantNotificationItemConfig> {
|
||||||
|
let item_key = item_key.trim();
|
||||||
|
config.items.iter().find(|item| item.key == item_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notification_item_channel_filter(
|
||||||
|
config: &ImportantNotificationConfig,
|
||||||
|
item_key: &str,
|
||||||
|
) -> ImportantNotificationChannelFilter {
|
||||||
|
find_notification_item(config, item_key)
|
||||||
|
.and_then(|item| item.channel)
|
||||||
|
.unwrap_or(config.default_channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_notification_item_template(
|
||||||
|
item: Option<&ImportantNotificationItemConfig>,
|
||||||
|
notification: ImportantNotification,
|
||||||
|
variables: &[(&str, String)],
|
||||||
|
) -> ImportantNotification {
|
||||||
|
let Some(item) = item else {
|
||||||
|
return notification;
|
||||||
|
};
|
||||||
|
let title = render_template(
|
||||||
|
item.title_template.as_deref(),
|
||||||
|
¬ification.title,
|
||||||
|
¬ification,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
let markdown_body = render_template(
|
||||||
|
item.markdown_template.as_deref(),
|
||||||
|
¬ification.markdown_body,
|
||||||
|
¬ification,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
let text_body = render_template(
|
||||||
|
item.text_template.as_deref(),
|
||||||
|
¬ification.text_body,
|
||||||
|
¬ification,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
ImportantNotification {
|
||||||
|
title,
|
||||||
|
markdown_body,
|
||||||
|
text_body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_template(
|
||||||
|
template: Option<&str>,
|
||||||
|
fallback: &str,
|
||||||
|
notification: &ImportantNotification,
|
||||||
|
variables: &[(&str, String)],
|
||||||
|
) -> String {
|
||||||
|
let mut rendered = template
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
.to_string();
|
||||||
|
rendered = rendered
|
||||||
|
.replace("{title}", ¬ification.title)
|
||||||
|
.replace("{body}", ¬ification.markdown_body)
|
||||||
|
.replace("{text_body}", ¬ification.text_body);
|
||||||
|
for (key, value) in variables {
|
||||||
|
rendered = rendered.replace(&format!("{{{}}}", key.trim()), value);
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
async fn maybe_send_email_notification(
|
async fn maybe_send_email_notification(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
config: &ImportantNotificationConfig,
|
config: &ImportantNotificationConfig,
|
||||||
@@ -356,10 +645,10 @@ async fn maybe_send_server_chan_notification(
|
|||||||
bypass_channel_toggle: bool,
|
bypass_channel_toggle: bool,
|
||||||
reports: &mut Vec<ImportantNotificationChannelReport>,
|
reports: &mut Vec<ImportantNotificationChannelReport>,
|
||||||
) {
|
) {
|
||||||
if !bypass_channel_toggle && !config.server_chan_enabled {
|
if !bypass_channel_toggle && !config.server_chan.enabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(send_key) = config.server_chan_send_key.as_deref() else {
|
if config.server_chan.send_key.is_none() {
|
||||||
reports.push(ImportantNotificationChannelReport {
|
reports.push(ImportantNotificationChannelReport {
|
||||||
channel: "server_chan",
|
channel: "server_chan",
|
||||||
success: false,
|
success: false,
|
||||||
@@ -367,11 +656,11 @@ async fn maybe_send_server_chan_notification(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match send_server_chan_notification(
|
match send_server_chan_push(
|
||||||
state,
|
state,
|
||||||
send_key,
|
&config.server_chan,
|
||||||
config.server_chan_template.as_deref(),
|
¬ification.title,
|
||||||
notification,
|
¬ification.markdown_body,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -391,65 +680,18 @@ async fn maybe_send_server_chan_notification(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_server_chan_notification(
|
fn single_report(
|
||||||
state: &AppState,
|
channel: &'static str,
|
||||||
send_key: &str,
|
success: bool,
|
||||||
template: Option<&str>,
|
message: impl Into<String>,
|
||||||
notification: &ImportantNotification,
|
) -> ImportantNotificationDeliveryReport {
|
||||||
) -> Result<(), GatewayError> {
|
ImportantNotificationDeliveryReport {
|
||||||
let send_key = send_key.trim();
|
success,
|
||||||
if send_key.is_empty() {
|
channels: vec![ImportantNotificationChannelReport {
|
||||||
return Err(GatewayError::Internal(
|
channel,
|
||||||
"Server 酱 SendKey 不能为空".to_string(),
|
success,
|
||||||
));
|
message: message.into(),
|
||||||
}
|
}],
|
||||||
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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +742,10 @@ fn escape_html(value: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_recipient_list, render_server_chan_desp, ImportantNotification};
|
use super::{
|
||||||
|
apply_notification_item_template, parse_notification_items, parse_recipient_list,
|
||||||
|
ImportantNotification, ImportantNotificationChannelFilter,
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -517,32 +762,55 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_notification() -> ImportantNotification {
|
#[test]
|
||||||
ImportantNotification {
|
fn parse_notification_items_reads_channel_and_user_email_flag() {
|
||||||
title: "告警".to_string(),
|
let items = parse_notification_items(Some(&json!([
|
||||||
markdown_body: "原始正文".to_string(),
|
{
|
||||||
text_body: "原始正文".to_string(),
|
"key": "user_balance_low",
|
||||||
}
|
"name": "用户余额不足",
|
||||||
|
"enabled": true,
|
||||||
|
"channel": "email",
|
||||||
|
"title_template": "余额提醒",
|
||||||
|
"markdown_template": "{user_email}: {balance}",
|
||||||
|
"user_email_enabled": true
|
||||||
|
}
|
||||||
|
])));
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].key, "user_balance_low");
|
||||||
|
assert_eq!(
|
||||||
|
items[0].channel,
|
||||||
|
Some(ImportantNotificationChannelFilter::Email)
|
||||||
|
);
|
||||||
|
assert!(items[0].user_email_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_chan_desp_uses_template_when_provided() {
|
fn item_template_renders_fallback_and_variables() {
|
||||||
let rendered = render_server_chan_desp(
|
let items = parse_notification_items(Some(&json!([
|
||||||
Some("**{title}**\n\n{body}\n\n--end--"),
|
{
|
||||||
&sample_notification(),
|
"key": "provider_quota_alert",
|
||||||
|
"name": "号池额度不足",
|
||||||
|
"title_template": "额度提醒:{provider_name}",
|
||||||
|
"markdown_template": "{body}\n剩余:{total_available}",
|
||||||
|
"text_template": "{text_body}\n剩余:{total_available}"
|
||||||
|
}
|
||||||
|
])));
|
||||||
|
let rendered = apply_notification_item_template(
|
||||||
|
Some(&items[0]),
|
||||||
|
ImportantNotification {
|
||||||
|
title: "默认标题".to_string(),
|
||||||
|
markdown_body: "默认正文".to_string(),
|
||||||
|
text_body: "默认文本".to_string(),
|
||||||
|
},
|
||||||
|
&[
|
||||||
|
("provider_name", "示例 Provider".to_string()),
|
||||||
|
("total_available", "8.0000".to_string()),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert_eq!(rendered, "**告警**\n\n原始正文\n\n--end--");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
assert_eq!(rendered.title, "额度提醒:示例 Provider");
|
||||||
fn server_chan_desp_falls_back_to_markdown_body_for_empty_template() {
|
assert_eq!(rendered.markdown_body, "默认正文\n剩余:8.0000");
|
||||||
assert_eq!(
|
assert_eq!(rendered.text_body, "默认文本\n剩余:8.0000");
|
||||||
render_server_chan_desp(None, &sample_notification()),
|
|
||||||
"原始正文"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
render_server_chan_desp(Some(" "), &sample_notification()),
|
|
||||||
"原始正文"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ mod roles;
|
|||||||
mod router;
|
mod router;
|
||||||
mod routing;
|
mod routing;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod server_chan_push;
|
||||||
mod state;
|
mod state;
|
||||||
mod system_features;
|
mod system_features;
|
||||||
mod task_runtime;
|
mod task_runtime;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::admin_api::{
|
|||||||
admin_provider_ops_local_action_response, store_admin_provider_ops_balance_cache, AdminAppState,
|
admin_provider_ops_local_action_response, store_admin_provider_ops_balance_cache, AdminAppState,
|
||||||
};
|
};
|
||||||
use crate::important_notification::{
|
use crate::important_notification::{
|
||||||
important_notification_dispatch_ready, send_important_notification, ImportantNotification,
|
important_notification_dispatch_ready_for_item, send_important_notification_for_item,
|
||||||
|
ImportantNotification, PROVIDER_QUOTA_ALERT_ITEM_KEY,
|
||||||
};
|
};
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
@@ -75,7 +76,8 @@ pub(crate) async fn perform_provider_quota_alert_once(
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !important_notification_dispatch_ready(state).await? {
|
if !important_notification_dispatch_ready_for_item(state, PROVIDER_QUOTA_ALERT_ITEM_KEY).await?
|
||||||
|
{
|
||||||
return Ok(ProviderQuotaAlertRunSummary {
|
return Ok(ProviderQuotaAlertRunSummary {
|
||||||
checked: 0,
|
checked: 0,
|
||||||
alerted: 0,
|
alerted: 0,
|
||||||
@@ -223,13 +225,21 @@ async fn run_provider_quota_alert_for_provider(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if should_notify {
|
if should_notify {
|
||||||
let report = send_important_notification(
|
let notification = build_provider_quota_alert_notification(
|
||||||
|
&target.provider,
|
||||||
|
total_available,
|
||||||
|
target.config.threshold_amount,
|
||||||
|
);
|
||||||
|
let variables = provider_quota_alert_notification_variables(
|
||||||
|
&target.provider,
|
||||||
|
total_available,
|
||||||
|
target.config.threshold_amount,
|
||||||
|
);
|
||||||
|
let report = send_important_notification_for_item(
|
||||||
state,
|
state,
|
||||||
build_provider_quota_alert_notification(
|
PROVIDER_QUOTA_ALERT_ITEM_KEY,
|
||||||
&target.provider,
|
notification,
|
||||||
total_available,
|
&variables,
|
||||||
target.config.threshold_amount,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let delivered = match &report {
|
let delivered = match &report {
|
||||||
@@ -357,6 +367,19 @@ fn build_provider_quota_alert_notification(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provider_quota_alert_notification_variables(
|
||||||
|
provider: &StoredProviderCatalogProvider,
|
||||||
|
total_available: f64,
|
||||||
|
threshold_amount: f64,
|
||||||
|
) -> Vec<(&'static str, String)> {
|
||||||
|
vec![
|
||||||
|
("provider_name", provider.name.clone()),
|
||||||
|
("provider_id", provider.id.clone()),
|
||||||
|
("total_available", format!("{total_available:.4}")),
|
||||||
|
("threshold_amount", format!("{threshold_amount:.4}")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
async fn read_quota_alert_runtime_state(
|
async fn read_quota_alert_runtime_state(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
|
|||||||
175
apps/aether-gateway/src/server_chan_push.rs
Normal file
175
apps/aether-gateway/src/server_chan_push.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::handlers::shared::{
|
||||||
|
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
||||||
|
};
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub(crate) const SERVER_CHAN_PUSH_ENABLED_KEY: &str = "module.server_chan_push.enabled";
|
||||||
|
pub(crate) const SERVER_CHAN_PUSH_SEND_KEY_KEY: &str = "module.server_chan_push.send_key";
|
||||||
|
pub(crate) const SERVER_CHAN_PUSH_TEMPLATE_KEY: &str = "module.server_chan_push.template";
|
||||||
|
pub(crate) const LEGACY_SERVER_CHAN_ENABLED_KEY: &str =
|
||||||
|
"module.important_notification.server_chan_enabled";
|
||||||
|
pub(crate) const LEGACY_SERVER_CHAN_SEND_KEY_KEY: &str =
|
||||||
|
"module.important_notification.server_chan_send_key";
|
||||||
|
pub(crate) const LEGACY_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 ServerChanPushConfig {
|
||||||
|
pub(crate) enabled: bool,
|
||||||
|
pub(crate) send_key: Option<String>,
|
||||||
|
pub(crate) template: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn server_chan_push_module_enabled(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<bool, GatewayError> {
|
||||||
|
let canonical = state
|
||||||
|
.read_system_config_json_value(SERVER_CHAN_PUSH_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_SERVER_CHAN_ENABLED_KEY)
|
||||||
|
.await?;
|
||||||
|
Ok(system_config_bool(legacy.as_ref(), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn server_chan_push_configured(state: &AppState) -> Result<bool, GatewayError> {
|
||||||
|
Ok(read_server_chan_push_config(state)
|
||||||
|
.await?
|
||||||
|
.send_key
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_server_chan_push_config(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<ServerChanPushConfig, GatewayError> {
|
||||||
|
let enabled = server_chan_push_module_enabled(state).await?;
|
||||||
|
let send_key = read_server_chan_value(
|
||||||
|
state,
|
||||||
|
SERVER_CHAN_PUSH_SEND_KEY_KEY,
|
||||||
|
LEGACY_SERVER_CHAN_SEND_KEY_KEY,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.and_then(|value| system_config_string(Some(&value)))
|
||||||
|
.map(|value| {
|
||||||
|
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||||
|
});
|
||||||
|
let template = read_server_chan_value(
|
||||||
|
state,
|
||||||
|
SERVER_CHAN_PUSH_TEMPLATE_KEY,
|
||||||
|
LEGACY_SERVER_CHAN_TEMPLATE_KEY,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.and_then(|value| system_config_string(Some(&value)));
|
||||||
|
|
||||||
|
Ok(ServerChanPushConfig {
|
||||||
|
enabled,
|
||||||
|
send_key,
|
||||||
|
template,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_server_chan_value(
|
||||||
|
state: &AppState,
|
||||||
|
canonical_key: &str,
|
||||||
|
legacy_key: &str,
|
||||||
|
) -> Result<Option<Value>, GatewayError> {
|
||||||
|
let canonical = state.read_system_config_json_value(canonical_key).await?;
|
||||||
|
if canonical.is_some() {
|
||||||
|
return Ok(canonical);
|
||||||
|
}
|
||||||
|
state.read_system_config_json_value(legacy_key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn send_server_chan_push(
|
||||||
|
state: &AppState,
|
||||||
|
config: &ServerChanPushConfig,
|
||||||
|
title: &str,
|
||||||
|
markdown_body: &str,
|
||||||
|
) -> Result<(), GatewayError> {
|
||||||
|
let Some(send_key) = config.send_key.as_deref() else {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"未配置 Server 酱 SendKey".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
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(config.template.as_deref(), title, markdown_body);
|
||||||
|
let url = format!("{SERVER_CHAN_API_BASE}/{send_key}.send");
|
||||||
|
let response = state
|
||||||
|
.client
|
||||||
|
.post(url)
|
||||||
|
.form(&[("title", title), ("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>, title: &str, markdown_body: &str) -> String {
|
||||||
|
match template {
|
||||||
|
Some(template) if !template.trim().is_empty() => template
|
||||||
|
.replace("{title}", title)
|
||||||
|
.replace("{body}", markdown_body),
|
||||||
|
_ => markdown_body.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::render_server_chan_desp;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_chan_desp_uses_template_when_provided() {
|
||||||
|
let rendered =
|
||||||
|
render_server_chan_desp(Some("**{title}**\n\n{body}\n\n--end--"), "告警", "原始正文");
|
||||||
|
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, "告警", "原始正文"),
|
||||||
|
"原始正文"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_server_chan_desp(Some(" "), "告警", "原始正文"),
|
||||||
|
"原始正文"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -809,7 +809,12 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["important_notification"]["admin_route"],
|
payload["important_notification"]["admin_route"],
|
||||||
"/admin/modules/important-notification"
|
"/admin/notification-service"
|
||||||
|
);
|
||||||
|
assert_eq!(payload["server_chan_push"]["display_name"], "Server 酱推送");
|
||||||
|
assert_eq!(
|
||||||
|
payload["server_chan_push"]["admin_route"],
|
||||||
|
"/admin/modules/server-chan"
|
||||||
);
|
);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,44 @@ fn chat_pii_redaction_default_rules() -> serde_json::Value {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notification_service_default_items() -> serde_json::Value {
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"key": "provider_quota_alert",
|
||||||
|
"name": "号池额度不足",
|
||||||
|
"enabled": true,
|
||||||
|
"channel": "global",
|
||||||
|
"title_template": "",
|
||||||
|
"markdown_template": "",
|
||||||
|
"text_template": "",
|
||||||
|
"user_email_enabled": false,
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "provider_pool_abnormal",
|
||||||
|
"name": "号池异常",
|
||||||
|
"enabled": true,
|
||||||
|
"channel": "global",
|
||||||
|
"title_template": "号池异常:{provider_name}",
|
||||||
|
"markdown_template": "号池 `{provider_name}` 出现异常,请检查服务状态。",
|
||||||
|
"text_template": "号池 {provider_name} 出现异常,请检查服务状态。",
|
||||||
|
"user_email_enabled": false,
|
||||||
|
"system": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "user_balance_low",
|
||||||
|
"name": "用户余额不足",
|
||||||
|
"enabled": true,
|
||||||
|
"channel": "email",
|
||||||
|
"title_template": "余额不足提醒",
|
||||||
|
"markdown_template": "你的账户余额已低于提醒阈值,请及时处理。",
|
||||||
|
"text_template": "你的账户余额已低于提醒阈值,请及时处理。",
|
||||||
|
"user_email_enabled": true,
|
||||||
|
"system": true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_chat_pii_redaction_placeholder_prefix(raw: &str) -> Option<String> {
|
fn normalize_chat_pii_redaction_placeholder_prefix(raw: &str) -> Option<String> {
|
||||||
let value = raw.trim();
|
let value = raw.trim();
|
||||||
if value.is_empty() || value.len() > 32 {
|
if value.is_empty() || value.len() > 32 {
|
||||||
@@ -670,6 +708,7 @@ const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
|||||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
|
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
|
||||||
"smtp_password",
|
"smtp_password",
|
||||||
"turnstile_secret_key",
|
"turnstile_secret_key",
|
||||||
|
"module.server_chan_push.send_key",
|
||||||
"module.important_notification.server_chan_send_key",
|
"module.important_notification.server_chan_send_key",
|
||||||
];
|
];
|
||||||
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||||
@@ -1167,6 +1206,7 @@ pub fn build_admin_module_validation_result(
|
|||||||
ldap_config: Option<&StoredLdapModuleConfig>,
|
ldap_config: Option<&StoredLdapModuleConfig>,
|
||||||
gemini_files_has_capable_key: bool,
|
gemini_files_has_capable_key: bool,
|
||||||
important_notification_configured: bool,
|
important_notification_configured: bool,
|
||||||
|
server_chan_push_configured: bool,
|
||||||
) -> (bool, Option<String>) {
|
) -> (bool, Option<String>) {
|
||||||
match module_name {
|
match module_name {
|
||||||
"oauth" => {
|
"oauth" => {
|
||||||
@@ -1241,7 +1281,14 @@ pub fn build_admin_module_validation_result(
|
|||||||
if important_notification_configured {
|
if important_notification_configured {
|
||||||
(true, None)
|
(true, None)
|
||||||
} else {
|
} else {
|
||||||
(false, Some("请先完成重要通知通道配置".to_string()))
|
(false, Some("请先完成通知服务推送渠道配置".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"server_chan_push" => {
|
||||||
|
if server_chan_push_configured {
|
||||||
|
(true, None)
|
||||||
|
} else {
|
||||||
|
(false, Some("请先配置 Server 酱 SendKey".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"gemini_files" => {
|
"gemini_files" => {
|
||||||
@@ -1264,9 +1311,11 @@ 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" | "important_notification" => {
|
"management_tokens"
|
||||||
"healthy"
|
| "model_directives"
|
||||||
}
|
| "proxy_nodes"
|
||||||
|
| "important_notification"
|
||||||
|
| "server_chan_push" => "healthy",
|
||||||
"gemini_files" => {
|
"gemini_files" => {
|
||||||
if gemini_files_has_capable_key {
|
if gemini_files_has_capable_key {
|
||||||
"healthy"
|
"healthy"
|
||||||
@@ -1445,6 +1494,12 @@ pub fn normalize_admin_system_config_key(requested_key: &str) -> String {
|
|||||||
REQUEST_RECORD_LEVEL_KEY.to_string()
|
REQUEST_RECORD_LEVEL_KEY.to_string()
|
||||||
} else if trimmed.eq_ignore_ascii_case("module.notification_email.enabled") {
|
} else if trimmed.eq_ignore_ascii_case("module.notification_email.enabled") {
|
||||||
"module.important_notification.enabled".to_string()
|
"module.important_notification.enabled".to_string()
|
||||||
|
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_enabled") {
|
||||||
|
"module.server_chan_push.enabled".to_string()
|
||||||
|
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_send_key") {
|
||||||
|
"module.server_chan_push.send_key".to_string()
|
||||||
|
} else if trimmed.eq_ignore_ascii_case("module.important_notification.server_chan_template") {
|
||||||
|
"module.server_chan_push.template".to_string()
|
||||||
} else {
|
} else {
|
||||||
trimmed.to_string()
|
trimmed.to_string()
|
||||||
}
|
}
|
||||||
@@ -1462,6 +1517,21 @@ pub fn admin_system_config_delete_keys(requested_key: &str) -> Vec<String> {
|
|||||||
"module.important_notification.enabled".to_string(),
|
"module.important_notification.enabled".to_string(),
|
||||||
"module.notification_email.enabled".to_string(),
|
"module.notification_email.enabled".to_string(),
|
||||||
]
|
]
|
||||||
|
} else if normalized == "module.server_chan_push.enabled" {
|
||||||
|
vec![
|
||||||
|
"module.server_chan_push.enabled".to_string(),
|
||||||
|
"module.important_notification.server_chan_enabled".to_string(),
|
||||||
|
]
|
||||||
|
} else if normalized == "module.server_chan_push.send_key" {
|
||||||
|
vec![
|
||||||
|
"module.server_chan_push.send_key".to_string(),
|
||||||
|
"module.important_notification.server_chan_send_key".to_string(),
|
||||||
|
]
|
||||||
|
} else if normalized == "module.server_chan_push.template" {
|
||||||
|
vec![
|
||||||
|
"module.server_chan_push.template".to_string(),
|
||||||
|
"module.important_notification.server_chan_template".to_string(),
|
||||||
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![normalized]
|
vec![normalized]
|
||||||
}
|
}
|
||||||
@@ -1586,9 +1656,11 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
|||||||
"module.important_notification.enabled" => Some(json!(false)),
|
"module.important_notification.enabled" => Some(json!(false)),
|
||||||
"module.important_notification.email_enabled" => Some(json!(false)),
|
"module.important_notification.email_enabled" => Some(json!(false)),
|
||||||
"module.important_notification.email_recipients" => Some(json!("")),
|
"module.important_notification.email_recipients" => Some(json!("")),
|
||||||
"module.important_notification.server_chan_enabled" => Some(json!(false)),
|
"module.important_notification.default_channel" => Some(json!("all")),
|
||||||
"module.important_notification.server_chan_send_key" => Some(serde_json::Value::Null),
|
"module.important_notification.items" => Some(notification_service_default_items()),
|
||||||
"module.important_notification.server_chan_template" => Some(json!("")),
|
"module.server_chan_push.enabled" => Some(json!(false)),
|
||||||
|
"module.server_chan_push.send_key" => Some(serde_json::Value::Null),
|
||||||
|
"module.server_chan_push.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)),
|
||||||
@@ -1777,6 +1849,125 @@ fn normalize_nullable_string_config_value(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_channel_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
|
||||||
|
match value {
|
||||||
|
Value::Null => Ok(json!("all")),
|
||||||
|
Value::String(raw) => {
|
||||||
|
let normalized = normalize_notification_channel(raw.trim(), false)?;
|
||||||
|
Ok(json!(normalized))
|
||||||
|
}
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_channel(raw: &str, allow_global: bool) -> Result<&'static str, ()> {
|
||||||
|
match raw.to_ascii_lowercase().as_str() {
|
||||||
|
"all" => Ok("all"),
|
||||||
|
"email" => Ok("email"),
|
||||||
|
"server_chan" | "serverchan" | "serve_chan" => Ok("server_chan"),
|
||||||
|
"global" | "" if allow_global => Ok("global"),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_service_items_value(
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, ()> {
|
||||||
|
let Value::Array(items) = value else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
let mut normalized_items = Vec::with_capacity(items.len());
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
for item in items {
|
||||||
|
let Value::Object(raw_item) = item else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
let key = normalize_notification_item_key(raw_item.get("key"))?;
|
||||||
|
if !keys.insert(key.clone()) {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let name = normalize_optional_bounded_string(raw_item.get("name"), 80)?
|
||||||
|
.unwrap_or_else(|| key.clone());
|
||||||
|
let enabled = raw_item
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let channel = raw_item
|
||||||
|
.get("channel")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|raw| normalize_notification_channel(raw.trim(), true))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or("global");
|
||||||
|
let title_template =
|
||||||
|
normalize_optional_bounded_string(raw_item.get("title_template"), 256)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let markdown_template =
|
||||||
|
normalize_optional_bounded_string(raw_item.get("markdown_template"), 8_000)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let text_template =
|
||||||
|
normalize_optional_bounded_string(raw_item.get("text_template"), 8_000)?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let user_email_enabled = raw_item
|
||||||
|
.get("user_email_enabled")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let system = raw_item
|
||||||
|
.get("system")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
normalized_items.push(json!({
|
||||||
|
"key": key,
|
||||||
|
"name": name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"channel": channel,
|
||||||
|
"title_template": title_template,
|
||||||
|
"markdown_template": markdown_template,
|
||||||
|
"text_template": text_template,
|
||||||
|
"user_email_enabled": user_email_enabled,
|
||||||
|
"system": system,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Value::Array(normalized_items))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_notification_item_key(value: Option<&Value>) -> Result<String, ()> {
|
||||||
|
let Some(raw) = value.and_then(Value::as_str).map(str::trim) else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
if raw.is_empty() || raw.len() > 64 {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
if !raw
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':'))
|
||||||
|
{
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(raw.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_bounded_string(
|
||||||
|
value: Option<&Value>,
|
||||||
|
max_len: usize,
|
||||||
|
) -> Result<Option<String>, ()> {
|
||||||
|
match value {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(Value::String(raw)) => {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.len() > max_len {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => 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],
|
||||||
@@ -1832,7 +2023,7 @@ pub fn parse_admin_system_config_update(
|
|||||||
match normalized_key.as_str() {
|
match normalized_key.as_str() {
|
||||||
"module.important_notification.enabled"
|
"module.important_notification.enabled"
|
||||||
| "module.important_notification.email_enabled"
|
| "module.important_notification.email_enabled"
|
||||||
| "module.important_notification.server_chan_enabled" => match value.as_bool() {
|
| "module.server_chan_push.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() => {
|
||||||
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
|
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
|
||||||
@@ -1852,7 +2043,27 @@ pub fn parse_admin_system_config_update(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
"module.important_notification.server_chan_send_key" => {
|
"module.important_notification.default_channel" => {
|
||||||
|
value = normalize_notification_channel_value(value).map_err(|_| {
|
||||||
|
(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "detail": "请求数据验证失败" }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
"module.important_notification.items" => {
|
||||||
|
if value.is_null() {
|
||||||
|
value = notification_service_default_items();
|
||||||
|
} else {
|
||||||
|
value = normalize_notification_service_items_value(value).map_err(|_| {
|
||||||
|
(
|
||||||
|
http::StatusCode::BAD_REQUEST,
|
||||||
|
json!({ "detail": "请求数据验证失败" }),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"module.server_chan_push.send_key" => {
|
||||||
value = normalize_nullable_string_config_value(value).map_err(|_| {
|
value = normalize_nullable_string_config_value(value).map_err(|_| {
|
||||||
(
|
(
|
||||||
http::StatusCode::BAD_REQUEST,
|
http::StatusCode::BAD_REQUEST,
|
||||||
@@ -1860,7 +2071,7 @@ pub fn parse_admin_system_config_update(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
"module.important_notification.server_chan_template" => {
|
"module.server_chan_push.template" => {
|
||||||
value = match value {
|
value = match value {
|
||||||
Value::Null => json!(""),
|
Value::Null => json!(""),
|
||||||
Value::String(raw) => json!(raw),
|
Value::String(raw) => json!(raw),
|
||||||
@@ -2928,6 +3139,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.server_chan_push.send_key"
|
||||||
|
));
|
||||||
assert!(is_sensitive_admin_system_config_key(
|
assert!(is_sensitive_admin_system_config_key(
|
||||||
"module.important_notification.server_chan_send_key"
|
"module.important_notification.server_chan_send_key"
|
||||||
));
|
));
|
||||||
@@ -2949,6 +3163,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_server_chan_config_keys_normalize_to_push_module() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_admin_system_config_key("module.important_notification.server_chan_send_key"),
|
||||||
|
"module.server_chan_push.send_key"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
admin_system_config_delete_keys("module.server_chan_push.send_key"),
|
||||||
|
vec![
|
||||||
|
"module.server_chan_push.send_key".to_string(),
|
||||||
|
"module.important_notification.server_chan_send_key".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_service_items_are_normalized() {
|
||||||
|
let update = parse_admin_system_config_update(
|
||||||
|
"module.important_notification.items",
|
||||||
|
r#"{
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"key": "user_balance_low",
|
||||||
|
"name": " 用户余额不足 ",
|
||||||
|
"enabled": true,
|
||||||
|
"channel": "serverchan",
|
||||||
|
"title_template": " 余额提醒 ",
|
||||||
|
"markdown_template": " {body} ",
|
||||||
|
"text_template": null,
|
||||||
|
"user_email_enabled": true,
|
||||||
|
"system": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.expect("items should parse");
|
||||||
|
|
||||||
|
assert_eq!(update.normalized_key, "module.important_notification.items");
|
||||||
|
assert_eq!(update.value[0]["channel"], json!("server_chan"));
|
||||||
|
assert_eq!(update.value[0]["name"], json!("用户余额不足"));
|
||||||
|
assert_eq!(update.value[0]["text_template"], json!(""));
|
||||||
|
assert_eq!(update.value[0]["user_email_enabled"], json!(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
|
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
|
||||||
let payload = build_admin_system_config_detail_payload(
|
let payload = build_admin_system_config_detail_payload(
|
||||||
|
|||||||
@@ -918,16 +918,20 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async testImportantNotification(channel: 'all' | 'email' | 'server_chan' = 'all'): Promise<{
|
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | {
|
||||||
|
channel?: 'all' | 'email' | 'server_chan'
|
||||||
|
item_key?: string
|
||||||
|
} = 'all'): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
channels: Array<{ channel: string; success: boolean; message: string }>
|
channels: Array<{ channel: string; success: boolean; message: string }>
|
||||||
}> {
|
}> {
|
||||||
|
const payload = typeof options === 'string' ? { channel: options } : options
|
||||||
const response = await apiClient.post<{
|
const response = await apiClient.post<{
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
channels: Array<{ channel: string; success: boolean; message: string }>
|
channels: Array<{ channel: string; success: boolean; message: string }>
|
||||||
}>('/api/admin/system/important-notification/test', { channel })
|
}>('/api/admin/system/important-notification/test', payload)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Mail, Shield, AlertTriangle, Send } from 'lucide-vue-next'
|
import { Mail, Shield, AlertTriangle, BellRing } from 'lucide-vue-next'
|
||||||
import type { LucideIcon } from 'lucide-vue-next'
|
import type { LucideIcon } from 'lucide-vue-next'
|
||||||
|
|
||||||
export interface BuiltinTool {
|
export interface BuiltinTool {
|
||||||
@@ -16,10 +16,10 @@ export const BUILTIN_TOOLS: BuiltinTool[] = [
|
|||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Server 酱',
|
name: '通知服务',
|
||||||
description: '配置 Server 酱 SendKey 与通知模板',
|
description: '管理通知项、模板和推送服务策略',
|
||||||
href: '/admin/server-chan',
|
href: '/admin/notification-service',
|
||||||
icon: Send,
|
icon: BellRing,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'IP 安全',
|
name: 'IP 安全',
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const authStoreMock = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const routerPushMock = vi.hoisted(() => vi.fn())
|
const routerPushMock = vi.hoisted(() => vi.fn())
|
||||||
|
const routeMock = vi.hoisted(() => ({
|
||||||
|
query: {},
|
||||||
|
}))
|
||||||
const toastMocks = vi.hoisted(() => ({
|
const toastMocks = vi.hoisted(() => ({
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
warning: vi.fn(),
|
warning: vi.fn(),
|
||||||
@@ -27,6 +30,7 @@ const oauthApiMocks = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => routeMock,
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: routerPushMock,
|
push: routerPushMock,
|
||||||
}),
|
}),
|
||||||
@@ -157,6 +161,7 @@ beforeEach(() => {
|
|||||||
authStoreMock.error = ''
|
authStoreMock.error = ''
|
||||||
authStoreMock.canAccessAdmin = false
|
authStoreMock.canAccessAdmin = false
|
||||||
authStoreMock.login.mockReset()
|
authStoreMock.login.mockReset()
|
||||||
|
routeMock.query = {}
|
||||||
routerPushMock.mockReset()
|
routerPushMock.mockReset()
|
||||||
toastMocks.success.mockReset()
|
toastMocks.success.mockReset()
|
||||||
toastMocks.warning.mockReset()
|
toastMocks.warning.mockReset()
|
||||||
|
|||||||
@@ -247,7 +247,7 @@
|
|||||||
额度提醒
|
额度提醒
|
||||||
</Label>
|
</Label>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
余额低于阈值时通过重要通知发送提醒
|
余额低于阈值时通过通知服务发送提醒
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch v-model="quotaAlert.enabled" />
|
<Switch v-model="quotaAlert.enabled" />
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ const endpointMocks = vi.hoisted(() => ({
|
|||||||
getAwsRegions: vi.fn(),
|
getAwsRegions: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/endpoints', () => endpointMocks)
|
vi.mock('@/api/endpoints', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/api/endpoints/provider_oauth')>(
|
||||||
|
'@/api/endpoints/provider_oauth',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...endpointMocks,
|
||||||
|
normalizeBatchImportCredentials: actual.normalizeBatchImportCredentials,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/components/ui', async () => {
|
vi.mock('@/components/ui', async () => {
|
||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent, h } = await import('vue')
|
||||||
@@ -358,7 +367,7 @@ describe('OAuthAccountDialog Grok import', () => {
|
|||||||
|
|
||||||
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
|
expect(endpointMocks.startBatchImportOAuthTask).toHaveBeenCalledWith(
|
||||||
'provider-1',
|
'provider-1',
|
||||||
'sso-1\nsso-2',
|
'["sso-1","sso-2"]',
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
|
expect(endpointMocks.importProviderRefreshToken).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -214,6 +214,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
<div class="rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<div class="mb-3 text-xs font-semibold text-muted-foreground">
|
||||||
|
功能权限
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<Label class="text-sm font-medium">敏感信息保护</Label>
|
<Label class="text-sm font-medium">敏感信息保护</Label>
|
||||||
<Switch v-model="form.chat_pii_redaction_enabled" />
|
<Switch v-model="form.chat_pii_redaction_enabled" />
|
||||||
@@ -225,6 +228,15 @@
|
|||||||
:disabled="!form.chat_pii_redaction_enabled"
|
:disabled="!form.chat_pii_redaction_enabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">通知推送服务</Label>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
允许用户配置自己的第三方推送渠道
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="form.notification_push_service_enabled" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -271,6 +283,8 @@ import { log } from '@/utils/logger'
|
|||||||
import { parseNumberInput } from '@/utils/form'
|
import { parseNumberInput } from '@/utils/form'
|
||||||
import {
|
import {
|
||||||
mergeChatPiiRedactionFeatureSettings,
|
mergeChatPiiRedactionFeatureSettings,
|
||||||
|
mergeNotificationPushServiceFeatureSettings,
|
||||||
|
readNotificationPushServiceFeatureSettings,
|
||||||
readChatPiiRedactionFeatureSettings,
|
readChatPiiRedactionFeatureSettings,
|
||||||
} from '@/utils/featureSettings'
|
} from '@/utils/featureSettings'
|
||||||
import {
|
import {
|
||||||
@@ -323,6 +337,7 @@ const form = ref({
|
|||||||
group_ids: [] as string[],
|
group_ids: [] as string[],
|
||||||
chat_pii_redaction_enabled: false,
|
chat_pii_redaction_enabled: false,
|
||||||
chat_pii_redaction_placeholder_notice: true,
|
chat_pii_redaction_placeholder_notice: true,
|
||||||
|
notification_push_service_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
const groupOptions = computed(() => (props.groups || []).map((group) => ({
|
||||||
@@ -348,6 +363,7 @@ function resetForm() {
|
|||||||
group_ids: [],
|
group_ids: [],
|
||||||
chat_pii_redaction_enabled: false,
|
chat_pii_redaction_enabled: false,
|
||||||
chat_pii_redaction_placeholder_notice: true,
|
chat_pii_redaction_placeholder_notice: true,
|
||||||
|
notification_push_service_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +371,7 @@ function loadUserData() {
|
|||||||
if (!props.user) return
|
if (!props.user) return
|
||||||
formNonce.value = createFieldNonce()
|
formNonce.value = createFieldNonce()
|
||||||
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
|
const redactionFeature = readChatPiiRedactionFeatureSettings(props.user.feature_settings)
|
||||||
|
const notificationPushFeature = readNotificationPushServiceFeatureSettings(props.user.feature_settings)
|
||||||
// 创建数组副本,避免与 props 数据共享引用
|
// 创建数组副本,避免与 props 数据共享引用
|
||||||
form.value = {
|
form.value = {
|
||||||
username: props.user.username,
|
username: props.user.username,
|
||||||
@@ -368,6 +385,7 @@ function loadUserData() {
|
|||||||
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
|
group_ids: props.user.group_ids ? [...props.user.group_ids] : [],
|
||||||
chat_pii_redaction_enabled: redactionFeature.enabled,
|
chat_pii_redaction_enabled: redactionFeature.enabled,
|
||||||
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
chat_pii_redaction_placeholder_notice: redactionFeature.inject_model_instruction,
|
||||||
|
notification_push_service_enabled: notificationPushFeature.enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,10 +460,7 @@ async function handleSubmit() {
|
|||||||
unlimited: form.value.unlimited,
|
unlimited: form.value.unlimited,
|
||||||
role: form.value.role,
|
role: form.value.role,
|
||||||
group_ids: [...form.value.group_ids],
|
group_ids: [...form.value.group_ids],
|
||||||
feature_settings: mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
|
feature_settings: buildFeatureSettingsPayload(),
|
||||||
enabled: form.value.chat_pii_redaction_enabled,
|
|
||||||
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditMode.value && props.user?.id) {
|
if (isEditMode.value && props.user?.id) {
|
||||||
@@ -472,6 +487,16 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFeatureSettingsPayload(): Record<string, unknown> | null {
|
||||||
|
const withRedaction = mergeChatPiiRedactionFeatureSettings(props.user?.feature_settings, {
|
||||||
|
enabled: form.value.chat_pii_redaction_enabled,
|
||||||
|
inject_model_instruction: form.value.chat_pii_redaction_placeholder_notice,
|
||||||
|
})
|
||||||
|
return mergeNotificationPushServiceFeatureSettings(withRedaction, {
|
||||||
|
enabled: form.value.notification_push_service_enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 设置保存状态(供父组件调用)
|
// 设置保存状态(供父组件调用)
|
||||||
function setSaving(value: boolean) {
|
function setSaving(value: boolean) {
|
||||||
saving.value = value
|
saving.value = value
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ import {
|
|||||||
Puzzle,
|
Puzzle,
|
||||||
Zap,
|
Zap,
|
||||||
FileUp,
|
FileUp,
|
||||||
|
Send,
|
||||||
Server,
|
Server,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -748,6 +749,7 @@ const navigation = computed(() => {
|
|||||||
Shield,
|
Shield,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Server,
|
Server,
|
||||||
|
Send,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Gift,
|
Gift,
|
||||||
|
|||||||
@@ -910,6 +910,52 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
|
|||||||
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL(秒)' },
|
{ key: 'default_cache_ttl', value: 3600, description: '默认缓存 TTL(秒)' },
|
||||||
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
|
{ key: 'fallback_enabled', value: true, description: '是否启用故障转移' },
|
||||||
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
|
{ key: 'max_fallback_attempts', value: 3, description: '最大故障转移次数' },
|
||||||
|
{ key: 'module.important_notification.enabled', value: false, description: '通知服务总开关' },
|
||||||
|
{ key: 'module.important_notification.email_enabled', value: false, description: '通知服务邮件推送开关' },
|
||||||
|
{ key: 'module.important_notification.email_recipients', value: '', description: '通知服务管理员收件人' },
|
||||||
|
{ key: 'module.important_notification.default_channel', value: 'all', description: '通知服务全局推送服务' },
|
||||||
|
{
|
||||||
|
key: 'module.important_notification.items',
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
key: 'provider_quota_alert',
|
||||||
|
name: '号池额度不足',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'global',
|
||||||
|
title_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
text_template: '',
|
||||||
|
user_email_enabled: false,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'provider_pool_abnormal',
|
||||||
|
name: '号池异常',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'global',
|
||||||
|
title_template: '号池异常:{provider_name}',
|
||||||
|
markdown_template: '号池 `{provider_name}` 出现异常,请检查服务状态。',
|
||||||
|
text_template: '号池 {provider_name} 出现异常,请检查服务状态。',
|
||||||
|
user_email_enabled: false,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user_balance_low',
|
||||||
|
name: '用户余额不足',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'email',
|
||||||
|
title_template: '余额不足提醒',
|
||||||
|
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
|
||||||
|
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
|
||||||
|
user_email_enabled: true,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: '通知服务通知项和模板',
|
||||||
|
},
|
||||||
|
{ key: 'module.server_chan_push.enabled', value: false, description: 'Server 酱推送开关' },
|
||||||
|
{ key: 'module.server_chan_push.send_key', value: null, description: 'Server 酱 SendKey' },
|
||||||
|
{ key: 'module.server_chan_push.template', value: '', description: 'Server 酱推送模板' },
|
||||||
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
|
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
|
||||||
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
|
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
|
||||||
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
|
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
|
||||||
@@ -960,18 +1006,32 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'important_notification',
|
name: 'important_notification',
|
||||||
display_name: '重要通知',
|
display_name: '通知服务',
|
||||||
description: '统一发送邮件和 Server 酱重要通知,供额度提醒等后台任务使用',
|
description: '统一管理通知项、模板和推送服务选择,供后台任务和用户通知使用',
|
||||||
category: 'integration',
|
category: 'integration',
|
||||||
available: true,
|
available: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
config_validated: false,
|
config_validated: false,
|
||||||
config_error: '请先完成重要通知通道配置',
|
config_error: '请先完成通知服务推送渠道配置',
|
||||||
admin_route: '/admin/modules/important-notification',
|
admin_route: '/admin/notification-service',
|
||||||
admin_menu_icon: 'BellRing',
|
admin_menu_icon: 'BellRing',
|
||||||
admin_menu_group: 'system',
|
admin_menu_group: null,
|
||||||
admin_menu_order: 58,
|
admin_menu_order: 58,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'server_chan_push',
|
||||||
|
display_name: 'Server 酱推送',
|
||||||
|
description: '第三方推送服务,配置 Server 酱 Turbo SendKey 并测试微信推送',
|
||||||
|
category: 'integration',
|
||||||
|
available: true,
|
||||||
|
enabled: false,
|
||||||
|
config_validated: false,
|
||||||
|
config_error: '请先配置 Server 酱 SendKey',
|
||||||
|
admin_route: '/admin/modules/server-chan',
|
||||||
|
admin_menu_icon: 'Send',
|
||||||
|
admin_menu_group: 'system',
|
||||||
|
admin_menu_order: 59,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'chat_pii_redaction',
|
name: 'chat_pii_redaction',
|
||||||
display_name: '敏感信息保护',
|
display_name: '敏感信息保护',
|
||||||
|
|||||||
@@ -1805,6 +1805,14 @@ registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_conf
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
|
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
|
||||||
}
|
}
|
||||||
|
if (key === 'module.server_chan_push.send_key') {
|
||||||
|
return createMockResponse({
|
||||||
|
key: entry.key,
|
||||||
|
value: null,
|
||||||
|
description: entry.description,
|
||||||
|
is_set: typeof entry.value === 'string' && entry.value.trim() !== '',
|
||||||
|
})
|
||||||
|
}
|
||||||
return createMockResponse({ key: entry.key, value: entry.value, description: entry.description })
|
return createMockResponse({ key: entry.key, value: entry.value, description: entry.description })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -276,15 +276,23 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'modules/important-notification',
|
path: 'modules/important-notification',
|
||||||
|
redirect: '/admin/notification-service'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'notification-service',
|
||||||
name: 'ImportantNotificationModule',
|
name: 'ImportantNotificationModule',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
|
component: () => importWithRetry(() => import('@/views/admin/modules/ImportantNotification.vue')),
|
||||||
meta: { module: 'important_notification' }
|
meta: { module: 'important_notification' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'server-chan',
|
path: 'server-chan',
|
||||||
|
redirect: '/admin/modules/server-chan'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'modules/server-chan',
|
||||||
name: 'ServerChanSettings',
|
name: 'ServerChanSettings',
|
||||||
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
|
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
|
||||||
meta: { module: 'important_notification' }
|
meta: { module: 'server_chan_push' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'email',
|
path: 'email',
|
||||||
|
|||||||
43
frontend/src/tests/vitest.setup.ts
Normal file
43
frontend/src/tests/vitest.setup.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
function createMemoryStorage(): Storage {
|
||||||
|
const store = new Map<string, string>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return store.size
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
store.clear()
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return store.get(String(key)) ?? null
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(store.keys())[index] ?? null
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
store.delete(String(key))
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
store.set(String(key), String(value))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStorage(name: 'localStorage' | 'sessionStorage') {
|
||||||
|
const storage = createMemoryStorage()
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, name, {
|
||||||
|
value: storage,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, name, {
|
||||||
|
value: storage,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installStorage('localStorage')
|
||||||
|
installStorage('sessionStorage')
|
||||||
@@ -3,6 +3,10 @@ export interface ChatPiiRedactionFeatureSettings {
|
|||||||
inject_model_instruction: boolean
|
inject_model_instruction: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationPushServiceFeatureSettings {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type FeatureSettingsMap = Record<string, unknown>
|
export type FeatureSettingsMap = Record<string, unknown>
|
||||||
|
|
||||||
const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = {
|
const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettings = {
|
||||||
@@ -10,6 +14,10 @@ const DEFAULT_CHAT_PII_REDACTION_FEATURE_SETTINGS: ChatPiiRedactionFeatureSettin
|
|||||||
inject_model_instruction: true,
|
inject_model_instruction: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS: NotificationPushServiceFeatureSettings = {
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@@ -49,3 +57,30 @@ export function mergeChatPiiRedactionFeatureSettings(
|
|||||||
}
|
}
|
||||||
return Object.keys(settings).length > 0 ? settings : null
|
return Object.keys(settings).length > 0 ? settings : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readNotificationPushServiceFeatureSettings(
|
||||||
|
featureSettings: unknown,
|
||||||
|
): NotificationPushServiceFeatureSettings {
|
||||||
|
const feature = isRecord(featureSettings)
|
||||||
|
? featureSettings.notification_push_service
|
||||||
|
: null
|
||||||
|
if (!isRecord(feature)) {
|
||||||
|
return { ...DEFAULT_NOTIFICATION_PUSH_SERVICE_FEATURE_SETTINGS }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: feature.enabled === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeNotificationPushServiceFeatureSettings(
|
||||||
|
featureSettings: unknown,
|
||||||
|
notificationPushService: NotificationPushServiceFeatureSettings,
|
||||||
|
): FeatureSettingsMap | null {
|
||||||
|
const settings: FeatureSettingsMap = isRecord(featureSettings)
|
||||||
|
? { ...featureSettings }
|
||||||
|
: {}
|
||||||
|
settings.notification_push_service = {
|
||||||
|
enabled: notificationPushService.enabled,
|
||||||
|
}
|
||||||
|
return Object.keys(settings).length > 0 ? settings : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ const moduleOrder = ref<string[]>([])
|
|||||||
const orderSaving = ref(false)
|
const orderSaving = ref(false)
|
||||||
const draggedModuleName = ref<string | null>(null)
|
const draggedModuleName = ref<string | null>(null)
|
||||||
const dragOverModuleName = ref<string | null>(null)
|
const dragOverModuleName = ref<string | null>(null)
|
||||||
|
const BUILTIN_BACKING_MODULES = new Set(['important_notification'])
|
||||||
|
|
||||||
// 过滤后的内置工具
|
// 过滤后的内置工具
|
||||||
const filteredBuiltinTools = computed(() => {
|
const filteredBuiltinTools = computed(() => {
|
||||||
@@ -351,7 +352,9 @@ function moveNameToTargetIndex(names: string[], draggedName: string, targetName:
|
|||||||
|
|
||||||
// 后端默认顺序
|
// 后端默认顺序
|
||||||
const defaultOrderedModules = computed(() => {
|
const defaultOrderedModules = computed(() => {
|
||||||
return Object.values(moduleStore.modules).sort(compareModuleDefaultOrder)
|
return Object.values(moduleStore.modules)
|
||||||
|
.filter(module => !BUILTIN_BACKING_MODULES.has(module.name))
|
||||||
|
.sort(compareModuleDefaultOrder)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 所有模块列表(应用自定义展示顺序)
|
// 所有模块列表(应用自定义展示顺序)
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="重要通知"
|
title="通知服务"
|
||||||
description="配置后台任务使用的邮件和 Server 酱通知通道"
|
description="统一管理通知项、通知模板和推送服务选择"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<CardSection
|
<CardSection
|
||||||
title="模块开关"
|
title="通知服务配置"
|
||||||
description="启用后,额度提醒等后台任务可以发送重要通知"
|
description="选择全局推送服务,并配置邮件和第三方推送渠道"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button
|
<Button
|
||||||
@@ -20,158 +20,321 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="space-y-6">
|
||||||
<div>
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<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>
|
<div>
|
||||||
<Label class="text-sm font-medium">
|
<Label class="block text-sm font-medium">
|
||||||
启用邮件通道
|
全局推送服务
|
||||||
</Label>
|
</Label>
|
||||||
<p
|
<Select v-model="config.default_channel">
|
||||||
v-if="emailChannelConfigurable"
|
<SelectTrigger class="mt-1">
|
||||||
class="mt-1 text-xs text-muted-foreground"
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
SMTP 服务在邮件配置中维护
|
<SelectContent>
|
||||||
</p>
|
<SelectItem value="all">
|
||||||
<p
|
所有可用服务
|
||||||
v-else
|
</SelectItem>
|
||||||
class="mt-1 text-xs text-destructive"
|
<SelectItem value="email">
|
||||||
>
|
邮件
|
||||||
<template v-if="!smtpConfigured">
|
</SelectItem>
|
||||||
请先在
|
<SelectItem value="server_chan">
|
||||||
<RouterLink
|
Server 酱
|
||||||
to="/admin/email"
|
</SelectItem>
|
||||||
class="hover:underline"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
邮件配置
|
</div>
|
||||||
</RouterLink>
|
|
||||||
中配置 SMTP,
|
<div class="flex items-center justify-between gap-4">
|
||||||
</template>
|
<div>
|
||||||
<template v-else>
|
<Label class="text-sm font-medium">
|
||||||
请先
|
启用通知服务
|
||||||
</template>
|
</Label>
|
||||||
填写至少一个收件人后再启用
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
</p>
|
{{ canEnableService ? '当前策略有可用推送服务' : '当前策略没有可用推送服务' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
v-model="config.enabled"
|
||||||
|
:disabled="!canEnableService"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
v-model="config.email_enabled"
|
|
||||||
:disabled="!emailChannelConfigurable"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-2">
|
||||||
<Label
|
<section class="space-y-4">
|
||||||
for="important-notification-recipients"
|
<div class="flex items-center justify-between gap-3">
|
||||||
class="block text-sm font-medium"
|
<div>
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
收件人
|
<Label class="text-sm font-medium">
|
||||||
</Label>
|
邮件配置
|
||||||
<Textarea
|
</Label>
|
||||||
id="important-notification-recipients"
|
<Badge :variant="emailReady ? 'success' : 'outline'">
|
||||||
v-model="config.email_recipients"
|
{{ emailReady ? '可用' : '未就绪' }}
|
||||||
rows="4"
|
</Badge>
|
||||||
placeholder="ops@example.com admin@example.com"
|
</div>
|
||||||
class="mt-1"
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
/>
|
SMTP 配置在
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<RouterLink
|
||||||
支持换行、逗号或分号分隔
|
to="/admin/email"
|
||||||
</p>
|
class="text-primary hover:underline"
|
||||||
</div>
|
>
|
||||||
</div>
|
邮件配置
|
||||||
</CardSection>
|
</RouterLink>
|
||||||
|
中维护
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
v-model="config.email_enabled"
|
||||||
|
:disabled="!smtpConfigured"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardSection
|
<div>
|
||||||
title="Server 酱"
|
<Label
|
||||||
description="通过 Server 酱 Turbo SendKey 推送微信提醒"
|
for="notification-service-recipients"
|
||||||
>
|
class="block text-sm font-medium"
|
||||||
<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>
|
</Label>
|
||||||
配置 SendKey 后再启用
|
<Textarea
|
||||||
</p>
|
id="notification-service-recipients"
|
||||||
</div>
|
v-model="config.email_recipients"
|
||||||
<Switch
|
rows="4"
|
||||||
v-model="config.server_chan_enabled"
|
placeholder="ops@example.com admin@example.com"
|
||||||
:disabled="!serverChanKeyIsSet"
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p
|
<section class="space-y-4">
|
||||||
v-if="serverChanKeyIsSet"
|
<div class="flex items-center justify-between gap-3">
|
||||||
class="text-xs text-muted-foreground"
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label class="text-sm font-medium">
|
||||||
|
Server 酱
|
||||||
|
</Label>
|
||||||
|
<Badge :variant="serverChanReady ? 'success' : 'outline'">
|
||||||
|
{{ serverChanReady ? '可用' : '未就绪' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
第三方推送服务在扩展模块中独立启用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
to="/admin/modules/server-chan"
|
||||||
|
class="inline-flex h-11 items-center rounded-xl border border-border/60 bg-card/60 px-4 text-sm font-semibold text-foreground hover:border-primary/60 hover:bg-primary/10 hover:text-primary"
|
||||||
|
>
|
||||||
|
配置 Server 酱推送
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardSection>
|
||||||
|
|
||||||
|
<CardSection
|
||||||
|
title="通知项"
|
||||||
|
description="每个通知项可以继承全局服务,也可以单独指定推送服务"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="addItem"
|
||||||
>
|
>
|
||||||
前往
|
<Plus class="mr-1.5 h-4 w-4" />
|
||||||
<RouterLink
|
添加通知项
|
||||||
to="/admin/server-chan"
|
</Button>
|
||||||
class="text-primary hover:underline"
|
</template>
|
||||||
>
|
|
||||||
Server 酱
|
<div class="space-y-4">
|
||||||
</RouterLink>
|
<div
|
||||||
配置 SendKey 与通知模板。
|
v-for="(item, index) in config.items"
|
||||||
</p>
|
:key="item.local_id"
|
||||||
|
class="rounded-lg border border-border/70 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Label class="text-sm font-semibold">
|
||||||
|
{{ item.name || item.key || '未命名通知项' }}
|
||||||
|
</Label>
|
||||||
|
<Badge
|
||||||
|
v-if="item.system"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
内置
|
||||||
|
</Badge>
|
||||||
|
<Badge :variant="isItemReady(item) ? 'success' : 'outline'">
|
||||||
|
{{ isItemReady(item) ? '可投递' : '未就绪' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 truncate text-xs text-muted-foreground">
|
||||||
|
{{ item.key }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch v-model="item.enabled" />
|
||||||
|
<Button
|
||||||
|
v-if="!item.system"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeItem(index)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
通知键
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-model="item.key"
|
||||||
|
class="mt-1"
|
||||||
|
:disabled="item.system"
|
||||||
|
placeholder="custom_event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
名称
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-model="item.name"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="自定义通知"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
推送服务
|
||||||
|
</Label>
|
||||||
|
<Select v-model="item.channel">
|
||||||
|
<SelectTrigger class="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">
|
||||||
|
使用全局
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="all">
|
||||||
|
所有可用服务
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="email">
|
||||||
|
邮件
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="server_chan">
|
||||||
|
Server 酱
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-lg border border-border/70 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<Label class="text-xs font-medium">
|
||||||
|
用户邮件
|
||||||
|
</Label>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
允许发送到用户自己的邮箱
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
v-model="item.user_email_enabled"
|
||||||
|
:disabled="!smtpConfigured"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-4">
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
标题模板
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-model="item.title_template"
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="{title}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
Markdown 模板
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
v-model="item.markdown_template"
|
||||||
|
rows="5"
|
||||||
|
class="mt-1 font-mono text-sm"
|
||||||
|
placeholder="{body}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="block text-xs font-medium">
|
||||||
|
文本模板
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
v-model="item.text_template"
|
||||||
|
rows="5"
|
||||||
|
class="mt-1 font-mono text-sm"
|
||||||
|
placeholder="{text_body}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<CardSection
|
<CardSection
|
||||||
title="测试通知"
|
title="测试通知"
|
||||||
description="按当前已保存配置发送一条重要通知测试"
|
description="按已保存配置发送测试通知"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||||
|
<Select v-model="testItemKey">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择通知项" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in config.items"
|
||||||
|
:key="item.local_id"
|
||||||
|
:value="item.key"
|
||||||
|
>
|
||||||
|
{{ item.name || item.key }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select v-model="testChannel">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">
|
||||||
|
按通知项
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="all">
|
||||||
|
所有可用服务
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="email">
|
||||||
|
邮件
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="server_chan">
|
||||||
|
Server 酱
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="testingAll || !anyChannelConfigurable"
|
:disabled="testing || !testItemKey"
|
||||||
@click="testChannel('all')"
|
@click="handleTest"
|
||||||
>
|
>
|
||||||
{{ testingAll ? '发送中...' : '测试全部通道' }}
|
<Send class="mr-1.5 h-4 w-4" />
|
||||||
</Button>
|
{{ testing ? '发送中...' : '发送测试' }}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:disabled="testingEmail || !emailChannelConfigurable"
|
|
||||||
@click="testChannel('email')"
|
|
||||||
>
|
|
||||||
{{ testingEmail ? '发送中...' : '测试邮件' }}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,7 +345,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in lastTestResult"
|
v-for="item in lastTestResult"
|
||||||
:key="item.channel"
|
:key="item.channel"
|
||||||
class="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
|
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span>{{ formatChannel(item.channel) }}</span>
|
<span>{{ formatChannel(item.channel) }}</span>
|
||||||
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
|
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
|
||||||
@@ -198,55 +361,128 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import Button from '@/components/ui/button.vue'
|
import { Plus, Send, Trash2 } from 'lucide-vue-next'
|
||||||
import Label from '@/components/ui/label.vue'
|
import {
|
||||||
import Switch from '@/components/ui/switch.vue'
|
Badge,
|
||||||
import Textarea from '@/components/ui/textarea.vue'
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Switch,
|
||||||
|
Textarea,
|
||||||
|
} from '@/components/ui'
|
||||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { modulesApi } from '@/api/modules'
|
import { modulesApi, type ModuleStatus } from '@/api/modules'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
|
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan'
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
local_id: string
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
channel: DeliveryChannel
|
||||||
|
title_template: string
|
||||||
|
markdown_template: string
|
||||||
|
text_template: string
|
||||||
|
user_email_enabled: boolean
|
||||||
|
system: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationConfig {
|
||||||
|
enabled: boolean
|
||||||
|
email_enabled: boolean
|
||||||
|
email_recipients: string
|
||||||
|
default_channel: Exclude<DeliveryChannel, 'global'>
|
||||||
|
items: NotificationItem[]
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIG_KEYS = {
|
const CONFIG_KEYS = {
|
||||||
enabled: 'module.important_notification.enabled',
|
enabled: 'module.important_notification.enabled',
|
||||||
email_enabled: 'module.important_notification.email_enabled',
|
email_enabled: 'module.important_notification.email_enabled',
|
||||||
email_recipients: 'module.important_notification.email_recipients',
|
email_recipients: 'module.important_notification.email_recipients',
|
||||||
server_chan_enabled: 'module.important_notification.server_chan_enabled',
|
default_channel: 'module.important_notification.default_channel',
|
||||||
server_chan_send_key: 'module.important_notification.server_chan_send_key',
|
items: 'module.important_notification.items',
|
||||||
|
server_chan_send_key: 'module.server_chan_push.send_key',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
interface ImportantNotificationConfig {
|
const DEFAULT_ITEMS: NotificationItem[] = [
|
||||||
enabled: boolean
|
{
|
||||||
email_enabled: boolean
|
local_id: 'provider_quota_alert',
|
||||||
email_recipients: string
|
key: 'provider_quota_alert',
|
||||||
server_chan_enabled: boolean
|
name: '号池额度不足',
|
||||||
}
|
enabled: true,
|
||||||
|
channel: 'global',
|
||||||
|
title_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
text_template: '',
|
||||||
|
user_email_enabled: false,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
local_id: 'provider_pool_abnormal',
|
||||||
|
key: 'provider_pool_abnormal',
|
||||||
|
name: '号池异常',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'global',
|
||||||
|
title_template: '号池异常:{provider_name}',
|
||||||
|
markdown_template: '号池 `{provider_name}` 出现异常,请检查服务状态。',
|
||||||
|
text_template: '号池 {provider_name} 出现异常,请检查服务状态。',
|
||||||
|
user_email_enabled: false,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
local_id: 'user_balance_low',
|
||||||
|
key: 'user_balance_low',
|
||||||
|
name: '用户余额不足',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'email',
|
||||||
|
title_template: '余额不足提醒',
|
||||||
|
markdown_template: '你的账户余额已低于提醒阈值,请及时处理。',
|
||||||
|
text_template: '你的账户余额已低于提醒阈值,请及时处理。',
|
||||||
|
user_email_enabled: true,
|
||||||
|
system: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testingAll = ref(false)
|
const testing = ref(false)
|
||||||
const testingEmail = ref(false)
|
|
||||||
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
|
|
||||||
|
|
||||||
const smtpConfigured = ref(false)
|
const smtpConfigured = ref(false)
|
||||||
const serverChanKeyIsSet = ref(false)
|
const serverChanKeyIsSet = ref(false)
|
||||||
|
const serverChanStatus = ref<ModuleStatus | null>(null)
|
||||||
|
const testItemKey = ref('provider_quota_alert')
|
||||||
|
const testChannel = ref<DeliveryChannel>('global')
|
||||||
|
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
|
||||||
|
|
||||||
const config = ref<ImportantNotificationConfig>({
|
const config = ref<NotificationConfig>({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
email_enabled: false,
|
email_enabled: false,
|
||||||
email_recipients: '',
|
email_recipients: '',
|
||||||
server_chan_enabled: false,
|
default_channel: 'all',
|
||||||
|
items: cloneDefaultItems(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const emailChannelConfigurable = computed(() => {
|
const emailReady = computed(() => {
|
||||||
return smtpConfigured.value && config.value.email_recipients.trim() !== ''
|
return config.value.email_enabled && smtpConfigured.value && config.value.email_recipients.trim() !== ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const anyChannelConfigurable = computed(() => {
|
const serverChanReady = computed(() => {
|
||||||
return emailChannelConfigurable.value || serverChanKeyIsSet.value
|
return serverChanStatus.value?.enabled === true && serverChanKeyIsSet.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEnableService = computed(() => {
|
||||||
|
if (deliveryReady(config.value.default_channel)) return true
|
||||||
|
return config.value.items.some(item => item.enabled && isItemReady(item))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -259,7 +495,9 @@ async function loadConfig() {
|
|||||||
moduleStatus,
|
moduleStatus,
|
||||||
emailEnabled,
|
emailEnabled,
|
||||||
recipients,
|
recipients,
|
||||||
serverChanEnabled,
|
defaultChannel,
|
||||||
|
items,
|
||||||
|
serverChanModuleStatus,
|
||||||
serverChanKey,
|
serverChanKey,
|
||||||
smtpHost,
|
smtpHost,
|
||||||
smtpFromEmail,
|
smtpFromEmail,
|
||||||
@@ -267,7 +505,9 @@ async function loadConfig() {
|
|||||||
modulesApi.getStatus('important_notification'),
|
modulesApi.getStatus('important_notification'),
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
|
adminApi.getSystemConfig(CONFIG_KEYS.email_enabled),
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
|
adminApi.getSystemConfig(CONFIG_KEYS.email_recipients),
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_enabled),
|
adminApi.getSystemConfig(CONFIG_KEYS.default_channel),
|
||||||
|
adminApi.getSystemConfig(CONFIG_KEYS.items),
|
||||||
|
modulesApi.getStatus('server_chan_push'),
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
|
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
|
||||||
adminApi.getSystemConfig('smtp_host'),
|
adminApi.getSystemConfig('smtp_host'),
|
||||||
adminApi.getSystemConfig('smtp_from_email'),
|
adminApi.getSystemConfig('smtp_from_email'),
|
||||||
@@ -276,43 +516,49 @@ async function loadConfig() {
|
|||||||
config.value.enabled = moduleStatus.enabled === true
|
config.value.enabled = moduleStatus.enabled === true
|
||||||
config.value.email_enabled = emailEnabled.value === true
|
config.value.email_enabled = emailEnabled.value === true
|
||||||
config.value.email_recipients = normalizeRecipients(recipients.value)
|
config.value.email_recipients = normalizeRecipients(recipients.value)
|
||||||
config.value.server_chan_enabled = serverChanEnabled.value === true
|
config.value.default_channel = normalizeDefaultChannel(defaultChannel.value)
|
||||||
|
config.value.items = normalizeItems(items.value)
|
||||||
|
serverChanStatus.value = serverChanModuleStatus
|
||||||
serverChanKeyIsSet.value = serverChanKey.is_set === true
|
serverChanKeyIsSet.value = serverChanKey.is_set === true
|
||||||
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
|
smtpConfigured.value = isNonEmptyString(smtpHost.value) && isNonEmptyString(smtpFromEmail.value)
|
||||||
|
if (!config.value.items.some(item => item.key === testItemKey.value)) {
|
||||||
|
testItemKey.value = config.value.items[0]?.key || ''
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '加载重要通知配置失败'))
|
error(parseApiError(err, '加载通知服务配置失败'))
|
||||||
log.error('加载重要通知配置失败:', err)
|
log.error('加载通知服务配置失败:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (!config.value.enabled) {
|
if (!canEnableService.value) {
|
||||||
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, false, '重要通知模块总开关')
|
config.value.enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '重要通知邮件通道开关'),
|
adminApi.updateSystemConfig(CONFIG_KEYS.email_enabled, config.value.email_enabled, '通知服务邮件推送开关'),
|
||||||
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '重要通知邮件收件人'),
|
adminApi.updateSystemConfig(CONFIG_KEYS.email_recipients, config.value.email_recipients, '通知服务管理员收件人'),
|
||||||
adminApi.updateSystemConfig(CONFIG_KEYS.server_chan_enabled, config.value.server_chan_enabled, '重要通知 Server 酱通道开关'),
|
adminApi.updateSystemConfig(CONFIG_KEYS.default_channel, config.value.default_channel, '通知服务全局推送服务'),
|
||||||
|
adminApi.updateSystemConfig(CONFIG_KEYS.items, serializeItems(), '通知服务通知项和模板'),
|
||||||
])
|
])
|
||||||
if (config.value.enabled) {
|
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, config.value.enabled, '通知服务总开关')
|
||||||
await adminApi.updateSystemConfig(CONFIG_KEYS.enabled, true, '重要通知模块总开关')
|
success('通知服务配置已保存')
|
||||||
}
|
|
||||||
success('重要通知配置已保存')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '保存重要通知配置失败'))
|
error(parseApiError(err, '保存通知服务配置失败'))
|
||||||
log.error('保存重要通知配置失败:', err)
|
log.error('保存通知服务配置失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testChannel(channel: 'all' | 'email') {
|
async function handleTest() {
|
||||||
setTesting(channel, true)
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.testImportantNotification(channel)
|
const result = await adminApi.testImportantNotification({
|
||||||
|
item_key: testItemKey.value,
|
||||||
|
channel: testChannel.value === 'global' ? undefined : testChannel.value,
|
||||||
|
})
|
||||||
lastTestResult.value = result.channels || []
|
lastTestResult.value = result.channels || []
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
success(result.message || '测试通知已发送')
|
success(result.message || '测试通知已发送')
|
||||||
@@ -321,15 +567,111 @@ async function testChannel(channel: 'all' | 'email') {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '测试通知发送失败'))
|
error(parseApiError(err, '测试通知发送失败'))
|
||||||
log.error('测试重要通知失败:', err)
|
log.error('测试通知服务失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(channel, false)
|
testing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTesting(channel: 'all' | 'email', value: boolean) {
|
function addItem() {
|
||||||
if (channel === 'all') testingAll.value = value
|
const suffix = Date.now().toString(36)
|
||||||
if (channel === 'email') testingEmail.value = value
|
const key = `custom_${suffix}`
|
||||||
|
config.value.items.push({
|
||||||
|
local_id: key,
|
||||||
|
key,
|
||||||
|
name: '自定义通知',
|
||||||
|
enabled: true,
|
||||||
|
channel: 'global',
|
||||||
|
title_template: '',
|
||||||
|
markdown_template: '',
|
||||||
|
text_template: '',
|
||||||
|
user_email_enabled: false,
|
||||||
|
system: false,
|
||||||
|
})
|
||||||
|
testItemKey.value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
const [removed] = config.value.items.splice(index, 1)
|
||||||
|
if (removed?.key === testItemKey.value) {
|
||||||
|
testItemKey.value = config.value.items[0]?.key || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isItemReady(item: NotificationItem): boolean {
|
||||||
|
if (!item.enabled) return false
|
||||||
|
return deliveryReady(resolveItemChannel(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryReady(channel: Exclude<DeliveryChannel, 'global'>): boolean {
|
||||||
|
if (channel === 'all') return emailReady.value || serverChanReady.value
|
||||||
|
if (channel === 'email') return emailReady.value
|
||||||
|
if (channel === 'server_chan') return serverChanReady.value
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveItemChannel(item: NotificationItem): Exclude<DeliveryChannel, 'global'> {
|
||||||
|
return item.channel === 'global' ? config.value.default_channel : item.channel
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeItems() {
|
||||||
|
return config.value.items.map(item => ({
|
||||||
|
key: normalizeItemKey(item.key),
|
||||||
|
name: item.name.trim() || normalizeItemKey(item.key),
|
||||||
|
enabled: item.enabled,
|
||||||
|
channel: item.channel,
|
||||||
|
title_template: item.title_template.trim(),
|
||||||
|
markdown_template: item.markdown_template.trim(),
|
||||||
|
text_template: item.text_template.trim(),
|
||||||
|
user_email_enabled: item.user_email_enabled,
|
||||||
|
system: item.system,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItems(value: unknown): NotificationItem[] {
|
||||||
|
if (!Array.isArray(value)) return cloneDefaultItems()
|
||||||
|
const items = value
|
||||||
|
.map((item, index) => normalizeItem(item, index))
|
||||||
|
.filter((item): item is NotificationItem => item !== null)
|
||||||
|
return items.length > 0 ? items : cloneDefaultItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(value: unknown, index: number): NotificationItem | null {
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const raw = value as Record<string, unknown>
|
||||||
|
const key = normalizeItemKey(raw.key)
|
||||||
|
if (!key) return null
|
||||||
|
return {
|
||||||
|
local_id: `${key}_${index}`,
|
||||||
|
key,
|
||||||
|
name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : key,
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
channel: normalizeItemChannel(raw.channel),
|
||||||
|
title_template: typeof raw.title_template === 'string' ? raw.title_template : '',
|
||||||
|
markdown_template: typeof raw.markdown_template === 'string' ? raw.markdown_template : '',
|
||||||
|
text_template: typeof raw.text_template === 'string' ? raw.text_template : '',
|
||||||
|
user_email_enabled: raw.user_email_enabled === true,
|
||||||
|
system: raw.system === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemKey(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
return value.trim().replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItemChannel(value: unknown): DeliveryChannel {
|
||||||
|
if (value === 'all' || value === 'email' || value === 'server_chan') return value
|
||||||
|
return 'global'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
|
||||||
|
if (value === 'email' || value === 'server_chan') return value
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDefaultItems(): NotificationItem[] {
|
||||||
|
return DEFAULT_ITEMS.map(item => ({ ...item }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): boolean {
|
function isNonEmptyString(value: unknown): boolean {
|
||||||
@@ -349,7 +691,10 @@ function normalizeRecipients(value: unknown): string {
|
|||||||
function formatChannel(channel: string): string {
|
function formatChannel(channel: string): string {
|
||||||
if (channel === 'email') return '邮件'
|
if (channel === 'email') return '邮件'
|
||||||
if (channel === 'server_chan') return 'Server 酱'
|
if (channel === 'server_chan') return 'Server 酱'
|
||||||
|
if (channel === 'user_email') return '用户邮件'
|
||||||
if (channel === 'module') return '模块'
|
if (channel === 'module') return '模块'
|
||||||
|
if (channel === 'item') return '通知项'
|
||||||
|
if (channel === 'none') return '无可用服务'
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Server 酱"
|
title="Server 酱推送"
|
||||||
description="配置 Server 酱 Turbo SendKey 与微信通知模板"
|
description="第三方推送服务,用于通知服务的 Server 酱渠道"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
<CardSection
|
<CardSection
|
||||||
title="SendKey"
|
title="服务配置"
|
||||||
description="使用 Server 酱 Turbo 官方 SendKey 推送微信通知"
|
description="配置 Server 酱 Turbo SendKey 和服务启用状态"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button
|
<Button
|
||||||
@@ -20,29 +20,46 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div>
|
<div class="space-y-5">
|
||||||
<Label
|
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/70 px-4 py-3">
|
||||||
for="server-chan-send-key"
|
<div>
|
||||||
class="block text-sm font-medium"
|
<Label class="text-sm font-medium">
|
||||||
>
|
启用 Server 酱推送
|
||||||
SendKey
|
</Label>
|
||||||
</Label>
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
<Input
|
通知服务选择 Server 酱时会检查此开关
|
||||||
id="server-chan-send-key"
|
</p>
|
||||||
v-model="sendKeyInput"
|
</div>
|
||||||
masked
|
<Switch
|
||||||
:placeholder="sendKeyIsSet ? '已设置(留空保持不变)' : 'SCTxxxxxxxxxxxxxxxxxxxxxxxx'"
|
v-model="enabled"
|
||||||
class="mt-1"
|
:disabled="!canEnable"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
</div>
|
||||||
可在 <span class="font-mono">sct.ftqq.com</span> 控制台获取
|
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<CardSection
|
<CardSection
|
||||||
title="通知模板"
|
title="通知模板"
|
||||||
description="可选 Markdown 模板,支持 {title} 和 {body} 变量;留空则使用默认正文"
|
description="Markdown 模板支持 {title} 和 {body} 变量"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label
|
||||||
@@ -51,32 +68,35 @@
|
|||||||
>
|
>
|
||||||
模板内容
|
模板内容
|
||||||
</Label>
|
</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="server-chan-template"
|
id="server-chan-template"
|
||||||
v-model="templateInput"
|
v-model="templateInput"
|
||||||
rows="10"
|
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"
|
class="mt-1 font-mono text-sm"
|
||||||
placeholder="**{title}** {body}"
|
placeholder="**{title}** {body}"
|
||||||
spellcheck="false"
|
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>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
<CardSection
|
<CardSection
|
||||||
title="测试 Server 酱"
|
title="测试服务"
|
||||||
description="按当前已保存配置向微信发送一条测试通知"
|
description="按已保存配置发送一条 Server 酱测试通知"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="testing"
|
:disabled="testing || !sendKeyIsSet"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
>
|
||||||
{{ testing ? '发送中...' : '测试 Server 酱' }}
|
{{ testing ? '发送中...' : '发送测试' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<RouterLink
|
||||||
|
to="/admin/notification-service"
|
||||||
|
class="inline-flex h-11 items-center rounded-xl px-3 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
打开通知服务
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +106,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in lastTestResult"
|
v-for="item in lastTestResult"
|
||||||
:key="item.channel"
|
:key="item.channel"
|
||||||
class="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
|
class="flex items-center justify-between gap-4 rounded-md border border-border px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span>{{ formatChannel(item.channel) }}</span>
|
<span>{{ formatChannel(item.channel) }}</span>
|
||||||
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
|
<span :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
|
||||||
@@ -100,47 +120,53 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import { RouterLink } from 'vue-router'
|
||||||
import Input from '@/components/ui/input.vue'
|
import { Button, Input, Label, Switch, Textarea } from '@/components/ui'
|
||||||
import Label from '@/components/ui/label.vue'
|
|
||||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
|
import { modulesApi } from '@/api/modules'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const CONFIG_KEYS = {
|
const CONFIG_KEYS = {
|
||||||
server_chan_send_key: 'module.important_notification.server_chan_send_key',
|
enabled: 'module.server_chan_push.enabled',
|
||||||
server_chan_template: 'module.important_notification.server_chan_template',
|
send_key: 'module.server_chan_push.send_key',
|
||||||
|
template: 'module.server_chan_push.template',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const { success, error } = useToast()
|
const { success, error } = useToast()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
|
const enabled = ref(false)
|
||||||
const sendKeyIsSet = ref(false)
|
const sendKeyIsSet = ref(false)
|
||||||
const sendKeyInput = ref('')
|
const sendKeyInput = ref('')
|
||||||
const templateInput = ref('')
|
const templateInput = ref('')
|
||||||
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
|
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
|
||||||
|
|
||||||
|
const canEnable = computed(() => sendKeyIsSet.value || sendKeyInput.value.trim() !== '')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const [sendKey, template] = await Promise.all([
|
const [moduleStatus, sendKey, template] = await Promise.all([
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
|
modulesApi.getStatus('server_chan_push'),
|
||||||
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_template),
|
adminApi.getSystemConfig(CONFIG_KEYS.send_key),
|
||||||
|
adminApi.getSystemConfig(CONFIG_KEYS.template),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
enabled.value = moduleStatus.enabled === true
|
||||||
sendKeyIsSet.value = sendKey.is_set === true
|
sendKeyIsSet.value = sendKey.is_set === true
|
||||||
sendKeyInput.value = ''
|
sendKeyInput.value = ''
|
||||||
templateInput.value = typeof template.value === 'string' ? template.value : ''
|
templateInput.value = typeof template.value === 'string' ? template.value : ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '加载 Server 酱配置失败'))
|
error(parseApiError(err, '加载 Server 酱推送配置失败'))
|
||||||
log.error('加载 Server 酱配置失败:', err)
|
log.error('加载 Server 酱推送配置失败:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,31 +174,25 @@ async function saveConfig() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const updates: Array<Promise<unknown>> = [
|
const updates: Array<Promise<unknown>> = [
|
||||||
adminApi.updateSystemConfig(
|
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Server 酱推送模板'),
|
||||||
CONFIG_KEYS.server_chan_template,
|
|
||||||
templateInput.value,
|
|
||||||
'重要通知 Server 酱 通知模板',
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
const trimmedKey = sendKeyInput.value.trim()
|
const trimmedKey = sendKeyInput.value.trim()
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
updates.push(
|
updates.push(adminApi.updateSystemConfig(CONFIG_KEYS.send_key, trimmedKey, 'Server 酱 SendKey'))
|
||||||
adminApi.updateSystemConfig(
|
|
||||||
CONFIG_KEYS.server_chan_send_key,
|
|
||||||
trimmedKey,
|
|
||||||
'重要通知 Server 酱 SendKey',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
await Promise.all(updates)
|
await Promise.all(updates)
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
sendKeyIsSet.value = true
|
sendKeyIsSet.value = true
|
||||||
sendKeyInput.value = ''
|
sendKeyInput.value = ''
|
||||||
}
|
}
|
||||||
success('Server 酱配置已保存')
|
if (!canEnable.value) {
|
||||||
|
enabled.value = false
|
||||||
|
}
|
||||||
|
await modulesApi.setEnabled('server_chan_push', enabled.value)
|
||||||
|
success('Server 酱推送配置已保存')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '保存 Server 酱配置失败'))
|
error(parseApiError(err, '保存 Server 酱推送配置失败'))
|
||||||
log.error('保存 Server 酱配置失败:', err)
|
log.error('保存 Server 酱推送配置失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -181,7 +201,7 @@ async function saveConfig() {
|
|||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
try {
|
||||||
const result = await adminApi.testImportantNotification('server_chan')
|
const result = await adminApi.testImportantNotification({ channel: 'server_chan' })
|
||||||
lastTestResult.value = result.channels || []
|
lastTestResult.value = result.channels || []
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
success(result.message || '测试通知已发送')
|
success(result.message || '测试通知已发送')
|
||||||
@@ -190,7 +210,7 @@ async function handleTest() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '测试通知发送失败'))
|
error(parseApiError(err, '测试通知发送失败'))
|
||||||
log.error('测试 Server 酱失败:', err)
|
log.error('测试 Server 酱推送失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
@@ -200,6 +220,7 @@ function formatChannel(channel: string): string {
|
|||||||
if (channel === 'server_chan') return 'Server 酱'
|
if (channel === 'server_chan') return 'Server 酱'
|
||||||
if (channel === 'email') return '邮件'
|
if (channel === 'email') return '邮件'
|
||||||
if (channel === 'module') return '模块'
|
if (channel === 'module') return '模块'
|
||||||
|
if (channel === 'none') return '无可用服务'
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -124,6 +124,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-if="featureSettingsForm.notificationPushServiceEnabled"
|
||||||
|
class="p-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-foreground">
|
||||||
|
通知推送服务
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
管理员已允许你配置自己的第三方推送渠道
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="success">
|
||||||
|
已开放
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- 密码设置(LDAP 用户不显示) -->
|
<!-- 密码设置(LDAP 用户不显示) -->
|
||||||
<Card
|
<Card
|
||||||
v-if="profile?.auth_source !== 'ldap'"
|
v-if="profile?.auth_source !== 'ldap'"
|
||||||
@@ -499,7 +518,7 @@
|
|||||||
邮件通知
|
邮件通知
|
||||||
</Label>
|
</Label>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
接收系统重要通知
|
接收系统通知邮件
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -671,6 +690,7 @@ import { log } from '@/utils/logger'
|
|||||||
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
|
import { getErrorMessage, getErrorStatus } from '@/types/api-error'
|
||||||
import {
|
import {
|
||||||
mergeChatPiiRedactionFeatureSettings,
|
mergeChatPiiRedactionFeatureSettings,
|
||||||
|
readNotificationPushServiceFeatureSettings,
|
||||||
readChatPiiRedactionFeatureSettings,
|
readChatPiiRedactionFeatureSettings,
|
||||||
} from '@/utils/featureSettings'
|
} from '@/utils/featureSettings'
|
||||||
|
|
||||||
@@ -715,6 +735,7 @@ const preferencesForm = ref({
|
|||||||
const featureSettingsForm = ref({
|
const featureSettingsForm = ref({
|
||||||
chatPiiRedactionEnabled: false,
|
chatPiiRedactionEnabled: false,
|
||||||
chatPiiRedactionInjectNotice: true,
|
chatPiiRedactionInjectNotice: true,
|
||||||
|
notificationPushServiceEnabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const savingProfile = ref(false)
|
const savingProfile = ref(false)
|
||||||
@@ -819,9 +840,11 @@ async function loadProfile() {
|
|||||||
username: profile.value.username
|
username: profile.value.username
|
||||||
}
|
}
|
||||||
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
|
const redactionFeature = readChatPiiRedactionFeatureSettings(profile.value.feature_settings)
|
||||||
|
const notificationPushFeature = readNotificationPushServiceFeatureSettings(profile.value.feature_settings)
|
||||||
featureSettingsForm.value = {
|
featureSettingsForm.value = {
|
||||||
chatPiiRedactionEnabled: redactionFeature.enabled,
|
chatPiiRedactionEnabled: redactionFeature.enabled,
|
||||||
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
|
chatPiiRedactionInjectNotice: redactionFeature.inject_model_instruction,
|
||||||
|
notificationPushServiceEnabled: notificationPushFeature.enabled,
|
||||||
}
|
}
|
||||||
// 保存原始值
|
// 保存原始值
|
||||||
originalProfileForm.value = { ...profileForm.value }
|
originalProfileForm.value = { ...profileForm.value }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/tests/vitest.setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
Reference in New Issue
Block a user