diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index e2cc83d96..02fe4a11d 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -43,59 +43,59 @@ const ACCOUNT_STATS_PAGE_LIMIT: u32 = 200; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AccountsQuery { - pub cursor: Option, - pub limit: Option, - pub page: Option, - pub page_size: Option, - pub search: Option, +pub(crate) struct AccountsQuery { + cursor: Option, + limit: Option, + page: Option, + page_size: Option, + search: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CreateAccountRequest { - pub token: Option, - pub refresh_token: Option, +pub(crate) struct CreateAccountRequest { + token: Option, + refresh_token: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BatchDeleteAccountsRequest { - pub ids: Vec, +pub(crate) struct BatchDeleteAccountsRequest { + ids: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SetAccountCookiesRequest { - pub id: String, - pub cookies: Value, +pub(crate) struct SetAccountCookiesRequest { + id: String, + cookies: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AccountActionRequest { - pub id: String, +pub(crate) struct AccountActionRequest { + id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AccountIdQuery { - pub id: String, +pub(crate) struct AccountIdQuery { + id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AccountExportQuery { - pub ids: Option, - pub format: Option, +pub(crate) struct AccountExportQuery { + ids: Option, + format: Option, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HealthCheckRequest { - pub ids: Option>, - pub stagger_ms: Option, - pub concurrency: Option, +pub(crate) struct HealthCheckRequest { + ids: Option>, + stagger_ms: Option, + concurrency: Option, } // ============================================================================ @@ -104,22 +104,22 @@ pub struct HealthCheckRequest { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AdminAccountData { - pub id: String, - pub email: Option, - pub account_id: Option, - pub user_id: Option, - pub label: Option, - pub plan_type: Option, - pub status: String, - pub access_token_expires_at: Option, - pub access_token_expires_at_display: Option, - pub added_at: String, - pub added_at_display: String, - pub updated_at: String, - pub updated_at_display: String, - pub quota: AdminAccountQuotaData, - pub usage: AdminAccountUsageData, +struct AdminAccountData { + id: String, + email: Option, + account_id: Option, + user_id: Option, + label: Option, + plan_type: Option, + status: String, + access_token_expires_at: Option, + access_token_expires_at_display: Option, + added_at: String, + added_at_display: String, + updated_at: String, + updated_at_display: String, + quota: AdminAccountQuotaData, + usage: AdminAccountUsageData, } impl From for AdminAccountData { @@ -160,12 +160,12 @@ impl AdminAccountData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AdminAccountQuotaData { - pub used_percent: Option, - pub used_percent_display: String, - pub reset_at_display: String, - pub refreshed_at_display: String, - pub window_used_display: String, +struct AdminAccountQuotaData { + used_percent: Option, + used_percent_display: String, + reset_at_display: String, + refreshed_at_display: String, + window_used_display: String, } impl Default for AdminAccountQuotaData { @@ -182,31 +182,31 @@ impl Default for AdminAccountQuotaData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AdminAccountUsageData { - pub request_count: i64, - pub request_count_display: String, - pub empty_response_count: i64, - pub input_tokens: i64, - pub input_tokens_display: String, - pub output_tokens: i64, - pub output_tokens_display: String, - pub cached_tokens: i64, - pub cached_tokens_display: String, - pub reasoning_tokens: i64, - pub total_tokens: i64, - pub total_tokens_display: String, - pub image_input_tokens: i64, - pub image_output_tokens: i64, - pub image_tokens_display: String, - pub image_request_count: i64, - pub image_request_failed_count: i64, - pub created_tokens: i64, - pub created_tokens_display: String, - pub read_tokens: i64, - pub read_tokens_display: String, - pub last_used_at: Option, - pub last_used_at_display: String, - pub models: Vec, +struct AdminAccountUsageData { + request_count: i64, + request_count_display: String, + empty_response_count: i64, + input_tokens: i64, + input_tokens_display: String, + output_tokens: i64, + output_tokens_display: String, + cached_tokens: i64, + cached_tokens_display: String, + reasoning_tokens: i64, + total_tokens: i64, + total_tokens_display: String, + image_input_tokens: i64, + image_output_tokens: i64, + image_tokens_display: String, + image_request_count: i64, + image_request_failed_count: i64, + created_tokens: i64, + created_tokens_display: String, + read_tokens: i64, + read_tokens_display: String, + last_used_at: Option, + last_used_at_display: String, + models: Vec, } impl AdminAccountUsageData { @@ -262,24 +262,24 @@ impl AdminAccountUsageData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AdminAccountModelUsageData { - pub model: String, - pub request_count: u64, - pub request_count_display: String, - pub success_rate: f64, - pub success_rate_display: String, - pub input_tokens: u64, - pub input_tokens_display: String, - pub output_tokens: u64, - pub output_tokens_display: String, - pub cached_tokens: u64, - pub cached_tokens_display: String, - pub total_tokens: u64, - pub total_tokens_display: String, - pub total_cost_usd: f64, - pub total_cost_usd_display: String, - pub last_used_at: Option, - pub last_used_at_display: String, +struct AdminAccountModelUsageData { + model: String, + request_count: u64, + request_count_display: String, + success_rate: f64, + success_rate_display: String, + input_tokens: u64, + input_tokens_display: String, + output_tokens: u64, + output_tokens_display: String, + cached_tokens: u64, + cached_tokens_display: String, + total_tokens: u64, + total_tokens_display: String, + total_cost_usd: f64, + total_cost_usd_display: String, + last_used_at: Option, + last_used_at_display: String, } #[derive(Debug, Clone, Default)] @@ -306,53 +306,53 @@ impl AccountListStats { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BatchDeleteAccountsData { - pub deleted: u32, - pub not_found: Vec, +struct BatchDeleteAccountsData { + deleted: u32, + not_found: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BatchUpdateAccountStatusData { - pub updated: u32, - pub not_found: Vec, +struct BatchUpdateAccountStatusData { + updated: u32, + not_found: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(untagged)] -pub enum AccountUpdateData { +enum AccountUpdateData { Account(Box), BatchStatus(BatchUpdateAccountStatusData), } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ResetAccountUsageData { - pub id: String, - pub reset: bool, +struct ResetAccountUsageData { + id: String, + reset: bool, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AccountCookiesData { - pub cookies: Option, +struct AccountCookiesData { + cookies: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AdminAccountExportData { - pub id: String, - pub email: Option, - pub account_id: Option, - pub user_id: Option, - pub label: Option, - pub plan_type: Option, - pub status: String, - pub access_token_expires_at: Option, - pub added_at: String, - pub updated_at: String, - pub token: String, - pub refresh_token: Option, +struct AdminAccountExportData { + id: String, + email: Option, + account_id: Option, + user_id: Option, + label: Option, + plan_type: Option, + status: String, + access_token_expires_at: Option, + added_at: String, + updated_at: String, + token: String, + refresh_token: Option, } impl From for AdminAccountExportData { @@ -376,14 +376,14 @@ impl From for AdminAccountExportData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AccountExportData { +struct AccountExportData { source_format: &'static str, accounts: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AccountImportData { +struct AccountImportData { imported: u32, skipped: u32, source_format: String, @@ -391,18 +391,18 @@ pub struct AccountImportData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct HealthCheckData { - pub summary: HealthCheckSummary, - pub results: Vec, +struct HealthCheckData { + summary: HealthCheckSummary, + results: Vec, } #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] -pub struct HealthCheckSummary { - pub total: usize, - pub alive: usize, - pub dead: usize, - pub skipped: usize, +struct HealthCheckSummary { + total: usize, + alive: usize, + dead: usize, + skipped: usize, } // ============================================================================ @@ -410,7 +410,7 @@ pub struct HealthCheckSummary { // ============================================================================ /// `GET /api/admin/accounts` -pub async fn accounts( +pub(crate) async fn accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -474,7 +474,7 @@ pub async fn accounts( } /// `POST /api/admin/accounts` -pub async fn create_account( +pub(crate) async fn create_account( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -497,7 +497,7 @@ pub async fn create_account( } /// `POST /api/admin/accounts/refresh` -pub async fn refresh_account( +pub(crate) async fn refresh_account( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -521,7 +521,7 @@ pub async fn refresh_account( } /// `POST /api/admin/accounts/reset-usage` -pub async fn reset_account_usage( +pub(crate) async fn reset_account_usage( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -546,7 +546,7 @@ pub async fn reset_account_usage( } /// `GET /api/admin/accounts/cookies` -pub async fn get_account_cookies( +pub(crate) async fn get_account_cookies( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -565,7 +565,7 @@ pub async fn get_account_cookies( } /// `POST /api/admin/accounts/cookies` -pub async fn set_account_cookies( +pub(crate) async fn set_account_cookies( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -589,7 +589,7 @@ pub async fn set_account_cookies( } /// `GET /api/admin/accounts/quota` -pub async fn account_quota( +pub(crate) async fn account_quota( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -629,7 +629,7 @@ pub async fn account_quota( } /// `GET /api/admin/accounts/quota-warnings` -pub async fn quota_warnings( +pub(crate) async fn quota_warnings( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -646,7 +646,7 @@ pub async fn quota_warnings( } /// `POST /api/admin/accounts/health-check` -pub async fn health_check_accounts( +pub(crate) async fn health_check_accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -702,7 +702,7 @@ pub async fn health_check_accounts( } /// `GET /api/admin/accounts/export` -pub async fn export_accounts( +pub(crate) async fn export_accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -732,7 +732,7 @@ pub async fn export_accounts( } /// `POST /api/admin/accounts/import` -pub async fn import_accounts( +pub(crate) async fn import_accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -757,7 +757,7 @@ pub async fn import_accounts( } /// `POST /api/admin/accounts/delete` -pub async fn batch_delete_accounts( +pub(crate) async fn batch_delete_accounts( State(state): State, Extension(request_id): Extension, headers: HeaderMap, @@ -786,7 +786,7 @@ pub async fn batch_delete_accounts( } /// `POST /api/admin/accounts/update` -pub async fn update_account( +pub(crate) async fn update_account( State(state): State, Extension(request_id): Extension, headers: HeaderMap, diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index 91abee2f0..2d6d19e12 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -8,7 +8,7 @@ use axum::{ response::IntoResponse, Extension, }; -use chrono::{DateTime, Duration, Timelike, Utc}; +use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -27,7 +27,10 @@ use crate::{ response::{AdminEnvelope, AdminError, AdminResponse}, }, http::middleware::request_id::RequestId, - infra::time::{china_datetime, china_datetime_rfc3339_str, china_relative_time, china_time}, + infra::time::{ + china_datetime, china_datetime_rfc3339_str, china_day_start, china_hour, china_hour_start, + china_quarter_hour_start, china_relative_time, china_time, + }, runtime::state::AppState, upstream::accounts::model::{Account, AccountStatus}, }; @@ -341,8 +344,25 @@ fn dashboard_cards( default_model: &str, default_service_tier: Option<&str>, ) -> DashboardCardsData { - let now = Utc::now(); - let today_start = day_start(now); + dashboard_cards_at( + accounts, + summary, + logs, + default_model, + default_service_tier, + Utc::now(), + ) +} + +fn dashboard_cards_at( + accounts: &[Account], + summary: &AdminUsageSummary, + logs: &[EventLog], + default_model: &str, + default_service_tier: Option<&str>, + now: DateTime, +) -> DashboardCardsData { + let today_start = china_day_start(now); let yesterday_start = today_start - Duration::days(1); let billing_context = BillingContext { default_model, @@ -407,8 +427,15 @@ fn dashboard_cards( } fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> DashboardTrendData { - let now = Utc::now(); - let current_hour = hour_start(now); + dashboard_trend_data_at(logs, kind, Utc::now()) +} + +fn dashboard_trend_data_at( + logs: &[EventLog], + kind: DashboardTrendKind, + now: DateTime, +) -> DashboardTrendData { + let current_hour = china_hour_start(now); let start = current_hour - Duration::hours(23); let mut buckets = (0..24) .map(|index| { @@ -421,7 +448,7 @@ fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> Dashboar if log.created_at < start || log.created_at > now { continue; } - let log_hour = hour_start(log.created_at); + let log_hour = china_hour_start(log.created_at); if let Some((_, bucket)) = buckets .iter_mut() .find(|(bucket_start, _)| *bucket_start == log_hour) @@ -435,7 +462,7 @@ fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> Dashboar .map(|(bucket_start, bucket)| { let latency = bucket.avg_latency().unwrap_or(0); DashboardTrendPointData { - time: format!("{:02}", bucket_start.hour()), + time: format!("{:02}", china_hour(bucket_start)), requests: bucket.requests, input_tokens: bucket.input_tokens, output_tokens: bucket.output_tokens, @@ -464,8 +491,14 @@ fn dashboard_trend_data(logs: &[EventLog], kind: DashboardTrendKind) -> Dashboar } fn dashboard_health_timeline_data(logs: &[EventLog]) -> DashboardHealthTimelineData { - let now = Utc::now(); - let current_slot = quarter_hour_start(now); + dashboard_health_timeline_data_at(logs, Utc::now()) +} + +fn dashboard_health_timeline_data_at( + logs: &[EventLog], + now: DateTime, +) -> DashboardHealthTimelineData { + let current_slot = china_quarter_hour_start(now); let start = current_slot - Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * (HEALTH_TIMELINE_SLOTS - 1)); let mut buckets = (0..HEALTH_TIMELINE_SLOTS) @@ -479,7 +512,7 @@ fn dashboard_health_timeline_data(logs: &[EventLog]) -> DashboardHealthTimelineD if log.created_at < start || log.created_at > now { continue; } - let log_slot = quarter_hour_start(log.created_at); + let log_slot = china_quarter_hour_start(log.created_at); if let Some((_, bucket)) = buckets .iter_mut() .find(|(bucket_start, _)| *bucket_start == log_slot) @@ -988,26 +1021,53 @@ fn empty_usage_summary() -> AdminUsageSummary { } } -fn day_start(value: DateTime) -> DateTime { - value - .date_naive() - .and_hms_opt(0, 0, 0) - .expect("valid midnight") - .and_utc() -} +#[cfg(test)] +mod tests { + use super::*; -fn hour_start(value: DateTime) -> DateTime { - value - .date_naive() - .and_hms_opt(value.hour(), 0, 0) - .expect("valid hour") - .and_utc() -} + #[test] + fn dashboard_cards_should_use_china_day_boundary() { + let now = utc("2026-06-24T18:00:00Z"); + let logs = vec![ + usage_log_at("2026-06-24T16:30:00Z", 10), + usage_log_at("2026-06-24T15:30:00Z", 20), + ]; -fn quarter_hour_start(value: DateTime) -> DateTime { - value - .date_naive() - .and_hms_opt(value.hour(), value.minute() / 15 * 15, 0) - .expect("valid quarter hour") - .and_utc() + let cards = dashboard_cards_at(&[], &empty_usage_summary(), &logs, "gpt-5.5", None, now); + + assert_eq!(cards.traffic.today_requests, 1); + assert_eq!(cards.traffic.yesterday_requests, 1); + assert_eq!(cards.tokens.today_tokens, 10); + assert_eq!(cards.tokens.yesterday_tokens, 20); + } + + #[test] + fn dashboard_trend_should_bucket_and_label_by_china_hour() { + let now = utc("2026-06-24T18:37:00Z"); + let logs = vec![usage_log_at("2026-06-24T18:15:00Z", 10)]; + + let trend = dashboard_trend_data_at(&logs, DashboardTrendKind::Usage, now); + let last = trend.points.last().expect("trend should contain points"); + + assert_eq!(last.time, "02"); + assert_eq!(last.requests, 1); + } + + fn usage_log_at(created_at: &str, input_tokens: u64) -> EventLog { + let mut log = EventLog::new("v1.response", EventLevel::Info, "completed"); + log.created_at = utc(created_at); + log.model = Some("gpt-5.5".to_string()); + log.metadata = serde_json::json!({ + "usage": { + "inputTokens": input_tokens, + "outputTokens": 0u64, + "cachedTokens": 0u64 + } + }); + log + } + + fn utc(value: &str) -> DateTime { + value.parse::>().expect("valid RFC3339 UTC") + } } diff --git a/src/infra/logging.rs b/src/infra/logging.rs index 45e8b3e29..599049f3c 100644 --- a/src/infra/logging.rs +++ b/src/infra/logging.rs @@ -1,16 +1,24 @@ //! 日志初始化与文件轮转。 use std::{ - fs, io, + fmt, fs, + fs::{File, OpenOptions}, + io, + io::Write, path::{Path, PathBuf}, }; +use chrono::{DateTime, Utc}; use thiserror::Error; -use tracing_appender::{ - non_blocking::WorkerGuard, - rolling::{RollingFileAppender, Rotation}, +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{ + fmt::{self as tracing_fmt, format::Writer, time::FormatTime}, + layer::SubscriberExt, + util::SubscriberInitExt, + EnvFilter, }; -use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +use crate::infra::time::{china_date, china_rfc3339_millis}; const LOG_PREFIX: &str = "codex-proxy-rs"; const LOG_SUFFIX: &str = "log"; @@ -40,9 +48,6 @@ pub enum LogError { /// 日志 IO 错误。 #[error("log io error: {0}")] Io(#[from] io::Error), - /// appender 初始化错误。 - #[error("log appender initialization failed: {0}")] - Appender(#[from] tracing_appender::rolling::InitError), /// tracing subscriber 已初始化。 #[error("global tracing subscriber is already initialized")] SubscriberAlreadyInitialized, @@ -53,21 +58,18 @@ pub struct LogGuard { _guard: WorkerGuard, } +/// 按中国自然日写入的日志 appender。 +pub struct ChinaDailyFileAppender { + directory: PathBuf, + retention_days: usize, + current_date: String, + writer: File, +} + /// 构造文件 appender。 -pub fn build_file_appender(config: &RotationConfig) -> Result { +pub fn build_file_appender(config: &RotationConfig) -> Result { fs::create_dir_all(&config.directory)?; - - let builder = RollingFileAppender::builder() - .rotation(Rotation::DAILY) - .filename_prefix(LOG_PREFIX) - .filename_suffix(LOG_SUFFIX); - let builder = if config.retention_days == 0 { - builder - } else { - builder.max_log_files(config.retention_days) - }; - - builder.build(&config.directory).map_err(LogError::Appender) + ChinaDailyFileAppender::open(&config.directory, config.retention_days, Utc::now()) } /// 初始化 tracing。 @@ -79,8 +81,9 @@ pub fn init_tracing(config: RotationConfig) -> Result { tracing_subscriber::registry() .with(filter) .with( - fmt::layer() + tracing_fmt::layer() .json() + .with_timer(ChinaLogTimer) .with_writer(writer) .with_target(true) .with_level(true) @@ -96,3 +99,128 @@ pub fn init_tracing(config: RotationConfig) -> Result { Ok(LogGuard { _guard: guard }) } + +impl ChinaDailyFileAppender { + fn open( + directory: impl AsRef, + retention_days: usize, + now: DateTime, + ) -> Result { + let directory = directory.as_ref().to_path_buf(); + let current_date = china_date(&now); + let writer = open_log_file(&directory, ¤t_date)?; + cleanup_old_logs(&directory, retention_days)?; + Ok(Self { + directory, + retention_days, + current_date, + writer, + }) + } + + fn rollover_if_needed(&mut self) -> io::Result<()> { + let current_date = china_date(&Utc::now()); + if current_date == self.current_date { + return Ok(()); + } + + self.writer.flush()?; + self.writer = open_log_file(&self.directory, ¤t_date)?; + self.current_date = current_date; + cleanup_old_logs(&self.directory, self.retention_days)?; + Ok(()) + } +} + +impl Write for ChinaDailyFileAppender { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.rollover_if_needed()?; + self.writer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +#[derive(Debug, Clone, Copy)] +struct ChinaLogTimer; + +impl FormatTime for ChinaLogTimer { + fn format_time(&self, writer: &mut Writer<'_>) -> fmt::Result { + write!(writer, "{}", china_log_timestamp(Utc::now())) + } +} + +fn china_log_timestamp(now: DateTime) -> String { + china_rfc3339_millis(&now) +} + +fn open_log_file(directory: &Path, date: &str) -> io::Result { + OpenOptions::new() + .create(true) + .append(true) + .open(directory.join(log_file_name(date))) +} + +fn log_file_name(date: &str) -> String { + format!("{LOG_PREFIX}.{date}.{LOG_SUFFIX}") +} + +fn cleanup_old_logs(directory: &Path, retention_days: usize) -> io::Result<()> { + if retention_days == 0 { + return Ok(()); + } + + let mut logs = fs::read_dir(directory)? + .filter_map(Result::ok) + .filter_map(|entry| { + let file_name = entry.file_name().to_string_lossy().into_owned(); + is_managed_log_file(&file_name).then_some((file_name, entry.path())) + }) + .collect::>(); + logs.sort_by(|left, right| right.0.cmp(&left.0)); + + for (_, path) in logs.into_iter().skip(retention_days) { + fs::remove_file(path)?; + } + Ok(()) +} + +fn is_managed_log_file(file_name: &str) -> bool { + file_name.starts_with(&format!("{LOG_PREFIX}.")) + && file_name.ends_with(&format!(".{LOG_SUFFIX}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn china_log_timestamp_should_use_china_offset() { + let now = "2026-06-24T16:36:59.190910486Z" + .parse::>() + .unwrap(); + + assert_eq!(china_log_timestamp(now), "2026-06-25T00:36:59.190+08:00"); + } + + #[test] + fn log_file_name_should_use_china_date() { + assert_eq!(log_file_name("2026-06-25"), "codex-proxy-rs.2026-06-25.log"); + } + + #[test] + fn cleanup_old_logs_should_keep_recent_china_dates() { + let dir = tempfile::tempdir().unwrap(); + for date in ["2026-06-23", "2026-06-24", "2026-06-25"] { + fs::write(dir.path().join(log_file_name(date)), b"log").unwrap(); + } + + cleanup_old_logs(dir.path(), 2).unwrap(); + + assert!(!dir.path().join(log_file_name("2026-06-23")).exists()); + assert!(dir.path().join(log_file_name("2026-06-24")).exists()); + assert!(dir.path().join(log_file_name("2026-06-25")).exists()); + } +} diff --git a/src/infra/time.rs b/src/infra/time.rs index 9d7c3fb66..9b1c3a05c 100644 --- a/src/infra/time.rs +++ b/src/infra/time.rs @@ -1,6 +1,6 @@ //! 时间格式化辅助。 -use chrono::{DateTime, FixedOffset, Utc}; +use chrono::{DateTime, FixedOffset, SecondsFormat, TimeZone, Timelike, Utc}; use serde::Serializer; const CHINA_OFFSET_SECONDS: i32 = 8 * 60 * 60; @@ -10,6 +10,13 @@ pub fn china_rfc3339(value: &DateTime) -> String { value.with_timezone(&china_offset()).to_rfc3339() } +/// 将 UTC 时间输出为中国时区毫秒精度 RFC3339 字符串。 +pub fn china_rfc3339_millis(value: &DateTime) -> String { + value + .with_timezone(&china_offset()) + .to_rfc3339_opts(SecondsFormat::Millis, false) +} + /// 将 RFC3339 字符串输出为中国时区 RFC3339 字符串。 pub fn china_rfc3339_str(value: &str) -> String { DateTime::parse_from_rfc3339(value) @@ -53,6 +60,67 @@ pub fn china_time(value: &DateTime) -> String { .to_string() } +/// 将 UTC 时间输出为适合文件名的中国时区时间戳。 +pub fn china_filename_timestamp_millis(value: &DateTime) -> String { + value + .with_timezone(&china_offset()) + .format("%Y%m%dT%H%M%S%.3f%z") + .to_string() +} + +/// 返回 UTC 时间所在中国自然日的起点。 +pub fn china_day_start(value: DateTime) -> DateTime { + let offset = china_offset(); + let local = value.with_timezone(&offset); + offset + .from_local_datetime( + &local + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("valid midnight"), + ) + .single() + .expect("fixed offset has a single local datetime") + .with_timezone(&Utc) +} + +/// 返回 UTC 时间所在中国小时的起点。 +pub fn china_hour_start(value: DateTime) -> DateTime { + let offset = china_offset(); + let local = value.with_timezone(&offset); + offset + .from_local_datetime( + &local + .date_naive() + .and_hms_opt(local.hour(), 0, 0) + .expect("valid hour"), + ) + .single() + .expect("fixed offset has a single local datetime") + .with_timezone(&Utc) +} + +/// 返回 UTC 时间所在中国 15 分钟时间槽的起点。 +pub fn china_quarter_hour_start(value: DateTime) -> DateTime { + let offset = china_offset(); + let local = value.with_timezone(&offset); + offset + .from_local_datetime( + &local + .date_naive() + .and_hms_opt(local.hour(), local.minute() / 15 * 15, 0) + .expect("valid quarter hour"), + ) + .single() + .expect("fixed offset has a single local datetime") + .with_timezone(&Utc) +} + +/// 返回 UTC 时间对应的中国小时。 +pub fn china_hour(value: &DateTime) -> u32 { + value.with_timezone(&china_offset()).hour() +} + /// 将 UTC 时间输出为相对时间,超过 7 天时返回中国时区日期。 pub fn china_relative_time(value: Option>, now: DateTime) -> String { let Some(value) = value else { diff --git a/src/upstream/accounts/store.rs b/src/upstream/accounts/store.rs index 3ee3c22fb..cfb6945fe 100644 --- a/src/upstream/accounts/store.rs +++ b/src/upstream/accounts/store.rs @@ -1463,13 +1463,15 @@ impl SqliteAccountStore { account_id: &str, label: Option, ) -> Result { - sqlx::query("UPDATE accounts SET label = ?1, updated_at = datetime('now') WHERE id = ?2") + let updated_at = Utc::now().to_rfc3339(); + let result = sqlx::query("UPDATE accounts SET label = ?1, updated_at = ?2 WHERE id = ?3") .bind(&label) + .bind(updated_at) .bind(account_id) .execute(&self.pool) .await .map_err(SqliteAccountStoreError::Database)?; - Ok(true) + Ok(result.rows_affected() > 0) } /// 按 ID 删除账号。 diff --git a/src/upstream/transport/websocket.rs b/src/upstream/transport/websocket.rs index c6f940dc7..005a4a55b 100644 --- a/src/upstream/transport/websocket.rs +++ b/src/upstream/transport/websocket.rs @@ -24,6 +24,7 @@ use tungstenite::{ Message, }; +use crate::infra::time::china_filename_timestamp_millis; use crate::upstream::protocol::events::{self, TokenUsage}; use crate::upstream::protocol::responses::CodexResponsesRequest; use crate::upstream::protocol::sse::SseError; @@ -1104,6 +1105,6 @@ fn is_sensitive_opening_header(name: &str) -> bool { } fn websocket_audit_file_name() -> String { - let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); + let timestamp = china_filename_timestamp_millis(&Utc::now()); format!("codex-ws-audit-{timestamp}-{}.json", Uuid::new_v4()) } diff --git a/tests/admin/monitoring/dashboard.rs b/tests/admin/monitoring/dashboard.rs index ecb11bd63..044f1ad08 100644 --- a/tests/admin/monitoring/dashboard.rs +++ b/tests/admin/monitoring/dashboard.rs @@ -12,7 +12,9 @@ use codex_proxy_rs::{ events::{EventLevel, EventLog}, }, }, - infra::{crypto::SecretBox, database::connect_sqlite, identity::ApiKeyHasher}, + infra::{ + crypto::SecretBox, database::connect_sqlite, identity::ApiKeyHasher, time::china_day_start, + }, proxy::dispatch::session_affinity::SqliteSessionAffinityStore, runtime::{ services::{BackgroundTaskStores, Services}, @@ -68,16 +70,24 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_event ) .await; let now = Utc::now(); - let mut first = usage_log_with_tokens(now, 10); + let today_start = china_day_start(now); + let today_log_at = if now - today_start > Duration::minutes(1) { + today_start + Duration::minutes(1) + } else { + now - Duration::seconds(1) + }; + let yesterday_log_at = today_start - Duration::minutes(1); + + let mut first = usage_log_with_tokens(today_log_at, 10); first.latency_ms = Some(1_000); first.metadata["firstTokenMs"] = json!(200u64); - let mut second = usage_log_with_tokens(now, 20); + let mut second = usage_log_with_tokens(today_log_at, 20); second.latency_ms = Some(2_000); second.metadata["firstTokenMs"] = json!(400u64); store.append(&first).await.unwrap(); store.append(&second).await.unwrap(); store - .append(&usage_log_with_tokens(now - Duration::days(2), 30)) + .append(&usage_log_with_tokens(yesterday_log_at, 30)) .await .unwrap(); @@ -85,6 +95,10 @@ async fn dashboard_summary_should_calculate_today_traffic_and_latency_from_event assert_eq!(body["data"]["cards"]["traffic"]["rpm"], 2); assert_eq!(body["data"]["cards"]["traffic"]["tpm"], 30); + assert_eq!(body["data"]["cards"]["traffic"]["todayRequests"], 2); + assert_eq!(body["data"]["cards"]["traffic"]["yesterdayRequests"], 1); + assert_eq!(body["data"]["cards"]["tokens"]["todayTokens"], 30); + assert_eq!(body["data"]["cards"]["tokens"]["yesterdayTokens"], 30); assert_eq!( body["data"]["cards"]["cache"]["firstTokenLatencyMs"] .as_u64() diff --git a/tests/infra/log_rotation/mod.rs b/tests/infra/log_rotation/mod.rs index afd57a27b..89a4982c8 100644 --- a/tests/infra/log_rotation/mod.rs +++ b/tests/infra/log_rotation/mod.rs @@ -1,6 +1,10 @@ use std::{fs, io::Write}; -use codex_proxy_rs::infra::logging::{build_file_appender, RotationConfig}; +use chrono::Utc; +use codex_proxy_rs::infra::{ + logging::{build_file_appender, RotationConfig}, + time::china_date, +}; #[test] fn rolling_appender_should_write_daily_codex_log_file() { @@ -16,11 +20,10 @@ fn rolling_appender_should_write_daily_codex_log_file() { .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) .collect::>(); + let expected = format!("codex-proxy-rs.{}.log", china_date(&Utc::now())); assert!( - names - .iter() - .any(|name| name.starts_with("codex-proxy-rs.") && name.ends_with(".log")), - "expected a daily codex log file, found {names:?}" + names.iter().any(|name| name == &expected), + "expected China-date codex log file {expected}, found {names:?}" ); } diff --git a/tests/infra/mod.rs b/tests/infra/mod.rs index 90169e6a7..1a12e5984 100644 --- a/tests/infra/mod.rs +++ b/tests/infra/mod.rs @@ -1,3 +1,4 @@ pub mod crypto; pub mod log_rotation; pub mod storage_schema; +pub mod time; diff --git a/tests/infra/time.rs b/tests/infra/time.rs new file mode 100644 index 000000000..237d86c26 --- /dev/null +++ b/tests/infra/time.rs @@ -0,0 +1,60 @@ +use chrono::{DateTime, Utc}; +use codex_proxy_rs::infra::time::{ + china_day_start, china_filename_timestamp_millis, china_hour, china_hour_start, + china_quarter_hour_start, china_rfc3339_millis, +}; + +#[test] +fn china_day_start_should_use_china_calendar_day() { + let value = "2026-06-24T18:30:00Z".parse::>().unwrap(); + + assert_eq!( + china_day_start(value).to_rfc3339(), + "2026-06-24T16:00:00+00:00" + ); +} + +#[test] +fn china_rfc3339_millis_should_keep_china_offset() { + let value = "2026-06-24T16:36:59.190910486Z" + .parse::>() + .unwrap(); + + assert_eq!( + china_rfc3339_millis(&value), + "2026-06-25T00:36:59.190+08:00" + ); +} + +#[test] +fn china_filename_timestamp_millis_should_keep_china_offset_without_colons() { + let value = "2026-06-24T16:36:59.190910486Z" + .parse::>() + .unwrap(); + + assert_eq!( + china_filename_timestamp_millis(&value), + "20260625T003659.190+0800" + ); +} + +#[test] +fn china_hour_start_should_use_china_hour() { + let value = "2026-06-24T18:30:00Z".parse::>().unwrap(); + + assert_eq!( + china_hour_start(value).to_rfc3339(), + "2026-06-24T18:00:00+00:00" + ); + assert_eq!(china_hour(&value), 2); +} + +#[test] +fn china_quarter_hour_start_should_use_china_quarter_hour() { + let value = "2026-06-24T18:37:00Z".parse::>().unwrap(); + + assert_eq!( + china_quarter_hour_start(value).to_rfc3339(), + "2026-06-24T18:30:00+00:00" + ); +} diff --git a/tests/upstream/accounts/account_repository.rs b/tests/upstream/accounts/account_repository.rs index 17880b1d4..b036894c2 100644 --- a/tests/upstream/accounts/account_repository.rs +++ b/tests/upstream/accounts/account_repository.rs @@ -1,4 +1,4 @@ -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use codex_proxy_rs::{ infra::{crypto::SecretBox, database::connect_sqlite}, upstream::accounts::{ @@ -146,10 +146,16 @@ async fn account_repository_should_update_status_and_label_without_rewriting_tok .fetch_one(&pool) .await .unwrap(); + let updated_at: (String,) = sqlx::query_as("select updated_at from accounts where id = ?") + .bind("acct_a") + .fetch_one(&pool) + .await + .unwrap(); let loaded = repo.get("acct_a").await.unwrap().unwrap(); assert_eq!(after.0, before.0); assert_eq!(loaded.status, AccountStatus::Disabled); assert_eq!(loaded.label.as_deref(), Some("work")); + assert!(updated_at.0.parse::>().is_ok()); } #[tokio::test] diff --git a/tests/upstream/transport/websocket.rs b/tests/upstream/transport/websocket.rs index dd2e71770..49accec4e 100644 --- a/tests/upstream/transport/websocket.rs +++ b/tests/upstream/transport/websocket.rs @@ -30,6 +30,14 @@ async fn websocket_audit_artifact_should_require_explicit_directory() { .await .expect("enabled audit should write") .expect("enabled audit path"); + let file_name = written + .file_name() + .and_then(|value| value.to_str()) + .expect("audit file name"); + assert!( + file_name.contains("+0800"), + "expected China-time audit file name, got {file_name}" + ); let body = std::fs::read_to_string(&written).expect("audit file"); let json = serde_json::from_str::(&body).expect("audit json");