forked from boos/BoosAPI
feat: polish console shell and notification settings
This commit is contained in:
+237
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user