fix: use China time across admin metrics

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