mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix: use China time across admin metrics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+138
-138
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, ¤t_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, ¤t_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
@@ -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 {
|
||||
|
||||
@@ -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 删除账号。
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod crypto;
|
||||
pub mod log_rotation;
|
||||
pub mod storage_schema;
|
||||
pub mod time;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user