fix: render admin times in China timezone

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-25 12:15:03 +08:00
co-authored by Codex
parent 12aee2b907
commit 11f0308f5f
12 changed files with 86 additions and 37 deletions
+10 -7
View File
@@ -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<AdminAccountMetadata> 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<StoredAccount> 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()),
}
+3 -1
View File
@@ -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(
+2 -1
View File
@@ -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(),
),
+10 -7
View File
@@ -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<AdminStoredClientApiKey> 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<AdminStoredClientApiKey> 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<AdminCreatedClientApiKey> 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,
}
}
+6 -17
View File
@@ -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> {
},
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>) -> String {
fn format_fingerprint_updated_at(value: Option<String>) -> String {
let Some(value) = value else {
return "-".to_string();
};
@@ -849,19 +850,7 @@ fn format_beijing_datetime(value: Option<String>) -> 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 {
+1
View File
@@ -60,6 +60,7 @@ pub struct EventLog {
/// 结构化元数据。
pub metadata: Value,
/// 创建时间。
#[serde(serialize_with = "crate::infra::serialize_china_rfc3339")]
pub created_at: DateTime<Utc>,
}
+5 -2
View File
@@ -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<AdminUsageRecord> 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)),
}
}
}
+41
View File
@@ -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<Utc>) -> 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<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&china_rfc3339(value))
}
fn china_offset() -> FixedOffset {
FixedOffset::east_opt(CHINA_OFFSET_SECONDS).expect("valid China timezone offset")
}
+4
View File
@@ -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]
+1 -1
View File
@@ -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 {
@@ -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',
+1
View File
@@ -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',