diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 02d3f8d57..fcf3c8064 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -16,7 +16,10 @@ use crate::{ admin::auth::session::require_admin_session, admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, http::middleware::request_id::RequestId, - infra::json::{clamp_limit, clamp_page, Page}, + infra::{ + china_rfc3339, china_rfc3339_str, + json::{clamp_limit, clamp_page, Page}, + }, runtime::state::AppState, upstream::accounts::store::StoredAccount, }; @@ -111,9 +114,9 @@ impl From for AdminAccountData { label: a.label, plan_type: a.plan_type, status: account_status_str(a.status).to_string(), - access_token_expires_at: a.access_token_expires_at.map(|dt| dt.to_rfc3339()), - added_at: a.added_at.to_rfc3339(), - updated_at: a.updated_at.to_rfc3339(), + access_token_expires_at: a.access_token_expires_at.map(|dt| china_rfc3339(&dt)), + added_at: china_rfc3339(&a.added_at), + updated_at: china_rfc3339(&a.updated_at), } } } @@ -179,9 +182,9 @@ impl From for AdminAccountExportData { label: a.label, plan_type: a.plan_type, status: account_status_str(a.status).to_string(), - access_token_expires_at: a.access_token_expires_at.map(|dt| dt.to_rfc3339()), - added_at: a.added_at, - updated_at: a.updated_at, + access_token_expires_at: a.access_token_expires_at.map(|dt| china_rfc3339(&dt)), + added_at: china_rfc3339_str(&a.added_at), + updated_at: china_rfc3339_str(&a.updated_at), token: a.access_token.expose_secret().to_string(), refresh_token: a.refresh_token.map(|t| t.expose_secret().to_string()), } diff --git a/src/admin/accounts/service/quota.rs b/src/admin/accounts/service/quota.rs index f7235b383..d13aea81b 100644 --- a/src/admin/accounts/service/quota.rs +++ b/src/admin/accounts/service/quota.rs @@ -1,6 +1,8 @@ use chrono::Utc; use secrecy::ExposeSecret; +use crate::infra::china_rfc3339; + use super::{types::AdminAccountError, AdminAccountService}; impl AdminAccountService { @@ -96,7 +98,7 @@ impl AdminAccountService { Ok(serde_json::json!({ "warnings": warnings, - "updatedAt": Utc::now().to_rfc3339() + "updatedAt": china_rfc3339(&Utc::now()) })) } pub async fn health_check_accounts( diff --git a/src/admin/auth/session.rs b/src/admin/auth/session.rs index 778582166..cb3760849 100644 --- a/src/admin/auth/session.rs +++ b/src/admin/auth/session.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ admin::response::{AdminEnvelope, AdminError, AdminResponse}, http::middleware::request_id::RequestId, + infra::china_rfc3339, runtime::state::AppState, }; @@ -80,7 +81,7 @@ pub async fn login( StatusCode::OK, AdminEnvelope::ok( AdminLoginData { - expires_at: session.expires_at.to_rfc3339(), + expires_at: china_rfc3339(&session.expires_at), }, request_id.clone(), ), diff --git a/src/admin/keys/routes.rs b/src/admin/keys/routes.rs index 74d5ebf30..0c694f5cf 100644 --- a/src/admin/keys/routes.rs +++ b/src/admin/keys/routes.rs @@ -16,7 +16,10 @@ use crate::{ }, admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, http::middleware::request_id::RequestId, - infra::json::{clamp_limit, Page}, + infra::{ + china_rfc3339_str, + json::{clamp_limit, Page}, + }, runtime::state::AppState, }; @@ -111,8 +114,8 @@ impl From for ClientApiKeyData { label: k.label, prefix: k.prefix, enabled: k.enabled, - created_at: k.created_at, - last_used_at: k.last_used_at, + created_at: china_rfc3339_str(&k.created_at), + last_used_at: k.last_used_at.as_deref().map(china_rfc3339_str), } } } @@ -125,8 +128,8 @@ impl From for ClientApiKeyExportEntry { label: k.label, prefix: k.prefix, enabled: k.enabled, - created_at: k.created_at, - last_used_at: k.last_used_at, + created_at: china_rfc3339_str(&k.created_at), + last_used_at: k.last_used_at.as_deref().map(china_rfc3339_str), } } } @@ -139,8 +142,8 @@ impl From for CreatedClientApiKeyData { label: k.label, prefix: k.prefix, enabled: k.enabled, - created_at: k.created_at, - last_used_at: k.last_used_at, + created_at: china_rfc3339_str(&k.created_at), + last_used_at: k.last_used_at.as_deref().map(china_rfc3339_str), plaintext: k.plaintext, } } diff --git a/src/admin/monitoring/dashboard.rs b/src/admin/monitoring/dashboard.rs index ba340a145..f0c1e951b 100644 --- a/src/admin/monitoring/dashboard.rs +++ b/src/admin/monitoring/dashboard.rs @@ -8,7 +8,7 @@ use axum::{ response::IntoResponse, Extension, }; -use chrono::{DateTime, Duration, FixedOffset, Timelike, Utc}; +use chrono::{DateTime, Duration, Timelike, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -27,6 +27,7 @@ use crate::{ response::{AdminEnvelope, AdminError, AdminResponse}, }, http::middleware::request_id::RequestId, + infra::{china_datetime_rfc3339_str, china_rfc3339}, runtime::state::AppState, upstream::accounts::model::{Account, AccountStatus}, }; @@ -736,7 +737,7 @@ fn account_usage_data( requests: nonnegative_i64_to_u64(usage.request_count), tokens: nonnegative_i64_to_u64(usage.input_tokens + usage.output_tokens), quota_used_percent, - last_used_at: usage.last_used_at.map(|value| value.to_rfc3339()), + last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)), status, } }) @@ -820,7 +821,7 @@ fn service_status_data(state: &AppState) -> Vec { }, DashboardServiceStatusData { label: "更新时间".to_string(), - value: format_beijing_datetime(fingerprint.updated_at), + value: format_fingerprint_updated_at(fingerprint.updated_at), detail: String::new(), tone: "normal".to_string(), }, @@ -841,7 +842,7 @@ fn empty_dash(value: String) -> String { } } -fn format_beijing_datetime(value: Option) -> String { +fn format_fingerprint_updated_at(value: Option) -> String { let Some(value) = value else { return "-".to_string(); }; @@ -849,19 +850,7 @@ fn format_beijing_datetime(value: Option) -> String { if value.is_empty() { return "-".to_string(); } - - let Some(offset) = FixedOffset::east_opt(8 * 60 * 60) else { - return value.to_string(); - }; - - DateTime::parse_from_rfc3339(value) - .map(|datetime| { - datetime - .with_timezone(&offset) - .format("%Y-%m-%d %H:%M:%S") - .to_string() - }) - .unwrap_or_else(|_| value.to_string()) + china_datetime_rfc3339_str(value) } fn empty_usage_summary() -> AdminUsageSummary { diff --git a/src/admin/monitoring/events.rs b/src/admin/monitoring/events.rs index e1ea088cd..30e0c2015 100644 --- a/src/admin/monitoring/events.rs +++ b/src/admin/monitoring/events.rs @@ -60,6 +60,7 @@ pub struct EventLog { /// 结构化元数据。 pub metadata: Value, /// 创建时间。 + #[serde(serialize_with = "crate::infra::serialize_china_rfc3339")] pub created_at: DateTime, } diff --git a/src/admin/monitoring/usage.rs b/src/admin/monitoring/usage.rs index bde844d52..22bc1551f 100644 --- a/src/admin/monitoring/usage.rs +++ b/src/admin/monitoring/usage.rs @@ -13,7 +13,10 @@ use crate::{ admin::monitoring::service::{AdminUsageError, AdminUsageRecord, AdminUsageSummary}, admin::response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, http::middleware::request_id::RequestId, - infra::json::{clamp_limit, clamp_page, Page}, + infra::{ + china_rfc3339, + json::{clamp_limit, clamp_page, Page}, + }, runtime::state::AppState, }; @@ -171,7 +174,7 @@ impl From for AdminUsageStatsData { image_output_tokens: usage.image_output_tokens, image_request_count: usage.image_request_count, image_request_failed_count: usage.image_request_failed_count, - last_used_at: usage.last_used_at.map(|v| v.to_rfc3339()), + last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)), } } } diff --git a/src/infra/mod.rs b/src/infra/mod.rs index eca74156f..4fcd8cc55 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -2,6 +2,11 @@ //! 平台基础设施层,承载加密、身份、存储、日志和 JSON 原语。 +use chrono::{DateTime, FixedOffset, Utc}; +use serde::Serializer; + +const CHINA_OFFSET_SECONDS: i32 = 8 * 60 * 60; + /// 加密原语。 pub mod crypto; /// 数据库连接与 SQLite 管理。 @@ -14,3 +19,39 @@ pub mod json; pub mod logging; /// 路径和安装 ID 辅助。 pub mod paths; + +/// 将 UTC 时间输出为中国时区 RFC3339 字符串。 +pub fn china_rfc3339(value: &DateTime) -> String { + value.with_timezone(&china_offset()).to_rfc3339() +} + +/// 将 RFC3339 字符串输出为中国时区 RFC3339 字符串。 +pub fn china_rfc3339_str(value: &str) -> String { + DateTime::parse_from_rfc3339(value) + .map(|datetime| datetime.with_timezone(&china_offset()).to_rfc3339()) + .unwrap_or_else(|_| value.to_string()) +} + +/// 将 RFC3339 字符串输出为中国时区日期时间。 +pub fn china_datetime_rfc3339_str(value: &str) -> String { + DateTime::parse_from_rfc3339(value) + .map(|datetime| { + datetime + .with_timezone(&china_offset()) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }) + .unwrap_or_else(|_| value.to_string()) +} + +/// Serde 序列化 UTC 时间为中国时区 RFC3339 字符串。 +pub fn serialize_china_rfc3339(value: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&china_rfc3339(value)) +} + +fn china_offset() -> FixedOffset { + FixedOffset::east_opt(CHINA_OFFSET_SECONDS).expect("valid China timezone offset") +} diff --git a/tests/admin/accounts/list.rs b/tests/admin/accounts/list.rs index e7b7f55eb..89851406f 100644 --- a/tests/admin/accounts/list.rs +++ b/tests/admin/accounts/list.rs @@ -49,6 +49,10 @@ async fn admin_accounts_list_should_not_decrypt_account_tokens() { let body = response_json(response).await; assert_eq!(body["data"]["items"][0]["id"], "acct_corrupt"); assert_eq!(body["data"]["items"][0]["email"], "user@example.com"); + assert_eq!( + body["data"]["items"][0]["addedAt"], + "2026-06-18T08:00:00+08:00" + ); } #[tokio::test] diff --git a/web/src/views/api-keys/ApiKeysView.vue b/web/src/views/api-keys/ApiKeysView.vue index 3f6198dda..6664f5aec 100644 --- a/web/src/views/api-keys/ApiKeysView.vue +++ b/web/src/views/api-keys/ApiKeysView.vue @@ -269,7 +269,7 @@ function formatDate(dateStr?: string): string { if (minutes < 60) return `${minutes}分钟前` if (hours < 24) return `${hours}小时前` if (days < 7) return `${days}天前` - return date.toLocaleDateString('zh-CN') + return date.toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }) } function maskKey(prefix: string): string { diff --git a/web/src/views/dashboard/composables/useDashboard.ts b/web/src/views/dashboard/composables/useDashboard.ts index e1e2b989a..45df3e01a 100644 --- a/web/src/views/dashboard/composables/useDashboard.ts +++ b/web/src/views/dashboard/composables/useDashboard.ts @@ -398,12 +398,13 @@ export function useDashboard(): { if (minutes < 60) return `${minutes}分钟前` if (hours < 24) return `${hours}小时前` if (days < 7) return `${days}天前` - return date.toLocaleDateString('zh-CN') + return date.toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }) } function formatTime(dateStr: string): string { const date = new Date(dateStr) return date.toLocaleTimeString('zh-CN', { + timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', second: '2-digit', diff --git a/web/src/views/logs/LogsView.vue b/web/src/views/logs/LogsView.vue index c000498ae..bc9a82575 100644 --- a/web/src/views/logs/LogsView.vue +++ b/web/src/views/logs/LogsView.vue @@ -146,6 +146,7 @@ async function handleViewDetail(log: EventLog) { function formatTime(dateStr: string): string { const date = new Date(dateStr) return date.toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',