feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层

- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate
- aether-data 扩展 repository 层:announcements、auth_modules、billing、
  candidate_selection、gemini_file_mappings、global_models、management_tokens、
  oauth_providers、proxy_nodes、quota、users、wallet 等模块
- aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/
  video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块
- 重构 executor decision 和 gateway state 为模块目录结构
- 新增 gateway router、frontdoor 路由层及对应测试
- Python 侧 API 路由重构,新增 compat/support 模块
- 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
fawney19
2026-03-31 19:19:04 +08:00
parent b5a0070023
commit ddf18fed9a
690 changed files with 235087 additions and 16301 deletions

View File

@@ -0,0 +1,389 @@
use std::collections::BTreeMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use super::types::{
normalize_proxy_metadata, ProxyNodeHeartbeatMutation, ProxyNodeReadRepository,
ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
};
use crate::DataLayerError;
#[derive(Debug, Default)]
pub struct InMemoryProxyNodeRepository {
nodes: RwLock<BTreeMap<String, StoredProxyNode>>,
events: RwLock<Vec<StoredProxyNodeEvent>>,
}
impl InMemoryProxyNodeRepository {
pub fn seed<I>(nodes: I) -> Self
where
I: IntoIterator<Item = StoredProxyNode>,
{
Self {
nodes: RwLock::new(
nodes
.into_iter()
.map(|node| (node.id.clone(), node))
.collect(),
),
events: RwLock::new(Vec::new()),
}
}
pub fn seed_with_events<I, J>(nodes: I, events: J) -> Self
where
I: IntoIterator<Item = StoredProxyNode>,
J: IntoIterator<Item = StoredProxyNodeEvent>,
{
Self {
nodes: RwLock::new(
nodes
.into_iter()
.map(|node| (node.id.clone(), node))
.collect(),
),
events: RwLock::new(events.into_iter().collect()),
}
}
fn now_unix_secs() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
}
fn next_event_id(events: &[StoredProxyNodeEvent]) -> i64 {
events.iter().map(|event| event.id).max().unwrap_or(0) + 1
}
}
#[async_trait]
impl ProxyNodeReadRepository for InMemoryProxyNodeRepository {
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, DataLayerError> {
let nodes = self.nodes.read().expect("proxy node repository lock");
let mut items = nodes.values().cloned().collect::<Vec<_>>();
items.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
Ok(items)
}
async fn find_proxy_node(
&self,
node_id: &str,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let nodes = self.nodes.read().expect("proxy node repository lock");
Ok(nodes.get(node_id).cloned())
}
async fn list_proxy_node_events(
&self,
node_id: &str,
limit: usize,
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
let events = self.events.read().expect("proxy node repository lock");
let mut items = events
.iter()
.filter(|event| event.node_id == node_id)
.cloned()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.created_at_unix_secs
.unwrap_or(0)
.cmp(&left.created_at_unix_secs.unwrap_or(0))
.then(right.id.cmp(&left.id))
});
items.truncate(limit);
Ok(items)
}
}
#[async_trait]
impl ProxyNodeWriteRepository for InMemoryProxyNodeRepository {
async fn apply_heartbeat(
&self,
mutation: &ProxyNodeHeartbeatMutation,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let mut nodes = self.nodes.write().expect("proxy node repository lock");
let Some(node) = nodes.get_mut(&mutation.node_id) else {
return Ok(None);
};
if !node.tunnel_mode {
return Err(DataLayerError::InvalidInput(
"non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode"
.to_string(),
));
}
let now = Self::now_unix_secs();
node.last_heartbeat_at_unix_secs = now;
if node.status != "online" || !node.tunnel_connected {
node.status = "online".to_string();
node.tunnel_connected = true;
node.tunnel_connected_at_unix_secs = now;
node.updated_at_unix_secs = now;
}
if let Some(value) = mutation.heartbeat_interval {
node.heartbeat_interval = value;
}
if let Some(value) = mutation.active_connections {
node.active_connections = value;
}
if let Some(value) = mutation.avg_latency_ms {
node.avg_latency_ms = Some(value);
}
let normalized_proxy_metadata = normalize_proxy_metadata(
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
);
if let Some(value) = normalized_proxy_metadata {
node.proxy_metadata = Some(value);
}
if let Some(value) = mutation.total_requests_delta.filter(|value| *value > 0) {
node.total_requests += value;
}
if let Some(value) = mutation.failed_requests_delta.filter(|value| *value > 0) {
node.failed_requests += value;
}
if let Some(value) = mutation.dns_failures_delta.filter(|value| *value > 0) {
node.dns_failures += value;
}
if let Some(value) = mutation.stream_errors_delta.filter(|value| *value > 0) {
node.stream_errors += value;
}
Ok(Some(node.clone()))
}
async fn update_tunnel_status(
&self,
mutation: &ProxyNodeTunnelStatusMutation,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let mut nodes = self.nodes.write().expect("proxy node repository lock");
let Some(node) = nodes.get_mut(&mutation.node_id) else {
return Ok(None);
};
let event_time = mutation
.observed_at_unix_secs
.or_else(Self::now_unix_secs)
.unwrap_or(0);
let event_type = if mutation.connected {
"connected"
} else {
"disconnected"
};
let event_detail = mutation.detail.clone().unwrap_or_else(|| {
format!(
"[hub_node_status] conn_count={}",
i32::max(mutation.conn_count, 0)
)
});
let mut events = self.events.write().expect("proxy node repository lock");
if let Some(last_transition) = node.tunnel_connected_at_unix_secs {
if event_time < last_transition {
let event_id = Self::next_event_id(&events);
events.push(StoredProxyNodeEvent {
id: event_id,
node_id: mutation.node_id.clone(),
event_type: event_type.to_string(),
detail: Some(format!("[stale_ignored] {event_detail}")),
created_at_unix_secs: Self::now_unix_secs(),
});
return Ok(Some(node.clone()));
}
}
node.tunnel_connected = mutation.connected;
node.tunnel_connected_at_unix_secs = Some(event_time);
node.status = if mutation.connected {
"online".to_string()
} else {
"offline".to_string()
};
node.updated_at_unix_secs = Some(event_time);
let event_id = Self::next_event_id(&events);
events.push(StoredProxyNodeEvent {
id: event_id,
node_id: mutation.node_id.clone(),
event_type: event_type.to_string(),
detail: Some(event_detail),
created_at_unix_secs: Some(event_time),
});
Ok(Some(node.clone()))
}
}
#[cfg(test)]
mod tests {
use super::InMemoryProxyNodeRepository;
use crate::repository::proxy_nodes::{
ProxyNodeHeartbeatMutation, ProxyNodeReadRepository, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
};
use serde_json::json;
fn sample_node() -> StoredProxyNode {
StoredProxyNode::new(
"node-1".to_string(),
"proxy-1".to_string(),
"127.0.0.1".to_string(),
0,
false,
"offline".to_string(),
30,
0,
0,
0,
0,
0,
true,
false,
2,
)
.expect("node should build")
.with_runtime_fields(
Some("test".to_string()),
None,
None,
None,
None,
None,
None,
None,
Some(json!({"allowed_ports": [443]})),
Some(1_700_000_000),
Some(1_700_000_001),
)
}
#[tokio::test]
async fn applies_heartbeat_and_tunnel_status_mutations() {
let repository = InMemoryProxyNodeRepository::seed(vec![sample_node()]);
let heartbeat = repository
.apply_heartbeat(&ProxyNodeHeartbeatMutation {
node_id: "node-1".to_string(),
heartbeat_interval: Some(45),
active_connections: Some(5),
total_requests_delta: Some(8),
avg_latency_ms: Some(12.5),
failed_requests_delta: Some(2),
dns_failures_delta: Some(1),
stream_errors_delta: Some(3),
proxy_metadata: Some(json!({"arch": "arm64"})),
proxy_version: Some("1.2.3".to_string()),
})
.await
.expect("heartbeat should succeed")
.expect("node should exist");
assert_eq!(heartbeat.status, "online");
assert_eq!(heartbeat.heartbeat_interval, 45);
assert_eq!(heartbeat.active_connections, 5);
assert_eq!(heartbeat.total_requests, 8);
assert_eq!(heartbeat.failed_requests, 2);
assert_eq!(heartbeat.dns_failures, 1);
assert_eq!(heartbeat.stream_errors, 3);
assert_eq!(
heartbeat
.proxy_metadata
.as_ref()
.and_then(|value| value.get("version"))
.and_then(|value| value.as_str()),
Some("1.2.3")
);
let stale = repository
.update_tunnel_status(&ProxyNodeTunnelStatusMutation {
node_id: "node-1".to_string(),
connected: false,
conn_count: 0,
detail: None,
observed_at_unix_secs: Some(1),
})
.await
.expect("status update should succeed")
.expect("node should exist");
assert_eq!(stale.status, "online");
let stale_events = repository
.list_proxy_node_events("node-1", 10)
.await
.expect("list events should succeed");
assert_eq!(stale_events.len(), 1);
assert_eq!(stale_events[0].event_type, "disconnected");
assert_eq!(
stale_events[0].detail.as_deref(),
Some("[stale_ignored] [hub_node_status] conn_count=0")
);
let updated = repository
.update_tunnel_status(&ProxyNodeTunnelStatusMutation {
node_id: "node-1".to_string(),
connected: false,
conn_count: 0,
detail: None,
observed_at_unix_secs: Some(1_800_000_000),
})
.await
.expect("status update should succeed")
.expect("node should exist");
assert_eq!(updated.status, "offline");
assert!(!updated.tunnel_connected);
let events = repository
.list_proxy_node_events("node-1", 10)
.await
.expect("list events should succeed");
assert_eq!(events.len(), 2);
assert_eq!(events[0].event_type, "disconnected");
assert_eq!(events[0].created_at_unix_secs, Some(1_800_000_000));
assert_eq!(
events[0].detail.as_deref(),
Some("[hub_node_status] conn_count=0")
);
let found = repository
.find_proxy_node("node-1")
.await
.expect("find should succeed")
.expect("node should exist");
assert_eq!(found.status, "offline");
}
#[tokio::test]
async fn lists_seeded_proxy_node_events_in_descending_order() {
let repository = InMemoryProxyNodeRepository::seed_with_events(
vec![sample_node()],
vec![
StoredProxyNodeEvent {
id: 1,
node_id: "node-1".to_string(),
event_type: "connected".to_string(),
detail: Some("older".to_string()),
created_at_unix_secs: Some(1_710_000_000),
},
StoredProxyNodeEvent {
id: 2,
node_id: "node-1".to_string(),
event_type: "disconnected".to_string(),
detail: Some("newer".to_string()),
created_at_unix_secs: Some(1_710_000_100),
},
],
);
let events = repository
.list_proxy_node_events("node-1", 1)
.await
.expect("list events should succeed");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, 2);
assert_eq!(events[0].detail.as_deref(), Some("newer"));
}
}

View File

@@ -0,0 +1,10 @@
mod memory;
mod sql;
mod types;
pub use memory::InMemoryProxyNodeRepository;
pub use sql::SqlxProxyNodeRepository;
pub use types::{
ProxyNodeHeartbeatMutation, ProxyNodeReadRepository, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
};

View File

@@ -0,0 +1,363 @@
use async_trait::async_trait;
use sqlx::{postgres::PgRow, PgPool, Row};
use super::types::{
normalize_proxy_metadata, ProxyNodeHeartbeatMutation, ProxyNodeReadRepository,
ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
};
use crate::DataLayerError;
const FIND_PROXY_NODE_SQL: &str = r#"
SELECT
id,
name,
ip,
port,
region,
is_manual,
proxy_url,
proxy_username,
proxy_password,
CAST(status AS TEXT) AS status,
registered_by,
EXTRACT(EPOCH FROM last_heartbeat_at)::bigint AS last_heartbeat_at_unix_secs,
heartbeat_interval,
active_connections,
total_requests,
CAST(avg_latency_ms AS DOUBLE PRECISION) AS avg_latency_ms,
failed_requests,
dns_failures,
stream_errors,
proxy_metadata,
hardware_info,
estimated_max_concurrency,
tunnel_mode,
tunnel_connected,
EXTRACT(EPOCH FROM tunnel_connected_at)::bigint AS tunnel_connected_at_unix_secs,
remote_config,
config_version,
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
FROM proxy_nodes
WHERE id = $1
LIMIT 1
"#;
const LIST_PROXY_NODES_SQL: &str = r#"
SELECT
id,
name,
ip,
port,
region,
is_manual,
proxy_url,
proxy_username,
proxy_password,
CAST(status AS TEXT) AS status,
registered_by,
EXTRACT(EPOCH FROM last_heartbeat_at)::bigint AS last_heartbeat_at_unix_secs,
heartbeat_interval,
active_connections,
total_requests,
CAST(avg_latency_ms AS DOUBLE PRECISION) AS avg_latency_ms,
failed_requests,
dns_failures,
stream_errors,
proxy_metadata,
hardware_info,
estimated_max_concurrency,
tunnel_mode,
tunnel_connected,
EXTRACT(EPOCH FROM tunnel_connected_at)::bigint AS tunnel_connected_at_unix_secs,
remote_config,
config_version,
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
FROM proxy_nodes
ORDER BY name ASC, id ASC
"#;
const LIST_PROXY_NODE_EVENTS_SQL: &str = r#"
SELECT
id,
node_id,
CAST(event_type AS TEXT) AS event_type,
detail,
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs
FROM proxy_node_events
WHERE node_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2
"#;
const APPLY_HEARTBEAT_SQL: &str = r#"
UPDATE proxy_nodes
SET
last_heartbeat_at = NOW(),
status = CASE
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
THEN 'online'::proxynodestatus
ELSE status
END,
tunnel_connected = CASE
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
THEN TRUE
ELSE tunnel_connected
END,
tunnel_connected_at = CASE
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
THEN NOW()
ELSE tunnel_connected_at
END,
updated_at = CASE
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
THEN NOW()
ELSE updated_at
END,
heartbeat_interval = COALESCE($2, heartbeat_interval),
active_connections = COALESCE($3, active_connections),
avg_latency_ms = COALESCE($4, avg_latency_ms),
proxy_metadata = COALESCE($5, proxy_metadata),
total_requests = total_requests + GREATEST(COALESCE($6, 0), 0),
failed_requests = failed_requests + GREATEST(COALESCE($7, 0), 0),
dns_failures = dns_failures + GREATEST(COALESCE($8, 0), 0),
stream_errors = stream_errors + GREATEST(COALESCE($9, 0), 0)
WHERE id = $1
"#;
#[derive(Debug, Clone)]
pub struct SqlxProxyNodeRepository {
pool: PgPool,
}
impl SqlxProxyNodeRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn optional_unix_secs(value: Option<i64>) -> Option<u64> {
value.and_then(|value| u64::try_from(value).ok())
}
fn row_to_stored(row: &PgRow) -> Result<StoredProxyNode, DataLayerError> {
Ok(StoredProxyNode::new(
row.try_get("id")?,
row.try_get("name")?,
row.try_get("ip")?,
row.try_get("port")?,
row.try_get("is_manual")?,
row.try_get("status")?,
row.try_get("heartbeat_interval")?,
row.try_get("active_connections")?,
row.try_get("total_requests")?,
row.try_get("failed_requests")?,
row.try_get("dns_failures")?,
row.try_get("stream_errors")?,
row.try_get("tunnel_mode")?,
row.try_get("tunnel_connected")?,
row.try_get("config_version")?,
)?
.with_manual_proxy_fields(
row.try_get("proxy_url")?,
row.try_get("proxy_username")?,
row.try_get("proxy_password")?,
)
.with_runtime_fields(
row.try_get("region")?,
row.try_get("registered_by")?,
Self::optional_unix_secs(row.try_get("last_heartbeat_at_unix_secs")?),
row.try_get("avg_latency_ms")?,
row.try_get("proxy_metadata")?,
row.try_get("hardware_info")?,
row.try_get("estimated_max_concurrency")?,
Self::optional_unix_secs(row.try_get("tunnel_connected_at_unix_secs")?),
row.try_get("remote_config")?,
Self::optional_unix_secs(row.try_get("created_at_unix_secs")?),
Self::optional_unix_secs(row.try_get("updated_at_unix_secs")?),
))
}
fn row_to_event(row: &PgRow) -> Result<StoredProxyNodeEvent, DataLayerError> {
Ok(StoredProxyNodeEvent {
id: row.try_get("id")?,
node_id: row.try_get("node_id")?,
event_type: row.try_get("event_type")?,
detail: row.try_get("detail")?,
created_at_unix_secs: Self::optional_unix_secs(row.try_get("created_at_unix_secs")?),
})
}
}
#[async_trait]
impl ProxyNodeReadRepository for SqlxProxyNodeRepository {
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, DataLayerError> {
let rows = sqlx::query(LIST_PROXY_NODES_SQL)
.fetch_all(&self.pool)
.await?;
rows.iter().map(Self::row_to_stored).collect()
}
async fn find_proxy_node(
&self,
node_id: &str,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let row = sqlx::query(FIND_PROXY_NODE_SQL)
.bind(node_id)
.fetch_optional(&self.pool)
.await?;
row.map(|row| Self::row_to_stored(&row)).transpose()
}
async fn list_proxy_node_events(
&self,
node_id: &str,
limit: usize,
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
let rows = sqlx::query(LIST_PROXY_NODE_EVENTS_SQL)
.bind(node_id)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await?;
rows.iter().map(Self::row_to_event).collect()
}
}
#[async_trait]
impl ProxyNodeWriteRepository for SqlxProxyNodeRepository {
async fn apply_heartbeat(
&self,
mutation: &ProxyNodeHeartbeatMutation,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let existing = self.find_proxy_node(&mutation.node_id).await?;
let Some(existing) = existing else {
return Ok(None);
};
if !existing.tunnel_mode {
return Err(DataLayerError::InvalidInput(
"non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode"
.to_string(),
));
}
let normalized_proxy_metadata = normalize_proxy_metadata(
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
);
sqlx::query(APPLY_HEARTBEAT_SQL)
.bind(&mutation.node_id)
.bind(mutation.heartbeat_interval)
.bind(mutation.active_connections)
.bind(mutation.avg_latency_ms)
.bind(normalized_proxy_metadata)
.bind(mutation.total_requests_delta)
.bind(mutation.failed_requests_delta)
.bind(mutation.dns_failures_delta)
.bind(mutation.stream_errors_delta)
.execute(&self.pool)
.await?;
self.find_proxy_node(&mutation.node_id).await
}
async fn update_tunnel_status(
&self,
mutation: &ProxyNodeTunnelStatusMutation,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let existing = self.find_proxy_node(&mutation.node_id).await?;
let Some(existing) = existing else {
return Ok(None);
};
let observed_at_unix_secs = mutation.observed_at_unix_secs;
let event_type = if mutation.connected {
"connected"
} else {
"disconnected"
};
let event_detail = mutation.detail.clone().unwrap_or_else(|| {
format!(
"[hub_node_status] conn_count={}",
i32::max(mutation.conn_count, 0)
)
});
let mut tx = self.pool.begin().await?;
if existing
.tunnel_connected_at_unix_secs
.zip(observed_at_unix_secs)
.is_some_and(|(last_transition, observed_at)| observed_at < last_transition)
{
sqlx::query(
r#"
INSERT INTO proxy_node_events (node_id, event_type, detail, created_at)
VALUES (
$1,
$2,
$3,
NOW()
)
"#,
)
.bind(&mutation.node_id)
.bind(event_type)
.bind(format!("[stale_ignored] {event_detail}"))
.execute(&mut *tx)
.await?;
tx.commit().await?;
return self.find_proxy_node(&mutation.node_id).await;
}
sqlx::query(
r#"
UPDATE proxy_nodes
SET
tunnel_connected = $2,
tunnel_connected_at = CASE
WHEN $3::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($3::double precision)
END,
status = CASE
WHEN $2 THEN 'online'::proxynodestatus
ELSE 'offline'::proxynodestatus
END,
updated_at = CASE
WHEN $3::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($3::double precision)
END
WHERE id = $1
"#,
)
.bind(&mutation.node_id)
.bind(mutation.connected)
.bind(observed_at_unix_secs.map(|value| value as f64))
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO proxy_node_events (node_id, event_type, detail, created_at)
VALUES (
$1,
$2,
$3,
CASE
WHEN $4::double precision IS NULL THEN NOW()
ELSE TO_TIMESTAMP($4::double precision)
END
)
"#,
)
.bind(&mutation.node_id)
.bind(event_type)
.bind(event_detail)
.bind(observed_at_unix_secs.map(|value| value as f64))
.execute(&mut *tx)
.await?;
tx.commit().await?;
self.find_proxy_node(&mutation.node_id).await
}
}

View File

@@ -0,0 +1,244 @@
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProxyNode {
pub id: String,
pub name: String,
pub ip: String,
pub port: i32,
pub region: Option<String>,
pub is_manual: bool,
pub proxy_url: Option<String>,
pub proxy_username: Option<String>,
pub proxy_password: Option<String>,
pub status: String,
pub registered_by: Option<String>,
pub last_heartbeat_at_unix_secs: Option<u64>,
pub heartbeat_interval: i32,
pub active_connections: i32,
pub total_requests: i64,
pub avg_latency_ms: Option<f64>,
pub failed_requests: i64,
pub dns_failures: i64,
pub stream_errors: i64,
pub proxy_metadata: Option<serde_json::Value>,
pub hardware_info: Option<serde_json::Value>,
pub estimated_max_concurrency: Option<i32>,
pub tunnel_mode: bool,
pub tunnel_connected: bool,
pub tunnel_connected_at_unix_secs: Option<u64>,
pub remote_config: Option<serde_json::Value>,
pub config_version: i32,
pub created_at_unix_secs: Option<u64>,
pub updated_at_unix_secs: Option<u64>,
}
impl StoredProxyNode {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
name: String,
ip: String,
port: i32,
is_manual: bool,
status: String,
heartbeat_interval: i32,
active_connections: i32,
total_requests: i64,
failed_requests: i64,
dns_failures: i64,
stream_errors: i64,
tunnel_mode: bool,
tunnel_connected: bool,
config_version: i32,
) -> Result<Self, crate::DataLayerError> {
if id.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"proxy_nodes.id is empty".to_string(),
));
}
if name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"proxy_nodes.name is empty".to_string(),
));
}
if ip.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"proxy_nodes.ip is empty".to_string(),
));
}
if status.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"proxy_nodes.status is empty".to_string(),
));
}
Ok(Self {
id,
name,
ip,
port,
region: None,
is_manual,
proxy_url: None,
proxy_username: None,
proxy_password: None,
status,
registered_by: None,
last_heartbeat_at_unix_secs: None,
heartbeat_interval,
active_connections,
total_requests,
avg_latency_ms: None,
failed_requests,
dns_failures,
stream_errors,
proxy_metadata: None,
hardware_info: None,
estimated_max_concurrency: None,
tunnel_mode,
tunnel_connected,
tunnel_connected_at_unix_secs: None,
remote_config: None,
config_version,
created_at_unix_secs: None,
updated_at_unix_secs: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_runtime_fields(
mut self,
region: Option<String>,
registered_by: Option<String>,
last_heartbeat_at_unix_secs: Option<u64>,
avg_latency_ms: Option<f64>,
proxy_metadata: Option<serde_json::Value>,
hardware_info: Option<serde_json::Value>,
estimated_max_concurrency: Option<i32>,
tunnel_connected_at_unix_secs: Option<u64>,
remote_config: Option<serde_json::Value>,
created_at_unix_secs: Option<u64>,
updated_at_unix_secs: Option<u64>,
) -> Self {
self.region = region;
self.registered_by = registered_by;
self.last_heartbeat_at_unix_secs = last_heartbeat_at_unix_secs;
self.avg_latency_ms = avg_latency_ms;
self.proxy_metadata = proxy_metadata;
self.hardware_info = hardware_info;
self.estimated_max_concurrency = estimated_max_concurrency;
self.tunnel_connected_at_unix_secs = tunnel_connected_at_unix_secs;
self.remote_config = remote_config;
self.created_at_unix_secs = created_at_unix_secs;
self.updated_at_unix_secs = updated_at_unix_secs;
self
}
pub fn with_manual_proxy_fields(
mut self,
proxy_url: Option<String>,
proxy_username: Option<String>,
proxy_password: Option<String>,
) -> Self {
self.proxy_url = proxy_url;
self.proxy_username = proxy_username;
self.proxy_password = proxy_password;
self
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProxyNodeHeartbeatMutation {
pub node_id: String,
pub heartbeat_interval: Option<i32>,
pub active_connections: Option<i32>,
pub total_requests_delta: Option<i64>,
pub avg_latency_ms: Option<f64>,
pub failed_requests_delta: Option<i64>,
pub dns_failures_delta: Option<i64>,
pub stream_errors_delta: Option<i64>,
pub proxy_metadata: Option<serde_json::Value>,
pub proxy_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProxyNodeTunnelStatusMutation {
pub node_id: String,
pub connected: bool,
pub conn_count: i32,
pub detail: Option<String>,
pub observed_at_unix_secs: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredProxyNodeEvent {
pub id: i64,
pub node_id: String,
pub event_type: String,
pub detail: Option<String>,
pub created_at_unix_secs: Option<u64>,
}
pub fn normalize_proxy_metadata(
proxy_metadata: Option<&serde_json::Value>,
proxy_version: Option<&str>,
) -> Option<serde_json::Value> {
let mut normalized = match proxy_metadata {
Some(serde_json::Value::Object(map)) => map.clone(),
Some(_) | None => serde_json::Map::new(),
};
let raw_version = normalized
.remove("version")
.and_then(|value| value.as_str().map(str::to_string));
let version = proxy_version
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(20).collect::<String>())
.or_else(|| {
raw_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(20).collect::<String>())
});
if let Some(version) = version {
normalized.insert("version".to_string(), serde_json::Value::String(version));
}
if normalized.is_empty() {
None
} else {
Some(serde_json::Value::Object(normalized))
}
}
#[async_trait]
pub trait ProxyNodeReadRepository: Send + Sync {
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, crate::DataLayerError>;
async fn find_proxy_node(
&self,
node_id: &str,
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
async fn list_proxy_node_events(
&self,
node_id: &str,
limit: usize,
) -> Result<Vec<StoredProxyNodeEvent>, crate::DataLayerError>;
}
#[async_trait]
pub trait ProxyNodeWriteRepository: Send + Sync {
async fn apply_heartbeat(
&self,
mutation: &ProxyNodeHeartbeatMutation,
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
async fn update_tunnel_status(
&self,
mutation: &ProxyNodeTunnelStatusMutation,
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
}