mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef52b3717 | ||
|
|
45e72682e6 | ||
|
|
90e241f74b | ||
|
|
a0e8f768dd | ||
|
|
318f8a748e | ||
|
|
0dcba14572 | ||
|
|
ab3a2e30c5 | ||
|
|
0490e28cfc | ||
|
|
9c10676764 | ||
|
|
caa6c8ffc8 | ||
|
|
9d651856be | ||
|
|
d10b8890e0 | ||
|
|
d97c6e064b | ||
|
|
f85801edb3 |
@@ -117,8 +117,9 @@ runs:
|
||||
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
|
||||
-f deploy/compose.yaml)
|
||||
"${compose[@]}" up -d --no-build --wait postgres redis
|
||||
# 基础设施使用 UTC,业务展示和调度时区由应用配置独立控制。
|
||||
test "$("${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = Asia/Shanghai
|
||||
psql -At -U codex_proxy -d codex_proxy -c "SHOW TimeZone;" | tr -d '\r')" = UTC
|
||||
"${compose[@]}" exec -T -e PGPASSWORD="$CI_POSTGRES_PASSWORD" postgres \
|
||||
psql -v ON_ERROR_STOP=1 -U codex_proxy -d codex_proxy -c \
|
||||
"CREATE TABLE cpr_ci_persistence_probe (value text PRIMARY KEY); INSERT INTO cpr_ci_persistence_probe VALUES ('persisted');"
|
||||
@@ -148,8 +149,8 @@ runs:
|
||||
test "$status" = 204
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -u | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs id -g | tr -d '\r')" = 10001
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0800
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = Asia/Shanghai
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs date +%z | tr -d '\r')" = +0000
|
||||
test "$("${compose[@]}" exec -T codex-proxy-rs cat /etc/timezone | tr -d '\r')" = UTC
|
||||
"${compose[@]}" exec -T codex-proxy-rs sh -c \
|
||||
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
|
||||
|
||||
|
||||
Generated
+2
@@ -1731,6 +1731,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
@@ -1779,6 +1780,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
|
||||
|
||||
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
|
||||
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
#[serde(default)]
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
launch(None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
|
||||
pub struct PluginCommand {
|
||||
pub instance_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
fn is_help(&self) -> bool {
|
||||
self.name.is_none()
|
||||
|| self
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|value| matches!(value.as_str(), "--help" | "-h"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugin_command(
|
||||
command: PluginCommand,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
launch(Some(command)).await
|
||||
}
|
||||
|
||||
async fn launch(
|
||||
command: Option<PluginCommand>,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
macro_rules! or_shutdown {
|
||||
($runtime:expr, $result:expr) => {
|
||||
match $result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
$runtime.shutdown().await;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let GatewayConfig {
|
||||
schema_version: _,
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
} = config;
|
||||
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
let host = if command.is_some() {
|
||||
gateway_host::initialize_command_line(host).await?
|
||||
} else {
|
||||
gateway_host::initialize(host).await?
|
||||
};
|
||||
host.report_startup_ready("Host");
|
||||
let help = command.as_ref().is_some_and(PluginCommand::is_help);
|
||||
let mut store = if help {
|
||||
gateway_store::initialize_read_only(store).await?
|
||||
} else if command.is_some() {
|
||||
gateway_store::initialize_command_line(store).await?
|
||||
} else {
|
||||
gateway_store::initialize(store).await?
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?);
|
||||
if let Some(command) = command.as_ref().filter(|_| help) {
|
||||
let runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
|
||||
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
|
||||
commands.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
return Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: stdout?,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
});
|
||||
}
|
||||
let provider_ports = store.provider_ports();
|
||||
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
|
||||
host.report_startup_ready("OpenAI Provider");
|
||||
let mut xai = provider_xai::initialize(provider_ports).await?;
|
||||
host.report_startup_ready("xAI Provider");
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
|
||||
openai.admin_provider(),
|
||||
xai.admin_provider(),
|
||||
])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
if let Some(command) = command {
|
||||
let instance_id = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
|
||||
);
|
||||
let name = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
|
||||
);
|
||||
let core = gateway_core::prepare_command_plane(
|
||||
store.core_ports(),
|
||||
providers.clone(),
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
);
|
||||
let accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts));
|
||||
let keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
admin_providers,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::service::Registry::new({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
});
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
or_shutdown!(plugin_runtime, store.start_command_line_writes());
|
||||
let result = commands
|
||||
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
|
||||
.await;
|
||||
commands.shutdown().await;
|
||||
plugin_runtime.shutdown().await;
|
||||
store.shutdown_command_line_writes().await?;
|
||||
return Ok(result?);
|
||||
}
|
||||
let core = gateway_core::prepare(
|
||||
store.core_ports(),
|
||||
providers,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
let plugin_accounts = gateway_admin::initialize_plugin_accounts(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().accounts(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_account_ports(&plugin_accounts)
|
||||
);
|
||||
let plugin_keys = gateway_admin::initialize_plugin_client_keys(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_key_ports(&plugin_keys)
|
||||
);
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
host.report_startup_ready("Core");
|
||||
let host_version = or_shutdown!(
|
||||
plugin_runtime,
|
||||
version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))
|
||||
);
|
||||
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
|
||||
gateway_plugin_runtime::PackageLimits::default(),
|
||||
host_version,
|
||||
));
|
||||
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
|
||||
));
|
||||
let mut admin = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_admin::initialize_with_plugin_accounts(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
service_middleware: {
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
},
|
||||
providers: admin_providers,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
published_snapshot: core.snapshots(),
|
||||
plugin_inspector,
|
||||
plugin_distribution,
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
plugin_accounts,
|
||||
)
|
||||
.await
|
||||
);
|
||||
let official_files = host.official_plugin_release_files();
|
||||
let official_identity = host.official_plugin_release_identity();
|
||||
let official_import = or_shutdown!(
|
||||
plugin_runtime,
|
||||
admin
|
||||
.services()
|
||||
.plugins()
|
||||
.import_official_release(
|
||||
official_files.as_ref(),
|
||||
&official_identity,
|
||||
&gateway_admin::model::MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "startup-official-plugin-import".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
);
|
||||
if official_import.artifacts > 0 {
|
||||
host.report_startup_ready("Official plugins");
|
||||
}
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.resolve(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(gateway_host::retention::worker(store.retention())?);
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease())
|
||||
);
|
||||
host.report_startup_ready("Workers");
|
||||
let served = host.serve(api.router()).await;
|
||||
plugin_runtime.shutdown().await;
|
||||
served?;
|
||||
Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_plugins(
|
||||
store: &gateway_store::StoreBundle,
|
||||
plugin_cache: std::path::PathBuf,
|
||||
version: &str,
|
||||
http: std::sync::Arc<gateway_host::outbound::HttpClient>,
|
||||
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
|
||||
Ok(std::sync::Arc::new(
|
||||
gateway_plugin_runtime::PluginRuntime::new(
|
||||
store.admin_ports().plugins(),
|
||||
store.admin_ports().plugin_state(),
|
||||
gateway_plugin_runtime::PluginRuntimeConfig {
|
||||
cache_directory: plugin_cache,
|
||||
host_version: version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?,
|
||||
package_limits: gateway_plugin_runtime::PackageLimits::default(),
|
||||
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Worker(#[from] gateway_core::task::WorkerDefinitionError),
|
||||
#[error(transparent)]
|
||||
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Outbound(#[from] gateway_host::outbound::HttpError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
#[error(transparent)]
|
||||
Registry(#[from] RegistryError),
|
||||
#[error(transparent)]
|
||||
Core(#[from] gateway_core::CoreError),
|
||||
#[error(transparent)]
|
||||
Admin(#[from] gateway_admin::model::AdminError),
|
||||
#[error(transparent)]
|
||||
Api(#[from] gateway_api::ApiError),
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! 插件帮助只读启动;命令仅组装命令平面和必要写泵。
|
||||
|
||||
use super::{
|
||||
BootstrapError, GatewayConfig,
|
||||
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
|
||||
startup::{Environment, Mode, Providers},
|
||||
};
|
||||
|
||||
/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。
|
||||
pub struct PluginCommand {
|
||||
pub instance_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginCommand {
|
||||
fn is_help(&self) -> bool {
|
||||
self.name.is_none()
|
||||
|| self
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|value| matches!(value.as_str(), "--help" | "-h"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugin_command(
|
||||
command: PluginCommand,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
if command.is_help() {
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Help).await?;
|
||||
help(command, environment).await
|
||||
} else {
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Command).await?;
|
||||
let providers = Providers::initialize(config.openai, &environment).await?;
|
||||
execute(command, environment, providers).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn help(
|
||||
command: PluginCommand,
|
||||
environment: Environment,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let runtime = prepare_plugins(
|
||||
&environment.store,
|
||||
environment.plugin_cache,
|
||||
&environment.version,
|
||||
environment.plugin_http,
|
||||
)?;
|
||||
let commands = or_shutdown!(runtime, runtime.prepare_command_line().await);
|
||||
let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref());
|
||||
commands.shutdown().await;
|
||||
runtime.shutdown().await;
|
||||
Ok(gateway_plugin_runtime::PluginCommandOutput {
|
||||
stdout: stdout?,
|
||||
stderr: String::new(),
|
||||
exit_code: 0,
|
||||
saved_accounts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
command: PluginCommand,
|
||||
environment: Environment,
|
||||
providers: Providers,
|
||||
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
|
||||
let Environment {
|
||||
host,
|
||||
mut store,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
..
|
||||
} = environment;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http)?;
|
||||
let instance_id = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.instance_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失"))
|
||||
);
|
||||
let name = or_shutdown!(
|
||||
plugin_runtime,
|
||||
command
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失"))
|
||||
);
|
||||
let core = gateway_core::prepare_command_plane(
|
||||
store.core_ports(),
|
||||
providers.core,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
providers.admin,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::service::Registry::new(service_middleware(
|
||||
core.snapshots(),
|
||||
&plugin_runtime,
|
||||
));
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
let result = async {
|
||||
store.start_command_line_writes()?;
|
||||
Ok::<_, BootstrapError>(
|
||||
commands
|
||||
.execute_cancellable(instance_id, name, &command.arguments, &host.cancellation())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
.await;
|
||||
commands.shutdown().await;
|
||||
plugin_runtime.shutdown().await;
|
||||
store.shutdown_command_line_writes().await?;
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
pub(super) host: HostConfig,
|
||||
pub(super) store: gateway_store::StoreConfig,
|
||||
pub(super) admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
pub(super) client: gateway_admin::ClientConfig,
|
||||
pub(super) api: gateway_api::ApiConfig,
|
||||
#[serde(default)]
|
||||
pub(super) openai: provider_openai::OpenAiConfig,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 网关唯一组合根:按启动模式连接各 Bundle。
|
||||
|
||||
mod command;
|
||||
mod config;
|
||||
mod plugins;
|
||||
mod server;
|
||||
mod startup;
|
||||
|
||||
pub use command::{PluginCommand, plugin_command};
|
||||
pub use config::GatewayConfig;
|
||||
pub use server::run;
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Worker(#[from] gateway_core::task::WorkerDefinitionError),
|
||||
#[error(transparent)]
|
||||
PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError),
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Outbound(#[from] gateway_host::outbound::HttpError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
#[error(transparent)]
|
||||
Registry(#[from] gateway_core::engine::provider::RegistryError),
|
||||
#[error(transparent)]
|
||||
Core(#[from] gateway_core::CoreError),
|
||||
#[error(transparent)]
|
||||
Admin(#[from] gateway_admin::model::AdminError),
|
||||
#[error(transparent)]
|
||||
Api(#[from] gateway_api::ApiError),
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 插件 Runtime 的组装与回调资源持有;服务和命令模式复用同一组管理端口。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::{
|
||||
model::AdminError,
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
|
||||
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
|
||||
use gateway_host::ConfigError;
|
||||
use gateway_plugin_runtime::PluginRuntime;
|
||||
|
||||
use super::BootstrapError;
|
||||
|
||||
// 必须在调用方仍持有 Core 和回调资源时异步关闭,不能先退出该作用域再清理。
|
||||
macro_rules! or_shutdown {
|
||||
($runtime:expr, $result:expr) => {
|
||||
match $result {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
$runtime.shutdown().await;
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) use or_shutdown;
|
||||
|
||||
pub(super) struct ManagementPorts {
|
||||
// Runtime 保存 Weak;组合根持有这些 Arc,直到插件全部关闭。
|
||||
pub(super) accounts: Arc<dyn PluginAccountAccess>,
|
||||
keys: Arc<dyn PluginClientKeyAccess>,
|
||||
resources: Arc<dyn PluginResourceAccess>,
|
||||
}
|
||||
|
||||
impl ManagementPorts {
|
||||
pub(super) fn new(
|
||||
store: &gateway_store::StoreBundle,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
let ports = store.admin_ports();
|
||||
Self {
|
||||
accounts: gateway_admin::initialize_plugin_accounts(
|
||||
providers.clone(),
|
||||
ports.accounts(),
|
||||
snapshot.clone(),
|
||||
),
|
||||
keys: gateway_admin::initialize_plugin_client_keys(
|
||||
providers,
|
||||
ports.client_keys(),
|
||||
snapshot.clone(),
|
||||
),
|
||||
resources: gateway_admin::initialize_plugin_resources(
|
||||
ports.plugin_resources(),
|
||||
snapshot,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
|
||||
runtime.bind_account_ports(&self.accounts)?;
|
||||
runtime.bind_client_key_ports(&self.keys)?;
|
||||
runtime.bind_resource_ports(&self.resources)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn service_middleware(
|
||||
snapshots: RuntimeSnapshotHandle,
|
||||
runtime: &PluginRuntime,
|
||||
) -> gateway_admin::service::PlanSource {
|
||||
let middleware = runtime.middleware_registry();
|
||||
Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prepare_plugins(
|
||||
store: &gateway_store::StoreBundle,
|
||||
plugin_cache: std::path::PathBuf,
|
||||
version: &str,
|
||||
http: Arc<gateway_host::outbound::HttpClient>,
|
||||
) -> Result<Arc<gateway_plugin_runtime::PluginRuntime>, BootstrapError> {
|
||||
Ok(Arc::new(
|
||||
gateway_plugin_runtime::PluginRuntime::new(
|
||||
store.admin_ports().plugins(),
|
||||
store.admin_ports().plugin_state(),
|
||||
gateway_plugin_runtime::PluginRuntimeConfig {
|
||||
cache_directory: plugin_cache,
|
||||
host_version: version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?,
|
||||
package_limits: gateway_plugin_runtime::PackageLimits::default(),
|
||||
rpc_limits: gateway_plugin_runtime::RpcLimits::default(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 服务模式完成全部组装后交给 Host 监听,并在请求和 Worker 排空后关闭插件。
|
||||
|
||||
use gateway_host::ConfigError;
|
||||
|
||||
use super::{
|
||||
BootstrapError, GatewayConfig,
|
||||
plugins::{ManagementPorts, or_shutdown, prepare_plugins, service_middleware},
|
||||
startup::{Environment, Mode, Providers},
|
||||
};
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let environment = Environment::initialize(config.host, config.store, Mode::Server).await?;
|
||||
let mut providers = Providers::initialize(config.openai, &environment).await?;
|
||||
let Environment {
|
||||
host,
|
||||
mut store,
|
||||
timezone,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
} = environment;
|
||||
let plugin_runtime = prepare_plugins(&store, plugin_cache, &version, plugin_http.clone())?;
|
||||
let core = gateway_core::prepare(
|
||||
store.core_ports(),
|
||||
providers.core,
|
||||
Some(plugin_runtime.clone()),
|
||||
Some(plugin_runtime.observer_registry()),
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.middleware_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
|
||||
);
|
||||
host.report_startup_ready("Core");
|
||||
let host_version = or_shutdown!(
|
||||
plugin_runtime,
|
||||
version
|
||||
.trim_start_matches('v')
|
||||
.parse()
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))
|
||||
);
|
||||
let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new(
|
||||
gateway_plugin_runtime::PackageLimits::default(),
|
||||
host_version,
|
||||
));
|
||||
let plugin_distribution = std::sync::Arc::new(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http)
|
||||
));
|
||||
let mut admin = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_admin::initialize_with_plugin_accounts(
|
||||
config.admin,
|
||||
config.client,
|
||||
store.admin_ports(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware: service_middleware(core.snapshots(), &plugin_runtime),
|
||||
providers: providers.admin,
|
||||
plugin_preparation: plugin_runtime.clone(),
|
||||
plugin_management: plugin_runtime.clone(),
|
||||
published_snapshot: core.snapshots(),
|
||||
plugin_inspector,
|
||||
plugin_distribution,
|
||||
pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
snapshot: core.snapshot_control(),
|
||||
account_probe: core.account_probe(),
|
||||
proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
client_distribution: host.client_distribution_resolver(),
|
||||
system: host.system_operations(),
|
||||
client_key_verifier: core.client_key_verifier(),
|
||||
},
|
||||
management.accounts.clone(),
|
||||
)
|
||||
.await
|
||||
);
|
||||
let official_files = host.official_plugin_release_files();
|
||||
let official_identity = host.official_plugin_release_identity();
|
||||
let official_import = or_shutdown!(
|
||||
plugin_runtime,
|
||||
admin
|
||||
.services()
|
||||
.plugins()
|
||||
.import_official_release(
|
||||
official_files.as_ref(),
|
||||
&official_identity,
|
||||
&gateway_admin::model::MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "startup-official-plugin-import".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
);
|
||||
if official_import.artifacts > 0 {
|
||||
host.report_startup_ready("Official plugins");
|
||||
}
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
config.api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.resolve(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_host::retention::worker(store.retention())
|
||||
));
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(providers.openai.take_worker_contributions());
|
||||
plan.extend(providers.xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
plan.push(or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.maintenance_worker(core.snapshots())
|
||||
));
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
host.start_workers(plan, store.worker_leader_lease())
|
||||
);
|
||||
host.report_startup_ready("Workers");
|
||||
let served = host.serve(api.router()).await;
|
||||
plugin_runtime.shutdown().await;
|
||||
Ok(served?)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 启动模式共享的基础设施与 Provider 组装,不启动 HTTP 或 Worker。
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use gateway_admin::ports::provider::ProviderAdminRegistry;
|
||||
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
|
||||
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_store::{StoreBundle, StoreConfig};
|
||||
|
||||
use super::BootstrapError;
|
||||
|
||||
pub(super) enum Mode {
|
||||
Server,
|
||||
Command,
|
||||
Help,
|
||||
}
|
||||
|
||||
pub(super) struct Environment {
|
||||
pub(super) host: HostBundle,
|
||||
pub(super) store: StoreBundle,
|
||||
pub(super) timezone: DeploymentTimeZone,
|
||||
pub(super) plugin_cache: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) plugin_http: Arc<HttpClient>,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
pub(super) async fn initialize(
|
||||
host: HostConfig,
|
||||
store: StoreConfig,
|
||||
mode: Mode,
|
||||
) -> Result<Self, BootstrapError> {
|
||||
let timezone = host.timezone;
|
||||
let store = store.with_timezone(timezone);
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
let host = match mode {
|
||||
Mode::Server => gateway_host::initialize(host).await?,
|
||||
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
|
||||
};
|
||||
host.report_startup_ready("Host");
|
||||
let store = match mode {
|
||||
Mode::Server => gateway_store::initialize(store).await?,
|
||||
Mode::Command => gateway_store::initialize_command_line(store).await?,
|
||||
Mode::Help => gateway_store::initialize_read_only(store).await?,
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = Arc::new(HttpClient::new()?);
|
||||
Ok(Self {
|
||||
host,
|
||||
store,
|
||||
timezone,
|
||||
plugin_cache,
|
||||
version,
|
||||
plugin_http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Providers {
|
||||
pub(super) openai: provider_openai::ProviderBundle,
|
||||
pub(super) xai: provider_xai::ProviderBundle,
|
||||
pub(super) core: ProviderRegistry,
|
||||
pub(super) admin: ProviderAdminRegistry,
|
||||
}
|
||||
|
||||
impl Providers {
|
||||
pub(super) async fn initialize(
|
||||
config: provider_openai::OpenAiConfig,
|
||||
environment: &Environment,
|
||||
) -> Result<Self, BootstrapError> {
|
||||
let ports = environment.store.provider_ports();
|
||||
let openai =
|
||||
provider_openai::initialize(config.with_timezone(environment.timezone), ports.clone())
|
||||
.await?;
|
||||
environment.host.report_startup_ready("OpenAI Provider");
|
||||
let xai = provider_xai::initialize(ports).await?;
|
||||
environment.host.report_startup_ready("xAI Provider");
|
||||
let core = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let admin = ProviderAdminRegistry::new([openai.admin_provider(), xai.admin_provider()])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
Ok(Self {
|
||||
openai,
|
||||
xai,
|
||||
core,
|
||||
admin,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[test]
|
||||
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
|
||||
for flag in ["--help", "-h", "help", "--version", "-V"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(flag)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{flag}");
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
for args in [
|
||||
vec!["unknown"],
|
||||
vec!["--help", "extra"],
|
||||
vec!["serve", "extra"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.args(args)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_command_is_not_interpreted_as_serve() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(std::ffi::OsString::from_vec(vec![0xff]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
|
||||
}
|
||||
+40
-199
@@ -3,205 +3,12 @@ use std::{fs, process::Command};
|
||||
use codex_proxy_rs::bootstrap::GatewayConfig;
|
||||
use gateway_host::LoadableConfig;
|
||||
|
||||
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
|
||||
const CONFIG_EXAMPLE: &str = include_str!("../../../../../deploy/config.example.yaml");
|
||||
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
|
||||
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[test]
|
||||
fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap();
|
||||
for flag in ["--help", "-h", "help", "--version", "-V"] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(flag)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{flag}");
|
||||
assert!(!output.stdout.is_empty());
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
for args in [
|
||||
vec!["unknown"],
|
||||
vec!["--help", "extra"],
|
||||
vec!["serve", "extra"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.args(args)
|
||||
.current_dir(directory.path())
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_command_is_not_interpreted_as_serve() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs"))
|
||||
.arg(std::ffi::OsString::from_vec(vec![0xff]))
|
||||
.env_clear()
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_load_complete_terminal_example() {
|
||||
parse_config(&valid_config()).expect("terminal config example");
|
||||
@@ -230,7 +37,7 @@ fn config_loader_should_share_resolved_assets_with_system_update() {
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
|
||||
"bootstrap::config::config_loader_should_share_resolved_assets_with_system_update",
|
||||
])
|
||||
.env(CHILD_ENV, case)
|
||||
.env_remove("CPR_WEB_DIST_DIR");
|
||||
@@ -292,7 +99,7 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
let status = Command::new(std::env::current_exe().expect("current test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
|
||||
"bootstrap::config::config_loader_should_apply_only_explicit_topology_overrides",
|
||||
])
|
||||
.env(TOPOLOGY_CHILD_ENV, "1")
|
||||
.env("CPR_SERVER_HOST", "127.0.0.1")
|
||||
@@ -374,7 +181,15 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
|
||||
return;
|
||||
}
|
||||
for case in ["normal", "unused", "missing"] {
|
||||
for case in [
|
||||
"normal",
|
||||
"unused",
|
||||
"missing",
|
||||
"timezoneEmpty",
|
||||
"timezoneInvalid",
|
||||
"timezoneNull",
|
||||
"timezoneType",
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
@@ -384,6 +199,14 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
.unwrap()
|
||||
.remove("port");
|
||||
}
|
||||
if case.starts_with("timezone") {
|
||||
document["host"]["timezone"] = match case {
|
||||
"timezoneEmpty" => serde_json::json!(""),
|
||||
"timezoneInvalid" => serde_json::json!(UNUSED_SECRET),
|
||||
"timezoneNull" => serde_json::json!(null),
|
||||
_ => serde_json::json!(8),
|
||||
};
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(
|
||||
@@ -394,7 +217,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"bootstrap::config::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
@@ -404,7 +227,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
case != "missing",
|
||||
case != "missing" && !case.starts_with("timezone"),
|
||||
"{case}: {stderr}"
|
||||
);
|
||||
match case {
|
||||
@@ -413,6 +236,9 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
case if case.starts_with("timezone") => {
|
||||
assert!(stderr.contains("host.timezone"), "{stderr}")
|
||||
}
|
||||
_ => assert!(!stderr.contains("警告"), "{stderr}"),
|
||||
}
|
||||
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
|
||||
@@ -580,3 +406,18 @@ fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), Stri
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((config, directory))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_timezone_defaults_and_valid_override_are_loaded() {
|
||||
let mut document = valid_config_document();
|
||||
document["host"].as_object_mut().unwrap().remove("timezone");
|
||||
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
|
||||
let host: gateway_host::config::HostConfig =
|
||||
serde_json::from_value(document["host"].clone()).unwrap();
|
||||
assert_eq!(host.timezone.name(), "Asia/Shanghai");
|
||||
document["host"]["timezone"] = serde_json::json!("Asia/Kathmandu");
|
||||
let (_config, _directory) = parse_config(&document.to_string()).unwrap();
|
||||
let host: gateway_host::config::HostConfig =
|
||||
serde_json::from_value(document["host"].clone()).unwrap();
|
||||
assert_eq!(host.timezone.name(), "Asia/Kathmandu");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod command;
|
||||
mod config;
|
||||
mod server;
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::{fs, process::Command};
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::server::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
@@ -18,7 +18,12 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
assert_eq!(
|
||||
rust_files(&root.join("src")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("bootstrap/command.rs"),
|
||||
PathBuf::from("bootstrap/config.rs"),
|
||||
PathBuf::from("bootstrap/mod.rs"),
|
||||
PathBuf::from("bootstrap/plugins.rs"),
|
||||
PathBuf::from("bootstrap/server.rs"),
|
||||
PathBuf::from("bootstrap/startup.rs"),
|
||||
PathBuf::from("lib.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
@@ -27,7 +32,10 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("bootstrap/command.rs"),
|
||||
PathBuf::from("bootstrap/config.rs"),
|
||||
PathBuf::from("bootstrap/mod.rs"),
|
||||
PathBuf::from("bootstrap/server.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -21,20 +21,23 @@ pub struct BackupSchedule {
|
||||
}
|
||||
|
||||
impl BackupSchedule {
|
||||
/// 解析并校验 5 段 Cron 与 IANA 时区。
|
||||
/// 按部署时区解析并校验 5 段 Cron。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
|
||||
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
|
||||
/// Cron 非法时返回 [`BackupError`]。
|
||||
pub fn parse(
|
||||
cron_expression: &str,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, BackupError> {
|
||||
let expression = format!("0 {cron_expression}");
|
||||
let schedule = Schedule::from_str(&expression).map_err(|_| {
|
||||
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
|
||||
})?;
|
||||
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
|
||||
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
|
||||
})?;
|
||||
Ok(Self { schedule, timezone })
|
||||
Ok(Self {
|
||||
schedule,
|
||||
timezone: timezone.iana(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
|
||||
@@ -49,9 +52,7 @@ impl BackupSchedule {
|
||||
// 检查窗口内是否存在触发;不存在则直接返回 None。
|
||||
let low_instant = instant(low_min, self.timezone)?;
|
||||
if self
|
||||
.schedule
|
||||
.after(&low_instant)
|
||||
.next()
|
||||
.next_after(low_instant.to_utc())
|
||||
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
|
||||
{
|
||||
return None;
|
||||
@@ -79,19 +80,21 @@ impl BackupSchedule {
|
||||
/// 返回第一个严格晚于 `at` 的触发时间。
|
||||
#[must_use]
|
||||
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let local = at.with_timezone(&self.timezone);
|
||||
self.schedule
|
||||
.after(&local)
|
||||
.next()
|
||||
.map(|firing| firing.with_timezone(&Utc))
|
||||
let local = at.with_timezone(&self.timezone).naive_local().and_utc();
|
||||
self.schedule.after(&local).find_map(|firing| {
|
||||
let instant = self
|
||||
.timezone
|
||||
.from_local_datetime(&firing.naive_utc())
|
||||
.earliest()?
|
||||
.to_utc();
|
||||
(instant > at).then_some(instant)
|
||||
})
|
||||
}
|
||||
|
||||
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
|
||||
fn firing_after(&self, minute_index: i64) -> Option<i64> {
|
||||
let instant = instant(minute_index, self.timezone)?;
|
||||
self.schedule
|
||||
.after(&instant)
|
||||
.next()
|
||||
let instant = instant(minute_index, self.timezone)?.to_utc();
|
||||
self.next_after(instant)
|
||||
.map(|firing| firing.timestamp().div_euclid(60))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const PENDING_DELETION_BATCH: u32 = 20;
|
||||
|
||||
/// 备份 Daemon 任务。
|
||||
pub struct BackupTask {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
@@ -58,11 +59,18 @@ impl BackupTask {
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone: Default::default(),
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_timezone(mut self, timezone: gateway_core::time::DeploymentTimeZone) -> Self {
|
||||
self.timezone = timezone;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
@@ -112,13 +120,29 @@ impl BackupTask {
|
||||
return Ok(());
|
||||
}
|
||||
let cron = settings.cron_expression.as_deref().unwrap_or_default();
|
||||
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
|
||||
if cron.is_empty() || timezone.is_empty() {
|
||||
let timezone = self.timezone.name();
|
||||
if cron.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let schedule = BackupSchedule::parse(cron, timezone)
|
||||
let schedule = BackupSchedule::parse(cron, self.timezone)
|
||||
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
|
||||
|
||||
if settings.schedule_timezone.as_deref() != Some(timezone) {
|
||||
// 时区切换只初始化未来游标,不把旧时区计划当作应补跑任务。
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
self.repository
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
settings.schedule_timezone.as_deref(),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let due = match settings.next_run_at {
|
||||
Some(next_run_at) if next_run_at <= now => true,
|
||||
Some(_) => false,
|
||||
@@ -127,7 +151,13 @@ impl BackupTask {
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
Some(timezone),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
@@ -145,7 +175,13 @@ impl BackupTask {
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.advance_schedule_cursor(
|
||||
next_run_at,
|
||||
cron,
|
||||
Some(timezone),
|
||||
settings.next_run_at,
|
||||
timezone,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
@@ -168,20 +204,19 @@ impl BackupTask {
|
||||
.map_err(infra_error)?;
|
||||
let inserted = self
|
||||
.repository
|
||||
.insert_scheduled_record(seed)
|
||||
.insert_scheduled_record(
|
||||
seed,
|
||||
schedule.next_after(now),
|
||||
cron,
|
||||
timezone,
|
||||
settings.next_run_at,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
if inserted {
|
||||
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
|
||||
} else {
|
||||
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
|
||||
}
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
warn!(scheduled_at = %scheduled_at, "计划已变化或存在冲突,本轮未创建任务");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
public_services: Arc<service::Registry>,
|
||||
plugins: Arc<PluginsService>,
|
||||
plugin_management: Arc<PluginManagementService>,
|
||||
@@ -207,6 +208,11 @@ pub struct AdminServices {
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub const fn timezone(&self) -> gateway_core::time::DeploymentTimeZone {
|
||||
self.timezone
|
||||
}
|
||||
|
||||
pub fn public_services(&self) -> Arc<service::Registry> {
|
||||
self.public_services.clone()
|
||||
}
|
||||
@@ -318,6 +324,7 @@ impl AdminBundle {
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub timezone: gateway_core::time::DeploymentTimeZone,
|
||||
pub service_middleware: service::PlanSource,
|
||||
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
|
||||
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
|
||||
@@ -367,6 +374,7 @@ async fn initialize_inner(
|
||||
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
@@ -413,12 +421,14 @@ async fn initialize_inner(
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
timezone,
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
)
|
||||
.with_timezone(timezone);
|
||||
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
|
||||
store.plugins(),
|
||||
plugin_inspector.clone(),
|
||||
@@ -431,6 +441,7 @@ async fn initialize_inner(
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
system.clone(),
|
||||
timezone,
|
||||
));
|
||||
let credentials = Arc::new(CredentialsService::new(
|
||||
registry.clone(),
|
||||
@@ -452,6 +463,7 @@ async fn initialize_inner(
|
||||
let mut public_services = service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
let services = AdminServices {
|
||||
timezone,
|
||||
public_services: Arc::new(public_services),
|
||||
plugin_management: Arc::new(PluginManagementService::new(
|
||||
plugin_management,
|
||||
@@ -492,6 +504,7 @@ async fn initialize_inner(
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
timezone,
|
||||
)),
|
||||
settings,
|
||||
system,
|
||||
|
||||
@@ -5,7 +5,6 @@ pub const INVALID_CONFIG: &str = "backup.invalid_config";
|
||||
/// Cron 表达式非法。
|
||||
pub const INVALID_CRON: &str = "backup.invalid_cron";
|
||||
/// IANA 时区非法。
|
||||
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
|
||||
/// 记录不存在。
|
||||
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
|
||||
/// 已存在活跃任务,拒绝创建。
|
||||
|
||||
@@ -183,7 +183,6 @@ impl fmt::Debug for UpdateBackupStorageCommand {
|
||||
pub struct UpdateBackupScheduleCommand {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
@@ -203,6 +202,7 @@ pub struct BackupSettings {
|
||||
pub force_path_style: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
/// 记录持久化游标采用的时区,由服务端维护,不是独立部署设置。
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
|
||||
use super::{AdminModelError, PageSize};
|
||||
|
||||
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
/// 页面筛选表达自然日范围,具体 UTC 边界由部署时区解析。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CalendarPeriod {
|
||||
Today,
|
||||
SevenDays,
|
||||
ThirtyDays,
|
||||
}
|
||||
|
||||
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
|
||||
#[must_use]
|
||||
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
|
||||
impl CalendarPeriod {
|
||||
pub fn parse(value: &str) -> Result<Self, AdminModelError> {
|
||||
match value {
|
||||
"today" => Ok(Self::Today),
|
||||
"7d" => Ok(Self::SevenDays),
|
||||
"30d" => Ok(Self::ThirtyDays),
|
||||
_ => Err(AdminModelError::InvalidTimeRange),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围。
|
||||
@@ -24,6 +33,25 @@ pub struct TimeRange {
|
||||
}
|
||||
|
||||
impl TimeRange {
|
||||
pub fn calendar_at(
|
||||
period: CalendarPeriod,
|
||||
end: DateTime<Utc>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<Self, AdminModelError> {
|
||||
let days = match period {
|
||||
CalendarPeriod::Today => 0,
|
||||
CalendarPeriod::SevenDays => 6,
|
||||
CalendarPeriod::ThirtyDays => 29,
|
||||
};
|
||||
let start = timezone
|
||||
.days_before(end, days)
|
||||
.ok_or(AdminModelError::InvalidTimeRange)?;
|
||||
// 自然日刚开始时允许空快照,不借用前一天或伪造未来终点。
|
||||
if start == end {
|
||||
return Ok(Self { start, end });
|
||||
}
|
||||
Self::new(start, end)
|
||||
}
|
||||
/// 创建最长 366 天的正时间范围。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -809,6 +837,8 @@ pub struct OpsError {
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
|
||||
@@ -826,11 +826,20 @@ pub struct ProviderQuota {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
pub credits: Option<ProviderQuotaCredits>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 上游点数余额的展示事实,保留原始十进制文本以避免精度损失。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaCredits {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
|
||||
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| {
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct RuntimeSettings {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
@@ -77,6 +78,7 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
@@ -182,6 +184,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
|
||||
@@ -55,6 +55,7 @@ pub trait BackupRepository: Send + Sync {
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
context: &MutationContext,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
|
||||
@@ -67,8 +68,16 @@ pub trait BackupRepository: Send + Sync {
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
|
||||
/// 核对启停、Cron、时区及旧游标后,同事务推进游标并插入 scheduled 任务。
|
||||
/// 配置已变化时不写入;同一时间点或活跃冲突时只推进游标,返回 `false`。
|
||||
async fn insert_scheduled_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 分页查询记录。
|
||||
async fn list_backup_records(
|
||||
@@ -111,12 +120,14 @@ pub trait BackupRepository: Send + Sync {
|
||||
/// 远端删除成功后硬删除记录行。
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
|
||||
|
||||
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
|
||||
/// 条件推进计划游标;仅当启用状态、计划表达式、时区及旧游标仍匹配时才写入,返回是否推进。
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
expected_timezone: Option<&str>,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
timezone: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
|
||||
@@ -286,6 +297,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
_command: UpdateBackupScheduleCommand,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
_timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
@@ -302,7 +314,14 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
async fn insert_scheduled_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
_expected_next_run_at: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_backup_records(
|
||||
@@ -352,7 +371,9 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
&self,
|
||||
_next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
_expected_timezone: Option<&str>,
|
||||
_expected_next_run_at: Option<DateTime<Utc>>,
|
||||
_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
|
||||
@@ -1052,6 +1052,7 @@ fn map_reset_credits_error_after_refresh(
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -76,6 +76,7 @@ pub trait BackupService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultBackupService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
@@ -89,8 +90,10 @@ impl DefaultBackupService {
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
repository,
|
||||
object_store,
|
||||
auth,
|
||||
@@ -128,22 +131,20 @@ impl BackupService for DefaultBackupService {
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_schedule(&command)?;
|
||||
let schedule =
|
||||
crate::backup::policy::BackupSchedule::parse(&command.cron_expression, self.timezone)
|
||||
.map_err(map_backup_error)?;
|
||||
let next_run_at = if command.schedule_enabled {
|
||||
Some(
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
schedule
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.repository
|
||||
.update_schedule_settings(command, next_run_at, context)
|
||||
.update_schedule_settings(command, next_run_at, context, self.timezone)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup schedule"))
|
||||
}
|
||||
@@ -408,23 +409,10 @@ fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验调度配置并解析 Cron 与时区。
|
||||
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
|
||||
if command.cron_expression.trim().is_empty() {
|
||||
return Err(AdminError::bad_request("cronExpression 不能为空"));
|
||||
}
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
|
||||
fn map_backup_error(error: BackupError) -> AdminError {
|
||||
match error.code() {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON => {
|
||||
AdminError::bad_request(error.message().to_owned())
|
||||
}
|
||||
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService, SystemService,
|
||||
@@ -15,9 +14,7 @@ use crate::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
observability::{OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery},
|
||||
system::SystemVersion,
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
@@ -62,6 +59,7 @@ pub trait KeyUsageService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
auth: Arc<dyn AuthService>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
@@ -76,8 +74,10 @@ impl DefaultKeyUsageService {
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
system: Arc<dyn SystemService>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
auth,
|
||||
verifier,
|
||||
keys,
|
||||
@@ -181,10 +181,13 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = usage_filter(&id, query.model);
|
||||
let now = Utc::now();
|
||||
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
|
||||
let now = query.range.end;
|
||||
// 健康条始终展示部署时区今日,不随历史范围或模型筛选改变。
|
||||
let today = TimeRange {
|
||||
start: china_day_start(now),
|
||||
start: self
|
||||
.timezone
|
||||
.day_start(now)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?,
|
||||
end: now,
|
||||
};
|
||||
let (overview, trend, health_points) = futures::try_join!(
|
||||
@@ -198,7 +201,7 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
key,
|
||||
overview,
|
||||
trend,
|
||||
health_timeline: health_timeline_at(&health_points, now),
|
||||
health_timeline: health_timeline_at(&health_points, now, self.timezone)?,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures::TryStreamExt;
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::{
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
UsageQuery, UsageSummary,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
@@ -37,7 +37,6 @@ use crate::{
|
||||
use super::{map_provider_error, map_store_error};
|
||||
|
||||
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
|
||||
@@ -126,6 +125,7 @@ pub trait ObservabilityService: Send + Sync {
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
@@ -140,8 +140,10 @@ impl DefaultObservabilityService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
store,
|
||||
accounts,
|
||||
settings,
|
||||
@@ -169,8 +171,14 @@ impl DefaultObservabilityService {
|
||||
.providers
|
||||
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
|
||||
}
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
let yesterday_start = today_start - Duration::days(1);
|
||||
let today_start = self
|
||||
.timezone
|
||||
.day_start(observation.range.end)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let yesterday_start = self
|
||||
.timezone
|
||||
.days_before(observation.range.end, 1)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let today =
|
||||
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
|
||||
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
|
||||
@@ -191,7 +199,8 @@ impl DefaultObservabilityService {
|
||||
let average_first_token_latency_ms =
|
||||
average(first_token_latency_sum_ms, first_token_latency_count);
|
||||
let trend = trend(TrendKind::Usage, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let health_timeline =
|
||||
health_timeline_at(&observation.trend, observation.range.end, self.timezone)?;
|
||||
let wire_profiles = self
|
||||
.providers
|
||||
.dashboard_wire_profiles(&settings.request_profiles);
|
||||
@@ -382,10 +391,19 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
}
|
||||
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
|
||||
self.store
|
||||
let mut page = self
|
||||
.store
|
||||
.list_ops_errors(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "operations errors"))
|
||||
.map_err(|error| map_store_error(error, "operations errors"))?;
|
||||
for error in &mut page.items {
|
||||
error.provider_account_plan_type_display =
|
||||
error.provider_kind.as_deref().and_then(|provider| {
|
||||
self.providers
|
||||
.plan_type_display(provider, error.provider_account_plan_type.as_deref())
|
||||
});
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,15 +870,28 @@ impl DefaultObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
/// 按部署时区的自然日计算 15 分钟健康桶,桶数随夏令时变化。
|
||||
pub(super) fn health_timeline_at(
|
||||
records: &[RequestMetricPoint],
|
||||
now: DateTime<Utc>,
|
||||
) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<HealthTimeline, AdminError> {
|
||||
let start = timezone
|
||||
.day_start(now)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let end = timezone
|
||||
.days_after(now, 1)
|
||||
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
|
||||
let current_slot = start
|
||||
+ Duration::minutes(
|
||||
(now - start)
|
||||
.num_minutes()
|
||||
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
|
||||
* HEALTH_TIMELINE_SLOT_MINUTES,
|
||||
);
|
||||
let slots = ((end - start).num_minutes() + HEALTH_TIMELINE_SLOT_MINUTES - 1)
|
||||
/ HEALTH_TIMELINE_SLOT_MINUTES;
|
||||
let mut buckets = (0..slots)
|
||||
.map(|index| {
|
||||
(
|
||||
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
|
||||
@@ -872,7 +903,13 @@ pub(super) fn health_timeline_at(
|
||||
if record.bucket_start < start || record.bucket_start > now {
|
||||
continue;
|
||||
}
|
||||
let record_slot = quarter_hour_start(record.bucket_start);
|
||||
let record_slot = start
|
||||
+ Duration::minutes(
|
||||
(record.bucket_start - start)
|
||||
.num_minutes()
|
||||
.div_euclid(HEALTH_TIMELINE_SLOT_MINUTES)
|
||||
* HEALTH_TIMELINE_SLOT_MINUTES,
|
||||
);
|
||||
if let Some((_, bucket)) = buckets
|
||||
.iter_mut()
|
||||
.find(|(bucket_start, _)| *bucket_start == record_slot)
|
||||
@@ -888,7 +925,7 @@ pub(super) fn health_timeline_at(
|
||||
totals.add_window(*bucket);
|
||||
totals
|
||||
});
|
||||
HealthTimeline {
|
||||
Ok(HealthTimeline {
|
||||
reliability_percent: health_reliability(totals),
|
||||
status: health_status(totals, false),
|
||||
success_requests: totals.success_requests,
|
||||
@@ -909,7 +946,7 @@ pub(super) fn health_timeline_at(
|
||||
caller_error_requests: bucket.caller_error_requests,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
|
||||
@@ -1173,10 +1210,3 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
.failure_count
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
@@ -38,15 +38,15 @@ fn record(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_cron_and_timezone() {
|
||||
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Not/AZone").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").is_ok());
|
||||
fn parse_rejects_invalid_cron() {
|
||||
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai".parse().unwrap()).is_err());
|
||||
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_after_is_strictly_later() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 2, 18, 0, 0).unwrap());
|
||||
@@ -54,7 +54,7 @@ fn next_after_is_strictly_later() {
|
||||
|
||||
#[test]
|
||||
fn last_firing_is_at_or_before() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai".parse().unwrap()).unwrap();
|
||||
// 北京时间 02:00 = 前一日 18:00 UTC。
|
||||
let before = Utc.with_ymd_and_hms(2026, 8, 1, 17, 59, 0).unwrap();
|
||||
assert_eq!(
|
||||
@@ -69,7 +69,8 @@ fn last_firing_is_at_or_before() {
|
||||
|
||||
#[test]
|
||||
fn dst_spring_forward_skips_missing_hour() {
|
||||
let schedule = BackupSchedule::parse("30 2 * * *", "America/New_York").unwrap();
|
||||
let schedule =
|
||||
BackupSchedule::parse("30 2 * * *", "America/New_York".parse().unwrap()).unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 3, 8, 6, 29, 0).unwrap(); // 01:29 EST
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap());
|
||||
@@ -169,3 +170,27 @@ fn disabled_thresholds_delete_nothing() {
|
||||
];
|
||||
assert!(decide_retention(0, 0, now, &records).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_skips_dst_gap_and_runs_only_first_repeated_wall_time() {
|
||||
let zone = "America/New_York".parse().unwrap();
|
||||
let gap = BackupSchedule::parse("30 2 * * *", zone).unwrap();
|
||||
assert_eq!(
|
||||
gap.next_after("2026-03-08T05:00:00Z".parse().unwrap()),
|
||||
Some("2026-03-09T06:30:00Z".parse().unwrap())
|
||||
);
|
||||
let fold = BackupSchedule::parse("30 1 * * *", zone).unwrap();
|
||||
let first = "2026-11-01T05:30:00Z".parse().unwrap();
|
||||
assert_eq!(
|
||||
fold.next_after("2026-11-01T04:00:00Z".parse().unwrap()),
|
||||
Some(first)
|
||||
);
|
||||
assert_eq!(
|
||||
fold.next_after(first),
|
||||
Some("2026-11-02T06:30:00Z".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
fold.last_firing_at_or_before("2026-11-01T06:45:00Z".parse().unwrap()),
|
||||
Some(first)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,11 +166,12 @@ impl BackupRepository for FakeBackupRepository {
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.schedule_enabled = command.schedule_enabled;
|
||||
settings.cron_expression = Some(command.cron_expression);
|
||||
settings.schedule_timezone = Some(command.schedule_timezone);
|
||||
settings.schedule_timezone = Some(timezone.name().to_owned());
|
||||
settings.retention_days = command.retention_days;
|
||||
settings.retention_count = command.retention_count;
|
||||
settings.next_run_at = next_run_at;
|
||||
@@ -205,7 +206,23 @@ impl BackupRepository for FakeBackupRepository {
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
async fn insert_scheduled_record(
|
||||
&self,
|
||||
seed: BackupRecordSeed,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
if !settings.schedule_enabled
|
||||
|| settings.cron_expression.as_deref() != Some(expected_cron)
|
||||
|| settings.schedule_timezone.as_deref() != Some(expected_timezone)
|
||||
|| settings.next_run_at != expected_next_run_at
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
settings.next_run_at = next_run_at;
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active())
|
||||
|| records.iter().any(|record| {
|
||||
@@ -391,11 +408,21 @@ impl BackupRepository for FakeBackupRepository {
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
expected_cron: &str,
|
||||
expected_timezone: Option<&str>,
|
||||
expected_next_run_at: Option<DateTime<Utc>>,
|
||||
timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
if !settings.schedule_enabled
|
||||
|| settings.cron_expression.as_deref() != Some(expected_cron)
|
||||
|| settings.schedule_timezone.as_deref() != expected_timezone
|
||||
|| settings.next_run_at != expected_next_run_at
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
settings.next_run_at = Some(next_run_at);
|
||||
settings.schedule_timezone = Some(timezone.to_owned());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -183,14 +183,14 @@ async fn retention_cleans_expired_scheduled_backups() {
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_scheduled_record(old_seed.clone())
|
||||
.insert_backup_record(old_seed.clone())
|
||||
.await
|
||||
.expect("insert old");
|
||||
// 旧备份 40 天前完成(释放活跃名额),再插入新备份。
|
||||
let now = Utc::now();
|
||||
repository.set_completed(&backup_id("old"), now - Duration::days(40));
|
||||
repository
|
||||
.insert_scheduled_record(new_seed.clone())
|
||||
.insert_backup_record(new_seed.clone())
|
||||
.await
|
||||
.expect("insert new");
|
||||
// 新备份 1 天前完成。
|
||||
@@ -213,12 +213,13 @@ async fn retention_cleans_expired_scheduled_backups() {
|
||||
UpdateBackupScheduleCommand {
|
||||
schedule_enabled: false,
|
||||
cron_expression: "0 2 * * *".to_owned(),
|
||||
schedule_timezone: "Asia/Shanghai".to_owned(),
|
||||
|
||||
retention_days: 30,
|
||||
retention_count: 0,
|
||||
},
|
||||
None,
|
||||
&system_context(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.expect("set retention");
|
||||
@@ -235,3 +236,26 @@ async fn retention_cleans_expired_scheduled_backups() {
|
||||
assert!(object_store.object(&old_seed.object_key).is_none());
|
||||
assert!(object_store.object(&new_seed.object_key).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timezone_change_rebases_future_cursor_without_running_old_due_backup() {
|
||||
let mut settings = configured_settings();
|
||||
settings.schedule_enabled = true;
|
||||
settings.cron_expression = Some("0 2 * * *".to_owned());
|
||||
settings.schedule_timezone = Some("Asia/Shanghai".to_owned());
|
||||
settings.next_run_at = Some(Utc::now() - Duration::days(1));
|
||||
let repository = Arc::new(FakeBackupRepository::new(settings));
|
||||
let task = BackupTask::new(
|
||||
repository.clone(),
|
||||
Arc::new(FakeDumpPort::new()),
|
||||
Arc::new(FakeObjectStore::new()),
|
||||
)
|
||||
.with_timezone("UTC".parse().unwrap());
|
||||
task.run_cycle(&CancellationToken::new()).await.unwrap();
|
||||
let settings = repository.load_settings().await.unwrap();
|
||||
assert_eq!(settings.schedule_timezone.as_deref(), Some("UTC"));
|
||||
assert!(settings.next_run_at.unwrap() > Utc::now());
|
||||
assert!(repository.all_records().is_empty());
|
||||
task.run_cycle(&CancellationToken::new()).await.unwrap();
|
||||
assert!(repository.all_records().is_empty());
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -150,6 +151,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -187,6 +189,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
|
||||
models: Vec::new(),
|
||||
};
|
||||
let quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -215,6 +218,7 @@ fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -243,6 +247,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -84,6 +84,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
|
||||
|
||||
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -133,6 +134,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -56,6 +56,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
|
||||
|
||||
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(now()),
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -1114,6 +1114,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
@@ -1655,6 +1656,7 @@ async fn accounts_recover_disabled_should_only_enable_scheduling_and_preserve_qu
|
||||
);
|
||||
}
|
||||
let quota = ProviderQuota {
|
||||
credits: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "primary".to_owned(),
|
||||
@@ -1993,6 +1995,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
let events = events();
|
||||
let openai = FakeProviderAdmin::new("openai", events.clone());
|
||||
openai.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
@@ -2125,6 +2128,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
|
||||
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
@@ -2173,6 +2177,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
|
||||
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
@@ -2224,6 +2229,7 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time(
|
||||
let observed = now - TimeDelta::hours(1);
|
||||
let reset = now + TimeDelta::days(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
observed_at: Some(observed),
|
||||
limit_reached: true,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -2378,6 +2384,7 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
|
||||
let added = now - TimeDelta::hours(5);
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: Some("pro".to_owned()),
|
||||
observed_at: Some(observed),
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
@@ -2524,6 +2531,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::hours(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
@@ -2681,6 +2689,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::days(7);
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: Some(Utc::now()),
|
||||
refresh_token_expires_at: None,
|
||||
@@ -2982,6 +2991,7 @@ pub(super) fn document() -> ProviderDocument {
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -37,6 +37,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
|
||||
|
||||
@@ -268,6 +268,7 @@ impl AdminHarness {
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone: Default::default(),
|
||||
service_middleware: self.service_middleware,
|
||||
plugin_preparation: Arc::new(plugins::TestPluginPorts),
|
||||
plugin_management: Arc::new(plugins::TestPluginPorts),
|
||||
|
||||
@@ -20,7 +20,7 @@ use gateway_admin::{
|
||||
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
|
||||
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
|
||||
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
|
||||
UsageListRecord, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
@@ -49,7 +49,9 @@ fn external_observability_range_rejects_over_366_days_and_reversed_range() {
|
||||
#[tokio::test]
|
||||
async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() {
|
||||
let now = Utc::now();
|
||||
let day_start = china_day_start(now);
|
||||
let day_start = gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(now)
|
||||
.unwrap();
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
store.replace_trend(vec![
|
||||
@@ -300,9 +302,20 @@ async fn dashboard_summary_should_coalesce_concurrent_requests_in_one_short_wind
|
||||
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
|
||||
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
|
||||
let second_end = DateTime::from_timestamp(end_bucket, 900_000_000).expect("second end");
|
||||
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
|
||||
let second_range =
|
||||
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
|
||||
let first_range = TimeRange::new(
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(first_end)
|
||||
.unwrap(),
|
||||
first_end,
|
||||
)
|
||||
.expect("first range");
|
||||
let second_range = TimeRange::new(
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(second_end)
|
||||
.unwrap(),
|
||||
second_end,
|
||||
)
|
||||
.expect("second range");
|
||||
let store = Arc::new(FixtureObservabilityStore::new(first_range));
|
||||
store.set_dashboard_delay(StdDuration::from_millis(20));
|
||||
let services = observability_services(store.clone()).await;
|
||||
@@ -323,9 +336,20 @@ async fn dashboard_summary_should_reload_after_the_short_window_changes() {
|
||||
let end_bucket = Utc::now().timestamp().div_euclid(2) * 2;
|
||||
let first_end = DateTime::from_timestamp(end_bucket, 100_000_000).expect("first end");
|
||||
let second_end = first_end + Duration::seconds(2);
|
||||
let first_range = TimeRange::new(china_day_start(first_end), first_end).expect("first range");
|
||||
let second_range =
|
||||
TimeRange::new(china_day_start(second_end), second_end).expect("second range");
|
||||
let first_range = TimeRange::new(
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(first_end)
|
||||
.unwrap(),
|
||||
first_end,
|
||||
)
|
||||
.expect("first range");
|
||||
let second_range = TimeRange::new(
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(second_end)
|
||||
.unwrap(),
|
||||
second_end,
|
||||
)
|
||||
.expect("second range");
|
||||
let store = Arc::new(FixtureObservabilityStore::new(first_range));
|
||||
let services = observability_services(store.clone()).await;
|
||||
let observability = services.observability();
|
||||
@@ -912,6 +936,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
|
||||
@@ -190,6 +190,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
credits: None,
|
||||
plan_type: cached.map(str::to_owned),
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
|
||||
@@ -285,6 +285,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
|
||||
@@ -111,7 +111,9 @@ struct AccountGroupView {
|
||||
capacity: AccountGroupCapacityView,
|
||||
usage: AccountGroupUsageView,
|
||||
created_at: DateTime<Utc>,
|
||||
created_at_display: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
updated_at_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -136,8 +138,8 @@ struct AccountGroupUsageView {
|
||||
retained_total_usd: String,
|
||||
}
|
||||
|
||||
impl From<AccountGroupRecord> for AccountGroupView {
|
||||
fn from(record: AccountGroupRecord) -> Self {
|
||||
impl From<(AccountGroupRecord, crate::time::TimePresenter)> for AccountGroupView {
|
||||
fn from((record, time): (AccountGroupRecord, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
id: record.id.to_string(),
|
||||
name: record.name,
|
||||
@@ -151,7 +153,9 @@ impl From<AccountGroupRecord> for AccountGroupView {
|
||||
account_summary: account_summary_view(record.account_summary),
|
||||
capacity: capacity_view(record.capacity),
|
||||
usage: usage_view(record.usage),
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at,
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -187,15 +191,19 @@ struct AccountGroupPageData {
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountGroupPage> for AccountGroupPageData {
|
||||
fn from(page: AccountGroupPage) -> Self {
|
||||
impl From<(AccountGroupPage, crate::time::TimePresenter)> for AccountGroupPageData {
|
||||
fn from((page, time): (AccountGroupPage, crate::time::TimePresenter)) -> Self {
|
||||
let total_pages = if page.total == 0 {
|
||||
0
|
||||
} else {
|
||||
page.total.div_ceil(u64::from(page.page_size))
|
||||
};
|
||||
Self {
|
||||
items: page.items.into_iter().map(Into::into).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| AccountGroupView::from((value, time)))
|
||||
.collect(),
|
||||
page: PageMeta::new(
|
||||
page.page,
|
||||
u32::from(page.page_size),
|
||||
@@ -215,11 +223,13 @@ struct AccountGroupMutationData {
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountGroupMutation> for AccountGroupMutationData {
|
||||
fn from(mutation: AccountGroupMutation) -> Self {
|
||||
impl From<(AccountGroupMutation, crate::time::TimePresenter)> for AccountGroupMutationData {
|
||||
fn from((mutation, time): (AccountGroupMutation, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
id: mutation.id.to_string(),
|
||||
record: mutation.record.map(Into::into),
|
||||
record: mutation
|
||||
.record
|
||||
.map(|value| AccountGroupView::from((value, time))),
|
||||
config_revision: mutation.config_revision.get(),
|
||||
}
|
||||
}
|
||||
@@ -247,6 +257,7 @@ async fn list<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let result = state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
@@ -255,7 +266,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountGroupPageData::from(result)),
|
||||
AdminEnvelope::ok(AccountGroupPageData::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -283,6 +294,7 @@ where
|
||||
},
|
||||
)
|
||||
.await,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -311,6 +323,7 @@ where
|
||||
},
|
||||
)
|
||||
.await,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +371,7 @@ where
|
||||
},
|
||||
)
|
||||
.await,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -381,17 +395,19 @@ where
|
||||
},
|
||||
)
|
||||
.await,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn mutation_response(
|
||||
status: StatusCode,
|
||||
result: Result<AccountGroupMutation, gateway_admin::model::AdminError>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError> {
|
||||
let result = result.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
status,
|
||||
AdminEnvelope::ok(AccountGroupMutationData::from(result)),
|
||||
AdminEnvelope::ok(AccountGroupMutationData::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ async fn list_accounts<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = query.validate().map_err(map_wire_error)?;
|
||||
let page = command.page;
|
||||
let page_size = command.page_size.get();
|
||||
@@ -105,7 +106,7 @@ where
|
||||
.list(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_page_data(result, page, page_size, Utc::now());
|
||||
let data = account_page_data(result, page, page_size, Utc::now(), time);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -117,6 +118,7 @@ async fn account_detail<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -131,7 +133,7 @@ where
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountDetailData {
|
||||
account: account_view(result, Utc::now()),
|
||||
account: account_view(result, Utc::now(), time),
|
||||
credential_configuration: configuration.map(provider_document_value),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
@@ -145,6 +147,7 @@ async fn export_accounts<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let ids = query.into_ids().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -152,7 +155,7 @@ where
|
||||
.export(&auth.context().mutation_context(), ids)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountExportData::from_result(result);
|
||||
let data = AccountExportData::from_result(result, time);
|
||||
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
@@ -313,6 +316,7 @@ async fn refresh_account<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -320,7 +324,7 @@ where
|
||||
.refresh(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
let data = account_refresh_data(result, Utc::now(), time);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -332,6 +336,7 @@ async fn recover_account<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -339,7 +344,7 @@ where
|
||||
.recover(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
let data = account_refresh_data(result, Utc::now(), time);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -351,6 +356,7 @@ async fn account_quota<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -359,7 +365,7 @@ where
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
account: account_view(result, Utc::now()),
|
||||
account: account_view(result, Utc::now(), time),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
@@ -381,7 +387,10 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountQuotaForecastData::from(result)),
|
||||
AdminEnvelope::ok(AccountQuotaForecastData::from((
|
||||
result,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -393,6 +402,7 @@ async fn account_personal_info<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -402,7 +412,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -477,6 +487,7 @@ async fn refresh_account_quota<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -485,7 +496,7 @@ where
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
account: account_view(result, Utc::now()),
|
||||
account: account_view(result, Utc::now(), time),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
@@ -498,6 +509,7 @@ async fn account_reset_credits<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -507,7 +519,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
|
||||
AdminEnvelope::ok(AccountResetCreditsData::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -519,6 +531,7 @@ async fn consume_account_reset_credit<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -528,7 +541,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
|
||||
AdminEnvelope::ok(AccountResetCreditResultData::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -597,6 +610,7 @@ async fn test_account_connection<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
|
||||
let stream = state
|
||||
.admin_services()
|
||||
@@ -604,8 +618,8 @@ where
|
||||
.test_connection(account_id, upstream_model)
|
||||
.await
|
||||
.map_err(map_service_error)?
|
||||
.map(|event| {
|
||||
let event = AccountConnectionTestEvent::from(event);
|
||||
.map(move |event| {
|
||||
let event = AccountConnectionTestEvent::from((event, time));
|
||||
let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned());
|
||||
Ok(Event::default().data(data))
|
||||
});
|
||||
|
||||
@@ -41,7 +41,9 @@ struct TaskId {
|
||||
struct SummaryView {
|
||||
task_id: String,
|
||||
created_at: String,
|
||||
created_at_display: String,
|
||||
finished_at: Option<String>,
|
||||
finished_at_display: Option<String>,
|
||||
stop_requested: bool,
|
||||
total: usize,
|
||||
counts: CountsView,
|
||||
@@ -59,12 +61,16 @@ struct CountsView {
|
||||
imported_accounts: usize,
|
||||
}
|
||||
|
||||
impl From<ImportTaskSummary> for SummaryView {
|
||||
fn from(task: ImportTaskSummary) -> Self {
|
||||
impl From<(ImportTaskSummary, crate::time::TimePresenter)> for SummaryView {
|
||||
fn from((task, time): (ImportTaskSummary, crate::time::TimePresenter)) -> Self {
|
||||
let counts = task.counts;
|
||||
Self {
|
||||
task_id: task.task_id.to_string(),
|
||||
created_at_display: time.label(task.created_at, "%m-%d %H:%M:%S"),
|
||||
created_at: task.created_at.to_rfc3339(),
|
||||
finished_at_display: task
|
||||
.finished_at
|
||||
.map(|value| time.label(value, "%m-%d %H:%M:%S")),
|
||||
finished_at: task.finished_at.map(|at| at.to_rfc3339()),
|
||||
stop_requested: task.stop_requested,
|
||||
total: task.total,
|
||||
@@ -98,10 +104,10 @@ struct ItemView {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ImportTaskDetail> for DetailView {
|
||||
fn from(task: ImportTaskDetail) -> Self {
|
||||
impl From<(ImportTaskDetail, crate::time::TimePresenter)> for DetailView {
|
||||
fn from((task, time): (ImportTaskDetail, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
summary: task.summary.into(),
|
||||
summary: SummaryView::from((task.summary, time)),
|
||||
items: task
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -134,6 +140,7 @@ async fn submit<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let submission_id = Uuid::parse_str(&request.submission_id)
|
||||
.map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?;
|
||||
if submission_id.is_nil() {
|
||||
@@ -168,7 +175,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::ACCEPTED,
|
||||
AdminEnvelope::ok(SummaryView::from(task)),
|
||||
AdminEnvelope::ok(SummaryView::from((task, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -176,12 +183,13 @@ async fn list<S>(auth: AdminAuth, State(state): State<S>) -> impl IntoResponse
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let items = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
.list(&auth.context().mutation_context())
|
||||
.into_iter()
|
||||
.map(SummaryView::from)
|
||||
.map(|value| SummaryView::from((value, time)))
|
||||
.collect();
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items }))
|
||||
}
|
||||
@@ -194,6 +202,7 @@ async fn detail<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let task = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
@@ -204,7 +213,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DetailView::from(task)),
|
||||
AdminEnvelope::ok(DetailView::from((task, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -216,6 +225,7 @@ async fn stop<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let task = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
@@ -226,7 +236,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DetailView::from(task)),
|
||||
AdminEnvelope::ok(DetailView::from((task, time))),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::{Stream, StreamExt as _};
|
||||
use gateway_admin::model::{
|
||||
AdminError as AdminServiceError, PageSize,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(super) fn account_page_data(
|
||||
page: u32,
|
||||
page_size: u16,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountPageData {
|
||||
let total_pages = if result.total == 0 {
|
||||
0
|
||||
@@ -18,7 +19,7 @@ pub(super) fn account_page_data(
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| account_view(item, now))
|
||||
.map(|item| account_view(item, now, time))
|
||||
.collect(),
|
||||
page: PageMeta::new(page, u32::from(page_size), result.total, total_pages),
|
||||
summary: AccountSummaryView {
|
||||
@@ -35,9 +36,10 @@ pub(super) fn account_page_data(
|
||||
pub(super) fn account_refresh_data(
|
||||
result: AccountRefreshResult,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountRefreshData {
|
||||
AccountRefreshData {
|
||||
account: account_view(result.account, now),
|
||||
account: account_view(result.account, now, time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +59,11 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
pub(super) fn account_view(
|
||||
item: AccountDirectoryItem,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountView {
|
||||
let AccountDirectoryItem {
|
||||
account,
|
||||
capacity,
|
||||
@@ -69,15 +75,18 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
} = item;
|
||||
let status = projection.status.as_str().to_owned();
|
||||
let cooldown = projection.cooldown;
|
||||
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
|
||||
let added_at = china_rfc3339(&account.created_at);
|
||||
let updated_at = china_rfc3339(&account.updated_at);
|
||||
let expires_at = account
|
||||
.access_token_expires_at
|
||||
.as_ref()
|
||||
.map(|value| time.rfc3339(value));
|
||||
let added_at = time.rfc3339(&account.created_at);
|
||||
let updated_at = time.rfc3339(&account.updated_at);
|
||||
let usage_period = quota.usage_window().map(|(_, period)| period);
|
||||
let mut usage = account_usage_view(usage, usage_period, now);
|
||||
let mut usage = account_usage_view(usage, usage_period, now, time);
|
||||
if account.authentication_kind == "api_key" {
|
||||
usage.window_label_display = "通用额度".to_owned();
|
||||
}
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
|
||||
AccountView {
|
||||
capabilities: capabilities.into(),
|
||||
id: account.id.clone(),
|
||||
@@ -124,33 +133,37 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
access_token_expires_at_display: account
|
||||
.access_token_expires_at
|
||||
.as_ref()
|
||||
.map(china_datetime),
|
||||
.map(|value| time.datetime(value)),
|
||||
refresh_token_expires_at,
|
||||
next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)),
|
||||
next_refresh_at: account.next_refresh_at.map(|value| time.rfc3339(&value)),
|
||||
next_refresh_at_display: account.next_refresh_at.map(|value| time.datetime(&value)),
|
||||
added_at,
|
||||
added_at_display: china_datetime(&account.created_at),
|
||||
added_at_display: time.datetime(&account.created_at),
|
||||
updated_at,
|
||||
updated_at_display: china_datetime(&account.updated_at),
|
||||
updated_at_display: time.datetime(&account.updated_at),
|
||||
quota,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
|
||||
fn from(report: AccountQuotaForecastReport) -> Self {
|
||||
impl From<(AccountQuotaForecastReport, crate::time::TimePresenter)> for AccountQuotaForecastData {
|
||||
fn from((report, time): (AccountQuotaForecastReport, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
account_id: report.account_id,
|
||||
generated_at: china_rfc3339(&report.generated_at),
|
||||
generated_at: time.rfc3339(&report.generated_at),
|
||||
forecasts: report
|
||||
.forecasts
|
||||
.into_iter()
|
||||
.map(quota_forecast_view)
|
||||
.map(|value| quota_forecast_view(value, time))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastView {
|
||||
fn quota_forecast_view(
|
||||
forecast: AccountQuotaForecast,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountQuotaForecastView {
|
||||
AccountQuotaForecastView {
|
||||
period: match forecast.period {
|
||||
AccountUsagePeriod::Weekly => "weekly",
|
||||
@@ -164,11 +177,11 @@ fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastVi
|
||||
used_percent_display: source
|
||||
.used_percent
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
observed_at: source.observed_at.map(|value| china_rfc3339(&value)),
|
||||
observed_at: source.observed_at.map(|value| time.rfc3339(&value)),
|
||||
observed_at_display: source
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
reset_at: china_rfc3339(&source.reset_at),
|
||||
.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)),
|
||||
reset_at: time.rfc3339(&source.reset_at),
|
||||
tokens_display: display_optional_tokens(source.tokens),
|
||||
usd_display: forecast_usd_display(source.usd),
|
||||
}),
|
||||
@@ -194,16 +207,34 @@ pub(super) fn account_quota_view(
|
||||
mut quota: ProviderQuota,
|
||||
cooldown: Option<gateway_core::account::AccountCooldown>,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> (AccountQuotaView, Option<String>) {
|
||||
quota.apply_limit_reached_display();
|
||||
let refresh_token_expires_at = quota
|
||||
.refresh_token_expires_at
|
||||
.map(|value| china_rfc3339(&value));
|
||||
.map(|value| time.rfc3339(&value));
|
||||
let refreshed_at_display = quota
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
|
||||
let windows = quota.windows.into_iter().map(quota_window_view).collect();
|
||||
let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into()));
|
||||
.map_or_else(|| "—".to_owned(), |value| time.relative(value, now));
|
||||
let windows = quota
|
||||
.windows
|
||||
.into_iter()
|
||||
.map(|value| quota_window_view(value, time))
|
||||
.collect();
|
||||
let rate_limited_until = cooldown.map(|value| time.rfc3339(&value.until.into()));
|
||||
let rate_limit_recovery_display = cooldown.map(|value| {
|
||||
let until = DateTime::<Utc>::from(value.until);
|
||||
if value.kind.requires_probe() && until <= now {
|
||||
return "等待探测成功".to_owned();
|
||||
}
|
||||
let minutes = (until - now).num_seconds().saturating_add(30) / 60;
|
||||
match minutes {
|
||||
..1 => time.datetime(&until),
|
||||
1..60 => format!("剩余 {minutes} 分钟"),
|
||||
_ if minutes % 60 == 0 => format!("剩余 {} 小时", minutes / 60),
|
||||
_ => format!("剩余 {} 小时 {} 分", minutes / 60, minutes % 60),
|
||||
}
|
||||
});
|
||||
let rate_limit_reason = cooldown.map(|value| {
|
||||
if value.kind.is_capacity_freeze() {
|
||||
"capacity_freeze".to_owned()
|
||||
@@ -217,15 +248,24 @@ pub(super) fn account_quota_view(
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
rate_limit_recovery_display,
|
||||
rate_limit_reason,
|
||||
recovery_probe_required,
|
||||
windows,
|
||||
credits: quota.credits.map(|credits| AccountQuotaCreditsView {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance,
|
||||
}),
|
||||
},
|
||||
refresh_token_expires_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
|
||||
pub(crate) fn quota_window_view(
|
||||
window: ProviderQuotaWindow,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountQuotaWindowView {
|
||||
let ProviderQuotaWindow {
|
||||
key,
|
||||
group,
|
||||
@@ -257,12 +297,14 @@ pub(crate) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWind
|
||||
used_percent_display: used_percent
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
limit_reached,
|
||||
local_usage: local_usage.as_ref().map(quota_local_usage),
|
||||
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
local_usage: local_usage
|
||||
.as_ref()
|
||||
.map(|value| quota_local_usage(value, time)),
|
||||
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| time.datetime(&value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
|
||||
pub(super) fn quota_local_usage(usage: &AccountUsage, time: crate::time::TimePresenter) -> Value {
|
||||
let total_tokens = usage.total_tokens.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"requestCount": usage.request_count,
|
||||
@@ -281,10 +323,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
|
||||
"imageRequestFailedCount": usage.image_request_failed_count,
|
||||
"totalTokens": total_tokens,
|
||||
"totalTokensDisplay": format_compact_number(total_tokens),
|
||||
"requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({
|
||||
"bucketStart": bucket.bucket_start,
|
||||
"requestCount": bucket.request_count,
|
||||
})).collect::<Vec<_>>(),
|
||||
"requestBuckets": time.request_buckets(usage.request_buckets.iter().map(|bucket| (bucket.bucket_start, bucket.request_count))),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,6 +331,7 @@ pub(super) fn account_usage_view(
|
||||
usage: Option<AccountUsage>,
|
||||
period: Option<AccountUsagePeriod>,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> AccountUsageView {
|
||||
let Some(usage) = usage else {
|
||||
return empty_account_usage();
|
||||
@@ -338,10 +378,11 @@ pub(super) fn account_usage_view(
|
||||
created_tokens_display: display_optional_tokens(usage.cache_write_tokens),
|
||||
read_tokens: usage.cached_tokens,
|
||||
read_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
|
||||
last_used_at: usage.last_used_at.map(|value| time.rfc3339(&value)),
|
||||
last_used_at_full_display: usage.last_used_at.map(|value| time.datetime(&value)),
|
||||
last_used_at_display: usage
|
||||
.last_used_at
|
||||
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now)),
|
||||
.map_or_else(|| "—".to_owned(), |value| time.relative(value, now)),
|
||||
cost_estimate_status: cost_estimate_status.to_owned(),
|
||||
known_cost_count: Some(known_count),
|
||||
partial_cost_count: Some(u64::from(cost_estimate_status == "partial")),
|
||||
@@ -350,7 +391,7 @@ pub(super) fn account_usage_view(
|
||||
models: usage
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| account_model_usage_view(model, now))
|
||||
.map(|model| account_model_usage_view(model, now, time))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -358,6 +399,7 @@ pub(super) fn account_usage_view(
|
||||
pub(super) fn account_model_usage_view(
|
||||
usage: AccountModelUsage,
|
||||
now: DateTime<Utc>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> ModelUsageView {
|
||||
let known_count = usage
|
||||
.cost_coverage
|
||||
@@ -414,8 +456,9 @@ pub(super) fn account_model_usage_view(
|
||||
partial_cost_count: u64::from(cost_estimate_status == "partial"),
|
||||
unknown_cost_count: usage.cost_coverage.unavailable_count,
|
||||
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
|
||||
last_used_at: china_rfc3339(&usage.last_used_at),
|
||||
last_used_at_display: relative_time(usage.last_used_at, now),
|
||||
last_used_at: time.rfc3339(&usage.last_used_at),
|
||||
last_used_at_full_display: Some(time.datetime(&usage.last_used_at)),
|
||||
last_used_at_display: time.relative(usage.last_used_at, now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +502,7 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
read_tokens: None,
|
||||
read_tokens_display: "—".to_owned(),
|
||||
last_used_at: None,
|
||||
last_used_at_full_display: None,
|
||||
last_used_at_display: "—".to_owned(),
|
||||
cost_estimate_status: "unavailable".to_owned(),
|
||||
known_cost_count: None,
|
||||
@@ -469,38 +513,6 @@ pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn relative_time(value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return china_datetime(&value);
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(super) fn china_offset() -> FixedOffset {
|
||||
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset")
|
||||
}
|
||||
|
||||
pub(super) fn china_rfc3339(value: &DateTime<Utc>) -> String {
|
||||
value.with_timezone(&china_offset()).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(super) fn china_datetime(value: &DateTime<Utc>) -> String {
|
||||
value
|
||||
.with_timezone(&china_offset())
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
AdminError::bad_request(format!("{} 字段不合法", error.field()))
|
||||
}
|
||||
|
||||
@@ -297,6 +297,7 @@ pub struct AccountView {
|
||||
pub access_token_expires_at_display: Option<String>,
|
||||
pub refresh_token_expires_at: Option<String>,
|
||||
pub next_refresh_at: Option<String>,
|
||||
pub next_refresh_at_display: Option<String>,
|
||||
pub added_at: String,
|
||||
pub added_at_display: String,
|
||||
pub updated_at: String,
|
||||
@@ -394,9 +395,20 @@ pub struct AccountQuotaView {
|
||||
pub limit_reached: bool,
|
||||
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。
|
||||
pub rate_limited_until: Option<String>,
|
||||
pub rate_limit_recovery_display: Option<String>,
|
||||
pub rate_limit_reason: Option<String>,
|
||||
pub recovery_probe_required: bool,
|
||||
pub windows: Vec<AccountQuotaWindowView>,
|
||||
pub credits: Option<AccountQuotaCreditsView>,
|
||||
}
|
||||
|
||||
/// 上游点数安全视图,不透出额度响应中的其他 Provider 字段。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaCreditsView {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
/// 一个 quota 时间窗口。
|
||||
@@ -450,6 +462,7 @@ pub struct AccountUsageView {
|
||||
pub read_tokens_display: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
pub last_used_at_full_display: Option<String>,
|
||||
pub cost_estimate_status: String,
|
||||
pub known_cost_count: Option<u64>,
|
||||
pub partial_cost_count: Option<u64>,
|
||||
@@ -492,6 +505,7 @@ pub struct ModelUsageView {
|
||||
pub costs: Vec<CurrencyCostView>,
|
||||
pub last_used_at: String,
|
||||
pub last_used_at_display: String,
|
||||
pub last_used_at_full_display: Option<String>,
|
||||
}
|
||||
|
||||
/// 单一货币的可查询成本。
|
||||
@@ -731,7 +745,7 @@ impl TryFrom<ProviderModelCatalogDocument> for AccountModelCatalogData {
|
||||
.map_err(|_| UnsupportedModelCatalogDocument)?;
|
||||
Ok(Self {
|
||||
model_count: result.model_count,
|
||||
observed_at: china_rfc3339(&result.observed_at),
|
||||
observed_at: result.observed_at.to_rfc3339(),
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
@@ -765,16 +779,21 @@ pub struct AccountPersonalInfoData {
|
||||
pub subscription: Option<AccountSubscriptionData>,
|
||||
}
|
||||
|
||||
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
fn from(info: AccountPersonalInfo) -> Self {
|
||||
impl From<(AccountPersonalInfo, crate::time::TimePresenter)> for AccountPersonalInfoData {
|
||||
fn from((info, time): (AccountPersonalInfo, crate::time::TimePresenter)) -> Self {
|
||||
let (profile, profile_error) = match info.profile {
|
||||
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
|
||||
Ok(profile) => (
|
||||
Some(AccountProfileStatisticsData::from((profile, time))),
|
||||
None,
|
||||
),
|
||||
Err(error) => (None, Some(error.message().to_owned())),
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
profile_error,
|
||||
subscription: info.subscription.map(AccountSubscriptionData::from),
|
||||
subscription: info
|
||||
.subscription
|
||||
.map(|value| AccountSubscriptionData::from((value, time))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,21 +803,30 @@ impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSubscriptionData {
|
||||
pub starts_at: Option<String>,
|
||||
pub starts_at_display: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub expires_at_display: String,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: String,
|
||||
pub observed_at_display: String,
|
||||
}
|
||||
|
||||
impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
fn from(subscription: ProviderSubscription) -> Self {
|
||||
impl From<(ProviderSubscription, crate::time::TimePresenter)> for AccountSubscriptionData {
|
||||
fn from((subscription, time): (ProviderSubscription, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
starts_at_display: subscription
|
||||
.starts_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
|
||||
expires_at_display: time.datetime(&subscription.expires_at),
|
||||
expires_at: subscription.expires_at.to_rfc3339(),
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at_display: time.datetime(&subscription.observed_at),
|
||||
observed_at: subscription.observed_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -808,6 +836,7 @@ impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileStatisticsData {
|
||||
pub activity_calendar: Option<ProfileActivityCalendar>,
|
||||
pub display_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
@@ -858,9 +887,15 @@ pub struct AccountProfileInvocationView {
|
||||
pub usage_count: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
|
||||
fn from(statistics: ProviderProfileStatistics) -> Self {
|
||||
impl From<(ProviderProfileStatistics, crate::time::TimePresenter)>
|
||||
for AccountProfileStatisticsData
|
||||
{
|
||||
fn from((statistics, time): (ProviderProfileStatistics, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
activity_calendar: statistics
|
||||
.daily_usage
|
||||
.as_deref()
|
||||
.and_then(|daily| profile_activity_calendar(daily, time.today())),
|
||||
display_name: statistics.display_name,
|
||||
username: statistics.username,
|
||||
image_url: statistics.image_url,
|
||||
@@ -939,6 +974,7 @@ pub struct AccountResetCreditView {
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub expires_at_display: Option<String>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
@@ -950,36 +986,41 @@ pub struct AccountResetCreditResultData {
|
||||
pub credit: Option<AccountResetCreditView>,
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredit> for AccountResetCreditView {
|
||||
fn from(credit: ProviderResetCredit) -> Self {
|
||||
impl From<(ProviderResetCredit, crate::time::TimePresenter)> for AccountResetCreditView {
|
||||
fn from((credit, time): (ProviderResetCredit, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
id: credit.id,
|
||||
status: credit.status,
|
||||
title: credit.title,
|
||||
expires_at_display: credit.expires_at.as_ref().map(|value| time.datetime(value)),
|
||||
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
|
||||
reset_type: credit.reset_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredits> for AccountResetCreditsData {
|
||||
fn from(credits: ProviderResetCredits) -> Self {
|
||||
impl From<(ProviderResetCredits, crate::time::TimePresenter)> for AccountResetCreditsData {
|
||||
fn from((credits, time): (ProviderResetCredits, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
available_count: credits.available_count,
|
||||
credits: credits
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(AccountResetCreditView::from)
|
||||
.map(|value| AccountResetCreditView::from((value, time)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
|
||||
fn from(result: ProviderResetCreditResult) -> Self {
|
||||
impl From<(ProviderResetCreditResult, crate::time::TimePresenter)>
|
||||
for AccountResetCreditResultData
|
||||
{
|
||||
fn from((result, time): (ProviderResetCreditResult, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
code: result.code,
|
||||
credit: result.credit.map(AccountResetCreditView::from),
|
||||
credit: result
|
||||
.credit
|
||||
.map(|value| AccountResetCreditView::from((value, time))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -995,7 +1036,15 @@ impl AccountExportData {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub(super) fn from_result(bundle: AccountExportBundle) -> Self {
|
||||
pub(super) fn from_result(
|
||||
bundle: AccountExportBundle,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> Self {
|
||||
let filename = format!(
|
||||
"cpr-accounts-selected-{}-{}.json",
|
||||
bundle.documents.len(),
|
||||
time.label(bundle.exported_at, "%Y-%m-%d")
|
||||
);
|
||||
let documents = bundle
|
||||
.documents
|
||||
.into_iter()
|
||||
@@ -1008,6 +1057,7 @@ impl AccountExportData {
|
||||
.collect::<Vec<_>>();
|
||||
Self::new(serde_json::json!({
|
||||
"exportedAt": bundle.exported_at.to_rfc3339(),
|
||||
"fileName": filename,
|
||||
"documents": documents,
|
||||
}))
|
||||
}
|
||||
@@ -1023,9 +1073,9 @@ pub struct AccountConnectionTestEvent {
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
|
||||
fn from(event: DomainConnectionTestEvent) -> Self {
|
||||
let data = match event {
|
||||
impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountConnectionTestEvent {
|
||||
fn from((event, time): (DomainConnectionTestEvent, crate::time::TimePresenter)) -> Self {
|
||||
let mut data = match event {
|
||||
DomainConnectionTestEvent::Started { model } => serde_json::json!({
|
||||
"type": "test_start",
|
||||
"model": model,
|
||||
@@ -1078,6 +1128,75 @@ impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
|
||||
"upstreamBody": upstream_body
|
||||
}),
|
||||
};
|
||||
let now = Utc::now();
|
||||
data["occurredAt"] = now.to_rfc3339().into();
|
||||
data["occurredAtDisplay"] = time.datetime(&now).into();
|
||||
data["timeDisplay"] = time.time(&now).into();
|
||||
Self { data }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileActivityCalendar {
|
||||
pub range_label: String,
|
||||
pub weeks: Vec<ProfileActivityWeek>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileActivityWeek {
|
||||
pub key: String,
|
||||
pub month_label: Option<String>,
|
||||
pub cells: Vec<ProfileActivityCell>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileActivityCell {
|
||||
pub date: String,
|
||||
pub date_display: String,
|
||||
pub tokens: u64,
|
||||
pub is_future: bool,
|
||||
}
|
||||
fn profile_activity_calendar(
|
||||
daily: &[ProviderProfileDailyUsage],
|
||||
today: chrono::NaiveDate,
|
||||
) -> Option<ProfileActivityCalendar> {
|
||||
use chrono::{Datelike as _, Days};
|
||||
let week_start =
|
||||
today.checked_sub_days(Days::new(u64::from(today.weekday().num_days_from_sunday())))?;
|
||||
let start = week_start.checked_sub_days(Days::new(51 * 7))?;
|
||||
let mut counts = std::collections::BTreeMap::<_, u64>::new();
|
||||
for entry in daily {
|
||||
if entry.date >= start && entry.date <= today {
|
||||
let value = counts.entry(entry.date).or_default();
|
||||
*value = value.saturating_add(entry.tokens);
|
||||
}
|
||||
}
|
||||
let mut weeks = Vec::with_capacity(52);
|
||||
for index in 0..52 {
|
||||
let mut cells = Vec::with_capacity(7);
|
||||
let mut month_label = None;
|
||||
let week = start.checked_add_days(Days::new(index * 7))?;
|
||||
for day in 0..7 {
|
||||
let date = week.checked_add_days(Days::new(day))?;
|
||||
if date.day() == 1 {
|
||||
month_label = Some(format!("{}月", date.month()));
|
||||
}
|
||||
cells.push(ProfileActivityCell {
|
||||
date: date.to_string(),
|
||||
date_display: format!("{}年{}月{}日", date.year(), date.month(), date.day()),
|
||||
tokens: counts.get(&date).copied().unwrap_or_default(),
|
||||
is_future: date > today,
|
||||
});
|
||||
}
|
||||
weeks.push(ProfileActivityWeek {
|
||||
key: week.to_string(),
|
||||
month_label,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
Some(ProfileActivityCalendar {
|
||||
range_label: format!("{start} 至 {today}"),
|
||||
weeks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,16 +40,18 @@ pub struct BackupSettingsView {
|
||||
pub verified: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
pub next_run_at_display: Option<String>,
|
||||
pub last_verified_at: Option<DateTime<Utc>>,
|
||||
pub last_verified_at_display: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub updated_at_display: String,
|
||||
}
|
||||
|
||||
impl From<BackupSettings> for BackupSettingsView {
|
||||
fn from(settings: BackupSettings) -> Self {
|
||||
impl From<(BackupSettings, crate::time::TimePresenter)> for BackupSettingsView {
|
||||
fn from((settings, time): (BackupSettings, crate::time::TimePresenter)) -> Self {
|
||||
let verified = settings.storage_verified();
|
||||
Self {
|
||||
storage_revision: settings.storage_revision,
|
||||
@@ -65,11 +67,19 @@ impl From<BackupSettings> for BackupSettingsView {
|
||||
verified,
|
||||
schedule_enabled: settings.schedule_enabled,
|
||||
cron_expression: settings.cron_expression,
|
||||
schedule_timezone: settings.schedule_timezone,
|
||||
retention_days: settings.retention_days,
|
||||
retention_count: settings.retention_count,
|
||||
next_run_at_display: settings
|
||||
.next_run_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
next_run_at: settings.next_run_at,
|
||||
last_verified_at_display: settings
|
||||
.last_verified_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
last_verified_at: settings.last_verified_at,
|
||||
updated_at_display: time.datetime(&settings.updated_at),
|
||||
updated_at: settings.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -95,7 +105,6 @@ pub struct UpdateBackupStorageRequest {
|
||||
pub struct UpdateBackupScheduleRequest {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
@@ -108,6 +117,7 @@ pub struct BackupRecordView {
|
||||
pub trigger_kind: String,
|
||||
pub status: String,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub scheduled_at_display: Option<String>,
|
||||
pub object_key: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
@@ -115,18 +125,27 @@ pub struct BackupRecordView {
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub started_at_display: Option<String>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub completed_at_display: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub expires_at_display: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_at_display: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub updated_at_display: String,
|
||||
}
|
||||
|
||||
impl From<BackupRecord> for BackupRecordView {
|
||||
fn from(record: BackupRecord) -> Self {
|
||||
impl From<(BackupRecord, crate::time::TimePresenter)> for BackupRecordView {
|
||||
fn from((record, time): (BackupRecord, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
id: record.id,
|
||||
trigger_kind: record.trigger_kind.to_string(),
|
||||
status: record.status.to_string(),
|
||||
scheduled_at_display: record
|
||||
.scheduled_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
scheduled_at: record.scheduled_at,
|
||||
object_key: record.object_key,
|
||||
size_bytes: record.size_bytes,
|
||||
@@ -134,10 +153,18 @@ impl From<BackupRecord> for BackupRecordView {
|
||||
attempt_count: record.attempt_count,
|
||||
error_code: record.error_code,
|
||||
error_message: record.error_message,
|
||||
started_at_display: record.started_at.as_ref().map(|value| time.datetime(value)),
|
||||
started_at: record.started_at,
|
||||
completed_at_display: record
|
||||
.completed_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
completed_at: record.completed_at,
|
||||
expires_at_display: record.expires_at.as_ref().map(|value| time.datetime(value)),
|
||||
expires_at: record.expires_at,
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at,
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -244,6 +271,7 @@ async fn backup_settings<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let settings = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
@@ -252,7 +280,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -264,6 +292,7 @@ async fn update_backup_storage<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = UpdateBackupStorageCommand {
|
||||
endpoint: request.endpoint,
|
||||
region: request.region,
|
||||
@@ -281,7 +310,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -312,10 +341,10 @@ async fn update_backup_schedule<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = UpdateBackupScheduleCommand {
|
||||
schedule_enabled: request.schedule_enabled,
|
||||
cron_expression: request.cron_expression,
|
||||
schedule_timezone: request.schedule_timezone,
|
||||
retention_days: request.retention_days,
|
||||
retention_count: request.retention_count,
|
||||
};
|
||||
@@ -327,7 +356,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
AdminEnvelope::ok(BackupSettingsView::from((settings, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -339,6 +368,7 @@ async fn backup_records<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
|
||||
let page = query.page.unwrap_or(1);
|
||||
@@ -378,7 +408,7 @@ where
|
||||
let items: Vec<BackupRecordView> = page_result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(BackupRecordView::from)
|
||||
.map(|value| BackupRecordView::from((value, time)))
|
||||
.collect();
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
@@ -394,6 +424,7 @@ async fn create_backup<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let expires_at = body
|
||||
.and_then(|body| body.0.expires_in_days)
|
||||
.filter(|&days| days > 0)
|
||||
@@ -406,7 +437,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::ACCEPTED,
|
||||
AdminEnvelope::ok(BackupRecordView::from(record)),
|
||||
AdminEnvelope::ok(BackupRecordView::from((record, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -445,6 +476,7 @@ async fn delete_backup<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let record = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
@@ -453,7 +485,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupRecordView::from(record)),
|
||||
AdminEnvelope::ok(BackupRecordView::from((record, time))),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -353,10 +353,16 @@ pub struct ClientKeyView {
|
||||
daily_used_usd: String,
|
||||
weekly_used_usd: String,
|
||||
daily_resets_at: Option<DateTime<Utc>>,
|
||||
daily_resets_at_display: Option<String>,
|
||||
weekly_resets_at: Option<DateTime<Utc>>,
|
||||
weekly_resets_at_display: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
created_at_display: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
updated_at_display: String,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
last_used_at_display: String,
|
||||
last_used_at_full_display: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -368,8 +374,8 @@ pub struct ClientKeyGroupView {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl From<ClientKeyRecord> for ClientKeyView {
|
||||
fn from(record: ClientKeyRecord) -> Self {
|
||||
impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
|
||||
fn from((record, time): (ClientKeyRecord, crate::time::TimePresenter)) -> Self {
|
||||
let routing_scope = if record.groups.is_empty() {
|
||||
"all"
|
||||
} else {
|
||||
@@ -413,10 +419,25 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
weekly_limit_usd: record.budget.limits.weekly_usd.canonical(),
|
||||
daily_used_usd: record.budget.daily_used_usd.canonical(),
|
||||
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
|
||||
daily_resets_at_display: record
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
|
||||
weekly_resets_at_display: record
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at,
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
updated_at: record.updated_at,
|
||||
last_used_at_display: time.relative_optional(record.last_used_at, time.now()),
|
||||
last_used_at_full_display: record
|
||||
.last_used_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
last_used_at: record.last_used_at,
|
||||
}
|
||||
}
|
||||
@@ -443,10 +464,12 @@ impl ClientKeyListData {
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
impl TryFrom<(ClientKeyPage, crate::time::TimePresenter)> for ClientKeyListData {
|
||||
type Error = WireValidationError;
|
||||
|
||||
fn try_from(page: ClientKeyPage) -> Result<Self, Self::Error> {
|
||||
fn try_from(
|
||||
(page, time): (ClientKeyPage, crate::time::TimePresenter),
|
||||
) -> Result<Self, Self::Error> {
|
||||
let next_cursor = page
|
||||
.next_cursor
|
||||
.map(wire_cursor)
|
||||
@@ -455,7 +478,10 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
.map(encode_client_key_cursor)
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
page.items.into_iter().map(Into::into).collect(),
|
||||
page.items
|
||||
.into_iter()
|
||||
.map(|value| ClientKeyView::from((value, time)))
|
||||
.collect(),
|
||||
next_cursor,
|
||||
page.total,
|
||||
))
|
||||
@@ -888,13 +914,14 @@ async fn list_client_keys<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let result = state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.list(query.into_command().map_err(map_wire_error)?)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ClientKeyListData::try_from(result).map_err(|_| AdminError::internal())?;
|
||||
let data = ClientKeyListData::try_from((result, time)).map_err(|_| AdminError::internal())?;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 过渡期展示格式化;目标迁移到 Vue presenter。
|
||||
//! 观测领域事实到管理页面的展示投影。
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -44,16 +44,6 @@ fn basis_points_rate(value: u64) -> f64 {
|
||||
value as f64 / 10_000.0
|
||||
}
|
||||
|
||||
pub(crate) fn china_datetime(value: &DateTime<Utc>) -> String {
|
||||
(*value + Duration::hours(8))
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn china_label(value: DateTime<Utc>, format: &str) -> String {
|
||||
(value + Duration::hours(8)).format(format).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
|
||||
outcome.as_str()
|
||||
}
|
||||
@@ -270,10 +260,13 @@ pub(crate) fn billing_view(billing: Option<&domain::UsageBilling>) -> Option<Bil
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageListRecordView {
|
||||
pub(crate) fn usage_list_record_view(
|
||||
record: domain::UsageListRecord,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageListRecordView {
|
||||
let token_details = usage_list_token_details(&record);
|
||||
let billing = billing_view(record.billing.as_ref());
|
||||
let created_at_display = china_datetime(&record.started_at);
|
||||
let created_at_display = time.datetime(&record.started_at);
|
||||
let model = record
|
||||
.upstream_model_id
|
||||
.clone()
|
||||
@@ -326,7 +319,10 @@ pub(crate) fn usage_list_record_view(record: domain::UsageListRecord) -> UsageLi
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView {
|
||||
pub(crate) fn usage_record_view(
|
||||
record: domain::UsageRecord,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageRecordView {
|
||||
let tokens = token_details(&record);
|
||||
let billing = billing_view(record.billing.as_ref());
|
||||
let costs = record
|
||||
@@ -432,7 +428,7 @@ pub(crate) fn usage_record_view(record: domain::UsageRecord) -> UsageRecordView
|
||||
message,
|
||||
metadata,
|
||||
created_at: record.started_at,
|
||||
created_at_display: china_datetime(&record.started_at),
|
||||
created_at_display: time.datetime(&record.started_at),
|
||||
client_ip: record.client_ip,
|
||||
user_agent: record.user_agent,
|
||||
reasoning_effort: record.reasoning_effort,
|
||||
@@ -535,11 +531,14 @@ pub(crate) fn usage_attempt_view(attempt: domain::UsageAttempt) -> UsageAttemptV
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetailView {
|
||||
pub(crate) fn usage_detail_view(
|
||||
detail: domain::UsageDetail,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageRecordDetailView {
|
||||
UsageRecordDetailView {
|
||||
trace: detail.trace,
|
||||
related_requests: detail.related_requests,
|
||||
request: usage_record_view(detail.request),
|
||||
request: usage_record_view(detail.request, time),
|
||||
attempts: detail
|
||||
.attempts
|
||||
.into_iter()
|
||||
@@ -549,16 +548,26 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
|
||||
pub(crate) fn usage_page_view(
|
||||
page: domain::UsagePage,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> PageData<UsageListRecordView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(usage_list_record_view).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| usage_list_record_view(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
pub(crate) fn ops_error_view(
|
||||
error: domain::OpsError,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> OpsErrorView {
|
||||
let account_label = error
|
||||
.provider_account_email
|
||||
.as_ref()
|
||||
@@ -583,6 +592,8 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
account_id: error.provider_account_ref,
|
||||
account_name: error.provider_account_name,
|
||||
account_email: error.provider_account_email,
|
||||
account_plan_type: error.provider_account_plan_type,
|
||||
account_plan_type_display: error.provider_account_plan_type_display,
|
||||
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
|
||||
model,
|
||||
requested_model: error.requested_model_id,
|
||||
@@ -626,22 +637,32 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
recovery_total_latency_ms: error.recovery_total_latency_ms,
|
||||
},
|
||||
created_at: error.occurred_at,
|
||||
created_at_display: china_datetime(&error.occurred_at),
|
||||
created_at_display: time.datetime(&error.occurred_at),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
|
||||
pub(crate) fn ops_page_view(
|
||||
page: domain::OpsErrorPage,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> PageData<OpsErrorView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(ops_error_view).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| ops_error_view(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
|
||||
let local_time = china_label(point.bucket_start, "%H:%M");
|
||||
let label = china_label(point.bucket_start, "%m-%d %H:%M");
|
||||
pub(crate) fn trend_point_view(
|
||||
point: domain::TrendPoint,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> TrendPointView {
|
||||
let local_time = time.label(point.bucket_start, "%H:%M");
|
||||
let label = time.label(point.bucket_start, "%m-%d %H:%M %:z");
|
||||
let success_rate_value = point.success_rate.map(|value| value * 100.0);
|
||||
TrendPointView {
|
||||
time: local_time,
|
||||
@@ -764,11 +785,19 @@ pub(crate) fn trend_summary_view(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trend_view(trend: domain::Trend, kind: TrendKind) -> TrendData {
|
||||
pub(crate) fn trend_view(
|
||||
trend: domain::Trend,
|
||||
kind: TrendKind,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> TrendData {
|
||||
TrendData {
|
||||
kind,
|
||||
summary: trend_summary_view(kind, &trend.summary),
|
||||
points: trend.points.into_iter().map(trend_point_view).collect(),
|
||||
points: trend
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|value| trend_point_view(value, time))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,7 +816,10 @@ pub(crate) fn reliability_display(value: Option<f64>) -> String {
|
||||
value.map_or_else(|| "-".to_owned(), |value| format!("{value:.1}%"))
|
||||
}
|
||||
|
||||
pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTimelineView {
|
||||
pub(crate) fn health_timeline_view(
|
||||
timeline: domain::HealthTimeline,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> HealthTimelineView {
|
||||
HealthTimelineView {
|
||||
title: "请求健康时间线".to_owned(),
|
||||
description: "有效请求可用性".to_owned(),
|
||||
@@ -801,25 +833,25 @@ pub(crate) fn health_timeline_view(timeline: domain::HealthTimeline) -> HealthTi
|
||||
points: timeline
|
||||
.points
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, point)| {
|
||||
let elapsed_minutes = i64::try_from(index).unwrap_or(i64::MAX).saturating_mul(15);
|
||||
HealthTimelinePointView {
|
||||
time: format!("{:02}:{:02}", elapsed_minutes / 60, elapsed_minutes % 60),
|
||||
status: health_status_name(point.status).to_owned(),
|
||||
reliability_display: reliability_display(point.reliability_percent),
|
||||
success_requests: point.success_requests,
|
||||
failed_requests: point.failed_requests,
|
||||
cancelled_requests: point.cancelled_requests,
|
||||
incomplete_requests: point.incomplete_requests,
|
||||
caller_error_requests: point.caller_error_requests,
|
||||
}
|
||||
.map(|point| HealthTimelinePointView {
|
||||
bucket_start: point.bucket_start,
|
||||
time: time.label(point.bucket_start, "%H:%M %:z"),
|
||||
status: health_status_name(point.status).to_owned(),
|
||||
reliability_display: reliability_display(point.reliability_percent),
|
||||
success_requests: point.success_requests,
|
||||
failed_requests: point.failed_requests,
|
||||
cancelled_requests: point.cancelled_requests,
|
||||
incomplete_requests: point.incomplete_requests,
|
||||
caller_error_requests: point.caller_error_requests,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> DashboardWireProfileView {
|
||||
pub(crate) fn wire_profile_view(
|
||||
profile: domain::DashboardWireProfile,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> DashboardWireProfileView {
|
||||
DashboardWireProfileView {
|
||||
provider: profile.provider,
|
||||
product: profile.product,
|
||||
@@ -840,9 +872,11 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo
|
||||
value: attribute.value,
|
||||
})
|
||||
.collect(),
|
||||
verified_at_display: profile.verified_at.map(|value| time.datetime(&value)),
|
||||
verified_at: profile.verified_at,
|
||||
release: profile.release.map(|release| DashboardDesktopReleaseView {
|
||||
status: release.status.into(),
|
||||
checked_at_display: release.checked_at.map(|value| time.datetime(&value)),
|
||||
checked_at: release.checked_at,
|
||||
latest_version: release.latest_version,
|
||||
latest_build: release.latest_build,
|
||||
@@ -851,29 +885,10 @@ pub(crate) fn wire_profile_view(profile: domain::DashboardWireProfile) -> Dashbo
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn relative_time(value: Option<DateTime<Utc>>, now: DateTime<Utc>) -> String {
|
||||
let Some(value) = value else {
|
||||
return "从未使用".to_owned();
|
||||
};
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return china_datetime(&value);
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(crate) fn dashboard_view(
|
||||
result: domain::DashboardResult,
|
||||
kind: TrendKind,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> DashboardDataView {
|
||||
let domain::DashboardResult {
|
||||
observation,
|
||||
@@ -937,21 +952,19 @@ pub(crate) fn dashboard_view(
|
||||
.total_tokens
|
||||
.map_or_else(|| "—".to_owned(), format_compact_number),
|
||||
request_count: credential.request_count,
|
||||
request_buckets: credential
|
||||
.request_buckets
|
||||
.iter()
|
||||
.map(|bucket| DashboardAccountRequestBucketView {
|
||||
bucket_start: bucket.bucket_start,
|
||||
request_count: bucket.request_count,
|
||||
})
|
||||
.collect(),
|
||||
request_buckets: time.request_buckets(
|
||||
credential
|
||||
.request_buckets
|
||||
.iter()
|
||||
.map(|bucket| (bucket.bucket_start, bucket.request_count)),
|
||||
),
|
||||
quota_used_percent: credential.quota_used_percent,
|
||||
usage_window: credential
|
||||
.quota_window
|
||||
.map(crate::admin::accounts::quota_window_view),
|
||||
.map(|window| crate::admin::accounts::quota_window_view(window, time)),
|
||||
metric_label,
|
||||
metric_value,
|
||||
last_used: relative_time(credential.last_used_at, range.end),
|
||||
last_used: time.relative_optional(credential.last_used_at, range.end),
|
||||
});
|
||||
}
|
||||
let unavailable_accounts = provider_accounts
|
||||
@@ -959,6 +972,8 @@ pub(crate) fn dashboard_view(
|
||||
.saturating_sub(provider_accounts.normal);
|
||||
|
||||
DashboardDataView {
|
||||
as_of: range.end,
|
||||
as_of_display: time.datetime(&range.end),
|
||||
cards: DashboardCardsView {
|
||||
credentials: DashboardCredentialsCardView {
|
||||
total: provider_accounts.total.to_string(),
|
||||
@@ -992,13 +1007,16 @@ pub(crate) fn dashboard_view(
|
||||
average_first_token_latency_ms: display_duration(average_first_token_latency_ms),
|
||||
},
|
||||
},
|
||||
trend: trend_view(trend, kind),
|
||||
health_timeline: health_timeline_view(health_timeline),
|
||||
wire_profiles: wire_profiles.into_iter().map(wire_profile_view).collect(),
|
||||
trend: trend_view(trend, kind, time),
|
||||
health_timeline: health_timeline_view(health_timeline, time),
|
||||
wire_profiles: wire_profiles
|
||||
.into_iter()
|
||||
.map(|value| wire_profile_view(value, time))
|
||||
.collect(),
|
||||
account_usage: account_usage_views,
|
||||
usage_records: recent_requests
|
||||
.into_iter()
|
||||
.map(usage_list_record_view)
|
||||
.map(|value| usage_list_record_view(value, time))
|
||||
.collect(),
|
||||
pool_summary: DashboardPoolSummaryView {
|
||||
total: provider_accounts.total,
|
||||
@@ -1033,14 +1051,17 @@ pub(crate) fn usage_summary_view(summary: domain::UsageSummary) -> UsageSummaryV
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsightsOverviewView {
|
||||
pub(crate) fn usage_insights_view(
|
||||
insights: domain::UsageInsights,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageInsightsOverviewView {
|
||||
let health_points = insights
|
||||
.health
|
||||
.points
|
||||
.iter()
|
||||
.map(|point| OverviewHealthPointView {
|
||||
bucket: point.bucket_start,
|
||||
label: china_label(point.bucket_start, "%m-%d %H:%M"),
|
||||
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
|
||||
total_requests: point.total_requests,
|
||||
success_requests: point.success_requests,
|
||||
failed_requests: point.failed_requests,
|
||||
@@ -1056,7 +1077,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
|
||||
.iter()
|
||||
.map(|point| OverviewPerformancePointView {
|
||||
bucket: point.bucket_start,
|
||||
label: china_label(point.bucket_start, "%m-%d %H:%M"),
|
||||
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
|
||||
latency_p50_ms: point.latency_percentiles.p50_ms.map(|value| value.as_f64()),
|
||||
latency_p95_ms: point.latency_percentiles.p95_ms.map(|value| value.as_f64()),
|
||||
latency_p99_ms: point.latency_percentiles.p99_ms.map(|value| value.as_f64()),
|
||||
@@ -1105,7 +1126,7 @@ pub(crate) fn usage_insights_view(insights: domain::UsageInsights) -> UsageInsig
|
||||
.iter()
|
||||
.map(|point| OverviewCostPointView {
|
||||
bucket: point.bucket_start,
|
||||
label: china_label(point.bucket_start, "%m-%d %H:%M"),
|
||||
label: time.label(point.bucket_start, "%m-%d %H:%M %:z"),
|
||||
input_tokens: point.input_tokens,
|
||||
output_tokens: point.output_tokens,
|
||||
cached_tokens: point.cached_tokens,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub const MAX_PAGE_SIZE: u16 = 100;
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DashboardQuery {
|
||||
pub kind: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
@@ -43,6 +45,8 @@ pub struct UsageQuery {
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
@@ -81,6 +85,8 @@ impl DetailQuery {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DiagnosticsQuery {
|
||||
pub dimension: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
@@ -116,6 +122,8 @@ pub struct OpsQuery {
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub period: Option<String>,
|
||||
pub as_of: Option<i64>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
@@ -279,19 +287,21 @@ pub(crate) fn request_outcome(
|
||||
pub(crate) fn usage_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
period: Option<&str>,
|
||||
as_of: Option<i64>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<domain::TimeRange, WireValidationError> {
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start = parse_datetime(start)?.unwrap_or(end - Duration::days(7));
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
crate::time::query_range(start, end, period, as_of, timezone, "7d")
|
||||
}
|
||||
|
||||
pub(crate) fn dashboard_today_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
period: Option<&str>,
|
||||
as_of: Option<i64>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<domain::TimeRange, WireValidationError> {
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end));
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
crate::time::query_range(start, end, period, as_of, timezone, "today")
|
||||
}
|
||||
|
||||
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
|
||||
@@ -320,19 +330,31 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
|
||||
pub(crate) fn usage_command(
|
||||
query: &UsageQuery,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<domain::UsageQuery, WireValidationError> {
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
Ok(domain::UsageQuery {
|
||||
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
|
||||
range: usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
timezone,
|
||||
)?,
|
||||
filter: usage_filter(query)?,
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
|
||||
pub(crate) fn ops_command(
|
||||
query: &OpsQuery,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<domain::OpsErrorQuery, WireValidationError> {
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
@@ -343,7 +365,13 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
|
||||
.or(query.status_code),
|
||||
)?;
|
||||
Ok(domain::OpsErrorQuery {
|
||||
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
|
||||
range: usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
timezone,
|
||||
)?,
|
||||
filter: domain::OpsErrorFilter {
|
||||
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
|
||||
request_id: non_empty(query.request_id.clone()),
|
||||
|
||||
@@ -39,10 +39,17 @@ pub(crate) async fn dashboard_summary<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
// 概览与独立趋势使用同一部署日界。
|
||||
let range = dashboard_today_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
@@ -51,7 +58,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(dashboard_view(result, kind)),
|
||||
AdminEnvelope::ok(dashboard_view(result, kind, time)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -63,9 +70,16 @@ pub(crate) async fn dashboard_trend<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let range = dashboard_today_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
@@ -74,7 +88,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(trend_view(result, kind)),
|
||||
AdminEnvelope::ok(trend_view(result, kind, time)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -86,14 +100,16 @@ pub(crate) async fn usage_records<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = usage_command(&query).map_err(map_wire_error)?;
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command =
|
||||
usage_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.usage_records(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = usage_page_view(result);
|
||||
let data = usage_page_view(result, time);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -105,6 +121,7 @@ pub(crate) async fn usage_record_detail<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
query.validate().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -114,7 +131,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(usage_detail_view(result)),
|
||||
AdminEnvelope::ok(usage_detail_view(result, time)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -126,8 +143,14 @@ pub(crate) async fn usage_records_summary<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let range = usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = usage_filter(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -149,8 +172,15 @@ pub(crate) async fn usage_insights_overview<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let range = usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = usage_filter(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -160,7 +190,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(usage_insights_view(result)),
|
||||
AdminEnvelope::ok(usage_insights_view(result, time)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -173,8 +203,14 @@ where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let dimension = query.dimension().map_err(map_wire_error)?;
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let range = usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
query.period.as_deref(),
|
||||
query.as_of,
|
||||
state.admin_services().timezone(),
|
||||
)
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = domain::UsageFilter {
|
||||
provider_kind: non_empty(query.provider),
|
||||
model: non_empty(query.model),
|
||||
@@ -202,13 +238,14 @@ pub(crate) async fn ops_errors<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = ops_command(&query).map_err(map_wire_error)?;
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = ops_command(&query, state.admin_services().timezone()).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.ops_errors(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ops_page_view(result);
|
||||
let data = ops_page_view(result, time);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ pub struct DashboardAccountUsageView {
|
||||
pub plan_type_display: String,
|
||||
pub tokens: String,
|
||||
pub request_count: u64,
|
||||
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
|
||||
pub request_buckets: Vec<crate::time::RequestBucketView>,
|
||||
pub quota_used_percent: Option<f64>,
|
||||
pub usage_window: Option<crate::admin::accounts::AccountQuotaWindowView>,
|
||||
pub metric_label: String,
|
||||
@@ -402,14 +402,6 @@ pub struct DashboardAccountUsageView {
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
/// Dashboard 账号单小时请求数。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardAccountRequestBucketView {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// Provider 账号池的持久事实汇总。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -470,6 +462,7 @@ pub struct AttemptMetricsView {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthTimelinePointView {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub time: String,
|
||||
pub status: String,
|
||||
pub reliability_display: String,
|
||||
@@ -510,6 +503,7 @@ pub struct DashboardWireProfileView {
|
||||
pub attributes: Vec<DashboardWireAttributeView>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verified_at: Option<DateTime<Utc>>,
|
||||
pub verified_at_display: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release: Option<DashboardDesktopReleaseView>,
|
||||
}
|
||||
@@ -537,6 +531,7 @@ pub struct DashboardDesktopReleaseView {
|
||||
pub status: DashboardDesktopReleaseStatusView,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
pub checked_at_display: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -570,6 +565,8 @@ impl From<domain::DesktopReleaseStatus> for DashboardDesktopReleaseStatusView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardDataView {
|
||||
pub as_of: DateTime<Utc>,
|
||||
pub as_of_display: String,
|
||||
pub cards: DashboardCardsView,
|
||||
pub trend: TrendData,
|
||||
pub health_timeline: HealthTimelineView,
|
||||
@@ -792,6 +789,8 @@ pub struct OpsErrorView {
|
||||
pub account_id: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
|
||||
@@ -153,13 +153,16 @@ struct ProxyView {
|
||||
revision: u64,
|
||||
account_count: u64,
|
||||
last_test_at: Option<String>,
|
||||
last_test_at_display: Option<String>,
|
||||
last_test: Option<ProxyTestView>,
|
||||
created_at: String,
|
||||
created_at_display: String,
|
||||
updated_at: String,
|
||||
updated_at_display: String,
|
||||
}
|
||||
|
||||
impl From<ProxyRecord> for ProxyView {
|
||||
fn from(record: ProxyRecord) -> Self {
|
||||
impl From<(ProxyRecord, crate::time::TimePresenter)> for ProxyView {
|
||||
fn from((record, time): (ProxyRecord, crate::time::TimePresenter)) -> Self {
|
||||
let endpoint = record.proxy.endpoint();
|
||||
Self {
|
||||
auto_location: record.auto_location,
|
||||
@@ -171,9 +174,15 @@ impl From<ProxyRecord> for ProxyView {
|
||||
endpoint,
|
||||
revision: record.revision.get(),
|
||||
account_count: record.account_count,
|
||||
last_test_at_display: record
|
||||
.last_test_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
|
||||
last_test: record.last_test.map(Into::into),
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at.to_rfc3339(),
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
updated_at: record.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -198,10 +207,10 @@ struct MutationView {
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<ProxyMutation> for MutationView {
|
||||
fn from(value: ProxyMutation) -> Self {
|
||||
impl From<(ProxyMutation, crate::time::TimePresenter)> for MutationView {
|
||||
fn from((value, time): (ProxyMutation, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
record: value.record.into(),
|
||||
record: ProxyView::from((value.record, time)),
|
||||
config_revision: value.config_revision.get(),
|
||||
}
|
||||
}
|
||||
@@ -244,6 +253,7 @@ async fn list<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
@@ -259,7 +269,11 @@ where
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyPageView {
|
||||
items: result.items.into_iter().map(Into::into).collect(),
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| ProxyView::from((value, time)))
|
||||
.collect(),
|
||||
page: PageMeta::new(
|
||||
result.page,
|
||||
u32::from(result.page_size),
|
||||
@@ -336,6 +350,7 @@ async fn create<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.0
|
||||
@@ -357,7 +372,7 @@ where
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(MutationView::from(result)),
|
||||
AdminEnvelope::ok(MutationView::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -393,6 +408,7 @@ async fn update<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.map(|value| {
|
||||
@@ -420,7 +436,7 @@ where
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(MutationView::from(result)),
|
||||
AdminEnvelope::ok(MutationView::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -480,6 +496,7 @@ async fn test<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
@@ -493,6 +510,6 @@ where
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyView::from(result)),
|
||||
AdminEnvelope::ok(ProxyView::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsView {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: String,
|
||||
@@ -73,6 +74,7 @@ pub struct RuntimeSettingsView {
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub updated_at_display: String,
|
||||
}
|
||||
|
||||
/// 原子替换全局运行参数的请求。
|
||||
@@ -98,6 +100,7 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: String,
|
||||
@@ -233,6 +236,7 @@ impl UpdateRuntimeSettingsRequest {
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
|
||||
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: self.smart_scheduling,
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
@@ -260,8 +264,8 @@ impl UpdateRuntimeSettingsRequest {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView {
|
||||
fn from((settings, time): (RuntimeSettings, crate::time::TimePresenter)) -> Self {
|
||||
let provider_request_profiles = settings
|
||||
.request_profiles
|
||||
.into_iter()
|
||||
@@ -282,6 +286,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
@@ -302,6 +307,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
updated_at_display: time.datetime(&settings.updated_at),
|
||||
updated_at: settings.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -351,10 +357,11 @@ struct ClientDownloadPackageView {
|
||||
size_bytes: Option<u64>,
|
||||
download_url: String,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
expires_at_display: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
|
||||
fn from(package: ClientDownloadPackage) -> Self {
|
||||
impl From<(ClientDownloadPackage, crate::time::TimePresenter)> for ClientDownloadPackageView {
|
||||
fn from((package, time): (ClientDownloadPackage, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
architecture: package.architecture.as_str().to_owned(),
|
||||
source: package.source.as_str().to_owned(),
|
||||
@@ -362,6 +369,10 @@ impl From<ClientDownloadPackage> for ClientDownloadPackageView {
|
||||
file_name: package.file_name,
|
||||
size_bytes: package.size_bytes,
|
||||
download_url: package.download_url,
|
||||
expires_at_display: package
|
||||
.expires_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
expires_at: package.expires_at,
|
||||
}
|
||||
}
|
||||
@@ -371,18 +382,26 @@ impl From<ClientDownloadPackage> for ClientDownloadPackageView {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexDesktopWindowsDownloadsView {
|
||||
resolved_at: DateTime<Utc>,
|
||||
resolved_at_display: String,
|
||||
cached: bool,
|
||||
warning: Option<String>,
|
||||
packages: Vec<ClientDownloadPackageView>,
|
||||
}
|
||||
|
||||
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
|
||||
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
|
||||
impl From<(CodexDesktopWindowsDownloads, crate::time::TimePresenter)>
|
||||
for CodexDesktopWindowsDownloadsView
|
||||
{
|
||||
fn from((downloads, time): (CodexDesktopWindowsDownloads, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
resolved_at_display: time.datetime(&downloads.resolved_at),
|
||||
resolved_at: downloads.resolved_at,
|
||||
cached: downloads.cached,
|
||||
warning: downloads.warning,
|
||||
packages: downloads.packages.into_iter().map(Into::into).collect(),
|
||||
packages: downloads
|
||||
.packages
|
||||
.into_iter()
|
||||
.map(|value| ClientDownloadPackageView::from((value, time)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,6 +466,7 @@ async fn codex_desktop_windows_downloads<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let downloads = state
|
||||
.admin_services()
|
||||
.client_distribution()
|
||||
@@ -454,7 +474,7 @@ where
|
||||
.await;
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
|
||||
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from((downloads, time))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -498,6 +518,7 @@ where
|
||||
AdminEnvelope::ok(serde_json::json!({
|
||||
"defaults": pricing.defaults, "overrides": pricing.overrides,
|
||||
"synced": pricing.synced, "syncedAt": pricing.synced_at,
|
||||
"syncedAtDisplay": pricing.synced_at.as_ref().map(|value| crate::time::TimePresenter::new(state.admin_services().timezone()).datetime(value)),
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -584,6 +605,7 @@ async fn settings<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
@@ -592,7 +614,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -604,6 +626,7 @@ async fn update_settings<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -613,7 +636,7 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from((result, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -835,7 +858,10 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(result.into_inner()),
|
||||
AdminEnvelope::ok(client_profile_preview_view(
|
||||
result.into_inner(),
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -865,6 +891,23 @@ where
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(result.into_inner()),
|
||||
AdminEnvelope::ok(client_profile_preview_view(
|
||||
result.into_inner(),
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
fn client_profile_preview_view(
|
||||
mut profile: serde_json::Map<String, serde_json::Value>,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
for (raw, display) in [
|
||||
("verifiedAt", "verifiedAtDisplay"),
|
||||
("checkedAt", "checkedAtDisplay"),
|
||||
] {
|
||||
let value = time.rfc_display(profile.get(raw).and_then(serde_json::Value::as_str));
|
||||
profile.insert(display.to_owned(), serde_json::json!(value));
|
||||
}
|
||||
profile
|
||||
}
|
||||
|
||||
@@ -151,11 +151,13 @@ struct SystemOperationStateView {
|
||||
message: Option<String>,
|
||||
error: Option<String>,
|
||||
started_at: Option<String>,
|
||||
started_at_display: Option<String>,
|
||||
finished_at: Option<String>,
|
||||
finished_at_display: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemOperationState> for SystemOperationStateView {
|
||||
fn from(operation: SystemOperationState) -> Self {
|
||||
impl From<(SystemOperationState, crate::time::TimePresenter)> for SystemOperationStateView {
|
||||
fn from((operation, time): (SystemOperationState, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
operation_id: operation.operation_id,
|
||||
kind: operation.kind.map(operation_kind_name),
|
||||
@@ -163,7 +165,15 @@ impl From<SystemOperationState> for SystemOperationStateView {
|
||||
target_version: operation.target_version,
|
||||
message: operation.message,
|
||||
error: operation.error,
|
||||
started_at_display: operation
|
||||
.started_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
started_at: operation.started_at.map(|value| value.to_rfc3339()),
|
||||
finished_at_display: operation
|
||||
.finished_at
|
||||
.as_ref()
|
||||
.map(|value| time.datetime(value)),
|
||||
finished_at: operation.finished_at.map(|value| value.to_rfc3339()),
|
||||
}
|
||||
}
|
||||
@@ -178,13 +188,13 @@ struct SystemUpdateStatusView {
|
||||
operation: SystemOperationStateView,
|
||||
}
|
||||
|
||||
impl From<SystemUpdateStatus> for SystemUpdateStatusView {
|
||||
fn from(status: SystemUpdateStatus) -> Self {
|
||||
impl From<(SystemUpdateStatus, crate::time::TimePresenter)> for SystemUpdateStatusView {
|
||||
fn from((status, time): (SystemUpdateStatus, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
previous_version: status.previous_version,
|
||||
current_version: status.current_version,
|
||||
need_restart: status.need_restart,
|
||||
operation: status.operation.into(),
|
||||
operation: SystemOperationStateView::from((status.operation, time)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +234,7 @@ struct SystemUpdateEventView {
|
||||
terminal: bool,
|
||||
progress_percent: Option<u8>,
|
||||
at: String,
|
||||
at_display: String,
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 系统管理路由。
|
||||
@@ -291,13 +302,16 @@ async fn update_event_stream<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let stream = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_events()
|
||||
.map(|message| {
|
||||
.map(move |message| {
|
||||
let id = message.id.clone();
|
||||
let data = SystemUpdateEventView::from(message).into_json().to_string();
|
||||
let data = SystemUpdateEventView::from((message, time))
|
||||
.into_json()
|
||||
.to_string();
|
||||
Ok(Event::default().event("update").id(id).data(data))
|
||||
});
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
@@ -347,6 +361,7 @@ async fn update_status<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let status = state
|
||||
.admin_services()
|
||||
.system()
|
||||
@@ -355,7 +370,7 @@ where
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemUpdateStatusView::from(status)),
|
||||
AdminEnvelope::ok(SystemUpdateStatusView::from((status, time))),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -445,8 +460,8 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
fn from(event: SystemUpdateEvent) -> Self {
|
||||
impl From<(SystemUpdateEvent, crate::time::TimePresenter)> for SystemUpdateEventView {
|
||||
fn from((event, time): (SystemUpdateEvent, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
id: event.id,
|
||||
operation_id: event.operation_id,
|
||||
@@ -455,6 +470,7 @@ impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
message: event.message,
|
||||
terminal: event.terminal,
|
||||
progress_percent: event.progress_percent,
|
||||
at_display: time.time(&event.occurred_at),
|
||||
at: event.occurred_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
@@ -470,7 +486,7 @@ impl SystemUpdateEventView {
|
||||
"message": self.message,
|
||||
"terminal": self.terminal,
|
||||
"progressPercent": self.progress_percent,
|
||||
"at": self.at,
|
||||
"at": self.at, "atDisplay": self.at_display,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,19 +83,20 @@ async fn overview<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let overview = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.overview(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
query.into_domain(state.admin_services().timezone())?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::overview(overview)),
|
||||
AdminEnvelope::ok(presenter::overview(overview, time)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -107,19 +108,20 @@ async fn records<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
|
||||
let records = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.records(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
query.into_domain(state.admin_services().timezone())?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::records(records)),
|
||||
AdminEnvelope::ok(presenter::records(records, time)),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(super) fn config(secret: ClientKeySecret) -> ConfigView {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct OverviewView {
|
||||
as_of: DateTime<Utc>,
|
||||
as_of_display: String,
|
||||
start_time: DateTime<Utc>,
|
||||
end_time: DateTime<Utc>,
|
||||
key: KeyView,
|
||||
@@ -68,9 +69,11 @@ struct KeyView {
|
||||
daily_limit_usd: String,
|
||||
daily_used_usd: String,
|
||||
daily_resets_at: Option<DateTime<Utc>>,
|
||||
daily_resets_at_display: Option<String>,
|
||||
weekly_limit_usd: String,
|
||||
weekly_used_usd: String,
|
||||
weekly_resets_at: Option<DateTime<Utc>>,
|
||||
weekly_resets_at_display: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -110,14 +113,16 @@ fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCovera
|
||||
struct TrendPointView {
|
||||
time: DateTime<Utc>,
|
||||
bucket_seconds: u32,
|
||||
label: String,
|
||||
#[serde(flatten)]
|
||||
metrics: MetricsView,
|
||||
}
|
||||
|
||||
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter) -> OverviewView {
|
||||
let key = value.key;
|
||||
OverviewView {
|
||||
as_of: Utc::now(),
|
||||
as_of: value.overview.range.end,
|
||||
as_of_display: time.time(&value.overview.range.end),
|
||||
start_time: value.overview.range.start,
|
||||
end_time: value.overview.range.end,
|
||||
key: KeyView {
|
||||
@@ -127,9 +132,17 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
requests_per_minute: key.limits.requests_per_minute,
|
||||
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
|
||||
daily_used_usd: key.budget.daily_used_usd.canonical(),
|
||||
daily_resets_at_display: key
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
|
||||
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
|
||||
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
|
||||
weekly_resets_at_display: key
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
|
||||
},
|
||||
summary: metrics(
|
||||
@@ -142,6 +155,14 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
.into_iter()
|
||||
.map(|point| TrendPointView {
|
||||
time: point.bucket_start,
|
||||
label: time.label(
|
||||
point.bucket_start,
|
||||
if point.granularity == Granularity::Day {
|
||||
"%m-%d"
|
||||
} else {
|
||||
"%m-%d %H:%M %:z"
|
||||
},
|
||||
),
|
||||
bucket_seconds: match point.granularity {
|
||||
Granularity::FifteenMinutes => 900,
|
||||
Granularity::Hour => 3600,
|
||||
@@ -150,7 +171,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
|
||||
})
|
||||
.collect(),
|
||||
health_timeline: health_timeline_view(value.health_timeline),
|
||||
health_timeline: health_timeline_view(value.health_timeline, time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +180,7 @@ pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
pub(super) struct RecordView {
|
||||
id: String,
|
||||
created_at: DateTime<Utc>,
|
||||
created_at_display: String,
|
||||
model: Option<String>,
|
||||
route: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
@@ -187,12 +209,13 @@ struct OutputTimingView {
|
||||
first_text_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn success_record(value: UsageListRecord) -> RecordView {
|
||||
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
|
||||
let token_details = Some(usage_list_token_details(&value));
|
||||
let billing = billing_view(value.billing.as_ref());
|
||||
RecordView {
|
||||
id: value.id,
|
||||
created_at: value.started_at,
|
||||
created_at_display: time.datetime(&value.started_at),
|
||||
model: value.requested_model_id,
|
||||
route: Some(value.endpoint),
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
@@ -215,10 +238,11 @@ fn success_record(value: UsageListRecord) -> RecordView {
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record(value: OpsError) -> RecordView {
|
||||
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
|
||||
RecordView {
|
||||
id: value.event_id,
|
||||
created_at: value.occurred_at,
|
||||
created_at_display: time.datetime(&value.occurred_at),
|
||||
model: value.requested_model_id,
|
||||
route: value.endpoint,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
@@ -236,16 +260,27 @@ fn error_record(value: OpsError) -> RecordView {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
|
||||
pub(super) fn records(
|
||||
value: KeyUsageRecords,
|
||||
time: crate::time::TimePresenter,
|
||||
) -> PageData<RecordView> {
|
||||
match value {
|
||||
KeyUsageRecords::Success(page) => PageData {
|
||||
items: page.items.into_iter().map(success_record).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| success_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
KeyUsageRecords::Error(page) => PageData {
|
||||
items: page.items.into_iter().map(error_record).collect(),
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|value| error_record(value, time))
|
||||
.collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, Duration};
|
||||
use chrono::Duration;
|
||||
use gateway_admin::model::{
|
||||
PageSize,
|
||||
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
|
||||
observability::TimeRange,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -18,25 +17,27 @@ pub(super) struct EmptyQuery {}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct OverviewQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
period: Option<String>,
|
||||
as_of: Option<i64>,
|
||||
start_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl OverviewQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
|
||||
let parse = |value: &str| {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.to_utc())
|
||||
.map_err(|_| {
|
||||
AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"时间必须使用 RFC3339 格式",
|
||||
)
|
||||
})
|
||||
};
|
||||
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
|
||||
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
|
||||
pub(super) fn into_domain(
|
||||
self,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<KeyUsageQuery, AdminError> {
|
||||
let range = crate::time::query_range(
|
||||
self.start_time.as_deref(),
|
||||
self.end_time.as_deref(),
|
||||
self.period.as_deref(),
|
||||
self.as_of,
|
||||
timezone,
|
||||
"today",
|
||||
)
|
||||
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
|
||||
if range.end - range.start > Duration::days(31) {
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -71,8 +72,10 @@ enum RecordKind {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct RecordsQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
period: Option<String>,
|
||||
as_of: Option<i64>,
|
||||
start_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
model: Option<String>,
|
||||
current_page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
@@ -81,7 +84,10 @@ pub(super) struct RecordsQuery {
|
||||
}
|
||||
|
||||
impl RecordsQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
|
||||
pub(super) fn into_domain(
|
||||
self,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<KeyUsageRecordsQuery, AdminError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
let page_size = self.page_size.unwrap_or(20);
|
||||
if current_page == 0 || !(1..=100).contains(&page_size) {
|
||||
@@ -92,11 +98,13 @@ impl RecordsQuery {
|
||||
}
|
||||
Ok(KeyUsageRecordsQuery {
|
||||
usage: OverviewQuery {
|
||||
period: self.period,
|
||||
as_of: self.as_of,
|
||||
start_time: self.start_time,
|
||||
end_time: self.end_time,
|
||||
model: self.model,
|
||||
}
|
||||
.into_domain()?,
|
||||
.into_domain(timezone)?,
|
||||
kind: match self.kind {
|
||||
RecordKind::Success => KeyUsageRecordKind::Success,
|
||||
RecordKind::Error => KeyUsageRecordKind::Error,
|
||||
|
||||
@@ -35,6 +35,9 @@ mod middleware;
|
||||
pub mod openai;
|
||||
mod provider;
|
||||
mod session_cookie;
|
||||
mod time;
|
||||
|
||||
pub use time::{RequestBucketView, TimePresenter};
|
||||
|
||||
/// API-owned HTTP 与静态资源配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 管理与自助页面共用的时间展示投影。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
|
||||
pub(crate) fn query_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
period: Option<&str>,
|
||||
as_of: Option<i64>,
|
||||
timezone: DeploymentTimeZone,
|
||||
default_period: &str,
|
||||
) -> Result<gateway_admin::model::observability::TimeRange, crate::admin::WireValidationError> {
|
||||
use crate::admin::{WireValidationError, observability::parse_datetime};
|
||||
use gateway_admin::model::observability::{CalendarPeriod, TimeRange};
|
||||
if start.is_some() || end.is_some() {
|
||||
if period.is_some() || as_of.is_some() {
|
||||
return Err(WireValidationError::new("timeRange"));
|
||||
}
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start = match parse_datetime(start)? {
|
||||
Some(start) => start,
|
||||
None => {
|
||||
let period = CalendarPeriod::parse(default_period)
|
||||
.map_err(|_| WireValidationError::new("period"))?;
|
||||
return TimeRange::calendar_at(period, end, timezone)
|
||||
.map_err(|_| WireValidationError::new("timeRange"));
|
||||
}
|
||||
};
|
||||
return TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"));
|
||||
}
|
||||
let end = match as_of {
|
||||
Some(value) => DateTime::from_timestamp_millis(value)
|
||||
.ok_or_else(|| WireValidationError::new("asOf"))?,
|
||||
None => Utc::now(),
|
||||
};
|
||||
let period = CalendarPeriod::parse(period.unwrap_or(default_period))
|
||||
.map_err(|_| WireValidationError::new("period"))?;
|
||||
TimeRange::calendar_at(period, end, timezone).map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TimePresenter {
|
||||
timezone: DeploymentTimeZone,
|
||||
now: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TimePresenter {
|
||||
pub(crate) fn today(self) -> chrono::NaiveDate {
|
||||
self.timezone.local(self.now).date_naive()
|
||||
}
|
||||
pub(crate) fn now(self) -> DateTime<Utc> {
|
||||
self.now
|
||||
}
|
||||
pub(crate) fn rfc_display(self, value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| self.datetime(&value.to_utc()))
|
||||
}
|
||||
pub(crate) fn request_buckets(
|
||||
self,
|
||||
buckets: impl Iterator<Item = (DateTime<Utc>, u64)>,
|
||||
) -> Vec<RequestBucketView> {
|
||||
buckets
|
||||
.map(|(start, count)| {
|
||||
let end = start + chrono::Duration::hours(1);
|
||||
RequestBucketView {
|
||||
bucket_start: start,
|
||||
request_count: count,
|
||||
label: format!(
|
||||
"{}–{} · {count} 次请求",
|
||||
self.label(start, "%m-%d %H:%M %:z"),
|
||||
self.label(end, "%H:%M %:z")
|
||||
),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(timezone: DeploymentTimeZone) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
now: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn datetime(self, value: &DateTime<Utc>) -> String {
|
||||
self.label(*value, "%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
|
||||
pub(crate) fn time(self, value: &DateTime<Utc>) -> String {
|
||||
self.label(*value, "%H:%M:%S")
|
||||
}
|
||||
|
||||
pub(crate) fn label(self, value: DateTime<Utc>, format: &str) -> String {
|
||||
self.timezone.local(value).format(format).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn rfc3339(self, value: &DateTime<Utc>) -> String {
|
||||
self.timezone.local(*value).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(crate) fn relative(self, value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return self.datetime(&value);
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(crate) fn relative_optional(
|
||||
self,
|
||||
value: Option<DateTime<Utc>>,
|
||||
now: DateTime<Utc>,
|
||||
) -> String {
|
||||
value.map_or_else(|| "—".to_owned(), |value| self.relative(value, now))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestBucketView {
|
||||
pub(crate) bucket_start: DateTime<Utc>,
|
||||
pub(crate) request_count: u64,
|
||||
pub(crate) label: String,
|
||||
}
|
||||
@@ -7,6 +7,135 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
accounts::{AccountCapacity, AccountPageItem, AccountRecord},
|
||||
};
|
||||
use gateway_core::account::{
|
||||
AccountCooldown, AccountCooldownKind, AccountStatusFacts, CredentialState, QuotaState,
|
||||
resolve_account_status,
|
||||
};
|
||||
|
||||
for zone in ["Asia/Shanghai", "UTC", "Asia/Kathmandu", "America/New_York"] {
|
||||
let zone = zone
|
||||
.parse::<gateway_core::time::DeploymentTimeZone>()
|
||||
.unwrap();
|
||||
let fixture = AdminTestFixture::with_timezone(zone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (seconds, kind, expected) in [
|
||||
(
|
||||
Some(1200),
|
||||
AccountCooldownKind::RateLimit,
|
||||
Some("剩余 20 分钟"),
|
||||
),
|
||||
(
|
||||
Some(7200),
|
||||
AccountCooldownKind::CapacityFreeze,
|
||||
Some("剩余 2 小时"),
|
||||
),
|
||||
(
|
||||
Some(7500),
|
||||
AccountCooldownKind::CapacityFreezeProbe,
|
||||
Some("剩余 2 小时 5 分"),
|
||||
),
|
||||
(
|
||||
Some(-60),
|
||||
AccountCooldownKind::CapacityFreezeProbe,
|
||||
Some("等待探测成功"),
|
||||
),
|
||||
(Some(20), AccountCooldownKind::RateLimit, None),
|
||||
(None, AccountCooldownKind::RateLimit, None),
|
||||
] {
|
||||
let now = Utc::now();
|
||||
let until = seconds.map(|seconds| now + Duration::seconds(seconds));
|
||||
let facts = AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: until.map(|until| AccountCooldown {
|
||||
until: until.into(),
|
||||
kind,
|
||||
}),
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
};
|
||||
*fixture.account.lock().unwrap() = Some(AccountPageItem {
|
||||
account: AccountRecord {
|
||||
id: "acct_cooldown".to_owned(),
|
||||
provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
groups: Vec::new(),
|
||||
name: "synthetic cooldown account".to_owned(),
|
||||
notes: None,
|
||||
email: None,
|
||||
upstream_user_id: None,
|
||||
upstream_account_id: None,
|
||||
plan_type: None,
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
credential_revision: Revision::new(1).unwrap(),
|
||||
has_refresh_token: true,
|
||||
access_token_expires_at: None,
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: Default::default(),
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
credential_state: facts.credential_state,
|
||||
credential_observed_at: now,
|
||||
quota: facts.quota,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
capacity: AccountCapacity {
|
||||
used_slots: None,
|
||||
total_slots: None,
|
||||
},
|
||||
projection: resolve_account_status(&facts, now.into()),
|
||||
});
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_cooldown_timezone")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let quota = &value["data"]["items"][0]["quota"];
|
||||
match until {
|
||||
Some(until) => {
|
||||
assert_eq!(
|
||||
DateTime::parse_from_rfc3339(quota["rateLimitedUntil"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.to_utc(),
|
||||
until
|
||||
);
|
||||
let expected = expected.map(str::to_owned).unwrap_or_else(|| {
|
||||
zone.local(until).format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
});
|
||||
assert_eq!(quota["rateLimitRecoveryDisplay"], expected);
|
||||
}
|
||||
None => {
|
||||
assert!(quota["rateLimitedUntil"].is_null());
|
||||
assert!(quota["rateLimitRecoveryDisplay"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_credential_rotation_route_is_not_exposed() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
|
||||
@@ -14,20 +14,24 @@ mod personal_info {
|
||||
#[test]
|
||||
fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountSubscriptionData::from(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: observed,
|
||||
});
|
||||
let response = AccountSubscriptionData::from((
|
||||
ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: observed,
|
||||
},
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
"startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00",
|
||||
"startsAt": null, "startsAtDisplay": null,
|
||||
"expiresAt": "2026-09-14T00:00:00+00:00", "expiresAtDisplay": "2026-09-14 08:00:00",
|
||||
"willRenew": null, "billingPeriod": null, "billingCurrency": "USD",
|
||||
"observedAt": "2026-09-14T00:00:00+00:00"
|
||||
"observedAt": "2026-09-14T00:00:00+00:00", "observedAtDisplay": "2026-09-14 08:00:00"
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -35,17 +39,20 @@ mod personal_info {
|
||||
#[test]
|
||||
fn personal_info_response_preserves_subscription_when_profile_fails() {
|
||||
let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap();
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
|
||||
subscription: Some(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: None,
|
||||
observed_at: observed,
|
||||
}),
|
||||
});
|
||||
let response = AccountPersonalInfoData::from((
|
||||
AccountPersonalInfo {
|
||||
profile: Err(AdminError::bad_gateway("上游服务请求失败")),
|
||||
subscription: Some(ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: observed,
|
||||
will_renew: None,
|
||||
billing_period: None,
|
||||
billing_currency: None,
|
||||
observed_at: observed,
|
||||
}),
|
||||
},
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
let value = serde_json::to_value(response).unwrap();
|
||||
assert!(value["profile"].is_null());
|
||||
assert_eq!(value["profileError"], "上游服务请求失败");
|
||||
@@ -58,10 +65,13 @@ mod personal_info {
|
||||
|
||||
#[test]
|
||||
fn personal_info_response_preserves_unknown_subscription_and_profile_error() {
|
||||
let response = AccountPersonalInfoData::from(AccountPersonalInfo {
|
||||
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
|
||||
subscription: None,
|
||||
});
|
||||
let response = AccountPersonalInfoData::from((
|
||||
AccountPersonalInfo {
|
||||
profile: Err(AdminError::unavailable("Provider 服务暂不可用")),
|
||||
subscription: None,
|
||||
},
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).unwrap(),
|
||||
json!({
|
||||
@@ -184,39 +194,42 @@ mod profile_statistics {
|
||||
|
||||
#[test]
|
||||
fn profile_statistics_response_preserves_nullable_official_fields() {
|
||||
let response = AccountProfileStatisticsData::from(ProviderProfileStatistics {
|
||||
display_name: Some("Ada".to_owned()),
|
||||
username: Some("ada".to_owned()),
|
||||
image_url: Some("https://example.test/avatar.png".to_owned()),
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(409_500_000),
|
||||
peak_tokens: Some(267_000_000),
|
||||
longest_task_duration_ms: Some(51_900_000),
|
||||
current_streak_days: Some(16),
|
||||
longest_streak_days: Some(32),
|
||||
},
|
||||
daily_usage: Some(vec![ProviderProfileDailyUsage {
|
||||
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
|
||||
tokens: 42,
|
||||
}]),
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: Some(0.0),
|
||||
reasoning_effort: Some("high".to_owned()),
|
||||
reasoning_effort_percent: Some(48.0),
|
||||
skills_explored: Some(8),
|
||||
total_skills_used: Some(61),
|
||||
total_threads: Some(2_391),
|
||||
invocations: Some(vec![ProviderProfileInvocation {
|
||||
invocation_type: "plugin".to_owned(),
|
||||
plugin_id: Some("plugin_1".to_owned()),
|
||||
plugin_name: Some("example".to_owned()),
|
||||
skill_id: None,
|
||||
skill_name: None,
|
||||
usage_count: Some(29),
|
||||
let response = AccountProfileStatisticsData::from((
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Ada".to_owned()),
|
||||
username: Some("ada".to_owned()),
|
||||
image_url: Some("https://example.test/avatar.png".to_owned()),
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(409_500_000),
|
||||
peak_tokens: Some(267_000_000),
|
||||
longest_task_duration_ms: Some(51_900_000),
|
||||
current_streak_days: Some(16),
|
||||
longest_streak_days: Some(32),
|
||||
},
|
||||
daily_usage: Some(vec![ProviderProfileDailyUsage {
|
||||
date: NaiveDate::from_ymd_opt(2026, 8, 25).expect("usage date"),
|
||||
tokens: 42,
|
||||
}]),
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: Some(0.0),
|
||||
reasoning_effort: Some("high".to_owned()),
|
||||
reasoning_effort_percent: Some(48.0),
|
||||
skills_explored: Some(8),
|
||||
total_skills_used: Some(61),
|
||||
total_threads: Some(2_391),
|
||||
invocations: Some(vec![ProviderProfileInvocation {
|
||||
invocation_type: "plugin".to_owned(),
|
||||
plugin_id: Some("plugin_1".to_owned()),
|
||||
plugin_name: Some("example".to_owned()),
|
||||
skill_id: None,
|
||||
skill_name: None,
|
||||
usage_count: Some(29),
|
||||
}]),
|
||||
},
|
||||
},
|
||||
});
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
let value = serde_json::to_value(response).expect("serialize profile statistics");
|
||||
|
||||
assert_eq!(value["displayName"], "Ada");
|
||||
@@ -488,6 +501,7 @@ mod response {
|
||||
read_tokens: None,
|
||||
read_tokens_display: "-".to_owned(),
|
||||
last_used_at: None,
|
||||
last_used_at_full_display: None,
|
||||
last_used_at_display: "-".to_owned(),
|
||||
cost_estimate_status: "unknown".to_owned(),
|
||||
known_cost_count: None,
|
||||
@@ -861,7 +875,7 @@ mod actions {
|
||||
|
||||
#[test]
|
||||
fn connection_test_events_should_preserve_the_existing_frontend_contract() {
|
||||
let events = [
|
||||
let mut events = [
|
||||
DomainConnectionTestEvent::Started {
|
||||
model: "grok-4.5".to_owned(),
|
||||
},
|
||||
@@ -887,7 +901,32 @@ mod actions {
|
||||
upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()),
|
||||
},
|
||||
]
|
||||
.map(|event| AccountConnectionTestEvent::from(event).data);
|
||||
.map(|event| {
|
||||
AccountConnectionTestEvent::from((
|
||||
event,
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
))
|
||||
.data
|
||||
});
|
||||
|
||||
let timezone = gateway_core::time::DeploymentTimeZone::default();
|
||||
for event in &mut events {
|
||||
let at = event["occurredAt"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<chrono::DateTime<chrono::Utc>>()
|
||||
.unwrap();
|
||||
let local = timezone.local(at);
|
||||
assert_eq!(
|
||||
event["occurredAtDisplay"],
|
||||
local.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
);
|
||||
assert_eq!(event["timeDisplay"], local.format("%H:%M:%S").to_string());
|
||||
let fields = event.as_object_mut().unwrap();
|
||||
fields.remove("occurredAt");
|
||||
fields.remove("occurredAtDisplay");
|
||||
fields.remove("timeDisplay");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
events,
|
||||
|
||||
@@ -36,11 +36,14 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
monthly.target_seconds = 30 * 86_400;
|
||||
monthly.estimated_tokens = Some(0);
|
||||
monthly.estimated_usd = Some(0.0);
|
||||
let view = AccountQuotaForecastData::from(AccountQuotaForecastReport {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
generated_at: now,
|
||||
forecasts: [forecast, monthly],
|
||||
});
|
||||
let view = AccountQuotaForecastData::from((
|
||||
AccountQuotaForecastReport {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
generated_at: now,
|
||||
forecasts: [forecast, monthly],
|
||||
},
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
let value = serde_json::to_value(view).unwrap();
|
||||
assert_eq!(value["accountId"], "acct_forecast");
|
||||
assert_eq!(value["generatedAt"], "2026-09-12T08:00:00+08:00");
|
||||
@@ -107,7 +110,7 @@ fn model_catalog_projection_keeps_upstream_document_and_rejects_non_codex_wire()
|
||||
.expect("codex catalog is projectable");
|
||||
assert_eq!(data.model_count, 1);
|
||||
assert_eq!(data.catalog, body);
|
||||
assert_eq!(data.observed_at, "2026-09-12T16:00:00+08:00");
|
||||
assert_eq!(data.observed_at, "2026-09-12T08:00:00+00:00");
|
||||
|
||||
// 只有模型 ID 的 API 目录拼不出合法的 model_catalog_json,不能降格返回给客户端。
|
||||
let adapted = RawJsonPayload::new("openai", Bytes::from_static(br#"{"models":[]}"#))
|
||||
|
||||
@@ -431,26 +431,29 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
|
||||
.single()
|
||||
.expect("valid time");
|
||||
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
|
||||
request_profile_overrides: Default::default(),
|
||||
budget: Default::default(),
|
||||
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
|
||||
name: "visible".to_owned(),
|
||||
label: None,
|
||||
groups: Vec::new(),
|
||||
provider_kinds: vec![
|
||||
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
|
||||
],
|
||||
prefix: "sk_visible12".to_owned(),
|
||||
enabled: true,
|
||||
limits: gateway_core::policy::RateLimits {
|
||||
max_concurrency: 2,
|
||||
requests_per_minute: 60,
|
||||
let view = ClientKeyView::from((
|
||||
gateway_admin::model::client_keys::ClientKeyRecord {
|
||||
request_profile_overrides: Default::default(),
|
||||
budget: Default::default(),
|
||||
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
|
||||
name: "visible".to_owned(),
|
||||
label: None,
|
||||
groups: Vec::new(),
|
||||
provider_kinds: vec![
|
||||
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
|
||||
],
|
||||
prefix: "sk_visible12".to_owned(),
|
||||
enabled: true,
|
||||
limits: gateway_core::policy::RateLimits {
|
||||
max_concurrency: 2,
|
||||
requests_per_minute: 60,
|
||||
},
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
},
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
});
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
));
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
|
||||
@@ -114,19 +114,24 @@ impl AdminTestFixture {
|
||||
}
|
||||
|
||||
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
|
||||
Self::with_dependencies(system, None).await
|
||||
Self::with_dependencies(system, None, Default::default()).await
|
||||
}
|
||||
|
||||
pub async fn with_key_verifier(
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Self {
|
||||
Self::with_dependencies(system, Some(verifier)).await
|
||||
Self::with_dependencies(system, Some(verifier), Default::default()).await
|
||||
}
|
||||
|
||||
pub async fn with_timezone(timezone: gateway_core::time::DeploymentTimeZone) -> Self {
|
||||
Self::with_dependencies(Arc::new(UnusedSystem), None, timezone).await
|
||||
}
|
||||
|
||||
async fn with_dependencies(
|
||||
system: Arc<dyn SystemOperations>,
|
||||
verifier: Option<Arc<dyn ClientKeyVerifier>>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Self {
|
||||
let api_key = Arc::new(Mutex::new(None));
|
||||
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
|
||||
@@ -184,6 +189,7 @@ impl AdminTestFixture {
|
||||
ClientConfig::default(),
|
||||
stores,
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone,
|
||||
service_middleware: std::sync::Arc::new(|| None),
|
||||
plugin_preparation: plugin_ports.clone(),
|
||||
plugin_management: plugin_ports.clone(),
|
||||
@@ -564,6 +570,7 @@ impl SettingsStore for MemorySettingsStore {
|
||||
max_waiting_per_key: command.max_waiting_per_key,
|
||||
max_waiting_per_account: command.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
|
||||
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
|
||||
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: command.smart_scheduling,
|
||||
rotation_strategy: command.rotation_strategy,
|
||||
@@ -1029,6 +1036,26 @@ impl AccountStore for UnusedStore {
|
||||
_: AccountListQuery,
|
||||
_: AccountRuntimeSnapshot,
|
||||
) -> AdminStoreResult<AccountPage> {
|
||||
if let Some(account) = self.account.lock().expect("account").as_ref() {
|
||||
let status = account.projection.status;
|
||||
return Ok(AccountPage {
|
||||
config_revision: Revision::new(1).unwrap(),
|
||||
items: vec![account.clone()],
|
||||
total: 1,
|
||||
summary: gateway_admin::model::accounts::AccountSummary {
|
||||
total: 1,
|
||||
normal: u64::from(status == gateway_core::account::AccountStatus::Normal),
|
||||
quota_exhausted: u64::from(
|
||||
status == gateway_core::account::AccountStatus::QuotaExhausted,
|
||||
),
|
||||
rate_limited: u64::from(
|
||||
status == gateway_core::account::AccountStatus::RateLimited,
|
||||
),
|
||||
disabled: u64::from(status == gateway_core::account::AccountStatus::Disabled),
|
||||
error: u64::from(status == gateway_core::account::AccountStatus::Error),
|
||||
},
|
||||
});
|
||||
}
|
||||
Err(unavailable("account list"))
|
||||
}
|
||||
|
||||
@@ -1552,6 +1579,7 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
|
||||
@@ -157,14 +157,14 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_default_to_current_china_day() {
|
||||
async fn dashboard_summary_should_default_to_current_deployment_day() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, DashboardObservation, TimeRange, china_day_start,
|
||||
AccountPoolMetrics, DashboardObservation, TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
@@ -206,7 +206,12 @@ async fn dashboard_summary_should_default_to_current_china_day() {
|
||||
.expect("recorded dashboard summary range");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(range.start, china_day_start(range.end));
|
||||
assert_eq!(
|
||||
range.start,
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(range.end)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -447,6 +452,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
provider_account_ref: Some("acct_err".to_owned()),
|
||||
provider_account_name: None,
|
||||
provider_account_email: Some("err@example.invalid".to_owned()),
|
||||
provider_account_plan_type: Some("pro".to_owned()),
|
||||
provider_account_plan_type_display: Some("Pro".to_owned()),
|
||||
provider_account_authentication_kind: Some("api_key".to_owned()),
|
||||
upstream_model_id: Some("upstream-err".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
@@ -509,6 +516,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"kind": value["data"]["items"][0]["kind"],
|
||||
"accountId": value["data"]["items"][0]["accountId"],
|
||||
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
|
||||
"accountPlanType": value["data"]["items"][0]["accountPlanType"],
|
||||
"accountPlanTypeDisplay": value["data"]["items"][0]["accountPlanTypeDisplay"],
|
||||
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"requestedModel": value["data"]["items"][0]["requestedModel"],
|
||||
@@ -528,6 +537,8 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"kind": "model_request",
|
||||
"accountId": "acct_err",
|
||||
"accountLabel": "err@example.invalid",
|
||||
"accountPlanType": "pro",
|
||||
"accountPlanTypeDisplay": "Pro",
|
||||
"clientStatusCode": 502,
|
||||
"route": "/v1/responses",
|
||||
"requestedModel": "gpt-5.4",
|
||||
@@ -975,3 +986,147 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
"$0.007"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor() {
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_admin::model::observability::{CalendarPeriod, DashboardObservation, TimeRange};
|
||||
use gateway_api::admin::observability;
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
for (name, anchor, start, display, slots) in [
|
||||
(
|
||||
"Asia/Shanghai",
|
||||
"2026-10-01T00:00:00Z",
|
||||
"2026-09-30T16:00:00Z",
|
||||
"2026-10-01 08:00:00",
|
||||
96,
|
||||
),
|
||||
(
|
||||
"UTC",
|
||||
"2026-10-01T00:00:00Z",
|
||||
"2026-10-01T00:00:00Z",
|
||||
"2026-10-01 00:00:00",
|
||||
96,
|
||||
),
|
||||
(
|
||||
"Asia/Kathmandu",
|
||||
"2026-10-01T00:00:00Z",
|
||||
"2026-09-30T18:15:00Z",
|
||||
"2026-10-01 05:45:00",
|
||||
96,
|
||||
),
|
||||
(
|
||||
"America/New_York",
|
||||
"2026-03-09T03:59:00Z",
|
||||
"2026-03-08T05:00:00Z",
|
||||
"2026-03-08 23:59:00",
|
||||
92,
|
||||
),
|
||||
(
|
||||
"America/New_York",
|
||||
"2026-11-02T04:59:00Z",
|
||||
"2026-11-01T04:00:00Z",
|
||||
"2026-11-01 23:59:00",
|
||||
100,
|
||||
),
|
||||
] {
|
||||
let timezone: DeploymentTimeZone = name.parse().unwrap();
|
||||
let end: chrono::DateTime<Utc> = anchor.parse().unwrap();
|
||||
let range = TimeRange::calendar_at(CalendarPeriod::Today, end, timezone).unwrap();
|
||||
let fixture = AdminTestFixture::with_timezone(timezone).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
|
||||
range,
|
||||
totals: Default::default(),
|
||||
provider_accounts: Default::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
let router = observability::router::<AdminTestState>().with_state(fixture.state());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/admin/dashboard/summary?period=today&asOf={}",
|
||||
end.timestamp_millis()
|
||||
))
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_timezone_calendar")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"{name}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let queried = fixture.dashboard_summary_range.lock().unwrap().unwrap();
|
||||
assert_eq!(
|
||||
queried.start,
|
||||
start.parse::<chrono::DateTime<Utc>>().unwrap()
|
||||
);
|
||||
assert_eq!(queried.end, end);
|
||||
assert_eq!(value["data"]["asOf"], anchor);
|
||||
assert_eq!(value["data"]["asOfDisplay"], display);
|
||||
let points = value["data"]["healthTimeline"]["points"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(points.len(), slots);
|
||||
let labels = points
|
||||
.iter()
|
||||
.map(|p| p["time"].as_str().unwrap())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(labels.len(), slots, "fold labels remain distinct");
|
||||
let invalid = router.oneshot(Request::builder()
|
||||
.uri(format!("/api/admin/dashboard/summary?period=today&asOf={}&startTime=2026-01-01T00%3A00%3A00Z", end.timestamp_millis()))
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_timezone_mixed_range")
|
||||
.body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calendar_query_rejects_an_anchor_outside_the_local_calendar() {
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
let fixture = AdminTestFixture::with_timezone(Default::default()).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/admin/dashboard/summary?period=today&asOf={}",
|
||||
chrono::DateTime::<chrono::Utc>::MAX_UTC.timestamp_millis()
|
||||
))
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header("x-request-id", "req_timezone_out_of_range")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(fixture.dashboard_summary_range.lock().unwrap().is_none());
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ fn update_body() -> Value {
|
||||
"requestIntervalMs": 25,
|
||||
"maxWaitingPerKey": 0,
|
||||
"maxWaitingPerAccount": 0,
|
||||
"openaiGuardianReservedConcurrency": 0,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
@@ -220,6 +221,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
||||
rotation_strategy: RotationStrategy::RoundRobin,
|
||||
@@ -244,7 +246,11 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
.expect("timestamp"),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(RuntimeSettingsView::from(settings)).expect("serialize view");
|
||||
let value = serde_json::to_value(RuntimeSettingsView::from((
|
||||
settings,
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
)))
|
||||
.expect("serialize view");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
@@ -264,6 +270,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"requestIntervalMs": 25,
|
||||
"maxWaitingPerKey": 0,
|
||||
"maxWaitingPerAccount": 0,
|
||||
"openaiGuardianReservedConcurrency": 0,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
@@ -284,7 +291,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"accountWarmupEnabled": false,
|
||||
"accountWarmupScheduleTime": "08:00",
|
||||
"accountWarmupModel": null,
|
||||
"updatedAt": "2026-08-02T10:30:00Z"
|
||||
"updatedAt": "2026-08-02T10:30:00Z",
|
||||
"updatedAtDisplay": "2026-08-02 18:30:00"
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -332,6 +340,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: request.smart_scheduling,
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
@@ -354,19 +363,22 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let response_fields: BTreeSet<String> =
|
||||
serde_json::to_value(RuntimeSettingsView::from(settings))
|
||||
.expect("serialize view")
|
||||
.as_object()
|
||||
.expect("view object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let response_fields: BTreeSet<String> = serde_json::to_value(RuntimeSettingsView::from((
|
||||
settings,
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
)))
|
||||
.expect("serialize view")
|
||||
.as_object()
|
||||
.expect("view object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut expected_fields = request_fields;
|
||||
expected_fields.insert("providerRequestProfiles".to_owned());
|
||||
expected_fields.insert("openaiClientProfile".to_owned());
|
||||
expected_fields.insert("xaiClientProfile".to_owned());
|
||||
expected_fields.insert("updatedAt".to_owned());
|
||||
expected_fields.insert("updatedAtDisplay".to_owned());
|
||||
expected_fields.insert("smartSchedulingDefaults".to_owned());
|
||||
|
||||
assert_eq!(response_fields, expected_fields);
|
||||
@@ -1268,3 +1280,50 @@ async fn settings_update_rejects_a_stale_version_without_replacing_the_saved_val
|
||||
9999
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_reservation_round_trips_and_rejects_invalid_values() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut revision = response_json(response).await["data"]["configRevision"].clone();
|
||||
for reserved in [1_u32, u32::MAX, 0] {
|
||||
let mut body = update_body();
|
||||
body["configRevision"] = revision;
|
||||
body["openaiGuardianReservedConcurrency"] = json!(reserved);
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response_json(response).await["data"]["openaiGuardianReservedConcurrency"],
|
||||
reserved
|
||||
);
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let data = response_json(response).await["data"].clone();
|
||||
revision = data["configRevision"].clone();
|
||||
assert_eq!(data["openaiGuardianReservedConcurrency"], reserved);
|
||||
}
|
||||
for invalid in [json!(-1), json!(1.5), json!(4294967296_u64)] {
|
||||
let mut body = update_body();
|
||||
body["openaiGuardianReservedConcurrency"] = invalid;
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
let mut omitted = update_body();
|
||||
omitted
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("openaiGuardianReservedConcurrency");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(omitted).is_err());
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/usage.rs",
|
||||
"src/provider.rs",
|
||||
"src/session_cookie.rs",
|
||||
"src/time.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ fn error_record() -> OpsError {
|
||||
provider_account_name: Some("private-sentinel".to_owned()),
|
||||
provider_account_email: Some("private-sentinel".to_owned()),
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type: Some("private-sentinel".to_owned()),
|
||||
provider_account_plan_type_display: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
failure_kind: "private-sentinel".to_owned(),
|
||||
|
||||
@@ -7,8 +7,8 @@ use axum::{
|
||||
http::{Method, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{RequestMetrics, china_day_start};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::RequestMetrics;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
@@ -172,6 +172,7 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
|
||||
&data,
|
||||
&[
|
||||
"asOf",
|
||||
"asOfDisplay",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"key",
|
||||
@@ -190,9 +191,11 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
|
||||
"dailyLimitUsd",
|
||||
"dailyUsedUsd",
|
||||
"dailyResetsAt",
|
||||
"dailyResetsAtDisplay",
|
||||
"weeklyLimitUsd",
|
||||
"weeklyUsedUsd",
|
||||
"weeklyResetsAt",
|
||||
"weeklyResetsAtDisplay",
|
||||
],
|
||||
);
|
||||
assert_eq!(data["key"]["dailyUsedUsd"], "0.640001");
|
||||
@@ -226,8 +229,20 @@ async fn overview_scopes_every_query_and_projects_only_key_visible_fields() {
|
||||
.iter()
|
||||
.find(|(_, filter)| filter.model.is_none())
|
||||
.unwrap();
|
||||
assert_eq!(health.0.start, china_day_start(health.0.end));
|
||||
assert!((Utc::now() - health.0.end) < Duration::seconds(5));
|
||||
assert_eq!(
|
||||
health.0.start,
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.day_start(health.0.end)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
health.0.end,
|
||||
data["asOf"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<chrono::DateTime<Utc>>()
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -254,6 +269,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
&[
|
||||
"id",
|
||||
"createdAt",
|
||||
"createdAtDisplay",
|
||||
"model",
|
||||
"route",
|
||||
"reasoningEffort",
|
||||
|
||||
@@ -15,6 +15,7 @@ path = "tests/main.rs"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
chrono-tz = { version = "0.10", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
http = "1.5.0"
|
||||
|
||||
@@ -90,6 +90,18 @@ impl AccountConcurrency {
|
||||
Self::Limited(limit) => Some(limit),
|
||||
}
|
||||
}
|
||||
|
||||
/// 扣除当前请求不可占用的预留名额;不限并发不受影响,有限上限至少保留 1 个名额。
|
||||
#[must_use]
|
||||
pub const fn excluding_reserved(self, reserved: u32) -> Self {
|
||||
match self {
|
||||
Self::Unlimited => Self::Unlimited,
|
||||
Self::Limited(limit) => match NonZeroU32::new(limit.get().saturating_sub(reserved)) {
|
||||
Some(remaining) => Self::Limited(remaining),
|
||||
None => Self::Limited(NonZeroU32::MIN),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NonZeroU32> for AccountConcurrency {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct AccountSelectionPolicy {
|
||||
max_concurrent_per_account: AccountConcurrency,
|
||||
request_interval: Duration,
|
||||
queue_policy: ConcurrencyQueuePolicy,
|
||||
openai_guardian_reserved_concurrency: u32,
|
||||
}
|
||||
|
||||
impl AccountSelectionPolicy {
|
||||
@@ -68,6 +69,7 @@ impl AccountSelectionPolicy {
|
||||
smart_scheduling: SmartSchedulingConfig::default(),
|
||||
max_concurrent_per_account: max_concurrent_per_account.into(),
|
||||
request_interval,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
queue_policy: ConcurrencyQueuePolicy {
|
||||
max_waiting: 0,
|
||||
timeout: Duration::ZERO,
|
||||
@@ -75,6 +77,18 @@ impl AccountSelectionPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 只传递冻结的运行设置,Guardian 分类与预留策略由 OpenAI Provider 解释。
|
||||
#[must_use]
|
||||
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
|
||||
self.openai_guardian_reserved_concurrency = reserved;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn openai_guardian_reserved_concurrency(self) -> u32 {
|
||||
self.openai_guardian_reserved_concurrency
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_queue(mut self, policy: ConcurrencyQueuePolicy) -> Self {
|
||||
self.queue_policy = policy;
|
||||
@@ -433,6 +447,18 @@ pub struct AccountSelectionContext {
|
||||
pub round_robin_cursor: u64,
|
||||
pub eligibility: AccountEligibilityPolicy,
|
||||
pub account_scope: Option<std::sync::Arc<crate::account::scope::FrozenAccountScope>>,
|
||||
/// 本请求不可占用的每账号预留并发名额,由 Provider 按请求类别决定;0 表示不预留。
|
||||
pub reserved_concurrency: u32,
|
||||
}
|
||||
|
||||
impl AccountSelectionContext {
|
||||
/// 本请求在该账号上可使用的并发上限;资格判断、租约与策略投影必须共用这一口径。
|
||||
#[must_use]
|
||||
pub fn concurrency_limit(&self, account: &ProviderAccount) -> AccountConcurrency {
|
||||
account
|
||||
.effective_concurrency(self.policy.max_concurrent_per_account())
|
||||
.excluding_reserved(self.reserved_concurrency)
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择账号时是否执行本地调度资格投影。
|
||||
@@ -568,13 +594,8 @@ impl AccountSelector {
|
||||
)
|
||||
})
|
||||
.try_fold((0_u64, 0_u64), |(used, total), candidate| {
|
||||
let capacity = u64::from(
|
||||
candidate
|
||||
.account
|
||||
.effective_concurrency(context.policy.max_concurrent_per_account())
|
||||
.limit()?
|
||||
.get(),
|
||||
);
|
||||
let capacity =
|
||||
u64::from(context.concurrency_limit(&candidate.account).limit()?.get());
|
||||
Some((
|
||||
used.saturating_add(u64::from(candidate.signals.in_flight)),
|
||||
total.saturating_add(capacity),
|
||||
@@ -789,9 +810,8 @@ impl AccountSelector {
|
||||
if context.excluded_accounts.contains(candidate.account.id()) {
|
||||
return Some(AccountSchedulingBlocker::Excluded);
|
||||
}
|
||||
if candidate
|
||||
.account
|
||||
.effective_concurrency(context.policy.max_concurrent_per_account())
|
||||
if context
|
||||
.concurrency_limit(&candidate.account)
|
||||
.limit()
|
||||
.is_some_and(|limit| candidate.signals.in_flight >= limit.get())
|
||||
{
|
||||
|
||||
@@ -43,8 +43,21 @@ pub enum QueueRejection {
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// 同一队列内的等待类别:高优先级排在已有高优先级等待者之后、全部普通等待者之前。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WaitPriority {
|
||||
#[default]
|
||||
Normal,
|
||||
High,
|
||||
}
|
||||
|
||||
struct Waiter {
|
||||
waker: Arc<AtomicWaker>,
|
||||
priority: WaitPriority,
|
||||
}
|
||||
|
||||
struct QueueState<K> {
|
||||
queues: HashMap<K, VecDeque<Arc<AtomicWaker>>>,
|
||||
queues: HashMap<K, VecDeque<Waiter>>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
@@ -75,6 +88,20 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
|
||||
.is_some_and(|queue| !queue.is_empty())
|
||||
}
|
||||
|
||||
/// 是否存在排在该类别请求之前的等待者;普通请求让位于全部等待者,高优先级只让位于同类。
|
||||
fn has_waiters_ahead_of(&self, key: &K, priority: WaitPriority) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.queues
|
||||
.get(key)
|
||||
.is_some_and(|queue| {
|
||||
queue
|
||||
.iter()
|
||||
.any(|waiter| priority == WaitPriority::Normal || waiter.priority == priority)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn enqueue(
|
||||
&self,
|
||||
keys: &[K],
|
||||
@@ -91,6 +118,18 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
|
||||
keys: &[K],
|
||||
max_waiting: u32,
|
||||
deadline: Instant,
|
||||
priority: impl FnMut(&K, usize) -> f64,
|
||||
) -> Result<WaitTicket<K>, QueueRejection> {
|
||||
self.enqueue_ranked(keys, max_waiting, deadline, WaitPriority::Normal, priority)
|
||||
}
|
||||
|
||||
/// 高优先级等待者不受单队列 `max_waiting` 限制,但仍要求排队已开启并受全局容量约束。
|
||||
fn enqueue_ranked(
|
||||
&self,
|
||||
keys: &[K],
|
||||
max_waiting: u32,
|
||||
deadline: Instant,
|
||||
waiter_priority: WaitPriority,
|
||||
mut priority: impl FnMut(&K, usize) -> f64,
|
||||
) -> Result<WaitTicket<K>, QueueRejection> {
|
||||
if Instant::now() >= deadline {
|
||||
@@ -107,17 +146,30 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
let len = state.queues.get(key).map_or(0, VecDeque::len);
|
||||
(len < max_waiting as usize).then(|| (key, priority(key, len)))
|
||||
let admitted = max_waiting > 0
|
||||
&& (waiter_priority == WaitPriority::High || len < max_waiting as usize);
|
||||
admitted.then(|| (key, priority(key, len)))
|
||||
})
|
||||
.min_by(|(_, left), (_, right)| right.total_cmp(left))
|
||||
.map(|(key, _)| key.clone())
|
||||
.ok_or(QueueRejection::Full)?;
|
||||
let waker = Arc::new(AtomicWaker::new());
|
||||
state
|
||||
.queues
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.push_back(Arc::clone(&waker));
|
||||
let queue = state.queues.entry(key.clone()).or_default();
|
||||
let position = match waiter_priority {
|
||||
WaitPriority::Normal => queue.len(),
|
||||
WaitPriority::High => queue
|
||||
.iter()
|
||||
.position(|waiter| waiter.priority == WaitPriority::Normal)
|
||||
.unwrap_or(queue.len()),
|
||||
};
|
||||
// 插到队首时原队首失去位置,它的有界重查会自然让出;新队首随后自行轮询到位。
|
||||
queue.insert(
|
||||
position,
|
||||
Waiter {
|
||||
waker: Arc::clone(&waker),
|
||||
priority: waiter_priority,
|
||||
},
|
||||
);
|
||||
state.total += 1;
|
||||
Ok(WaitTicket {
|
||||
state: Arc::clone(&self.state),
|
||||
@@ -142,19 +194,20 @@ impl<K: Eq + Hash> WaitTicket<K> {
|
||||
&self.key
|
||||
}
|
||||
|
||||
fn is_head(&self) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.queues
|
||||
.get(&self.key)
|
||||
.and_then(VecDeque::front)
|
||||
.is_some_and(|head| Arc::ptr_eq(&head.waker, &self.waker))
|
||||
}
|
||||
|
||||
pub async fn turn(&self) -> Result<(), QueueRejection> {
|
||||
let ready = poll_fn(|cx| {
|
||||
self.waker.register(cx.waker());
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state
|
||||
.queues
|
||||
.get(&self.key)
|
||||
.and_then(VecDeque::front)
|
||||
.is_some_and(|head| Arc::ptr_eq(head, &self.waker))
|
||||
{
|
||||
if self.is_head() {
|
||||
Poll::Ready(())
|
||||
} else {
|
||||
Poll::Pending
|
||||
@@ -188,11 +241,14 @@ impl<K: Eq + Hash> Drop for WaitTicket<K> {
|
||||
let Some(queue) = state.queues.get_mut(&self.key) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = queue.iter().position(|item| Arc::ptr_eq(item, &self.waker)) else {
|
||||
let Some(index) = queue
|
||||
.iter()
|
||||
.position(|item| Arc::ptr_eq(&item.waker, &self.waker))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
queue.remove(index);
|
||||
let next = queue.front().cloned();
|
||||
let next = queue.front().map(|waiter| Arc::clone(&waiter.waker));
|
||||
if queue.is_empty() {
|
||||
state.queues.remove(&self.key);
|
||||
}
|
||||
@@ -212,6 +268,7 @@ pub struct CapacityWait<'a, K: Eq + Hash> {
|
||||
policy: ConcurrencyQueuePolicy,
|
||||
request_deadline: SystemTime,
|
||||
budget: &'a ConcurrencyWaitBudget,
|
||||
priority: WaitPriority,
|
||||
started_at: Option<Instant>,
|
||||
ticket: Option<WaitTicket<K>>,
|
||||
}
|
||||
@@ -229,17 +286,26 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
|
||||
policy,
|
||||
request_deadline,
|
||||
budget,
|
||||
priority: WaitPriority::Normal,
|
||||
started_at: None,
|
||||
ticket: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_priority(mut self, priority: WaitPriority) -> Self {
|
||||
self.priority = priority;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_try(&self, key: &K) -> bool {
|
||||
self.ticket
|
||||
.as_ref()
|
||||
.is_some_and(|ticket| ticket.key() == key)
|
||||
|| !self.queue.has_waiters(key)
|
||||
if let Some(ticket) = self.ticket.as_ref().filter(|ticket| ticket.key() == key) {
|
||||
// 重读容量期间可能被高优先级请求插队,持有 ticket 不代表仍有选号资格。
|
||||
ticket.is_head()
|
||||
} else {
|
||||
!self.queue.has_waiters_ahead_of(key, self.priority)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -271,10 +337,11 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
|
||||
self.ticket = None;
|
||||
}
|
||||
if self.ticket.is_none() {
|
||||
self.ticket = Some(self.queue.enqueue_with_priority(
|
||||
self.ticket = Some(self.queue.enqueue_ranked(
|
||||
keys,
|
||||
self.policy.max_waiting,
|
||||
deadline,
|
||||
self.priority,
|
||||
priority,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ impl TraceContext {
|
||||
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
|
||||
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
|
||||
"roundRobinCursor": context.round_robin_cursor,
|
||||
"reservedConcurrency": context.reserved_concurrency,
|
||||
"smartScoreTolerance": smart.then(|| context.policy.smart_scheduling().score_tolerance()),
|
||||
"candidateCount": candidates.len(),
|
||||
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
|
||||
|
||||
@@ -427,17 +427,13 @@ impl RequestPolicyContext {
|
||||
if eligible.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let policy = context.policy;
|
||||
let projected = eligible
|
||||
.iter()
|
||||
.map(|candidate| AccountScheduleCandidate {
|
||||
account_id: candidate.account.id().clone(),
|
||||
weight: candidate.account.weight().get(),
|
||||
in_flight: candidate.signals.in_flight,
|
||||
maximum_concurrency: candidate
|
||||
.account
|
||||
.effective_concurrency(policy.max_concurrent_per_account())
|
||||
.get(),
|
||||
maximum_concurrency: context.concurrency_limit(&candidate.account).get(),
|
||||
last_started_at: candidate.signals.last_started_at,
|
||||
quota_reset_at: candidate.signals.quota_reset_at,
|
||||
quota_remaining_rank: candidate.signals.quota_remaining_rank,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod routing;
|
||||
pub mod runtime;
|
||||
pub mod settings;
|
||||
pub mod task;
|
||||
pub mod time;
|
||||
pub mod upstream;
|
||||
pub mod validation;
|
||||
|
||||
|
||||
@@ -936,6 +936,19 @@ fn invalid_refresh_policy(operation: &'static str) -> ProviderStoreError {
|
||||
}
|
||||
|
||||
pub trait ProviderRuntimePolicyPort: Send + Sync {
|
||||
/// 原子推进预热执行游标,重启或时钟回拨后不重复领取已消费的时刻。
|
||||
fn claim_warmup_slot<'a>(
|
||||
&'a self,
|
||||
_timezone: crate::time::DeploymentTimeZone,
|
||||
_slot: chrono::NaiveDateTime,
|
||||
) -> BoxFuture<'a, Result<bool, ProviderStoreError>> {
|
||||
Box::pin(async {
|
||||
Err(ProviderStoreError::new(
|
||||
ProviderStoreErrorKind::Unavailable,
|
||||
"claim warmup slot",
|
||||
))
|
||||
})
|
||||
}
|
||||
/// 仅首次启动写入该 Provider 的默认选择;已保存的管理配置始终优先。
|
||||
fn initialize_request_profile<'a>(
|
||||
&'a self,
|
||||
|
||||
@@ -55,6 +55,7 @@ impl CompiledSettings {
|
||||
AccountConcurrency::new(settings.max_concurrent_per_account),
|
||||
Duration::from_millis(settings.request_interval_ms),
|
||||
)
|
||||
.with_openai_guardian_reserved_concurrency(settings.openai_guardian_reserved_concurrency)
|
||||
.with_smart_scheduling(settings.smart_scheduling)
|
||||
.with_queue(ConcurrencyQueuePolicy {
|
||||
max_waiting: settings.max_waiting_per_account,
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct SettingsValues {
|
||||
pub(crate) max_waiting_per_key: u32,
|
||||
pub(crate) max_waiting_per_account: u32,
|
||||
pub(crate) concurrency_wait_timeout_seconds: u32,
|
||||
pub(crate) openai_guardian_reserved_concurrency: u32,
|
||||
pub(crate) responses_max_decompressed_body_bytes: u64,
|
||||
pub(crate) request_interval_ms: u64,
|
||||
pub(crate) smart_scheduling: crate::account::SmartSchedulingConfig,
|
||||
@@ -24,6 +25,12 @@ pub struct SettingsValues {
|
||||
}
|
||||
|
||||
impl SettingsValues {
|
||||
#[must_use]
|
||||
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
|
||||
self.openai_guardian_reserved_concurrency = reserved;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn request_profiles(&self) -> &BTreeMap<ProviderKind, crate::account::OpaqueProviderData> {
|
||||
&self.request_profiles
|
||||
@@ -104,6 +111,7 @@ impl SettingsValues {
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
request_interval_ms,
|
||||
smart_scheduling: crate::account::SmartSchedulingConfig::default(),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! 部署时区的日历换算,持久化和比较始终使用 UTC 时间点。
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Days, NaiveDate, NaiveDateTime, Offset as _, TimeZone as _, Utc};
|
||||
use chrono_tz::{GapInfo, Tz};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 由组合根传递的不可变 IANA 时区。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct DeploymentTimeZone(Tz);
|
||||
|
||||
impl Default for DeploymentTimeZone {
|
||||
fn default() -> Self {
|
||||
Self(chrono_tz::Asia::Shanghai)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DeploymentTimeZone {
|
||||
type Err = chrono_tz::ParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
value.parse().map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl DeploymentTimeZone {
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
self.0.name()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn iana(self) -> Tz {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn local(self, value: DateTime<Utc>) -> DateTime<Tz> {
|
||||
value.with_timezone(&self.0)
|
||||
}
|
||||
|
||||
/// 重复本地时刻只取较早时间点,缺失时刻由调用方决定是否跳过。
|
||||
#[must_use]
|
||||
pub fn resolve_local(self, value: NaiveDateTime) -> Option<DateTime<Utc>> {
|
||||
self.0
|
||||
.from_local_datetime(&value)
|
||||
.earliest()
|
||||
.map(|value| value.to_utc())
|
||||
}
|
||||
|
||||
/// 午夜跳时取缺口结束点,包含整日跳过的历史时区变更。
|
||||
#[must_use]
|
||||
pub fn date_start(self, date: NaiveDate) -> Option<DateTime<Utc>> {
|
||||
let midnight = date.and_hms_opt(0, 0, 0)?;
|
||||
self.resolve_local(midnight).or_else(|| {
|
||||
GapInfo::new(&midnight, &self.0)?
|
||||
.end
|
||||
.map(|value| value.to_utc())
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn day_start(self, value: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
self.date_start(self.local_date(value)?)
|
||||
}
|
||||
|
||||
/// 先移动本地日期再解析时间点,避免把自然日写成固定 24 小时。
|
||||
#[must_use]
|
||||
pub fn days_before(self, value: DateTime<Utc>, days: u64) -> Option<DateTime<Utc>> {
|
||||
self.date_start(self.local_date(value)?.checked_sub_days(Days::new(days))?)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn days_after(self, value: DateTime<Utc>, days: u64) -> Option<DateTime<Utc>> {
|
||||
self.date_start(self.local_date(value)?.checked_add_days(Days::new(days))?)
|
||||
}
|
||||
|
||||
fn local_date(self, value: DateTime<Utc>) -> Option<NaiveDate> {
|
||||
let local = self.local(value);
|
||||
// UTC 可表示的端点在叠加时区偏移后仍可能超出本地日历范围。
|
||||
local
|
||||
.naive_utc()
|
||||
.checked_add_offset(local.offset().fix())
|
||||
.map(|value| value.date())
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,13 @@ use std::time::{Duration, SystemTime};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use gateway_core::account::{
|
||||
AccountAttemptFeedback, AccountCandidate, AccountConcurrencyLimit, AccountEligibilityPolicy,
|
||||
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
|
||||
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus, AccountWeight,
|
||||
CredentialCasUpdate, CredentialRevision, CredentialState, OpaqueProviderData,
|
||||
PlaintextCredential, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState, RotationStrategy,
|
||||
AccountAttemptFeedback, AccountCandidate, AccountConcurrency, AccountConcurrencyLimit,
|
||||
AccountEligibilityPolicy, AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals,
|
||||
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
|
||||
AccountStatus, AccountWeight, CredentialCasUpdate, CredentialRevision, CredentialState,
|
||||
OpaqueProviderData, PlaintextCredential, PreferredAccountSelection, ProviderAccount,
|
||||
ProviderAccountId, ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState,
|
||||
RotationStrategy,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ClientRoutingScope, FrozenAccountScope, ProviderKind, RuntimeAccount, RuntimeAccountDirectory,
|
||||
@@ -109,6 +110,7 @@ fn context(strategy: RotationStrategy) -> AccountSelectionContext {
|
||||
round_robin_cursor: 0,
|
||||
eligibility: AccountEligibilityPolicy::Enforce,
|
||||
account_scope: None,
|
||||
reserved_concurrency: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,3 +1095,42 @@ fn due_probe_freeze_blocks_scheduling_and_remains_visible_as_rate_limited() {
|
||||
.expect("available account");
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_concurrency_only_blocks_requests_that_cannot_use_the_reserved_slots() {
|
||||
let candidates = [candidate_with_concurrency("acct_reserved", 4, 5)];
|
||||
let mut normal = context(RotationStrategy::Smart);
|
||||
normal.reserved_concurrency = 1;
|
||||
assert_eq!(normal.concurrency_limit(&candidates[0].account).get(), 4);
|
||||
assert!(AccountSelector.select(&candidates, &normal).is_none());
|
||||
let capacity = AccountSelector
|
||||
.capacity_snapshot(&candidates, &normal)
|
||||
.unwrap();
|
||||
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 4));
|
||||
// 预留造成的暂满仍是可等待的容量约束,不能被当成不可用账号。
|
||||
assert_eq!(
|
||||
AccountSelector.wait_candidates(&candidates, &normal).len(),
|
||||
1
|
||||
);
|
||||
|
||||
let prioritized = context(RotationStrategy::Smart);
|
||||
let capacity = AccountSelector
|
||||
.capacity_snapshot(&candidates, &prioritized)
|
||||
.unwrap();
|
||||
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 5));
|
||||
let selected = AccountSelector
|
||||
.select(&candidates, &prioritized)
|
||||
.expect("reserved slot available");
|
||||
assert_eq!(selected.candidate().account.id().as_str(), "acct_reserved");
|
||||
let full = [candidate_with_concurrency("acct_reserved", 5, 5)];
|
||||
assert!(AccountSelector.select(&full, &prioritized).is_none());
|
||||
|
||||
// 预留不能让普通请求失去全部名额,也不改变不限并发。
|
||||
let mut oversized = context(RotationStrategy::Smart);
|
||||
oversized.reserved_concurrency = 10;
|
||||
assert_eq!(oversized.concurrency_limit(&candidates[0].account).get(), 1);
|
||||
assert_eq!(
|
||||
AccountConcurrency::Unlimited.excluding_reserved(2),
|
||||
AccountConcurrency::Unlimited
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::{Duration, Instant, SystemTime};
|
||||
use futures::{FutureExt, executor::block_on};
|
||||
use gateway_core::concurrency::{
|
||||
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
|
||||
QueueRejection,
|
||||
QueueRejection, WaitPriority,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -231,3 +231,97 @@ fn changed_scores_keep_existing_position_until_account_becomes_ineligible() {
|
||||
assert!(!queue.has_waiters(&"b"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_priority_waiters_go_ahead_of_normal_waiters_and_keep_fifo_among_themselves() {
|
||||
block_on(async {
|
||||
let queue = ConcurrencyWaitQueue::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let policy = ConcurrencyQueuePolicy {
|
||||
max_waiting: 2,
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let request_deadline = SystemTime::now() + Duration::from_secs(2);
|
||||
let first_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
|
||||
let second_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
|
||||
|
||||
let normal_budget = ConcurrencyWaitBudget::default();
|
||||
let normal = CapacityWait::new(&queue, policy, request_deadline, &normal_budget);
|
||||
let first_budget = ConcurrencyWaitBudget::default();
|
||||
let mut first_high = CapacityWait::new(&queue, policy, request_deadline, &first_budget)
|
||||
.with_priority(WaitPriority::High);
|
||||
// 只有普通等待者时,高优先级请求可以直接尝试租约,普通请求仍须排队。
|
||||
assert!(first_high.can_try(&"a"));
|
||||
assert!(!normal.can_try(&"a"));
|
||||
|
||||
// 普通等待者已占满单队列上限,高优先级仍可入队并直接成为队首。
|
||||
first_high.wait(&["a"]).await.unwrap();
|
||||
let second_budget = ConcurrencyWaitBudget::default();
|
||||
let mut second_high = CapacityWait::new(&queue, policy, request_deadline, &second_budget)
|
||||
.with_priority(WaitPriority::High);
|
||||
assert!(!second_high.can_try(&"a"));
|
||||
assert!(second_high.wait(&["a"]).now_or_never().is_none());
|
||||
assert!(first_normal.turn().now_or_never().is_none());
|
||||
|
||||
drop(first_high);
|
||||
second_high.wait(&["a"]).await.unwrap();
|
||||
assert!(first_normal.turn().now_or_never().is_none());
|
||||
drop(second_high);
|
||||
first_normal.turn().await.unwrap();
|
||||
drop(first_normal);
|
||||
second_normal.turn().await.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn displaced_normal_head_yields_its_lease_attempt_until_high_priority_waiters_leave() {
|
||||
block_on(async {
|
||||
let queue = ConcurrencyWaitQueue::default();
|
||||
let policy = ConcurrencyQueuePolicy {
|
||||
max_waiting: 2,
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let deadline = SystemTime::now() + Duration::from_secs(2);
|
||||
let normal_budget = ConcurrencyWaitBudget::default();
|
||||
let high_budget = ConcurrencyWaitBudget::default();
|
||||
let mut normal = CapacityWait::new(&queue, policy, deadline, &normal_budget);
|
||||
normal.wait(&["account"]).await.unwrap();
|
||||
assert!(normal.can_try(&"account"));
|
||||
|
||||
// 普通队首已经开始重读容量,尚未取得租约时被高优先级请求插队。
|
||||
let mut high = CapacityWait::new(&queue, policy, deadline, &high_budget)
|
||||
.with_priority(WaitPriority::High);
|
||||
high.wait(&["account"]).await.unwrap();
|
||||
assert!(!normal.can_try(&"account"));
|
||||
assert!(high.can_try(&"account"));
|
||||
assert!(normal.wait(&["account"]).now_or_never().is_none());
|
||||
|
||||
// 审批取消也必须恢复原队首的资格,不能丢失其等待位置。
|
||||
drop(high);
|
||||
normal.wait(&["account"]).await.unwrap();
|
||||
assert!(normal.can_try(&"account"));
|
||||
drop(normal);
|
||||
assert!(!queue.has_waiters(&"account"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_priority_waiters_still_require_queueing_to_be_enabled() {
|
||||
block_on(async {
|
||||
let queue = ConcurrencyWaitQueue::<&str>::default();
|
||||
let policy = ConcurrencyQueuePolicy {
|
||||
max_waiting: 0,
|
||||
timeout: Duration::from_secs(2),
|
||||
};
|
||||
let budget = ConcurrencyWaitBudget::default();
|
||||
let mut waiting = CapacityWait::new(
|
||||
&queue,
|
||||
policy,
|
||||
SystemTime::now() + Duration::from_secs(2),
|
||||
&budget,
|
||||
)
|
||||
.with_priority(WaitPriority::High);
|
||||
assert_eq!(waiting.wait(&["a"]).await, Err(QueueRejection::Full));
|
||||
assert!(!queue.has_waiters(&"a"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1921,6 +1921,7 @@ impl Provider for PolicySelectingProvider {
|
||||
round_robin_cursor: 0,
|
||||
eligibility: AccountEligibilityPolicy::Enforce,
|
||||
account_scope: attempt.account_scope().cloned(),
|
||||
reserved_concurrency: 0,
|
||||
};
|
||||
match attempt
|
||||
.select_account(
|
||||
|
||||
@@ -116,6 +116,7 @@ fn context() -> AccountSelectionContext {
|
||||
round_robin_cursor: 0,
|
||||
eligibility: AccountEligibilityPolicy::Enforce,
|
||||
account_scope: None,
|
||||
reserved_concurrency: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,3 +16,5 @@ mod runtime;
|
||||
mod settings;
|
||||
mod task;
|
||||
mod upstream;
|
||||
|
||||
mod time;
|
||||
|
||||
@@ -364,7 +364,7 @@ fn compiler_accepts_unlimited_default_account_concurrency() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
|
||||
fn compiled_plans_keep_their_scheduling_config_after_a_new_snapshot_is_built() {
|
||||
use gateway_core::account::SmartSchedulingConfig;
|
||||
let configs = [
|
||||
SmartSchedulingConfig::default(),
|
||||
@@ -376,7 +376,8 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
|
||||
revision(index as u64 + 1),
|
||||
revision(index as u64 + 1),
|
||||
SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None)
|
||||
.with_smart_scheduling(config),
|
||||
.with_smart_scheduling(config)
|
||||
.with_openai_guardian_reserved_concurrency(index as u32),
|
||||
vec![],
|
||||
vec![],
|
||||
vec![SnapshotProviderAccountFacts::new(
|
||||
@@ -401,7 +402,12 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for (plan, config) in plans.iter().zip(configs) {
|
||||
for (index, (plan, config)) in plans.iter().zip(configs).enumerate() {
|
||||
assert_eq!(
|
||||
plan.account_selection_policy()
|
||||
.openai_guardian_reserved_concurrency(),
|
||||
index as u32
|
||||
);
|
||||
assert_eq!(plan.account_selection_policy().smart_scheduling(), config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
|
||||
fn utc(value: &str) -> DateTime<Utc> {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_boundaries_follow_offsets_and_dst_instead_of_fixed_hours() {
|
||||
for (zone, at, start, end, hours) in [
|
||||
(
|
||||
"Asia/Shanghai",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2025-12-31T16:00:00Z",
|
||||
"2026-01-01T16:00:00Z",
|
||||
24,
|
||||
),
|
||||
(
|
||||
"UTC",
|
||||
"2026-01-01T05:00:00Z",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2026-01-02T00:00:00Z",
|
||||
24,
|
||||
),
|
||||
(
|
||||
"Asia/Kathmandu",
|
||||
"2026-01-01T00:00:00Z",
|
||||
"2025-12-31T18:15:00Z",
|
||||
"2026-01-01T18:15:00Z",
|
||||
24,
|
||||
),
|
||||
(
|
||||
"America/New_York",
|
||||
"2026-03-08T16:00:00Z",
|
||||
"2026-03-08T05:00:00Z",
|
||||
"2026-03-09T04:00:00Z",
|
||||
23,
|
||||
),
|
||||
(
|
||||
"America/New_York",
|
||||
"2026-11-01T16:00:00Z",
|
||||
"2026-11-01T04:00:00Z",
|
||||
"2026-11-02T05:00:00Z",
|
||||
25,
|
||||
),
|
||||
] {
|
||||
let zone: DeploymentTimeZone = zone.parse().unwrap();
|
||||
let at = utc(at);
|
||||
assert_eq!(zone.day_start(at), Some(utc(start)));
|
||||
assert_eq!(zone.days_after(at, 1), Some(utc(end)));
|
||||
assert_eq!((utc(end) - utc(start)).num_hours(), hours);
|
||||
}
|
||||
let zone: DeploymentTimeZone = "America/New_York".parse().unwrap();
|
||||
let start = utc("2026-03-06T05:00:00Z");
|
||||
assert_eq!(zone.days_after(start, 7), Some(utc("2026-03-13T04:00:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_slots_skip_gaps_and_pick_only_the_first_fold_instant() {
|
||||
let zone: DeploymentTimeZone = "America/New_York".parse().unwrap();
|
||||
assert!(
|
||||
zone.resolve_local("2026-03-08T02:30:00".parse().unwrap())
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
zone.resolve_local("2026-11-01T01:30:00".parse().unwrap()),
|
||||
Some(utc("2026-11-01T05:30:00Z"))
|
||||
);
|
||||
let zone: DeploymentTimeZone = "America/Sao_Paulo".parse().unwrap();
|
||||
assert_eq!(
|
||||
zone.date_start(NaiveDate::from_ymd_opt(2018, 11, 4).unwrap()),
|
||||
Some(utc("2018-11-04T03:00:00Z"))
|
||||
);
|
||||
let zone: DeploymentTimeZone = "Pacific/Apia".parse().unwrap();
|
||||
assert_eq!(
|
||||
zone.date_start(NaiveDate::from_ymd_opt(2011, 12, 30).unwrap()),
|
||||
Some(utc("2011-12-30T10:00:00Z"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_timezone_rejects_invalid_names_and_types() {
|
||||
assert_eq!(DeploymentTimeZone::default().name(), "Asia/Shanghai");
|
||||
for value in [
|
||||
serde_json::json!(""),
|
||||
serde_json::json!("UTC+8"),
|
||||
serde_json::json!("Not/AZone"),
|
||||
serde_json::json!(null),
|
||||
serde_json::json!(8),
|
||||
] {
|
||||
assert!(serde_json::from_value::<DeploymentTimeZone>(value).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_boundaries_reject_unrepresentable_local_dates() {
|
||||
for (zone, value) in [
|
||||
("Asia/Shanghai", DateTime::<Utc>::MAX_UTC),
|
||||
("America/New_York", DateTime::<Utc>::MIN_UTC),
|
||||
] {
|
||||
let zone: DeploymentTimeZone = zone.parse().unwrap();
|
||||
assert_eq!(zone.day_start(value), None);
|
||||
assert_eq!(zone.days_before(value, 0), None);
|
||||
assert_eq!(zone.days_after(value, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ semver = "1.0.27"
|
||||
secrecy = "0.10.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_ignored = "0.1.14"
|
||||
serde_path_to_error = "0.1.20"
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
tar = "0.4.44"
|
||||
@@ -47,6 +48,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
hyper = { version = "=1.11.1", default-features = false, features = ["server"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread", "test-util"] }
|
||||
wiremock = "0.6.5"
|
||||
|
||||
@@ -35,16 +35,21 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
|
||||
.build()
|
||||
.map_err(|_| ConfigError::InvalidDocument { path: path.clone() })?;
|
||||
let mut unused = std::collections::BTreeSet::new();
|
||||
let mut value: T = serde_ignored::deserialize(document, |field| {
|
||||
let mut ignored = |field: serde_ignored::Path<'_>| {
|
||||
unused.insert(field.to_string());
|
||||
})
|
||||
.map_err(|error| match missing_config_field(&error) {
|
||||
Some(field) => ConfigError::MissingField {
|
||||
path: path.clone(),
|
||||
field,
|
||||
},
|
||||
None => ConfigError::InvalidDocument { path: path.clone() },
|
||||
})?;
|
||||
};
|
||||
let mut value: T =
|
||||
serde_path_to_error::deserialize(serde_ignored::Deserializer::new(document, &mut ignored))
|
||||
.map_err(|error| match missing_config_field(error.inner()) {
|
||||
Some(field) => ConfigError::MissingField {
|
||||
path: path.clone(),
|
||||
field,
|
||||
},
|
||||
None => ConfigError::InvalidValue {
|
||||
path: path.clone(),
|
||||
field: error.path().to_string(),
|
||||
},
|
||||
})?;
|
||||
// 此时日志尚未初始化;只报告字段路径,不回显可能包含凭据的配置值。
|
||||
for field in unused {
|
||||
if !T::EXTERNAL_SECTIONS.contains(&field.as_str()) {
|
||||
@@ -69,6 +74,8 @@ fn missing_config_field(error: &config::ConfigError) -> Option<String> {
|
||||
/// Host 唯一拥有的进程配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct HostConfig {
|
||||
#[serde(default)]
|
||||
pub timezone: gateway_core::time::DeploymentTimeZone,
|
||||
pub listen: ListenConfig,
|
||||
pub runtime_data_dir: PathBuf,
|
||||
pub logging: LoggingConfig,
|
||||
@@ -216,6 +223,8 @@ pub struct FileLoggingConfig {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("配置文件 {path} 的字段 {field:?} 不合法")]
|
||||
InvalidValue { path: PathBuf, field: String },
|
||||
#[error("current directory is unavailable")]
|
||||
CurrentDirectory,
|
||||
#[error("deploy/config.yaml was not found")]
|
||||
|
||||
@@ -56,7 +56,7 @@ pub async fn initialize(config: HostConfig) -> Result<HostBundle, HostError> {
|
||||
.official_plugins_dir()
|
||||
.map_err(|_| HostError::OfficialPluginRelease)?,
|
||||
));
|
||||
let log_guard = initialize_logging(&config.logging)?;
|
||||
let log_guard = initialize_logging(&config.logging, config.timezone)?;
|
||||
let cancellation = CancellationToken::new();
|
||||
let connections = Arc::new(ConnectionTracker::new(cancellation.clone()));
|
||||
let workers = WorkerSupervisor::new(cancellation.clone());
|
||||
|
||||
@@ -48,7 +48,10 @@ pub enum LogError {
|
||||
}
|
||||
|
||||
/// 初始化日志;文件按完整日期留存,不以分片数量淘汰窗口内记录。
|
||||
pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError> {
|
||||
pub fn initialize_logging(
|
||||
config: &LoggingConfig,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> Result<LogGuard, LogError> {
|
||||
let directive = env::var("RUST_LOG").unwrap_or_else(|_| config.level.clone());
|
||||
let file_filter = application_file_filter(&directive)?;
|
||||
let recovery_file_filter = oauth_recovery_file_filter();
|
||||
@@ -70,6 +73,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let file_writer = if config.file.enabled {
|
||||
let (writer, guard) = create_file_writer(
|
||||
config,
|
||||
timezone,
|
||||
config.file.retention_days,
|
||||
APPLICATION_LOG_FILE_PREFIX,
|
||||
"gateway-log-application-file",
|
||||
@@ -83,6 +87,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let recovery_file_writer = if config.oauth_recovery {
|
||||
let (writer, guard) = create_file_writer(
|
||||
config,
|
||||
timezone,
|
||||
config.file.retention_days,
|
||||
OAUTH_RECOVERY_LOG_FILE_PREFIX,
|
||||
"gateway-log-oauth-recovery-file",
|
||||
@@ -96,6 +101,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let request_dump_file_writer = if config.request_dump {
|
||||
let (writer, guard) = create_file_writer(
|
||||
config,
|
||||
timezone,
|
||||
config.request_dump_retention_days,
|
||||
REQUEST_DUMP_LOG_FILE_PREFIX,
|
||||
"gateway-log-request-dump-file",
|
||||
@@ -110,6 +116,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let stdout_layer = stdout_writer.map(|writer| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.compact()
|
||||
.with_timer(DeploymentClock(timezone))
|
||||
.with_writer(writer)
|
||||
.with_target(false)
|
||||
.with_ansi(false)
|
||||
@@ -118,6 +125,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let file_layer = file_writer.map(|writer| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(DeploymentClock(timezone))
|
||||
.with_writer(writer)
|
||||
.with_target(true)
|
||||
.with_file(true)
|
||||
@@ -131,6 +139,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let recovery_file_layer = recovery_file_writer.map(|writer| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(DeploymentClock(timezone))
|
||||
.with_writer(writer)
|
||||
.with_target(true)
|
||||
.with_file(true)
|
||||
@@ -144,6 +153,7 @@ pub fn initialize_logging(config: &LoggingConfig) -> Result<LogGuard, LogError>
|
||||
let request_dump_file_layer = request_dump_file_writer.map(|writer| {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.json()
|
||||
.with_timer(DeploymentClock(timezone))
|
||||
.with_writer(writer)
|
||||
.with_target(true)
|
||||
.with_file(true)
|
||||
@@ -209,6 +219,7 @@ impl LogGuard {
|
||||
|
||||
fn create_file_writer(
|
||||
config: &LoggingConfig,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
retention_days: usize,
|
||||
prefix: &'static str,
|
||||
thread_name: &'static str,
|
||||
@@ -225,6 +236,19 @@ fn create_file_writer(
|
||||
maximum_bytes,
|
||||
retention_days,
|
||||
Arc::clone(&health),
|
||||
timezone,
|
||||
)?;
|
||||
Ok(FileLogSink::spawn(writer, thread_name, health)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DeploymentClock(gateway_core::time::DeploymentTimeZone);
|
||||
|
||||
impl tracing_subscriber::fmt::time::FormatTime for DeploymentClock {
|
||||
fn format_time(
|
||||
&self,
|
||||
writer: &mut tracing_subscriber::fmt::format::Writer<'_>,
|
||||
) -> std::fmt::Result {
|
||||
write!(writer, "{}", self.0.local(chrono::Utc::now()).to_rfc3339())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 按大小轮转、压缩已关闭分片、按完整 UTC 日期组清理。
|
||||
//! 按大小轮转、压缩已关闭分片、按部署时区自然日期组清理。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
@@ -12,6 +12,7 @@ use flate2::{Compression, write::GzEncoder};
|
||||
use super::sink::LogHealth;
|
||||
|
||||
pub(super) struct RotatingLogWriter {
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
directory: PathBuf,
|
||||
prefix: &'static str,
|
||||
maximum_bytes: u64,
|
||||
@@ -30,10 +31,11 @@ impl RotatingLogWriter {
|
||||
maximum_bytes: u64,
|
||||
retention_days: usize,
|
||||
health: Arc<LogHealth>,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> io::Result<Self> {
|
||||
fs::create_dir_all(&directory)?;
|
||||
let date = Utc::now().date_naive();
|
||||
cleanup_log_files(&directory, prefix, date, retention_days)?;
|
||||
let date = timezone.local(Utc::now()).date_naive();
|
||||
cleanup_log_files(&directory, prefix, date, retention_days, timezone)?;
|
||||
let latest = managed_log_files(&directory, prefix)?
|
||||
.into_iter()
|
||||
.filter(|entry| entry.date == date)
|
||||
@@ -48,6 +50,7 @@ impl RotatingLogWriter {
|
||||
let path = directory.join(log_file_name(prefix, date, segment));
|
||||
let file = open_log_segment(&path)?;
|
||||
let writer = Self {
|
||||
timezone,
|
||||
directory,
|
||||
prefix,
|
||||
maximum_bytes,
|
||||
@@ -71,7 +74,7 @@ impl RotatingLogWriter {
|
||||
}
|
||||
|
||||
fn rotate_if_required(&mut self, incoming_bytes: usize) -> io::Result<()> {
|
||||
let date = Utc::now().date_naive();
|
||||
let date = self.timezone.local(Utc::now()).date_naive();
|
||||
let day_changed = date != self.date;
|
||||
let size_exceeded = self.bytes_written > 0
|
||||
&& self.bytes_written.saturating_add(incoming_bytes as u64) > self.maximum_bytes;
|
||||
@@ -105,9 +108,13 @@ impl RotatingLogWriter {
|
||||
self.health.maintenance_failed(error.kind());
|
||||
}
|
||||
// Maintenance errors must not discard the record that triggered rotation.
|
||||
if let Err(error) =
|
||||
cleanup_log_files(&self.directory, self.prefix, date, self.retention_days)
|
||||
{
|
||||
if let Err(error) = cleanup_log_files(
|
||||
&self.directory,
|
||||
self.prefix,
|
||||
date,
|
||||
self.retention_days,
|
||||
self.timezone,
|
||||
) {
|
||||
self.health.maintenance_failed(error.kind());
|
||||
}
|
||||
Ok(())
|
||||
@@ -197,6 +204,7 @@ fn cleanup_log_files(
|
||||
prefix: &str,
|
||||
today: NaiveDate,
|
||||
retention_days: usize,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
) -> io::Result<()> {
|
||||
let days =
|
||||
u64::try_from(retention_days).map_err(|_| io::Error::other("log retention overflow"))?;
|
||||
@@ -211,12 +219,11 @@ fn cleanup_log_files(
|
||||
if date >= cutoff {
|
||||
continue;
|
||||
}
|
||||
// Keep a whole date if any segment was written more recently than its filename.
|
||||
// This also protects archives recovered after clock corrections or manual restoration.
|
||||
// 用较近的文件名日期或实际写入日期保护整组,避免时区切换后提前清理。
|
||||
let mut latest = date;
|
||||
for path in &paths {
|
||||
let modified: chrono::DateTime<Utc> = path.metadata()?.modified()?.into();
|
||||
latest = latest.max(modified.date_naive());
|
||||
latest = latest.max(timezone.local(modified).date_naive());
|
||||
}
|
||||
if latest >= cutoff {
|
||||
continue;
|
||||
|
||||
@@ -263,18 +263,18 @@ impl HttpClient {
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|_| HttpError::invalid("URL"))?;
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(|| HttpError::invalid("host"))?
|
||||
.as_str();
|
||||
headers.insert(
|
||||
http::header::HOST,
|
||||
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
|
||||
);
|
||||
if url.scheme() == "http"
|
||||
&& let Some(proxy) = proxy.filter(|proxy| proxy.expose_url().starts_with("http"))
|
||||
{
|
||||
// 正向代理接收数字地址的绝对 URI;Host 保留原始域名,禁止代理再次解析目标。
|
||||
let authority = uri
|
||||
.authority()
|
||||
.ok_or_else(|| HttpError::invalid("host"))?
|
||||
.as_str();
|
||||
headers.insert(
|
||||
http::header::HOST,
|
||||
HeaderValue::from_str(authority).map_err(|_| HttpError::invalid("host"))?,
|
||||
);
|
||||
let address = addresses
|
||||
.first()
|
||||
.ok_or_else(|| HttpError::invalid("address"))?;
|
||||
|
||||
@@ -129,6 +129,7 @@ impl Downloads {
|
||||
continue;
|
||||
}
|
||||
if !(200..300).contains(&response.status) {
|
||||
tracing::warn!(status = response.status, "插件来源返回非成功状态");
|
||||
return Err(source_error());
|
||||
}
|
||||
if header(&response.headers, "content-length")
|
||||
|
||||
@@ -201,6 +201,7 @@ fn valid_config() -> HostConfig {
|
||||
..SystemUpdateConfig::default()
|
||||
};
|
||||
HostConfig {
|
||||
timezone: Default::default(),
|
||||
listen: ListenConfig {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 8080,
|
||||
|
||||
@@ -10,6 +10,7 @@ use gateway_host::system_update::SystemUpdateConfig;
|
||||
mod sink;
|
||||
mod writer;
|
||||
|
||||
const LOG_TIMEZONE_ENV: &str = "CPR_LOGGING_TEST_TIMEZONE";
|
||||
const LOG_DIRECTORY_ENV: &str = "CPR_LOGGING_TEST_DIRECTORY";
|
||||
const CHILD_PROCESS_ENV: &str = "CPR_LOGGING_TEST_CHILD";
|
||||
const REQUEST_DUMP_ENABLED_ENV: &str = "CPR_LOGGING_TEST_REQUEST_DUMP_ENABLED";
|
||||
@@ -28,6 +29,7 @@ const REQUEST_DUMP_SECRET: &str = "unredacted-authorization-value";
|
||||
#[test]
|
||||
fn logging_requires_at_least_one_sink() {
|
||||
let mut config = HostConfig {
|
||||
timezone: Default::default(),
|
||||
listen: ListenConfig {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 8080,
|
||||
@@ -302,6 +304,10 @@ fn with_logging(config: HostConfig, write: impl FnOnce()) {
|
||||
|
||||
fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig {
|
||||
HostConfig {
|
||||
timezone: env::var(LOG_TIMEZONE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.parse().unwrap())
|
||||
.unwrap_or_default(),
|
||||
listen: ListenConfig {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 8080,
|
||||
@@ -373,3 +379,49 @@ fn log_file_set_exists(directory: &Path, file_prefix: &str) -> bool {
|
||||
fn json_target_field(target: &str) -> String {
|
||||
format!(r#""target":"{target}""#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deployment_timezone_controls_log_timestamp_and_file_date() {
|
||||
for name in ["Asia/Shanghai", "UTC", "America/New_York", "Asia/Kathmandu"] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let output = Command::new(env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"logging::sensitive_file_logging_is_separate_and_overrides_global_log_level",
|
||||
])
|
||||
.env(CHILD_PROCESS_ENV, "1")
|
||||
.env(LOG_DIRECTORY_ENV, directory.path())
|
||||
.env(LOG_TIMEZONE_ENV, name)
|
||||
.env(REQUEST_DUMP_ENABLED_ENV, "true")
|
||||
.env("RUST_LOG", "off,logging_test_application=info")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let timezone: gateway_core::time::DeploymentTimeZone = name.parse().unwrap();
|
||||
for prefix in [
|
||||
APPLICATION_LOG_FILE_PREFIX,
|
||||
OAUTH_RECOVERY_LOG_FILE_PREFIX,
|
||||
REQUEST_DUMP_LOG_FILE_PREFIX,
|
||||
] {
|
||||
let body = read_log_file_set(directory.path(), prefix);
|
||||
let record: serde_json::Value =
|
||||
serde_json::from_str(body.lines().next().unwrap()).unwrap();
|
||||
let timestamp =
|
||||
chrono::DateTime::parse_from_rfc3339(record["timestamp"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timestamp.to_rfc3339(),
|
||||
timezone.local(timestamp.to_utc()).to_rfc3339()
|
||||
);
|
||||
let path = directory.path().join(format!(
|
||||
"{prefix}{}.log",
|
||||
timezone.local(timestamp.to_utc()).date_naive()
|
||||
));
|
||||
assert!(path.exists(), "{name}: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ fn write_failure_marks_log_completeness_unhealthy_even_after_writes_recover() {
|
||||
assert_eq!(runtime.block_on(health.check()), HealthState::Healthy);
|
||||
let blocked = directory.join(format!(
|
||||
"{REQUEST_DUMP_LOG_FILE_PREFIX}{}.1.log",
|
||||
chrono::Utc::now().date_naive()
|
||||
gateway_core::time::DeploymentTimeZone::default()
|
||||
.local(chrono::Utc::now())
|
||||
.date_naive()
|
||||
));
|
||||
fs::create_dir(&blocked).unwrap();
|
||||
let payload = "x".repeat(1024 * 1024);
|
||||
|
||||
@@ -22,7 +22,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let today = gateway_core::time::DeploymentTimeZone::default()
|
||||
.local(chrono::Utc::now())
|
||||
.date_naive();
|
||||
let mut retained = Vec::new();
|
||||
let mut expired = Vec::new();
|
||||
for (prefix, days) in [
|
||||
@@ -42,7 +44,7 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
|
||||
let path = directory.path().join(format!("{prefix}{old_date}.log"));
|
||||
seed_log(&path, old_date, "expired date\n");
|
||||
expired.push(path);
|
||||
// If any old segment was recently written, protect its whole UTC date.
|
||||
// 时区切换或恢复的文件按较近写入日期保护整个分段组。
|
||||
let restored_date = old_date - chrono::Days::new(1);
|
||||
for segment in 0..=1 {
|
||||
let path = directory
|
||||
@@ -139,10 +141,9 @@ fn retention_preserves_complete_dates_across_compression_rotation_and_restart()
|
||||
|
||||
fn seed_log(path: &Path, date: chrono::NaiveDate, body: &str) {
|
||||
fs::write(path, body).unwrap();
|
||||
let modified = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(0, 0, 0).unwrap(),
|
||||
chrono::Utc,
|
||||
);
|
||||
let modified = gateway_core::time::DeploymentTimeZone::default()
|
||||
.date_start(date)
|
||||
.unwrap();
|
||||
fs::File::open(path)
|
||||
.unwrap()
|
||||
.set_modified(SystemTime::from(modified))
|
||||
|
||||
@@ -17,6 +17,37 @@ fn local_network() -> NetworkPolicy {
|
||||
NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_https_http2_uses_authority_without_an_extra_host_header() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::support::network::{FixedDns, Mode, ROOT, Server};
|
||||
|
||||
let mut server = Server::start(Mode::Http2Origin, true, "127.0.0.1:0").await;
|
||||
let url = format!("https://upstream.test:{}/query", server.address.port());
|
||||
let client = HttpClient::with_root_certificates(vec![ROOT.to_vec()])
|
||||
.unwrap()
|
||||
.with_resolver(Arc::new(FixedDns(server.address.ip())));
|
||||
let mut response = client
|
||||
.open(
|
||||
request(url.clone()),
|
||||
None,
|
||||
&local_network(),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body.read(64).await.unwrap().unwrap(), "secured");
|
||||
let observation = server.next().await;
|
||||
assert!(
|
||||
observation
|
||||
.head
|
||||
.starts_with(&format!("GET {url} HTTP/2.0\r\n"))
|
||||
);
|
||||
assert_eq!(observation.sni.as_deref(), Some("upstream.test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_http_does_not_follow_redirects_or_fall_back_from_proxy() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user