mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
173
apps/aether-gateway/src/bark_push.rs
Normal file
173
apps/aether-gateway/src/bark_push.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use crate::handlers::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) const BARK_PUSH_ENABLED_KEY: &str = "module.bark_push.enabled";
|
||||
pub(crate) const BARK_PUSH_DEVICE_KEY_KEY: &str = "module.bark_push.device_key";
|
||||
pub(crate) const BARK_PUSH_SERVER_URL_KEY: &str = "module.bark_push.server_url";
|
||||
pub(crate) const BARK_PUSH_TEMPLATE_KEY: &str = "module.bark_push.template";
|
||||
|
||||
const DEFAULT_BARK_API_BASE: &str = "https://api.day.app";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BarkPushConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) device_key: Option<String>,
|
||||
pub(crate) server_url: String,
|
||||
pub(crate) template: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn bark_push_module_enabled(state: &AppState) -> Result<bool, GatewayError> {
|
||||
let value = state
|
||||
.read_system_config_json_value(BARK_PUSH_ENABLED_KEY)
|
||||
.await?;
|
||||
Ok(system_config_bool(value.as_ref(), false))
|
||||
}
|
||||
|
||||
pub(crate) async fn bark_push_configured(state: &AppState) -> Result<bool, GatewayError> {
|
||||
let config = read_bark_push_config(state).await?;
|
||||
Ok(config.device_key.is_some() && !config.server_url.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_bark_push_config(
|
||||
state: &AppState,
|
||||
) -> Result<BarkPushConfig, GatewayError> {
|
||||
let enabled = bark_push_module_enabled(state).await?;
|
||||
let device_key = state
|
||||
.read_system_config_json_value(BARK_PUSH_DEVICE_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 server_url = state
|
||||
.read_system_config_json_value(BARK_PUSH_SERVER_URL_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BARK_API_BASE.to_string());
|
||||
let template = state
|
||||
.read_system_config_json_value(BARK_PUSH_TEMPLATE_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
|
||||
Ok(BarkPushConfig {
|
||||
enabled,
|
||||
device_key,
|
||||
server_url,
|
||||
template,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn send_bark_push(
|
||||
state: &AppState,
|
||||
config: &BarkPushConfig,
|
||||
title: &str,
|
||||
markdown_body: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(device_key) = config.device_key.as_deref() else {
|
||||
return Err(GatewayError::Internal("未配置 Bark Device Key".to_string()));
|
||||
};
|
||||
let device_key = device_key.trim();
|
||||
if device_key.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark Device Key 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let server_url = normalized_bark_server_url(&config.server_url)?;
|
||||
let body = render_bark_body(config.template.as_deref(), title, markdown_body);
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{server_url}/push"))
|
||||
.json(&json!({
|
||||
"device_key": device_key,
|
||||
"title": title,
|
||||
"body": body,
|
||||
}))
|
||||
.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!(
|
||||
"Bark 返回 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| matches!(code, 0 | 200))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(|code| matches!(code.trim(), "0" | "200"))
|
||||
})
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if !code_is_ok {
|
||||
return Err(GatewayError::Internal(format!("Bark 返回失败: {payload}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalized_bark_server_url(server_url: &str) -> Result<String, GatewayError> {
|
||||
let server_url = server_url.trim().trim_end_matches('/');
|
||||
if server_url.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if !server_url.starts_with("https://") && !server_url.starts_with("http://") {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址必须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(server_url.to_string())
|
||||
}
|
||||
|
||||
fn render_bark_body(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::{normalized_bark_server_url, render_bark_body};
|
||||
|
||||
#[test]
|
||||
fn bark_body_uses_template_when_provided() {
|
||||
let rendered = render_bark_body(Some("{title}\n\n{body}"), "告警", "原始正文");
|
||||
assert_eq!(rendered, "告警\n\n原始正文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_body_falls_back_to_markdown_body_for_empty_template() {
|
||||
assert_eq!(render_bark_body(None, "告警", "原始正文"), "原始正文");
|
||||
assert_eq!(
|
||||
render_bark_body(Some(" "), "告警", "原始正文"),
|
||||
"原始正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_server_url_trims_trailing_slashes() {
|
||||
assert_eq!(
|
||||
normalized_bark_server_url(" https://api.day.app/ ").expect("url should parse"),
|
||||
"https://api.day.app"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1464,6 +1464,8 @@ fn build_sse_body_stream(
|
||||
#[derive(Default)]
|
||||
struct SseControlBlockFilter {
|
||||
buffered: Vec<u8>,
|
||||
emitted_len: usize,
|
||||
passthrough_current_block: bool,
|
||||
}
|
||||
|
||||
impl SseControlBlockFilter {
|
||||
@@ -1475,17 +1477,39 @@ impl SseControlBlockFilter {
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
let mut output = Vec::new();
|
||||
while let Some((block_end, separator_len)) = find_sse_block_boundary(&self.buffered) {
|
||||
let block = self
|
||||
.buffered
|
||||
.drain(..block_end + separator_len)
|
||||
.collect::<Vec<_>>();
|
||||
if sse_block_has_data_line(&block) {
|
||||
output.extend(block);
|
||||
let block_len = block_end + separator_len;
|
||||
let block = self.buffered.drain(..block_len).collect::<Vec<_>>();
|
||||
if self.passthrough_current_block {
|
||||
let emitted_len = self.emitted_len.min(block.len());
|
||||
output.extend_from_slice(&block[emitted_len..]);
|
||||
} else if sse_block_has_data_line(&block) {
|
||||
output.extend_from_slice(&block);
|
||||
}
|
||||
self.emitted_len = 0;
|
||||
self.passthrough_current_block = false;
|
||||
}
|
||||
|
||||
if self.passthrough_current_block {
|
||||
if self.buffered.len() > self.emitted_len {
|
||||
output.extend_from_slice(&self.buffered[self.emitted_len..]);
|
||||
self.emitted_len = self.buffered.len();
|
||||
}
|
||||
} else if sse_buffer_has_data_line(&self.buffered) {
|
||||
self.passthrough_current_block = true;
|
||||
output.extend_from_slice(&self.buffered);
|
||||
self.emitted_len = self.buffered.len();
|
||||
}
|
||||
|
||||
if self.buffered.len() > SSE_CONTROL_FILTER_MAX_BUFFER_BYTES {
|
||||
output.extend(std::mem::take(&mut self.buffered));
|
||||
let buffered = std::mem::take(&mut self.buffered);
|
||||
if self.passthrough_current_block {
|
||||
let emitted_len = self.emitted_len.min(buffered.len());
|
||||
output.extend_from_slice(&buffered[emitted_len..]);
|
||||
} else {
|
||||
output.extend(buffered);
|
||||
}
|
||||
self.emitted_len = 0;
|
||||
self.passthrough_current_block = false;
|
||||
}
|
||||
|
||||
output
|
||||
@@ -1497,7 +1521,13 @@ impl SseControlBlockFilter {
|
||||
}
|
||||
|
||||
let block = std::mem::take(&mut self.buffered);
|
||||
if sse_block_has_data_line(&block) {
|
||||
let emitted_len = self.emitted_len.min(block.len());
|
||||
let passthrough_current_block = self.passthrough_current_block;
|
||||
self.emitted_len = 0;
|
||||
self.passthrough_current_block = false;
|
||||
if passthrough_current_block {
|
||||
block[emitted_len..].to_vec()
|
||||
} else if sse_block_has_data_line(&block) {
|
||||
block
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -1549,6 +1579,15 @@ fn sse_block_has_data_line(block: &[u8]) -> bool {
|
||||
.any(|line| line.trim_start().starts_with("data:"))
|
||||
}
|
||||
|
||||
fn sse_buffer_has_data_line(buffer: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(buffer) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
text.lines()
|
||||
.any(|line| line.trim_start().starts_with("data:"))
|
||||
}
|
||||
|
||||
fn stream_chunk_contains_sse_done(chunk: &[u8]) -> bool {
|
||||
std::str::from_utf8(chunk).ok().is_some_and(|text| {
|
||||
text.lines().any(|line| {
|
||||
@@ -4691,6 +4730,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_body_stream_forwards_data_line_before_block_boundary() {
|
||||
let (tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
|
||||
let mut body_stream = Box::pin(build_sse_body_stream(
|
||||
Vec::new(),
|
||||
rx,
|
||||
true,
|
||||
Duration::from_secs(60),
|
||||
));
|
||||
|
||||
let keepalive = tokio::time::timeout(Duration::from_millis(50), body_stream.next())
|
||||
.await
|
||||
.expect("initial keepalive should be immediate")
|
||||
.expect("stream should yield initial keepalive")
|
||||
.expect("initial keepalive should be ok");
|
||||
assert_eq!(keepalive.as_ref(), b": aether-keepalive\n\n");
|
||||
|
||||
tx.send(Ok(Bytes::from_static(
|
||||
b"event: response.output_text.delta\n",
|
||||
)))
|
||||
.await
|
||||
.expect("event line should send");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), body_stream.next())
|
||||
.await
|
||||
.is_err(),
|
||||
"event-only partial block should remain buffered"
|
||||
);
|
||||
|
||||
tx.send(Ok(Bytes::from_static(
|
||||
b"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n",
|
||||
)))
|
||||
.await
|
||||
.expect("data line should send");
|
||||
let data_chunk = tokio::time::timeout(Duration::from_millis(50), body_stream.next())
|
||||
.await
|
||||
.expect("data-bearing block should stream before terminator")
|
||||
.expect("stream should yield data-bearing block")
|
||||
.expect("data-bearing block should be ok");
|
||||
assert_eq!(
|
||||
data_chunk.as_ref(),
|
||||
b"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n"
|
||||
);
|
||||
|
||||
tx.send(Ok(Bytes::from_static(b"\n")))
|
||||
.await
|
||||
.expect("terminator should send");
|
||||
let terminator = tokio::time::timeout(Duration::from_millis(50), body_stream.next())
|
||||
.await
|
||||
.expect("terminator should stream")
|
||||
.expect("stream should yield terminator")
|
||||
.expect("terminator should be ok");
|
||||
assert_eq!(terminator.as_ref(), b"\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_body_stream_uses_local_keepalive_when_prefetched_blocks_are_control_only() {
|
||||
let (_tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::bark_push::bark_push_configured;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::{module_available_from_env, system_config_bool};
|
||||
use crate::important_notification::{
|
||||
@@ -96,6 +97,18 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
||||
admin_menu_group: Some("system"),
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "bark_push",
|
||||
display_name: "Bark 推送",
|
||||
description: "第三方推送服务,配置 Bark Device Key 并测试 iOS 推送",
|
||||
category: "integration",
|
||||
env_key: "BARK_PUSH_AVAILABLE",
|
||||
default_available: true,
|
||||
admin_route: Some("/admin/modules/bark"),
|
||||
admin_menu_icon: Some("Send"),
|
||||
admin_menu_group: Some("system"),
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "model_directives",
|
||||
display_name: "模型后缀参数",
|
||||
@@ -169,6 +182,7 @@ pub(crate) struct AdminModuleRuntimeState {
|
||||
gemini_files_has_capable_key: bool,
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_module_by_name(name: &str) -> Option<&'static AdminModuleDefinition> {
|
||||
@@ -257,6 +271,7 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
|
||||
let notification_configured = important_notification_configured(state.app()).await?;
|
||||
let server_chan_configured = server_chan_push_configured(state.app()).await?;
|
||||
let bark_configured = bark_push_configured(state.app()).await?;
|
||||
|
||||
Ok(AdminModuleRuntimeState {
|
||||
oauth_providers,
|
||||
@@ -264,6 +279,7 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
gemini_files_has_capable_key,
|
||||
important_notification_configured: notification_configured,
|
||||
server_chan_push_configured: server_chan_configured,
|
||||
bark_push_configured: bark_configured,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,6 +294,7 @@ pub(crate) fn build_admin_module_validation_result(
|
||||
runtime.gemini_files_has_capable_key,
|
||||
runtime.important_notification_configured,
|
||||
runtime.server_chan_push_configured,
|
||||
runtime.bark_push_configured,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::admin_api::AdminAppState;
|
||||
use crate::bark_push::{read_bark_push_config, send_bark_push, BarkPushConfig};
|
||||
use crate::email_delivery::{
|
||||
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
|
||||
};
|
||||
@@ -35,6 +36,7 @@ pub(crate) enum ImportantNotificationChannelFilter {
|
||||
All,
|
||||
Email,
|
||||
ServerChan,
|
||||
Bark,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -45,6 +47,7 @@ struct ImportantNotificationConfig {
|
||||
default_channel: ImportantNotificationChannelFilter,
|
||||
items: Vec<ImportantNotificationItemConfig>,
|
||||
server_chan: ServerChanPushConfig,
|
||||
bark: BarkPushConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -63,6 +66,7 @@ struct ImportantNotificationItemConfig {
|
||||
struct NotificationChannelReadiness {
|
||||
email: bool,
|
||||
server_chan: bool,
|
||||
bark: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -226,6 +230,7 @@ async fn read_notification_channel_readiness(
|
||||
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(),
|
||||
bark: config.bark.enabled && config.bark.device_key.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,9 +239,12 @@ fn channel_filter_has_ready_channel(
|
||||
readiness: NotificationChannelReadiness,
|
||||
) -> bool {
|
||||
match filter {
|
||||
ImportantNotificationChannelFilter::All => readiness.email || readiness.server_chan,
|
||||
ImportantNotificationChannelFilter::All => {
|
||||
readiness.email || readiness.server_chan || readiness.bark
|
||||
}
|
||||
ImportantNotificationChannelFilter::Email => readiness.email,
|
||||
ImportantNotificationChannelFilter::ServerChan => readiness.server_chan,
|
||||
ImportantNotificationChannelFilter::Bark => readiness.bark,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +300,20 @@ async fn dispatch_important_notification(
|
||||
.await;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
channel_filter,
|
||||
ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::Bark
|
||||
) {
|
||||
maybe_send_bark_notification(
|
||||
state,
|
||||
&config,
|
||||
¬ification,
|
||||
bypass_enable_checks,
|
||||
&mut reports,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if reports.is_empty() {
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "none",
|
||||
@@ -385,6 +407,7 @@ async fn read_important_notification_config(
|
||||
.unwrap_or(ImportantNotificationChannelFilter::All),
|
||||
items: parse_notification_items(items.as_ref()),
|
||||
server_chan: read_server_chan_push_config(state).await?,
|
||||
bark: read_bark_push_config(state).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -395,6 +418,7 @@ fn parse_channel_filter(raw: &str) -> Option<ImportantNotificationChannelFilter>
|
||||
"server_chan" | "serverchan" | "serve_chan" => {
|
||||
Some(ImportantNotificationChannelFilter::ServerChan)
|
||||
}
|
||||
"bark" => Some(ImportantNotificationChannelFilter::Bark),
|
||||
"global" | "" => None,
|
||||
_ => None,
|
||||
}
|
||||
@@ -680,6 +704,48 @@ async fn maybe_send_server_chan_notification(
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_send_bark_notification(
|
||||
state: &AppState,
|
||||
config: &ImportantNotificationConfig,
|
||||
notification: &ImportantNotification,
|
||||
bypass_channel_toggle: bool,
|
||||
reports: &mut Vec<ImportantNotificationChannelReport>,
|
||||
) {
|
||||
if !bypass_channel_toggle && !config.bark.enabled {
|
||||
return;
|
||||
}
|
||||
if config.bark.device_key.is_none() {
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: false,
|
||||
message: "未配置 Bark Device Key".to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
match send_bark_push(
|
||||
state,
|
||||
&config.bark,
|
||||
¬ification.title,
|
||||
¬ification.markdown_body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: true,
|
||||
message: "Bark 通知已发送".to_string(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "failed to send bark important notification");
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: false,
|
||||
message: format!("Bark 通知发送失败: {err:?}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn single_report(
|
||||
channel: &'static str,
|
||||
success: bool,
|
||||
@@ -743,8 +809,8 @@ fn escape_html(value: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_notification_item_template, parse_notification_items, parse_recipient_list,
|
||||
ImportantNotification, ImportantNotificationChannelFilter,
|
||||
apply_notification_item_template, parse_channel_filter, parse_notification_items,
|
||||
parse_recipient_list, ImportantNotification, ImportantNotificationChannelFilter,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -785,6 +851,14 @@ mod tests {
|
||||
assert!(items[0].user_email_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_channel_filter_accepts_bark() {
|
||||
assert_eq!(
|
||||
parse_channel_filter("bark"),
|
||||
Some(ImportantNotificationChannelFilter::Bark)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_template_renders_fallback_and_variables() {
|
||||
let items = parse_notification_items(Some(&json!([
|
||||
|
||||
@@ -30,6 +30,7 @@ mod api;
|
||||
mod async_task;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod bark_push;
|
||||
mod cache;
|
||||
mod client_session_affinity;
|
||||
mod clock;
|
||||
|
||||
@@ -243,6 +243,35 @@ fn local_scheduler_affinity_target(plan: &ExecutionPlan) -> Option<SchedulerAffi
|
||||
})
|
||||
}
|
||||
|
||||
async fn local_execution_plan_uses_pool(state: &AppState, plan: &ExecutionPlan) -> bool {
|
||||
let Ok(Some(transport)) = state
|
||||
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
admin_provider_pool_config_from_config_value(transport.provider.config.as_ref()).is_some()
|
||||
}
|
||||
|
||||
async fn local_scheduler_affinity_matches_failed_target(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
cached_target: &SchedulerAffinityTarget,
|
||||
failed_target: &SchedulerAffinityTarget,
|
||||
) -> bool {
|
||||
if cached_target == failed_target {
|
||||
return true;
|
||||
}
|
||||
if cached_target.provider_id != failed_target.provider_id
|
||||
|| cached_target.endpoint_id != failed_target.endpoint_id
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
local_execution_plan_uses_pool(state, plan).await
|
||||
}
|
||||
|
||||
async fn scheduler_cache_affinity_enabled(state: &AppState) -> bool {
|
||||
match read_scheduler_ordering_config(state).await {
|
||||
Ok(config) => config.scheduling_mode == SchedulerSchedulingMode::CacheAffinity,
|
||||
@@ -360,7 +389,24 @@ async fn record_attempt_failure_effect(
|
||||
}
|
||||
|
||||
if let Some(cache_key) = local_scheduler_affinity_cache_key(context.report_context) {
|
||||
let _ = state.remove_scheduler_affinity_cache_entry(&cache_key);
|
||||
let Some(failed_target) = local_scheduler_affinity_target(context.plan) else {
|
||||
return;
|
||||
};
|
||||
let Some(cached_target) =
|
||||
state.read_scheduler_affinity_target(&cache_key, SCHEDULER_AFFINITY_TTL)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if local_scheduler_affinity_matches_failed_target(
|
||||
state,
|
||||
context.plan,
|
||||
&cached_target,
|
||||
&failed_target,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = state.remove_scheduler_affinity_cache_entry(&cache_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +494,10 @@ async fn record_adaptive_rate_limit_effect(
|
||||
updated_key.status_snapshot = Some(projection.status_snapshot);
|
||||
updated_key.updated_at_unix_secs = Some(observed_at_unix_secs);
|
||||
|
||||
if let Err(err) = state.update_provider_catalog_key(&updated_key).await {
|
||||
if let Err(err) = state
|
||||
.update_provider_catalog_key_runtime_state(&updated_key)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"gateway orchestration effects: failed to persist adaptive rate-limit projection for provider {} endpoint {} key {}: {:?}",
|
||||
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
||||
@@ -496,7 +545,10 @@ async fn record_adaptive_success_effect(
|
||||
updated_key.status_snapshot = Some(projection.status_snapshot);
|
||||
updated_key.updated_at_unix_secs = Some(observed_at_unix_secs);
|
||||
|
||||
if let Err(err) = state.update_provider_catalog_key(&updated_key).await {
|
||||
if let Err(err) = state
|
||||
.update_provider_catalog_key_runtime_state(&updated_key)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"gateway orchestration effects: failed to persist adaptive success projection for provider {} endpoint {} key {}: {:?}",
|
||||
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
||||
@@ -1204,6 +1256,20 @@ mod tests {
|
||||
.expect("provider should build")
|
||||
}
|
||||
|
||||
fn sample_pool_health_provider() -> StoredProviderCatalogProvider {
|
||||
sample_health_provider().with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({"pool_advanced": {}})),
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_health_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
StoredProviderCatalogEndpoint::new(
|
||||
"ep-1".to_string(),
|
||||
@@ -1266,6 +1332,20 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn pool_health_state() -> AppState {
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_pool_health_provider()],
|
||||
vec![sample_health_endpoint()],
|
||||
vec![sample_health_key()],
|
||||
));
|
||||
AppState::new()
|
||||
.expect("gateway state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
}
|
||||
|
||||
fn health_state_with_key(key: StoredProviderCatalogKey) -> AppState {
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_health_provider()],
|
||||
@@ -1429,6 +1509,137 @@ mod tests {
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_failure_keeps_scheduler_affinity_for_non_affinity_candidate() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
let plan = sample_plan();
|
||||
let report_context = json!({
|
||||
"api_key_id": "api-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"model": "gpt-5",
|
||||
});
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
let affinity_target = SchedulerAffinityTarget {
|
||||
provider_id: "prov-2".to_string(),
|
||||
endpoint_id: "ep-2".to_string(),
|
||||
key_id: "key-2".to_string(),
|
||||
};
|
||||
|
||||
state.remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
affinity_target.clone(),
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
16,
|
||||
);
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
LocalExecutionEffectContext {
|
||||
plan: &plan,
|
||||
report_context: Some(&report_context),
|
||||
},
|
||||
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
||||
status_code: 524,
|
||||
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
Some(affinity_target)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_failure_keeps_scheduler_affinity_for_non_pool_sibling_key() {
|
||||
let state = health_state();
|
||||
let plan = sample_plan();
|
||||
let report_context = json!({
|
||||
"api_key_id": "api-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"model": "gpt-5",
|
||||
});
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
let affinity_target = SchedulerAffinityTarget {
|
||||
provider_id: "prov-1".to_string(),
|
||||
endpoint_id: "ep-1".to_string(),
|
||||
key_id: "key-2".to_string(),
|
||||
};
|
||||
|
||||
state.remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
affinity_target.clone(),
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
16,
|
||||
);
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
LocalExecutionEffectContext {
|
||||
plan: &plan,
|
||||
report_context: Some(&report_context),
|
||||
},
|
||||
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
||||
status_code: 524,
|
||||
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
Some(affinity_target)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_failure_invalidates_scheduler_affinity_for_same_pool_candidate() {
|
||||
let state = pool_health_state();
|
||||
let plan = sample_plan();
|
||||
let report_context = json!({
|
||||
"api_key_id": "api-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"model": "gpt-5",
|
||||
});
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
|
||||
state.remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
SchedulerAffinityTarget {
|
||||
provider_id: "prov-1".to_string(),
|
||||
endpoint_id: "ep-1".to_string(),
|
||||
key_id: "key-2".to_string(),
|
||||
},
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
16,
|
||||
);
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
LocalExecutionEffectContext {
|
||||
plan: &plan,
|
||||
report_context: Some(&report_context),
|
||||
},
|
||||
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
||||
status_code: 524,
|
||||
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attempt_failure_keeps_scheduler_affinity_for_non_failure_status() {
|
||||
let state = AppState::new().expect("gateway state should build");
|
||||
@@ -1546,6 +1757,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_success_keeps_scheduler_affinity_after_health_state_update() {
|
||||
let state = health_state();
|
||||
let plan = sample_plan();
|
||||
let report_context = json!({
|
||||
"api_key_id": "api-key-1",
|
||||
"client_api_format": "openai:chat",
|
||||
"model": "gpt-5",
|
||||
});
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
LocalExecutionEffectContext {
|
||||
plan: &plan,
|
||||
report_context: Some(&report_context),
|
||||
},
|
||||
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
Some(SchedulerAffinityTarget {
|
||||
provider_id: "prov-1".to_string(),
|
||||
endpoint_id: "ep-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_balance_success_does_not_remember_scheduler_affinity_cache() {
|
||||
let state = AppState::new()
|
||||
@@ -1985,6 +2229,21 @@ mod tests {
|
||||
async fn adaptive_rate_limit_effect_updates_adaptive_key_observation() {
|
||||
let state = adaptive_state();
|
||||
let plan = sample_plan();
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
let target = SchedulerAffinityTarget {
|
||||
provider_id: plan.provider_id.clone(),
|
||||
endpoint_id: plan.endpoint_id.clone(),
|
||||
key_id: plan.key_id.clone(),
|
||||
};
|
||||
state.remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
target.clone(),
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
16,
|
||||
);
|
||||
let initial_epoch = state.scheduler_affinity_epoch();
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
@@ -2048,6 +2307,11 @@ mod tests {
|
||||
.and_then(|value| value.get("enforcement_active")),
|
||||
Some(&json!(false))
|
||||
);
|
||||
assert_eq!(state.scheduler_affinity_epoch(), initial_epoch);
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
Some(target)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2178,6 +2442,21 @@ mod tests {
|
||||
.expect("request candidate should build")],
|
||||
);
|
||||
let plan = sample_plan();
|
||||
let cache_key =
|
||||
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
||||
.expect("scheduler affinity cache key should build");
|
||||
let target = SchedulerAffinityTarget {
|
||||
provider_id: plan.provider_id.clone(),
|
||||
endpoint_id: plan.endpoint_id.clone(),
|
||||
key_id: plan.key_id.clone(),
|
||||
};
|
||||
state.remember_scheduler_affinity_target(
|
||||
&cache_key,
|
||||
target.clone(),
|
||||
SCHEDULER_AFFINITY_TTL,
|
||||
16,
|
||||
);
|
||||
let initial_epoch = state.scheduler_affinity_epoch();
|
||||
|
||||
apply_local_execution_effect(
|
||||
&state,
|
||||
@@ -2209,5 +2488,10 @@ mod tests {
|
||||
.and_then(Value::as_str),
|
||||
Some("high_utilization")
|
||||
);
|
||||
assert_eq!(state.scheduler_affinity_epoch(), initial_epoch);
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
||||
Some(target)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ async fn sync_grok_quota_from_report_context(
|
||||
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
|
||||
Ok(state
|
||||
.update_provider_catalog_key(&updated_key)
|
||||
.update_provider_catalog_key_runtime_state(&updated_key)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
@@ -721,7 +721,7 @@ async fn sync_codex_quota_from_response_headers(
|
||||
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
|
||||
let updated = state
|
||||
.update_provider_catalog_key(&updated_key)
|
||||
.update_provider_catalog_key_runtime_state(&updated_key)
|
||||
.await?
|
||||
.is_some();
|
||||
if updated {
|
||||
|
||||
@@ -615,6 +615,21 @@ impl AppState {
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_key_runtime_state(
|
||||
&self,
|
||||
key: &provider_catalog::StoredProviderCatalogKey,
|
||||
) -> Result<Option<provider_catalog::StoredProviderCatalogKey>, GatewayError> {
|
||||
let updated = self
|
||||
.data
|
||||
.update_provider_catalog_key(key)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if updated.is_some() {
|
||||
self.invalidate_provider_health_routing_caches();
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_provider_catalog_key_upstream_metadata(
|
||||
&self,
|
||||
key_id: &str,
|
||||
@@ -806,7 +821,7 @@ impl AppState {
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if updated {
|
||||
self.invalidate_provider_routing_caches();
|
||||
self.invalidate_provider_health_routing_caches();
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
@@ -930,4 +945,47 @@ mod tests {
|
||||
.expect("provider transport should exist after update");
|
||||
assert!(snapshot.provider.keep_priority_on_conversion);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_catalog_health_update_keeps_scheduler_affinity_cache() {
|
||||
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider()],
|
||||
vec![sample_endpoint()],
|
||||
vec![sample_key()],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("app state should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
||||
.with_encryption_key_for_tests("test-encryption-key"),
|
||||
);
|
||||
|
||||
let cache_key = "scheduler_affinity:api-key-1:openai:chat:gpt-5";
|
||||
let ttl = Duration::from_secs(300);
|
||||
let target = SchedulerAffinityTarget {
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
};
|
||||
state.remember_scheduler_affinity_target(cache_key, target.clone(), ttl, 128);
|
||||
let initial_epoch = state.scheduler_affinity_epoch();
|
||||
|
||||
let health_by_format = serde_json::json!({
|
||||
"openai:chat": {
|
||||
"last_success_at_unix_secs": 1,
|
||||
"consecutive_failures": 0
|
||||
}
|
||||
});
|
||||
let updated = state
|
||||
.update_provider_catalog_key_health_state("key-1", true, Some(&health_by_format), None)
|
||||
.await
|
||||
.expect("key health update should succeed");
|
||||
|
||||
assert!(updated);
|
||||
assert_eq!(state.scheduler_affinity_epoch(), initial_epoch);
|
||||
assert_eq!(
|
||||
state.read_scheduler_affinity_target(cache_key, ttl),
|
||||
Some(target)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +572,11 @@ impl AppState {
|
||||
self.invalidate_scheduler_affinity_cache();
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_provider_health_routing_caches(&self) {
|
||||
self.data.clear_minimal_candidate_selection_cache();
|
||||
self.clear_provider_transport_snapshot_cache();
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_auth_context_cache(&self) {
|
||||
self.auth_context_cache.clear();
|
||||
}
|
||||
|
||||
@@ -816,6 +816,8 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
||||
payload["server_chan_push"]["admin_route"],
|
||||
"/admin/modules/server-chan"
|
||||
);
|
||||
assert_eq!(payload["bark_push"]["display_name"], "Bark 推送");
|
||||
assert_eq!(payload["bark_push"]["admin_route"], "/admin/modules/bark");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -705,11 +705,13 @@ struct AdminApiFormatDefinition {
|
||||
|
||||
const REQUEST_RECORD_LEVEL_KEY: &str = "request_record_level";
|
||||
const LEGACY_REQUEST_LOG_LEVEL_KEY: &str = "request_log_level";
|
||||
const DEFAULT_BARK_API_BASE: &str = "https://api.day.app";
|
||||
const SENSITIVE_SYSTEM_CONFIG_KEYS: &[&str] = &[
|
||||
"smtp_password",
|
||||
"turnstile_secret_key",
|
||||
"module.server_chan_push.send_key",
|
||||
"module.important_notification.server_chan_send_key",
|
||||
"module.bark_push.device_key",
|
||||
];
|
||||
const ADMIN_API_FORMAT_DEFINITIONS: &[AdminApiFormatDefinition] = &[
|
||||
AdminApiFormatDefinition {
|
||||
@@ -1207,6 +1209,7 @@ pub fn build_admin_module_validation_result(
|
||||
gemini_files_has_capable_key: bool,
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
) -> (bool, Option<String>) {
|
||||
match module_name {
|
||||
"oauth" => {
|
||||
@@ -1291,6 +1294,13 @@ pub fn build_admin_module_validation_result(
|
||||
(false, Some("请先配置 Server 酱 SendKey".to_string()))
|
||||
}
|
||||
}
|
||||
"bark_push" => {
|
||||
if bark_push_configured {
|
||||
(true, None)
|
||||
} else {
|
||||
(false, Some("请先配置 Bark Device Key".to_string()))
|
||||
}
|
||||
}
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
(true, None)
|
||||
@@ -1315,6 +1325,7 @@ pub fn build_admin_module_health(
|
||||
| "model_directives"
|
||||
| "proxy_nodes"
|
||||
| "important_notification"
|
||||
| "bark_push"
|
||||
| "server_chan_push" => "healthy",
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
@@ -1661,6 +1672,10 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
||||
"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.bark_push.enabled" => Some(json!(false)),
|
||||
"module.bark_push.device_key" => Some(serde_json::Value::Null),
|
||||
"module.bark_push.server_url" => Some(json!(DEFAULT_BARK_API_BASE)),
|
||||
"module.bark_push.template" => Some(json!("")),
|
||||
"module.chat_pii_redaction.enabled" => Some(json!(false)),
|
||||
"module.chat_pii_redaction.rules" => Some(chat_pii_redaction_default_rules()),
|
||||
"module.chat_pii_redaction.cache_ttl_seconds" => Some(json!(300)),
|
||||
@@ -1849,6 +1864,25 @@ fn normalize_nullable_string_config_value(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_bark_server_url_config_value(
|
||||
value: serde_json::Value,
|
||||
) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(json!(DEFAULT_BARK_API_BASE)),
|
||||
Value::String(raw) => {
|
||||
let raw = raw.trim().trim_end_matches('/');
|
||||
if raw.is_empty() {
|
||||
return Ok(json!(DEFAULT_BARK_API_BASE));
|
||||
}
|
||||
if !raw.starts_with("https://") && !raw.starts_with("http://") {
|
||||
return Err(());
|
||||
}
|
||||
Ok(json!(raw))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_notification_channel_value(value: serde_json::Value) -> Result<serde_json::Value, ()> {
|
||||
match value {
|
||||
Value::Null => Ok(json!("all")),
|
||||
@@ -1865,6 +1899,7 @@ fn normalize_notification_channel(raw: &str, allow_global: bool) -> Result<&'sta
|
||||
"all" => Ok("all"),
|
||||
"email" => Ok("email"),
|
||||
"server_chan" | "serverchan" | "serve_chan" => Ok("server_chan"),
|
||||
"bark" => Ok("bark"),
|
||||
"global" | "" if allow_global => Ok("global"),
|
||||
_ => Err(()),
|
||||
}
|
||||
@@ -2023,7 +2058,8 @@ pub fn parse_admin_system_config_update(
|
||||
match normalized_key.as_str() {
|
||||
"module.important_notification.enabled"
|
||||
| "module.important_notification.email_enabled"
|
||||
| "module.server_chan_push.enabled" => match value.as_bool() {
|
||||
| "module.server_chan_push.enabled"
|
||||
| "module.bark_push.enabled" => match value.as_bool() {
|
||||
Some(enabled) => value = json!(enabled),
|
||||
None if value.is_null() => {
|
||||
value = admin_system_config_default_value(&normalized_key).unwrap_or(json!(false));
|
||||
@@ -2083,6 +2119,34 @@ pub fn parse_admin_system_config_update(
|
||||
}
|
||||
};
|
||||
}
|
||||
"module.bark_push.device_key" => {
|
||||
value = normalize_nullable_string_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.bark_push.server_url" => {
|
||||
value = normalize_bark_server_url_config_value(value).map_err(|_| {
|
||||
(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"module.bark_push.template" => {
|
||||
value = match value {
|
||||
Value::Null => json!(""),
|
||||
Value::String(raw) => json!(raw),
|
||||
_ => {
|
||||
return Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({ "detail": "请求数据验证失败" }),
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
"module.chat_pii_redaction.enabled" => match value.as_bool() {
|
||||
Some(enabled) => value = json!(enabled),
|
||||
None if value.is_null() => {
|
||||
@@ -3145,6 +3209,9 @@ mod tests {
|
||||
assert!(is_sensitive_admin_system_config_key(
|
||||
"module.important_notification.server_chan_send_key"
|
||||
));
|
||||
assert!(is_sensitive_admin_system_config_key(
|
||||
"module.bark_push.device_key"
|
||||
));
|
||||
assert!(!is_sensitive_admin_system_config_key("site_name"));
|
||||
}
|
||||
|
||||
@@ -3208,6 +3275,25 @@ mod tests {
|
||||
assert_eq!(update.value[0]["user_email_enabled"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_push_config_values_are_normalized() {
|
||||
let update = parse_admin_system_config_update(
|
||||
"module.bark_push.server_url",
|
||||
r#"{ "value": " https://api.day.app/ " }"#.as_bytes(),
|
||||
)
|
||||
.expect("server url should parse");
|
||||
|
||||
assert_eq!(update.normalized_key, "module.bark_push.server_url");
|
||||
assert_eq!(update.value, json!("https://api.day.app"));
|
||||
|
||||
let err = parse_admin_system_config_update(
|
||||
"module.bark_push.server_url",
|
||||
r#"{ "value": "api.day.app" }"#.as_bytes(),
|
||||
)
|
||||
.expect_err("server url without scheme should fail");
|
||||
assert_eq!(err.0, http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_admin_system_config_detail_masks_turnstile_secret_key() {
|
||||
let payload = build_admin_system_config_detail_payload(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use aether_ai_formats::formats::conversion::request::{
|
||||
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
@@ -8,6 +10,20 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
|
||||
|
||||
fn is_responses_shaped_body_on_chat_endpoint(body_json: &Value) -> bool {
|
||||
body_json
|
||||
.as_object()
|
||||
.is_some_and(|object| !object.contains_key("messages") && object.contains_key("input"))
|
||||
}
|
||||
|
||||
fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Cow<'_, Value>> {
|
||||
if is_responses_shaped_body_on_chat_endpoint(body_json) {
|
||||
return normalize_openai_responses_request_to_openai_chat_request(body_json)
|
||||
.map(Cow::Owned);
|
||||
}
|
||||
Some(Cow::Borrowed(body_json))
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
@@ -27,7 +43,8 @@ pub fn build_local_openai_chat_request_body_with_model_directives(
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
let request_body_object = chat_body.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
request_body_object
|
||||
.iter()
|
||||
@@ -94,24 +111,39 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
) -> Option<Value> {
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?,
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
RequestConversionKind::ToClaudeStandard => {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_claude_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToGeminiStandard => {
|
||||
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
|
||||
convert_openai_chat_request_to_gemini_request(
|
||||
chat_body.as_ref(),
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
if is_responses_shaped_body_on_chat_endpoint(body_json) {
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
enable_model_directives,
|
||||
)?
|
||||
} else {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)?
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut provider_request_body = with_model_directive_overrides(
|
||||
@@ -342,6 +374,111 @@ mod tests {
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_accepts_responses_shape_from_chat_endpoint() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"input": [{"role": "user", "content": "hello"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "Shell",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": false
|
||||
}],
|
||||
"reasoning": {"effort": "high"}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
|
||||
.expect("responses-shaped chat body should build as chat");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["messages"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
|
||||
assert_eq!(
|
||||
provider_request_body["tools"][0]["function"]["name"],
|
||||
"Shell"
|
||||
);
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "high");
|
||||
assert_eq!(provider_request_body["stream"], true);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_openai_chat_request_body_preserves_responses_shape_for_responses_target() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"input": [{"role": "user", "content": "hello"}],
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"stream_options": {"include_usage": true},
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "Shell",
|
||||
"parameters": {"type": "object"},
|
||||
"strict": false
|
||||
}, {
|
||||
"type": "function",
|
||||
"parameters": {"type": "object"}
|
||||
}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("responses-shaped chat body should build as responses");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["input"][0]["role"], "user");
|
||||
assert_eq!(provider_request_body["input"][0]["content"], "hello");
|
||||
assert_eq!(provider_request_body["tools"][0]["name"], "Shell");
|
||||
assert_eq!(provider_request_body["tools"][0]["strict"], false);
|
||||
assert_eq!(provider_request_body["tools"][1]["type"], "function");
|
||||
assert_eq!(
|
||||
provider_request_body["include"][0],
|
||||
"reasoning.encrypted_content"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_request_body["stream_options"]["include_usage"],
|
||||
true
|
||||
);
|
||||
assert_eq!(provider_request_body["stream"], false);
|
||||
assert!(provider_request_body.get("messages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "from messages"}],
|
||||
"input": [{"role": "user", "content": "from input"}]
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
"openai:responses",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("normal chat body should still use messages");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["input"][0]["content"][0]["text"],
|
||||
"from messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
|
||||
let body_json = json!({
|
||||
|
||||
@@ -187,6 +187,7 @@ pub struct TerminalUsageSeed {
|
||||
pub is_stream: bool,
|
||||
pub status_code: u16,
|
||||
pub terminal_error_message: Option<String>,
|
||||
pub terminal_failure_category: Option<String>,
|
||||
pub response_time_ms: Option<u64>,
|
||||
pub first_byte_time_ms: Option<u64>,
|
||||
pub request_headers: Option<Value>,
|
||||
@@ -511,6 +512,7 @@ fn build_terminal_usage_event_from_seed_impl(
|
||||
is_stream,
|
||||
status_code,
|
||||
terminal_error_message,
|
||||
terminal_failure_category,
|
||||
response_time_ms,
|
||||
first_byte_time_ms,
|
||||
request_headers,
|
||||
@@ -579,7 +581,12 @@ fn build_terminal_usage_event_from_seed_impl(
|
||||
is_stream: Some(is_stream),
|
||||
status_code: Some(status_code),
|
||||
error_message,
|
||||
error_category: resolve_error_category(status_code, event_type),
|
||||
error_category: resolve_error_category(
|
||||
status_code,
|
||||
event_type,
|
||||
is_stream,
|
||||
terminal_failure_category.as_deref(),
|
||||
),
|
||||
response_time_ms,
|
||||
first_byte_time_ms,
|
||||
request_headers,
|
||||
@@ -868,6 +875,7 @@ pub fn build_sync_terminal_usage_seed(
|
||||
is_stream: context_seed.is_stream,
|
||||
status_code,
|
||||
terminal_error_message: None,
|
||||
terminal_failure_category: None,
|
||||
response_time_ms,
|
||||
first_byte_time_ms,
|
||||
request_headers: context_seed.request_headers,
|
||||
@@ -906,8 +914,8 @@ pub fn build_stream_terminal_usage_seed(
|
||||
client_response_headers,
|
||||
provider_response_full,
|
||||
provider_response_body_state,
|
||||
client_response,
|
||||
client_response_body_state,
|
||||
mut client_response,
|
||||
mut client_response_body_state,
|
||||
standardized_usage,
|
||||
observed_stream_finish,
|
||||
terminal_error_message,
|
||||
@@ -933,6 +941,31 @@ pub fn build_stream_terminal_usage_seed(
|
||||
.as_ref()
|
||||
.and_then(extract_explicit_error_message_from_json)
|
||||
});
|
||||
let terminal_failure_category = if terminal_error_message.is_some() {
|
||||
Some("stream_terminal_error".to_string())
|
||||
} else if missing_observed_finish {
|
||||
Some("stream_missing_terminal_event".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let terminal_error_message = terminal_error_message.or_else(|| {
|
||||
missing_observed_finish
|
||||
.then(|| "execution runtime stream ended before provider terminal event".to_string())
|
||||
});
|
||||
if client_response.is_none() {
|
||||
if let (Some(message), Some(category)) = (
|
||||
terminal_error_message.as_deref(),
|
||||
terminal_failure_category.as_deref(),
|
||||
) {
|
||||
client_response = Some(build_stream_terminal_error_client_response(
|
||||
category,
|
||||
message,
|
||||
status_code,
|
||||
provider_response_full.as_ref(),
|
||||
));
|
||||
client_response_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
}
|
||||
}
|
||||
let terminal_state = infer_stream_terminal_state(
|
||||
report_kind.as_str(),
|
||||
status_code,
|
||||
@@ -963,6 +996,7 @@ pub fn build_stream_terminal_usage_seed(
|
||||
is_stream: context_seed.is_stream,
|
||||
status_code,
|
||||
terminal_error_message,
|
||||
terminal_failure_category,
|
||||
response_time_ms,
|
||||
first_byte_time_ms,
|
||||
request_headers: context_seed.request_headers,
|
||||
@@ -2144,17 +2178,77 @@ fn is_sensitive_body_key(key: &str) -> bool {
|
||||
|| normalized == "cookie"
|
||||
}
|
||||
|
||||
fn resolve_error_category(status_code: u16, event_type: UsageEventType) -> Option<String> {
|
||||
fn resolve_error_category(
|
||||
status_code: u16,
|
||||
event_type: UsageEventType,
|
||||
is_stream: bool,
|
||||
terminal_failure_category: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match event_type {
|
||||
UsageEventType::Cancelled => Some("cancelled".to_string()),
|
||||
UsageEventType::Failed if status_code >= 500 => Some("server_error".to_string()),
|
||||
UsageEventType::Failed if status_code >= 400 => Some("client_error".to_string()),
|
||||
UsageEventType::Failed if status_code >= 300 => Some("redirect".to_string()),
|
||||
UsageEventType::Failed if (200..300).contains(&status_code) => terminal_failure_category
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| is_stream.then(|| "stream_terminal_error".to_string()))
|
||||
.or_else(|| Some("non_success_status".to_string())),
|
||||
UsageEventType::Failed => Some("non_success_status".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_stream_terminal_error_client_response(
|
||||
category: &str,
|
||||
message: &str,
|
||||
status_code: u16,
|
||||
provider_response: Option<&Value>,
|
||||
) -> Value {
|
||||
let mut error = provider_response
|
||||
.and_then(extract_error_object_from_json)
|
||||
.unwrap_or_default();
|
||||
error
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String(category.to_string()));
|
||||
error
|
||||
.entry("message".to_string())
|
||||
.or_insert_with(|| Value::String(message.to_string()));
|
||||
error
|
||||
.entry("upstream_status".to_string())
|
||||
.or_insert_with(|| Value::from(status_code));
|
||||
|
||||
json!({ "error": Value::Object(error) })
|
||||
}
|
||||
|
||||
fn extract_error_object_from_json(value: &Value) -> Option<Map<String, Value>> {
|
||||
value
|
||||
.get("error")
|
||||
.and_then(value_to_error_object)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("response")
|
||||
.and_then(|response| response.get("error"))
|
||||
.and_then(value_to_error_object)
|
||||
})
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("chunks")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|chunks| chunks.iter().find_map(extract_error_object_from_json))
|
||||
})
|
||||
}
|
||||
|
||||
fn value_to_error_object(value: &Value) -> Option<Map<String, Value>> {
|
||||
match value {
|
||||
Value::Object(object) => Some(object.clone()),
|
||||
Value::String(message) if !message.trim().is_empty() => Some(Map::from_iter([(
|
||||
"message".to_string(),
|
||||
Value::String(message.trim().to_string()),
|
||||
)])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_error_message(
|
||||
status_code: u16,
|
||||
body_json: Option<&Value>,
|
||||
@@ -3841,12 +3935,112 @@ mod tests {
|
||||
assert_eq!(event.data.status_code, Some(200));
|
||||
assert_eq!(
|
||||
event.data.error_category.as_deref(),
|
||||
Some("non_success_status")
|
||||
Some("stream_missing_terminal_event")
|
||||
);
|
||||
assert_eq!(
|
||||
event.data.error_message.as_deref(),
|
||||
Some("execution runtime stream ended before provider terminal event")
|
||||
);
|
||||
assert_eq!(event.data.input_tokens, None);
|
||||
assert_eq!(event.data.output_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_terminal_usage_marks_http_200_response_failed_as_stream_terminal_error() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-stream-response-failed-1".to_string(),
|
||||
candidate_id: Some("cand-stream-response-failed-1".to_string()),
|
||||
provider_name: Some("OpenAI".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/v1/responses".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: None,
|
||||
body_ref: None,
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.5".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
let message = "This content was flagged for possible cybersecurity risk";
|
||||
let provider_sse = format!(
|
||||
concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {{\"type\":\"response.failed\",\"response\":{{\"status\":\"failed\",\"error\":{{\"message\":\"{}\",\"code\":\"cyber_policy\"}}}}}}\n\n"
|
||||
),
|
||||
message
|
||||
);
|
||||
let payload = GatewayStreamReportRequest {
|
||||
trace_id: "trace-stream-response-failed-1".to_string(),
|
||||
report_kind: "openai_responses_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses"
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"text/event-stream".to_string(),
|
||||
)]),
|
||||
provider_body_base64: Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(provider_sse),
|
||||
),
|
||||
provider_body_state: Some(UsageBodyCaptureState::Inline),
|
||||
client_body_base64: None,
|
||||
client_body_state: Some(UsageBodyCaptureState::None),
|
||||
terminal_summary: Some(ExecutionStreamTerminalSummary {
|
||||
response_id: Some("resp_failed".to_string()),
|
||||
model: Some("gpt-5.5".to_string()),
|
||||
observed_finish: true,
|
||||
parser_error: Some(message.to_string()),
|
||||
..ExecutionStreamTerminalSummary::default()
|
||||
}),
|
||||
telemetry: None,
|
||||
};
|
||||
|
||||
let event =
|
||||
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||
.expect("usage event should build");
|
||||
|
||||
assert_eq!(event.event_type, UsageEventType::Failed);
|
||||
assert_eq!(event.data.status_code, Some(200));
|
||||
assert_eq!(event.data.error_message.as_deref(), Some(message));
|
||||
assert_eq!(
|
||||
event.data.error_category.as_deref(),
|
||||
Some("stream_terminal_error")
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.client_response_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("error"))
|
||||
.and_then(|error| error.get("code"))
|
||||
.and_then(Value::as_str),
|
||||
Some("cyber_policy")
|
||||
);
|
||||
assert_eq!(
|
||||
event
|
||||
.data
|
||||
.client_response_body
|
||||
.as_ref()
|
||||
.and_then(|body| body.get("error"))
|
||||
.and_then(|error| error.get("type"))
|
||||
.and_then(Value::as_str),
|
||||
Some("stream_terminal_error")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_image_usage_estimates_request_tokens_when_provider_usage_is_missing() {
|
||||
let plan = ExecutionPlan {
|
||||
@@ -4957,6 +5151,7 @@ mod tests {
|
||||
},
|
||||
status_code: 200,
|
||||
terminal_error_message: None,
|
||||
terminal_failure_category: None,
|
||||
response_time_ms: Some(123),
|
||||
first_byte_time_ms: Some(45),
|
||||
request_headers: Some(json!({
|
||||
|
||||
@@ -922,8 +922,8 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | {
|
||||
channel?: 'all' | 'email' | 'server_chan'
|
||||
async testImportantNotification(options: 'all' | 'email' | 'server_chan' | 'bark' | {
|
||||
channel?: 'all' | 'email' | 'server_chan' | 'bark'
|
||||
item_key?: string
|
||||
} = 'all'): Promise<{
|
||||
success: boolean
|
||||
|
||||
@@ -91,6 +91,27 @@ describe('request failure notice', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not present HTTP 200 as the cause of stream terminal failures', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
status_code: 200,
|
||||
status: 'failed',
|
||||
error_message: 'This content was flagged for possible cybersecurity risk',
|
||||
failure_summary: {
|
||||
source: 'client_response',
|
||||
status_code: 200,
|
||||
type: 'stream_terminal_error',
|
||||
message: 'This content was flagged for possible cybersecurity risk',
|
||||
},
|
||||
}))
|
||||
|
||||
expect(notice).toEqual({
|
||||
title: '执行失败原因',
|
||||
message: 'This content was flagged for possible cybersecurity risk',
|
||||
isSchedulingFailure: false,
|
||||
meta: ['stream_terminal_error', 'client_response'],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale notice when the refreshed detail has no error fields', () => {
|
||||
const notice = resolveRequestFailureNotice(buildRequestDetail({
|
||||
status_code: 200,
|
||||
|
||||
@@ -51,15 +51,15 @@ describe('usage status helpers', () => {
|
||||
expect(isUsageRecordSuccessful(record)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats explicit failed status with a 2xx status code as successful for display', () => {
|
||||
it('treats explicit failed status as authoritative over a 2xx transport code', () => {
|
||||
const record = buildUsageRecord({
|
||||
status: 'failed',
|
||||
status_code: 200,
|
||||
error_message: 'stale failure flag'
|
||||
error_message: 'stream terminal error'
|
||||
})
|
||||
|
||||
expect(isUsageRecordFailed(record)).toBe(false)
|
||||
expect(isUsageRecordSuccessful(record)).toBe(true)
|
||||
expect(isUsageRecordFailed(record)).toBe(true)
|
||||
expect(isUsageRecordSuccessful(record)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes request status strings before mapping timeline status', () => {
|
||||
|
||||
@@ -18,7 +18,9 @@ function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): Re
|
||||
}
|
||||
|
||||
function formatHttpStatus(statusCode: number | null | undefined): string | null {
|
||||
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
|
||||
return typeof statusCode === 'number' && (statusCode < 200 || statusCode >= 300)
|
||||
? `HTTP ${statusCode}`
|
||||
: null
|
||||
}
|
||||
|
||||
function uniqueMeta(values: Array<string | null | undefined>): string[] {
|
||||
|
||||
@@ -189,7 +189,7 @@ export function isUsageRecordFailed(record: UsageFailureSignal & Pick<UsageRecor
|
||||
return false
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return !hasTerminalSuccessStatusCode(record)
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (hasTerminalSuccessStatusCode(record)) {
|
||||
@@ -208,7 +208,7 @@ export function isUsageRecordSuccessful(record: UsageFailureSignal & Pick<UsageR
|
||||
return true
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return hasTerminalSuccessStatusCode(record)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -956,6 +956,10 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
|
||||
{ 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: 'module.bark_push.enabled', value: false, description: 'Bark 推送开关' },
|
||||
{ key: 'module.bark_push.device_key', value: null, description: 'Bark Device Key' },
|
||||
{ key: 'module.bark_push.server_url', value: 'https://api.day.app', description: 'Bark 服务器地址' },
|
||||
{ key: 'module.bark_push.template', value: '', description: 'Bark 推送模板' },
|
||||
{ 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_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
|
||||
@@ -1032,6 +1036,20 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
|
||||
admin_menu_group: 'system',
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
{
|
||||
name: 'bark_push',
|
||||
display_name: 'Bark 推送',
|
||||
description: '第三方推送服务,配置 Bark Device Key 并测试 iOS 推送',
|
||||
category: 'integration',
|
||||
available: true,
|
||||
enabled: false,
|
||||
config_validated: false,
|
||||
config_error: '请先配置 Bark Device Key',
|
||||
admin_route: '/admin/modules/bark',
|
||||
admin_menu_icon: 'Send',
|
||||
admin_menu_group: 'system',
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
{
|
||||
name: 'chat_pii_redaction',
|
||||
display_name: '敏感信息保护',
|
||||
|
||||
@@ -1805,7 +1805,7 @@ registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_conf
|
||||
if (!entry) {
|
||||
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
|
||||
}
|
||||
if (key === 'module.server_chan_push.send_key') {
|
||||
if (key === 'module.server_chan_push.send_key' || key === 'module.bark_push.device_key') {
|
||||
return createMockResponse({
|
||||
key: entry.key,
|
||||
value: null,
|
||||
|
||||
@@ -294,6 +294,16 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/ServerChanSettings.vue')),
|
||||
meta: { module: 'server_chan_push' }
|
||||
},
|
||||
{
|
||||
path: 'bark',
|
||||
redirect: '/admin/modules/bark'
|
||||
},
|
||||
{
|
||||
path: 'modules/bark',
|
||||
name: 'BarkSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/modules/BarkSettings.vue')),
|
||||
meta: { module: 'bark_push' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
271
frontend/src/views/admin/modules/BarkSettings.vue
Normal file
271
frontend/src/views/admin/modules/BarkSettings.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Bark 推送"
|
||||
description="第三方推送服务,用于通知服务的 Bark 渠道"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<CardSection
|
||||
title="服务配置"
|
||||
description="配置 Bark Device Key、服务器地址和服务启用状态"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border/70 px-4 py-3">
|
||||
<div>
|
||||
<Label class="text-sm font-medium">
|
||||
启用 Bark 推送
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
通知服务选择 Bark 时会检查此开关
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="enabled"
|
||||
:disabled="!canEnable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<Label
|
||||
for="bark-device-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Device Key
|
||||
</Label>
|
||||
<Input
|
||||
id="bark-device-key"
|
||||
v-model="deviceKeyInput"
|
||||
masked
|
||||
:placeholder="deviceKeyIsSet ? '已设置(留空保持不变)' : '从 Bark App 推送地址中获取'"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Bark App 中推送地址
|
||||
<span class="font-mono">https://api.day.app/xxxx</span>
|
||||
的 <span class="font-mono">xxxx</span> 部分
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="bark-server-url"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="bark-server-url"
|
||||
v-model="serverUrlInput"
|
||||
placeholder="https://api.day.app"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
支持官方服务或自建 Bark Server,保存时会去掉末尾斜杠
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="通知模板"
|
||||
description="模板支持 {title} 和 {body} 变量"
|
||||
>
|
||||
<div>
|
||||
<Label
|
||||
for="bark-template"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
模板内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bark-template"
|
||||
v-model="templateInput"
|
||||
rows="10"
|
||||
class="mt-1 font-mono text-sm"
|
||||
placeholder="{body}"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<CardSection
|
||||
title="测试服务"
|
||||
description="按已保存配置发送一条 Bark 测试通知"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="testing || !deviceKeyIsSet"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ testing ? '发送中...' : '发送测试' }}
|
||||
</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
|
||||
v-if="lastTestResult.length > 0"
|
||||
class="mt-4 space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="item in lastTestResult"
|
||||
:key="item.channel"
|
||||
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 :class="item.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'">
|
||||
{{ item.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Button, Input, Label, Switch, Textarea } from '@/components/ui'
|
||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { modulesApi } from '@/api/modules'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const CONFIG_KEYS = {
|
||||
enabled: 'module.bark_push.enabled',
|
||||
device_key: 'module.bark_push.device_key',
|
||||
server_url: 'module.bark_push.server_url',
|
||||
template: 'module.bark_push.template',
|
||||
} as const
|
||||
|
||||
const DEFAULT_SERVER_URL = 'https://api.day.app'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const enabled = ref(false)
|
||||
const deviceKeyIsSet = ref(false)
|
||||
const deviceKeyInput = ref('')
|
||||
const serverUrlInput = ref(DEFAULT_SERVER_URL)
|
||||
const templateInput = ref('')
|
||||
const lastTestResult = ref<Array<{ channel: string; success: boolean; message: string }>>([])
|
||||
|
||||
const canEnable = computed(() => deviceKeyIsSet.value || deviceKeyInput.value.trim() !== '')
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const [moduleStatus, deviceKey, serverUrl, template] = await Promise.all([
|
||||
modulesApi.getStatus('bark_push'),
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.device_key),
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.server_url),
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.template),
|
||||
])
|
||||
|
||||
enabled.value = moduleStatus.enabled === true
|
||||
deviceKeyIsSet.value = deviceKey.is_set === true
|
||||
deviceKeyInput.value = ''
|
||||
serverUrlInput.value = typeof serverUrl.value === 'string' && serverUrl.value.trim()
|
||||
? serverUrl.value
|
||||
: DEFAULT_SERVER_URL
|
||||
templateInput.value = typeof template.value === 'string' ? template.value : ''
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '加载 Bark 推送配置失败'))
|
||||
log.error('加载 Bark 推送配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updates: Array<Promise<unknown>> = [
|
||||
adminApi.updateSystemConfig(
|
||||
CONFIG_KEYS.server_url,
|
||||
normalizeServerUrl(serverUrlInput.value),
|
||||
'Bark 服务器地址'
|
||||
),
|
||||
adminApi.updateSystemConfig(CONFIG_KEYS.template, templateInput.value, 'Bark 推送模板'),
|
||||
]
|
||||
const trimmedKey = deviceKeyInput.value.trim()
|
||||
if (trimmedKey) {
|
||||
updates.push(adminApi.updateSystemConfig(
|
||||
CONFIG_KEYS.device_key,
|
||||
trimmedKey,
|
||||
'Bark Device Key'
|
||||
))
|
||||
}
|
||||
await Promise.all(updates)
|
||||
if (trimmedKey) {
|
||||
deviceKeyIsSet.value = true
|
||||
deviceKeyInput.value = ''
|
||||
}
|
||||
if (!canEnable.value) {
|
||||
enabled.value = false
|
||||
}
|
||||
await modulesApi.setEnabled('bark_push', enabled.value)
|
||||
success('Bark 推送配置已保存')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存 Bark 推送配置失败'))
|
||||
log.error('保存 Bark 推送配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
const result = await adminApi.testImportantNotification({ channel: 'bark' })
|
||||
lastTestResult.value = result.channels || []
|
||||
if (result.success) {
|
||||
success(result.message || '测试通知已发送')
|
||||
} else {
|
||||
error(result.message || '测试通知发送失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '测试通知发送失败'))
|
||||
log.error('测试 Bark 推送失败:', err)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServerUrl(value: string): string {
|
||||
const trimmed = value.trim().replace(/\/+$/, '')
|
||||
return trimmed || DEFAULT_SERVER_URL
|
||||
}
|
||||
|
||||
function formatChannel(channel: string): string {
|
||||
if (channel === 'bark') return 'Bark'
|
||||
if (channel === 'server_chan') return 'Server 酱'
|
||||
if (channel === 'email') return '邮件'
|
||||
if (channel === 'module') return '模块'
|
||||
if (channel === 'none') return '无可用服务'
|
||||
return channel
|
||||
}
|
||||
</script>
|
||||
@@ -40,6 +40,9 @@
|
||||
<SelectItem value="server_chan">
|
||||
Server 酱
|
||||
</SelectItem>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -60,7 +63,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-2">
|
||||
<div class="grid gap-6 border-t border-border/60 pt-5 lg:grid-cols-3">
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -130,6 +133,31 @@
|
||||
配置 Server 酱推送
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm font-medium">
|
||||
Bark
|
||||
</Label>
|
||||
<Badge :variant="barkReady ? 'success' : 'outline'">
|
||||
{{ barkReady ? '可用' : '未就绪' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
通过 Bark 向 iOS 设备推送通知
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
to="/admin/modules/bark"
|
||||
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"
|
||||
>
|
||||
配置 Bark 推送
|
||||
</RouterLink>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
@@ -231,6 +259,9 @@
|
||||
<SelectItem value="server_chan">
|
||||
Server 酱
|
||||
</SelectItem>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -326,6 +357,9 @@
|
||||
<SelectItem value="server_chan">
|
||||
Server 酱
|
||||
</SelectItem>
|
||||
<SelectItem value="bark">
|
||||
Bark
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -382,7 +416,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan'
|
||||
type DeliveryChannel = 'global' | 'all' | 'email' | 'server_chan' | 'bark'
|
||||
|
||||
interface NotificationItem {
|
||||
local_id: string
|
||||
@@ -412,6 +446,7 @@ const CONFIG_KEYS = {
|
||||
default_channel: 'module.important_notification.default_channel',
|
||||
items: 'module.important_notification.items',
|
||||
server_chan_send_key: 'module.server_chan_push.send_key',
|
||||
bark_device_key: 'module.bark_push.device_key',
|
||||
} as const
|
||||
|
||||
const DEFAULT_ITEMS: NotificationItem[] = [
|
||||
@@ -460,6 +495,8 @@ const testing = ref(false)
|
||||
const smtpConfigured = ref(false)
|
||||
const serverChanKeyIsSet = ref(false)
|
||||
const serverChanStatus = ref<ModuleStatus | null>(null)
|
||||
const barkKeyIsSet = ref(false)
|
||||
const barkStatus = 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 }>>([])
|
||||
@@ -480,6 +517,10 @@ const serverChanReady = computed(() => {
|
||||
return serverChanStatus.value?.enabled === true && serverChanKeyIsSet.value
|
||||
})
|
||||
|
||||
const barkReady = computed(() => {
|
||||
return barkStatus.value?.enabled === true && barkKeyIsSet.value
|
||||
})
|
||||
|
||||
const canEnableService = computed(() => {
|
||||
if (deliveryReady(config.value.default_channel)) return true
|
||||
return config.value.items.some(item => item.enabled && isItemReady(item))
|
||||
@@ -499,6 +540,8 @@ async function loadConfig() {
|
||||
items,
|
||||
serverChanModuleStatus,
|
||||
serverChanKey,
|
||||
barkModuleStatus,
|
||||
barkDeviceKey,
|
||||
smtpHost,
|
||||
smtpFromEmail,
|
||||
] = await Promise.all([
|
||||
@@ -509,6 +552,8 @@ async function loadConfig() {
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.items),
|
||||
modulesApi.getStatus('server_chan_push'),
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.server_chan_send_key),
|
||||
modulesApi.getStatus('bark_push'),
|
||||
adminApi.getSystemConfig(CONFIG_KEYS.bark_device_key),
|
||||
adminApi.getSystemConfig('smtp_host'),
|
||||
adminApi.getSystemConfig('smtp_from_email'),
|
||||
])
|
||||
@@ -520,6 +565,8 @@ async function loadConfig() {
|
||||
config.value.items = normalizeItems(items.value)
|
||||
serverChanStatus.value = serverChanModuleStatus
|
||||
serverChanKeyIsSet.value = serverChanKey.is_set === true
|
||||
barkStatus.value = barkModuleStatus
|
||||
barkKeyIsSet.value = barkDeviceKey.is_set === true
|
||||
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 || ''
|
||||
@@ -604,9 +651,10 @@ function isItemReady(item: NotificationItem): boolean {
|
||||
}
|
||||
|
||||
function deliveryReady(channel: Exclude<DeliveryChannel, 'global'>): boolean {
|
||||
if (channel === 'all') return emailReady.value || serverChanReady.value
|
||||
if (channel === 'all') return emailReady.value || serverChanReady.value || barkReady.value
|
||||
if (channel === 'email') return emailReady.value
|
||||
if (channel === 'server_chan') return serverChanReady.value
|
||||
if (channel === 'bark') return barkReady.value
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -661,12 +709,12 @@ function normalizeItemKey(value: unknown): string {
|
||||
}
|
||||
|
||||
function normalizeItemChannel(value: unknown): DeliveryChannel {
|
||||
if (value === 'all' || value === 'email' || value === 'server_chan') return value
|
||||
if (value === 'all' || value === 'email' || value === 'server_chan' || value === 'bark') return value
|
||||
return 'global'
|
||||
}
|
||||
|
||||
function normalizeDefaultChannel(value: unknown): Exclude<DeliveryChannel, 'global'> {
|
||||
if (value === 'email' || value === 'server_chan') return value
|
||||
if (value === 'email' || value === 'server_chan' || value === 'bark') return value
|
||||
return 'all'
|
||||
}
|
||||
|
||||
@@ -691,6 +739,7 @@ function normalizeRecipients(value: unknown): string {
|
||||
function formatChannel(channel: string): string {
|
||||
if (channel === 'email') return '邮件'
|
||||
if (channel === 'server_chan') return 'Server 酱'
|
||||
if (channel === 'bark') return 'Bark'
|
||||
if (channel === 'user_email') return '用户邮件'
|
||||
if (channel === 'module') return '模块'
|
||||
if (channel === 'item') return '通知项'
|
||||
|
||||
Reference in New Issue
Block a user