diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c8f11ef2f..49e2bedbf 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1731,6 +1731,7 @@ dependencies = [ "async-trait", "base64 0.23.1", "bytes", + "chrono", "chrono-tz", "futures", "futures-timer", @@ -1779,6 +1780,7 @@ dependencies = [ "serde", "serde_ignored", "serde_json", + "serde_path_to_error", "sha2 0.11.0", "tar", "tempfile", diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 9f5a4dcd1..84f86d686 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -104,6 +104,8 @@ async fn launch( openai, } = config; + let timezone = host.timezone; + let store = store.with_timezone(timezone); let plugin_cache = host.runtime_data_dir().join("plugins"); let host = if command.is_some() { gateway_host::initialize_command_line(host).await? @@ -141,7 +143,8 @@ async fn launch( }); } let provider_ports = store.provider_ports(); - let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?; + let mut openai = + provider_openai::initialize(openai.with_timezone(timezone), provider_ports.clone()).await?; host.report_startup_ready("OpenAI Provider"); let mut xai = provider_xai::initialize(provider_ports).await?; host.report_startup_ready("xAI Provider"); @@ -294,6 +297,7 @@ async fn launch( client, store.admin_ports(), gateway_admin::AdminRuntimePorts { + timezone, service_middleware: { let snapshots = core.snapshots(); let middleware = plugin_runtime.middleware_registry(); diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 5f0bc0bee..6fd714153 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -374,7 +374,15 @@ fn config_loader_should_report_startup_configuration_diagnostics() { gateway_host::load_config::().expect("startup configuration"); return; } - for case in ["normal", "unused", "missing"] { + for case in [ + "normal", + "unused", + "missing", + "timezoneEmpty", + "timezoneInvalid", + "timezoneNull", + "timezoneType", + ] { let mut document = valid_config_document(); if case == "unused" { document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET); @@ -384,6 +392,14 @@ fn config_loader_should_report_startup_configuration_diagnostics() { .unwrap() .remove("port"); } + if case.starts_with("timezone") { + document["host"]["timezone"] = match case { + "timezoneEmpty" => serde_json::json!(""), + "timezoneInvalid" => serde_json::json!(UNUSED_SECRET), + "timezoneNull" => serde_json::json!(null), + _ => serde_json::json!(8), + }; + } let directory = tempfile::tempdir().unwrap(); fs::create_dir(directory.path().join("deploy")).unwrap(); fs::write( @@ -404,7 +420,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() { let stderr = String::from_utf8_lossy(&output.stderr); assert_eq!( output.status.success(), - case != "missing", + case != "missing" && !case.starts_with("timezone"), "{case}: {stderr}" ); match case { @@ -413,6 +429,9 @@ fn config_loader_should_report_startup_configuration_diagnostics() { "{stderr}" ), "missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"), + case if case.starts_with("timezone") => { + assert!(stderr.contains("host.timezone"), "{stderr}") + } _ => assert!(!stderr.contains("警告"), "{stderr}"), } assert!(!stderr.contains(UNUSED_SECRET), "{stderr}"); @@ -580,3 +599,18 @@ fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), Stri .map_err(|error| error.to_string())?; Ok((config, directory)) } + +#[test] +fn deployment_timezone_defaults_and_valid_override_are_loaded() { + let mut document = valid_config_document(); + document["host"].as_object_mut().unwrap().remove("timezone"); + let (_config, _directory) = parse_config(&document.to_string()).unwrap(); + let host: gateway_host::config::HostConfig = + serde_json::from_value(document["host"].clone()).unwrap(); + assert_eq!(host.timezone.name(), "Asia/Shanghai"); + document["host"]["timezone"] = serde_json::json!("Asia/Kathmandu"); + let (_config, _directory) = parse_config(&document.to_string()).unwrap(); + let host: gateway_host::config::HostConfig = + serde_json::from_value(document["host"].clone()).unwrap(); + assert_eq!(host.timezone.name(), "Asia/Kathmandu"); +} diff --git a/backend/crates/gateway-admin/src/backup/policy.rs b/backend/crates/gateway-admin/src/backup/policy.rs index a1c2d248f..39e9fc3aa 100644 --- a/backend/crates/gateway-admin/src/backup/policy.rs +++ b/backend/crates/gateway-admin/src/backup/policy.rs @@ -21,20 +21,23 @@ pub struct BackupSchedule { } impl BackupSchedule { - /// 解析并校验 5 段 Cron 与 IANA 时区。 + /// 按部署时区解析并校验 5 段 Cron。 /// /// # Errors /// - /// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。 - pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result { + /// Cron 非法时返回 [`BackupError`]。 + pub fn parse( + cron_expression: &str, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Result { let expression = format!("0 {cron_expression}"); let schedule = Schedule::from_str(&expression).map_err(|_| { BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned()) })?; - let timezone = schedule_timezone.parse::().map_err(|_| { - BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned()) - })?; - Ok(Self { schedule, timezone }) + Ok(Self { + schedule, + timezone: timezone.iana(), + }) } /// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。 @@ -49,9 +52,7 @@ impl BackupSchedule { // 检查窗口内是否存在触发;不存在则直接返回 None。 let low_instant = instant(low_min, self.timezone)?; if self - .schedule - .after(&low_instant) - .next() + .next_after(low_instant.to_utc()) .is_none_or(|firing| firing.timestamp() > high_min * 60 + 59) { return None; @@ -79,19 +80,21 @@ impl BackupSchedule { /// 返回第一个严格晚于 `at` 的触发时间。 #[must_use] pub fn next_after(&self, at: DateTime) -> Option> { - let local = at.with_timezone(&self.timezone); - self.schedule - .after(&local) - .next() - .map(|firing| firing.with_timezone(&Utc)) + let local = at.with_timezone(&self.timezone).naive_local().and_utc(); + self.schedule.after(&local).find_map(|firing| { + let instant = self + .timezone + .from_local_datetime(&firing.naive_utc()) + .earliest()? + .to_utc(); + (instant > at).then_some(instant) + }) } /// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。 fn firing_after(&self, minute_index: i64) -> Option { - let instant = instant(minute_index, self.timezone)?; - self.schedule - .after(&instant) - .next() + let instant = instant(minute_index, self.timezone)?.to_utc(); + self.next_after(instant) .map(|firing| firing.timestamp().div_euclid(60)) } } diff --git a/backend/crates/gateway-admin/src/backup/task.rs b/backend/crates/gateway-admin/src/backup/task.rs index 10a4393bd..513410af1 100644 --- a/backend/crates/gateway-admin/src/backup/task.rs +++ b/backend/crates/gateway-admin/src/backup/task.rs @@ -44,6 +44,7 @@ const PENDING_DELETION_BATCH: u32 = 20; /// 备份 Daemon 任务。 pub struct BackupTask { + timezone: gateway_core::time::DeploymentTimeZone, repository: Arc, dump: Arc, object_store: Arc, @@ -58,11 +59,18 @@ impl BackupTask { object_store: Arc, ) -> Self { Self { + timezone: Default::default(), repository, dump, object_store, } } + + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self + } } impl DaemonTask for BackupTask { @@ -112,13 +120,29 @@ impl BackupTask { return Ok(()); } let cron = settings.cron_expression.as_deref().unwrap_or_default(); - let timezone = settings.schedule_timezone.as_deref().unwrap_or_default(); - if cron.is_empty() || timezone.is_empty() { + let timezone = self.timezone.name(); + if cron.is_empty() { return Ok(()); } - let schedule = BackupSchedule::parse(cron, timezone) + let schedule = BackupSchedule::parse(cron, self.timezone) .map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?; + if settings.schedule_timezone.as_deref() != Some(timezone) { + // 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务。 + if let Some(next_run_at) = schedule.next_after(now) { + self.repository + .advance_schedule_cursor( + next_run_at, + cron, + settings.schedule_timezone.as_deref(), + settings.next_run_at, + timezone, + ) + .await + .map_err(repo_error)?; + } + return Ok(()); + } let due = match settings.next_run_at { Some(next_run_at) if next_run_at <= now => true, Some(_) => false, @@ -127,7 +151,13 @@ impl BackupTask { if let Some(next_run_at) = schedule.next_after(now) { let _ = self .repository - .advance_schedule_cursor(next_run_at, cron, timezone) + .advance_schedule_cursor( + next_run_at, + cron, + Some(timezone), + settings.next_run_at, + timezone, + ) .await .map_err(repo_error)?; } @@ -145,7 +175,13 @@ impl BackupTask { if let Some(next_run_at) = schedule.next_after(now) { let _ = self .repository - .advance_schedule_cursor(next_run_at, cron, timezone) + .advance_schedule_cursor( + next_run_at, + cron, + Some(timezone), + settings.next_run_at, + timezone, + ) .await .map_err(repo_error)?; } @@ -168,20 +204,19 @@ impl BackupTask { .map_err(infra_error)?; let inserted = self .repository - .insert_scheduled_record(seed) + .insert_scheduled_record( + seed, + schedule.next_after(now), + cron, + timezone, + settings.next_run_at, + ) .await .map_err(repo_error)?; if inserted { info!(scheduled_at = %scheduled_at, "计划备份任务已创建"); } else { - warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标"); - } - if let Some(next_run_at) = schedule.next_after(now) { - let _ = self - .repository - .advance_schedule_cursor(next_run_at, cron, timezone) - .await - .map_err(repo_error)?; + warn!(scheduled_at = %scheduled_at, "计划已变化或存在冲突,本轮未创建任务"); } Ok(()) } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 8be6917eb..b5c065b71 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -187,6 +187,7 @@ pub enum AdminConfigError { /// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。 #[derive(Clone)] pub struct AdminServices { + timezone: gateway_core::time::DeploymentTimeZone, public_services: Arc, plugins: Arc, plugin_management: Arc, @@ -207,6 +208,11 @@ pub struct AdminServices { } impl AdminServices { + #[must_use] + pub const fn timezone(&self) -> gateway_core::time::DeploymentTimeZone { + self.timezone + } + pub fn public_services(&self) -> Arc { self.public_services.clone() } @@ -318,6 +324,7 @@ impl AdminBundle { /// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。 pub struct AdminRuntimePorts { + pub timezone: gateway_core::time::DeploymentTimeZone, pub service_middleware: service::PlanSource, pub plugin_preparation: Arc, pub plugin_management: Arc, @@ -367,6 +374,7 @@ async fn initialize_inner( plugin_accounts: Option>, ) -> Result { let AdminRuntimePorts { + timezone, service_middleware, plugin_preparation, plugin_management, @@ -413,12 +421,14 @@ async fn initialize_inner( backup_ports.object_store(), store.auth(), snapshot.clone(), + timezone, )); let backup_task = backup::task::BackupTask::new( backup_ports.repository(), backup_ports.dump(), backup_ports.object_store(), - ); + ) + .with_timezone(timezone); let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new( store.plugins(), plugin_inspector.clone(), @@ -431,6 +441,7 @@ async fn initialize_inner( store.client_keys(), store.observability(), system.clone(), + timezone, )); let credentials = Arc::new(CredentialsService::new( registry.clone(), @@ -452,6 +463,7 @@ async fn initialize_inner( let mut public_services = service::Registry::new(service_middleware); public_services.register_settings(&settings)?; let services = AdminServices { + timezone, public_services: Arc::new(public_services), plugin_management: Arc::new(PluginManagementService::new( plugin_management, @@ -492,6 +504,7 @@ async fn initialize_inner( store.accounts(), store.settings(), registry.clone(), + timezone, )), settings, system, diff --git a/backend/crates/gateway-admin/src/model/backup/code.rs b/backend/crates/gateway-admin/src/model/backup/code.rs index 38f8ec31f..58e9da224 100644 --- a/backend/crates/gateway-admin/src/model/backup/code.rs +++ b/backend/crates/gateway-admin/src/model/backup/code.rs @@ -5,7 +5,6 @@ pub const INVALID_CONFIG: &str = "backup.invalid_config"; /// Cron 表达式非法。 pub const INVALID_CRON: &str = "backup.invalid_cron"; /// IANA 时区非法。 -pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone"; /// 记录不存在。 pub const RECORD_NOT_FOUND: &str = "backup.record_not_found"; /// 已存在活跃任务,拒绝创建。 diff --git a/backend/crates/gateway-admin/src/model/backup/mod.rs b/backend/crates/gateway-admin/src/model/backup/mod.rs index ba9360c16..f40e800f1 100644 --- a/backend/crates/gateway-admin/src/model/backup/mod.rs +++ b/backend/crates/gateway-admin/src/model/backup/mod.rs @@ -183,7 +183,6 @@ impl fmt::Debug for UpdateBackupStorageCommand { pub struct UpdateBackupScheduleCommand { pub schedule_enabled: bool, pub cron_expression: String, - pub schedule_timezone: String, pub retention_days: u32, pub retention_count: u32, } @@ -203,6 +202,7 @@ pub struct BackupSettings { pub force_path_style: bool, pub schedule_enabled: bool, pub cron_expression: Option, + /// 记录持久化游标采用的时区,由服务端维护,不是独立部署设置。 pub schedule_timezone: Option, pub retention_days: u32, pub retention_count: u32, diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index bde9385e2..ee402d805 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -2,18 +2,27 @@ use std::str::FromStr; -use chrono::{DateTime, TimeDelta, Timelike as _, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use super::{AdminModelError, PageSize}; -/// 观测日界所用的东八区(UTC+8)固定偏移秒数。 -const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60; +/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析。 +#[derive(Debug, Clone, Copy)] +pub enum CalendarPeriod { + Today, + SevenDays, + ThirtyDays, +} -/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。 -#[must_use] -pub fn china_day_start(value: DateTime) -> DateTime { - let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60); - value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond())) +impl CalendarPeriod { + pub fn parse(value: &str) -> Result { + match value { + "today" => Ok(Self::Today), + "7d" => Ok(Self::SevenDays), + "30d" => Ok(Self::ThirtyDays), + _ => Err(AdminModelError::InvalidTimeRange), + } + } } /// 外部观测查询的 UTC 时间范围。 @@ -24,6 +33,25 @@ pub struct TimeRange { } impl TimeRange { + pub fn calendar_at( + period: CalendarPeriod, + end: DateTime, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Result { + let days = match period { + CalendarPeriod::Today => 0, + CalendarPeriod::SevenDays => 6, + CalendarPeriod::ThirtyDays => 29, + }; + let start = timezone + .days_before(end, days) + .ok_or(AdminModelError::InvalidTimeRange)?; + // 自然日刚开始时允许空快照,不借用前一天或伪造未来终点。 + if start == end { + return Ok(Self { start, end }); + } + Self::new(start, end) + } /// 创建最长 366 天的正时间范围。 /// /// # Errors diff --git a/backend/crates/gateway-admin/src/ports/backup.rs b/backend/crates/gateway-admin/src/ports/backup.rs index 8c524aa94..1d88c3c57 100644 --- a/backend/crates/gateway-admin/src/ports/backup.rs +++ b/backend/crates/gateway-admin/src/ports/backup.rs @@ -55,6 +55,7 @@ pub trait BackupRepository: Send + Sync { command: UpdateBackupScheduleCommand, next_run_at: Option>, context: &MutationContext, + timezone: gateway_core::time::DeploymentTimeZone, ) -> AdminStoreResult; /// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。 @@ -67,8 +68,16 @@ pub trait BackupRepository: Send + Sync { /// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。 async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult; - /// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。 - async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult; + /// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务。 + /// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`。 + async fn insert_scheduled_record( + &self, + seed: BackupRecordSeed, + next_run_at: Option>, + expected_cron: &str, + expected_timezone: &str, + expected_next_run_at: Option>, + ) -> AdminStoreResult; /// 分页查询记录。 async fn list_backup_records( @@ -111,12 +120,14 @@ pub trait BackupRepository: Send + Sync { /// 远端删除成功后硬删除记录行。 async fn delete_record(&self, id: &str) -> AdminStoreResult<()>; - /// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。 + /// 条件推进计划游标;仅当启用状态、计划表达式、时区及旧游标仍匹配时才写入,返回是否推进。 async fn advance_schedule_cursor( &self, next_run_at: DateTime, expected_cron: &str, - expected_timezone: &str, + expected_timezone: Option<&str>, + expected_next_run_at: Option>, + timezone: &str, ) -> AdminStoreResult; /// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。 @@ -286,6 +297,7 @@ impl BackupRepository for UnavailableBackupRepository { _command: UpdateBackupScheduleCommand, _next_run_at: Option>, _context: &MutationContext, + _timezone: gateway_core::time::DeploymentTimeZone, ) -> AdminStoreResult { Err(disabled_store()) } @@ -302,7 +314,14 @@ impl BackupRepository for UnavailableBackupRepository { ) -> AdminStoreResult { Err(disabled_store()) } - async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult { + async fn insert_scheduled_record( + &self, + _seed: BackupRecordSeed, + _next_run_at: Option>, + _expected_cron: &str, + _expected_timezone: &str, + _expected_next_run_at: Option>, + ) -> AdminStoreResult { Err(disabled_store()) } async fn list_backup_records( @@ -352,7 +371,9 @@ impl BackupRepository for UnavailableBackupRepository { &self, _next_run_at: DateTime, _expected_cron: &str, - _expected_timezone: &str, + _expected_timezone: Option<&str>, + _expected_next_run_at: Option>, + _timezone: &str, ) -> AdminStoreResult { Err(disabled_store()) } diff --git a/backend/crates/gateway-admin/src/use_case/backup.rs b/backend/crates/gateway-admin/src/use_case/backup.rs index 2cf7c8c69..9871ea898 100644 --- a/backend/crates/gateway-admin/src/use_case/backup.rs +++ b/backend/crates/gateway-admin/src/use_case/backup.rs @@ -76,6 +76,7 @@ pub trait BackupService: Send + Sync { } pub(crate) struct DefaultBackupService { + timezone: gateway_core::time::DeploymentTimeZone, repository: Arc, object_store: Arc, auth: Arc, @@ -89,8 +90,10 @@ impl DefaultBackupService { object_store: Arc, auth: Arc, snapshot: Arc, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Self { Self { + timezone, repository, object_store, auth, @@ -128,22 +131,20 @@ impl BackupService for DefaultBackupService { context: &MutationContext, command: UpdateBackupScheduleCommand, ) -> Result { - validate_schedule(&command)?; + let schedule = + crate::backup::policy::BackupSchedule::parse(&command.cron_expression, self.timezone) + .map_err(map_backup_error)?; let next_run_at = if command.schedule_enabled { Some( - crate::backup::policy::BackupSchedule::parse( - &command.cron_expression, - &command.schedule_timezone, - ) - .map_err(map_backup_error)? - .next_after(Utc::now()) - .ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?, + schedule + .next_after(Utc::now()) + .ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?, ) } else { None }; self.repository - .update_schedule_settings(command, next_run_at, context) + .update_schedule_settings(command, next_run_at, context, self.timezone) .await .map_err(|error| map_store_error(error, "backup schedule")) } @@ -408,23 +409,10 @@ fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminErro Ok(()) } -/// 校验调度配置并解析 Cron 与时区。 -fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> { - if command.cron_expression.trim().is_empty() { - return Err(AdminError::bad_request("cronExpression 不能为空")); - } - crate::backup::policy::BackupSchedule::parse( - &command.cron_expression, - &command.schedule_timezone, - ) - .map_err(map_backup_error)?; - Ok(()) -} - /// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。 fn map_backup_error(error: BackupError) -> AdminError { match error.code() { - code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => { + code::INVALID_CONFIG | code::INVALID_CRON => { AdminError::bad_request(error.message().to_owned()) } code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()), diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index c7253683b..33daa70da 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use async_trait::async_trait; -use chrono::Utc; use crate::{ AuthService, SystemService, @@ -15,9 +14,7 @@ use crate::{ KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords, KeyUsageRecordsQuery, }, - observability::{ - OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start, - }, + observability::{OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery}, system::SystemVersion, }, ports::store::{ClientKeyStore, ObservabilityStore}, @@ -62,6 +59,7 @@ pub trait KeyUsageService: Send + Sync { } pub(crate) struct DefaultKeyUsageService { + timezone: gateway_core::time::DeploymentTimeZone, auth: Arc, verifier: Arc, keys: Arc, @@ -76,8 +74,10 @@ impl DefaultKeyUsageService { keys: Arc, observations: Arc, system: Arc, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Self { Self { + timezone, auth, verifier, keys, @@ -181,10 +181,13 @@ impl KeyUsageService for DefaultKeyUsageService { return Ok(None); }; let filter = usage_filter(&id, query.model); - let now = Utc::now(); - // 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。 + let now = query.range.end; + // 健康条始终展示部署时区今日,不随历史范围或模型筛选改变。 let today = TimeRange { - start: china_day_start(now), + start: self + .timezone + .day_start(now) + .ok_or_else(|| AdminError::internal("时间超出支持范围"))?, end: now, }; let (overview, trend, health_points) = futures::try_join!( @@ -198,7 +201,7 @@ impl KeyUsageService for DefaultKeyUsageService { key, overview, trend, - health_timeline: health_timeline_at(&health_points, now), + health_timeline: health_timeline_at(&health_points, now, self.timezone)?, })) } diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index 30a33eb65..5ea19eacb 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -8,7 +8,7 @@ use std::{ }; use async_trait::async_trait; -use chrono::{DateTime, Duration, Timelike as _, Utc}; +use chrono::{DateTime, Duration, Utc}; use futures::TryStreamExt; use gateway_core::{account::ProviderAccountId, routing::ProviderKind}; @@ -24,7 +24,7 @@ use crate::{ UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage, - UsageQuery, UsageSummary, china_day_start, + UsageQuery, UsageSummary, }, provider_credentials::ProviderQuotaRequest, }, @@ -37,7 +37,6 @@ use crate::{ use super::{map_provider_error, map_store_error}; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; -const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4; const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10; const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3; const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0; @@ -126,6 +125,7 @@ pub trait ObservabilityService: Send + Sync { } pub(crate) struct DefaultObservabilityService { + timezone: gateway_core::time::DeploymentTimeZone, store: Arc, accounts: Arc, settings: Arc, @@ -140,8 +140,10 @@ impl DefaultObservabilityService { accounts: Arc, settings: Arc, providers: ProviderAdminRegistry, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Self { Self { + timezone, store, accounts, settings, @@ -169,8 +171,14 @@ impl DefaultObservabilityService { .providers .plan_type_display(&account.provider_kind, account.plan_type.as_deref()); } - let today_start = china_day_start(observation.range.end); - let yesterday_start = today_start - Duration::days(1); + let today_start = self + .timezone + .day_start(observation.range.end) + .ok_or_else(|| AdminError::internal("时间超出支持范围"))?; + let yesterday_start = self + .timezone + .days_before(observation.range.end, 1) + .ok_or_else(|| AdminError::internal("时间超出支持范围"))?; let today = dashboard_period_metrics(&observation.trend, today_start, observation.range.end); let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start); @@ -191,7 +199,8 @@ impl DefaultObservabilityService { let average_first_token_latency_ms = average(first_token_latency_sum_ms, first_token_latency_count); let trend = trend(TrendKind::Usage, observation.trend.clone())?; - let health_timeline = health_timeline_at(&observation.trend, Utc::now()); + let health_timeline = + health_timeline_at(&observation.trend, observation.range.end, self.timezone)?; let wire_profiles = self .providers .dashboard_wire_profiles(&settings.request_profiles); @@ -861,15 +870,28 @@ impl DefaultObservabilityService { } } -/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。 -#[must_use] +/// 按部署时区的自然日计算 15 分钟健康桶,桶数随夏令时变化。 pub(super) fn health_timeline_at( records: &[RequestMetricPoint], now: DateTime, -) -> HealthTimeline { - let current_slot = quarter_hour_start(now); - let start = china_day_start(now); - let mut buckets = (0..HEALTH_TIMELINE_SLOTS) + timezone: gateway_core::time::DeploymentTimeZone, +) -> Result { + let start = timezone + .day_start(now) + .ok_or_else(|| AdminError::internal("时间超出支持范围"))?; + let end = timezone + .days_after(now, 1) + .ok_or_else(|| AdminError::internal("时间超出支持范围"))?; + let current_slot = start + + Duration::minutes( + (now - start) + .num_minutes() + .div_euclid(HEALTH_TIMELINE_SLOT_MINUTES) + * HEALTH_TIMELINE_SLOT_MINUTES, + ); + let slots = ((end - start).num_minutes() + HEALTH_TIMELINE_SLOT_MINUTES - 1) + / HEALTH_TIMELINE_SLOT_MINUTES; + let mut buckets = (0..slots) .map(|index| { ( start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index), @@ -881,7 +903,13 @@ pub(super) fn health_timeline_at( if record.bucket_start < start || record.bucket_start > now { continue; } - let record_slot = quarter_hour_start(record.bucket_start); + let record_slot = start + + Duration::minutes( + (record.bucket_start - start) + .num_minutes() + .div_euclid(HEALTH_TIMELINE_SLOT_MINUTES) + * HEALTH_TIMELINE_SLOT_MINUTES, + ); if let Some((_, bucket)) = buckets .iter_mut() .find(|(bucket_start, _)| *bucket_start == record_slot) @@ -897,7 +925,7 @@ pub(super) fn health_timeline_at( totals.add_window(*bucket); totals }); - HealthTimeline { + Ok(HealthTimeline { reliability_percent: health_reliability(totals), status: health_status(totals, false), success_requests: totals.success_requests, @@ -918,7 +946,7 @@ pub(super) fn health_timeline_at( caller_error_requests: bucket.caller_error_requests, }) .collect(), - } + }) } fn trend(kind: TrendKind, points: Vec) -> Result { @@ -1182,10 +1210,3 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 { .failure_count .saturating_sub(metrics.caller_error_count) } - -fn quarter_hour_start(value: DateTime) -> DateTime { - let elapsed = value - .timestamp() - .rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60); - value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond())) -} diff --git a/backend/crates/gateway-admin/tests/backup/policy.rs b/backend/crates/gateway-admin/tests/backup/policy.rs index 842a511f0..2888b68e1 100644 --- a/backend/crates/gateway-admin/tests/backup/policy.rs +++ b/backend/crates/gateway-admin/tests/backup/policy.rs @@ -38,15 +38,15 @@ fn record( } #[test] -fn parse_rejects_bad_cron_and_timezone() { - assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai").is_err()); - assert!(BackupSchedule::parse("0 2 * * *", "Not/AZone").is_err()); - assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").is_ok()); +fn parse_rejects_invalid_cron() { + assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai".parse().unwrap()).is_err()); + + assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).is_ok()); } #[test] fn next_after_is_strictly_later() { - let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap(); + let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap(); let at = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap(); let next = schedule.next_after(at).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 2, 18, 0, 0).unwrap()); @@ -54,7 +54,7 @@ fn next_after_is_strictly_later() { #[test] fn last_firing_is_at_or_before() { - let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap(); + let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap(); // 北京时间 02:00 = 前一日 18:00 UTC。 let before = Utc.with_ymd_and_hms(2026, 8, 1, 17, 59, 0).unwrap(); assert_eq!( @@ -69,7 +69,8 @@ fn last_firing_is_at_or_before() { #[test] fn dst_spring_forward_skips_missing_hour() { - let schedule = BackupSchedule::parse("30 2 * * *", "America/New_York").unwrap(); + let schedule = + BackupSchedule::parse("30 2 * * *", "America/New_York".parse().unwrap()).unwrap(); let at = Utc.with_ymd_and_hms(2026, 3, 8, 6, 29, 0).unwrap(); // 01:29 EST let next = schedule.next_after(at).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap()); @@ -169,3 +170,27 @@ fn disabled_thresholds_delete_nothing() { ]; assert!(decide_retention(0, 0, now, &records).is_empty()); } + +#[test] +fn cron_skips_dst_gap_and_runs_only_first_repeated_wall_time() { + let zone = "America/New_York".parse().unwrap(); + let gap = BackupSchedule::parse("30 2 * * *", zone).unwrap(); + assert_eq!( + gap.next_after("2026-03-08T05:00:00Z".parse().unwrap()), + Some("2026-03-09T06:30:00Z".parse().unwrap()) + ); + let fold = BackupSchedule::parse("30 1 * * *", zone).unwrap(); + let first = "2026-11-01T05:30:00Z".parse().unwrap(); + assert_eq!( + fold.next_after("2026-11-01T04:00:00Z".parse().unwrap()), + Some(first) + ); + assert_eq!( + fold.next_after(first), + Some("2026-11-02T06:30:00Z".parse().unwrap()) + ); + assert_eq!( + fold.last_firing_at_or_before("2026-11-01T06:45:00Z".parse().unwrap()), + Some(first) + ); +} diff --git a/backend/crates/gateway-admin/tests/backup/support.rs b/backend/crates/gateway-admin/tests/backup/support.rs index 99b658929..5aada9459 100644 --- a/backend/crates/gateway-admin/tests/backup/support.rs +++ b/backend/crates/gateway-admin/tests/backup/support.rs @@ -166,11 +166,12 @@ impl BackupRepository for FakeBackupRepository { command: UpdateBackupScheduleCommand, next_run_at: Option>, _context: &MutationContext, + timezone: gateway_core::time::DeploymentTimeZone, ) -> AdminStoreResult { let mut settings = self.settings.lock().expect("settings"); settings.schedule_enabled = command.schedule_enabled; settings.cron_expression = Some(command.cron_expression); - settings.schedule_timezone = Some(command.schedule_timezone); + settings.schedule_timezone = Some(timezone.name().to_owned()); settings.retention_days = command.retention_days; settings.retention_count = command.retention_count; settings.next_run_at = next_run_at; @@ -205,7 +206,23 @@ impl BackupRepository for FakeBackupRepository { Ok(record) } - async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult { + async fn insert_scheduled_record( + &self, + seed: BackupRecordSeed, + next_run_at: Option>, + expected_cron: &str, + expected_timezone: &str, + expected_next_run_at: Option>, + ) -> AdminStoreResult { + let mut settings = self.settings.lock().expect("settings"); + if !settings.schedule_enabled + || settings.cron_expression.as_deref() != Some(expected_cron) + || settings.schedule_timezone.as_deref() != Some(expected_timezone) + || settings.next_run_at != expected_next_run_at + { + return Ok(false); + } + settings.next_run_at = next_run_at; let mut records = self.records.lock().expect("records"); if records.iter().any(|record| record.status.is_active()) || records.iter().any(|record| { @@ -391,11 +408,21 @@ impl BackupRepository for FakeBackupRepository { async fn advance_schedule_cursor( &self, next_run_at: DateTime, - _expected_cron: &str, - _expected_timezone: &str, + expected_cron: &str, + expected_timezone: Option<&str>, + expected_next_run_at: Option>, + timezone: &str, ) -> AdminStoreResult { let mut settings = self.settings.lock().expect("settings"); + if !settings.schedule_enabled + || settings.cron_expression.as_deref() != Some(expected_cron) + || settings.schedule_timezone.as_deref() != expected_timezone + || settings.next_run_at != expected_next_run_at + { + return Ok(false); + } settings.next_run_at = Some(next_run_at); + settings.schedule_timezone = Some(timezone.to_owned()); Ok(true) } diff --git a/backend/crates/gateway-admin/tests/backup/task.rs b/backend/crates/gateway-admin/tests/backup/task.rs index d8bd7c256..cf6d1c20a 100644 --- a/backend/crates/gateway-admin/tests/backup/task.rs +++ b/backend/crates/gateway-admin/tests/backup/task.rs @@ -183,14 +183,14 @@ async fn retention_cleans_expired_scheduled_backups() { expires_at: None, }; repository - .insert_scheduled_record(old_seed.clone()) + .insert_backup_record(old_seed.clone()) .await .expect("insert old"); // 旧备份 40 天前完成(释放活跃名额),再插入新备份。 let now = Utc::now(); repository.set_completed(&backup_id("old"), now - Duration::days(40)); repository - .insert_scheduled_record(new_seed.clone()) + .insert_backup_record(new_seed.clone()) .await .expect("insert new"); // 新备份 1 天前完成。 @@ -213,12 +213,13 @@ async fn retention_cleans_expired_scheduled_backups() { UpdateBackupScheduleCommand { schedule_enabled: false, cron_expression: "0 2 * * *".to_owned(), - schedule_timezone: "Asia/Shanghai".to_owned(), + retention_days: 30, retention_count: 0, }, None, &system_context(), + Default::default(), ) .await .expect("set retention"); @@ -235,3 +236,26 @@ async fn retention_cleans_expired_scheduled_backups() { assert!(object_store.object(&old_seed.object_key).is_none()); assert!(object_store.object(&new_seed.object_key).is_some()); } + +#[tokio::test] +async fn timezone_change_rebases_future_cursor_without_running_old_due_backup() { + let mut settings = configured_settings(); + settings.schedule_enabled = true; + settings.cron_expression = Some("0 2 * * *".to_owned()); + settings.schedule_timezone = Some("Asia/Shanghai".to_owned()); + settings.next_run_at = Some(Utc::now() - Duration::days(1)); + let repository = Arc::new(FakeBackupRepository::new(settings)); + let task = BackupTask::new( + repository.clone(), + Arc::new(FakeDumpPort::new()), + Arc::new(FakeObjectStore::new()), + ) + .with_timezone("UTC".parse().unwrap()); + task.run_cycle(&CancellationToken::new()).await.unwrap(); + let settings = repository.load_settings().await.unwrap(); + assert_eq!(settings.schedule_timezone.as_deref(), Some("UTC")); + assert!(settings.next_run_at.unwrap() > Utc::now()); + assert!(repository.all_records().is_empty()); + task.run_cycle(&CancellationToken::new()).await.unwrap(); + assert!(repository.all_records().is_empty()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index bd28ac5f0..6d788a969 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -268,6 +268,7 @@ impl AdminHarness { Arc::new(plugins::TestPluginPorts), ), gateway_admin::AdminRuntimePorts { + timezone: Default::default(), service_middleware: self.service_middleware, plugin_preparation: Arc::new(plugins::TestPluginPorts), plugin_management: Arc::new(plugins::TestPluginPorts), diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 3eda928b9..960f60585 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -20,7 +20,7 @@ use gateway_admin::{ DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter, - UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start, + UsageListRecord, UsageOverview, UsagePage, UsageQuery, }, settings::{ AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy, @@ -49,7 +49,9 @@ fn external_observability_range_rejects_over_366_days_and_reversed_range() { #[tokio::test] async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() { let now = Utc::now(); - let day_start = china_day_start(now); + let day_start = gateway_core::time::DeploymentTimeZone::default() + .day_start(now) + .unwrap(); let current_slot = quarter_hour_start(now); let store = Arc::new(FixtureObservabilityStore::new(observation_range(now))); store.replace_trend(vec![ @@ -300,9 +302,20 @@ async fn dashboard_summary_should_coalesce_concurrent_requests_in_one_short_wind let end_bucket = Utc::now().timestamp().div_euclid(2) * 2; let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end"); let second_end = DateTime::from_timestamp(end_bucket, 900_000_000).expect("second end"); - let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range"); - let second_range = - TimeRange::new(china_day_start(second_end), second_end).expect("second range"); + let first_range = TimeRange::new( + gateway_core::time::DeploymentTimeZone::default() + .day_start(first_end) + .unwrap(), + first_end, + ) + .expect("first range"); + let second_range = TimeRange::new( + gateway_core::time::DeploymentTimeZone::default() + .day_start(second_end) + .unwrap(), + second_end, + ) + .expect("second range"); let store = Arc::new(FixtureObservabilityStore::new(first_range)); store.set_dashboard_delay(StdDuration::from_millis(20)); let services = observability_services(store.clone()).await; @@ -323,9 +336,20 @@ async fn dashboard_summary_should_reload_after_the_short_window_changes() { let end_bucket = Utc::now().timestamp().div_euclid(2) * 2; let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end"); let second_end = first_end + Duration::seconds(2); - let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range"); - let second_range = - TimeRange::new(china_day_start(second_end), second_end).expect("second range"); + let first_range = TimeRange::new( + gateway_core::time::DeploymentTimeZone::default() + .day_start(first_end) + .unwrap(), + first_end, + ) + .expect("first range"); + let second_range = TimeRange::new( + gateway_core::time::DeploymentTimeZone::default() + .day_start(second_end) + .unwrap(), + second_end, + ) + .expect("second range"); let store = Arc::new(FixtureObservabilityStore::new(first_range)); let services = observability_services(store.clone()).await; let observability = services.observability(); diff --git a/backend/crates/gateway-api/src/admin/account_groups.rs b/backend/crates/gateway-api/src/admin/account_groups.rs index 2bd6cb2d2..2522fb6a5 100644 --- a/backend/crates/gateway-api/src/admin/account_groups.rs +++ b/backend/crates/gateway-api/src/admin/account_groups.rs @@ -111,7 +111,9 @@ struct AccountGroupView { capacity: AccountGroupCapacityView, usage: AccountGroupUsageView, created_at: DateTime, + created_at_display: String, updated_at: DateTime, + updated_at_display: String, } #[derive(Debug, Clone, Serialize)] @@ -136,8 +138,8 @@ struct AccountGroupUsageView { retained_total_usd: String, } -impl From for AccountGroupView { - fn from(record: AccountGroupRecord) -> Self { +impl From<(AccountGroupRecord, crate::time::TimePresenter)> for AccountGroupView { + fn from((record, time): (AccountGroupRecord, crate::time::TimePresenter)) -> Self { Self { id: record.id.to_string(), name: record.name, @@ -151,7 +153,9 @@ impl From for AccountGroupView { account_summary: account_summary_view(record.account_summary), capacity: capacity_view(record.capacity), usage: usage_view(record.usage), + created_at_display: time.datetime(&record.created_at), created_at: record.created_at, + updated_at_display: time.datetime(&record.updated_at), updated_at: record.updated_at, } } @@ -187,15 +191,19 @@ struct AccountGroupPageData { config_revision: u64, } -impl From for AccountGroupPageData { - fn from(page: AccountGroupPage) -> Self { +impl From<(AccountGroupPage, crate::time::TimePresenter)> for AccountGroupPageData { + fn from((page, time): (AccountGroupPage, crate::time::TimePresenter)) -> Self { let total_pages = if page.total == 0 { 0 } else { page.total.div_ceil(u64::from(page.page_size)) }; Self { - items: page.items.into_iter().map(Into::into).collect(), + items: page + .items + .into_iter() + .map(|value| AccountGroupView::from((value, time))) + .collect(), page: PageMeta::new( page.page, u32::from(page.page_size), @@ -215,11 +223,13 @@ struct AccountGroupMutationData { config_revision: u64, } -impl From for AccountGroupMutationData { - fn from(mutation: AccountGroupMutation) -> Self { +impl From<(AccountGroupMutation, crate::time::TimePresenter)> for AccountGroupMutationData { + fn from((mutation, time): (AccountGroupMutation, crate::time::TimePresenter)) -> Self { Self { id: mutation.id.to_string(), - record: mutation.record.map(Into::into), + record: mutation + .record + .map(|value| AccountGroupView::from((value, time))), config_revision: mutation.config_revision.get(), } } @@ -247,6 +257,7 @@ async fn list( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let result = state .admin_services() .account_groups() @@ -255,7 +266,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountGroupPageData::from(result)), + AdminEnvelope::ok(AccountGroupPageData::from((result, time))), )) } @@ -283,6 +294,7 @@ where }, ) .await, + crate::time::TimePresenter::new(state.admin_services().timezone()), ) } @@ -311,6 +323,7 @@ where }, ) .await, + crate::time::TimePresenter::new(state.admin_services().timezone()), ) } @@ -358,6 +371,7 @@ where }, ) .await, + crate::time::TimePresenter::new(state.admin_services().timezone()), ) } @@ -381,17 +395,19 @@ where }, ) .await, + crate::time::TimePresenter::new(state.admin_services().timezone()), ) } fn mutation_response( status: StatusCode, result: Result, + time: crate::time::TimePresenter, ) -> Result>, AdminError> { let result = result.map_err(map_service_error)?; Ok(AdminResponse::new( status, - AdminEnvelope::ok(AccountGroupMutationData::from(result)), + AdminEnvelope::ok(AccountGroupMutationData::from((result, time))), )) } diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 0c0a546fb..37ef01af2 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -96,6 +96,7 @@ async fn list_accounts( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let command = query.validate().map_err(map_wire_error)?; let page = command.page; let page_size = command.page_size.get(); @@ -105,7 +106,7 @@ where .list(command) .await .map_err(map_service_error)?; - let data = account_page_data(result, page, page_size, Utc::now()); + let data = account_page_data(result, page, page_size, Utc::now(), time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -117,6 +118,7 @@ async fn account_detail( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = query.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -131,7 +133,7 @@ where .await .map_err(map_service_error)?; let data = AccountDetailData { - account: account_view(result, Utc::now()), + account: account_view(result, Utc::now(), time), credential_configuration: configuration.map(provider_document_value), }; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) @@ -145,6 +147,7 @@ async fn export_accounts( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let ids = query.into_ids().map_err(map_wire_error)?; let result = state .admin_services() @@ -152,7 +155,7 @@ where .export(&auth.context().mutation_context(), ids) .await .map_err(map_service_error)?; - let data = AccountExportData::from_result(result); + let data = AccountExportData::from_result(result, time); let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response(); response .headers_mut() @@ -313,6 +316,7 @@ async fn refresh_account( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = request.into_command().map_err(map_wire_error)?; let result = state .admin_services() @@ -320,7 +324,7 @@ where .refresh(&auth.context().mutation_context(), account_id) .await .map_err(map_service_error)?; - let data = account_refresh_data(result, Utc::now()); + let data = account_refresh_data(result, Utc::now(), time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -332,6 +336,7 @@ async fn recover_account( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = request.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -339,7 +344,7 @@ where .recover(&auth.context().mutation_context(), account_id) .await .map_err(map_service_error)?; - let data = account_refresh_data(result, Utc::now()); + let data = account_refresh_data(result, Utc::now(), time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -351,6 +356,7 @@ async fn account_quota( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = query.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -359,7 +365,7 @@ where .await .map_err(map_service_error)?; let data = AccountQuotaData { - account: account_view(result, Utc::now()), + account: account_view(result, Utc::now(), time), }; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -381,7 +387,10 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountQuotaForecastData::from(result)), + AdminEnvelope::ok(AccountQuotaForecastData::from(( + result, + crate::time::TimePresenter::new(state.admin_services().timezone()), + ))), )) } @@ -393,6 +402,7 @@ async fn account_personal_info( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = query.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -402,7 +412,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountPersonalInfoData::from(result)), + AdminEnvelope::ok(AccountPersonalInfoData::from((result, time))), )) } @@ -477,6 +487,7 @@ async fn refresh_account_quota( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = request.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -485,7 +496,7 @@ where .await .map_err(map_service_error)?; let data = AccountQuotaData { - account: account_view(result, Utc::now()), + account: account_view(result, Utc::now(), time), }; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -498,6 +509,7 @@ async fn account_reset_credits( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let account_id = query.into_id().map_err(map_wire_error)?; let result = state .admin_services() @@ -507,7 +519,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountResetCreditsData::from(result)), + AdminEnvelope::ok(AccountResetCreditsData::from((result, time))), )) } @@ -519,6 +531,7 @@ async fn consume_account_reset_credit( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let command = request.into_command().map_err(map_wire_error)?; let result = state .admin_services() @@ -528,7 +541,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountResetCreditResultData::from(result)), + AdminEnvelope::ok(AccountResetCreditResultData::from((result, time))), )) } @@ -597,6 +610,7 @@ async fn test_account_connection( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?; let stream = state .admin_services() @@ -604,8 +618,8 @@ where .test_connection(account_id, upstream_model) .await .map_err(map_service_error)? - .map(|event| { - let event = AccountConnectionTestEvent::from(event); + .map(move |event| { + let event = AccountConnectionTestEvent::from((event, time)); let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned()); Ok(Event::default().data(data)) }); diff --git a/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs b/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs index 8a8257b76..a08609191 100644 --- a/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs +++ b/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs @@ -41,7 +41,9 @@ struct TaskId { struct SummaryView { task_id: String, created_at: String, + created_at_display: String, finished_at: Option, + finished_at_display: Option, stop_requested: bool, total: usize, counts: CountsView, @@ -59,12 +61,16 @@ struct CountsView { imported_accounts: usize, } -impl From for SummaryView { - fn from(task: ImportTaskSummary) -> Self { +impl From<(ImportTaskSummary, crate::time::TimePresenter)> for SummaryView { + fn from((task, time): (ImportTaskSummary, crate::time::TimePresenter)) -> Self { let counts = task.counts; Self { task_id: task.task_id.to_string(), + created_at_display: time.label(task.created_at, "%m-%d %H:%M:%S"), created_at: task.created_at.to_rfc3339(), + finished_at_display: task + .finished_at + .map(|value| time.label(value, "%m-%d %H:%M:%S")), finished_at: task.finished_at.map(|at| at.to_rfc3339()), stop_requested: task.stop_requested, total: task.total, @@ -98,10 +104,10 @@ struct ItemView { message: Option, } -impl From for DetailView { - fn from(task: ImportTaskDetail) -> Self { +impl From<(ImportTaskDetail, crate::time::TimePresenter)> for DetailView { + fn from((task, time): (ImportTaskDetail, crate::time::TimePresenter)) -> Self { Self { - summary: task.summary.into(), + summary: SummaryView::from((task.summary, time)), items: task .items .into_iter() @@ -134,6 +140,7 @@ async fn submit( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let submission_id = Uuid::parse_str(&request.submission_id) .map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?; if submission_id.is_nil() { @@ -168,7 +175,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::ACCEPTED, - AdminEnvelope::ok(SummaryView::from(task)), + AdminEnvelope::ok(SummaryView::from((task, time))), )) } @@ -176,12 +183,13 @@ async fn list(auth: AdminAuth, State(state): State) -> impl IntoResponse where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let items = state .admin_services() .import_tasks() .list(&auth.context().mutation_context()) .into_iter() - .map(SummaryView::from) + .map(|value| SummaryView::from((value, time))) .collect(); AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items })) } @@ -194,6 +202,7 @@ async fn detail( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let task = state .admin_services() .import_tasks() @@ -204,7 +213,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(DetailView::from(task)), + AdminEnvelope::ok(DetailView::from((task, time))), )) } @@ -216,6 +225,7 @@ async fn stop( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let task = state .admin_services() .import_tasks() @@ -226,7 +236,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(DetailView::from(task)), + AdminEnvelope::ok(DetailView::from((task, time))), )) } diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index 8fc3cdd01..9f933c7c6 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -14,7 +14,7 @@ use axum::{ routing::{get, post}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, FixedOffset, Utc}; +use chrono::{DateTime, Utc}; use futures::{Stream, StreamExt as _}; use gateway_admin::model::{ AdminError as AdminServiceError, PageSize, diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 752a3852c..c734865f5 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -8,6 +8,7 @@ pub(super) fn account_page_data( page: u32, page_size: u16, now: DateTime, + time: crate::time::TimePresenter, ) -> AccountPageData { let total_pages = if result.total == 0 { 0 @@ -18,7 +19,7 @@ pub(super) fn account_page_data( items: result .items .into_iter() - .map(|item| account_view(item, now)) + .map(|item| account_view(item, now, time)) .collect(), page: PageMeta::new(page, u32::from(page_size), result.total, total_pages), summary: AccountSummaryView { @@ -35,9 +36,10 @@ pub(super) fn account_page_data( pub(super) fn account_refresh_data( result: AccountRefreshResult, now: DateTime, + time: crate::time::TimePresenter, ) -> AccountRefreshData { AccountRefreshData { - account: account_view(result.account, now), + account: account_view(result.account, now, time), } } @@ -57,7 +59,11 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData { } } -pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> AccountView { +pub(super) fn account_view( + item: AccountDirectoryItem, + now: DateTime, + time: crate::time::TimePresenter, +) -> AccountView { let AccountDirectoryItem { account, capacity, @@ -69,15 +75,18 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac } = item; let status = projection.status.as_str().to_owned(); let cooldown = projection.cooldown; - let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339); - let added_at = china_rfc3339(&account.created_at); - let updated_at = china_rfc3339(&account.updated_at); + let expires_at = account + .access_token_expires_at + .as_ref() + .map(|value| time.rfc3339(value)); + let added_at = time.rfc3339(&account.created_at); + let updated_at = time.rfc3339(&account.updated_at); let usage_period = quota.usage_window().map(|(_, period)| period); - let mut usage = account_usage_view(usage, usage_period, now); + let mut usage = account_usage_view(usage, usage_period, now, time); if account.authentication_kind == "api_key" { usage.window_label_display = "通用额度".to_owned(); } - let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now); + let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time); AccountView { capabilities: capabilities.into(), id: account.id.clone(), @@ -124,33 +133,37 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac access_token_expires_at_display: account .access_token_expires_at .as_ref() - .map(china_datetime), + .map(|value| time.datetime(value)), refresh_token_expires_at, - next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)), + next_refresh_at: account.next_refresh_at.map(|value| time.rfc3339(&value)), + next_refresh_at_display: account.next_refresh_at.map(|value| time.datetime(&value)), added_at, - added_at_display: china_datetime(&account.created_at), + added_at_display: time.datetime(&account.created_at), updated_at, - updated_at_display: china_datetime(&account.updated_at), + updated_at_display: time.datetime(&account.updated_at), quota, usage, } } -impl From for AccountQuotaForecastData { - fn from(report: AccountQuotaForecastReport) -> Self { +impl From<(AccountQuotaForecastReport, crate::time::TimePresenter)> for AccountQuotaForecastData { + fn from((report, time): (AccountQuotaForecastReport, crate::time::TimePresenter)) -> Self { Self { account_id: report.account_id, - generated_at: china_rfc3339(&report.generated_at), + generated_at: time.rfc3339(&report.generated_at), forecasts: report .forecasts .into_iter() - .map(quota_forecast_view) + .map(|value| quota_forecast_view(value, time)) .collect(), } } } -fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastView { +fn quota_forecast_view( + forecast: AccountQuotaForecast, + time: crate::time::TimePresenter, +) -> AccountQuotaForecastView { AccountQuotaForecastView { period: match forecast.period { AccountUsagePeriod::Weekly => "weekly", @@ -164,11 +177,11 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi used_percent_display: source .used_percent .map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")), - observed_at: source.observed_at.map(|value| china_rfc3339(&value)), + observed_at: source.observed_at.map(|value| time.rfc3339(&value)), observed_at_display: source .observed_at - .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), - reset_at: china_rfc3339(&source.reset_at), + .map_or_else(|| "—".to_owned(), |value| time.datetime(&value)), + reset_at: time.rfc3339(&source.reset_at), tokens_display: display_optional_tokens(source.tokens), usd_display: forecast_usd_display(source.usd), }), @@ -194,16 +207,34 @@ pub(super) fn account_quota_view( mut quota: ProviderQuota, cooldown: Option, now: DateTime, + time: crate::time::TimePresenter, ) -> (AccountQuotaView, Option) { quota.apply_limit_reached_display(); let refresh_token_expires_at = quota .refresh_token_expires_at - .map(|value| china_rfc3339(&value)); + .map(|value| time.rfc3339(&value)); let refreshed_at_display = quota .observed_at - .map_or_else(|| "—".to_owned(), |value| relative_time(value, now)); - let windows = quota.windows.into_iter().map(quota_window_view).collect(); - let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into())); + .map_or_else(|| "—".to_owned(), |value| time.relative(value, now)); + let windows = quota + .windows + .into_iter() + .map(|value| quota_window_view(value, time)) + .collect(); + let rate_limited_until = cooldown.map(|value| time.rfc3339(&value.until.into())); + let rate_limit_recovery_display = cooldown.map(|value| { + let until = DateTime::::from(value.until); + if value.kind.requires_probe() && until <= now { + return "等待探测成功".to_owned(); + } + let minutes = (until - now).num_seconds().saturating_add(30) / 60; + match minutes { + ..1 => time.datetime(&until), + 1..60 => format!("剩余 {minutes} 分钟"), + _ if minutes % 60 == 0 => format!("剩余 {} 小时", minutes / 60), + _ => format!("剩余 {} 小时 {} 分", minutes / 60, minutes % 60), + } + }); let rate_limit_reason = cooldown.map(|value| { if value.kind.is_capacity_freeze() { "capacity_freeze".to_owned() @@ -217,6 +248,7 @@ pub(super) fn account_quota_view( refreshed_at_display, limit_reached: quota.limit_reached, rate_limited_until, + rate_limit_recovery_display, rate_limit_reason, recovery_probe_required, windows, @@ -230,7 +262,10 @@ pub(super) fn account_quota_view( ) } -pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView { +pub(crate) fn quota_window_view( + window: ProviderQuotaWindow, + time: crate::time::TimePresenter, +) -> AccountQuotaWindowView { let ProviderQuotaWindow { key, group, @@ -262,12 +297,14 @@ pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWind used_percent_display: used_percent .map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")), limit_reached, - local_usage: local_usage.as_ref().map(quota_local_usage), - reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), + local_usage: local_usage + .as_ref() + .map(|value| quota_local_usage(value, time)), + reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)), } } -pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value { +pub(super) fn quota_local_usage(usage: &AccountUsage, time: crate::time::TimePresenter) -> Value { let total_tokens = usage.total_tokens.unwrap_or_default(); serde_json::json!({ "requestCount": usage.request_count, @@ -286,10 +323,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value { "imageRequestFailedCount": usage.image_request_failed_count, "totalTokens": total_tokens, "totalTokensDisplay": format_compact_number(total_tokens), - "requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({ - "bucketStart": bucket.bucket_start, - "requestCount": bucket.request_count, - })).collect::>(), + "requestBuckets": time.request_buckets(usage.request_buckets.iter().map(|bucket| (bucket.bucket_start, bucket.request_count))), }) } @@ -297,6 +331,7 @@ pub(super) fn account_usage_view( usage: Option, period: Option, now: DateTime, + time: crate::time::TimePresenter, ) -> AccountUsageView { let Some(usage) = usage else { return empty_account_usage(); @@ -343,10 +378,11 @@ pub(super) fn account_usage_view( created_tokens_display: display_optional_tokens(usage.cache_write_tokens), read_tokens: usage.cached_tokens, read_tokens_display: display_optional_tokens(usage.cached_tokens), - last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)), + last_used_at: usage.last_used_at.map(|value| time.rfc3339(&value)), + last_used_at_full_display: usage.last_used_at.map(|value| time.datetime(&value)), last_used_at_display: usage .last_used_at - .map_or_else(|| "—".to_owned(), |value| relative_time(value, now)), + .map_or_else(|| "—".to_owned(), |value| time.relative(value, now)), cost_estimate_status: cost_estimate_status.to_owned(), known_cost_count: Some(known_count), partial_cost_count: Some(u64::from(cost_estimate_status == "partial")), @@ -355,7 +391,7 @@ pub(super) fn account_usage_view( models: usage .models .into_iter() - .map(|model| account_model_usage_view(model, now)) + .map(|model| account_model_usage_view(model, now, time)) .collect(), } } @@ -363,6 +399,7 @@ pub(super) fn account_usage_view( pub(super) fn account_model_usage_view( usage: AccountModelUsage, now: DateTime, + time: crate::time::TimePresenter, ) -> ModelUsageView { let known_count = usage .cost_coverage @@ -419,8 +456,9 @@ pub(super) fn account_model_usage_view( partial_cost_count: u64::from(cost_estimate_status == "partial"), unknown_cost_count: usage.cost_coverage.unavailable_count, costs: usage.costs.iter().map(account_currency_cost_view).collect(), - last_used_at: china_rfc3339(&usage.last_used_at), - last_used_at_display: relative_time(usage.last_used_at, now), + last_used_at: time.rfc3339(&usage.last_used_at), + last_used_at_full_display: Some(time.datetime(&usage.last_used_at)), + last_used_at_display: time.relative(usage.last_used_at, now), } } @@ -464,6 +502,7 @@ pub(super) fn empty_account_usage() -> AccountUsageView { read_tokens: None, read_tokens_display: "—".to_owned(), last_used_at: None, + last_used_at_full_display: None, last_used_at_display: "—".to_owned(), cost_estimate_status: "unavailable".to_owned(), known_cost_count: None, @@ -474,38 +513,6 @@ pub(super) fn empty_account_usage() -> AccountUsageView { } } -pub(super) fn relative_time(value: DateTime, now: DateTime) -> String { - let elapsed = now.signed_duration_since(value); - if elapsed.num_seconds() < 0 { - return china_datetime(&value); - } - if elapsed.num_seconds() < 60 { - return "刚刚".to_owned(); - } - if elapsed.num_minutes() < 60 { - return format!("{} 分钟前", elapsed.num_minutes()); - } - if elapsed.num_hours() < 24 { - return format!("{} 小时前", elapsed.num_hours()); - } - format!("{} 天前", elapsed.num_days()) -} - -pub(super) fn china_offset() -> FixedOffset { - FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset") -} - -pub(super) fn china_rfc3339(value: &DateTime) -> String { - value.with_timezone(&china_offset()).to_rfc3339() -} - -pub(super) fn china_datetime(value: &DateTime) -> String { - value - .with_timezone(&china_offset()) - .format("%Y-%m-%d %H:%M:%S") - .to_string() -} - pub(super) fn map_wire_error(error: WireValidationError) -> AdminError { AdminError::bad_request(format!("{} 字段不合法", error.field())) } diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 4d10f29b1..89403ee95 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -297,6 +297,7 @@ pub struct AccountView { pub access_token_expires_at_display: Option, pub refresh_token_expires_at: Option, pub next_refresh_at: Option, + pub next_refresh_at_display: Option, pub added_at: String, pub added_at_display: String, pub updated_at: String, @@ -394,6 +395,7 @@ pub struct AccountQuotaView { pub limit_reached: bool, /// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。 pub rate_limited_until: Option, + pub rate_limit_recovery_display: Option, pub rate_limit_reason: Option, pub recovery_probe_required: bool, pub windows: Vec, @@ -460,6 +462,7 @@ pub struct AccountUsageView { pub read_tokens_display: String, pub last_used_at: Option, pub last_used_at_display: String, + pub last_used_at_full_display: Option, pub cost_estimate_status: String, pub known_cost_count: Option, pub partial_cost_count: Option, @@ -502,6 +505,7 @@ pub struct ModelUsageView { pub costs: Vec, pub last_used_at: String, pub last_used_at_display: String, + pub last_used_at_full_display: Option, } /// 单一货币的可查询成本。 @@ -741,7 +745,7 @@ impl TryFrom for AccountModelCatalogData { .map_err(|_| UnsupportedModelCatalogDocument)?; Ok(Self { model_count: result.model_count, - observed_at: china_rfc3339(&result.observed_at), + observed_at: result.observed_at.to_rfc3339(), catalog, }) } @@ -775,16 +779,21 @@ pub struct AccountPersonalInfoData { pub subscription: Option, } -impl From for AccountPersonalInfoData { - fn from(info: AccountPersonalInfo) -> Self { +impl From<(AccountPersonalInfo, crate::time::TimePresenter)> for AccountPersonalInfoData { + fn from((info, time): (AccountPersonalInfo, crate::time::TimePresenter)) -> Self { let (profile, profile_error) = match info.profile { - Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None), + Ok(profile) => ( + Some(AccountProfileStatisticsData::from((profile, time))), + None, + ), Err(error) => (None, Some(error.message().to_owned())), }; Self { profile, profile_error, - subscription: info.subscription.map(AccountSubscriptionData::from), + subscription: info + .subscription + .map(|value| AccountSubscriptionData::from((value, time))), } } } @@ -794,21 +803,30 @@ impl From for AccountPersonalInfoData { #[serde(rename_all = "camelCase")] pub struct AccountSubscriptionData { pub starts_at: Option, + pub starts_at_display: Option, pub expires_at: String, + pub expires_at_display: String, pub will_renew: Option, pub billing_period: Option, pub billing_currency: Option, pub observed_at: String, + pub observed_at_display: String, } -impl From for AccountSubscriptionData { - fn from(subscription: ProviderSubscription) -> Self { +impl From<(ProviderSubscription, crate::time::TimePresenter)> for AccountSubscriptionData { + fn from((subscription, time): (ProviderSubscription, crate::time::TimePresenter)) -> Self { Self { + starts_at_display: subscription + .starts_at + .as_ref() + .map(|value| time.datetime(value)), starts_at: subscription.starts_at.map(|value| value.to_rfc3339()), + expires_at_display: time.datetime(&subscription.expires_at), expires_at: subscription.expires_at.to_rfc3339(), will_renew: subscription.will_renew, billing_period: subscription.billing_period, billing_currency: subscription.billing_currency, + observed_at_display: time.datetime(&subscription.observed_at), observed_at: subscription.observed_at.to_rfc3339(), } } @@ -818,6 +836,7 @@ impl From for AccountSubscriptionData { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountProfileStatisticsData { + pub activity_calendar: Option, pub display_name: Option, pub username: Option, pub image_url: Option, @@ -868,9 +887,15 @@ pub struct AccountProfileInvocationView { pub usage_count: Option, } -impl From for AccountProfileStatisticsData { - fn from(statistics: ProviderProfileStatistics) -> Self { +impl From<(ProviderProfileStatistics, crate::time::TimePresenter)> + for AccountProfileStatisticsData +{ + fn from((statistics, time): (ProviderProfileStatistics, crate::time::TimePresenter)) -> Self { Self { + activity_calendar: statistics + .daily_usage + .as_deref() + .and_then(|daily| profile_activity_calendar(daily, time.today())), display_name: statistics.display_name, username: statistics.username, image_url: statistics.image_url, @@ -949,6 +974,7 @@ pub struct AccountResetCreditView { pub status: Option, pub title: Option, pub expires_at: Option, + pub expires_at_display: Option, pub reset_type: Option, } @@ -960,36 +986,41 @@ pub struct AccountResetCreditResultData { pub credit: Option, } -impl From for AccountResetCreditView { - fn from(credit: ProviderResetCredit) -> Self { +impl From<(ProviderResetCredit, crate::time::TimePresenter)> for AccountResetCreditView { + fn from((credit, time): (ProviderResetCredit, crate::time::TimePresenter)) -> Self { Self { id: credit.id, status: credit.status, title: credit.title, + expires_at_display: credit.expires_at.as_ref().map(|value| time.datetime(value)), expires_at: credit.expires_at.map(|value| value.to_rfc3339()), reset_type: credit.reset_type, } } } -impl From for AccountResetCreditsData { - fn from(credits: ProviderResetCredits) -> Self { +impl From<(ProviderResetCredits, crate::time::TimePresenter)> for AccountResetCreditsData { + fn from((credits, time): (ProviderResetCredits, crate::time::TimePresenter)) -> Self { Self { available_count: credits.available_count, credits: credits .credits .into_iter() - .map(AccountResetCreditView::from) + .map(|value| AccountResetCreditView::from((value, time))) .collect(), } } } -impl From for AccountResetCreditResultData { - fn from(result: ProviderResetCreditResult) -> Self { +impl From<(ProviderResetCreditResult, crate::time::TimePresenter)> + for AccountResetCreditResultData +{ + fn from((result, time): (ProviderResetCreditResult, crate::time::TimePresenter)) -> Self { Self { code: result.code, - credit: result.credit.map(AccountResetCreditView::from), + credit: result + .credit + .map(|value| AccountResetCreditView::from((value, time))), } } } @@ -1005,7 +1036,15 @@ impl AccountExportData { Self(value) } - pub(super) fn from_result(bundle: AccountExportBundle) -> Self { + pub(super) fn from_result( + bundle: AccountExportBundle, + time: crate::time::TimePresenter, + ) -> Self { + let filename = format!( + "cpr-accounts-selected-{}-{}.json", + bundle.documents.len(), + time.label(bundle.exported_at, "%Y-%m-%d") + ); let documents = bundle .documents .into_iter() @@ -1018,6 +1057,7 @@ impl AccountExportData { .collect::>(); Self::new(serde_json::json!({ "exportedAt": bundle.exported_at.to_rfc3339(), + "fileName": filename, "documents": documents, })) } @@ -1033,9 +1073,9 @@ pub struct AccountConnectionTestEvent { pub data: Value, } -impl From for AccountConnectionTestEvent { - fn from(event: DomainConnectionTestEvent) -> Self { - let data = match event { +impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountConnectionTestEvent { + fn from((event, time): (DomainConnectionTestEvent, crate::time::TimePresenter)) -> Self { + let mut data = match event { DomainConnectionTestEvent::Started { model } => serde_json::json!({ "type": "test_start", "model": model, @@ -1088,6 +1128,75 @@ impl From for AccountConnectionTestEvent { "upstreamBody": upstream_body }), }; + let now = Utc::now(); + data["occurredAt"] = now.to_rfc3339().into(); + data["occurredAtDisplay"] = time.datetime(&now).into(); + data["timeDisplay"] = time.time(&now).into(); Self { data } } } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileActivityCalendar { + pub range_label: String, + pub weeks: Vec, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileActivityWeek { + pub key: String, + pub month_label: Option, + pub cells: Vec, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileActivityCell { + pub date: String, + pub date_display: String, + pub tokens: u64, + pub is_future: bool, +} +fn profile_activity_calendar( + daily: &[ProviderProfileDailyUsage], + today: chrono::NaiveDate, +) -> Option { + use chrono::{Datelike as _, Days}; + let week_start = + today.checked_sub_days(Days::new(u64::from(today.weekday().num_days_from_sunday())))?; + let start = week_start.checked_sub_days(Days::new(51 * 7))?; + let mut counts = std::collections::BTreeMap::<_, u64>::new(); + for entry in daily { + if entry.date >= start && entry.date <= today { + let value = counts.entry(entry.date).or_default(); + *value = value.saturating_add(entry.tokens); + } + } + let mut weeks = Vec::with_capacity(52); + for index in 0..52 { + let mut cells = Vec::with_capacity(7); + let mut month_label = None; + let week = start.checked_add_days(Days::new(index * 7))?; + for day in 0..7 { + let date = week.checked_add_days(Days::new(day))?; + if date.day() == 1 { + month_label = Some(format!("{}月", date.month())); + } + cells.push(ProfileActivityCell { + date: date.to_string(), + date_display: format!("{}年{}月{}日", date.year(), date.month(), date.day()), + tokens: counts.get(&date).copied().unwrap_or_default(), + is_future: date > today, + }); + } + weeks.push(ProfileActivityWeek { + key: week.to_string(), + month_label, + cells, + }); + } + Some(ProfileActivityCalendar { + range_label: format!("{start} 至 {today}"), + weeks, + }) +} diff --git a/backend/crates/gateway-api/src/admin/backups.rs b/backend/crates/gateway-api/src/admin/backups.rs index 90ad46e96..fd45a1d1f 100644 --- a/backend/crates/gateway-api/src/admin/backups.rs +++ b/backend/crates/gateway-api/src/admin/backups.rs @@ -40,16 +40,18 @@ pub struct BackupSettingsView { pub verified: bool, pub schedule_enabled: bool, pub cron_expression: Option, - pub schedule_timezone: Option, pub retention_days: u32, pub retention_count: u32, pub next_run_at: Option>, + pub next_run_at_display: Option, pub last_verified_at: Option>, + pub last_verified_at_display: Option, pub updated_at: DateTime, + pub updated_at_display: String, } -impl From for BackupSettingsView { - fn from(settings: BackupSettings) -> Self { +impl From<(BackupSettings, crate::time::TimePresenter)> for BackupSettingsView { + fn from((settings, time): (BackupSettings, crate::time::TimePresenter)) -> Self { let verified = settings.storage_verified(); Self { storage_revision: settings.storage_revision, @@ -65,11 +67,19 @@ impl From for BackupSettingsView { verified, schedule_enabled: settings.schedule_enabled, cron_expression: settings.cron_expression, - schedule_timezone: settings.schedule_timezone, retention_days: settings.retention_days, retention_count: settings.retention_count, + next_run_at_display: settings + .next_run_at + .as_ref() + .map(|value| time.datetime(value)), next_run_at: settings.next_run_at, + last_verified_at_display: settings + .last_verified_at + .as_ref() + .map(|value| time.datetime(value)), last_verified_at: settings.last_verified_at, + updated_at_display: time.datetime(&settings.updated_at), updated_at: settings.updated_at, } } @@ -95,7 +105,6 @@ pub struct UpdateBackupStorageRequest { pub struct UpdateBackupScheduleRequest { pub schedule_enabled: bool, pub cron_expression: String, - pub schedule_timezone: String, pub retention_days: u32, pub retention_count: u32, } @@ -108,6 +117,7 @@ pub struct BackupRecordView { pub trigger_kind: String, pub status: String, pub scheduled_at: Option>, + pub scheduled_at_display: Option, pub object_key: String, pub size_bytes: Option, pub sha256: Option, @@ -115,18 +125,27 @@ pub struct BackupRecordView { pub error_code: Option, pub error_message: Option, pub started_at: Option>, + pub started_at_display: Option, pub completed_at: Option>, + pub completed_at_display: Option, pub expires_at: Option>, + pub expires_at_display: Option, pub created_at: DateTime, + pub created_at_display: String, pub updated_at: DateTime, + pub updated_at_display: String, } -impl From for BackupRecordView { - fn from(record: BackupRecord) -> Self { +impl From<(BackupRecord, crate::time::TimePresenter)> for BackupRecordView { + fn from((record, time): (BackupRecord, crate::time::TimePresenter)) -> Self { Self { id: record.id, trigger_kind: record.trigger_kind.to_string(), status: record.status.to_string(), + scheduled_at_display: record + .scheduled_at + .as_ref() + .map(|value| time.datetime(value)), scheduled_at: record.scheduled_at, object_key: record.object_key, size_bytes: record.size_bytes, @@ -134,10 +153,18 @@ impl From for BackupRecordView { attempt_count: record.attempt_count, error_code: record.error_code, error_message: record.error_message, + started_at_display: record.started_at.as_ref().map(|value| time.datetime(value)), started_at: record.started_at, + completed_at_display: record + .completed_at + .as_ref() + .map(|value| time.datetime(value)), completed_at: record.completed_at, + expires_at_display: record.expires_at.as_ref().map(|value| time.datetime(value)), expires_at: record.expires_at, + created_at_display: time.datetime(&record.created_at), created_at: record.created_at, + updated_at_display: time.datetime(&record.updated_at), updated_at: record.updated_at, } } @@ -244,6 +271,7 @@ async fn backup_settings( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let settings = state .admin_services() .backups() @@ -252,7 +280,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(BackupSettingsView::from(settings)), + AdminEnvelope::ok(BackupSettingsView::from((settings, time))), )) } @@ -264,6 +292,7 @@ async fn update_backup_storage( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let command = UpdateBackupStorageCommand { endpoint: request.endpoint, region: request.region, @@ -281,7 +310,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(BackupSettingsView::from(settings)), + AdminEnvelope::ok(BackupSettingsView::from((settings, time))), )) } @@ -312,10 +341,10 @@ async fn update_backup_schedule( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let command = UpdateBackupScheduleCommand { schedule_enabled: request.schedule_enabled, cron_expression: request.cron_expression, - schedule_timezone: request.schedule_timezone, retention_days: request.retention_days, retention_count: request.retention_count, }; @@ -327,7 +356,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(BackupSettingsView::from(settings)), + AdminEnvelope::ok(BackupSettingsView::from((settings, time))), )) } @@ -339,6 +368,7 @@ async fn backup_records( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20)) .map_err(|_| AdminError::bad_request("pageSize 不合法"))?; let page = query.page.unwrap_or(1); @@ -378,7 +408,7 @@ where let items: Vec = page_result .items .into_iter() - .map(BackupRecordView::from) + .map(|value| BackupRecordView::from((value, time))) .collect(); Ok(AdminResponse::new( StatusCode::OK, @@ -394,6 +424,7 @@ async fn create_backup( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let expires_at = body .and_then(|body| body.0.expires_in_days) .filter(|&days| days > 0) @@ -406,7 +437,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::ACCEPTED, - AdminEnvelope::ok(BackupRecordView::from(record)), + AdminEnvelope::ok(BackupRecordView::from((record, time))), )) } @@ -445,6 +476,7 @@ async fn delete_backup( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let record = state .admin_services() .backups() @@ -453,7 +485,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(BackupRecordView::from(record)), + AdminEnvelope::ok(BackupRecordView::from((record, time))), )) } diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 4385b5563..cc3193643 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -353,10 +353,16 @@ pub struct ClientKeyView { daily_used_usd: String, weekly_used_usd: String, daily_resets_at: Option>, + daily_resets_at_display: Option, weekly_resets_at: Option>, + weekly_resets_at_display: Option, created_at: DateTime, + created_at_display: String, updated_at: DateTime, + updated_at_display: String, last_used_at: Option>, + last_used_at_display: String, + last_used_at_full_display: Option, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -368,8 +374,8 @@ pub struct ClientKeyGroupView { enabled: bool, } -impl From for ClientKeyView { - fn from(record: ClientKeyRecord) -> Self { +impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView { + fn from((record, time): (ClientKeyRecord, crate::time::TimePresenter)) -> Self { let routing_scope = if record.groups.is_empty() { "all" } else { @@ -413,10 +419,25 @@ impl From for ClientKeyView { weekly_limit_usd: record.budget.limits.weekly_usd.canonical(), daily_used_usd: record.budget.daily_used_usd.canonical(), weekly_used_usd: record.budget.weekly_used_usd.canonical(), + daily_resets_at_display: record + .budget + .daily_resets_at + .map(|value| time.datetime(&value.into())), daily_resets_at: record.budget.daily_resets_at.map(DateTime::from), + weekly_resets_at_display: record + .budget + .weekly_resets_at + .map(|value| time.datetime(&value.into())), weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from), + created_at_display: time.datetime(&record.created_at), created_at: record.created_at, + updated_at_display: time.datetime(&record.updated_at), updated_at: record.updated_at, + last_used_at_display: time.relative_optional(record.last_used_at, time.now()), + last_used_at_full_display: record + .last_used_at + .as_ref() + .map(|value| time.datetime(value)), last_used_at: record.last_used_at, } } @@ -443,10 +464,12 @@ impl ClientKeyListData { } } -impl TryFrom for ClientKeyListData { +impl TryFrom<(ClientKeyPage, crate::time::TimePresenter)> for ClientKeyListData { type Error = WireValidationError; - fn try_from(page: ClientKeyPage) -> Result { + fn try_from( + (page, time): (ClientKeyPage, crate::time::TimePresenter), + ) -> Result { let next_cursor = page .next_cursor .map(wire_cursor) @@ -455,7 +478,10 @@ impl TryFrom for ClientKeyListData { .map(encode_client_key_cursor) .transpose()?; Ok(Self::new( - page.items.into_iter().map(Into::into).collect(), + page.items + .into_iter() + .map(|value| ClientKeyView::from((value, time))) + .collect(), next_cursor, page.total, )) @@ -888,13 +914,14 @@ async fn list_client_keys( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let result = state .admin_services() .client_keys() .list(query.into_command().map_err(map_wire_error)?) .await .map_err(map_service_error)?; - let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?; + let data = ClientKeyListData::try_from((result, time)).map_err(|_| AdminError::internal())?; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } diff --git a/backend/crates/gateway-api/src/admin/observability/mod.rs b/backend/crates/gateway-api/src/admin/observability/mod.rs index 5d9216fe2..a73acf784 100644 --- a/backend/crates/gateway-api/src/admin/observability/mod.rs +++ b/backend/crates/gateway-api/src/admin/observability/mod.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Utc}; use gateway_admin::model::{PageSize as DomainPageSize, observability as domain}; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 965def980..5c21e5bcc 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -1,4 +1,4 @@ -//! 过渡期展示格式化;目标迁移到 Vue presenter。 +//! 观测领域事实到管理页面的展示投影。 use super::*; @@ -44,16 +44,6 @@ fn basis_points_rate(value: u64) -> f64 { value as f64 / 10_000.0 } -pub(crate) fn china_datetime(value: &DateTime) -> String { - (*value + Duration::hours(8)) - .format("%Y-%m-%d %H:%M:%S") - .to_string() -} - -pub(crate) fn china_label(value: DateTime, format: &str) -> String { - (value + Duration::hours(8)).format(format).to_string() -} - pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str { outcome.as_str() } @@ -270,10 +260,13 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option UsageListRecordView { +pub(crate) fn usage_list_record_view( + record: domain::UsageListRecord, + time: crate::time::TimePresenter, +) -> UsageListRecordView { let token_details = usage_list_token_details(&record); let billing = billing_view(record.billing.as_ref()); - let created_at_display = china_datetime(&record.started_at); + let created_at_display = time.datetime(&record.started_at); let model = record .upstream_model_id .clone() @@ -326,7 +319,10 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi } } -pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView { +pub(crate) fn usage_record_view( + record: domain::UsageRecord, + time: crate::time::TimePresenter, +) -> UsageRecordView { let tokens = token_details(&record); let billing = billing_view(record.billing.as_ref()); let costs = record @@ -432,7 +428,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView message, metadata, created_at: record.started_at, - created_at_display: china_datetime(&record.started_at), + created_at_display: time.datetime(&record.started_at), client_ip: record.client_ip, user_agent: record.user_agent, reasoning_effort: record.reasoning_effort, @@ -535,11 +531,14 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV } } -pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetailView { +pub(crate) fn usage_detail_view( + detail: domain::UsageDetail, + time: crate::time::TimePresenter, +) -> UsageRecordDetailView { UsageRecordDetailView { trace: detail.trace, related_requests: detail.related_requests, - request: usage_record_view(detail.request), + request: usage_record_view(detail.request, time), attempts: detail .attempts .into_iter() @@ -549,16 +548,26 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai } } -pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData { +pub(crate) fn usage_page_view( + page: domain::UsagePage, + time: crate::time::TimePresenter, +) -> PageData { PageData { - items: page.items.into_iter().map(usage_list_record_view).collect(), + items: page + .items + .into_iter() + .map(|value| usage_list_record_view(value, time)) + .collect(), current_page: page.current_page, page_size: page.page_size, total: page.total, } } -pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView { +pub(crate) fn ops_error_view( + error: domain::OpsError, + time: crate::time::TimePresenter, +) -> OpsErrorView { let account_label = error .provider_account_email .as_ref() @@ -628,22 +637,32 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView { recovery_total_latency_ms: error.recovery_total_latency_ms, }, created_at: error.occurred_at, - created_at_display: china_datetime(&error.occurred_at), + created_at_display: time.datetime(&error.occurred_at), } } -pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData { +pub(crate) fn ops_page_view( + page: domain::OpsErrorPage, + time: crate::time::TimePresenter, +) -> PageData { PageData { - items: page.items.into_iter().map(ops_error_view).collect(), + items: page + .items + .into_iter() + .map(|value| ops_error_view(value, time)) + .collect(), current_page: page.current_page, page_size: page.page_size, total: page.total, } } -pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView { - let local_time = china_label(point.bucket_start, "%H:%M"); - let label = china_label(point.bucket_start, "%m-%d %H:%M"); +pub(crate) fn trend_point_view( + point: domain::TrendPoint, + time: crate::time::TimePresenter, +) -> TrendPointView { + let local_time = time.label(point.bucket_start, "%H:%M"); + let label = time.label(point.bucket_start, "%m-%d %H:%M %:z"); let success_rate_value = point.success_rate.map(|value| value * 100.0); TrendPointView { time: local_time, @@ -766,11 +785,19 @@ pub(crate) fn trend_summary_view( } } -pub(crate) fn trend_view(trend: domain::Trend, kind: TrendKind) -> TrendData { +pub(crate) fn trend_view( + trend: domain::Trend, + kind: TrendKind, + time: crate::time::TimePresenter, +) -> TrendData { TrendData { kind, summary: trend_summary_view(kind, &trend.summary), - points: trend.points.into_iter().map(trend_point_view).collect(), + points: trend + .points + .into_iter() + .map(|value| trend_point_view(value, time)) + .collect(), } } @@ -789,7 +816,10 @@ pub(crate) fn reliability_display(value: Option) -> String { value.map_or_else(|| "-".to_owned(), |value| format!("{value:.1}%")) } -pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTimelineView { +pub(crate) fn health_timeline_view( + timeline: domain::HealthTimeline, + time: crate::time::TimePresenter, +) -> HealthTimelineView { HealthTimelineView { title: "请求健康时间线".to_owned(), description: "有效请求可用性".to_owned(), @@ -803,25 +833,25 @@ pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTi points: timeline .points .into_iter() - .enumerate() - .map(|(index, point)| { - let elapsed_minutes = i64::try_from(index).unwrap_or(i64::MAX).saturating_mul(15); - HealthTimelinePointView { - time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60), - status: health_status_name(point.status).to_owned(), - reliability_display: reliability_display(point.reliability_percent), - success_requests: point.success_requests, - failed_requests: point.failed_requests, - cancelled_requests: point.cancelled_requests, - incomplete_requests: point.incomplete_requests, - caller_error_requests: point.caller_error_requests, - } + .map(|point| HealthTimelinePointView { + bucket_start: point.bucket_start, + time: time.label(point.bucket_start, "%H:%M %:z"), + status: health_status_name(point.status).to_owned(), + reliability_display: reliability_display(point.reliability_percent), + success_requests: point.success_requests, + failed_requests: point.failed_requests, + cancelled_requests: point.cancelled_requests, + incomplete_requests: point.incomplete_requests, + caller_error_requests: point.caller_error_requests, }) .collect(), } } -pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> DashboardWireProfileView { +pub(crate) fn wire_profile_view( + profile: domain::DashboardWireProfile, + time: crate::time::TimePresenter, +) -> DashboardWireProfileView { DashboardWireProfileView { provider: profile.provider, product: profile.product, @@ -842,9 +872,11 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo value: attribute.value, }) .collect(), + verified_at_display: profile.verified_at.map(|value| time.datetime(&value)), verified_at: profile.verified_at, release: profile.release.map(|release| DashboardDesktopReleaseView { status: release.status.into(), + checked_at_display: release.checked_at.map(|value| time.datetime(&value)), checked_at: release.checked_at, latest_version: release.latest_version, latest_build: release.latest_build, @@ -853,29 +885,10 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo } } -pub(crate) fn relative_time(value: Option>, now: DateTime) -> String { - let Some(value) = value else { - return "从未使用".to_owned(); - }; - let elapsed = now.signed_duration_since(value); - if elapsed.num_seconds() < 0 { - return china_datetime(&value); - } - if elapsed.num_seconds() < 60 { - return "刚刚".to_owned(); - } - if elapsed.num_minutes() < 60 { - return format!("{} 分钟前", elapsed.num_minutes()); - } - if elapsed.num_hours() < 24 { - return format!("{} 小时前", elapsed.num_hours()); - } - format!("{} 天前", elapsed.num_days()) -} - pub(crate) fn dashboard_view( result: domain::DashboardResult, kind: TrendKind, + time: crate::time::TimePresenter, ) -> DashboardDataView { let domain::DashboardResult { observation, @@ -939,21 +952,19 @@ pub(crate) fn dashboard_view( .total_tokens .map_or_else(|| "—".to_owned(), format_compact_number), request_count: credential.request_count, - request_buckets: credential - .request_buckets - .iter() - .map(|bucket| DashboardAccountRequestBucketView { - bucket_start: bucket.bucket_start, - request_count: bucket.request_count, - }) - .collect(), + request_buckets: time.request_buckets( + credential + .request_buckets + .iter() + .map(|bucket| (bucket.bucket_start, bucket.request_count)), + ), quota_used_percent: credential.quota_used_percent, usage_window: credential .quota_window - .map(crate::admin::accounts::quota_window_view), + .map(|window| crate::admin::accounts::quota_window_view(window, time)), metric_label, metric_value, - last_used: relative_time(credential.last_used_at, range.end), + last_used: time.relative_optional(credential.last_used_at, range.end), }); } let unavailable_accounts = provider_accounts @@ -961,6 +972,8 @@ pub(crate) fn dashboard_view( .saturating_sub(provider_accounts.normal); DashboardDataView { + as_of: range.end, + as_of_display: time.datetime(&range.end), cards: DashboardCardsView { credentials: DashboardCredentialsCardView { total: provider_accounts.total.to_string(), @@ -994,13 +1007,16 @@ pub(crate) fn dashboard_view( average_first_token_latency_ms: display_duration(average_first_token_latency_ms), }, }, - trend: trend_view(trend, kind), - health_timeline: health_timeline_view(health_timeline), - wire_profiles: wire_profiles.into_iter().map(wire_profile_view).collect(), + trend: trend_view(trend, kind, time), + health_timeline: health_timeline_view(health_timeline, time), + wire_profiles: wire_profiles + .into_iter() + .map(|value| wire_profile_view(value, time)) + .collect(), account_usage: account_usage_views, usage_records: recent_requests .into_iter() - .map(usage_list_record_view) + .map(|value| usage_list_record_view(value, time)) .collect(), pool_summary: DashboardPoolSummaryView { total: provider_accounts.total, @@ -1035,14 +1051,17 @@ pub(crate) fn usage_summary_view(summary: domain::UsageSummary) -> UsageSummaryV } } -pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsightsOverviewView { +pub(crate) fn usage_insights_view( + insights: domain::UsageInsights, + time: crate::time::TimePresenter, +) -> UsageInsightsOverviewView { let health_points = insights .health .points .iter() .map(|point| OverviewHealthPointView { bucket: point.bucket_start, - label: china_label(point.bucket_start, "%m-%d %H:%M"), + label: time.label(point.bucket_start, "%m-%d %H:%M %:z"), total_requests: point.total_requests, success_requests: point.success_requests, failed_requests: point.failed_requests, @@ -1058,7 +1077,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig .iter() .map(|point| OverviewPerformancePointView { bucket: point.bucket_start, - label: china_label(point.bucket_start, "%m-%d %H:%M"), + label: time.label(point.bucket_start, "%m-%d %H:%M %:z"), latency_p50_ms: point.latency_percentiles.p50_ms.map(|value| value.as_f64()), latency_p95_ms: point.latency_percentiles.p95_ms.map(|value| value.as_f64()), latency_p99_ms: point.latency_percentiles.p99_ms.map(|value| value.as_f64()), @@ -1107,7 +1126,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig .iter() .map(|point| OverviewCostPointView { bucket: point.bucket_start, - label: china_label(point.bucket_start, "%m-%d %H:%M"), + label: time.label(point.bucket_start, "%m-%d %H:%M %:z"), input_tokens: point.input_tokens, output_tokens: point.output_tokens, cached_tokens: point.cached_tokens, diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs index 3b0acc118..9092d0fc8 100644 --- a/backend/crates/gateway-api/src/admin/observability/query.rs +++ b/backend/crates/gateway-api/src/admin/observability/query.rs @@ -12,6 +12,8 @@ pub const MAX_PAGE_SIZE: u16 = 100; #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DashboardQuery { pub kind: Option, + pub period: Option, + pub as_of: Option, pub start_time: Option, pub end_time: Option, } @@ -43,6 +45,8 @@ pub struct UsageQuery { pub response_id: Option, pub upstream_request_id: Option, pub search: Option, + pub period: Option, + pub as_of: Option, pub start_time: Option, pub end_time: Option, } @@ -81,6 +85,8 @@ impl DetailQuery { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DiagnosticsQuery { pub dimension: Option, + pub period: Option, + pub as_of: Option, pub start_time: Option, pub end_time: Option, pub provider: Option, @@ -116,6 +122,8 @@ pub struct OpsQuery { pub response_id: Option, pub upstream_request_id: Option, pub search: Option, + pub period: Option, + pub as_of: Option, pub start_time: Option, pub end_time: Option, } @@ -279,19 +287,21 @@ pub(crate) fn request_outcome( pub(crate) fn usage_range( start: Option<&str>, end: Option<&str>, + period: Option<&str>, + as_of: Option, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Result { - let end = parse_datetime(end)?.unwrap_or_else(Utc::now); - let start = parse_datetime(start)?.unwrap_or(end - Duration::days(7)); - domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange")) + crate::time::query_range(start, end, period, as_of, timezone, "7d") } pub(crate) fn dashboard_today_range( start: Option<&str>, end: Option<&str>, + period: Option<&str>, + as_of: Option, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Result { - let end = parse_datetime(end)?.unwrap_or_else(Utc::now); - let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end)); - domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange")) + crate::time::query_range(start, end, period, as_of, timezone, "today") } pub(crate) fn usage_filter(query: &UsageQuery) -> Result { @@ -320,19 +330,31 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result Result { +pub(crate) fn usage_command( + query: &UsageQuery, + timezone: gateway_core::time::DeploymentTimeZone, +) -> Result { let (current_page, page_size) = query.validate_pagination()?; let page_size_value = DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?; Ok(domain::UsageQuery { - range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?, + range: usage_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + timezone, + )?, filter: usage_filter(query)?, current_page, page_size: page_size_value, }) } -pub(crate) fn ops_command(query: &OpsQuery) -> Result { +pub(crate) fn ops_command( + query: &OpsQuery, + timezone: gateway_core::time::DeploymentTimeZone, +) -> Result { let (current_page, page_size) = query.validate_pagination()?; let page_size_value = DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?; @@ -343,7 +365,13 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let kind = query.trend_kind().map_err(map_wire_error)?; - // 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。 - let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref()) - .map_err(map_wire_error)?; + // 概览与独立趋势使用同一部署日界。 + let range = dashboard_today_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + state.admin_services().timezone(), + ) + .map_err(map_wire_error)?; let result = state .admin_services() .observability() @@ -51,7 +58,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(dashboard_view(result, kind)), + AdminEnvelope::ok(dashboard_view(result, kind, time)), )) } @@ -63,9 +70,16 @@ pub(crate) async fn dashboard_trend( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let kind = query.trend_kind().map_err(map_wire_error)?; - let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref()) - .map_err(map_wire_error)?; + let range = dashboard_today_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + state.admin_services().timezone(), + ) + .map_err(map_wire_error)?; let result = state .admin_services() .observability() @@ -74,7 +88,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(trend_view(result, kind)), + AdminEnvelope::ok(trend_view(result, kind, time)), )) } @@ -86,14 +100,16 @@ pub(crate) async fn usage_records( where S: SessionState + Send + Sync, { - let command = usage_command(&query).map_err(map_wire_error)?; + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); + let command = + usage_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?; let result = state .admin_services() .observability() .usage_records(command) .await .map_err(map_service_error)?; - let data = usage_page_view(result); + let data = usage_page_view(result, time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -105,6 +121,7 @@ pub(crate) async fn usage_record_detail( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); query.validate().map_err(map_wire_error)?; let result = state .admin_services() @@ -114,7 +131,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(usage_detail_view(result)), + AdminEnvelope::ok(usage_detail_view(result, time)), )) } @@ -126,8 +143,14 @@ pub(crate) async fn usage_records_summary( where S: SessionState + Send + Sync, { - let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref()) - .map_err(map_wire_error)?; + let range = usage_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + state.admin_services().timezone(), + ) + .map_err(map_wire_error)?; let filter = usage_filter(&query).map_err(map_wire_error)?; let result = state .admin_services() @@ -149,8 +172,15 @@ pub(crate) async fn usage_insights_overview( where S: SessionState + Send + Sync, { - let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref()) - .map_err(map_wire_error)?; + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); + let range = usage_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + state.admin_services().timezone(), + ) + .map_err(map_wire_error)?; let filter = usage_filter(&query).map_err(map_wire_error)?; let result = state .admin_services() @@ -160,7 +190,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(usage_insights_view(result)), + AdminEnvelope::ok(usage_insights_view(result, time)), )) } @@ -173,8 +203,14 @@ where S: SessionState + Send + Sync, { let dimension = query.dimension().map_err(map_wire_error)?; - let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref()) - .map_err(map_wire_error)?; + let range = usage_range( + query.start_time.as_deref(), + query.end_time.as_deref(), + query.period.as_deref(), + query.as_of, + state.admin_services().timezone(), + ) + .map_err(map_wire_error)?; let filter = domain::UsageFilter { provider_kind: non_empty(query.provider), model: non_empty(query.model), @@ -202,13 +238,14 @@ pub(crate) async fn ops_errors( where S: SessionState + Send + Sync, { - let command = ops_command(&query).map_err(map_wire_error)?; + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); + let command = ops_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?; let result = state .admin_services() .observability() .ops_errors(command) .await .map_err(map_service_error)?; - let data = ops_page_view(result); + let data = ops_page_view(result, time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index be27bec34..05520777d 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -394,7 +394,7 @@ pub struct DashboardAccountUsageView { pub plan_type_display: String, pub tokens: String, pub request_count: u64, - pub request_buckets: Vec, + pub request_buckets: Vec, pub quota_used_percent: Option, pub usage_window: Option, pub metric_label: String, @@ -402,14 +402,6 @@ pub struct DashboardAccountUsageView { pub last_used: String, } -/// Dashboard 账号单小时请求数。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DashboardAccountRequestBucketView { - pub bucket_start: DateTime, - pub request_count: u64, -} - /// Provider 账号池的持久事实汇总。 #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -470,6 +462,7 @@ pub struct AttemptMetricsView { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct HealthTimelinePointView { + pub bucket_start: DateTime, pub time: String, pub status: String, pub reliability_display: String, @@ -510,6 +503,7 @@ pub struct DashboardWireProfileView { pub attributes: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub verified_at: Option>, + pub verified_at_display: Option, #[serde(skip_serializing_if = "Option::is_none")] pub release: Option, } @@ -537,6 +531,7 @@ pub struct DashboardDesktopReleaseView { pub status: DashboardDesktopReleaseStatusView, #[serde(skip_serializing_if = "Option::is_none")] pub checked_at: Option>, + pub checked_at_display: Option, #[serde(skip_serializing_if = "Option::is_none")] pub latest_version: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -570,6 +565,8 @@ impl From for DashboardDesktopReleaseStatusView { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DashboardDataView { + pub as_of: DateTime, + pub as_of_display: String, pub cards: DashboardCardsView, pub trend: TrendData, pub health_timeline: HealthTimelineView, diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 12e2e8f65..ffa8f9d8e 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -153,13 +153,16 @@ struct ProxyView { revision: u64, account_count: u64, last_test_at: Option, + last_test_at_display: Option, last_test: Option, created_at: String, + created_at_display: String, updated_at: String, + updated_at_display: String, } -impl From for ProxyView { - fn from(record: ProxyRecord) -> Self { +impl From<(ProxyRecord, crate::time::TimePresenter)> for ProxyView { + fn from((record, time): (ProxyRecord, crate::time::TimePresenter)) -> Self { let endpoint = record.proxy.endpoint(); Self { auto_location: record.auto_location, @@ -171,9 +174,15 @@ impl From for ProxyView { endpoint, revision: record.revision.get(), account_count: record.account_count, + last_test_at_display: record + .last_test_at + .as_ref() + .map(|value| time.datetime(value)), last_test_at: record.last_test_at.map(|at| at.to_rfc3339()), last_test: record.last_test.map(Into::into), + created_at_display: time.datetime(&record.created_at), created_at: record.created_at.to_rfc3339(), + updated_at_display: time.datetime(&record.updated_at), updated_at: record.updated_at.to_rfc3339(), } } @@ -198,10 +207,10 @@ struct MutationView { config_revision: u64, } -impl From for MutationView { - fn from(value: ProxyMutation) -> Self { +impl From<(ProxyMutation, crate::time::TimePresenter)> for MutationView { + fn from((value, time): (ProxyMutation, crate::time::TimePresenter)) -> Self { Self { - record: value.record.into(), + record: ProxyView::from((value.record, time)), config_revision: value.config_revision.get(), } } @@ -244,6 +253,7 @@ async fn list( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let result = state .admin_services() .proxies() @@ -259,7 +269,11 @@ where Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(ProxyPageView { - items: result.items.into_iter().map(Into::into).collect(), + items: result + .items + .into_iter() + .map(|value| ProxyView::from((value, time))) + .collect(), page: PageMeta::new( result.page, u32::from(result.page_size), @@ -336,6 +350,7 @@ async fn create( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let proxy = request .proxy_url .0 @@ -357,7 +372,7 @@ where .map_err(map_error)?; Ok(AdminResponse::new( StatusCode::CREATED, - AdminEnvelope::ok(MutationView::from(result)), + AdminEnvelope::ok(MutationView::from((result, time))), )) } @@ -393,6 +408,7 @@ async fn update( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let proxy = request .proxy_url .map(|value| { @@ -420,7 +436,7 @@ where .map_err(map_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(MutationView::from(result)), + AdminEnvelope::ok(MutationView::from((result, time))), )) } @@ -480,6 +496,7 @@ async fn test( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let result = state .admin_services() .proxies() @@ -493,6 +510,6 @@ where .map_err(map_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(ProxyView::from(result)), + AdminEnvelope::ok(ProxyView::from((result, time))), )) } diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 29a0fc7fd..3f3e474ed 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -73,6 +73,7 @@ pub struct RuntimeSettingsView { pub account_warmup_schedule_time: String, pub account_warmup_model: Option, pub updated_at: DateTime, + pub updated_at_display: String, } /// 原子替换全局运行参数的请求。 @@ -260,8 +261,8 @@ impl UpdateRuntimeSettingsRequest { } } -impl From for RuntimeSettingsView { - fn from(settings: RuntimeSettings) -> Self { +impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView { + fn from((settings, time): (RuntimeSettings, crate::time::TimePresenter)) -> Self { let provider_request_profiles = settings .request_profiles .into_iter() @@ -302,6 +303,7 @@ impl From for RuntimeSettingsView { account_warmup_enabled: settings.account_warmup_enabled, account_warmup_schedule_time: settings.account_warmup_schedule_time, account_warmup_model: settings.account_warmup_model, + updated_at_display: time.datetime(&settings.updated_at), updated_at: settings.updated_at, } } @@ -351,10 +353,11 @@ struct ClientDownloadPackageView { size_bytes: Option, download_url: String, expires_at: Option>, + expires_at_display: Option, } -impl From for ClientDownloadPackageView { - fn from(package: ClientDownloadPackage) -> Self { +impl From<(ClientDownloadPackage, crate::time::TimePresenter)> for ClientDownloadPackageView { + fn from((package, time): (ClientDownloadPackage, crate::time::TimePresenter)) -> Self { Self { architecture: package.architecture.as_str().to_owned(), source: package.source.as_str().to_owned(), @@ -362,6 +365,10 @@ impl From for ClientDownloadPackageView { file_name: package.file_name, size_bytes: package.size_bytes, download_url: package.download_url, + expires_at_display: package + .expires_at + .as_ref() + .map(|value| time.datetime(value)), expires_at: package.expires_at, } } @@ -371,18 +378,26 @@ impl From for ClientDownloadPackageView { #[serde(rename_all = "camelCase")] struct CodexDesktopWindowsDownloadsView { resolved_at: DateTime, + resolved_at_display: String, cached: bool, warning: Option, packages: Vec, } -impl From for CodexDesktopWindowsDownloadsView { - fn from(downloads: CodexDesktopWindowsDownloads) -> Self { +impl From<(CodexDesktopWindowsDownloads, crate::time::TimePresenter)> + for CodexDesktopWindowsDownloadsView +{ + fn from((downloads, time): (CodexDesktopWindowsDownloads, crate::time::TimePresenter)) -> Self { Self { + resolved_at_display: time.datetime(&downloads.resolved_at), resolved_at: downloads.resolved_at, cached: downloads.cached, warning: downloads.warning, - packages: downloads.packages.into_iter().map(Into::into).collect(), + packages: downloads + .packages + .into_iter() + .map(|value| ClientDownloadPackageView::from((value, time))) + .collect(), } } } @@ -447,6 +462,7 @@ async fn codex_desktop_windows_downloads( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let downloads = state .admin_services() .client_distribution() @@ -454,7 +470,7 @@ where .await; AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)), + AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from((downloads, time))), ) } @@ -498,6 +514,7 @@ where AdminEnvelope::ok(serde_json::json!({ "defaults": pricing.defaults, "overrides": pricing.overrides, "synced": pricing.synced, "syncedAt": pricing.synced_at, + "syncedAtDisplay": pricing.synced_at.as_ref().map(|value| crate::time::TimePresenter::new(state.admin_services().timezone()).datetime(value)), })), )) } @@ -584,6 +601,7 @@ async fn settings( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let result = state .admin_services() .settings() @@ -592,7 +610,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(RuntimeSettingsView::from(result)), + AdminEnvelope::ok(RuntimeSettingsView::from((result, time))), )) } @@ -604,6 +622,7 @@ async fn update_settings( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let command = request.into_command().map_err(map_wire_error)?; let result = state .admin_services() @@ -613,7 +632,7 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(RuntimeSettingsView::from(result)), + AdminEnvelope::ok(RuntimeSettingsView::from((result, time))), )) } @@ -835,7 +854,10 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(result.into_inner()), + AdminEnvelope::ok(client_profile_preview_view( + result.into_inner(), + crate::time::TimePresenter::new(state.admin_services().timezone()), + )), )) } @@ -865,6 +887,23 @@ where .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(result.into_inner()), + AdminEnvelope::ok(client_profile_preview_view( + result.into_inner(), + crate::time::TimePresenter::new(state.admin_services().timezone()), + )), )) } + +fn client_profile_preview_view( + mut profile: serde_json::Map, + time: crate::time::TimePresenter, +) -> serde_json::Map { + for (raw, display) in [ + ("verifiedAt", "verifiedAtDisplay"), + ("checkedAt", "checkedAtDisplay"), + ] { + let value = time.rfc_display(profile.get(raw).and_then(serde_json::Value::as_str)); + profile.insert(display.to_owned(), serde_json::json!(value)); + } + profile +} diff --git a/backend/crates/gateway-api/src/admin/system.rs b/backend/crates/gateway-api/src/admin/system.rs index 0a7a0dd4a..1c7c8c4e1 100644 --- a/backend/crates/gateway-api/src/admin/system.rs +++ b/backend/crates/gateway-api/src/admin/system.rs @@ -151,11 +151,13 @@ struct SystemOperationStateView { message: Option, error: Option, started_at: Option, + started_at_display: Option, finished_at: Option, + finished_at_display: Option, } -impl From for SystemOperationStateView { - fn from(operation: SystemOperationState) -> Self { +impl From<(SystemOperationState, crate::time::TimePresenter)> for SystemOperationStateView { + fn from((operation, time): (SystemOperationState, crate::time::TimePresenter)) -> Self { Self { operation_id: operation.operation_id, kind: operation.kind.map(operation_kind_name), @@ -163,7 +165,15 @@ impl From for SystemOperationStateView { target_version: operation.target_version, message: operation.message, error: operation.error, + started_at_display: operation + .started_at + .as_ref() + .map(|value| time.datetime(value)), started_at: operation.started_at.map(|value| value.to_rfc3339()), + finished_at_display: operation + .finished_at + .as_ref() + .map(|value| time.datetime(value)), finished_at: operation.finished_at.map(|value| value.to_rfc3339()), } } @@ -178,13 +188,13 @@ struct SystemUpdateStatusView { operation: SystemOperationStateView, } -impl From for SystemUpdateStatusView { - fn from(status: SystemUpdateStatus) -> Self { +impl From<(SystemUpdateStatus, crate::time::TimePresenter)> for SystemUpdateStatusView { + fn from((status, time): (SystemUpdateStatus, crate::time::TimePresenter)) -> Self { Self { previous_version: status.previous_version, current_version: status.current_version, need_restart: status.need_restart, - operation: status.operation.into(), + operation: SystemOperationStateView::from((status.operation, time)), } } } @@ -224,6 +234,7 @@ struct SystemUpdateEventView { terminal: bool, progress_percent: Option, at: String, + at_display: String, } /// 构造固定 GET/POST 系统管理路由。 @@ -291,13 +302,16 @@ async fn update_event_stream( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let stream = state .admin_services() .system() .update_events() - .map(|message| { + .map(move |message| { let id = message.id.clone(); - let data = SystemUpdateEventView::from(message).into_json().to_string(); + let data = SystemUpdateEventView::from((message, time)) + .into_json() + .to_string(); Ok(Event::default().event("update").id(id).data(data)) }); Ok(Sse::new(stream).keep_alive(KeepAlive::default())) @@ -347,6 +361,7 @@ async fn update_status( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let status = state .admin_services() .system() @@ -355,7 +370,7 @@ where .map_err(map_system_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(SystemUpdateStatusView::from(status)), + AdminEnvelope::ok(SystemUpdateStatusView::from((status, time))), )) } @@ -445,8 +460,8 @@ where )) } -impl From for SystemUpdateEventView { - fn from(event: SystemUpdateEvent) -> Self { +impl From<(SystemUpdateEvent, crate::time::TimePresenter)> for SystemUpdateEventView { + fn from((event, time): (SystemUpdateEvent, crate::time::TimePresenter)) -> Self { Self { id: event.id, operation_id: event.operation_id, @@ -455,6 +470,7 @@ impl From for SystemUpdateEventView { message: event.message, terminal: event.terminal, progress_percent: event.progress_percent, + at_display: time.time(&event.occurred_at), at: event.occurred_at.to_rfc3339(), } } @@ -470,7 +486,7 @@ impl SystemUpdateEventView { "message": self.message, "terminal": self.terminal, "progressPercent": self.progress_percent, - "at": self.at, + "at": self.at, "atDisplay": self.at_display, }) } } diff --git a/backend/crates/gateway-api/src/key_usage/mod.rs b/backend/crates/gateway-api/src/key_usage/mod.rs index 387e6b263..bb27e504e 100644 --- a/backend/crates/gateway-api/src/key_usage/mod.rs +++ b/backend/crates/gateway-api/src/key_usage/mod.rs @@ -83,19 +83,20 @@ async fn overview( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let overview = state .admin_services() .key_usage() .overview( session_cookie::value(&headers).as_deref(), - query.into_domain()?, + query.into_domain(state.admin_services().timezone())?, ) .await .map_err(map_admin_service_error)? .ok_or_else(AdminError::session_required)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(presenter::overview(overview)), + AdminEnvelope::ok(presenter::overview(overview, time)), )) } @@ -107,19 +108,20 @@ async fn records( where S: SessionState + Send + Sync, { + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); let records = state .admin_services() .key_usage() .records( session_cookie::value(&headers).as_deref(), - query.into_domain()?, + query.into_domain(state.admin_services().timezone())?, ) .await .map_err(map_admin_service_error)? .ok_or_else(AdminError::session_required)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(presenter::records(records)), + AdminEnvelope::ok(presenter::records(records, time)), )) } diff --git a/backend/crates/gateway-api/src/key_usage/presenter.rs b/backend/crates/gateway-api/src/key_usage/presenter.rs index 11d118080..c77a61bb1 100644 --- a/backend/crates/gateway-api/src/key_usage/presenter.rs +++ b/backend/crates/gateway-api/src/key_usage/presenter.rs @@ -50,6 +50,7 @@ pub(super) fn config(secret: ClientKeySecret) -> ConfigView { #[serde(rename_all = "camelCase")] pub(super) struct OverviewView { as_of: DateTime, + as_of_display: String, start_time: DateTime, end_time: DateTime, key: KeyView, @@ -68,9 +69,11 @@ struct KeyView { daily_limit_usd: String, daily_used_usd: String, daily_resets_at: Option>, + daily_resets_at_display: Option, weekly_limit_usd: String, weekly_used_usd: String, weekly_resets_at: Option>, + weekly_resets_at_display: Option, } #[derive(Serialize)] @@ -110,14 +113,16 @@ fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCovera struct TrendPointView { time: DateTime, bucket_seconds: u32, + label: String, #[serde(flatten)] metrics: MetricsView, } -pub(super) fn overview(value: KeyUsageOverview) -> OverviewView { +pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter) -> OverviewView { let key = value.key; OverviewView { - as_of: Utc::now(), + as_of: value.overview.range.end, + as_of_display: time.time(&value.overview.range.end), start_time: value.overview.range.start, end_time: value.overview.range.end, key: KeyView { @@ -127,9 +132,17 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView { requests_per_minute: key.limits.requests_per_minute, daily_limit_usd: key.budget.limits.daily_usd.canonical(), daily_used_usd: key.budget.daily_used_usd.canonical(), + daily_resets_at_display: key + .budget + .daily_resets_at + .map(|value| time.datetime(&value.into())), daily_resets_at: key.budget.daily_resets_at.map(DateTime::from), weekly_limit_usd: key.budget.limits.weekly_usd.canonical(), weekly_used_usd: key.budget.weekly_used_usd.canonical(), + weekly_resets_at_display: key + .budget + .weekly_resets_at + .map(|value| time.datetime(&value.into())), weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from), }, summary: metrics( @@ -142,6 +155,14 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView { .into_iter() .map(|point| TrendPointView { time: point.bucket_start, + label: time.label( + point.bucket_start, + if point.granularity == Granularity::Day { + "%m-%d" + } else { + "%m-%d %H:%M %:z" + }, + ), bucket_seconds: match point.granularity { Granularity::FifteenMinutes => 900, Granularity::Hour => 3600, @@ -150,7 +171,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView { metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage), }) .collect(), - health_timeline: health_timeline_view(value.health_timeline), + health_timeline: health_timeline_view(value.health_timeline, time), } } @@ -159,6 +180,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView { pub(super) struct RecordView { id: String, created_at: DateTime, + created_at_display: String, model: Option, route: Option, reasoning_effort: Option, @@ -187,12 +209,13 @@ struct OutputTimingView { first_text_ms: Option, } -fn success_record(value: UsageListRecord) -> RecordView { +fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView { let token_details = Some(usage_list_token_details(&value)); let billing = billing_view(value.billing.as_ref()); RecordView { id: value.id, created_at: value.started_at, + created_at_display: time.datetime(&value.started_at), model: value.requested_model_id, route: Some(value.endpoint), reasoning_effort: value.reasoning_effort, @@ -215,10 +238,11 @@ fn success_record(value: UsageListRecord) -> RecordView { } } -fn error_record(value: OpsError) -> RecordView { +fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView { RecordView { id: value.event_id, created_at: value.occurred_at, + created_at_display: time.datetime(&value.occurred_at), model: value.requested_model_id, route: value.endpoint, reasoning_effort: value.reasoning_effort, @@ -236,16 +260,27 @@ fn error_record(value: OpsError) -> RecordView { } } -pub(super) fn records(value: KeyUsageRecords) -> PageData { +pub(super) fn records( + value: KeyUsageRecords, + time: crate::time::TimePresenter, +) -> PageData { match value { KeyUsageRecords::Success(page) => PageData { - items: page.items.into_iter().map(success_record).collect(), + items: page + .items + .into_iter() + .map(|value| success_record(value, time)) + .collect(), current_page: page.current_page, page_size: page.page_size, total: page.total, }, KeyUsageRecords::Error(page) => PageData { - items: page.items.into_iter().map(error_record).collect(), + items: page + .items + .into_iter() + .map(|value| error_record(value, time)) + .collect(), current_page: page.current_page, page_size: page.page_size, total: page.total, diff --git a/backend/crates/gateway-api/src/key_usage/query.rs b/backend/crates/gateway-api/src/key_usage/query.rs index fdb41262d..a1e4285ea 100644 --- a/backend/crates/gateway-api/src/key_usage/query.rs +++ b/backend/crates/gateway-api/src/key_usage/query.rs @@ -1,11 +1,10 @@ //! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。 use axum::http::StatusCode; -use chrono::{DateTime, Duration}; +use chrono::Duration; use gateway_admin::model::{ PageSize, key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery}, - observability::TimeRange, }; use serde::Deserialize; @@ -18,25 +17,27 @@ pub(super) struct EmptyQuery {} #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct OverviewQuery { - start_time: String, - end_time: String, + period: Option, + as_of: Option, + start_time: Option, + end_time: Option, model: Option, } impl OverviewQuery { - pub(super) fn into_domain(self) -> Result { - let parse = |value: &str| { - DateTime::parse_from_rfc3339(value) - .map(|value| value.to_utc()) - .map_err(|_| { - AdminError::invalid_request( - StatusCode::BAD_REQUEST, - "时间必须使用 RFC3339 格式", - ) - }) - }; - let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?) - .map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?; + pub(super) fn into_domain( + self, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Result { + let range = crate::time::query_range( + self.start_time.as_deref(), + self.end_time.as_deref(), + self.period.as_deref(), + self.as_of, + timezone, + "today", + ) + .map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?; if range.end - range.start > Duration::days(31) { return Err(AdminError::invalid_request( StatusCode::BAD_REQUEST, @@ -71,8 +72,10 @@ enum RecordKind { #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct RecordsQuery { - start_time: String, - end_time: String, + period: Option, + as_of: Option, + start_time: Option, + end_time: Option, model: Option, current_page: Option, page_size: Option, @@ -81,7 +84,10 @@ pub(super) struct RecordsQuery { } impl RecordsQuery { - pub(super) fn into_domain(self) -> Result { + pub(super) fn into_domain( + self, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Result { let current_page = self.current_page.unwrap_or(1); let page_size = self.page_size.unwrap_or(20); if current_page == 0 || !(1..=100).contains(&page_size) { @@ -92,11 +98,13 @@ impl RecordsQuery { } Ok(KeyUsageRecordsQuery { usage: OverviewQuery { + period: self.period, + as_of: self.as_of, start_time: self.start_time, end_time: self.end_time, model: self.model, } - .into_domain()?, + .into_domain(timezone)?, kind: match self.kind { RecordKind::Success => KeyUsageRecordKind::Success, RecordKind::Error => KeyUsageRecordKind::Error, diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index d07bda09b..38106df2b 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -35,6 +35,9 @@ mod middleware; pub mod openai; mod provider; mod session_cookie; +mod time; + +pub use time::{RequestBucketView, TimePresenter}; /// API-owned HTTP 与静态资源配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] diff --git a/backend/crates/gateway-api/src/time.rs b/backend/crates/gateway-api/src/time.rs new file mode 100644 index 000000000..5720f67fd --- /dev/null +++ b/backend/crates/gateway-api/src/time.rs @@ -0,0 +1,136 @@ +//! 管理与自助页面共用的时间展示投影。 + +use chrono::{DateTime, Utc}; +use gateway_core::time::DeploymentTimeZone; + +pub(crate) fn query_range( + start: Option<&str>, + end: Option<&str>, + period: Option<&str>, + as_of: Option, + timezone: DeploymentTimeZone, + default_period: &str, +) -> Result { + use crate::admin::{WireValidationError, observability::parse_datetime}; + use gateway_admin::model::observability::{CalendarPeriod, TimeRange}; + if start.is_some() || end.is_some() { + if period.is_some() || as_of.is_some() { + return Err(WireValidationError::new("timeRange")); + } + let end = parse_datetime(end)?.unwrap_or_else(Utc::now); + let start = match parse_datetime(start)? { + Some(start) => start, + None => { + let period = CalendarPeriod::parse(default_period) + .map_err(|_| WireValidationError::new("period"))?; + return TimeRange::calendar_at(period, end, timezone) + .map_err(|_| WireValidationError::new("timeRange")); + } + }; + return TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange")); + } + let end = match as_of { + Some(value) => DateTime::from_timestamp_millis(value) + .ok_or_else(|| WireValidationError::new("asOf"))?, + None => Utc::now(), + }; + let period = CalendarPeriod::parse(period.unwrap_or(default_period)) + .map_err(|_| WireValidationError::new("period"))?; + TimeRange::calendar_at(period, end, timezone).map_err(|_| WireValidationError::new("timeRange")) +} + +#[derive(Clone, Copy)] +pub struct TimePresenter { + timezone: DeploymentTimeZone, + now: DateTime, +} + +impl TimePresenter { + pub(crate) fn today(self) -> chrono::NaiveDate { + self.timezone.local(self.now).date_naive() + } + pub(crate) fn now(self) -> DateTime { + self.now + } + pub(crate) fn rfc_display(self, value: Option<&str>) -> Option { + value + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| self.datetime(&value.to_utc())) + } + pub(crate) fn request_buckets( + self, + buckets: impl Iterator, u64)>, + ) -> Vec { + buckets + .map(|(start, count)| { + let end = start + chrono::Duration::hours(1); + RequestBucketView { + bucket_start: start, + request_count: count, + label: format!( + "{}–{} · {count} 次请求", + self.label(start, "%m-%d %H:%M %:z"), + self.label(end, "%H:%M %:z") + ), + } + }) + .collect() + } + + #[must_use] + pub fn new(timezone: DeploymentTimeZone) -> Self { + Self { + timezone, + now: Utc::now(), + } + } + + pub(crate) fn datetime(self, value: &DateTime) -> String { + self.label(*value, "%Y-%m-%d %H:%M:%S") + } + + pub(crate) fn time(self, value: &DateTime) -> String { + self.label(*value, "%H:%M:%S") + } + + pub(crate) fn label(self, value: DateTime, format: &str) -> String { + self.timezone.local(value).format(format).to_string() + } + + pub(crate) fn rfc3339(self, value: &DateTime) -> String { + self.timezone.local(*value).to_rfc3339() + } + + pub(crate) fn relative(self, value: DateTime, now: DateTime) -> String { + let elapsed = now.signed_duration_since(value); + if elapsed.num_seconds() < 0 { + return self.datetime(&value); + } + if elapsed.num_seconds() < 60 { + return "刚刚".to_owned(); + } + if elapsed.num_minutes() < 60 { + return format!("{} 分钟前", elapsed.num_minutes()); + } + if elapsed.num_hours() < 24 { + return format!("{} 小时前", elapsed.num_hours()); + } + format!("{} 天前", elapsed.num_days()) + } + + pub(crate) fn relative_optional( + self, + value: Option>, + now: DateTime, + ) -> String { + value.map_or_else(|| "—".to_owned(), |value| self.relative(value, now)) + } +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestBucketView { + pub(crate) bucket_start: DateTime, + pub(crate) request_count: u64, + pub(crate) label: String, +} diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 1f584635a..221f33fb0 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -7,6 +7,135 @@ use tower::ServiceExt as _; use super::super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() { + use chrono::{DateTime, Duration, Utc}; + use gateway_admin::model::{ + Revision, + accounts::{AccountCapacity, AccountPageItem, AccountRecord}, + }; + use gateway_core::account::{ + AccountCooldown, AccountCooldownKind, AccountStatusFacts, CredentialState, QuotaState, + resolve_account_status, + }; + + for zone in ["Asia/Shanghai", "UTC", "Asia/Kathmandu", "America/New_York"] { + let zone = zone + .parse::() + .unwrap(); + let fixture = AdminTestFixture::with_timezone(zone).await; + fixture.auth.insert_session("valid-session"); + for (seconds, kind, expected) in [ + ( + Some(1200), + AccountCooldownKind::RateLimit, + Some("剩余 20 分钟"), + ), + ( + Some(7200), + AccountCooldownKind::CapacityFreeze, + Some("剩余 2 小时"), + ), + ( + Some(7500), + AccountCooldownKind::CapacityFreezeProbe, + Some("剩余 2 小时 5 分"), + ), + ( + Some(-60), + AccountCooldownKind::CapacityFreezeProbe, + Some("等待探测成功"), + ), + (Some(20), AccountCooldownKind::RateLimit, None), + (None, AccountCooldownKind::RateLimit, None), + ] { + let now = Utc::now(); + let until = seconds.map(|seconds| now + Duration::seconds(seconds)); + let facts = AccountStatusFacts { + enabled: true, + credential_state: CredentialState::Ready, + access_token_expires_at: None, + quota: QuotaState::unknown(), + cooldown: until.map(|until| AccountCooldown { + until: until.into(), + kind, + }), + last_error_reason: None, + last_error_message: None, + }; + *fixture.account.lock().unwrap() = Some(AccountPageItem { + account: AccountRecord { + id: "acct_cooldown".to_owned(), + provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(), + groups: Vec::new(), + name: "synthetic cooldown account".to_owned(), + notes: None, + email: None, + upstream_user_id: None, + upstream_account_id: None, + plan_type: None, + authentication_kind: "oauth".to_owned(), + credential_revision: Revision::new(1).unwrap(), + has_refresh_token: true, + access_token_expires_at: None, + next_refresh_at: None, + enabled: true, + concurrency_limit: None, + weight: Default::default(), + model_access: Default::default(), + outbound_proxy: None, + credential_state: facts.credential_state, + credential_observed_at: now, + quota: facts.quota, + last_error_reason: None, + last_error_message: None, + created_at: now, + updated_at: now, + }, + capacity: AccountCapacity { + used_slots: None, + total_slots: None, + }, + projection: resolve_account_status(&facts, now.into()), + }); + let response = admin::accounts::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/accounts") + .header("x-request-id", "req_cooldown_timezone") + .header(header::COOKIE, "cpr_session=valid-session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 32768).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let quota = &value["data"]["items"][0]["quota"]; + match until { + Some(until) => { + assert_eq!( + DateTime::parse_from_rfc3339(quota["rateLimitedUntil"].as_str().unwrap()) + .unwrap() + .to_utc(), + until + ); + let expected = expected.map(str::to_owned).unwrap_or_else(|| { + zone.local(until).format("%Y-%m-%d %H:%M:%S").to_string() + }); + assert_eq!(quota["rateLimitRecoveryDisplay"], expected); + } + None => { + assert!(quota["rateLimitedUntil"].is_null()); + assert!(quota["rateLimitRecoveryDisplay"].is_null()); + } + } + } + } +} + #[tokio::test] async fn standalone_credential_rotation_route_is_not_exposed() { let fixture = AdminTestFixture::new().await; diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index c42fa6484..f4ffbd553 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -14,20 +14,24 @@ mod personal_info { #[test] fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() { let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap(); - let response = AccountSubscriptionData::from(ProviderSubscription { - starts_at: None, - expires_at: observed, - will_renew: None, - billing_period: None, - billing_currency: Some("USD".to_owned()), - observed_at: observed, - }); + let response = AccountSubscriptionData::from(( + ProviderSubscription { + starts_at: None, + expires_at: observed, + will_renew: None, + billing_period: None, + billing_currency: Some("USD".to_owned()), + observed_at: observed, + }, + gateway_api::TimePresenter::new(Default::default()), + )); assert_eq!( serde_json::to_value(response).unwrap(), json!({ - "startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00", + "startsAt": null, "startsAtDisplay": null, + "expiresAt": "2026-09-14T00:00:00+00:00", "expiresAtDisplay": "2026-09-14 08:00:00", "willRenew": null, "billingPeriod": null, "billingCurrency": "USD", - "observedAt": "2026-09-14T00:00:00+00:00" + "observedAt": "2026-09-14T00:00:00+00:00", "observedAtDisplay": "2026-09-14 08:00:00" }) ); } @@ -35,17 +39,20 @@ mod personal_info { #[test] fn personal_info_response_preserves_subscription_when_profile_fails() { let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap(); - let response = AccountPersonalInfoData::from(AccountPersonalInfo { - profile: Err(AdminError::bad_gateway("上游服务请求失败")), - subscription: Some(ProviderSubscription { - starts_at: None, - expires_at: observed, - will_renew: None, - billing_period: None, - billing_currency: None, - observed_at: observed, - }), - }); + let response = AccountPersonalInfoData::from(( + AccountPersonalInfo { + profile: Err(AdminError::bad_gateway("上游服务请求失败")), + subscription: Some(ProviderSubscription { + starts_at: None, + expires_at: observed, + will_renew: None, + billing_period: None, + billing_currency: None, + observed_at: observed, + }), + }, + gateway_api::TimePresenter::new(Default::default()), + )); let value = serde_json::to_value(response).unwrap(); assert!(value["profile"].is_null()); assert_eq!(value["profileError"], "上游服务请求失败"); @@ -58,10 +65,13 @@ mod personal_info { #[test] fn personal_info_response_preserves_unknown_subscription_and_profile_error() { - let response = AccountPersonalInfoData::from(AccountPersonalInfo { - profile: Err(AdminError::unavailable("Provider 服务暂不可用")), - subscription: None, - }); + let response = AccountPersonalInfoData::from(( + AccountPersonalInfo { + profile: Err(AdminError::unavailable("Provider 服务暂不可用")), + subscription: None, + }, + gateway_api::TimePresenter::new(Default::default()), + )); assert_eq!( serde_json::to_value(response).unwrap(), json!({ @@ -184,39 +194,42 @@ mod profile_statistics { #[test] fn profile_statistics_response_preserves_nullable_official_fields() { - let response = AccountProfileStatisticsData::from(ProviderProfileStatistics { - display_name: Some("Ada".to_owned()), - username: Some("ada".to_owned()), - image_url: Some("https://example.test/avatar.png".to_owned()), - has_stats_error: false, - summary: ProviderProfileStatisticsSummary { - total_text_tokens: Some(409_500_000), - peak_tokens: Some(267_000_000), - longest_task_duration_ms: Some(51_900_000), - current_streak_days: Some(16), - longest_streak_days: Some(32), - }, - daily_usage: Some(vec![ProviderProfileDailyUsage { - date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"), - tokens: 42, - }]), - activity_insights: ProviderProfileActivityInsights { - fast_mode_percent: Some(0.0), - reasoning_effort: Some("high".to_owned()), - reasoning_effort_percent: Some(48.0), - skills_explored: Some(8), - total_skills_used: Some(61), - total_threads: Some(2_391), - invocations: Some(vec![ProviderProfileInvocation { - invocation_type: "plugin".to_owned(), - plugin_id: Some("plugin_1".to_owned()), - plugin_name: Some("example".to_owned()), - skill_id: None, - skill_name: None, - usage_count: Some(29), + let response = AccountProfileStatisticsData::from(( + ProviderProfileStatistics { + display_name: Some("Ada".to_owned()), + username: Some("ada".to_owned()), + image_url: Some("https://example.test/avatar.png".to_owned()), + has_stats_error: false, + summary: ProviderProfileStatisticsSummary { + total_text_tokens: Some(409_500_000), + peak_tokens: Some(267_000_000), + longest_task_duration_ms: Some(51_900_000), + current_streak_days: Some(16), + longest_streak_days: Some(32), + }, + daily_usage: Some(vec![ProviderProfileDailyUsage { + date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"), + tokens: 42, }]), + activity_insights: ProviderProfileActivityInsights { + fast_mode_percent: Some(0.0), + reasoning_effort: Some("high".to_owned()), + reasoning_effort_percent: Some(48.0), + skills_explored: Some(8), + total_skills_used: Some(61), + total_threads: Some(2_391), + invocations: Some(vec![ProviderProfileInvocation { + invocation_type: "plugin".to_owned(), + plugin_id: Some("plugin_1".to_owned()), + plugin_name: Some("example".to_owned()), + skill_id: None, + skill_name: None, + usage_count: Some(29), + }]), + }, }, - }); + gateway_api::TimePresenter::new(Default::default()), + )); let value = serde_json::to_value(response).expect("serialize profile statistics"); assert_eq!(value["displayName"], "Ada"); @@ -488,6 +501,7 @@ mod response { read_tokens: None, read_tokens_display: "-".to_owned(), last_used_at: None, + last_used_at_full_display: None, last_used_at_display: "-".to_owned(), cost_estimate_status: "unknown".to_owned(), known_cost_count: None, @@ -861,7 +875,7 @@ mod actions { #[test] fn connection_test_events_should_preserve_the_existing_frontend_contract() { - let events = [ + let mut events = [ DomainConnectionTestEvent::Started { model: "grok-4.5".to_owned(), }, @@ -887,7 +901,32 @@ mod actions { upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()), }, ] - .map(|event| AccountConnectionTestEvent::from(event).data); + .map(|event| { + AccountConnectionTestEvent::from(( + event, + gateway_api::TimePresenter::new(Default::default()), + )) + .data + }); + + let timezone = gateway_core::time::DeploymentTimeZone::default(); + for event in &mut events { + let at = event["occurredAt"] + .as_str() + .unwrap() + .parse::>() + .unwrap(); + let local = timezone.local(at); + assert_eq!( + event["occurredAtDisplay"], + local.format("%Y-%m-%d %H:%M:%S").to_string() + ); + assert_eq!(event["timeDisplay"], local.format("%H:%M:%S").to_string()); + let fields = event.as_object_mut().unwrap(); + fields.remove("occurredAt"); + fields.remove("occurredAtDisplay"); + fields.remove("timeDisplay"); + } assert_eq!( events, diff --git a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs index 59d02703f..f8fafcdd2 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs @@ -36,11 +36,14 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() { monthly.target_seconds = 30 * 86_400; monthly.estimated_tokens = Some(0); monthly.estimated_usd = Some(0.0); - let view = AccountQuotaForecastData::from(AccountQuotaForecastReport { - account_id: "acct_forecast".to_owned(), - generated_at: now, - forecasts: [forecast, monthly], - }); + let view = AccountQuotaForecastData::from(( + AccountQuotaForecastReport { + account_id: "acct_forecast".to_owned(), + generated_at: now, + forecasts: [forecast, monthly], + }, + gateway_api::TimePresenter::new(Default::default()), + )); let value = serde_json::to_value(view).unwrap(); assert_eq!(value["accountId"], "acct_forecast"); assert_eq!(value["generatedAt"], "2026-09-12T08:00:00+08:00"); @@ -107,7 +110,7 @@ fn model_catalog_projection_keeps_upstream_document_and_rejects_non_codex_wire() .expect("codex catalog is projectable"); assert_eq!(data.model_count, 1); assert_eq!(data.catalog, body); - assert_eq!(data.observed_at, "2026-09-12T16:00:00+08:00"); + assert_eq!(data.observed_at, "2026-09-12T08:00:00+00:00"); // 只有模型 ID 的 API 目录拼不出合法的 model_catalog_json,不能降格返回给客户端。 let adapted = RawJsonPayload::new("openai", Bytes::from_static(br#"{"models":[]}"#)) diff --git a/backend/crates/gateway-api/tests/admin/client_keys.rs b/backend/crates/gateway-api/tests/admin/client_keys.rs index c22b15a7a..b5555e977 100644 --- a/backend/crates/gateway-api/tests/admin/client_keys.rs +++ b/backend/crates/gateway-api/tests/admin/client_keys.rs @@ -431,26 +431,29 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() { .with_ymd_and_hms(2026, 7, 18, 8, 0, 0) .single() .expect("valid time"); - let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord { - request_profile_overrides: Default::default(), - budget: Default::default(), - id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"), - name: "visible".to_owned(), - label: None, - groups: Vec::new(), - provider_kinds: vec![ - gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"), - ], - prefix: "sk_visible12".to_owned(), - enabled: true, - limits: gateway_core::policy::RateLimits { - max_concurrency: 2, - requests_per_minute: 60, + let view = ClientKeyView::from(( + gateway_admin::model::client_keys::ClientKeyRecord { + request_profile_overrides: Default::default(), + budget: Default::default(), + id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"), + name: "visible".to_owned(), + label: None, + groups: Vec::new(), + provider_kinds: vec![ + gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"), + ], + prefix: "sk_visible12".to_owned(), + enabled: true, + limits: gateway_core::policy::RateLimits { + max_concurrency: 2, + requests_per_minute: 60, + }, + created_at, + updated_at: created_at, + last_used_at: Some(created_at), }, - created_at, - updated_at: created_at, - last_used_at: Some(created_at), - }); + gateway_api::TimePresenter::new(Default::default()), + )); let list = serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list"); assert!(list.get("configRevision").is_none()); diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 60cb3335c..221e4ea52 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -114,19 +114,24 @@ impl AdminTestFixture { } pub async fn with_system(system: Arc) -> Self { - Self::with_dependencies(system, None).await + Self::with_dependencies(system, None, Default::default()).await } pub async fn with_key_verifier( verifier: Arc, system: Arc, ) -> Self { - Self::with_dependencies(system, Some(verifier)).await + Self::with_dependencies(system, Some(verifier), Default::default()).await + } + + pub async fn with_timezone(timezone: gateway_core::time::DeploymentTimeZone) -> Self { + Self::with_dependencies(Arc::new(UnusedSystem), None, timezone).await } async fn with_dependencies( system: Arc, verifier: Option>, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Self { let api_key = Arc::new(Mutex::new(None)); let auth = Arc::new(MemoryAuthStore::new(api_key.clone())); @@ -184,6 +189,7 @@ impl AdminTestFixture { ClientConfig::default(), stores, gateway_admin::AdminRuntimePorts { + timezone, service_middleware: std::sync::Arc::new(|| None), plugin_preparation: plugin_ports.clone(), plugin_management: plugin_ports.clone(), @@ -1029,6 +1035,26 @@ impl AccountStore for UnusedStore { _: AccountListQuery, _: AccountRuntimeSnapshot, ) -> AdminStoreResult { + if let Some(account) = self.account.lock().expect("account").as_ref() { + let status = account.projection.status; + return Ok(AccountPage { + config_revision: Revision::new(1).unwrap(), + items: vec![account.clone()], + total: 1, + summary: gateway_admin::model::accounts::AccountSummary { + total: 1, + normal: u64::from(status == gateway_core::account::AccountStatus::Normal), + quota_exhausted: u64::from( + status == gateway_core::account::AccountStatus::QuotaExhausted, + ), + rate_limited: u64::from( + status == gateway_core::account::AccountStatus::RateLimited, + ), + disabled: u64::from(status == gateway_core::account::AccountStatus::Disabled), + error: u64::from(status == gateway_core::account::AccountStatus::Error), + }, + }); + } Err(unavailable("account list")) } diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 89e1aa35b..16821226a 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -157,14 +157,14 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() { } #[tokio::test] -async fn dashboard_summary_should_default_to_current_china_day() { +async fn dashboard_summary_should_default_to_current_deployment_day() { use axum::{ body::Body, http::{Request, StatusCode, header}, }; use chrono::{Duration, Utc}; use gateway_admin::model::observability::{ - AccountPoolMetrics, DashboardObservation, TimeRange, china_day_start, + AccountPoolMetrics, DashboardObservation, TimeRange, }; use gateway_api::admin::observability; use tower::ServiceExt as _; @@ -206,7 +206,12 @@ async fn dashboard_summary_should_default_to_current_china_day() { .expect("recorded dashboard summary range"); assert_eq!(response.status(), StatusCode::OK); - assert_eq!(range.start, china_day_start(range.end)); + assert_eq!( + range.start, + gateway_core::time::DeploymentTimeZone::default() + .day_start(range.end) + .unwrap() + ); } #[tokio::test] @@ -981,3 +986,147 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { "$0.007" ); } + +#[tokio::test] +async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor() { + use crate::admin::{AdminTestFixture, AdminTestState}; + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use gateway_admin::model::observability::{CalendarPeriod, DashboardObservation, TimeRange}; + use gateway_api::admin::observability; + use gateway_core::time::DeploymentTimeZone; + use tower::ServiceExt as _; + + for (name, anchor, start, display, slots) in [ + ( + "Asia/Shanghai", + "2026-10-01T00:00:00Z", + "2026-09-30T16:00:00Z", + "2026-10-01 08:00:00", + 96, + ), + ( + "UTC", + "2026-10-01T00:00:00Z", + "2026-10-01T00:00:00Z", + "2026-10-01 00:00:00", + 96, + ), + ( + "Asia/Kathmandu", + "2026-10-01T00:00:00Z", + "2026-09-30T18:15:00Z", + "2026-10-01 05:45:00", + 96, + ), + ( + "America/New_York", + "2026-03-09T03:59:00Z", + "2026-03-08T05:00:00Z", + "2026-03-08 23:59:00", + 92, + ), + ( + "America/New_York", + "2026-11-02T04:59:00Z", + "2026-11-01T04:00:00Z", + "2026-11-01 23:59:00", + 100, + ), + ] { + let timezone: DeploymentTimeZone = name.parse().unwrap(); + let end: chrono::DateTime = anchor.parse().unwrap(); + let range = TimeRange::calendar_at(CalendarPeriod::Today, end, timezone).unwrap(); + let fixture = AdminTestFixture::with_timezone(timezone).await; + fixture.auth.insert_session("valid-session"); + *fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation { + range, + totals: Default::default(), + provider_accounts: Default::default(), + trend: Vec::new(), + account_usage: Vec::new(), + recent_requests: Vec::new(), + }); + let router = observability::router::().with_state(fixture.state()); + let response = router + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/dashboard/summary?period=today&asOf={}", + end.timestamp_millis() + )) + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_timezone_calendar") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{name}: {}", + String::from_utf8_lossy(&bytes) + ); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let queried = fixture.dashboard_summary_range.lock().unwrap().unwrap(); + assert_eq!( + queried.start, + start.parse::>().unwrap() + ); + assert_eq!(queried.end, end); + assert_eq!(value["data"]["asOf"], anchor); + assert_eq!(value["data"]["asOfDisplay"], display); + let points = value["data"]["healthTimeline"]["points"] + .as_array() + .unwrap(); + assert_eq!(points.len(), slots); + let labels = points + .iter() + .map(|p| p["time"].as_str().unwrap()) + .collect::>(); + assert_eq!(labels.len(), slots, "fold labels remain distinct"); + let invalid = router.oneshot(Request::builder() + .uri(format!("/api/admin/dashboard/summary?period=today&asOf={}&startTime=2026-01-01T00%3A00%3A00Z", end.timestamp_millis())) + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_timezone_mixed_range") + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + } +} + +#[tokio::test] +async fn calendar_query_rejects_an_anchor_outside_the_local_calendar() { + use crate::admin::{AdminTestFixture, AdminTestState}; + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use gateway_api::admin::observability; + use tower::ServiceExt as _; + + let fixture = AdminTestFixture::with_timezone(Default::default()).await; + fixture.auth.insert_session("valid-session"); + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/dashboard/summary?period=today&asOf={}", + chrono::DateTime::::MAX_UTC.timestamp_millis() + )) + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_timezone_out_of_range") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(fixture.dashboard_summary_range.lock().unwrap().is_none()); +} diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 2a283dafa..88dd8bdb3 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -244,7 +244,11 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { .expect("timestamp"), }; - let value = serde_json::to_value(RuntimeSettingsView::from(settings)).expect("serialize view"); + let value = serde_json::to_value(RuntimeSettingsView::from(( + settings, + gateway_api::TimePresenter::new(Default::default()), + ))) + .expect("serialize view"); assert_eq!( value, json!({ @@ -284,7 +288,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { "accountWarmupEnabled": false, "accountWarmupScheduleTime": "08:00", "accountWarmupModel": null, - "updatedAt": "2026-08-02T10:30:00Z" + "updatedAt": "2026-08-02T10:30:00Z", + "updatedAtDisplay": "2026-08-02 18:30:00" }) ); } @@ -354,19 +359,22 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { updated_at: chrono::Utc::now(), }; - let response_fields: BTreeSet = - serde_json::to_value(RuntimeSettingsView::from(settings)) - .expect("serialize view") - .as_object() - .expect("view object") - .keys() - .cloned() - .collect(); + let response_fields: BTreeSet = serde_json::to_value(RuntimeSettingsView::from(( + settings, + gateway_api::TimePresenter::new(Default::default()), + ))) + .expect("serialize view") + .as_object() + .expect("view object") + .keys() + .cloned() + .collect(); let mut expected_fields = request_fields; expected_fields.insert("providerRequestProfiles".to_owned()); expected_fields.insert("openaiClientProfile".to_owned()); expected_fields.insert("xaiClientProfile".to_owned()); expected_fields.insert("updatedAt".to_owned()); + expected_fields.insert("updatedAtDisplay".to_owned()); expected_fields.insert("smartSchedulingDefaults".to_owned()); assert_eq!(response_fields, expected_fields); diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 4f963dfe9..88499de37 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -98,6 +98,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/openai/usage.rs", "src/provider.rs", "src/session_cookie.rs", + "src/time.rs", ]; expected.sort_unstable(); diff --git a/backend/crates/gateway-api/tests/key_usage/mod.rs b/backend/crates/gateway-api/tests/key_usage/mod.rs index 43a6051e9..fad2b6b51 100644 --- a/backend/crates/gateway-api/tests/key_usage/mod.rs +++ b/backend/crates/gateway-api/tests/key_usage/mod.rs @@ -7,8 +7,8 @@ use axum::{ http::{Method, StatusCode, header}, response::Response, }; -use chrono::{Duration, Utc}; -use gateway_admin::model::observability::{RequestMetrics, china_day_start}; +use chrono::Utc; +use gateway_admin::model::observability::RequestMetrics; use serde_json::{Value, json}; use tower::ServiceExt as _; @@ -172,6 +172,7 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() { &data, &[ "asOf", + "asOfDisplay", "startTime", "endTime", "key", @@ -190,9 +191,11 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() { "dailyLimitUsd", "dailyUsedUsd", "dailyResetsAt", + "dailyResetsAtDisplay", "weeklyLimitUsd", "weeklyUsedUsd", "weeklyResetsAt", + "weeklyResetsAtDisplay", ], ); assert_eq!(data["key"]["dailyUsedUsd"], "0.640001"); @@ -226,8 +229,20 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() { .iter() .find(|(_, filter)| filter.model.is_none()) .unwrap(); - assert_eq!(health.0.start, china_day_start(health.0.end)); - assert!((Utc::now() - health.0.end) < Duration::seconds(5)); + assert_eq!( + health.0.start, + gateway_core::time::DeploymentTimeZone::default() + .day_start(health.0.end) + .unwrap() + ); + assert_eq!( + health.0.end, + data["asOf"] + .as_str() + .unwrap() + .parse::>() + .unwrap() + ); } #[tokio::test] @@ -254,6 +269,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() { &[ "id", "createdAt", + "createdAtDisplay", "model", "route", "reasoningEffort", diff --git a/backend/crates/gateway-core/Cargo.toml b/backend/crates/gateway-core/Cargo.toml index 1b9edf96f..3318ec1ba 100644 --- a/backend/crates/gateway-core/Cargo.toml +++ b/backend/crates/gateway-core/Cargo.toml @@ -15,6 +15,7 @@ path = "tests/main.rs" async-trait = "0.1.89" base64 = "0.23.0" bytes = "1.11.1" +chrono = { version = "0.4.45", features = ["serde"] } chrono-tz = { version = "0.10", features = ["serde"] } futures = "0.3.32" http = "1.5.0" diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 3a9120fc1..fe634e979 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod routing; pub mod runtime; pub mod settings; pub mod task; +pub mod time; pub mod upstream; pub mod validation; diff --git a/backend/crates/gateway-core/src/provider_ports.rs b/backend/crates/gateway-core/src/provider_ports.rs index c0c0d261b..8b9bfb594 100644 --- a/backend/crates/gateway-core/src/provider_ports.rs +++ b/backend/crates/gateway-core/src/provider_ports.rs @@ -936,6 +936,19 @@ fn invalid_refresh_policy(operation: &'static str) -> ProviderStoreError { } pub trait ProviderRuntimePolicyPort: Send + Sync { + /// 原子领取每日时间槽,跨进程重启与重复本地时刻保持幂等。 + fn claim_warmup_slot<'a>( + &'a self, + _timezone: crate::time::DeploymentTimeZone, + _slot: chrono::NaiveDateTime, + ) -> BoxFuture<'a, Result> { + Box::pin(async { + Err(ProviderStoreError::new( + ProviderStoreErrorKind::Unavailable, + "claim warmup slot", + )) + }) + } /// 仅首次启动写入该 Provider 的默认选择;已保存的管理配置始终优先。 fn initialize_request_profile<'a>( &'a self, diff --git a/backend/crates/gateway-core/src/time.rs b/backend/crates/gateway-core/src/time.rs new file mode 100644 index 000000000..05e15eb78 --- /dev/null +++ b/backend/crates/gateway-core/src/time.rs @@ -0,0 +1,88 @@ +//! 部署时区的日历换算,持久化和比较始终使用 UTC 时间点。 + +use std::str::FromStr; + +use chrono::{DateTime, Days, NaiveDate, NaiveDateTime, Offset as _, TimeZone as _, Utc}; +use chrono_tz::{GapInfo, Tz}; +use serde::{Deserialize, Serialize}; + +/// 由组合根传递的不可变 IANA 时区。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(transparent)] +pub struct DeploymentTimeZone(Tz); + +impl Default for DeploymentTimeZone { + fn default() -> Self { + Self(chrono_tz::Asia::Shanghai) + } +} + +impl FromStr for DeploymentTimeZone { + type Err = chrono_tz::ParseError; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +impl DeploymentTimeZone { + #[must_use] + pub fn name(self) -> &'static str { + self.0.name() + } + + #[must_use] + pub const fn iana(self) -> Tz { + self.0 + } + + #[must_use] + pub fn local(self, value: DateTime) -> DateTime { + value.with_timezone(&self.0) + } + + /// 重复本地时刻只取较早时间点,缺失时刻由调用方决定是否跳过。 + #[must_use] + pub fn resolve_local(self, value: NaiveDateTime) -> Option> { + self.0 + .from_local_datetime(&value) + .earliest() + .map(|value| value.to_utc()) + } + + /// 午夜跳时取缺口结束点,包含整日跳过的历史时区变更。 + #[must_use] + pub fn date_start(self, date: NaiveDate) -> Option> { + let midnight = date.and_hms_opt(0, 0, 0)?; + self.resolve_local(midnight).or_else(|| { + GapInfo::new(&midnight, &self.0)? + .end + .map(|value| value.to_utc()) + }) + } + + #[must_use] + pub fn day_start(self, value: DateTime) -> Option> { + self.date_start(self.local_date(value)?) + } + + /// 先移动本地日期再解析时间点,避免把自然日写成固定 24 小时。 + #[must_use] + pub fn days_before(self, value: DateTime, days: u64) -> Option> { + self.date_start(self.local_date(value)?.checked_sub_days(Days::new(days))?) + } + + #[must_use] + pub fn days_after(self, value: DateTime, days: u64) -> Option> { + self.date_start(self.local_date(value)?.checked_add_days(Days::new(days))?) + } + + fn local_date(self, value: DateTime) -> Option { + let local = self.local(value); + // UTC 可表示的端点在叠加时区偏移后仍可能超出本地日历范围。 + local + .naive_utc() + .checked_add_offset(local.offset().fix()) + .map(|value| value.date()) + } +} diff --git a/backend/crates/gateway-core/tests/main.rs b/backend/crates/gateway-core/tests/main.rs index 427570963..5e5b5d9e5 100644 --- a/backend/crates/gateway-core/tests/main.rs +++ b/backend/crates/gateway-core/tests/main.rs @@ -16,3 +16,5 @@ mod runtime; mod settings; mod task; mod upstream; + +mod time; diff --git a/backend/crates/gateway-core/tests/time.rs b/backend/crates/gateway-core/tests/time.rs new file mode 100644 index 000000000..acb8cbb08 --- /dev/null +++ b/backend/crates/gateway-core/tests/time.rs @@ -0,0 +1,106 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use gateway_core::time::DeploymentTimeZone; + +fn utc(value: &str) -> DateTime { + value.parse().unwrap() +} + +#[test] +fn calendar_boundaries_follow_offsets_and_dst_instead_of_fixed_hours() { + for (zone, at, start, end, hours) in [ + ( + "Asia/Shanghai", + "2026-01-01T00:00:00Z", + "2025-12-31T16:00:00Z", + "2026-01-01T16:00:00Z", + 24, + ), + ( + "UTC", + "2026-01-01T05:00:00Z", + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z", + 24, + ), + ( + "Asia/Kathmandu", + "2026-01-01T00:00:00Z", + "2025-12-31T18:15:00Z", + "2026-01-01T18:15:00Z", + 24, + ), + ( + "America/New_York", + "2026-03-08T16:00:00Z", + "2026-03-08T05:00:00Z", + "2026-03-09T04:00:00Z", + 23, + ), + ( + "America/New_York", + "2026-11-01T16:00:00Z", + "2026-11-01T04:00:00Z", + "2026-11-02T05:00:00Z", + 25, + ), + ] { + let zone: DeploymentTimeZone = zone.parse().unwrap(); + let at = utc(at); + assert_eq!(zone.day_start(at), Some(utc(start))); + assert_eq!(zone.days_after(at, 1), Some(utc(end))); + assert_eq!((utc(end) - utc(start)).num_hours(), hours); + } + let zone: DeploymentTimeZone = "America/New_York".parse().unwrap(); + let start = utc("2026-03-06T05:00:00Z"); + assert_eq!(zone.days_after(start, 7), Some(utc("2026-03-13T04:00:00Z"))); +} + +#[test] +fn local_slots_skip_gaps_and_pick_only_the_first_fold_instant() { + let zone: DeploymentTimeZone = "America/New_York".parse().unwrap(); + assert!( + zone.resolve_local("2026-03-08T02:30:00".parse().unwrap()) + .is_none() + ); + assert_eq!( + zone.resolve_local("2026-11-01T01:30:00".parse().unwrap()), + Some(utc("2026-11-01T05:30:00Z")) + ); + let zone: DeploymentTimeZone = "America/Sao_Paulo".parse().unwrap(); + assert_eq!( + zone.date_start(NaiveDate::from_ymd_opt(2018, 11, 4).unwrap()), + Some(utc("2018-11-04T03:00:00Z")) + ); + let zone: DeploymentTimeZone = "Pacific/Apia".parse().unwrap(); + assert_eq!( + zone.date_start(NaiveDate::from_ymd_opt(2011, 12, 30).unwrap()), + Some(utc("2011-12-30T10:00:00Z")) + ); +} + +#[test] +fn deployment_timezone_rejects_invalid_names_and_types() { + assert_eq!(DeploymentTimeZone::default().name(), "Asia/Shanghai"); + for value in [ + serde_json::json!(""), + serde_json::json!("UTC+8"), + serde_json::json!("Not/AZone"), + serde_json::json!(null), + serde_json::json!(8), + ] { + assert!(serde_json::from_value::(value).is_err()); + } +} + +#[test] +fn calendar_boundaries_reject_unrepresentable_local_dates() { + for (zone, value) in [ + ("Asia/Shanghai", DateTime::::MAX_UTC), + ("America/New_York", DateTime::::MIN_UTC), + ] { + let zone: DeploymentTimeZone = zone.parse().unwrap(); + assert_eq!(zone.day_start(value), None); + assert_eq!(zone.days_before(value, 0), None); + assert_eq!(zone.days_after(value, 0), None); + } +} diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index b4dff0043..4fa36d20b 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -34,6 +34,7 @@ semver = "1.0.27" secrecy = "0.10.3" serde = { version = "1.0.228", features = ["derive"] } serde_ignored = "0.1.14" +serde_path_to_error = "0.1.20" serde_json = "1.0.150" sha2 = "0.11.0" tar = "0.4.44" diff --git a/backend/crates/gateway-host/src/config.rs b/backend/crates/gateway-host/src/config.rs index 0e6be4a0b..269c24fab 100644 --- a/backend/crates/gateway-host/src/config.rs +++ b/backend/crates/gateway-host/src/config.rs @@ -35,16 +35,21 @@ pub fn load_config() -> Result { .build() .map_err(|_| ConfigError::InvalidDocument { path: path.clone() })?; let mut unused = std::collections::BTreeSet::new(); - let mut value: T = serde_ignored::deserialize(document, |field| { + let mut ignored = |field: serde_ignored::Path<'_>| { unused.insert(field.to_string()); - }) - .map_err(|error| match missing_config_field(&error) { - Some(field) => ConfigError::MissingField { - path: path.clone(), - field, - }, - None => ConfigError::InvalidDocument { path: path.clone() }, - })?; + }; + let mut value: T = + serde_path_to_error::deserialize(serde_ignored::Deserializer::new(document, &mut ignored)) + .map_err(|error| match missing_config_field(error.inner()) { + Some(field) => ConfigError::MissingField { + path: path.clone(), + field, + }, + None => ConfigError::InvalidValue { + path: path.clone(), + field: error.path().to_string(), + }, + })?; // 此时日志尚未初始化;只报告字段路径,不回显可能包含凭据的配置值。 for field in unused { if !T::EXTERNAL_SECTIONS.contains(&field.as_str()) { @@ -69,6 +74,8 @@ fn missing_config_field(error: &config::ConfigError) -> Option { /// Host 唯一拥有的进程配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct HostConfig { + #[serde(default)] + pub timezone: gateway_core::time::DeploymentTimeZone, pub listen: ListenConfig, pub runtime_data_dir: PathBuf, pub logging: LoggingConfig, @@ -216,6 +223,8 @@ pub struct FileLoggingConfig { #[derive(Debug, thiserror::Error)] pub enum ConfigError { + #[error("配置文件 {path} 的字段 {field:?} 不合法")] + InvalidValue { path: PathBuf, field: String }, #[error("current directory is unavailable")] CurrentDirectory, #[error("deploy/config.yaml was not found")] diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index fb5f51d1f..6970ff317 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -56,7 +56,7 @@ pub async fn initialize(config: HostConfig) -> Result { .official_plugins_dir() .map_err(|_| HostError::OfficialPluginRelease)?, )); - let log_guard = initialize_logging(&config.logging)?; + let log_guard = initialize_logging(&config.logging, config.timezone)?; let cancellation = CancellationToken::new(); let connections = Arc::new(ConnectionTracker::new(cancellation.clone())); let workers = WorkerSupervisor::new(cancellation.clone()); diff --git a/backend/crates/gateway-host/src/logging/mod.rs b/backend/crates/gateway-host/src/logging/mod.rs index 9a8b58fc3..5063428a6 100644 --- a/backend/crates/gateway-host/src/logging/mod.rs +++ b/backend/crates/gateway-host/src/logging/mod.rs @@ -48,7 +48,10 @@ pub enum LogError { } /// 初始化日志;文件按完整日期留存,不以分片数量淘汰窗口内记录。 -pub fn initialize_logging(config: &LoggingConfig) -> Result { +pub fn initialize_logging( + config: &LoggingConfig, + timezone: gateway_core::time::DeploymentTimeZone, +) -> Result { let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone()); let file_filter = application_file_filter(&directive)?; let recovery_file_filter = oauth_recovery_file_filter(); @@ -70,6 +73,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let file_writer = if config.file.enabled { let (writer, guard) = create_file_writer( config, + timezone, config.file.retention_days, APPLICATION_LOG_FILE_PREFIX, "gateway-log-application-file", @@ -83,6 +87,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let recovery_file_writer = if config.oauth_recovery { let (writer, guard) = create_file_writer( config, + timezone, config.file.retention_days, OAUTH_RECOVERY_LOG_FILE_PREFIX, "gateway-log-oauth-recovery-file", @@ -96,6 +101,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let request_dump_file_writer = if config.request_dump { let (writer, guard) = create_file_writer( config, + timezone, config.request_dump_retention_days, REQUEST_DUMP_LOG_FILE_PREFIX, "gateway-log-request-dump-file", @@ -110,6 +116,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let stdout_layer = stdout_writer.map(|writer| { tracing_subscriber::fmt::layer() .compact() + .with_timer(DeploymentClock(timezone)) .with_writer(writer) .with_target(false) .with_ansi(false) @@ -118,6 +125,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let file_layer = file_writer.map(|writer| { tracing_subscriber::fmt::layer() .json() + .with_timer(DeploymentClock(timezone)) .with_writer(writer) .with_target(true) .with_file(true) @@ -131,6 +139,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let recovery_file_layer = recovery_file_writer.map(|writer| { tracing_subscriber::fmt::layer() .json() + .with_timer(DeploymentClock(timezone)) .with_writer(writer) .with_target(true) .with_file(true) @@ -144,6 +153,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result let request_dump_file_layer = request_dump_file_writer.map(|writer| { tracing_subscriber::fmt::layer() .json() + .with_timer(DeploymentClock(timezone)) .with_writer(writer) .with_target(true) .with_file(true) @@ -209,6 +219,7 @@ impl LogGuard { fn create_file_writer( config: &LoggingConfig, + timezone: gateway_core::time::DeploymentTimeZone, retention_days: usize, prefix: &'static str, thread_name: &'static str, @@ -225,6 +236,19 @@ fn create_file_writer( maximum_bytes, retention_days, Arc::clone(&health), + timezone, )?; Ok(FileLogSink::spawn(writer, thread_name, health)?) } + +#[derive(Clone, Copy)] +struct DeploymentClock(gateway_core::time::DeploymentTimeZone); + +impl tracing_subscriber::fmt::time::FormatTime for DeploymentClock { + fn format_time( + &self, + writer: &mut tracing_subscriber::fmt::format::Writer<'_>, + ) -> std::fmt::Result { + write!(writer, "{}", self.0.local(chrono::Utc::now()).to_rfc3339()) + } +} diff --git a/backend/crates/gateway-host/src/logging/writer.rs b/backend/crates/gateway-host/src/logging/writer.rs index 74e112cfd..e33007b0c 100644 --- a/backend/crates/gateway-host/src/logging/writer.rs +++ b/backend/crates/gateway-host/src/logging/writer.rs @@ -1,4 +1,4 @@ -//! 按大小轮转、压缩已关闭分片、按完整 UTC 日期组清理。 +//! 按大小轮转、压缩已关闭分片、按部署时区自然日期组清理。 use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; @@ -12,6 +12,7 @@ use flate2::{Compression, write::GzEncoder}; use super::sink::LogHealth; pub(super) struct RotatingLogWriter { + timezone: gateway_core::time::DeploymentTimeZone, directory: PathBuf, prefix: &'static str, maximum_bytes: u64, @@ -30,10 +31,11 @@ impl RotatingLogWriter { maximum_bytes: u64, retention_days: usize, health: Arc, + timezone: gateway_core::time::DeploymentTimeZone, ) -> io::Result { fs::create_dir_all(&directory)?; - let date = Utc::now().date_naive(); - cleanup_log_files(&directory, prefix, date, retention_days)?; + let date = timezone.local(Utc::now()).date_naive(); + cleanup_log_files(&directory, prefix, date, retention_days, timezone)?; let latest = managed_log_files(&directory, prefix)? .into_iter() .filter(|entry| entry.date == date) @@ -48,6 +50,7 @@ impl RotatingLogWriter { let path = directory.join(log_file_name(prefix, date, segment)); let file = open_log_segment(&path)?; let writer = Self { + timezone, directory, prefix, maximum_bytes, @@ -71,7 +74,7 @@ impl RotatingLogWriter { } fn rotate_if_required(&mut self, incoming_bytes: usize) -> io::Result<()> { - let date = Utc::now().date_naive(); + let date = self.timezone.local(Utc::now()).date_naive(); let day_changed = date != self.date; let size_exceeded = self.bytes_written > 0 && self.bytes_written.saturating_add(incoming_bytes as u64) > self.maximum_bytes; @@ -105,9 +108,13 @@ impl RotatingLogWriter { self.health.maintenance_failed(error.kind()); } // Maintenance errors must not discard the record that triggered rotation. - if let Err(error) = - cleanup_log_files(&self.directory, self.prefix, date, self.retention_days) - { + if let Err(error) = cleanup_log_files( + &self.directory, + self.prefix, + date, + self.retention_days, + self.timezone, + ) { self.health.maintenance_failed(error.kind()); } Ok(()) @@ -197,6 +204,7 @@ fn cleanup_log_files( prefix: &str, today: NaiveDate, retention_days: usize, + timezone: gateway_core::time::DeploymentTimeZone, ) -> io::Result<()> { let days = u64::try_from(retention_days).map_err(|_| io::Error::other("log retention overflow"))?; @@ -211,12 +219,11 @@ fn cleanup_log_files( if date >= cutoff { continue; } - // Keep a whole date if any segment was written more recently than its filename. - // This also protects archives recovered after clock corrections or manual restoration. + // 用较近的文件名日期或实际写入日期保护整组,避免时区切换后提前清理。 let mut latest = date; for path in &paths { let modified: chrono::DateTime = path.metadata()?.modified()?.into(); - latest = latest.max(modified.date_naive()); + latest = latest.max(timezone.local(modified).date_naive()); } if latest >= cutoff { continue; diff --git a/backend/crates/gateway-host/tests/config.rs b/backend/crates/gateway-host/tests/config.rs index 79428ce18..33afacb74 100644 --- a/backend/crates/gateway-host/tests/config.rs +++ b/backend/crates/gateway-host/tests/config.rs @@ -201,6 +201,7 @@ fn valid_config() -> HostConfig { ..SystemUpdateConfig::default() }; HostConfig { + timezone: Default::default(), listen: ListenConfig { host: "127.0.0.1".to_owned(), port: 8080, diff --git a/backend/crates/gateway-host/tests/logging/mod.rs b/backend/crates/gateway-host/tests/logging/mod.rs index eeed6d4e9..12c059245 100644 --- a/backend/crates/gateway-host/tests/logging/mod.rs +++ b/backend/crates/gateway-host/tests/logging/mod.rs @@ -10,6 +10,7 @@ use gateway_host::system_update::SystemUpdateConfig; mod sink; mod writer; +const LOG_TIMEZONE_ENV: &str = "CPR_LOGGING_TEST_TIMEZONE"; const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY"; const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD"; const REQUEST_DUMP_ENABLED_ENV: &str = "CPR_LOGGING_TEST_REQUEST_DUMP_ENABLED"; @@ -28,6 +29,7 @@ const REQUEST_DUMP_SECRET: &str = "unredacted-authorization-value"; #[test] fn logging_requires_at_least_one_sink() { let mut config = HostConfig { + timezone: Default::default(), listen: ListenConfig { host: "127.0.0.1".to_owned(), port: 8080, @@ -302,6 +304,10 @@ fn with_logging(config: HostConfig, write: impl FnOnce()) { fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig { HostConfig { + timezone: env::var(LOG_TIMEZONE_ENV) + .ok() + .map(|value| value.parse().unwrap()) + .unwrap_or_default(), listen: ListenConfig { host: "127.0.0.1".to_owned(), port: 8080, @@ -373,3 +379,49 @@ fn log_file_set_exists(directory: &Path, file_prefix: &str) -> bool { fn json_target_field(target: &str) -> String { format!(r#""target":"{target}""#) } + +#[test] +fn deployment_timezone_controls_log_timestamp_and_file_date() { + for name in ["Asia/Shanghai", "UTC", "America/New_York", "Asia/Kathmandu"] { + let directory = tempfile::tempdir().unwrap(); + let output = Command::new(env::current_exe().unwrap()) + .args([ + "--exact", + "logging::sensitive_file_logging_is_separate_and_overrides_global_log_level", + ]) + .env(CHILD_PROCESS_ENV, "1") + .env(LOG_DIRECTORY_ENV, directory.path()) + .env(LOG_TIMEZONE_ENV, name) + .env(REQUEST_DUMP_ENABLED_ENV, "true") + .env("RUST_LOG", "off,logging_test_application=info") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let timezone: gateway_core::time::DeploymentTimeZone = name.parse().unwrap(); + for prefix in [ + APPLICATION_LOG_FILE_PREFIX, + OAUTH_RECOVERY_LOG_FILE_PREFIX, + REQUEST_DUMP_LOG_FILE_PREFIX, + ] { + let body = read_log_file_set(directory.path(), prefix); + let record: serde_json::Value = + serde_json::from_str(body.lines().next().unwrap()).unwrap(); + let timestamp = + chrono::DateTime::parse_from_rfc3339(record["timestamp"].as_str().unwrap()) + .unwrap(); + assert_eq!( + timestamp.to_rfc3339(), + timezone.local(timestamp.to_utc()).to_rfc3339() + ); + let path = directory.path().join(format!( + "{prefix}{}.log", + timezone.local(timestamp.to_utc()).date_naive() + )); + assert!(path.exists(), "{name}: {}", path.display()); + } + } +} diff --git a/backend/crates/gateway-host/tests/logging/sink.rs b/backend/crates/gateway-host/tests/logging/sink.rs index 7c9ede72a..207862be5 100644 --- a/backend/crates/gateway-host/tests/logging/sink.rs +++ b/backend/crates/gateway-host/tests/logging/sink.rs @@ -64,7 +64,9 @@ fn write_failure_marks_log_completeness_unhealthy_even_after_writes_recover() { assert_eq!(runtime.block_on(health.check()), HealthState::Healthy); let blocked = directory.join(format!( "{REQUEST_DUMP_LOG_FILE_PREFIX}{}.1.log", - chrono::Utc::now().date_naive() + gateway_core::time::DeploymentTimeZone::default() + .local(chrono::Utc::now()) + .date_naive() )); fs::create_dir(&blocked).unwrap(); let payload = "x".repeat(1024 * 1024); diff --git a/backend/crates/gateway-host/tests/logging/writer.rs b/backend/crates/gateway-host/tests/logging/writer.rs index 6c0031240..577dde160 100644 --- a/backend/crates/gateway-host/tests/logging/writer.rs +++ b/backend/crates/gateway-host/tests/logging/writer.rs @@ -22,7 +22,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart() return; } let directory = tempfile::tempdir().unwrap(); - let today = chrono::Utc::now().date_naive(); + let today = gateway_core::time::DeploymentTimeZone::default() + .local(chrono::Utc::now()) + .date_naive(); let mut retained = Vec::new(); let mut expired = Vec::new(); for (prefix, days) in [ @@ -42,7 +44,7 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart() let path = directory.path().join(format!("{prefix}{old_date}.log")); seed_log(&path, old_date, "expired date\n"); expired.push(path); - // If any old segment was recently written, protect its whole UTC date. + // 时区切换或恢复的文件按较近写入日期保护整个分段组。 let restored_date = old_date - chrono::Days::new(1); for segment in 0..=1 { let path = directory @@ -139,10 +141,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart() fn seed_log(path: &Path, date: chrono::NaiveDate, body: &str) { fs::write(path, body).unwrap(); - let modified = chrono::DateTime::::from_naive_utc_and_offset( - date.and_hms_opt(0, 0, 0).unwrap(), - chrono::Utc, - ); + let modified = gateway_core::time::DeploymentTimeZone::default() + .date_start(date) + .unwrap(); fs::File::open(path) .unwrap() .set_modified(SystemTime::from(modified)) diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index 1e25fb32d..253bb73a1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -529,6 +529,7 @@ impl Environment { ClientConfig::default(), self.store.admin_ports(), gateway_admin::AdminRuntimePorts { + timezone: Default::default(), service_middleware: { let snapshots = core.snapshots(); let middleware = runtime.middleware_registry(); diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index 9e9a84ced..e903ffc4b 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -164,7 +164,10 @@ async fn connect( observability_query_budget.clone(), )), admin_account_runtime, - Arc::new(postgres::PgAccountGroupRepository::new(pool.clone())), + Arc::new( + postgres::PgAccountGroupRepository::new(pool.clone()) + .with_timezone(config.timezone), + ), Arc::new(postgres::PgProxyRepository::new(pool.clone())), ), Arc::new(AuthStoreAdapter { @@ -174,12 +177,15 @@ async fn connect( state: redis::RedisAuthStateRepository::new(redis_connection.clone(), REDIS_NAMESPACE)?, }), Arc::new(postgres::PgAdminClientKeyStore::new(pool.clone())), - Arc::new(postgres::PgAdminObservabilityStore::new( - pool.clone(), - Some(credential_leases.clone()), - Some(Arc::clone(&cooldowns) as Arc), - observability_query_budget, - )), + Arc::new( + postgres::PgAdminObservabilityStore::new( + pool.clone(), + Some(credential_leases.clone()), + Some(Arc::clone(&cooldowns) as Arc), + observability_query_budget, + ) + .with_timezone(config.timezone), + ), Arc::new(AdminSettingsStoreAdapter { control_plane: postgres::PgControlPlaneRepository::new(pool.clone()), }), @@ -225,7 +231,9 @@ async fn connect( ), Arc::new(client_key_usage), ) - .with_budget(Arc::new(postgres::PgClientBudgetStore::new(pool.clone()))) + .with_budget(Arc::new( + postgres::PgClientBudgetStore::new(pool.clone()).with_timezone(config.timezone), + )) .with_session_affinity(Arc::clone(&provider_session_affinity)); let provider_ports = ProviderStorePorts::new( diff --git a/backend/crates/gateway-store/src/config.rs b/backend/crates/gateway-store/src/config.rs index f44205d38..83e262238 100644 --- a/backend/crates/gateway-store/src/config.rs +++ b/backend/crates/gateway-store/src/config.rs @@ -20,6 +20,8 @@ pub(crate) const POSTGRES_HEALTH_RETRY_DELAY: Duration = Duration::from_millis(5 /// Store 自己拥有并校验的启动配置。 #[derive(Clone, Deserialize)] pub struct StoreConfig { + #[serde(skip)] + pub(crate) timezone: gateway_core::time::DeploymentTimeZone, pub(crate) database: StoreConnectionConfig, pub(crate) redis: StoreConnectionConfig, #[serde(default)] @@ -70,6 +72,12 @@ impl StorePoolConfig { } impl StoreConfig { + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self + } + pub fn resolve_and_validate(&mut self, runtime_data_dir: &Path) -> StoreResult<()> { if runtime_data_dir.as_os_str().is_empty() { return Err(StoreError::InvalidData { diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 237ef879e..3374b6aaa 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -37,13 +37,23 @@ const ENTITY: &str = "account group"; /// Account group store with transactional revision and audit ownership. #[derive(Clone)] pub struct PgAccountGroupRepository { + timezone: gateway_core::time::DeploymentTimeZone, pool: PgPool, } impl PgAccountGroupRepository { #[must_use] - pub const fn new(pool: PgPool) -> Self { - Self { pool } + pub fn new(pool: PgPool) -> Self { + Self { + pool, + timezone: Default::default(), + } + } + + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self } async fn current_revision(&self) -> AdminStoreResult { @@ -60,7 +70,7 @@ impl PgAccountGroupRepository { .await .map_err(|error| admin_store_error(ENTITY, error))? .ok_or_else(|| not_found(id.as_str()))?; - let costs = group_costs(&self.pool, &[id.as_str().to_owned()]) + let costs = group_costs(&self.pool, &[id.as_str().to_owned()], self.timezone) .await .map_err(|error| admin_store_error(ENTITY, error))?; if let Some(usage) = costs.get(id.as_str()) { @@ -136,7 +146,7 @@ impl AccountGroupStore for PgAccountGroupRepository { .iter() .map(|record| record.id.as_str().to_owned()) .collect::>(); - let costs = group_costs(&self.pool, &group_ids) + let costs = group_costs(&self.pool, &group_ids, self.timezone) .await .map_err(|error| admin_store_error(ENTITY, error))?; for record in &mut items { @@ -516,6 +526,7 @@ fn group_record(row: &sqlx::postgres::PgRow) -> StoreResult async fn group_costs( pool: &PgPool, group_ids: &[String], + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult> { if group_ids.is_empty() { return Ok(BTreeMap::new()); @@ -529,8 +540,7 @@ async fn group_costs( ) select requested_groups.group_id, coalesce(sum(mr.cost_amount) filter ( - where mr.started_at >= date_trunc('day', now() at time zone 'Asia/Shanghai') - at time zone 'Asia/Shanghai' + where mr.started_at >= $2 ), 0)::text as today_usd, coalesce(sum(mr.cost_amount), 0)::text as retained_total_usd from requested_groups @@ -549,6 +559,11 @@ async fn group_costs( // 动态片段仅为共享的固定 usage-fact predicate;group IDs 仍使用 bind。 let rows = sqlx::query(sqlx::AssertSqlSafe(statement)) .bind(group_ids) + .bind( + timezone + .day_start(chrono::Utc::now()) + .ok_or_else(|| invalid("invalid business date"))?, + ) .fetch_all(pool) .await .map_err(|_| unavailable("load account group costs"))?; diff --git a/backend/crates/gateway-store/src/postgres/backup.rs b/backend/crates/gateway-store/src/postgres/backup.rs index dd7b9e766..f7b04587a 100644 --- a/backend/crates/gateway-store/src/postgres/backup.rs +++ b/backend/crates/gateway-store/src/postgres/backup.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use gateway_admin::model::audit::MutationAuditOperation; use secrecy::{ExposeSecret as _, SecretString}; -use sqlx::{PgPool, Postgres, Row as _, Transaction}; +use sqlx::{Acquire as _, PgPool, Postgres, Row as _, Transaction}; use gateway_admin::model::backup::{ BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupRecordSeed, BackupSettings, @@ -155,6 +155,7 @@ impl BackupRepository for PgBackupRepository { command: UpdateBackupScheduleCommand, next_run_at: Option>, context: &MutationContext, + timezone: gateway_core::time::DeploymentTimeZone, ) -> AdminStoreResult { let mut transaction = self .pool @@ -175,7 +176,7 @@ impl BackupRepository for PgBackupRepository { ) .bind(command.schedule_enabled) .bind(&command.cron_expression) - .bind(&command.schedule_timezone) + .bind(timezone.name()) .bind(i64::from(command.retention_days)) .bind(i64::from(command.retention_count)) .bind(next_run_at) @@ -266,35 +267,78 @@ impl BackupRepository for PgBackupRepository { } } - async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult { + async fn insert_scheduled_record( + &self, + seed: BackupRecordSeed, + next_run_at: Option>, + expected_cron: &str, + expected_timezone: &str, + expected_next_run_at: Option>, + ) -> AdminStoreResult { let mut transaction = self .pool .begin() .await .map_err(|_| store_unavailable("begin scheduled insert"))?; - match insert_queued_in_transaction(&mut transaction, &seed).await { + // 先锁定并核对计划,防止配置变更后仍排入旧计划任务。 + let advanced = sqlx::query( + "update backup_settings set next_run_at = $1 + where id = 1 and schedule_enabled and cron_expression = $2 + and schedule_timezone = $3 and next_run_at is not distinct from $4", + ) + .bind(next_run_at) + .bind(expected_cron) + .bind(expected_timezone) + .bind(expected_next_run_at) + .execute(&mut *transaction) + .await + .map_err(|_| store_unavailable("guard scheduled insert"))? + .rows_affected() + > 0; + if !advanced { + transaction + .rollback() + .await + .map_err(|_| store_unavailable("rollback stale schedule"))?; + return Ok(false); + } + // 唯一约束冲突只跳过本次任务;保存点避免连带撤销游标推进。 + let mut insertion = transaction + .begin() + .await + .map_err(|_| store_unavailable("begin scheduled savepoint"))?; + let inserted = match insert_queued_in_transaction(&mut insertion, &seed).await { Ok(_) => { - transaction + insertion .commit() .await - .map_err(|_| store_unavailable("commit scheduled insert"))?; - Ok(true) + .map_err(|_| store_unavailable("release scheduled savepoint"))?; + true } Err(error) if is_active_or_scheduled_conflict(&error) => { - transaction + insertion .rollback() .await - .map_err(|_| store_unavailable("rollback scheduled insert"))?; - Ok(false) + .map_err(|_| store_unavailable("rollback scheduled savepoint"))?; + false } Err(error) => { + insertion + .rollback() + .await + .map_err(|_| store_unavailable("rollback scheduled savepoint"))?; transaction .rollback() .await .map_err(|_| store_unavailable("rollback scheduled insert"))?; - Err(error) + return Err(error); } - } + }; + transaction + .commit() + .await + .map_err(|_| store_unavailable("commit scheduled insert"))?; + Ok(inserted) } async fn list_backup_records( @@ -537,19 +581,24 @@ impl BackupRepository for PgBackupRepository { &self, next_run_at: DateTime, expected_cron: &str, - expected_timezone: &str, + expected_timezone: Option<&str>, + expected_next_run_at: Option>, + timezone: &str, ) -> AdminStoreResult { let result = sqlx::query( "update backup_settings - set next_run_at = $1 + set next_run_at = $1, schedule_timezone = $5 where id = 1 and schedule_enabled and cron_expression = $2 - and schedule_timezone = $3", + and schedule_timezone is not distinct from $3 + and next_run_at is not distinct from $4", ) .bind(next_run_at) .bind(expected_cron) .bind(expected_timezone) + .bind(expected_next_run_at) + .bind(timezone) .execute(&self.pool) .await .map_err(|_| store_unavailable("advance backup schedule cursor"))?; diff --git a/backend/crates/gateway-store/src/postgres/client_budgets.rs b/backend/crates/gateway-store/src/postgres/client_budgets.rs index 1d2aca365..d604d24eb 100644 --- a/backend/crates/gateway-store/src/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/src/postgres/client_budgets.rs @@ -126,6 +126,7 @@ async fn reset_client_key_budget_in_transaction( } pub struct PgClientBudgetStore { + timezone: gateway_core::time::DeploymentTimeZone, pool: PgPool, retry: Mutex>, } @@ -134,11 +135,18 @@ impl PgClientBudgetStore { #[must_use] pub fn new(pool: PgPool) -> Self { Self { + timezone: Default::default(), pool, retry: Mutex::new(BTreeMap::new()), } } + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self + } + async fn admit_inner(&self, key_id: ClientApiKeyId) -> Result<(), GatewayError> { // 短暂存储故障后按原金额重试;进程退出丢失的费用不转成人工核账或阻断 Key。 let retries = self @@ -184,7 +192,7 @@ impl PgClientBudgetStore { .map_err(|_| unavailable())?, }; let now = Utc::now(); - advance_windows(&mut tx, key_id.as_str(), now, now) + advance_windows(&mut tx, key_id.as_str(), now, now, self.timezone) .await .map_err(|_| unavailable())?; if limits.is_limited() { @@ -241,7 +249,7 @@ impl PgClientBudgetStore { .await .map_err(|_| ClientBudgetError)?; let Some(key) = key else { return Ok(()) }; // 删除 Key 时也会删除其费用记录。 - settle_in_transaction(&mut tx, &key, charge) + settle_in_transaction(&mut tx, &key, charge, self.timezone) .await .map_err(|_| ClientBudgetError)?; tx.commit().await.map_err(|_| ClientBudgetError) @@ -252,6 +260,7 @@ async fn settle_in_transaction( tx: &mut Transaction<'_, Postgres>, key: &str, charge: &ClientBudgetCharge, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Result<(), sqlx::Error> { let completed_at = DateTime::::from(charge.completed_at); // 仅在请求结束时写入费用;请求 ID 冲突时不重复累计。 @@ -268,7 +277,7 @@ async fn settle_in_transaction( .await? .rows_affected(); if changed == 1 { - advance_windows(tx, key, Utc::now(), completed_at).await?; + advance_windows(tx, key, Utc::now(), completed_at, timezone).await?; sqlx::query("update client_key_budget_windows set daily_used_usd = daily_used_usd + case when $3 >= daily_start and $3 < daily_end then $2::text::numeric else 0 end, weekly_used_usd = weekly_used_usd + case when $3 >= weekly_start and $3 < weekly_end then $2::text::numeric else 0 end @@ -303,20 +312,29 @@ async fn advance_windows( key: &str, now: DateTime, used_at: DateTime, + timezone: gateway_core::time::DeploymentTimeZone, ) -> Result<(), sqlx::Error> { - // 到期日仍按北京时间零点计算,计费起点不能退回重置前;迟到费用不启动新窗口。 + // 已打开窗口不因部署时区变化清零;续接起点不能早于旧窗口末端或人工重置边界。 + let day = timezone + .day_start(now) + .ok_or_else(|| sqlx::Error::Protocol("invalid budget day".to_owned()))?; + let daily_end = timezone + .days_after(now, 1) + .ok_or_else(|| sqlx::Error::Protocol("invalid budget day end".to_owned()))?; + let weekly_end = timezone + .days_after(now, 7) + .ok_or_else(|| sqlx::Error::Protocol("invalid budget week end".to_owned()))?; sqlx::query("insert into client_key_budget_windows (client_api_key_id, daily_start, daily_end, weekly_start, weekly_end) - select $1, day, day + interval '24 hours', day, day + interval '168 hours' - from (select date_trunc('day', $2::timestamptz at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai' as day) d + select $1, $4, $5, $4, $6 on conflict (client_api_key_id) do update set - daily_start = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then greatest(excluded.daily_start, client_key_budget_windows.daily_start) else client_key_budget_windows.daily_start end, + daily_start = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then greatest(excluded.daily_start, client_key_budget_windows.daily_end, client_key_budget_windows.daily_start) else client_key_budget_windows.daily_start end, daily_end = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then excluded.daily_end else client_key_budget_windows.daily_end end, daily_used_usd = case when client_key_budget_windows.daily_end <= $2 and client_key_budget_windows.daily_start <= $3 then 0 else client_key_budget_windows.daily_used_usd end, - weekly_start = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then greatest(excluded.weekly_start, client_key_budget_windows.weekly_start) else client_key_budget_windows.weekly_start end, + weekly_start = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then greatest(excluded.weekly_start, client_key_budget_windows.weekly_end, client_key_budget_windows.weekly_start) else client_key_budget_windows.weekly_start end, weekly_end = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then excluded.weekly_end else client_key_budget_windows.weekly_end end, weekly_used_usd = case when client_key_budget_windows.weekly_end <= $2 and client_key_budget_windows.weekly_start <= $3 then 0 else client_key_budget_windows.weekly_used_usd end") - .bind(key).bind(now).bind(used_at).execute(&mut **tx).await?; + .bind(key).bind(now).bind(used_at).bind(day).bind(daily_end).bind(weekly_end).execute(&mut **tx).await?; Ok(()) } diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index 7aa9f65af..6e658315d 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -121,7 +121,8 @@ async fn connect_pool( "select set_config('statement_timeout', $1, false), set_config('lock_timeout', $2, false), set_config('idle_in_transaction_session_timeout', $3, false), - set_config('default_transaction_read_only', $4, false)", + set_config('default_transaction_read_only', $4, false), + set_config('TimeZone', 'UTC', false)", ) .bind(statement_timeout) .bind(lock_timeout) diff --git a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs index faf418af0..90a883454 100644 --- a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs @@ -16,6 +16,7 @@ use crate::redis::{CredentialLeaseRepository as _, RedisCredentialLeaseRepositor #[derive(Clone)] pub struct PgObservabilityRepository { + timezone: gateway_core::time::DeploymentTimeZone, pool: PgPool, cooldowns: Option>, query_budget: ObservabilityQueryBudget, @@ -29,12 +30,19 @@ impl PgObservabilityRepository { query_budget: ObservabilityQueryBudget, ) -> Self { Self { + timezone: Default::default(), pool, cooldowns, query_budget, } } + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self + } + /// 从账号事实和当前冷却一次性派生 Dashboard 五态;不在 SQL 中复制状态机。 async fn account_status_snapshot( &self, @@ -104,6 +112,11 @@ impl PgAdminObservabilityStore { runtime_signals, } } + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.repository = self.repository.with_timezone(timezone); + self + } } #[async_trait] @@ -137,7 +150,7 @@ impl ObservabilityRepository for PgObservabilityRepository { let (trend, account_usage, recent_requests) = futures::try_join!( self.query_budget.run( "load dashboard request trend", - dashboard_request_metric_series(&self.pool, range, &filter), + dashboard_request_metric_series(&self.pool, range, &filter, self.timezone), ), self.query_budget.run( "load dashboard account usage", @@ -165,7 +178,12 @@ impl ObservabilityRepository for PgObservabilityRepository { self.query_budget .run( "load dashboard request trend", - dashboard_request_metric_series(&self.pool, range, &UsageRecordFilter::default()), + dashboard_request_metric_series( + &self.pool, + range, + &UsageRecordFilter::default(), + self.timezone, + ), ) .await } @@ -178,7 +196,7 @@ impl ObservabilityRepository for PgObservabilityRepository { self.query_budget .run( "load usage request trend", - request_metric_series(&self.pool, range, &filter), + request_metric_series(&self.pool, range, &filter, self.timezone), ) .await } @@ -190,7 +208,7 @@ impl ObservabilityRepository for PgObservabilityRepository { ) -> BoxStream<'_, StoreResult> { self.query_budget.run_stream( "load calculated usage billing facts", - calculated_usage_billing_facts(&self.pool, range, filter), + calculated_usage_billing_facts(&self.pool, range, filter, self.timezone), ) } diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index bf41391f4..388d93f3c 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -5,6 +5,13 @@ use super::*; pub(crate) fn store_range( range: admin_observability::TimeRange, ) -> AdminStoreResult { + // 显式外部范围已经校验;自然日零点的空快照仍须返回零计数。 + if range.start == range.end { + return Ok(ObservabilityRange { + start: range.start, + end: range.end, + }); + } ObservabilityRange::new(range.start, range.end).map_err(observability_error) } diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index e6552a8a1..e81ed8403 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -415,7 +415,7 @@ pub struct ProviderAccountUsageQuery { pub range: ObservabilityRange, pub account_ids: Option>, pub limit: u16, - pub(crate) include_hourly_request_buckets: bool, + pub(crate) request_bucket_range: Option, } impl ProviderAccountUsageQuery { @@ -431,7 +431,7 @@ impl ProviderAccountUsageQuery { limit: u16::try_from(account_ids.len()) .map_err(|_| invalid("account usage query is too large"))?, account_ids: Some(account_ids), - include_hourly_request_buckets: false, + request_bucket_range: None, }) } @@ -443,17 +443,22 @@ impl ProviderAccountUsageQuery { range, account_ids: None, limit, - include_hourly_request_buckets: false, + request_bucket_range: None, }) } pub fn with_hourly_request_buckets(mut self) -> StoreResult { - if self.range.end.signed_duration_since(self.range.start) - > TimeDelta::hours(ACCOUNT_USAGE_TIMELINE_HOURS) - { - return Err(invalid("account request timeline cannot exceed 24 hours")); - } - self.include_hourly_request_buckets = true; + // 小时图独立展示最近 24 个 UTC 小时桶,自然日统计不能限制为 24 小时。 + let current_hour = + DateTime::from_timestamp(self.range.end.timestamp().div_euclid(3600) * 3600, 0) + .ok_or_else(|| invalid("account request timeline exceeds supported timestamps"))?; + let start = current_hour + .checked_sub_signed(TimeDelta::hours(ACCOUNT_USAGE_TIMELINE_HOURS - 1)) + .ok_or_else(|| invalid("account request timeline exceeds supported timestamps"))?; + self.request_bucket_range = Some(ObservabilityRange { + start, + end: self.range.end, + }); Ok(self) } } diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/accounts.rs b/backend/crates/gateway-store/src/postgres/observability/queries/accounts.rs index 693e23640..dc90ef6c2 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/accounts.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/accounts.rs @@ -139,8 +139,8 @@ pub(crate) async fn provider_account_usage( .iter() .map(|item| item.account_id.clone()) .collect::>(); - let mut request_buckets = if query.include_hourly_request_buckets { - provider_account_request_buckets(pool, query.range, &account_ids).await? + let mut request_buckets = if let Some(range) = query.request_bucket_range { + provider_account_request_buckets(pool, range, &account_ids).await? } else { HashMap::new() }; @@ -201,24 +201,21 @@ pub(crate) async fn provider_account_request_buckets( ); } - let step = TimeDelta::hours(1); let mut timelines = HashMap::with_capacity(account_ids.len()); for account_id in account_ids { let mut account_observed = observed.remove(account_id).unwrap_or_default(); - let mut bucket_start = range.start; - let mut bucket_index = 0_u64; let mut buckets = Vec::new(); - while bucket_start < range.end { + for bucket_index in 0..ACCOUNT_USAGE_TIMELINE_HOURS { + let bucket_start = range + .start + .checked_add_signed(TimeDelta::hours(bucket_index)) + .ok_or_else(|| invalid("account request timeline exceeds supported timestamps"))?; buckets.push(ProviderAccountRequestBucket { bucket_start, - request_count: account_observed.remove(&bucket_index).unwrap_or_default(), + request_count: account_observed + .remove(&(bucket_index as u64)) + .unwrap_or_default(), }); - bucket_start = bucket_start - .checked_add_signed(step) - .ok_or_else(|| invalid("account request timeline exceeds supported timestamps"))?; - bucket_index = bucket_index - .checked_add(1) - .ok_or_else(|| invalid("account request timeline exceeds supported buckets"))?; } timelines.insert(account_id.clone(), buckets); } diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs index 46783476c..46c70b28a 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/dashboard.rs @@ -135,16 +135,18 @@ pub(crate) async fn request_metric_series( pool: &PgPool, range: ObservabilityRange, filter: &UsageRecordFilter, + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult> { - request_metric_series_inner(pool, range, filter, true).await + request_metric_series_inner(pool, range, filter, true, timezone).await } pub(crate) async fn dashboard_request_metric_series( pool: &PgPool, range: ObservabilityRange, filter: &UsageRecordFilter, + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult> { - request_metric_series_inner(pool, range, filter, false).await + request_metric_series_inner(pool, range, filter, false, timezone).await } async fn request_metric_series_inner( @@ -152,16 +154,17 @@ async fn request_metric_series_inner( range: ObservabilityRange, filter: &UsageRecordFilter, load_costs: bool, + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult> { filter.validate()?; let granularity = granularity_for(range); // 与 request_metrics 同一契约:结果计数覆盖全部请求,用量/延迟/成本 // 聚合仅统计用量事实。 let fact = completed_usage_fact_predicate("mr"); - let mut query = QueryBuilder::::new("select date_bin("); - query.push_bind(granularity.sql_interval()); + let mut query = QueryBuilder::::new("select "); + push_metric_bucket(&mut query, range, granularity, timezone)?; query.push(format!( - "::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start, + " as bucket_start, count(*)::bigint as request_count, count(*) filter (where outcome = 'succeeded')::bigint as success_count, count(*) filter (where outcome = 'failed')::bigint as failure_count, @@ -273,30 +276,32 @@ async fn request_metric_series_inner( ); } if load_costs { - let bucket_costs = request_costs_by_bucket(pool, range, filter, granularity).await?; + let bucket_costs = + request_costs_by_bucket(pool, range, filter, granularity, timezone).await?; for (bucket, costs) in bucket_costs { if let Some(point) = points.get_mut(&bucket) { point.costs = costs; } } } - fill_metric_gaps(range, granularity, points) + fill_metric_gaps(range, granularity, points, timezone) } pub(crate) fn calculated_usage_billing_facts( pool: &PgPool, range: ObservabilityRange, filter: UsageRecordFilter, + timezone: gateway_core::time::DeploymentTimeZone, ) -> futures::stream::BoxStream<'_, StoreResult> { use futures::TryStreamExt; Box::pin(async_stream::try_stream! { filter.validate()?; let granularity = granularity_for(range); - let mut query = QueryBuilder::::new("select date_bin("); - query.push_bind(granularity.sql_interval()); + let mut query = QueryBuilder::::new("select "); + push_metric_bucket(&mut query, range, granularity, timezone)?; query.push( - "::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start, + " as bucket_start, mr.provider_kind, mr.upstream_model_id, mr.service_tier, mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.cache_write_tokens, mr.billing_snapshot_json, mr.cost_currency, mr.cost_amount::text as amount @@ -328,11 +333,12 @@ pub(crate) async fn request_costs_by_bucket( range: ObservabilityRange, filter: &UsageRecordFilter, granularity: ObservationGranularity, + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult, Vec>> { - let mut query = QueryBuilder::::new("select date_bin("); - query.push_bind(granularity.sql_interval()); + let mut query = QueryBuilder::::new("select "); + push_metric_bucket(&mut query, range, granularity, timezone)?; query.push( - "::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00') as bucket_start, + " as bucket_start, mr.cost_currency, sum(mr.cost_amount)::text as amount from model_requests mr where mr.started_at >= ", @@ -636,11 +642,26 @@ pub(crate) fn fill_metric_gaps( range: ObservabilityRange, granularity: ObservationGranularity, mut points: BTreeMap, RequestMetricPoint>, + timezone: gateway_core::time::DeploymentTimeZone, ) -> StoreResult> { + if granularity == ObservationGranularity::Day { + return Ok(calendar_buckets(range, timezone)? + .into_iter() + .map(|bucket| { + points.remove(&bucket).unwrap_or(RequestMetricPoint { + bucket_start: bucket, + granularity, + metrics: RequestMetrics::default(), + cost_coverage: CostCoverage::default(), + costs: Vec::new(), + }) + }) + .collect()); + } let seconds = granularity.seconds(); - let start_epoch = range.start.timestamp().div_euclid(seconds) * seconds; - let mut bucket = DateTime::from_timestamp(start_epoch, 0) - .ok_or_else(|| invalid("metric range start is outside supported timestamps"))?; + let mut bucket = + DateTime::from_timestamp(range.start.timestamp().div_euclid(seconds) * seconds, 0) + .ok_or_else(|| invalid("invalid metric start"))?; let step = TimeDelta::seconds(seconds); let mut result = Vec::new(); while bucket < range.end { @@ -653,7 +674,48 @@ pub(crate) fn fill_metric_gaps( })); bucket = bucket .checked_add_signed(step) - .ok_or_else(|| invalid("metric range exceeds supported timestamps"))?; + .ok_or_else(|| invalid("invalid next metric bucket"))?; + } + Ok(result) +} + +// 将同一组 UTC 日界交给请求、费用和空桶补齐,避免数据库另用一套时区规则。 +fn push_metric_bucket( + query: &mut QueryBuilder, + range: ObservabilityRange, + granularity: ObservationGranularity, + timezone: gateway_core::time::DeploymentTimeZone, +) -> StoreResult<()> { + if granularity == ObservationGranularity::Day { + let boundaries = calendar_buckets(range, timezone)?; + query + .push("(") + .push_bind(boundaries.clone()) + .push("::timestamptz[])[width_bucket(mr.started_at, ") + .push_bind(boundaries) + .push("::timestamptz[])]"); + } else { + query + .push("date_bin(") + .push_bind(granularity.sql_interval()) + .push("::interval, mr.started_at, timestamptz '1970-01-01 00:00:00+00')"); + } + Ok(()) +} + +fn calendar_buckets( + range: ObservabilityRange, + timezone: gateway_core::time::DeploymentTimeZone, +) -> StoreResult>> { + let mut day = timezone + .day_start(range.start) + .ok_or_else(|| invalid("invalid calendar day"))?; + let mut result = Vec::new(); + while day < range.end { + result.push(day); + day = timezone + .days_after(day, 1) + .ok_or_else(|| invalid("invalid next calendar day"))?; } Ok(result) } diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 9ffa11dca..bddbf454e 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -64,13 +64,7 @@ impl PgAdminAccountStore { let mut observations = Vec::with_capacity(account_ids.len()); for account_ids in account_ids.chunks(ADMIN_USAGE_CHUNK_SIZE) { let query = ProviderAccountUsageQuery::for_accounts(range, account_ids.to_vec()) - .and_then(|query| { - if range.end.signed_duration_since(range.start) <= TimeDelta::hours(24) { - query.with_hourly_request_buckets() - } else { - Ok(query) - } - }) + .and_then(ProviderAccountUsageQuery::with_hourly_request_buckets) .map_err(|error| admin_store_error(ENTITY, error))?; observations.extend( self.observability diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 4ea94f049..0e15f9815 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -281,6 +281,34 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul } impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository { + fn claim_warmup_slot<'a>( + &'a self, + timezone: gateway_core::time::DeploymentTimeZone, + slot: chrono::NaiveDateTime, + ) -> futures::future::BoxFuture<'a, Result> { + Box::pin(async move { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| provider_unavailable("claim warmup slot"))?; + // 去重只需覆盖近期开关、重启与回拨,保留七天后由下一次领取回收。 + sqlx::query( + "delete from account_warmup_slots where claimed_at < now() - interval '7 days'", + ) + .execute(&mut *transaction) + .await + .map_err(|_| provider_unavailable("cleanup warmup slots"))?; + let claimed = sqlx::query("insert into account_warmup_slots (timezone, local_slot) values ($1, $2) on conflict do nothing") + .bind(timezone.name()).bind(slot).execute(&mut *transaction).await + .map_err(|_| provider_unavailable("claim warmup slot"))?.rows_affected() == 1; + transaction + .commit() + .await + .map_err(|_| provider_unavailable("commit warmup slot"))?; + Ok(claimed) + }) + } fn initialize_request_profile<'a>( &'a self, provider: &'a gateway_core::routing::ProviderKind, diff --git a/backend/crates/gateway-store/tests/postgres/backup.rs b/backend/crates/gateway-store/tests/postgres/backup.rs index ca1c1a595..48a29948e 100644 --- a/backend/crates/gateway-store/tests/postgres/backup.rs +++ b/backend/crates/gateway-store/tests/postgres/backup.rs @@ -66,12 +66,13 @@ async fn enable_verified_schedule(repository: &PgBackupRepository) -> BackupSett UpdateBackupScheduleCommand { schedule_enabled: true, cron_expression: "0 2 * * *".to_owned(), - schedule_timezone: "Asia/Shanghai".to_owned(), + retention_days: 7, retention_count: 5, }, Some(Utc::now() + chrono::Duration::hours(1)), &context(), + Default::default(), ) .await .expect("enable schedule") @@ -288,25 +289,48 @@ async fn scheduled_insert_dedupes_by_logical_time() { }; let repository = PgBackupRepository::new(db.pool.clone()); - let at = Utc::now(); - let first = repository - .insert_scheduled_record(seed( - &"a".repeat(32), - BackupTriggerKind::Scheduled, - Some(at), - )) - .await - .expect("first scheduled"); - assert!(first); - let second = repository - .insert_scheduled_record(seed( - &"b".repeat(32), - BackupTriggerKind::Scheduled, - Some(at), - )) - .await - .expect("dedup conflict returns false"); - assert!(!second); + let settings = enable_verified_schedule(&repository).await; + let at = chrono::DateTime::from_timestamp_micros(Utc::now().timestamp_micros()).unwrap(); + let next = Some(at + chrono::Duration::days(1)); + assert!( + repository + .insert_scheduled_record( + seed(&"a".repeat(32), BackupTriggerKind::Scheduled, Some(at)), + next, + "0 2 * * *", + "Asia/Shanghai", + settings.next_run_at, + ) + .await + .expect("first scheduled") + ); + let later = Some(at + chrono::Duration::days(2)); + assert!( + !repository + .insert_scheduled_record( + seed(&"b".repeat(32), BackupTriggerKind::Scheduled, Some(at)), + later, + "0 2 * * *", + "Asia/Shanghai", + next, + ) + .await + .expect("dedup conflict returns false") + ); + assert_eq!(repository.load_settings().await.unwrap().next_run_at, later); + assert!( + !repository + .insert_scheduled_record( + seed(&"c".repeat(32), BackupTriggerKind::Scheduled, Some(at)), + next, + "0 2 * * *", + "Asia/Shanghai", + settings.next_run_at, + ) + .await + .expect("stale cursor cannot write") + ); + assert_eq!(repository.load_settings().await.unwrap().next_run_at, later); db.close().await; } @@ -384,12 +408,13 @@ async fn schedule_update_persists_cursor_and_clears_on_disable() { UpdateBackupScheduleCommand { schedule_enabled: true, cron_expression: "0 2 * * *".to_owned(), - schedule_timezone: "Asia/Shanghai".to_owned(), + retention_days: 7, retention_count: 5, }, Some(Utc::now() + chrono::Duration::hours(1)), &context(), + Default::default(), ) .await .expect("enable schedule"); @@ -401,12 +426,13 @@ async fn schedule_update_persists_cursor_and_clears_on_disable() { UpdateBackupScheduleCommand { schedule_enabled: false, cron_expression: "0 2 * * *".to_owned(), - schedule_timezone: "Asia/Shanghai".to_owned(), + retention_days: 7, retention_count: 5, }, None, &context(), + Default::default(), ) .await .expect("disable schedule"); @@ -415,3 +441,67 @@ async fn schedule_update_persists_cursor_and_clears_on_disable() { db.close().await; } + +#[tokio::test] +async fn scheduled_insert_rejects_changed_schedule_and_stale_timezone_rebase() { + let Some(db) = TestDatabase::create("backup_schedule_guard").await else { + return; + }; + let repository = PgBackupRepository::new(db.pool.clone()); + let old = enable_verified_schedule(&repository).await; + let next = Utc::now() + chrono::Duration::hours(2); + for (mutation, expected_cursor) in [ + ( + "update backup_settings set cron_expression = '0 3 * * *'", + old.next_run_at, + ), + ( + "update backup_settings set schedule_timezone = 'UTC'", + old.next_run_at, + ), + ( + "update backup_settings set schedule_enabled = false, next_run_at = null", + None, + ), + ] { + sqlx::query("update backup_settings set schedule_enabled = true, cron_expression = '0 2 * * *', schedule_timezone = 'Asia/Shanghai', next_run_at = $1") + .bind(old.next_run_at).execute(&db.pool).await.unwrap(); + sqlx::query(mutation).execute(&db.pool).await.unwrap(); + assert!( + !repository + .insert_scheduled_record( + seed( + &"d".repeat(32), + BackupTriggerKind::Scheduled, + Some(Utc::now()) + ), + Some(next), + "0 2 * * *", + "Asia/Shanghai", + old.next_run_at, + ) + .await + .unwrap() + ); + assert!( + !repository + .advance_schedule_cursor( + next, + "0 2 * * *", + Some("Asia/Shanghai"), + old.next_run_at, + "UTC" + ) + .await + .unwrap() + ); + let current = repository.load_settings().await.unwrap(); + assert_eq!(current.next_run_at, expected_cursor); + } + let count: i64 = sqlx::query_scalar("select count(*) from backup_records") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(count, 0, "obsolete schedule never queues a task"); + db.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/client_budgets.rs b/backend/crates/gateway-store/tests/postgres/client_budgets.rs index be8b2c07f..365c7b554 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budgets.rs @@ -1145,3 +1145,65 @@ async fn plugin_budget_limits_revalidate_authority_and_rollback_with_audit() { assert_eq!(after.weekly_resets_at, None); database.close().await; } + +#[tokio::test] +async fn timezone_cutover_preserves_open_windows_and_resumes_without_overlap() { + use gateway_core::time::DeploymentTimeZone; + let Some(database) = TestDatabase::create("budget_timezone_cutover").await else { + return; + }; + seed(&database, "key", "10", "20").await; + let original = PgClientBudgetStore::new(database.pool.clone()); + original.admit(key_id("key")).await.unwrap(); + original + .settle(charge("key", "before-cutover", "1")) + .await + .unwrap(); + let before: (DateTime, DateTime, DateTime, DateTime) = sqlx::query_as( + "select daily_start, daily_end, weekly_start, weekly_end from client_key_budget_windows where client_api_key_id = 'key'" + ).fetch_one(&database.pool).await.unwrap(); + let timezone: DeploymentTimeZone = "UTC".parse().unwrap(); + let changed = PgClientBudgetStore::new(database.pool.clone()).with_timezone(timezone); + changed.admit(key_id("key")).await.unwrap(); + let after: (DateTime, DateTime, DateTime, DateTime) = sqlx::query_as( + "select daily_start, daily_end, weekly_start, weekly_end from client_key_budget_windows where client_api_key_id = 'key'" + ).fetch_one(&database.pool).await.unwrap(); + assert_eq!(before, after); + assert_eq!( + status(&database, "key").await.daily_used_usd.canonical(), + "1" + ); + let now = Utc::now(); + let old_end = chrono::DateTime::from_timestamp_micros(now.timestamp_micros()).unwrap() + - chrono::Duration::seconds(1); + sqlx::query("update client_key_budget_windows set daily_start = $1, daily_end = $2, weekly_start = $1, weekly_end = $2") + .bind(old_end - chrono::Duration::hours(1)).bind(old_end).execute(&database.pool).await.unwrap(); + changed.admit(key_id("key")).await.unwrap(); + let resumed: (DateTime, DateTime, DateTime, DateTime) = sqlx::query_as( + "select daily_start, daily_end, weekly_start, weekly_end from client_key_budget_windows where client_api_key_id = 'key'" + ).fetch_one(&database.pool).await.unwrap(); + assert_eq!(resumed.0, old_end); + assert_eq!(resumed.2, old_end); + assert_eq!(resumed.1, timezone.days_after(now, 1).unwrap()); + assert_eq!(resumed.3, timezone.days_after(now, 7).unwrap()); + changed + .settle(ClientBudgetCharge { + completed_at: (old_end - chrono::Duration::seconds(1)).into(), + ..charge("key", "late-cutover", "2") + }) + .await + .unwrap(); + assert_eq!( + status(&database, "key").await.daily_used_usd.canonical(), + "0" + ); + changed + .settle(charge("key", "after-cutover", "3")) + .await + .unwrap(); + assert_eq!( + status(&database, "key").await.daily_used_usd.canonical(), + "3" + ); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/mod.rs b/backend/crates/gateway-store/tests/postgres/mod.rs index 48abccf11..eac173a9c 100644 --- a/backend/crates/gateway-store/tests/postgres/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/mod.rs @@ -146,6 +146,14 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly .fetch_one(&first) .await .expect("load runtime PostgreSQL session settings"); + let timezone: String = sqlx::query_scalar("show time zone") + .fetch_one(&first) + .await + .unwrap(); + assert_eq!( + timezone, "UTC", + "runtime sessions use an explicit technical time basis" + ); let first_tables = sqlx::query_scalar::<_, String>( "select table_name from information_schema.tables @@ -226,6 +234,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly "_sqlx_migrations", "account_group_accounts", "account_groups", + "account_warmup_slots", "admin_audit_events", "admin_users", "authorization_receipts", diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index f02233637..e360f600e 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -1310,7 +1310,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det ); assert_eq!(dashboard.provider_accounts.total, 1); assert_eq!(dashboard.account_usage[0].request_count, 1); - assert_eq!(dashboard.account_usage[0].request_buckets.len(), 2); + assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24); assert_eq!( dashboard.account_usage[0] .request_buckets @@ -1771,7 +1771,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact ); assert_eq!(dashboard.provider_accounts.total, 1); assert_eq!(dashboard.account_usage[0].request_count, 1); - assert_eq!(dashboard.account_usage[0].request_buckets.len(), 2); + assert_eq!(dashboard.account_usage[0].request_buckets.len(), 24); assert_eq!( dashboard.account_usage[0] .request_buckets @@ -1823,8 +1823,8 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact .request_buckets .iter() .map(|bucket| bucket.request_count) - .collect::>(), - vec![1, 0], + .sum::(), + 1, ); assert_eq!( ( @@ -2181,3 +2181,168 @@ async fn seed_calculated_billing_facts( .await?; Ok(()) } + +#[tokio::test] +async fn calendar_trends_share_exact_day_boundaries_for_requests_costs_and_empty_buckets() { + use gateway_core::time::DeploymentTimeZone; + for (name, at) in [ + ("America/New_York", "2026-11-01T05:30:00Z"), + ("America/Havana", "2026-11-01T04:30:00Z"), + ("Asia/Kathmandu", "2026-10-01T00:00:00Z"), + ] { + let Some(database) = TestDatabase::create("calendar_trends").await else { + return; + }; + let timezone: DeploymentTimeZone = name.parse().unwrap(); + let at: chrono::DateTime = at.parse().unwrap(); + seed_observability_facts(&database.pool, at).await.unwrap(); + seed_calculated_billing_facts(&database.pool, at) + .await + .unwrap(); + sqlx::query( + "update model_requests set started_at = $1, completed_at = $1 + interval '1 second'", + ) + .bind(at) + .execute(&database.pool) + .await + .unwrap(); + let range = + admin_observability::TimeRange::new(at - TimeDelta::days(32), at + TimeDelta::days(1)) + .unwrap(); + let store = admin_observability_store(&database.pool).with_timezone(timezone); + let points = store + .usage_trend(range, Default::default()) + .await + .expect("calendar trend"); + let populated = points.iter().find(|p| p.metrics.request_count > 0).unwrap(); + assert_eq!( + populated.bucket_start, + timezone.day_start(at).unwrap(), + "{name}" + ); + assert_eq!(populated.metrics.request_count, 5); + assert_eq!(populated.costs[0].amount.as_str(), "2.5"); + let mut boundary = timezone.day_start(range.start).unwrap(); + for point in &points { + assert_eq!(point.bucket_start, boundary, "{name}"); + boundary = timezone.days_after(boundary, 1).unwrap(); + } + let facts = store + .usage_calculated_billing_facts(range, Default::default()) + .try_collect::>() + .await + .expect("calendar cost buckets"); + // 只有已完整交付的 calculated 费用参与趋势事实;其他费用仍按相同边界聚合。 + assert_eq!(facts.len(), 1); + assert!( + facts + .iter() + .all(|fact| fact.bucket_start == populated.bucket_start) + ); + database.close().await; + } +} + +#[tokio::test] +async fn account_hourly_buckets_use_utc_hours_independently_of_calendar_metrics() { + use admin_observability::CalendarPeriod; + use gateway_core::time::DeploymentTimeZone; + for (name, end, early, metrics_count, hourly_count) in [ + ( + "America/New_York", + "2026-11-02T04:59:00Z", + "2026-11-01T04:10:00Z", + 2, + 1, + ), + ( + "America/New_York", + "2026-03-09T03:59:00Z", + "2026-03-08T04:10:00Z", + 1, + 2, + ), + ( + "Asia/Kathmandu", + "2026-10-01T04:20:00Z", + "2026-09-30T18:05:00Z", + 1, + 2, + ), + ("UTC", "2026-10-02T00:00:00Z", "2026-10-01T23:10:00Z", 0, 2), + ] { + let Some(database) = TestDatabase::create("account_hourly_calendar").await else { + return; + }; + let timezone: DeploymentTimeZone = name.parse().unwrap(); + let end: chrono::DateTime = end.parse().unwrap(); + let early: chrono::DateTime = early.parse().unwrap(); + seed_observability_facts(&database.pool, end).await.unwrap(); + seed_calculated_billing_facts(&database.pool, end) + .await + .unwrap(); + sqlx::query( + "update model_requests set started_at = $1, completed_at = $1 + interval '1 second', + downstream_committed_at = $1 where id = 'req_observe_success'", + ) + .bind(early) + .execute(&database.pool) + .await + .unwrap(); + let range = + admin_observability::TimeRange::calendar_at(CalendarPeriod::Today, end, timezone) + .unwrap(); + let dashboard = admin_observability_store(&database.pool) + .with_timezone(timezone) + .dashboard_summary(range, end) + .await + .expect("calendar dashboard supports variable day lengths"); + assert_eq!( + dashboard + .account_usage + .iter() + .map(|account| account.request_count) + .sum::(), + metrics_count, + "{name} {end}" + ); + let accounts = observability_repository(&database.pool) + .provider_account_usage( + ProviderAccountUsageQuery::for_accounts( + ObservabilityRange { + start: range.start, + end, + }, + vec!["acct_observe".to_owned()], + ) + .unwrap() + .with_hourly_request_buckets() + .unwrap(), + ) + .await + .expect("hourly buckets are separate from calendar metrics"); + assert_eq!(accounts[0].request_count, metrics_count, "{name}"); + let buckets = &accounts[0].request_buckets; + assert_eq!(buckets.len(), 24); + assert_eq!( + buckets + .iter() + .map(|bucket| bucket.request_count) + .sum::(), + hourly_count, + "{name}" + ); + let current_hour = + chrono::DateTime::from_timestamp(end.timestamp().div_euclid(3600) * 3600, 0).unwrap(); + for (index, bucket) in buckets.iter().enumerate() { + assert_eq!( + bucket.bucket_start, + current_hour - TimeDelta::hours(23 - index as i64) + ); + } + if end == current_hour { + assert_eq!(buckets.last().unwrap().request_count, 0); + } + database.close().await; + } +} diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index 980b03ecb..5feaefb0c 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -957,7 +957,7 @@ async fn terminal_admin_usage_chunks_large_selections_and_preserves_exact_costs( assert_eq!(usage[0].total_tokens, Some(18)); assert_eq!(usage[0].costs[0].currency, "USD"); assert_eq!(usage[0].costs[0].amount.as_str(), "1.2345678901"); - assert_eq!(usage[0].request_buckets.len(), 2); + assert_eq!(usage[0].request_buckets.len(), 24); assert_eq!( usage[0] .request_buckets diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index a0ef8e37f..8f5433233 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -897,3 +897,47 @@ async fn control_plane_replacement_commits_one_writer_per_revision_and_preserves ); database.close().await; } + +#[tokio::test] +async fn warmup_slots_survive_restart_and_serialize_competing_claims() { + use gateway_core::{provider_ports::ProviderRuntimePolicyPort, time::DeploymentTimeZone}; + let Some(database) = TestDatabase::create("warmup_slots").await else { + return; + }; + let zone = DeploymentTimeZone::default(); + let slot = zone + .local(Utc::now()) + .date_naive() + .and_hms_opt(8, 0, 0) + .unwrap(); + let first = PgRuntimeSettingsRepository::new(database.pool.clone()); + let second = PgRuntimeSettingsRepository::new(database.pool.clone()); + let (a, b) = tokio::join!( + first.claim_warmup_slot(zone, slot), + second.claim_warmup_slot(zone, slot) + ); + assert_ne!(a.unwrap(), b.unwrap(), "one leader claims the local minute"); + let restarted = PgRuntimeSettingsRepository::new(database.pool.clone()); + assert!(!restarted.claim_warmup_slot(zone, slot).await.unwrap()); + assert!( + restarted + .claim_warmup_slot(zone, slot + TimeDelta::hours(5)) + .await + .unwrap() + ); + assert!( + !restarted.claim_warmup_slot(zone, slot).await.unwrap(), + "another slot cannot erase prior deduplication" + ); + sqlx::query("update account_warmup_slots set claimed_at = now() - interval '8 days'") + .execute(&database.pool) + .await + .unwrap(); + assert!(restarted.claim_warmup_slot(zone, slot).await.unwrap()); + let count: i64 = sqlx::query_scalar("select count(*) from account_warmup_slots") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(count, 1, "expired deduplication state is reclaimed"); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 36a0f15e4..7b92173ce 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -29,6 +29,8 @@ const fn default_stream_max_retries() -> u64 { /// OpenAI Provider 唯一启动配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct OpenAiConfig { + #[serde(skip)] + pub(crate) timezone: gateway_core::time::DeploymentTimeZone, #[serde(default)] pub api: CodexApiConfig, #[serde(default)] @@ -46,6 +48,12 @@ pub struct OpenAiConfig { } impl OpenAiConfig { + #[must_use] + pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self { + self.timezone = timezone; + self + } + /// 校验 Provider-owned 字段,并从统一运行数据目录定位会话身份密钥。 pub fn resolve_and_validate( &mut self, @@ -109,6 +117,7 @@ impl OpenAiConfig { impl Default for OpenAiConfig { fn default() -> Self { Self { + timezone: Default::default(), api: CodexApiConfig::default(), ws_pool: CodexWebSocketPoolSettings::default(), quota: CodexQuotaSettings::default(), diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 2cb0287ad..951273259 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -8,7 +8,7 @@ use std::fmt; use std::sync::Arc; use std::time::SystemTime; -use chrono::{DateTime, FixedOffset, Utc}; +use chrono::{DateTime, Utc}; use gateway_core::account::{ AccountErrorReason, CredentialCasUpdate, CredentialRevision, CredentialState, LoadedCredential, NewProviderAccount, ProviderAccount, ProviderAccountId, ProviderAccountIdentity, @@ -680,8 +680,8 @@ impl CodexCredentialAdmin { label: Some(account.name().to_owned()), plan_type: account.plan_type().map(str::to_owned), status: cpr_status(&account), - added_at: china_rfc3339(item.added_at), - updated_at: china_rfc3339(item.updated_at), + added_at: item.added_at.to_rfc3339(), + updated_at: item.updated_at.to_rfc3339(), outbound_proxy_url: account .outbound_proxy() .map(|proxy| proxy.expose_url().to_owned()), @@ -1218,12 +1218,6 @@ fn cpr_status(account: &ProviderAccount) -> &'static str { } } -fn china_rfc3339(value: DateTime) -> String { - value - .with_timezone(&FixedOffset::east_opt(8 * 60 * 60).expect("valid China offset")) - .to_rfc3339() -} - fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError { match error { RefreshFailure::InvalidGrant { message, upstream } => { diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 099c00c70..9659126f1 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -178,7 +178,8 @@ pub async fn initialize( config.stream_max_retries(), ) .map_err(OpenAiInitializeError::Provider)? - .with_session_identity(session_identity), + .with_session_identity(session_identity) + .with_timezone(config.timezone), ); let token_client = Arc::new( credential::token_client::openai_token_client( @@ -233,6 +234,7 @@ pub async fn initialize( desktop_release_status, )); let worker_contributions = provider::worker_contributions( + config.timezone, refresh, quota, catalog, diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 4d9a1282c..e63138403 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -278,6 +278,15 @@ impl CodexProvider { }) } + #[must_use] + pub(crate) fn with_timezone( + mut self, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Self { + self.client = self.client.with_timezone(timezone); + self + } + pub(crate) fn with_session_identity(mut self, identity: CodexSessionIdentity) -> Self { self.session_identity = Some(identity); self diff --git a/backend/crates/providers/openai/src/provider/workers.rs b/backend/crates/providers/openai/src/provider/workers.rs index 8ca14b003..7ede78590 100644 --- a/backend/crates/providers/openai/src/provider/workers.rs +++ b/backend/crates/providers/openai/src/provider/workers.rs @@ -1,7 +1,5 @@ //! OpenAI Provider 向 Host 贡献的后台 worker。 -use std::sync::Mutex; - use super::*; use crate::transport::profile::cli_release::CliReleaseService; use crate::transport::profile::platform_release::PlatformDesktopReleaseService; @@ -25,6 +23,7 @@ pub(super) const WARMUP_WORKER_OWNER: &str = "openai-account-warmup"; pub(super) const WARMUP_CHECK_INTERVAL: Duration = Duration::from_secs(30); pub(crate) fn worker_contributions( + timezone: gateway_core::time::DeploymentTimeZone, refresh: Arc, quota: Arc, catalog: Arc, @@ -76,7 +75,7 @@ pub(crate) fn worker_contributions( WorkerContribution::Registration(scheduled_registration( warmup_id, WARMUP_CHECK_INTERVAL, - Box::new(OpenAiWarmupTask::new(Arc::clone("a))), + Box::new(OpenAiWarmupTask::new(Arc::clone("a), timezone)), )?), WorkerContribution::Registration(scheduled_registration( catalog_id, @@ -343,16 +342,16 @@ impl ScheduledTask for OpenAiPlatformDesktopReleaseTask { } pub(super) struct OpenAiWarmupTask { + timezone: gateway_core::time::DeploymentTimeZone, quota: Arc, - last_slot_executed: Mutex>, } impl OpenAiWarmupTask { - pub(super) fn new(quota: Arc) -> Self { - Self { - quota, - last_slot_executed: Mutex::new(None), - } + pub(super) fn new( + quota: Arc, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Self { + Self { timezone, quota } } } @@ -374,10 +373,19 @@ impl ScheduledTask for OpenAiWarmupTask { if !policy.enabled() { return Ok(()); } - use chrono::{Datelike as _, Timelike as _}; - let china_now = chrono::Utc::now() + chrono::Duration::hours(8); - let hour = china_now.time().hour(); - let minute = china_now.time().minute(); + use chrono::Timelike as _; + let now = chrono::Utc::now(); + let local_now = self.timezone.local(now); + // 回拨产生的第二个相同时刻不能再次执行。 + if self + .timezone + .resolve_local(local_now.naive_local()) + .is_none_or(|first| first != now) + { + return Ok(()); + } + let hour = local_now.time().hour(); + let minute = local_now.time().minute(); let scheduled_times = policy.scheduled_times(); let matched = scheduled_times .iter() @@ -385,27 +393,23 @@ impl ScheduledTask for OpenAiWarmupTask { if !matched { return Ok(()); } - let slot_key = format!( - "{:04}-{:02}-{:02} {:02}:{:02}", - china_now.date_naive().year(), - china_now.date_naive().month(), - china_now.date_naive().day(), - hour, - minute - ); - { - let mut last = self - .last_slot_executed - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if last.as_deref() == Some(&slot_key) { - return Ok(()); - } - *last = Some(slot_key); - } let Some(model) = policy.model() else { return Err(WorkerTaskError::safe("OpenAI warmup model is missing")); }; + let slot = local_now + .naive_local() + .with_second(0) + .and_then(|value| value.with_nanosecond(0)) + .ok_or_else(|| WorkerTaskError::safe("invalid warmup slot"))?; + if !self + .quota + .runtime_policy() + .claim_warmup_slot(self.timezone, slot) + .await + .map_err(|_| WorkerTaskError::safe("warmup slot is unavailable"))? + { + return Ok(()); + } tracing::info!(hour, minute, model, "OpenAI account warmup cycle started"); let outcome = tokio::select! { () = context.cancellation().cancelled() => return Ok(()), @@ -425,13 +429,13 @@ impl ScheduledTask for OpenAiWarmupTask { tracing::warn!(error = %error, "OpenAI account warmup cycle failed"); } } - let china_after = chrono::Utc::now() + chrono::Duration::hours(8); - if china_after.date_naive() == china_now.date_naive() - && china_after.hour() == hour - && china_after.minute() == minute + let local_after = self.timezone.local(chrono::Utc::now()); + if local_after.date_naive() == local_now.date_naive() + && local_after.hour() == hour + && local_after.minute() == minute { // 持有本轮 leader lease 到时间槽结束,避免其他实例在同一分钟再次执行。 - let hold = Duration::from_secs(u64::from(61 - china_after.second())); + let hold = Duration::from_secs(u64::from(61 - local_after.second())); tokio::select! { () = context.cancellation().cancelled() => return Ok(()), () = tokio::time::sleep(hold) => {} diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index 599db33e8..e2e81fc49 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -678,6 +678,7 @@ impl OpenAiUpstreamProtocol { /// Codex HTTP/SSE 上游客户端。 #[derive(Clone)] pub struct CodexBackendClient { + pub(super) timezone: gateway_core::time::DeploymentTimeZone, pub(super) response_control: Option, pub(super) connection_budget: Option, pub(super) client: Client, @@ -695,6 +696,15 @@ pub struct CodexBackendClient { } impl CodexBackendClient { + #[must_use] + pub(crate) fn with_timezone( + mut self, + timezone: gateway_core::time::DeploymentTimeZone, + ) -> Self { + self.timezone = timezone; + self + } + pub(crate) fn with_response_control( mut self, control: Option, diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index c81e7a4b3..e920b288c 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -64,6 +64,7 @@ impl CodexBackendClient { ) -> Self { let base_url = base_url.into().trim_end_matches('/').to_string(); Self { + timezone: Default::default(), connection_budget: None, response_control: None, direct_client: client.clone(), @@ -303,7 +304,9 @@ impl CodexBackendClient { websocket_create.connection().opening_audit_snapshot(), websocket_payload_audit_snapshot(&websocket_request), ); - if let Err(error) = write_websocket_audit_artifact_from_env(&artifact).await { + if let Err(error) = + write_websocket_audit_artifact_from_env(&artifact, self.timezone).await + { tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } } diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index d7cff6e84..16363e250 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -19,7 +19,6 @@ pub mod reset_credits; mod response_meta; pub(crate) mod session; pub mod subscription; -mod time; pub(crate) use downstream::normalize_selected_codex_downstream_body; pub(crate) use endpoints::valid_upstream_base_url; pub mod tls; diff --git a/backend/crates/providers/openai/src/transport/time.rs b/backend/crates/providers/openai/src/transport/time.rs deleted file mode 100644 index 03214875c..000000000 --- a/backend/crates/providers/openai/src/transport/time.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Codex transport 私有时间辅助。 - -use chrono::{DateTime, FixedOffset, Utc}; - -const CHINA_OFFSET_SECONDS: i32 = 8 * 60 * 60; - -pub(crate) fn china_filename_timestamp_millis(value: &DateTime) -> String { - value - .with_timezone(&china_offset()) - .format("%Y%m%dT%H%M%S%.3f%z") - .to_string() -} - -fn china_offset() -> FixedOffset { - FixedOffset::east_opt(CHINA_OFFSET_SECONDS).expect("valid China timezone offset") -} diff --git a/backend/crates/providers/openai/src/transport/websocket/audit.rs b/backend/crates/providers/openai/src/transport/websocket/audit.rs index 6737449ec..d2b5cdf05 100644 --- a/backend/crates/providers/openai/src/transport/websocket/audit.rs +++ b/backend/crates/providers/openai/src/transport/websocket/audit.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use crate::transport::protocol::websocket::{ OpeningAuditHeader, OpeningAuditSnapshot, WebSocketAuditArtifact, }; -use crate::transport::time::china_filename_timestamp_millis; +use gateway_core::time::DeploymentTimeZone; use super::model::CodexWebSocketConnection; @@ -37,13 +37,14 @@ pub fn websocket_audit_dir() -> Option<&'static Path> { pub async fn write_websocket_audit_artifact_for_dir( dir: Option<&Path>, artifact: &WebSocketAuditArtifact, + timezone: DeploymentTimeZone, ) -> io::Result> { let Some(dir) = dir.filter(|dir| !dir.as_os_str().is_empty()) else { return Ok(None); }; tokio::fs::create_dir_all(dir).await?; - let path = dir.join(websocket_audit_file_name()); + let path = dir.join(websocket_audit_file_name(timezone)); let body = serde_json::to_vec_pretty(artifact).map_err(io::Error::other)?; tokio::fs::write(&path, body).await?; Ok(Some(path)) @@ -52,8 +53,9 @@ pub async fn write_websocket_audit_artifact_for_dir( /// 按环境变量配置写入 WebSocket audit artifact。 pub async fn write_websocket_audit_artifact_from_env( artifact: &WebSocketAuditArtifact, + timezone: DeploymentTimeZone, ) -> io::Result> { - write_websocket_audit_artifact_for_dir(websocket_audit_dir(), artifact).await + write_websocket_audit_artifact_for_dir(websocket_audit_dir(), artifact, timezone).await } impl CodexWebSocketConnection { @@ -116,7 +118,7 @@ fn is_sensitive_opening_header(name: &str) -> bool { ) } -fn websocket_audit_file_name() -> String { - let timestamp = china_filename_timestamp_millis(&Utc::now()); +fn websocket_audit_file_name(timezone: DeploymentTimeZone) -> String { + let timestamp = timezone.local(Utc::now()).format("%Y%m%dT%H%M%S%.3f%z"); format!("codex-ws-audit-{timestamp}-{}.json", Uuid::new_v4()) } diff --git a/backend/crates/providers/openai/tests/transport/websocket/mod.rs b/backend/crates/providers/openai/tests/transport/websocket/mod.rs index 89d7b10e0..3d1a0cfce 100644 --- a/backend/crates/providers/openai/tests/transport/websocket/mod.rs +++ b/backend/crates/providers/openai/tests/transport/websocket/mod.rs @@ -45,24 +45,28 @@ async fn websocket_audit_artifact_should_require_explicit_directory() { payload: None, }; - let disabled = write_websocket_audit_artifact_for_dir(None, &artifact) + let disabled = write_websocket_audit_artifact_for_dir(None, &artifact, Default::default()) .await .expect("disabled audit should be ok"); assert!(disabled.is_none()); assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); - let written = write_websocket_audit_artifact_for_dir(Some(dir.path()), &artifact) - .await - .expect("enabled audit should write") - .expect("enabled audit path"); + let written = write_websocket_audit_artifact_for_dir( + Some(dir.path()), + &artifact, + "Asia/Kathmandu".parse().unwrap(), + ) + .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}" + file_name.contains("+0545"), + "expected deployment-timezone 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"); diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index bc5d8bb7f..824c8c7dc 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -18,3 +18,4 @@ fcfae912ddefd54551ebf8e7e733442519d2862d2977134d548ebc1e75c1dc34 0017_unlimited 229c70296764b94fe69c3f19889e566fa290c6c44c64970efe96d7fd2a0008d5 0018_plugins.sql 40a4d4b16e6ec2c2c659980d416b089fc170acd2255aaaf751c4a04c3790a941 0019_plugin_resources.sql e8c1339166ac02fb2d4a077424ee5f64f6dc20eb4d3f8ec4fcb09c0f2a0e61f6 0020_plugin_trust.sql +338037806358b23dc8b62a39e73b04ce4173c4556383e5df19b7e8c7123924ed 0021_account_warmup_slots.sql diff --git a/backend/migrations/0021_account_warmup_slots.sql b/backend/migrations/0021_account_warmup_slots.sql new file mode 100644 index 000000000..46dbe6d00 --- /dev/null +++ b/backend/migrations/0021_account_warmup_slots.sql @@ -0,0 +1,7 @@ +-- 每日预激活槽在执行前领取,进程重启与夏令时回拨不会重复发送上游请求。 +create table account_warmup_slots ( + timezone text not null, + local_slot timestamp without time zone not null, + claimed_at timestamptz not null default now(), + primary key (timezone, local_slot) +); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 8f3559434..f1924ed69 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -42,7 +42,7 @@ FROM ${POSTGRES_IMAGE} AS pg-client FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS runtime-base -ENV TZ=Asia/Shanghai +ENV TZ=UTC # 运行时基础只从 Debian 官方源补 libpq(pg_dump 的动态依赖;ABI 兼容,无需引入第三方 # APT 源)。pg_dump/pg_restore 二进制由 pg-client stage COPY,构建期不依赖运行时安装。 diff --git a/deploy/README.md b/deploy/README.md index 17302b293..f52115468 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,7 +18,7 @@ | 位置 | 内容 | 修改方式 | | --- | --- | --- | -| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据与日志等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 | +| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据、时区与日志等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 | | `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 | | PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API | | 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 | @@ -31,6 +31,26 @@ Compose 通过环境变量将监听和数据库地址设为容器内地址,并指定镜像中的静态资源目录 +### 部署时区 + +在 `deploy/config.yaml` 设置统一的 IANA 时区,省略时默认 `Asia/Shanghai`: + +```yaml +host: + timezone: 'Asia/Shanghai' +``` + +支持 `UTC`、`America/New_York`、`Asia/Kathmandu` 等名称;空值、未知名称和错误类型会阻止启动, +错误指出 `host.timezone`,不输出配置值。修改后重启网关生效,二进制与 Compose 使用同一配置 + +管理端与 Key 用量页的时间文本由后端生成;自然日筛选、日统计、Key 限额、账号预热和备份 Cron 使用该时区。 +浏览器、进程 `TZ` 和 PostgreSQL 会话时区不覆盖业务配置;Compose 的基础设施时间基准为 UTC。 +上游请求身份中的位置时区仍由对应运行设置或代理配置控制,与部署时区独立 + +切换时区保留已打开限额窗口的 UTC 边界与用量,到期后按新时区续接;不会因重启立即清零。 +备份调度从切换后的当前时刻计算未来执行点,不补跑旧时区漏过的任务,已入队任务保持原状态。 +夏令时中不存在的预热或 Cron 时刻跳过,重复时刻只选较早一次。日志日期与保留规则见[日志与保留窗口](#日志与保留窗口) + ## 部署结构 ```mermaid @@ -355,8 +375,8 @@ Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建 | 文件集 | 保留配置 | 默认完整窗口 | | --- | --- | --- | -| 普通日志、OAuth 恢复日志 | `host.logging.file.retention_days` | 至少 7×24 小时 | -| 全量请求/响应报文 | `host.logging.request_dump_retention_days` | 至少 24 小时(默认 1) | +| 普通日志、OAuth 恢复日志 | `host.logging.file.retention_days` | 前 7 个完整自然日及当天 | +| 全量请求/响应报文 | `host.logging.request_dump_retention_days` | 前 1 个完整自然日及当天 | 文件名统一为 `codex-proxy-rs-<类别>.YYYY-MM-DD[.N].log[.gz]`,类别分别为 `application`、`oauth-recovery`、`request-dump`。专用 tracing target 为 `oauth_recovery` 和 @@ -364,9 +384,10 @@ Provider schema 以明文 JSON 保存于 PostgreSQL。Redis 只保存可重建 程序只管理上述规范名称的日志,其他命名的文件由运维手动清理。 普通日志未配置 `retention_days` 时默认使用 7 天,显式配置优先 -按 UTC 日期整组保留:例如 9 月 8 日配置 1 天,会保留 9 月 7 日全天及 9 月 8 日的所有分片, +日志时间戳采用部署时区并携带数字偏移,文件按该时区的日期轮转和整组保留:例如 9 月 8 日配置 1 天,会保留 9 月 7 日全天及 9 月 8 日的所有分片, 到 9 月 9 日才允许清理 9 月 7 日。这会略多保留,保证跨午夜及高流量时不留下半天日志。 -配置 7 天同理,保留前 7 个完整 UTC 日期及当天;若旧日期分片近期又被写入,整组延后删除。 +配置 7 天同理,保留前 7 个完整自然日及当天,夏令时日期允许为 23 或 25 小时。切换时区不重命名历史文件; +按文件名日期与部署时区中的实际修改日期取较近值,旧日期分片近期被写入时整组延后删除。 `max_file_size_mb` 仅决定分片大小(默认 20 MiB),不决定保存时长,单条大记录不会被截断。 关闭的分片压缩为 `.log.gz`;成功压缩、同步并发布归档后才删除原文件,保留原修改时间。 清理发生在启动和轮转时;空闲期间过期文件可能暂时多保留。检索时须同时读取 `.log` 与 `.log.gz` diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 4a4616175..2a6169ca8 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -16,11 +16,11 @@ services: command: - postgres - -c - - timezone=Asia/Shanghai + - timezone=UTC - -c - - log_timezone=Asia/Shanghai + - log_timezone=UTC environment: - TZ: Asia/Shanghai + TZ: UTC POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 3e1c82ecb..f02bbd511 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -8,6 +8,8 @@ schema_version: 1 host: + # 统一展示、查询日期、限额重置和定时任务的 IANA 时区,修改后重启生效。 + timezone: 'Asia/Shanghai' listen: # 本地 cargo run 使用;Docker 内由 CPR_SERVER_HOST/PORT 覆盖。 host: '127.0.0.1' diff --git a/docs/api.md b/docs/api.md index da6e69918..0bda486c8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -147,6 +147,28 @@ HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、 OpenAI 上游 `401` 按 `50201` 返回,不代表管理员会话失效,也不要求管理端重新登录。 Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`50201` 及对应的安全提示 +### 页面时间合同 + +管理端与 Key 用量接口保留原始 RFC3339 时间点,并提供 `createdAtDisplay`、`updatedAtDisplay`、 +`expiresAtDisplay` 等展示字段。展示文本由后端按部署 `host.timezone` 生成,完整时间通常为 +`YYYY-MM-DD HH:mm:ss`,短时间、相对时间和图表标签按对应视图返回;缺失时间的展示字段为 `null` 或占位文本。 +调用者直接显示文本,使用原始时间点进行排序、比较和输入,不把展示文本转回请求时间 + +账号冷却返回 RFC3339 `rateLimitedUntil` 和 `rateLimitRecoveryDisplay`,后者包含剩余时长、 +到期时间或等待恢复探测的提示,随账号数据刷新;前端不解析展示文本或自行计算恢复提示 + +`period=today|7d|30d` 表达自然日范围,`asOf` 是 Unix 毫秒查询锚点,省略时取服务端当前时刻。 +近 7 天包含锚点所在日期及前 6 个自然日,近 30 天同理包含前 29 日;结束点为锚点。 +同一组汇总、趋势与列表共享 `asOf`,分页保持锚点,刷新再更新。显式 `startTime` / `endTime` +使用带偏移的 RFC3339,不能与 `period` 或 `asOf` 混用;对应接口仍执行范围上限校验。 +自然日刚开始时允许空的今日快照,显式起止范围仍要求开始早于结束 + +图表的 `label`、日期提示和空桶由后端提供;日粒度按本地日界,小时及 15 分钟桶以 UTC 时间点定位。 +账号请求柱覆盖截至锚点的最近 24 个 UTC 小时桶,包含当前未完整小时,独立于今日汇总的自然日范围。 +健康时间线覆盖锚点所在自然日,每 15 分钟一个桶,夏令时日期可以为 92 或 100 个桶。 +重复本地时刻的标签携带偏移,唯一键、排序和去重仍使用原始时间点。 +只有日历日期的上游统计保留日期语义,不当作 UTC 午夜换算 + ### 管理写入一致性 管理写入不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进 @@ -413,7 +435,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` 金额使用十进制字符串,`total` 为当前周期限额,`used` 为该周期已结算金额,`remaining` 为限额减已用且最低为零。 不限额时 `total`、`remaining` 均为 `null`,仍返回已用金额。`resetsAt` 为 RFC3339 时间,尚未开启或已到期的窗口返回 `null`, -已到期窗口的 `used` 为 `"0"`。日窗口按北京时间零点划分,周窗口沿用首次使用起的七天周期,不固定为周一。 +已到期窗口的 `used` 为 `"0"`。日窗口按部署时区的自然日划分,周窗口沿用首次使用起的七个本地日历日周期,不固定为周一。 修改限额、管理员重置和费用结算均复用现有 Key 账本,不从请求日志重算余额 缺失、非法、已禁用或已删除的 Key 返回 OpenAI 风格 `401` 错误;未知查询参数返回 `400 invalid_usage_query`, @@ -468,19 +490,19 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` | 方法 | 路由 | 查询 | 说明 | | --- | --- | --- | --- | -| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 | +| `GET` | `/api/key-usage/overview` | `period?`、`asOf?` 或 `startTime?`、`endTime?`,另含 `model?` | 用量汇总、趋势、当前额度和锚点所在日期的健康时间线 | | `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 | | `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 | | `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 | -用量查询的起止时间使用 RFC3339,开始必须早于结束,一次最多 31 天。模型按完整名称匹配; +用量查询支持[页面时间合同](#页面时间合同),默认 `period=7d`,一次最多 31 天。模型按完整名称匹配; 不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条; `kind` 为 `success`(默认)或 `error`。分页响应为 `{ items, currentPage, pageSize, total }` -overview 返回 `asOf`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。 +overview 返回 `asOf`、`asOfDisplay`、`startTime`、`endTime`、`key`、`summary`、`trend`、`healthTimeline`。 `key` 仅包含名称、掩码前缀、并发/RPM、日与周限额、已用 USD 及重置时间;零限额表示不限, 未启动窗口的重置时间为 null。额度使用现有结算账本,不受日志日期或模型筛选影响。 -健康时间线沿用管理端的 96 个北京时间日内桶与可用性语义,不受历史范围和模型筛选影响 +健康时间线沿用管理端的自然日分桶与可用性语义,以 `asOf` 为锚点,不受所选周期长度和模型筛选影响 汇总和趋势返回请求数、输入、输出、缓存读写、推理、总 Tokens 与 USD 成本;输入已包含缓存读写, 推理为输出的明细,不得把缓存或推理重复计入总消耗。趋势另含 `time` 与 `bucketSeconds`。 @@ -687,6 +709,9 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除或连接配 } ``` +所有连接测试事件都包含服务端发出时的 `occurredAt`、完整 `occurredAtDisplay` 和短 `timeDisplay`。 +浏览器本地取消或断网没有服务端发生时间,应保持事件顺序和状态,不伪造时间戳 + - `source` 为 `gateway`、`provider` 或 `upstream`:分别表示尚未进入 Provider、Provider 本地且未发送、 已发送/可能已发送或已经捕获到上游事实 - `gatewayErrorCode` 是 `GatewayErrorKind` 的稳定机器值,管理端据此生成中文摘要 @@ -1132,7 +1157,8 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 列表返回 `dailyLimitUsd`、`weeklyLimitUsd`、`dailyUsedUsd`、`weeklyUsedUsd`(均为字符串)、 `dailyResetsAt`、`weeklyResetsAt`(RFC3339 或 `null`)。 记账和限额比较保留完整精度。 -日窗口按北京时间零点重置;周窗口从首次准入当天零点起持续七天,到期后在下一次使用时重新开启。 +日窗口按部署时区的下一自然日边界重置;周窗口从首次准入当天日界起持续七个本地日历日,到期后在下一次使用时重新开启。 +切换部署时区不修改已打开窗口的起止点或金额,到期后续接窗口不与旧窗口重叠。 手动重置清零所选周期的已用金额并清除到期时间,保留限额上限和历史费用,返回 `{ id }`。 所选窗口的重置时间返回 `null`,下次使用时按新建 Key 的规则重新开启。重置前完成但延迟结算的费用不再计入所选周期,也不会开启窗口; 重置后完成的请求继续计费,包括重置时仍在进行的请求。操作保留管理员审计,不改变账号上游额度。 @@ -1214,9 +1240,10 @@ accountWarmupScheduleTime accountWarmupModel ``` -定时账号预热默认关闭。`accountWarmupScheduleTime` 使用北京时间(UTC+8)的 `HH:MM`, +定时账号预热默认关闭。`accountWarmupScheduleTime` 使用部署时区中的 `HH:MM`, 多个时段以逗号分隔,默认 `08:00`;`accountWarmupModel` 默认 `null`,开启前必须显式选择模型。 任务面向可用的 OpenAI OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。 +不存在的本地时刻跳过,重复时刻只执行较早一次;同一本地分钟的去重跨进程重启保留。 只有收到响应成功终态才记为预热成功;预热不计入客户端业务用量 `requestLocationEnabled` 是必填布尔值,默认 `false`:关闭时不覆盖客户端原有位置和时区;开启时使用已保存的 @@ -1463,7 +1490,7 @@ Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。门禁 | `GET` | `/api/admin/settings/backups` | 无 | 读取存储配置(含明文 Secret)、验证状态与调度配置 | | `POST` | `/api/admin/settings/backups/storage/update` | S3 配置 | 更新存储配置;`secretAccessKey` 为空字符串会校验失败 | | `POST` | `/api/admin/settings/backups/storage/test` | 无 | 测试已保存的存储配置(Put/Head/Get/Delete 探针) | -| `POST` | `/api/admin/settings/backups/schedule/update` | 调度配置 | 更新 Cron、时区与保留策略 | +| `POST` | `/api/admin/settings/backups/schedule/update` | 调度配置 | 更新 Cron 与保留策略 | | `GET` | `/api/admin/settings/backups/records` | 查询参数 | 分页查询备份记录 | | `POST` | `/api/admin/settings/backups/create` | `{ expiresInDays? }` | 创建手动备份,返回 `202 Accepted`;`expiresInDays` 为过期天数(0 或缺省表示不过期) | | `POST` | `/api/admin/settings/backups/download-url` | `{ backupId }` | 创建 5 分钟有效预签名下载地址(仅 completed) | @@ -1473,8 +1500,9 @@ Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。门禁 ```text storageRevision, endpoint, region, bucket, accessKeyId, secretAccessKey, prefix, -forcePathStyle, verified, scheduleEnabled, cronExpression, scheduleTimezone, -retentionDays, retentionCount, nextRunAt, lastVerifiedAt, updatedAt +forcePathStyle, verified, scheduleEnabled, cronExpression, +retentionDays, retentionCount, nextRunAt, nextRunAtDisplay, +lastVerifiedAt, lastVerifiedAtDisplay, updatedAt, updatedAtDisplay ``` 更新存储请求字段: @@ -1490,10 +1518,11 @@ endpoint, region, bucket, accessKeyId, secretAccessKey, prefix, forcePathStyle 更新调度请求字段: ```text -scheduleEnabled, cronExpression, scheduleTimezone, retentionDays, retentionCount +scheduleEnabled, cronExpression, retentionDays, retentionCount ``` -`cronExpression` 为 5 段格式;`retentionDays`/`retentionCount` 为 0 表示禁用对应清理。启用计划前必须已保存完整存储配置且通过连接测试 +`cronExpression` 为 5 段格式,按部署 `host.timezone` 解释;不存在的本地时刻跳过,重复时刻只执行较早一次。 +调度时区没有独立请求字段。`retentionDays`/`retentionCount` 为 0 表示禁用对应清理。启用计划前必须已保存完整存储配置且通过连接测试 记录列表查询参数: @@ -1508,6 +1537,8 @@ id, triggerKind, status, scheduledAt, objectKey, sizeBytes, sha256, attemptCount errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt ``` +记录中的时间点同时返回对应的 `*Display` 字段 + `expiresAt` 在创建时确定:手动备份来自 `expiresInDays`,计划备份来自当时的 `retentionDays`;到期后由 Worker 进入删除流程 @@ -1523,7 +1554,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt | HTTP | 场景 | | --- | --- | -| `400` | 配置、Cron、时区或状态参数无效 | +| `400` | 配置、Cron 或状态参数无效 | | `404` | 备份记录不存在 | | `409` | 已有活跃任务、状态冲突或存储身份锁定 | | `502` | S3 兼容服务返回无效或失败响应 | @@ -1535,7 +1566,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt | 方法 | 路由 | 说明 | | --- | --- | --- | -| `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind`、`startTime`、`endTime` | +| `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind` 与统一时间范围参数 | | `GET` | `/api/admin/dashboard/trend` | Dashboard 趋势;`kind=usage\|latency\|errors` | | `GET` | `/api/admin/usage/records` | 请求记录分页列表 | | `GET` | `/api/admin/usage/records/detail` | 按 `id` 查询请求详情 | @@ -1559,6 +1590,9 @@ Dashboard 的 `accountUsage[]` 由后端提供 `usageWindow`、`metricLabel`、` ### 请求字段与筛选 +Dashboard 默认 `period=today`,用量、诊断和错误查询默认 `period=7d`,范围参数遵循[页面时间合同](#页面时间合同)。 +Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚点 + 用量查询可组合页码/游标、时间范围、Provider、Client Key、账号、模型、route、transport、状态码、 request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项, diff --git a/docs/architecture.md b/docs/architecture.md index d55f4465a..6d9b808d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,6 +89,12 @@ UI 包只公开组件、主题与样式入口;Pinia 持久化、登录、路 插件页面把所需 UI、Vue 和 Tailwind CSS 4 样式编译进包内静态资源,通过隔离页面与受限消息桥使用自己的管理接口, 不在运行时借用宿主 Vue 实例或内部模块。组件与主题扩展方式见 [管理端主题](theme.md) +部署时区由 Host 配置拥有,组合根把不可变的 `DeploymentTimeZone` 传给各模块。 +Core 的 `time` 只提供时区与日历边界换算;Admin/Provider 拥有查询范围与调度规则,Store 绑定显式 UTC 边界并维护事务。 +API 的时间 presenter 负责页面及事件展示文本,前端消费投影,不解释时区或格式化日期。 +持久化、协议传输、排序与 TTL 使用 UTC 时间点或持续时长,不依赖进程、浏览器及数据库会话的本地时区。 +请求位置中的时区属于 Provider 请求身份,与部署日历规则独立。配置操作见[部署时区](../deploy/README.md#部署时区) + ### 3.1 插件扩展 `gateway-plugin/` 只是目录分组,包含独立 SDK 和宿主 Runtime,不存在聚合 crate。 @@ -564,7 +570,8 @@ Admin 的手动重置复用同一账本与 Key 行锁,在一个事务中清零 [SDK 预算接口](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#client-key-预算)。插件的外部周期和截止时间属于插件私有状态,展示在插件页面; 宿主预算接口和管理页面只表达原生窗口事实,不为插件周期修改滚动规则或重置语义 -- 日窗口按北京时间零点划分;七天窗口从首次准入当天零点开始,到期后由下一次使用重新开启,不固定为周一 +- 日窗口按部署时区自然日划分;七天窗口从首次准入当天日界开始,按七个本地日历日续期,不固定为周一。 + 已打开窗口不随时区切换清零或改写 UTC 边界,续接起点不得早于旧终点与人工重置边界 - 金额优先使用 Provider 上报的 USD,否则按现有模型价格估算;订阅账号的估算费用不代表上游订阅账单。 费用归属请求完成时间,跨日请求计入完成日,延迟写入仍保留原完成时间 - 准入检查已结算金额,不预占未来费用。达到任一金额阈值后拒绝新请求,已准入请求仍可完成并使总额超过阈值 @@ -746,7 +753,12 @@ Worker 由各 Bundle 贡献、由 Host 统一监督: - Core:`runtime` owner 的 RuntimeSnapshot 周期对账和 Redis change 订阅 - Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理; 以及账号冻结恢复 worker(容量熔断的自适应并发下调与到期探测解冻) -- Provider:credential refresh、quota/catalog 健康和官方版本/etag 检查 +- Provider:credential refresh、quota/catalog 健康、账号预热和官方版本/etag 检查 + +预热与备份按部署时区解释本地时刻,缺失时刻跳过、重复时刻取较早一次。 +预热由 Store 持久领取本地分钟,防止多个时间槽、回拨或重启丢失去重状态;取消仍沿既有 worker lease 生命周期释放。 +备份设置中的时区仅记录游标来源,切换后从当前时刻重算未来游标,不补跑旧计划。 +游标重算核对启停、Cron、来源时区及旧游标;任务入队与游标推进同事务提交,冲突时只跳过任务 账号容量熔断默认关闭。启用后,仅普通请求收到的明确容量拒绝(`server_is_overloaded`、`slow_down` 或结构化错误中的明确过载提示)按滑动窗口计数,并把当时观测到的在途并发并入峰值证据; diff --git a/frontend/package.json b/frontend/package.json index 1caaffce3..7eb8cb462 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,6 @@ "@vueuse/core": "^15.0.0", "axios": "^1.20.0", "css-tree": "^3.2.1", - "dayjs": "^1.11.23", "echarts": "^6.1.0", "es-toolkit": "^1.52.0", "gsap": "^3.15.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c74818c59..ea7624661 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -190,9 +190,6 @@ importers: css-tree: specifier: ^3.2.1 version: 3.2.1 - dayjs: - specifier: ^1.11.23 - version: 1.11.23 echarts: specifier: ^6.1.0 version: 6.1.0 @@ -1281,9 +1278,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dayjs@1.11.23: - resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3561,8 +3555,6 @@ snapshots: csstype@3.2.3: {} - dayjs@1.11.23: {} - debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/frontend/src/api/modules/account-groups.ts b/frontend/src/api/modules/account-groups.ts index 005cff5d3..9796636aa 100644 --- a/frontend/src/api/modules/account-groups.ts +++ b/frontend/src/api/modules/account-groups.ts @@ -34,7 +34,9 @@ export interface AccountGroup extends AccountGroupRef { capacity: AccountGroupCapacity usage: AccountGroupUsage createdAt: string + createdAtDisplay: string updatedAt: string + updatedAtDisplay: string } export interface AccountGroupPageMeta { diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index e6cdbf82b..516958fb3 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -39,6 +39,7 @@ export interface AccountQuota { limitReached: boolean // 429 临时限流(Redis 冷却)到期时间;非限流中为 null。 rateLimitedUntil: string | null + rateLimitRecoveryDisplay: string | null rateLimitReason: 'upstream_rate_limit' | 'capacity_freeze' | null recoveryProbeRequired: boolean windows: AccountQuotaWindow[] @@ -82,6 +83,7 @@ export interface AccountModelUsage { costs: AccountCurrencyCost[] lastUsedAt: string lastUsedAtDisplay: string + lastUsedAtFullDisplay: string | null } export interface AccountUsage { @@ -112,6 +114,7 @@ export interface AccountUsage { readTokensDisplay: string lastUsedAt: string | null lastUsedAtDisplay: string + lastUsedAtFullDisplay: string | null costEstimateStatus: string knownCostCount: number | null partialCostCount: number | null @@ -156,6 +159,7 @@ export interface Account { accessTokenExpiresAtDisplay: string | null refreshTokenExpiresAt: string | null nextRefreshAt: string | null + nextRefreshAtDisplay: string | null addedAt: string addedAtDisplay: string updatedAt: string @@ -271,14 +275,23 @@ export interface AccountProfileActivityInsights { export interface AccountSubscription { startsAt: string | null + startsAtDisplay: string | null expiresAt: string + expiresAtDisplay: string willRenew: boolean | null billingPeriod: string | null billingCurrency: string | null observedAt: string + observedAtDisplay: string +} + +export interface ProfileActivityCalendar { + rangeLabel: string + weeks: Array<{ key: string, monthLabel: string | null, cells: Array<{ date: string, dateDisplay: string, tokens: number, isFuture: boolean }> }> } export interface AccountProfileStatisticsResponse { + activityCalendar: ProfileActivityCalendar | null displayName: string | null username: string | null imageUrl: string | null @@ -299,6 +312,7 @@ export interface AccountResetCredit { status: string | null title: string | null expiresAt: string | null + expiresAtDisplay: string | null resetType: string | null } @@ -333,7 +347,9 @@ export type ImportItemStatus = 'pending' | 'running' | 'succeeded' | 'failed' | export interface AccountImportTask { taskId: string createdAt: string + createdAtDisplay: string finishedAt: string | null + finishedAtDisplay: string | null stopRequested: boolean total: number counts: Record & { importedAccounts: number } @@ -481,7 +497,7 @@ export function getAccounts(data: AccountListParams, options: RequestOptions = { } export function exportAccounts(data: AccountExportParam) { - return request({ + return request<{ exportedAt: string, fileName: string, documents: unknown[] }>({ url: '/api/admin/accounts/export', method: 'GET', params: data, diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/api-keys.ts index deae50fc6..57124641c 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/api-keys.ts @@ -23,10 +23,16 @@ export interface ApiKey { dailyUsedUsd: string weeklyUsedUsd: string dailyResetsAt: string | null + dailyResetsAtDisplay: string | null weeklyResetsAt: string | null + weeklyResetsAtDisplay: string | null createdAt: string + createdAtDisplay: string updatedAt: string + updatedAtDisplay: string lastUsedAt: string | null + lastUsedAtDisplay: string + lastUsedAtFullDisplay: string | null routingScope: ApiKeyRoutingScope groups: AccountGroupRef[] providerKinds: string[] diff --git a/frontend/src/api/modules/backups.ts b/frontend/src/api/modules/backups.ts index 66b7a2427..e40955b49 100644 --- a/frontend/src/api/modules/backups.ts +++ b/frontend/src/api/modules/backups.ts @@ -13,12 +13,14 @@ export interface BackupSettingsView { verified: boolean scheduleEnabled: boolean cronExpression: string | null - scheduleTimezone: string | null retentionDays: number retentionCount: number nextRunAt: string | null + nextRunAtDisplay: string | null lastVerifiedAt: string | null + lastVerifiedAtDisplay: string | null updatedAt: string + updatedAtDisplay: string } export interface UpdateBackupStoragePayload { @@ -34,7 +36,6 @@ export interface UpdateBackupStoragePayload { export interface UpdateBackupSchedulePayload { scheduleEnabled: boolean cronExpression: string - scheduleTimezone: string retentionDays: number retentionCount: number } @@ -60,6 +61,7 @@ export interface BackupRecord { triggerKind: BackupTriggerKind status: BackupStatus scheduledAt: string | null + scheduledAtDisplay: string | null objectKey: string sizeBytes: number | null sha256: string | null @@ -67,10 +69,15 @@ export interface BackupRecord { errorCode: string | null errorMessage: string | null startedAt: string | null + startedAtDisplay: string | null completedAt: string | null + completedAtDisplay: string | null expiresAt: string | null + expiresAtDisplay: string | null createdAt: string + createdAtDisplay: string updatedAt: string + updatedAtDisplay: string } export interface BackupRecordPage { diff --git a/frontend/src/api/modules/client-profiles.ts b/frontend/src/api/modules/client-profiles.ts index 29ce291b2..7b8d1aba7 100644 --- a/frontend/src/api/modules/client-profiles.ts +++ b/frontend/src/api/modules/client-profiles.ts @@ -49,7 +49,9 @@ export interface ClientProfilePreview { versionSource: 'official' | 'custom' recognized?: boolean verifiedAt: string | null + verifiedAtDisplay: string | null checkedAt: string | null + checkedAtDisplay: string | null error: string | null } @@ -92,7 +94,9 @@ export interface XaiClientProfilePreview extends Omit verifiedAt?: string + verifiedAtDisplay?: string release?: { status: 'unchecked' | 'aligned' | 'review_required' | 'check_failed' checkedAt?: string + checkedAtDisplay?: string latestVersion?: string latestBuild?: string error?: string @@ -134,6 +137,7 @@ export interface DashboardWireProfile { export interface DashboardAccountRequestBucket { bucketStart: string + label: string requestCount: number } @@ -171,6 +175,8 @@ export interface DashboardCapacityInfo { } export interface DashboardSummaryResponse { + asOf: string + asOfDisplay: string cards: DashboardCards trend: DashboardTrendResponse healthTimeline: DashboardHealthTimeline @@ -183,6 +189,8 @@ export interface DashboardSummaryResponse { } interface DashboardQuery { + period: 'today' + asOf: number kind: string } diff --git a/frontend/src/api/modules/key-usage.ts b/frontend/src/api/modules/key-usage.ts index 7e742f4d1..e9e4d1469 100644 --- a/frontend/src/api/modules/key-usage.ts +++ b/frontend/src/api/modules/key-usage.ts @@ -33,18 +33,22 @@ export interface KeyUsageBudget { dailyLimitUsd: string dailyUsedUsd: string dailyResetsAt: string | null + dailyResetsAtDisplay: string | null weeklyLimitUsd: string weeklyUsedUsd: string weeklyResetsAt: string | null + weeklyResetsAtDisplay: string | null } export interface KeyUsageTrendPoint extends KeyUsageMetrics { time: string bucketSeconds: number + label: string } export interface KeyUsageOverview { asOf: string + asOfDisplay: string startTime: string endTime: string key: KeyUsageBudget @@ -58,6 +62,7 @@ export type KeyUsageRecordKind = 'success' | 'error' export interface KeyUsageRecord { id: string createdAt: string + createdAtDisplay: string model: string | null route: string | null reasoningEffort: string | null @@ -82,8 +87,8 @@ export interface KeyUsagePage { } export interface KeyUsageQuery { - startTime: string - endTime: string + period: 'today' | '7d' | '30d' + asOf: number model?: string } diff --git a/frontend/src/api/modules/pricing.ts b/frontend/src/api/modules/pricing.ts index 64086a49a..1a92a8cf7 100644 --- a/frontend/src/api/modules/pricing.ts +++ b/frontend/src/api/modules/pricing.ts @@ -22,6 +22,7 @@ export interface PricingCatalog { synced: PricingMap overrides: PricingMap syncedAt: string | null + syncedAtDisplay: string | null } export type PricingChange diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index fa55e5036..9a029b245 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -37,9 +37,12 @@ export interface OutboundProxyRecord { revision: number accountCount: number lastTestAt: string | null + lastTestAtDisplay: string | null lastTest: OutboundProxyTest | null createdAt: string + createdAtDisplay: string updatedAt: string + updatedAtDisplay: string } interface ProxyPage { diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index 8a9c519dc..f2f342d60 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -51,6 +51,7 @@ export interface RuntimeSettings { accountWarmupScheduleTime: string accountWarmupModel: string | null updatedAt: string + updatedAtDisplay: string } export type ClientArchitecture = 'x64' | 'arm64' @@ -64,10 +65,12 @@ export interface ClientDownloadPackage { sizeBytes: number | null downloadUrl: string expiresAt: string | null + expiresAtDisplay: string | null } export interface CodexDesktopWindowsDownloads { resolvedAt: string + resolvedAtDisplay: string cached: boolean warning: string | null packages: ClientDownloadPackage[] @@ -93,7 +96,7 @@ export function getSettings(options: RequestOptions = {}) { }) } -type UpdateSettingsParam = Omit & { +type UpdateSettingsParam = Omit & { providerRequestProfiles: ProviderRequestProfileUpdates } diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 16b692de1..b2a5d56fd 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -58,7 +58,9 @@ export interface SystemUpdateStatus { message: string | null error: string | null startedAt: string | null + startedAtDisplay: string | null finishedAt: string | null + finishedAtDisplay: string | null } } diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index 97250a1eb..e1984a9cd 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -466,8 +466,8 @@ export interface UsageDiagnosticsResponse { // 请求参数类型:仅定义 API 边界的形状,调用方不依赖显式声明。 interface UsageRangeQuery { - startTime: string - endTime: string + period: 'today' | '7d' | '30d' + asOf: number provider?: string model?: string statusCode?: number diff --git a/frontend/src/components/LastUsedAtCell.vue b/frontend/src/components/LastUsedAtCell.vue index 4f9230f96..9073c66fc 100644 --- a/frontend/src/components/LastUsedAtCell.vue +++ b/frontend/src/components/LastUsedAtCell.vue @@ -1,20 +1,7 @@ diff --git a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue index c7cad6285..187357aba 100644 --- a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue +++ b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue @@ -1,10 +1,9 @@ @@ -36,7 +35,7 @@ const dates = computed(() => [ {{ date.label }}
- + 未知
diff --git a/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue b/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue index 0d6d68c17..e68acf4e9 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue @@ -1,13 +1,13 @@ diff --git a/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts b/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts index 47e63e04a..c26949273 100644 --- a/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts +++ b/frontend/src/views/accounts/components/AccountUsageWindow/presenter.ts @@ -9,6 +9,7 @@ type AccountUsageWindowMode = 'quota' | 'local' | 'unknown' interface AccountRequestBucket { bucketStart: string + label: string requestCount: number } @@ -46,7 +47,6 @@ export interface AccountUsageWindowPresentationInput { window: AccountQuotaWindow | undefined variant: AccountUsageWindowVariant showLocalValue: boolean - now: number } const variantDefinitions: Record< @@ -91,12 +91,6 @@ const variantDefinitions: Record< }, } -const hourMilliseconds = 60 * 60 * 1_000 -const hourFormatter = new Intl.DateTimeFormat('zh-CN', { - hour: '2-digit', - minute: '2-digit', - hour12: false, -}) const quotaToneClasses: Record< QuotaWindowTone, { bar: string, text: string } @@ -166,7 +160,7 @@ export function resolveAccountUsageWindowPresentation( requestDisplay: localRequestDisplay, requestValueVisible: localRequestValueVisible, timelineTitle: `${localRequestLabel} ${localRequestDisplay} 次`, - requestBars: requestTimeline(localUsage?.requestBuckets ?? [], input.now), + requestBars: requestTimeline(localUsage?.requestBuckets ?? []), durationDisplay: rollingWindowDurationDisplay(input.window?.windowSeconds), }, } @@ -204,12 +198,13 @@ function requestBuckets(value: unknown) { if ( typeof item.bucketStart !== 'string' || !finiteNumber(item.requestCount) - || !Number.isFinite(new Date(item.bucketStart).getTime()) + || typeof item.label !== 'string' ) { return [] } return [{ bucketStart: item.bucketStart, + label: item.label, requestCount: item.requestCount, }] }) @@ -288,37 +283,12 @@ function quotaWindowTone(usedPercent: number | null): QuotaWindowTone { return 'success' } -function requestTimeline( - buckets: NonNullable, - now: number, -): AccountRequestBar[] { - const currentHour = Math.floor(now / hourMilliseconds) * hourMilliseconds - const bucketCounts = new Map( - buckets.map((bucket) => { - const bucketHour = Math.floor(new Date(bucket.bucketStart).getTime() / hourMilliseconds) - * hourMilliseconds - return [bucketHour, Math.max(0, bucket.requestCount)] as const - }), - ) - const slots = Array.from({ length: 24 }, (_, index) => { - const startTime = currentHour - (23 - index) * hourMilliseconds - return { - startTime, - requestCount: bucketCounts.get(startTime) ?? 0, - } - }) - const maximum = Math.max(1, ...slots.map(slot => slot.requestCount)) - - return slots.map((slot) => { - const start = new Date(slot.startTime) - const end = new Date(slot.startTime + hourMilliseconds) - return { - key: start.toISOString(), - requestCount: slot.requestCount, - height: slot.requestCount === 0 - ? '0' - : `${Math.max(25, Math.round(slot.requestCount / maximum * 100))}%`, - title: `${hourFormatter.format(start)}–${hourFormatter.format(end)} · ${slot.requestCount} 次请求`, - } - }) +function requestTimeline(buckets: NonNullable): AccountRequestBar[] { + const maximum = Math.max(1, ...buckets.map(bucket => bucket.requestCount)) + return buckets.map(bucket => ({ + key: bucket.bucketStart, + requestCount: bucket.requestCount, + height: bucket.requestCount === 0 ? '0' : `${Math.max(25, Math.round(bucket.requestCount / maximum * 100))}%`, + title: bucket.label, + })) } diff --git a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts index baeaebb51..5f48eca51 100644 --- a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts +++ b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts @@ -9,7 +9,6 @@ import { getAccountModels, refreshAccountModels } from '@/api' import { API_BASE_URL } from '@/api/constants' import { useIdSet } from '@/composables/useIdSet' import { useRequestState } from '@/composables/useRequestState' -import { formatDateTime, formatTime } from '@/utils/format' import { errorMessage, withMinimumDuration } from '@/utils/operation' interface ConnectionTestRun { @@ -80,13 +79,16 @@ interface ConnectionTestFailureEvent { upstreamBody?: string | null } -type ConnectionTestEvent - = | ConnectionTestStartEvent - | ConnectionTestRequestEvent - | ConnectionTestStatusEvent - | ConnectionTestContentEvent - | ConnectionTestCompleteEvent - | ConnectionTestFailureEvent +interface ConnectionTestTimestamp { occurredAtDisplay: string, timeDisplay: string } + +type ConnectionTestEvent = ConnectionTestTimestamp & ( + | ConnectionTestStartEvent + | ConnectionTestRequestEvent + | ConnectionTestStatusEvent + | ConnectionTestContentEvent + | ConnectionTestCompleteEvent + | ConnectionTestFailureEvent +) const CONNECTION_TEST_EVENT_TYPES = new Set([ 'test_start', @@ -268,10 +270,11 @@ export function useAccountConnectionTest(options: { reload: () => Promise Promise Promise item.key === key) - const next = connectionTestLogItem(key, text, tone, detail) + const next = connectionTestLogItem(key, text, tone, detail, event) if (index === -1) { connectionTestLogs.value = [...connectionTestLogs.value, next] return @@ -306,11 +311,11 @@ export function useAccountConnectionTest(options: { reload: () => Promise Promise Promise Promise Promise @@ -316,7 +317,7 @@ const {