From ab3a2e30c5941801bc553bab35bd2074047d3270 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 1 Oct 2026 15:30:37 +0800 Subject: [PATCH] feat(time): unify deployment timezone across display and scheduling Use one validated IANA timezone for backend display projections, calendar queries, budget windows, warmup slots, backups, and logs. Remove frontend date formatting and the independent backup timezone setting. Preserve UTC instants and active budget windows, persist warmup deduplication, and advance backup schedules transactionally. Cover DST boundaries, timezone changes, invalid calendar anchors, and account cooldown displays. Co-authored-by: Codex --- backend/Cargo.lock | 2 + backend/apps/gateway/src/bootstrap.rs | 6 +- backend/apps/gateway/tests/bootstrap.rs | 38 +++- .../crates/gateway-admin/src/backup/policy.rs | 41 ++-- .../crates/gateway-admin/src/backup/task.rs | 63 ++++-- backend/crates/gateway-admin/src/lib.rs | 15 +- .../gateway-admin/src/model/backup/code.rs | 1 - .../gateway-admin/src/model/backup/mod.rs | 2 +- .../gateway-admin/src/model/observability.rs | 44 ++++- .../crates/gateway-admin/src/ports/backup.rs | 33 +++- .../gateway-admin/src/use_case/backup.rs | 34 ++-- .../gateway-admin/src/use_case/key_usage.rs | 19 +- .../src/use_case/observability.rs | 65 ++++--- .../gateway-admin/tests/backup/policy.rs | 39 +++- .../gateway-admin/tests/backup/support.rs | 35 +++- .../crates/gateway-admin/tests/backup/task.rs | 30 ++- .../gateway-admin/tests/use_case/mod.rs | 1 + .../tests/use_case/observability.rs | 40 +++- .../gateway-api/src/admin/account_groups.rs | 36 +++- .../src/admin/accounts/handlers.rs | 40 ++-- .../src/admin/accounts/import_tasks.rs | 28 ++- .../gateway-api/src/admin/accounts/mod.rs | 2 +- .../src/admin/accounts/presenter.rs | 145 +++++++------- .../gateway-api/src/admin/accounts/wire.rs | 151 +++++++++++++-- .../crates/gateway-api/src/admin/backups.rs | 60 ++++-- .../gateway-api/src/admin/client_keys.rs | 39 +++- .../src/admin/observability/mod.rs | 2 +- .../src/admin/observability/presenter.rs | 181 ++++++++++-------- .../src/admin/observability/query.rs | 48 ++++- .../src/admin/observability/routes.rs | 75 ++++++-- .../src/admin/observability/wire.rs | 15 +- .../crates/gateway-api/src/admin/proxies.rs | 35 +++- .../crates/gateway-api/src/admin/settings.rs | 63 ++++-- .../crates/gateway-api/src/admin/system.rs | 38 ++-- .../crates/gateway-api/src/key_usage/mod.rs | 10 +- .../gateway-api/src/key_usage/presenter.rs | 51 ++++- .../crates/gateway-api/src/key_usage/query.rs | 50 +++-- backend/crates/gateway-api/src/lib.rs | 3 + backend/crates/gateway-api/src/time.rs | 136 +++++++++++++ .../tests/admin/accounts/handlers.rs | 129 +++++++++++++ .../gateway-api/tests/admin/accounts/mod.rs | 155 +++++++++------ .../tests/admin/accounts/presenter.rs | 15 +- .../gateway-api/tests/admin/client_keys.rs | 41 ++-- backend/crates/gateway-api/tests/admin/mod.rs | 30 ++- .../tests/admin/observability/response.rs | 155 ++++++++++++++- .../gateway-api/tests/admin/settings.rs | 28 ++- .../crates/gateway-api/tests/architecture.rs | 1 + .../crates/gateway-api/tests/key_usage/mod.rs | 24 ++- backend/crates/gateway-core/Cargo.toml | 1 + backend/crates/gateway-core/src/lib.rs | 1 + .../crates/gateway-core/src/provider_ports.rs | 13 ++ backend/crates/gateway-core/src/time.rs | 88 +++++++++ backend/crates/gateway-core/tests/main.rs | 2 + backend/crates/gateway-core/tests/time.rs | 106 ++++++++++ backend/crates/gateway-host/Cargo.toml | 1 + backend/crates/gateway-host/src/config.rs | 27 ++- backend/crates/gateway-host/src/lib.rs | 2 +- .../crates/gateway-host/src/logging/mod.rs | 26 ++- .../crates/gateway-host/src/logging/writer.rs | 27 ++- backend/crates/gateway-host/tests/config.rs | 1 + .../crates/gateway-host/tests/logging/mod.rs | 52 +++++ .../crates/gateway-host/tests/logging/sink.rs | 4 +- .../gateway-host/tests/logging/writer.rs | 13 +- .../runtime/tests/support/environment.rs | 1 + backend/crates/gateway-store/src/bundle.rs | 24 ++- backend/crates/gateway-store/src/config.rs | 8 + .../src/postgres/account_groups.rs | 27 ++- .../gateway-store/src/postgres/backup.rs | 79 ++++++-- .../src/postgres/client_budgets.rs | 36 +++- .../crates/gateway-store/src/postgres/mod.rs | 3 +- .../postgres/observability/admin_adapter.rs | 26 ++- .../src/postgres/observability/mapping.rs | 7 + .../src/postgres/observability/model.rs | 23 ++- .../observability/queries/accounts.rs | 23 +-- .../observability/queries/dashboard.rs | 96 ++++++++-- .../provider_accounts/admin_adapter.rs | 8 +- .../src/postgres/runtime_settings.rs | 28 +++ .../gateway-store/tests/postgres/backup.rs | 134 ++++++++++--- .../tests/postgres/client_budgets.rs | 62 ++++++ .../gateway-store/tests/postgres/mod.rs | 9 + .../tests/postgres/observability/mod.rs | 173 ++++++++++++++++- .../tests/postgres/provider_accounts/mod.rs | 2 +- .../tests/postgres/runtime_settings.rs | 44 +++++ backend/crates/providers/openai/src/config.rs | 9 + .../providers/openai/src/credential/admin.rs | 12 +- backend/crates/providers/openai/src/lib.rs | 4 +- .../providers/openai/src/provider/mod.rs | 9 + .../providers/openai/src/provider/workers.rs | 76 ++++---- .../providers/openai/src/transport/client.rs | 10 + .../openai/src/transport/client_sse.rs | 5 +- .../providers/openai/src/transport/mod.rs | 1 - .../providers/openai/src/transport/time.rs | 16 -- .../openai/src/transport/websocket/audit.rs | 12 +- .../openai/tests/transport/websocket/mod.rs | 18 +- backend/migrations/.frozen-sha256 | 1 + .../migrations/0021_account_warmup_slots.sql | 7 + deploy/Dockerfile | 2 +- deploy/README.md | 31 ++- deploy/compose.yaml | 6 +- deploy/config.example.yaml | 2 + docs/api.md | 62 ++++-- docs/architecture.md | 16 +- frontend/package.json | 1 - frontend/pnpm-lock.yaml | 8 - frontend/src/api/modules/account-groups.ts | 2 + frontend/src/api/modules/accounts.ts | 18 +- frontend/src/api/modules/api-keys.ts | 6 + frontend/src/api/modules/backups.ts | 11 +- frontend/src/api/modules/client-profiles.ts | 4 + frontend/src/api/modules/dashboard.ts | 8 + frontend/src/api/modules/key-usage.ts | 9 +- frontend/src/api/modules/pricing.ts | 1 + frontend/src/api/modules/proxies.ts | 3 + frontend/src/api/modules/settings.ts | 5 +- frontend/src/api/modules/system.ts | 2 + frontend/src/api/modules/usage.ts | 4 +- frontend/src/components/LastUsedAtCell.vue | 17 +- .../ClientProfilePreviewPanel.vue | 5 +- .../components/SystemUpdateModal/index.vue | 5 +- frontend/src/stores/modules/system-update.ts | 1 + frontend/src/utils/format.ts | 59 +----- .../components/AccountConnectionTestModal.vue | 3 +- .../AccountImportTasks/TaskDetail.vue | 4 +- .../components/AccountImportTasks/index.vue | 4 +- .../AccountImportTasks/presenter.ts | 5 - .../AccountProfileModal/Subscription.vue | 7 +- .../AccountProfileModal/TokenActivity.vue | 10 +- .../components/AccountProfileModal/index.vue | 2 +- .../components/AccountQuotaPanel/Entry.vue | 3 - .../AccountQuotaPanel/ResetCredits.vue | 15 +- .../AccountQuotaSummaryCell/Entry.vue | 3 - .../components/AccountStatusBadge/index.vue | 9 +- .../AccountStatusBadge/presenter.ts | 33 +--- .../components/AccountUsageWindow/index.vue | 3 - .../AccountUsageWindow/presenter.ts | 54 ++---- .../composables/useAccountConnectionTest.ts | 59 +++--- .../composables/useAccountMutations.ts | 3 +- frontend/src/views/accounts/index.vue | 5 +- .../utils/accountProfileStatistics.ts | 159 ++------------- .../components/RequestHealthTimelineCard.vue | 15 +- .../dashboard/components/WireProfileCard.vue | 9 +- .../dashboard/composables/useDashboard.ts | 15 +- .../composables/useRequestTrendChart.ts | 17 +- frontend/src/views/dashboard/constants.ts | 1 + .../groups/composables/useAccountGroups.ts | 2 - .../key-usage/components/KeyUsageBudget.vue | 8 +- .../key-usage/components/KeyUsageRecords.vue | 3 +- .../key-usage/components/KeyUsageTrend.vue | 7 +- .../key-usage/composables/useKeyUsage.ts | 15 +- frontend/src/views/key-usage/index.vue | 3 +- frontend/src/views/key-usage/utils/format.ts | 8 - .../keys/components/ApiKeyBudgetCell.vue | 9 +- .../views/keys/composables/useApiKeysQuery.ts | 2 - frontend/src/views/keys/index.vue | 2 +- frontend/src/views/proxies/index.vue | 3 +- .../settings/components/AccountWarmupCard.vue | 2 +- .../components/backup/BackupRecordsCard.vue | 5 +- .../components/backup/BackupScheduleCard.vue | 15 +- .../ClientDesktopOfflineDownloads.vue | 3 +- .../settings/composables/useBackupSettings.ts | 3 - .../views/settings/pricing/PricingToolbar.vue | 4 +- frontend/src/views/settings/pricing/index.vue | 2 +- .../src/views/settings/pricing/usePricing.ts | 2 +- .../src/views/theme/fixtures/dashboard.ts | 5 + .../usage/composables/useUsageTimeRange.ts | 42 +--- .../views/usage/utils/diagnosticsBundle.ts | 1 - 166 files changed, 3215 insertions(+), 1308 deletions(-) create mode 100644 backend/crates/gateway-api/src/time.rs create mode 100644 backend/crates/gateway-core/src/time.rs create mode 100644 backend/crates/gateway-core/tests/time.rs delete mode 100644 backend/crates/providers/openai/src/transport/time.rs create mode 100644 backend/migrations/0021_account_warmup_slots.sql 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 {