feat: polish console shell and notification settings

This commit is contained in:
boos
2026-06-06 19:56:56 +08:00
parent cd843465b2
commit 5ecea182bc
10 changed files with 790 additions and 78 deletions
+237 -12
View File
@@ -1,6 +1,7 @@
use crate::{
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
error::{AppError, AppResult},
services::notification::NotificationService,
AppState,
};
use axum::{
@@ -12,6 +13,7 @@ use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
use std::collections::HashMap;
pub fn routes() -> Router<AppState> {
Router::new()
@@ -914,6 +916,88 @@ fn mask_downstream_key(key: &str) -> String {
format!("••••••••{}", last4)
}
fn mask_optional_secret(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
let chars: Vec<char> = value.chars().collect();
let keep = chars.len().min(4);
let suffix: String = chars[chars.len().saturating_sub(keep)..].iter().collect();
Some(format!("••••{}", suffix))
}
async fn load_settings_map(
state: &AppState,
prefix: Option<&str>,
) -> AppResult<HashMap<String, String>> {
let rows: Vec<(String, Option<String>)> = match prefix {
Some(prefix) => sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE ?")
.bind(format!("{prefix}%"))
.fetch_all(&*state.db)
.await?,
None => sqlx::query_as("SELECT key, value FROM settings")
.fetch_all(&*state.db)
.await?,
};
Ok(rows
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect())
}
fn setting_string(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key)
.map(|value| parse_runtime_bool(value))
.unwrap_or(default)
}
fn setting_i64(map: &HashMap<String, String>, key: &str, default: i64) -> i64 {
map.get(key)
.and_then(|value| value.trim().parse::<i64>().ok())
.unwrap_or(default)
}
fn parse_runtime_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
)
}
async fn upsert_setting(state: &AppState, key: &str, value: impl Into<String>) -> AppResult<()> {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(value.into())
.execute(&*state.db)
.await?;
Ok(())
}
async fn upsert_optional_secret(
state: &AppState,
key: &str,
value: Option<String>,
) -> AppResult<()> {
let Some(value) = value.map(|value| value.trim().to_string()) else {
return Ok(());
};
if value.is_empty() || value.chars().all(|ch| ch == '•' || ch == '*') {
return Ok(());
}
upsert_setting(state, key, value).await
}
fn bool_setting_value(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
return Vec::new();
@@ -964,10 +1048,45 @@ async fn change_auth_token() -> Json<Value> {
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
}
async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
Json(json!({
async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
let settings = load_settings_map(&state, Some("notify_")).await?;
let smtp_port = setting_i64(&settings, "notify_smtp_port", 587).clamp(1, 65_535);
let has_feishu = setting_string(&settings, "notify_feishu_webhook").is_some();
let has_serverchan = setting_string(&settings, "notify_serverchan_key").is_some();
let has_telegram = setting_string(&settings, "notify_telegram_bot_token").is_some()
&& setting_string(&settings, "notify_telegram_chat_id").is_some();
let has_smtp = setting_string(&settings, "notify_smtp_host").is_some()
&& setting_string(&settings, "notify_smtp_user").is_some()
&& setting_string(&settings, "notify_smtp_pass").is_some()
&& setting_string(&settings, "notify_smtp_from").is_some();
Ok(Json(json!({
"proxyTokenMasked": state.runtime_auth.proxy_token_masked(),
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
"webhookEnabled": setting_bool(&settings, "notify_webhook_enabled", true),
"webhookUrl": setting_string(&settings, "notify_webhook_url").unwrap_or_default(),
"barkEnabled": setting_bool(&settings, "notify_bark_enabled", true),
"barkUrl": setting_string(&settings, "notify_bark_url").unwrap_or_default(),
"feishuEnabled": setting_bool(&settings, "notify_feishu_enabled", has_feishu),
"feishuWebhook": "",
"feishuWebhookMasked": mask_optional_secret(setting_string(&settings, "notify_feishu_webhook").as_deref()),
"serverChanEnabled": setting_bool(&settings, "notify_serverchan_enabled", has_serverchan),
"serverChanKeyMasked": mask_optional_secret(setting_string(&settings, "notify_serverchan_key").as_deref()),
"telegramEnabled": setting_bool(&settings, "notify_telegram_enabled", has_telegram),
"telegramApiBaseUrl": setting_string(&settings, "notify_telegram_api_base_url")
.unwrap_or_else(|| "https://api.telegram.org".to_string()),
"telegramBotTokenMasked": mask_optional_secret(setting_string(&settings, "notify_telegram_bot_token").as_deref()),
"telegramChatId": setting_string(&settings, "notify_telegram_chat_id").unwrap_or_default(),
"telegramUseSystemProxy": setting_bool(&settings, "notify_telegram_use_system_proxy", false),
"telegramMessageThreadId": setting_string(&settings, "notify_telegram_thread_id").unwrap_or_default(),
"smtpEnabled": setting_bool(&settings, "notify_smtp_enabled", has_smtp),
"smtpHost": setting_string(&settings, "notify_smtp_host").unwrap_or_default(),
"smtpPort": smtp_port,
"smtpSecure": setting_bool(&settings, "notify_smtp_secure", true),
"smtpUser": setting_string(&settings, "notify_smtp_user").unwrap_or_default(),
"smtpPassMasked": mask_optional_secret(setting_string(&settings, "notify_smtp_pass").as_deref()),
"smtpFrom": setting_string(&settings, "notify_smtp_from").unwrap_or_default(),
"smtpTo": setting_string(&settings, "notify_smtp_to").unwrap_or_default(),
"notifyCooldownSec": setting_i64(&settings, "notify_cooldown_sec", 300).max(0),
"server": {
"host": state.config.server.host,
"port": state.config.server.port,
@@ -988,13 +1107,36 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
"externalUrl": state.config.comfyui.external_url,
"agentModel": state.config.comfyui.agent_model,
},
}))
})))
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimeSettingsUpdateRequest {
proxy_token: Option<String>,
webhook_url: Option<String>,
bark_url: Option<String>,
webhook_enabled: Option<bool>,
bark_enabled: Option<bool>,
feishu_enabled: Option<bool>,
feishu_webhook: Option<String>,
server_chan_enabled: Option<bool>,
server_chan_key: Option<String>,
telegram_enabled: Option<bool>,
telegram_api_base_url: Option<String>,
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
telegram_use_system_proxy: Option<bool>,
telegram_message_thread_id: Option<String>,
smtp_enabled: Option<bool>,
smtp_host: Option<String>,
smtp_port: Option<i64>,
smtp_secure: Option<bool>,
smtp_user: Option<String>,
smtp_pass: Option<String>,
smtp_from: Option<String>,
smtp_to: Option<String>,
notify_cooldown_sec: Option<i64>,
}
async fn update_runtime_settings(
@@ -1014,11 +1156,88 @@ async fn update_runtime_settings(
})));
}
Ok(Json(json!({
"success": true,
"proxyTokenMasked": state.runtime_auth.proxy_token_masked(),
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
})))
if let Some(enabled) = req.webhook_enabled {
upsert_setting(&state, "notify_webhook_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.webhook_url {
upsert_setting(&state, "notify_webhook_url", value.trim().to_string()).await?;
}
if let Some(enabled) = req.bark_enabled {
upsert_setting(&state, "notify_bark_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.bark_url {
upsert_setting(&state, "notify_bark_url", value.trim().to_string()).await?;
}
if let Some(enabled) = req.feishu_enabled {
upsert_setting(&state, "notify_feishu_enabled", bool_setting_value(enabled)).await?;
}
upsert_optional_secret(&state, "notify_feishu_webhook", req.feishu_webhook).await?;
if let Some(enabled) = req.server_chan_enabled {
upsert_setting(
&state,
"notify_serverchan_enabled",
bool_setting_value(enabled),
)
.await?;
}
upsert_optional_secret(&state, "notify_serverchan_key", req.server_chan_key).await?;
if let Some(enabled) = req.telegram_enabled {
upsert_setting(
&state,
"notify_telegram_enabled",
bool_setting_value(enabled),
)
.await?;
}
if let Some(value) = req.telegram_api_base_url {
upsert_setting(&state, "notify_telegram_api_base_url", value.trim().to_string()).await?;
}
upsert_optional_secret(&state, "notify_telegram_bot_token", req.telegram_bot_token).await?;
if let Some(value) = req.telegram_chat_id {
upsert_setting(&state, "notify_telegram_chat_id", value.trim().to_string()).await?;
}
if let Some(enabled) = req.telegram_use_system_proxy {
upsert_setting(
&state,
"notify_telegram_use_system_proxy",
bool_setting_value(enabled),
)
.await?;
}
if let Some(value) = req.telegram_message_thread_id {
upsert_setting(&state, "notify_telegram_thread_id", value.trim().to_string()).await?;
}
if let Some(enabled) = req.smtp_enabled {
upsert_setting(&state, "notify_smtp_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.smtp_host {
upsert_setting(&state, "notify_smtp_host", value.trim().to_string()).await?;
}
if let Some(value) = req.smtp_port {
upsert_setting(&state, "notify_smtp_port", value.clamp(1, 65_535).to_string()).await?;
}
if let Some(enabled) = req.smtp_secure {
upsert_setting(&state, "notify_smtp_secure", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.smtp_user {
upsert_setting(&state, "notify_smtp_user", value.trim().to_string()).await?;
}
upsert_optional_secret(&state, "notify_smtp_pass", req.smtp_pass).await?;
if let Some(value) = req.smtp_from {
upsert_setting(&state, "notify_smtp_from", value.trim().to_string()).await?;
}
if let Some(value) = req.smtp_to {
upsert_setting(&state, "notify_smtp_to", value.trim().to_string()).await?;
}
if let Some(value) = req.notify_cooldown_sec {
upsert_setting(&state, "notify_cooldown_sec", value.max(0).to_string()).await?;
}
let Json(mut body) = runtime_settings(State(state.clone())).await?;
if let Value::Object(ref mut map) = body {
map.insert("success".to_string(), Value::Bool(true));
}
Ok(Json(body))
}
async fn brand_list() -> Json<Value> {
@@ -1191,8 +1410,13 @@ async fn clear_usage_data(State(state): State<AppState>) -> AppResult<Json<Value
Ok(Json(json!({ "success": true })))
}
async fn test_notification() -> Json<Value> {
Json(json!({ "success": true, "message": "notification endpoint available" }))
async fn test_notification(State(state): State<AppState>) -> AppResult<Json<Value>> {
let channels = NotificationService::new(state.db.clone()).send_test().await?;
Ok(Json(json!({
"success": true,
"message": format!("测试通知已发送:{}", channels.join(", ")),
"channels": channels,
})))
}
#[cfg(test)]
@@ -1260,7 +1484,7 @@ mod tests {
#[tokio::test]
async fn runtime_settings_reports_and_updates_proxy_token() {
let state = test_state().await;
let Json(initial) = runtime_settings(State(state.clone())).await;
let Json(initial) = runtime_settings(State(state.clone())).await.unwrap();
assert_eq!(initial["proxyTokenMasked"], "••••oken");
assert_eq!(initial["proxyTokenSource"], "config");
@@ -1268,6 +1492,7 @@ mod tests {
State(state.clone()),
Json(RuntimeSettingsUpdateRequest {
proxy_token: Some("sk-new-runtime-token".into()),
..Default::default()
}),
)
.await