From b042ddfd6ba6c59dc5cf2eb62c58051f1489167b Mon Sep 17 00:00:00 2001 From: mojie126 Date: Thu, 5 Nov 2026 23:07:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=95=BF=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=9A=84=E5=86=85=E5=AD=98=E4=B8=8E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=BC=80=E9=94=80=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调试报文限量保留、正文合计与单条体积设上限,超限淘汰最旧记录 - 大请求及时释放原始字节,避免解析与转发期间长期保留多份正文 - 请求终态与限额事件在同一事务内提交,在途状态最多每秒写入一次 - 日志每 64 条、请求统计每 128 次记账或每 60 秒批量维护 - 日志查询与统计加缓存,请求总数用内存缓存,无筛选不再全表计数 - SQLite 检查点阈值提高到 10000 页,新库启用增量回收 - 图片逐张压缩,避免一次性复制全部图片地址 - 调试关闭时清理历史快照并停止详细日志,同类余额查询失败 15 分钟内去重 - 更新检查复用成功结果,减少重复网络请求 --- .../src-tauri/server/src/server/api/chat.rs | 18 +- .../server/src/server/api/debug_api.rs | 2 +- .../server/src/server/api/live_progress.rs | 25 +- .../src-tauri/server/src/server/api/mod.rs | 1 - .../server/src/server/api/pipeline.rs | 93 ++++--- .../server/src/server/api/pipeline/raw.rs | 29 +- .../server/src/server/api/protocol.rs | 25 +- .../server/src/server/api/record_persist.rs | 27 -- .../server/src/server/api/stats_api.rs | 6 + .../src-tauri/server/src/server/config/mod.rs | 12 + .../src/server/config/routing_profiles.rs | 35 +++ .../src/server/core/billing/keepalive.rs | 2 +- .../src/server/core/cloud_sync/settings.rs | 2 + .../server/src/server/core/debug_traffic.rs | 256 +++++++++++++++--- .../src/server/core/debug_traffic/sql.rs | 47 +++- .../server/src/server/core/limiter/counter.rs | 42 ++- .../server/src/server/core/update/check.rs | 12 + .../server/core/upstream/image_compress.rs | 33 ++- .../src/server/core/upstream/provider_loop.rs | 15 +- .../core/upstream/provider_loop/tests.rs | 5 +- .../server/src/server/core/usage_query.rs | 15 +- .../server/src/server/core/usage_records.rs | 20 ++ .../src-tauri/server/src/server/db/mod.rs | 10 +- .../src-tauri/server/src/server/db/schema.rs | 2 +- .../src-tauri/server/src/server/logging.rs | 10 +- .../server/src/server/logs_store/mod.rs | 84 +++--- .../src-tauri/server/src/server/mod.rs | 4 + .../server/src/server/request_stats.rs | 198 +++++++++----- .../src/server/request_stats/maintenance.rs | 2 + .../server/src/server/request_stats/record.rs | 4 +- .../server/src/server/request_stats/sql.rs | 12 +- .../src/server/request_stats/sql/raw.rs | 39 +++ 32 files changed, 800 insertions(+), 287 deletions(-) delete mode 100644 desktop-tauri/src-tauri/server/src/server/api/record_persist.rs diff --git a/desktop-tauri/src-tauri/server/src/server/api/chat.rs b/desktop-tauri/src-tauri/server/src/server/api/chat.rs index 957dfb762..23c77e54a 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/chat.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/chat.rs @@ -114,9 +114,15 @@ pub async fn chat_completions( .map(|items| items.len()) .unwrap_or(0); let user_agent = pipeline::user_agent_of(&headers); + let body_len = body.len(); + let dedupe_key = pipeline::sha256_hex(&body); + let raw_request = pipeline::capture_raw_body(&body); // ③ 调试落盘:保存最近一次入站请求体,供重放分析(覆盖写) write_debug_files(&body, method, path, &user_agent); + // 原始字节已全部转成后续步骤所需的值,解析后的 payload 已持有正文, + // 此时释放 Bytes 可把百 MB 级请求的峰值内存降下一份 + drop(body); // ④ 模型路由走公共管道(默认模型回落 / 别名映射 / 目录校验, // 与另外两条协议入口逐条同源)。下游原始请求名在改写前取一次: @@ -146,14 +152,13 @@ pub async fn chat_completions( &format!( "← {method} {path} model={} stream={stream} msgs={message_count} bytes={} ua={user_agent}", if requested_model.is_empty() { "(未指定)" } else { &requested_model }, - body.len(), + body_len, ), ); // ⑤ 转发:请求体原样交给转发层(内容处理在每家 provider 发送前按作用范围 // 逐家判定,见 `core::upstream::payload` 的 `send_body`);去重键取**客户端 // 原始请求体**的哈希,与「某一家是否被处理」无关。 - let dedupe_key = pipeline::sha256_hex(&body); let telemetry = Arc::new(RequestTelemetry::with_id()); // 「进行中」行:模型解析已成功、转发即将开始 —— 先插一条 status=0 的明细, // 让请求日志在响应还没跑完时就能看到这条请求(收尾时由 `record` 的 UPDATE @@ -192,10 +197,9 @@ pub async fn chat_completions( telemetry_id, started_at, )); - // 下游原始请求体在此刻抄一份(request_raw 表的请求侧,见 `capture_raw_body`): - // body 还是客户端发来的原值;送进转发层后 payload 会被就地改写(默认模型注入)。 - // **仅调试模式开启时采集** —— 关着时这里是零开销的 None(预览对话没有正文) - let raw_request = pipeline::capture_raw_body(&body); + // 下游原始请求体已在解析后、释放 Bytes 前按原样抄入 raw_request + // (request_raw 表的请求侧,见 `capture_raw_body`),转发层的就地改写 + // 不会影响这份快照,调试关闭时它是零开销的 None // 自动续写的上下文(开关 `autoContinue` **默认关**)。关着时这里连一份 clone // 都不做 —— `plan()` 先问开关,再决定要不要复制请求体/请求头/白名单。 // 判据、多轮拼接与用量合并口径都在 `core::upstream::continuation`。 @@ -490,4 +494,4 @@ pub fn spawn_catalog_refresh(state: &ServerState) { crate::spawn_task(async move { crate::server::core::providers::adapter::refresh_implemented(&store).await; }); -} \ No newline at end of file +} diff --git a/desktop-tauri/src-tauri/server/src/server/api/debug_api.rs b/desktop-tauri/src-tauri/server/src/server/api/debug_api.rs index 8607e6b7c..340544e5a 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/debug_api.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/debug_api.rs @@ -97,6 +97,6 @@ fn debug_json() -> Value { json!({ config::KEY_DEBUG_MODE: config::current().debug_mode(), "count": debug_traffic::count(), - "limit": debug_traffic::MAX_ENTRIES, + "limit": debug_traffic::max_entries(), }) } diff --git a/desktop-tauri/src-tauri/server/src/server/api/live_progress.rs b/desktop-tauri/src-tauri/server/src/server/api/live_progress.rs index b44f73d14..068890e1b 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/live_progress.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/live_progress.rs @@ -1,15 +1,28 @@ use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use crate::server::request_stats::{RequestStats, RunningProgress}; +/// 在途状态的最小写库间隔:状态变化仍保留,最多合并这段时间内的快照 +const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(1000); + pub(super) fn sink(stats: Arc, id: String) -> impl Fn(RunningProgress) + Send + Sync { let (sender, mut receiver) = tokio::sync::mpsc::channel::<()>(1); - let pending = Arc::new(Mutex::new(None::)); - let worker = pending.clone(); - let _ = crate::spawn_task(async move { + let latest = Arc::new(Mutex::new(None::)); + let worker = latest.clone(); + crate::spawn_task(async move { + let mut last_write = Instant::now() - MIN_WRITE_INTERVAL; while receiver.recv().await.is_some() { + // 先等窗口结束再取快照,把窗口内的多次状态变化合并成最后一次 + let wait = MIN_WRITE_INTERVAL.saturating_sub(last_write.elapsed()); + if !wait.is_zero() { + tokio::time::sleep(wait).await; + } + // 等待期间最后一条快照已覆盖前值;把尚未消费的唤醒合并在本次提交里 + while receiver.try_recv().is_ok() {} let progress = worker.lock().unwrap_or_else(|error| error.into_inner()).take(); if let Some(progress) = progress { + last_write = Instant::now(); let stats = stats.clone(); let id = id.clone(); if let Err(error) = tokio::task::spawn_blocking(move || stats.update_running(&id, &progress)).await { @@ -18,9 +31,11 @@ pub(super) fn sink(stats: Arc, id: String) -> impl Fn(RunningProgr } } }); + let latest_for_sink = latest.clone(); move |progress| { - // 只保留最后一次完整快照;终态写入后的 UPDATE 由 status=0 条件挡住。 - *pending.lock().unwrap_or_else(|error| error.into_inner()) = Some(progress); + // 只保留最后一次完整快照;终态写入后的 UPDATE 由 status=0 条件挡住, + // 一秒内的连续变化由工作线程合并后只提交一次 + *latest_for_sink.lock().unwrap_or_else(|error| error.into_inner()) = Some(progress); let _ = sender.try_send(()); } } diff --git a/desktop-tauri/src-tauri/server/src/server/api/mod.rs b/desktop-tauri/src-tauri/server/src/server/api/mod.rs index 68fd26545..0049dc0dc 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/mod.rs @@ -126,7 +126,6 @@ pub mod onboarding; pub mod panel; pub mod pipeline; mod live_progress; -mod record_persist; // 提供商插件包的管理面(列表 / 安装 / 卸载 / 启停):插件是用户安装的, // 这一组端点就是「装插件」的唯一入口,见该文件模块头 pub mod plugins; diff --git a/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs b/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs index 242305873..cc2ce5ebe 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/pipeline.rs @@ -36,7 +36,7 @@ use crate::server::core::upstream::usage::{self, RequestTelemetry, TelemetrySnap use crate::server::errors::GatewayError; use crate::server::logging; use crate::server::request_stats::{ - AttemptDetail, NewRequestEntry, RequestStats, RetryEvent, RunningProgress, SensitiveHit, + AttemptDetail, NewRequestEntry, RequestStats, RetryEvent, SensitiveHit, }; use crate::server::ServerState; @@ -48,6 +48,8 @@ use raw::RawCapture; const DEBUG_DIR: &str = "debug"; const DEBUG_REQUEST_FILE: &str = "last-request.json"; const DEBUG_META_FILE: &str = "last-request.meta.txt"; +/// 调试快照文件的开关状态:0 = 未判定、1 = 当前开启、2 = 当前关闭 +static DEBUG_SNAPSHOT_STATE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); /// 非调试请求的错误摘要上限;调试请求保留完整错误。 const ERROR_SUMMARY_CHARS: usize = 200; @@ -495,32 +497,22 @@ pub fn live_row_sink( id: String, started_at: i64, ) -> impl Fn(&TelemetrySnapshot) + Send + Sync { + let live = super::live_progress::sink(stats.clone(), id.clone()); move |snapshot| { - stats.update_running( - &id, - &RunningProgress { - provider: snapshot.provider.clone().unwrap_or_default(), - account_id: snapshot.account_id.clone(), - account_name: snapshot.account_name.clone(), - upstream_model: snapshot.upstream_model.clone(), - upstream_reasoning: snapshot.upstream_reasoning.clone(), - // 与收尾同口径:一次都没发出去(0)按 1 次算,理由见 - // `TelemetrySnapshot::attempts` 的说明 - attempts: snapshot.attempts.max(1), - first_response_ms: snapshot.first_response_at.map(|at| (at - started_at).max(0)), - // 阶段与阶段起点:起点为 None 只可能是「还停在初始阶段(连接中)、 - // 一次都没切换过」——那个阶段的起点就是**请求开始时刻**(下面这个 - // started_at,与插入在途行时写进 phase_started_at 的值同源), - // 所以用 started_at 兜底。不兜的话这一列会写进 NULL, - // 而状态列的第二行按它算「连接中已持续多久」会算出个负数或 - // 干脆没有 —— 两种都不该出现在「已受理但还没发出去」的请求上 - phase: snapshot.phase.as_str().to_string(), - phase_started_at: snapshot.phase_started_at.or(Some(started_at)), - attempt_details: stored_attempt_details(&snapshot.attempts_detail), - route: snapshot.route.clone(), - sensitive_hits: stored_sensitive_hits(&snapshot.sensitive_hits), - }, - ); + live(crate::server::request_stats::RunningProgress { + provider: snapshot.provider.clone().unwrap_or_default(), + account_id: snapshot.account_id.clone(), + account_name: snapshot.account_name.clone(), + upstream_model: snapshot.upstream_model.clone(), + upstream_reasoning: snapshot.upstream_reasoning.clone(), + attempts: snapshot.attempts.max(1), + first_response_ms: snapshot.first_response_at.map(|at| (at - started_at).max(0)), + phase: snapshot.phase.as_str().to_string(), + phase_started_at: snapshot.phase_started_at.or(Some(started_at)), + attempt_details: stored_attempt_details(&snapshot.attempts_detail), + route: snapshot.route.clone(), + sensitive_hits: stored_sensitive_hits(&snapshot.sensitive_hits), + }); } } @@ -662,11 +654,15 @@ pub fn record_entry(context: &RecordContext, fallback_error: Option) { let limited_model = if entry.upstream_model.is_empty() { entry.model.clone() } else { entry.upstream_model.clone() }; let limited_request = entry.id.clone(); let sent = snapshot.attempts > 0; - context.stats.record(entry); - if sent { - limiter::counter::note(&limited_account, &limited_request, &limited_model, - &snapshot.quota_channel, limited_tokens, context.started_at); - } + let limiter_event = sent.then_some(( + limited_account.as_str(), + limited_request.as_str(), + limited_model.as_str(), + snapshot.quota_channel.as_str(), + limited_tokens, + context.started_at, + )); + context.stats.record(entry, limiter_event); if !limited_account.is_empty() { limiter::note_request_tokens(&limited_account, limited_tokens, finished_at); } @@ -1009,13 +1005,26 @@ pub fn error_response( /// 调试落盘:原始 body + 一行 meta(覆盖写;**失败不影响请求**)。 /// /// 与 Node 版一致:目录不存在时创建,任何 IO 失败都静默吞掉 —— -/// 调试落盘是排障辅助,不能因为它失败就让用户的请求失败。 +/// 调试落盘是排障辅助,不能因为它失败就让用户的请求失败,超过 8 MiB 的 +/// 请求只写元数据,避免每次请求都把百 MB 级正文复制到群晖磁盘 pub fn write_debug_files(body: &[u8], method: &str, path: &str, user_agent: &str) { let dir = config::config_dir().join(DEBUG_DIR); + use std::sync::atomic::Ordering; + if !crate::server::core::debug_traffic::enabled() { + if DEBUG_SNAPSHOT_STATE.swap(2, Ordering::Relaxed) != 2 { + cleanup_debug_files(); + } + return; + } + DEBUG_SNAPSHOT_STATE.store(1, Ordering::Relaxed); if std::fs::create_dir_all(&dir).is_err() { return; } - let _ = std::fs::write(dir.join(DEBUG_REQUEST_FILE), body); + if body.len() <= crate::server::core::debug_traffic::MAX_BODY_BYTES { + let _ = std::fs::write(dir.join(DEBUG_REQUEST_FILE), body); + } else { + let _ = std::fs::remove_file(dir.join(DEBUG_REQUEST_FILE)); + } let meta = format!( "{} {method} {path} {}B ua={user_agent}", iso_timestamp(), @@ -1024,6 +1033,20 @@ pub fn write_debug_files(body: &[u8], method: &str, path: &str, user_agent: &str let _ = std::fs::write(dir.join(DEBUG_META_FILE), meta); } +/// 调试关闭时清理历史快照,避免关闭后仍长期占着上次的大请求正文 +pub fn cleanup_debug_files_if_disabled() { + if !crate::server::core::debug_traffic::enabled() { + cleanup_debug_files(); + DEBUG_SNAPSHOT_STATE.store(2, std::sync::atomic::Ordering::Relaxed); + } +} + +fn cleanup_debug_files() { + let dir = config::config_dir().join(DEBUG_DIR); + let _ = std::fs::remove_file(dir.join(DEBUG_REQUEST_FILE)); + let _ = std::fs::remove_file(dir.join(DEBUG_META_FILE)); +} + /// ISO-8601 UTC 时间戳(对应 Node 的 `new Date().toISOString()`) pub fn iso_timestamp() -> String { let millis = logging::now_ms(); @@ -1236,6 +1259,10 @@ mod tests { limit: Some(5), model: None, provider: None, + client: None, + api_key: None, + production_only: false, + completed_only: false, status: None, start: None, end: None, diff --git a/desktop-tauri/src-tauri/server/src/server/api/pipeline/raw.rs b/desktop-tauri/src-tauri/server/src/server/api/pipeline/raw.rs index 8e966a995..36422c132 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/pipeline/raw.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/pipeline/raw.rs @@ -1,9 +1,14 @@ -//! 调试模式下的下游正文采集,不对日志副本设置单条大小上限。 +//! 调试模式下的下游正文采集,单条大小上限与上游调试报文保持一致。 + +const MAX_RAW_BYTES: usize = crate::server::core::debug_traffic::MAX_BODY_BYTES; pub fn raw_body_text(bytes: &[u8]) -> Option { if bytes.is_empty() { return None; } + if bytes.len() > MAX_RAW_BYTES { + return None; + } Some(String::from_utf8_lossy(bytes).into_owned()) } @@ -18,7 +23,8 @@ pub fn capture_raw_json(value: &T) -> Option { if !crate::server::core::debug_traffic::enabled() { return None; } - serde_json::to_string(value).ok() + let text = serde_json::to_string(value).ok()?; + (text.len() <= MAX_RAW_BYTES).then_some(text) } pub(super) struct RawCapture { @@ -32,8 +38,12 @@ impl RawCapture { } pub(super) fn push(&mut self, chunk: &[u8]) { - if self.enabled { + if self.enabled && self.buf.len().saturating_add(chunk.len()) <= MAX_RAW_BYTES { self.buf.extend_from_slice(chunk); + } else if self.enabled { + // 超过上限后停止累积,避免超大响应流把整份正文复制进内存 + self.enabled = false; + self.buf.clear(); } } @@ -49,3 +59,16 @@ impl RawCapture { }) } } + +#[cfg(test)] +mod tests { + use super::{RawCapture, MAX_RAW_BYTES}; + + #[test] + fn oversized_stream_stops_accumulating() { + let mut capture = RawCapture::new(true); + let chunk = vec![b'x'; MAX_RAW_BYTES + 1]; + capture.push(&chunk); + assert!(capture.into_text().is_none()); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/api/protocol.rs b/desktop-tauri/src-tauri/server/src/server/api/protocol.rs index 6dc12dd0d..ababa42a2 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/protocol.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/protocol.rs @@ -160,6 +160,9 @@ pub async fn responses_endpoint( }; // 协议翻译前识别,保留原始文本块边界。 let compact_client = compact_routing::client_for_request(&source.behavior_client, &raw, RequestFormat::Responses); + let body_len = body.len(); + let dedupe_key = pipeline::sha256_hex(&body); + let raw_request = pipeline::capture_raw_body(&body); // 原始请求体留一份:回程要用它回显请求侧字段(instructions / tools / …), // 而下面会把 payload 改写成 Chat 形态 let original = raw.clone(); @@ -203,10 +206,13 @@ pub async fn responses_endpoint( &format!( "← POST {path} model={} stream={stream} bytes={} ua={user_agent}", if requested_model.is_empty() { "(未指定)" } else { &requested_model }, - body.len(), + body_len, ), ); pipeline::write_debug_files(&body, "POST", path, &user_agent); + // 原始字节已转成去重键、调试正文与回显所需的值;解析后的协议体已持有 + // 内容,这里释放 Bytes,避免大请求在转换与转发期间保留两份完整正文 + drop(body); // 上游恒走流式(理由见模块头) // scope 的**提供商**那一半进转发层(选路时按承载家过滤); @@ -247,7 +253,7 @@ pub async fn responses_endpoint( chat_body, &headers, stream, - pipeline::sha256_hex(&body), + dedupe_key, scope, telemetry.clone(), ) @@ -264,7 +270,7 @@ pub async fn responses_endpoint( // 下游原始请求体:客户端发来的那一份(协议翻译前)——仅调试模式 // 开启时采集(`capture_raw_body`)。响应侧非流式在聚合完成后补, // 流式由 RecordingStream 在流结束时定稿 - raw_request: pipeline::capture_raw_body(&body), + raw_request, raw_response: None, // 真实流量:不是模型测试(见 `RecordContext::is_test`) is_test: false, @@ -358,6 +364,9 @@ pub async fn messages_endpoint( Ok(value) => value, Err(response) => return response, }; + let body_len = body.len(); + let dedupe_key = pipeline::sha256_hex(&body); + let raw_request = pipeline::capture_raw_body(&body); let stream = raw.get("stream").and_then(Value::as_bool).unwrap_or(false); let compact_client = compact_routing::client_for_request(&source.behavior_client, &raw, RequestFormat::Anthropic); @@ -387,10 +396,12 @@ pub async fn messages_endpoint( &format!( "← POST {path} model={} stream={stream} bytes={} ua={user_agent}", if requested_model.is_empty() { "(未指定)" } else { &requested_model }, - body.len(), + body_len, ), ); pipeline::write_debug_files(&body, "POST", path, &user_agent); + // 与 responses_endpoint 同一处理:释放原始字节,避免大请求保留两份正文 + drop(body); // scope 的**提供商**那一半进转发层(选路时按承载家过滤); // 模型那一半已在上面判过(分工见 forward_chat 的说明)。 @@ -434,7 +445,7 @@ pub async fn messages_endpoint( chat_body, &headers, stream, - pipeline::sha256_hex(&body), + dedupe_key, scope, telemetry.clone(), ) @@ -451,7 +462,7 @@ pub async fn messages_endpoint( // 下游原始请求体:客户端发来的那一份(协议翻译前)——仅调试模式 // 开启时采集(`capture_raw_body`)。响应侧非流式在聚合完成后补, // 流式由 RecordingStream 在流结束时定稿 - raw_request: pipeline::capture_raw_body(&body), + raw_request, raw_response: None, // 真实流量:不是模型测试(见 `RecordContext::is_test`) is_test: false, @@ -553,4 +564,4 @@ fn anthropic_error_response_with_status(status: u16, message: &str) -> Response let mut response = json_response(body); *response.status_mut() = code; response -} \ No newline at end of file +} diff --git a/desktop-tauri/src-tauri/server/src/server/api/record_persist.rs b/desktop-tauri/src-tauri/server/src/server/api/record_persist.rs deleted file mode 100644 index c2587165e..000000000 --- a/desktop-tauri/src-tauri/server/src/server/api/record_persist.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::sync::Arc; - -use crate::server::core::limiter; -use crate::server::request_stats::{NewRequestEntry, RequestStats}; - -pub(super) struct FinishedRecord<'a> { - pub stats: Arc, - pub entry: NewRequestEntry, - pub sent: bool, - pub channel: String, - pub started_at: i64, - pub finished_at: i64, - pub raw_request: Option<&'a str>, - pub raw_response: Option<&'a str>, -} - -pub(super) fn persist(record: FinishedRecord<'_>) { - let FinishedRecord { stats, entry, sent, channel, started_at, finished_at, raw_request, raw_response } = record; - let account = entry.account_id.clone(); - let tokens = entry.total_tokens; - let model = if entry.upstream_model.is_empty() { entry.model.clone() } else { entry.upstream_model.clone() }; - let id = entry.id.clone(); - stats.record(entry); - if sent { limiter::counter::note(&account, &id, &model, &channel, tokens, started_at); } - if !account.is_empty() { limiter::note_request_tokens(&account, tokens, finished_at); } - stats.store_raw(&id, started_at, raw_request, raw_response); -} diff --git a/desktop-tauri/src-tauri/server/src/server/api/stats_api.rs b/desktop-tauri/src-tauri/server/src/server/api/stats_api.rs index f65db9dd9..8d12a954c 100644 --- a/desktop-tauri/src-tauri/server/src/server/api/stats_api.rs +++ b/desktop-tauri/src-tauri/server/src/server/api/stats_api.rs @@ -524,6 +524,12 @@ pub async fn put_retention(State(state): State, body: Bytes) -> Res logging::console_line("[Logs]", &format!("按保留设置清理了 {removed} 条日志")); } } + // 调试报文最多 100 条,并跟随请求日志条数;用户改小请求日志上限时 + // 立即把上游调试报文一并收敛,不必等下一次请求写入 + let removed = crate::server::core::debug_traffic::prune(); + if removed > 0 { + logging::console_line("[Debug]", &format!("按保留设置清理了 {removed} 条调试报文")); + } } let settings = config::retention_settings(); diff --git a/desktop-tauri/src-tauri/server/src/server/config/mod.rs b/desktop-tauri/src-tauri/server/src/server/config/mod.rs index f3c5a76c9..f7b1c07a2 100644 --- a/desktop-tauri/src-tauri/server/src/server/config/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/config/mod.rs @@ -1048,6 +1048,18 @@ pub fn cors_enabled() -> bool { false } +/// 只取调试模式开关的轻量读取 +/// +/// 转发链每个请求都会判定一次,不能为此克隆整份 `raw` +pub fn debug_mode_enabled() -> bool { + if let Ok(guard) = CONFIG.read() { + if let Some(config) = guard.as_ref() { + return config.debug_mode; + } + } + false +} + /// 只取 AI 助手配置(**不克隆整份 raw**)。 /// /// 与 [`cors_enabled`] 同一理由:助手对话入口与设置页各取一次,而 `current()` diff --git a/desktop-tauri/src-tauri/server/src/server/config/routing_profiles.rs b/desktop-tauri/src-tauri/server/src/server/config/routing_profiles.rs index a4b575868..ab122d7e4 100644 --- a/desktop-tauri/src-tauri/server/src/server/config/routing_profiles.rs +++ b/desktop-tauri/src-tauri/server/src/server/config/routing_profiles.rs @@ -222,3 +222,38 @@ pub fn save_routing_profile(model: &str, profile: Option) -> Res if !saved { return Err("路由配置落盘失败,本次修改仅在当前进程生效".to_string()); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{validate_shape, RoutingProfile, RoutingProviderScope}; + use std::collections::BTreeMap; + + fn selected_profile() -> RoutingProfile { + RoutingProfile { + provider_scope: Some(RoutingProviderScope::Selected), + providers: vec!["provider".to_string()], + disabled_providers: vec![], + account_order: BTreeMap::from([( + "provider".to_string(), + vec!["account".to_string()], + )]), + excluded_accounts: vec![], + strategy: None, + sticky: None, + } + } + + #[test] + fn selected_scope_accepts_a_provider_kept_but_disabled() { + let mut profile = selected_profile(); + profile.disabled_providers.push("provider".to_string()); + assert!(validate_shape(&profile).is_ok()); + } + + #[test] + fn selected_scope_still_rejects_an_unknown_disabled_provider() { + let mut profile = selected_profile(); + profile.disabled_providers.push("missing".to_string()); + assert!(validate_shape(&profile).is_err()); + } +} diff --git a/desktop-tauri/src-tauri/server/src/server/core/billing/keepalive.rs b/desktop-tauri/src-tauri/server/src/server/core/billing/keepalive.rs index c8bc93c94..7e7b20475 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/billing/keepalive.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/billing/keepalive.rs @@ -206,7 +206,7 @@ pub fn record_activity( entry.status = result.as_ref().err().map(|error| i64::from(error.http_status().as_u16())).unwrap_or(502); entry.error = Some(result.as_ref().err().map(|error| error.message.clone()).unwrap_or_else(|| "日活模型保活未成功,详情见签到记录".to_string())); } - stats.record(entry); + stats.record(entry, None); } /// 对外状态(签到中心快照的 `keepalive` 段与保存响应共用这一份): diff --git a/desktop-tauri/src-tauri/server/src/server/core/cloud_sync/settings.rs b/desktop-tauri/src-tauri/server/src/server/core/cloud_sync/settings.rs index 7ca5273e3..20d06a214 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/cloud_sync/settings.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/cloud_sync/settings.rs @@ -464,6 +464,7 @@ mod tests { base_url: "https://dav.example.com/dav".to_string(), username: "u".to_string(), password: "x".to_string(), + user_agent: String::new(), }, ..CloudSyncSettings::default() }; @@ -480,6 +481,7 @@ mod tests { base_url: "https://dav.example.com/dav".to_string(), username: "u".to_string(), password: "x".to_string(), + user_agent: String::new(), }, ..CloudSyncSettings::default() }; diff --git a/desktop-tauri/src-tauri/server/src/server/core/debug_traffic.rs b/desktop-tauri/src-tauri/server/src/server/core/debug_traffic.rs index 0a449c931..ebf38c957 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/debug_traffic.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/debug_traffic.rs @@ -10,9 +10,9 @@ //! 下游侧(客户端发来的请求体 + 网关下发的响应体)由 `request_raw` 表采 //! (`api::pipeline::capture_raw_body` / `capture_raw_json`),与本表**同一个 //! 开关**:调试模式开启时两处都采、关闭时两处都不采(预览对话因此默认没有 -//! 正文可看 —— 对齐 OmniProxy 的口径)。另有一份与本开关无关的覆盖式落盘 +//! 正文可看 —— 对齐 OmniProxy 的口径)。另有一份同样受开关控制的覆盖式落盘 //! (`write_debug_files` → `{config_dir}/debug/last-request.json`),那是 -//! 「最近一次入站请求体」的快照,三者互不重叠。 +//! 「最近一次入站请求体」的快照,关闭调试时会被清理 //! //! ── 为什么单独一张表,不塞进 requests 的行里 ────────────────── //! 一次 SSE 响应动辄数百 KB,塞进请求明细会让那张表迅速膨胀、拖慢每一页的 @@ -26,8 +26,8 @@ //! 不脱敏等于把各家账号的 token 抄在磁盘上。响应头里的 `set-cookie` 同理。 //! //! ── 留存控制 ────────────────────────────────────────────── -//! 请求体与响应体完整保存,不按单条大小截断;仅超过 [`MAX_ENTRIES`] 时淘汰最旧记录。 -//! 未完成的流按实际收到的字节保存;人为中止不会伪造未收到的内容。 +//! 请求体与响应体在单条上限内完整保存,超过 [`MAX_BODY_BYTES`] 的正文只保留 +//! 状态与头部,按 [`MAX_ENTRIES`] 与 [`MAX_TOTAL_BYTES`] 双重上限淘汰最旧记录 //! //! ── `Db` 是进程级状态(本模块的两处特殊之处之一)──────────── //! 与 `AccountStore` / `LogStore` / `RequestStats` 不同,本模块没有「注入式句柄」 @@ -64,23 +64,38 @@ use crate::server::logging; mod sql; -/// 表里保留的最大条数。超过后**精确删到上限**(每次只删超出那几条)。 -/// -/// 取 500:与事件日志的上限同量级,够看「最近这批请求发生了什么」; -/// 调试模式是**临时排障**用的,不是长期归档 —— 真要长期留,用户该用导出, -/// 而不是指望这里无限增长。 +/// 调试报文最多保留 100 条,请求日志条数低于 100 时跟随请求日志上限。 /// /// ── 为什么不再「丢最旧的一半」─────────────────────────────── /// 旧实现的注释写明「丢一半」是为了省下每次超限都整文件重写的代价 /// (一次重写换掉一半的量,比逐条删便宜)。进数据库后这条理由**消失了**: /// 淘汰是一条 DELETE,它的成本与删多少行无关(旧实现里正是「删多少」决定了 /// 要重写多少字节)。 -/// 精确删到上限的好处是可预期的容量:用户看到 `count()` 就到 500 为止, -/// 不会出现「刚过 500 就掉到 250」那种一半的跳变。 -/// 代价是超限后每次 `record` 都会发一条 DELETE,但那次删除通常只影响 -/// **一两行**(`record` 每次只加一条,除非有人手工往库里塞了一大批)—— -/// 而且只在超限时才发(见 [`record`] 的先判后删)。 -pub const MAX_ENTRIES: usize = 500; +/// 精确删到上限的好处是可预期的容量:用户看到 `count()` 就到上限为止, +/// 不会出现「刚过上限就掉到一半」那种跳变。总数还会再受 +/// [`MAX_TOTAL_BYTES`] 约束,避免大正文合计接近 1 GiB。 +pub const MAX_ENTRIES: usize = 100; + +/// 调试报文当前生效的条数上限 +/// +/// 调试报文是请求日志的附属排障数据,不能超过请求日志自身的保留条数; +/// 请求日志上限高于 100 时仍固定为 100,低于 100 时跟随请求日志上限 +pub fn max_entries() -> usize { + crate::server::config::retention_settings() + .request_count + .clamp(1, MAX_ENTRIES as i64) as usize +} + +/// 调试报文正文的合计上限:默认 128 MiB。 +/// +/// 群晖等低内存设备上,历史 500 条平均 1.8 MiB 的报文会占近 1 GiB, +/// 查询与淘汰时会同时放大 SQLite 页缓存与进程内存,因此条数上限之外 +/// 还必须限制合计正文字节 +pub const MAX_TOTAL_BYTES: i64 = 128 * 1024 * 1024; + +/// 单条请求体或响应体超过该值时不再写调试报文,避免几十 MB 级请求 +/// 被完整复制到 SQLite 并长期占用内存 +pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024; /// 凭据类请求头:命中即替换成 `[redacted]`(大小写不敏感)。 /// @@ -126,25 +141,39 @@ fn db() -> Option<&'static Db> { /// 初始化存储(启动时调用一次;`OnceLock` 天然幂等 —— 重复调用只有第一次生效)。 /// -/// ── 它现在还需要做什么(相比改造前少了一大半)─────────────── -/// 改造前它做三件事:设目录、清空内存、把旧文件载入内存并裁一遍(超限还要 -/// 回写文件)。进数据库后后两件都不需要了: -/// - **没有「载入」**:库就是全集,读取是一次查询(改造前必须先把文件读进 -/// 内存才能 `get` / `count`,那是文件时代的补偿机制); -/// - **没有「启动裁剪」**:超限的行由 [`record`] 的留存条数守着,而启动时 -/// 库里最多就是上次退出时的状态(那时已经合规);即便有人手工往库里塞了 -/// 超限数据,下一次 `record` 也会收敛它。 -/// 所以它只剩「把 `Db` 装上」这一件事 —— 但**仍然必要且必须被调用**:没装 -/// 就相当于本模块没启用,[`record`] 会静默丢弃、[`get`] 一律 `None`。 +/// ── 为什么启动时要裁剪 ──────────────────────────────────────── +/// 旧版本只按条数限制,存量库可能已经积累近 1 GiB 报文;启动时先按新上限 +/// 收敛一次,后续查询与逐条淘汰才不会长期背着历史峰值 +/// +/// 装载 `Db` **仍然必要且必须被调用**:没装就相当于本模块没启用, +/// [`record`] 会静默丢弃、[`get`] 一律 `None` /// 无返回值、不报错:调试数据不值得让网关起不来(模块头的取向)。 /// /// 无论调试开关是否打开都要初始化(`ServerState::bootstrap` 的注释):开关是 /// **逐请求**判定的(改完设置下一个请求就生效),存储没就绪会让开启后的第一批 /// 请求无处可落。 -pub fn init(db: Option) { +pub fn init(installed_db: Option) { // 重复调用只让第一次生效(`set` 失败即已初始化)。与改造前一致: // 那时是覆盖目录 + 重新载入,现在库自己就是真相,覆盖没有意义。 - let _ = DB.set(db); + let installed = installed_db.is_some(); + let _ = DB.set(installed_db); + // 旧库可能已经累积了大量报文,启动时立即按条数与总字节上限收敛, + // 避免后续每次查询都背着历史峰值工作 + if installed { + let Some(db) = db() else { return }; + match db.with_mut(|conn| -> rusqlite::Result<()> { + let tx = conn.transaction()?; + let removed = sql::enforce_limits(&tx)?; + tx.commit()?; + if removed { + sql::incremental_vacuum(conn)?; + } + Ok(()) + }) { + Some(Ok(())) | None => {} + Some(Err(error)) => eprintln!("[Debug] 启动时清理调试报文失败: {error}"), + } + } } /// 报文数据所在的文件(**就是库文件**;设置页概况用)。 @@ -178,11 +207,50 @@ pub fn count() -> usize { } } +/// 按当前条数与正文合计上限清理旧调试报文 +pub fn prune() -> usize { + let Some(db) = db() else { return 0 }; + match db.with_mut(|conn| -> rusqlite::Result { + let tx = conn.transaction()?; + let (before, _) = sql::count_and_bytes(&tx)?; + let removed = sql::enforce_limits(&tx)?; + tx.commit()?; + let (after, _) = sql::count_and_bytes(conn)?; + if removed { + sql::incremental_vacuum(conn)?; + } + Ok(before.saturating_sub(after)) + }) { + Some(Ok(removed)) => removed, + Some(Err(error)) => { + eprintln!("[Debug] 调试报文裁剪失败: {error}"); + 0 + } + None => 0, + } +} + /// 落一条完整报文;同 id 覆盖,失败只打日志,不影响转发。 +/// +/// 超过 [`MAX_BODY_BYTES`] 的正文直接跳过:此时完整内容已大到足以拖慢 +/// 序列化、SQLite 页缓存与备份,调试记录保留状态码与头即可 pub fn record(entry: TrafficEntry) { + let request_size = entry.request_body.to_string().len(); + let response_size = entry.response_body.as_deref().map(str::len).unwrap_or(0); + if payload_exceeds_limit(request_size, response_size) { + eprintln!( + "[Debug] 报文超过单条上限,已跳过正文保存: id={} request={} response={}", + entry.id, request_size, response_size, + ); + return; + } super::blocking::in_place(|| record_sync(entry)); } +fn payload_exceeds_limit(request_size: usize, response_size: usize) -> bool { + request_size > MAX_BODY_BYTES || response_size > MAX_BODY_BYTES +} + fn record_sync(entry: TrafficEntry) { let Some(db) = db() else { // 库不可用:静默丢弃(与改造前「STORE 未初始化」同一处理)。 @@ -191,7 +259,13 @@ fn record_sync(entry: TrafficEntry) { }; // 失败只打控制台:**不能**用 logging::log / verbose —— 它们要写同一个库, // 而此刻我们正握着那把锁(见模块头的死锁约束)。 - match db.with_mut(|conn| sql::upsert_entry(conn, &entry)) { + match db.with_mut(|conn| -> rusqlite::Result<()> { + let tx = conn.transaction()?; + sql::upsert_entry(&tx, &entry)?; + sql::enforce_limits(&tx)?; + tx.commit()?; + Ok(()) + }) { Some(Ok(())) => {} Some(Err(error)) => { eprintln!("[Debug] 调试报文写入数据库失败: {error}"); @@ -200,11 +274,6 @@ fn record_sync(entry: TrafficEntry) { // 连接中毒(`Db` 的取向:不拿半截数据当真相) None => return, } - // 写入后只清理超过留存条数的旧记录。 - match db.with(|conn| sql::enforce_limits(conn)) { - Some(Ok(())) | None => {} - Some(Err(error)) => eprintln!("[Debug] 调试报文裁剪失败: {error}"), - } } /// 按 id 取一条(请求日志页的「详情」列用;库不可用时 `None`) @@ -323,6 +392,8 @@ struct CaptureState { sent: bool, /// 已落库(保证只写一次) done: bool, + /// 响应体已超过采集上限,后续分片不再累积 + body_overflow: bool, } impl TrafficCapture { @@ -331,10 +402,11 @@ impl TrafficCapture { pub fn begin(id: &str) -> Self { Self { state: std::sync::Mutex::new(CaptureState { - entry: blank_entry(id, "", "", &[], &Value::Null), + entry: blank_entry(id, "", "", &[], None), body: Vec::new(), sent: false, done: false, + body_overflow: false, }), } } @@ -349,11 +421,21 @@ impl TrafficCapture { url: &str, provider: &str, headers: &[(String, String)], - body: &Value, + body: &str, ) { + if body.len() > MAX_BODY_BYTES { + let mut guard = self.lock(); + guard.entry = blank_entry(&guard.entry.id.clone(), url, provider, headers, None); + guard.body.clear(); + guard.body_overflow = true; + guard.sent = true; + return; + } + let body = serde_json::from_str::(body).unwrap_or(Value::Null); let mut guard = self.lock(); - guard.entry = blank_entry(&guard.entry.id.clone(), url, provider, headers, body); + guard.entry = blank_entry(&guard.entry.id.clone(), url, provider, headers, Some(&body)); guard.body.clear(); + guard.body_overflow = false; // 走到这里说明请求体已构造、即将发出 —— 这条报文有内容可落 guard.sent = true; // done 不重置:本条请求只写一行,重试不产生新条目 @@ -367,6 +449,7 @@ impl TrafficCapture { let mut guard = self.lock(); guard.entry.status = Some(status); guard.body.clear(); + guard.body_overflow = false; let pairs = headers .iter() .map(|(name, value)| (name.as_str(), value.to_str().unwrap_or("[binary]"))); @@ -374,7 +457,16 @@ impl TrafficCapture { } pub fn push(&self, chunk: &[u8]) { - self.lock().body.extend_from_slice(chunk); + let mut guard = self.lock(); + if guard.body_overflow { + return; + } + if guard.body.len().saturating_add(chunk.len()) > MAX_BODY_BYTES { + guard.body.clear(); + guard.body_overflow = true; + return; + } + guard.body.extend_from_slice(chunk); } #[cfg(test)] @@ -395,7 +487,11 @@ impl TrafficCapture { if !guard.sent { return; } - let body = std::mem::take(&mut guard.body); + let body = if guard.body_overflow { + Vec::new() + } else { + std::mem::take(&mut guard.body) + }; if !body.is_empty() { // 字符可能横跨网络分片,完整累积后再解码。 guard.entry.response_body = Some(match String::from_utf8(body) { @@ -403,7 +499,7 @@ impl TrafficCapture { Err(error) => String::from_utf8_lossy(error.as_bytes()).into_owned(), }); } - let entry = std::mem::replace(&mut guard.entry, blank_entry("", "", "", &[], &Value::Null)); + let entry = std::mem::replace(&mut guard.entry, blank_entry("", "", "", &[], None)); drop(guard); record(entry); } @@ -421,13 +517,84 @@ impl Drop for TrafficCapture { } } +#[cfg(test)] +mod tests { + use super::{MAX_BODY_BYTES, MAX_ENTRIES, MAX_TOTAL_BYTES, TrafficEntry}; + use crate::server::db::test_temp::TempDb; + + fn entry(id: &str, ts: i64, size: usize) -> TrafficEntry { + TrafficEntry { + id: id.to_string(), + ts, + url: "https://example.test/v1/chat/completions".to_string(), + provider: "test".to_string(), + request_headers: serde_json::json!({}), + request_body: serde_json::json!({"payload": "x".repeat(size)}), + status: Some(200), + response_headers: None, + response_body: None, + truncated: false, + } + } + + #[test] + fn total_byte_limit_keeps_newest_rows() { + let (db, _guard) = TempDb::open("debug-limit"); + let pragmas = db + .with(|conn| { + let auto_vacuum = conn + .pragma_query_value(None, "auto_vacuum", |row| row.get::<_, i64>(0)) + .ok()?; + let wal_autocheckpoint = conn + .pragma_query_value(None, "wal_autocheckpoint", |row| row.get::<_, i64>(0)) + .ok()?; + Some((auto_vacuum, wal_autocheckpoint)) + }) + .flatten(); + assert_eq!(pragmas, Some((2, 10000))); + let _ = db.with_mut(|conn| { + for (index, size) in [1usize, 2, 3].into_iter().enumerate() { + super::sql::upsert_entry(conn, &entry(&format!("e{index}"), index as i64, size))?; + } + conn.execute( + "UPDATE debug_traffic SET size = 100000000 WHERE id IN ('e0', 'e1', 'e2')", + [], + )?; + super::sql::enforce_limits(conn) + }); + let rows = db.with(|conn| { + conn.query_row("SELECT COUNT(*), COALESCE(SUM(size),0) FROM debug_traffic", [], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }) + }); + let (count, bytes) = rows.and_then(Result::ok).unwrap_or((0, 0)); + assert!(count <= 2); + assert!(bytes <= MAX_TOTAL_BYTES); + assert!(MAX_BODY_BYTES > 0 && MAX_TOTAL_BYTES > 0); + } + + #[test] + fn oversized_body_is_not_recorded() { + let entry = entry("oversized", 4, MAX_BODY_BYTES); + let request_size = entry.request_body.to_string().len(); + assert!(super::payload_exceeds_limit(request_size, 0)); + } + + #[test] + fn entry_limit_never_exceeds_one_hundred() { + let request_limit = crate::server::config::retention_settings().request_count; + assert_eq!(super::max_entries(), request_limit.clamp(1, 100) as usize); + assert_eq!(MAX_ENTRIES, 100); + } +} + /// 一条空白记录的构造(`begin` / `reset_request` 共用) fn blank_entry( id: &str, url: &str, provider: &str, headers: &[(String, String)], - body: &Value, + body: Option<&Value>, ) -> TrafficEntry { let pairs = headers .iter() @@ -438,7 +605,7 @@ fn blank_entry( url: url.to_string(), provider: provider.to_string(), request_headers: redact_headers(pairs), - request_body: body.clone(), + request_body: body.cloned().unwrap_or(Value::Null), status: None, response_headers: None, response_body: None, @@ -454,7 +621,7 @@ fn blank_entry( /// 开关改完下一个请求就生效,不必重启。转发热路径上多一次读锁是可接受的 /// —— 与「每次重试判定都读重试设置」同一取向(见 `config::RetrySettings`)。 pub fn enabled() -> bool { - crate::server::config::current().debug_mode() + crate::server::config::debug_mode_enabled() } /// 迁移项用:把一批旧文件条目写进 `debug_traffic` 表(**一个事务**,含导入后的裁剪)。 @@ -490,8 +657,11 @@ pub(crate) fn import_legacy( skipped += 1; } } - sql::enforce_limits(&tx)?; + let removed = sql::enforce_limits(&tx)?; tx.commit()?; + if removed { + sql::incremental_vacuum(conn)?; + } Ok((imported, skipped)) } diff --git a/desktop-tauri/src-tauri/server/src/server/core/debug_traffic/sql.rs b/desktop-tauri/src-tauri/server/src/server/core/debug_traffic/sql.rs index f0869c7b1..105f979e9 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/debug_traffic/sql.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/debug_traffic/sql.rs @@ -8,7 +8,7 @@ //! //! ── `size` 列 ──────────────────────────────────────────── //! 保存完整报文落库后的文本字节合计,不含 SQLite 页与行开销。 -//! 留存只按条数裁剪,不按该字段删除大报文。 +//! 留存同时按条数与该字段的合计值裁剪,避免大报文堆到近 1 GiB。 //! //! ── 排序键:`ts DESC, rowid DESC` ──────────────────────────── //! 「最新」按 **ts** 判(`idx_debug_traffic_ts` 正为此存在),同 ts 时用 `rowid` @@ -29,7 +29,7 @@ use rusqlite::types::Value as SqlValue; use rusqlite::{params, Connection}; -use super::{TrafficEntry, MAX_ENTRIES}; +use super::{max_entries, TrafficEntry, MAX_TOTAL_BYTES}; /// 全部列(所有 SELECT 都按这个顺序取,[`decode_row`] 依赖它; /// 不写 `SELECT *` 是为了「加列时读侧要显式跟上」这件事在 diff 里可见) @@ -202,15 +202,42 @@ pub(super) fn select_one(conn: &Connection, id: &str) -> rusqlite::Result