refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::downstream_api_key::DownstreamApiKey;
|
||||
|
||||
/// Result of downstream token authorization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DownstreamAuthResult {
|
||||
/// Found in downstream_api_keys table.
|
||||
Managed { key: DownstreamApiKey },
|
||||
/// Matched the global proxy token from config.
|
||||
Global { token: String },
|
||||
}
|
||||
|
||||
/// Authorize a downstream token (sk-xxx or global proxy token).
|
||||
///
|
||||
/// Looks up the token in:
|
||||
/// 1. downstream_api_keys table (for managed keys)
|
||||
/// 2. Falls back to checking against the global proxy token
|
||||
pub async fn authorize_downstream_token(
|
||||
token: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> Result<DownstreamAuthResult, String> {
|
||||
// Try managed keys first
|
||||
if token.starts_with("sk-") {
|
||||
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE key = ? AND enabled = 1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| format!("Database error: {e}"))?;
|
||||
|
||||
if let Some(k) = key {
|
||||
return Ok(DownstreamAuthResult::Managed { key: k });
|
||||
}
|
||||
|
||||
// Key not found in table
|
||||
return Err("Invalid API key".to_string());
|
||||
}
|
||||
|
||||
// Not an sk- key, can't be a downstream key
|
||||
Err("Invalid API key format".to_string())
|
||||
}
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash an API key using Argon2id with a random salt.
|
||||
pub fn hash_api_key(key: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(key.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash API key: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify an API key against an Argon2id hash.
|
||||
pub fn verify_api_key(key: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(key.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_api_key() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(verify_api_key(key, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = "sk-test-api-key-12345";
|
||||
let hash = hash_api_key(key).unwrap();
|
||||
assert!(!verify_api_key("wrong-key", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hash_fails() {
|
||||
let result = verify_api_key("some-key", "not-a-valid-hash");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_key() {
|
||||
let key = "sk-test-key";
|
||||
let hash1 = hash_api_key(key).unwrap();
|
||||
let hash2 = hash_api_key(key).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_api_key(key, &hash1).unwrap());
|
||||
assert!(verify_api_key(key, &hash2).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // user_id
|
||||
pub role: String, // "admin" | "user"
|
||||
pub exp: usize, // expiry timestamp
|
||||
pub iat: usize, // issued at
|
||||
}
|
||||
|
||||
pub fn generate_jwt(
|
||||
secret: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
expiry_hours: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let now = Utc::now();
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
role: role.to_string(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: (now + Duration::hours(expiry_hours)).timestamp() as usize,
|
||||
};
|
||||
Ok(encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn validate_jwt(secret: &str, token: &str) -> anyhow::Result<Claims> {
|
||||
let data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_validate_jwt() {
|
||||
let secret = "test-secret-key";
|
||||
let token = generate_jwt(secret, "user-123", "admin", 24).unwrap();
|
||||
let claims = validate_jwt(secret, &token).unwrap();
|
||||
assert_eq!(claims.sub, "user-123");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert!(claims.iat > 0);
|
||||
assert!(claims.exp > claims.iat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token_fails() {
|
||||
let secret = "test-secret-key";
|
||||
let result = validate_jwt(secret, "invalid-token");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_secret_fails() {
|
||||
let token = generate_jwt("secret1", "user-123", "user", 24).unwrap();
|
||||
let result = validate_jwt("secret2", &token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Auth module — JWT, API key, and password authentication
|
||||
|
||||
pub mod api_key;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
|
||||
/// Represents the authentication context for a request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthContext {
|
||||
/// Admin token-based authentication.
|
||||
Admin { token: String },
|
||||
/// Authenticated user session with JWT.
|
||||
User { user_id: String, role: String },
|
||||
/// Downstream API key authentication.
|
||||
DownstreamKey { key_id: i64, name: String },
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
/// Generate 16 random bytes for a salt using `uuid::Uuid::new_v4()`.
|
||||
fn random_salt_bytes() -> [u8; 16] {
|
||||
*uuid::Uuid::new_v4().as_bytes()
|
||||
}
|
||||
|
||||
/// Hash a user password using Argon2id with a random salt.
|
||||
pub fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let salt = SaltString::encode_b64(&random_salt_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to generate salt: {e}"))?;
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a password against an Argon2id hash.
|
||||
pub fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("Invalid hash format: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let password = "secure-password-123!";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let password = "correct-password";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(!verify_password("wrong-password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_password() {
|
||||
let password = "my-password";
|
||||
let hash1 = hash_password(password).unwrap();
|
||||
let hash2 = hash_password(password).unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_password(password, &hash1).unwrap());
|
||||
assert!(verify_password(password, &hash2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_password() {
|
||||
let password = "";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use figment::{
|
||||
providers::{Env, Format, Toml},
|
||||
Figment,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub db: DbConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub proxy: ProxyConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub log_level: String,
|
||||
pub cors_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DbConfig {
|
||||
pub url: String,
|
||||
pub max_connections: u32,
|
||||
pub ssl: bool,
|
||||
pub runtime_db_url: Option<String>,
|
||||
pub runtime_db_dialect: Option<String>,
|
||||
pub runtime_db_ssl: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub jwt_secret: String,
|
||||
pub jwt_expiry_hours: i64,
|
||||
pub auth_token: String,
|
||||
pub proxy_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProxyConfig {
|
||||
pub upstream_timeout_secs: u64,
|
||||
pub first_byte_timeout_secs: u64,
|
||||
pub max_retries: u32,
|
||||
pub proxy_log_retention_days: u32,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
Ok(Figment::new()
|
||||
.merge(Toml::file("config.toml").nested())
|
||||
.merge(Env::prefixed("BOOSAPI_").split("__"))
|
||||
.extract()?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
|
||||
|
||||
pub mod models;
|
||||
pub mod queries;
|
||||
|
||||
pub async fn create_pool(database_url: &str, max_connections: u32) -> anyhow::Result<SqlitePool> {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents an account bound to a site.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Account {
|
||||
pub id: i64,
|
||||
pub site_id: i64,
|
||||
pub username: Option<String>,
|
||||
pub access_token: String,
|
||||
pub api_token: Option<String>,
|
||||
pub balance: Option<f64>,
|
||||
pub balance_used: Option<f64>,
|
||||
pub quota: Option<f64>,
|
||||
pub unit_cost: Option<f64>,
|
||||
pub value_score: Option<f64>,
|
||||
pub status: String,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub last_checkin_at: Option<String>,
|
||||
pub last_balance_refresh: Option<String>,
|
||||
pub extra_config: Option<String>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub sort_order: Option<i64>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Represents an account token (from account_tokens table).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct AccountToken {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: String,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Background task for tracking async operations (deployments, syncs, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct BackgroundTask {
|
||||
pub id: String,
|
||||
pub task_type: String,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub dedupe_key: Option<String>,
|
||||
pub success_title: Option<String>,
|
||||
pub failure_title: Option<String>,
|
||||
pub notify_on_success: Option<bool>,
|
||||
pub notify_on_failure: Option<bool>,
|
||||
pub result_json: Option<String>,
|
||||
pub progress: Option<i64>,
|
||||
pub progress_total: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub started_at: Option<String>,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a downstream API key for external consumers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct DownstreamApiKey {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub used_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub used_requests: Option<i64>,
|
||||
pub supported_models: Option<String>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub site_weight_multipliers: Option<String>,
|
||||
pub excluded_site_ids: Option<String>,
|
||||
pub excluded_credential_refs: Option<String>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// System event for admin notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Event {
|
||||
pub id: i64,
|
||||
#[sqlx(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub title: String,
|
||||
pub message: Option<String>,
|
||||
pub level: Option<String>,
|
||||
#[sqlx(rename = "read")]
|
||||
pub is_read: Option<bool>,
|
||||
pub related_id: Option<i64>,
|
||||
pub related_type: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Database models
|
||||
|
||||
pub mod account;
|
||||
pub mod background_task;
|
||||
pub mod downstream_api_key;
|
||||
pub mod event;
|
||||
pub mod organization;
|
||||
pub mod route_channel;
|
||||
pub mod setting;
|
||||
pub mod site;
|
||||
pub mod site_announcement;
|
||||
pub mod token_route;
|
||||
pub mod user;
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Organization for multi-tenant setup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Organization {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub plan: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Member of an organization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct OrganizationMember {
|
||||
pub id: i64,
|
||||
pub org_id: i64,
|
||||
pub user_id: i64,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Invitation to join an organization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Invitation {
|
||||
pub id: i64,
|
||||
pub org_id: i64,
|
||||
pub email: String,
|
||||
pub token: String,
|
||||
pub role: Option<String>,
|
||||
pub invited_by: Option<i64>,
|
||||
pub status: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a route channel linking routes, accounts, and tokens.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct RouteChannel {
|
||||
pub id: i64,
|
||||
pub route_id: i64,
|
||||
pub account_id: i64,
|
||||
pub token_id: Option<i64>,
|
||||
pub oauth_route_unit_id: Option<i64>,
|
||||
pub source_model: Option<String>,
|
||||
pub priority: Option<i64>,
|
||||
pub weight: Option<i64>,
|
||||
pub enabled: Option<bool>,
|
||||
pub manual_override: Option<bool>,
|
||||
pub success_count: Option<i64>,
|
||||
pub fail_count: Option<i64>,
|
||||
pub total_latency_ms: Option<i64>,
|
||||
pub total_cost: Option<f64>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_selected_at: Option<String>,
|
||||
pub last_fail_at: Option<String>,
|
||||
pub consecutive_fail_count: i64,
|
||||
pub cooldown_level: i64,
|
||||
pub cooldown_until: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a key-value setting.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a site (AI provider endpoint).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Site {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub platform: String,
|
||||
pub status: String,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub external_checkin_url: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
pub post_refresh_probe_enabled: Option<bool>,
|
||||
pub post_refresh_probe_model: Option<String>,
|
||||
pub post_refresh_probe_scope: Option<String>,
|
||||
pub post_refresh_probe_latency_threshold_ms: Option<i64>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub sort_order: Option<i64>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Site-level announcements (notifications from upstream providers).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct SiteAnnouncement {
|
||||
pub id: i64,
|
||||
pub site_id: i64,
|
||||
pub platform: String,
|
||||
pub source_key: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub level: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub starts_at: Option<String>,
|
||||
pub ends_at: Option<String>,
|
||||
pub upstream_created_at: Option<String>,
|
||||
pub upstream_updated_at: Option<String>,
|
||||
pub first_seen_at: Option<String>,
|
||||
pub last_seen_at: Option<String>,
|
||||
pub read_at: Option<String>,
|
||||
pub dismissed_at: Option<String>,
|
||||
pub raw_payload: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Represents a token route (model routing rule).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct TokenRoute {
|
||||
pub id: i64,
|
||||
pub model_pattern: String,
|
||||
pub model_mapping: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub display_icon: Option<String>,
|
||||
pub decision_snapshot: Option<String>,
|
||||
pub decision_refreshed_at: Option<String>,
|
||||
pub routing_strategy: Option<String>,
|
||||
pub route_mode: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// A platform user (admin or regular).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::{
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::AppResult,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn find_user_by_id(db: &SqlitePool, id: i64) -> AppResult<Option<User>> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_user_by_email(db: &SqlitePool, email: &str) -> AppResult<Option<User>> {
|
||||
sqlx::query_as("SELECT * FROM users WHERE email = ?")
|
||||
.bind(email)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn list_user_api_keys(db: &SqlitePool, user_id: i64) -> AppResult<Vec<DownstreamApiKey>> {
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC")
|
||||
.bind(user_id)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn find_downstream_key_by_token(
|
||||
db: &SqlitePool,
|
||||
token: &str,
|
||||
) -> AppResult<Option<DownstreamApiKey>> {
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE key = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('desktop icon assets', () => {
|
||||
it('keeps runtime icon paths aligned to the generated desktop icon', async () => {
|
||||
const {
|
||||
DESKTOP_BUILD_ICON_RELATIVE_PATH,
|
||||
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
|
||||
DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH,
|
||||
getDesktopRuntimeIconPath,
|
||||
getDesktopTrayIconPath,
|
||||
} = await import('./iconAssets.js');
|
||||
|
||||
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png'));
|
||||
expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png'));
|
||||
expect(DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-tray-template.png'));
|
||||
expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
|
||||
expect(getDesktopTrayIconPath('/app', 'darwin')).toBe(join('/app', 'dist', 'web', 'desktop-tray-template.png'));
|
||||
expect(getDesktopTrayIconPath('/app', 'win32')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
|
||||
});
|
||||
|
||||
it('points electron-builder at the generated desktop package icon', async () => {
|
||||
const configPath = join(process.cwd(), 'electron-builder.yml');
|
||||
const config = await readFile(configPath, 'utf8');
|
||||
|
||||
expect(config).toContain('icon: build/desktop-icon.png');
|
||||
});
|
||||
|
||||
it('builds Linux desktop packages for AppImage, deb, and rpm distributions', async () => {
|
||||
const configPath = join(process.cwd(), 'electron-builder.yml');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { load } = require('js-yaml') as { load: (source: string) => { linux?: { target?: string[] } } };
|
||||
const config = load(await readFile(configPath, 'utf8'));
|
||||
|
||||
expect(config.linux?.target).toEqual(expect.arrayContaining(['AppImage', 'deb', 'rpm']));
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png');
|
||||
export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png');
|
||||
export const DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-tray-template.png');
|
||||
export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
|
||||
|
||||
export function getDesktopRuntimeIconPath(appPath: string) {
|
||||
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export function getDesktopTrayTemplateIconPath(appPath: string) {
|
||||
return join(appPath, DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export function getDesktopTrayIconPath(appPath: string, platform = process.platform) {
|
||||
return platform === 'darwin'
|
||||
? getDesktopTrayTemplateIconPath(appPath)
|
||||
: getDesktopRuntimeIconPath(appPath);
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog,
|
||||
nativeImage,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import log from 'electron-log';
|
||||
import electronUpdater from 'electron-updater';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopHealthUrl,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
resolveDesktopServerPort,
|
||||
resolveDesktopServerWorkingDir,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js';
|
||||
import { attachDesktopNavigationGuard, createSafeOpenExternal } from './navigationGuard.js';
|
||||
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverUrl = '';
|
||||
let isQuitting = false;
|
||||
let isRestartingBackend = false;
|
||||
|
||||
log.initialize();
|
||||
|
||||
function getUserDataDir() {
|
||||
return join(app.getPath('userData'), 'data');
|
||||
}
|
||||
|
||||
function getLogsDir() {
|
||||
return join(app.getPath('userData'), 'logs');
|
||||
}
|
||||
|
||||
function ensureDesktopDirs() {
|
||||
mkdirSync(getUserDataDir(), { recursive: true });
|
||||
mkdirSync(getLogsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function getServerEntryPath() {
|
||||
return join(app.getAppPath(), 'dist', 'server', 'index.js');
|
||||
}
|
||||
|
||||
function getTrayIconPath() {
|
||||
return getDesktopTrayIconPath(app.getAppPath(), process.platform);
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
return getTrayIconPath();
|
||||
}
|
||||
|
||||
function resolveFrontendUrl() {
|
||||
const devUrl = (process.env.BOOSAPI_DESKTOP_DEV_SERVER_URL || '').trim();
|
||||
if (devUrl) return devUrl;
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
function resolveExternalServerUrl() {
|
||||
return (process.env.BOOSAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim();
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function buildTrayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open BoosAPI',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: 'Restart Backend',
|
||||
click: () => {
|
||||
void restartBackend();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Launch at Login',
|
||||
type: 'checkbox',
|
||||
checked: app.getLoginItemSettings().openAtLogin,
|
||||
click: (menuItem) => {
|
||||
app.setLoginItemSettings({ openAtLogin: menuItem.checked });
|
||||
tray?.setContextMenu(buildTrayMenu());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: () => {
|
||||
void checkForUpdates(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Logs Folder',
|
||||
click: () => {
|
||||
void shell.openPath(getLogsDir());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setupTray() {
|
||||
if (tray) return;
|
||||
let trayImage = nativeImage.createFromPath(getTrayIconPath());
|
||||
if (process.platform === 'darwin' && !trayImage.isEmpty()) {
|
||||
trayImage = trayImage.resize({
|
||||
width: 18,
|
||||
height: 18,
|
||||
quality: 'best',
|
||||
});
|
||||
trayImage.setTemplateImage(true);
|
||||
}
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('BoosAPI');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
}
|
||||
|
||||
async function createMainWindow() {
|
||||
if (mainWindow) return mainWindow;
|
||||
const frontendUrl = resolveFrontendUrl();
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
height: 960,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
show: false,
|
||||
icon: existsSync(getWindowIconPath()) ? getWindowIconPath() : undefined,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
attachDesktopNavigationGuard({
|
||||
appUrl: frontendUrl,
|
||||
openExternal: createSafeOpenExternal({
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
onError: (url, error) => {
|
||||
log.error('Failed to open external URL', { url, error });
|
||||
},
|
||||
}),
|
||||
webContents: mainWindow.webContents,
|
||||
});
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
await mainWindow.loadURL(frontendUrl);
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function attachServerLogs(processHandle: ChildProcess) {
|
||||
processHandle.stdout?.on('data', (chunk) => {
|
||||
log.info(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
processHandle.stderr?.on('data', (chunk) => {
|
||||
log.error(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(url: string) {
|
||||
await waitForServerReady({
|
||||
url,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function startManagedBackend() {
|
||||
ensureDesktopDirs();
|
||||
const serverEntryPath = getServerEntryPath();
|
||||
const port = resolveDesktopServerPort(process.env);
|
||||
serverUrl = createDesktopServerUrl(port);
|
||||
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: process.env,
|
||||
userDataDir: getUserDataDir(),
|
||||
logsDir: getLogsDir(),
|
||||
port,
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [serverEntryPath], {
|
||||
cwd: resolveDesktopServerWorkingDir({
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
isPackaged: app.isPackaged,
|
||||
}),
|
||||
env: {
|
||||
...env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: app.isPackaged ? 'production' : 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
serverProcess = child;
|
||||
|
||||
attachServerLogs(child);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
const fatal = isFatalServerExit({ code, signal });
|
||||
serverProcess = null;
|
||||
if (!fatal || isQuitting || isRestartingBackend) return;
|
||||
void handleServerCrash(code);
|
||||
});
|
||||
|
||||
await waitForManagedServerReady(createDesktopHealthUrl(port));
|
||||
}
|
||||
|
||||
async function connectToExternalBackend() {
|
||||
const externalServerUrl = resolveExternalServerUrl();
|
||||
serverUrl = externalServerUrl;
|
||||
await waitForServerReady({
|
||||
url: `${externalServerUrl}/api/desktop/health`,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedBackend() {
|
||||
if (!serverProcess) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const current = serverProcess;
|
||||
if (!current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
}, 10_000);
|
||||
|
||||
current.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
current.kill();
|
||||
});
|
||||
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
async function handleServerCrash(code: number | null) {
|
||||
mainWindow?.hide();
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'BoosAPI backend stopped',
|
||||
message: `The local BoosAPI backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
detail: 'You can restart the backend now or quit the desktop app.',
|
||||
buttons: ['Restart Backend', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await restartBackend();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
|
||||
async function restartBackend() {
|
||||
if (isRestartingBackend) return;
|
||||
isRestartingBackend = true;
|
||||
try {
|
||||
await stopManagedBackend();
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
if (mainWindow) {
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
showMainWindow();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to restart backend', error);
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Restart failed',
|
||||
message: 'BoosAPI could not restart the local backend.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRestartingBackend = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(isManual: boolean) {
|
||||
if (!app.isPackaged) {
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Updates unavailable',
|
||||
message: 'Auto-update is only available for packaged desktop builds.',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
log.error('Failed to check for updates', error);
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Update check failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.logger = log;
|
||||
|
||||
autoUpdater.on('update-available', async (info) => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update available',
|
||||
message: `BoosAPI ${info.version} is available.`,
|
||||
detail: 'Download and install it after the current session?',
|
||||
buttons: ['Download', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', async () => {
|
||||
log.info('No desktop updates available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: 'The new BoosAPI desktop update is ready to install.',
|
||||
buttons: ['Install and Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
isQuitting = true;
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
log.error('Auto-update error', error);
|
||||
});
|
||||
|
||||
void checkForUpdates(false);
|
||||
}
|
||||
|
||||
async function bootDesktopApp() {
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
|
||||
setupTray();
|
||||
await createMainWindow();
|
||||
setupAutoUpdater();
|
||||
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin });
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady()
|
||||
.then(async () => {
|
||||
try {
|
||||
await bootDesktopApp();
|
||||
} catch (error) {
|
||||
log.error('Failed to boot BoosAPI desktop', error);
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'BoosAPI failed to start',
|
||||
message: 'The desktop shell could not start the local BoosAPI service.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
buttons: ['Retry', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await bootDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error('Unhandled startup error', error);
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
void createMainWindow();
|
||||
return;
|
||||
}
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
void stopManagedBackend();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep the tray resident app alive until the user explicitly quits.
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
attachDesktopNavigationGuard,
|
||||
createSafeOpenExternal,
|
||||
resolveDesktopNavigationAction,
|
||||
type DesktopWebContentsLike,
|
||||
type DesktopWillNavigateEvent,
|
||||
type DesktopWindowOpenDetails,
|
||||
} from './navigationGuard.js';
|
||||
|
||||
function createWebContentsHarness() {
|
||||
let openHandler: ((details: DesktopWindowOpenDetails) => { action: 'allow' | 'deny' }) | null = null;
|
||||
let navigateHandler: ((event: DesktopWillNavigateEvent, url: string) => void) | null = null;
|
||||
|
||||
const webContents: DesktopWebContentsLike = {
|
||||
setWindowOpenHandler: (handler) => {
|
||||
openHandler = handler;
|
||||
},
|
||||
on: (event, listener) => {
|
||||
if (event === 'will-navigate') {
|
||||
navigateHandler = listener;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
webContents,
|
||||
getOpenHandler() {
|
||||
if (!openHandler) throw new Error('window open handler not registered');
|
||||
return openHandler;
|
||||
},
|
||||
getNavigateHandler() {
|
||||
if (!navigateHandler) throw new Error('will-navigate handler not registered');
|
||||
return navigateHandler;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('desktop navigation guard', () => {
|
||||
const appUrl = 'http://127.0.0.1:5173/dashboard';
|
||||
|
||||
it('routes cross-origin popup links to the default browser', () => {
|
||||
expect(resolveDesktopNavigationAction('https://github.com/cita-777/metapi', appUrl)).toBe('deny');
|
||||
});
|
||||
|
||||
it('keeps same-origin popup links inside the desktop app', () => {
|
||||
expect(resolveDesktopNavigationAction('/monitor-proxy/ldoh/', appUrl)).toBe('allow');
|
||||
expect(resolveDesktopNavigationAction('about:blank', appUrl)).toBe('allow');
|
||||
});
|
||||
|
||||
it('denies malformed navigation targets by default', () => {
|
||||
expect(resolveDesktopNavigationAction('https://example.com', 'not a valid app url')).toBe('deny');
|
||||
expect(resolveDesktopNavigationAction('http://[::1', appUrl)).toBe('deny');
|
||||
});
|
||||
|
||||
it('opens cross-origin window.open targets externally and denies the popup', () => {
|
||||
const openExternal = vi.fn();
|
||||
const harness = createWebContentsHarness();
|
||||
|
||||
attachDesktopNavigationGuard({
|
||||
appUrl,
|
||||
openExternal,
|
||||
webContents: harness.webContents,
|
||||
});
|
||||
|
||||
const result = harness.getOpenHandler()({
|
||||
url: 'https://metapi.cita777.me',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ action: 'deny' });
|
||||
expect(openExternal).toHaveBeenCalledWith('https://metapi.cita777.me');
|
||||
});
|
||||
|
||||
it('prevents same-window cross-origin navigations and opens them externally', () => {
|
||||
const openExternal = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const harness = createWebContentsHarness();
|
||||
|
||||
attachDesktopNavigationGuard({
|
||||
appUrl,
|
||||
openExternal,
|
||||
webContents: harness.webContents,
|
||||
});
|
||||
|
||||
harness.getNavigateHandler()({ preventDefault }, 'https://example.com/docs');
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/docs');
|
||||
});
|
||||
|
||||
it('does not block same-origin navigations', () => {
|
||||
const openExternal = vi.fn();
|
||||
const preventDefault = vi.fn();
|
||||
const harness = createWebContentsHarness();
|
||||
|
||||
attachDesktopNavigationGuard({
|
||||
appUrl,
|
||||
openExternal,
|
||||
webContents: harness.webContents,
|
||||
});
|
||||
|
||||
harness.getNavigateHandler()({ preventDefault }, 'http://127.0.0.1:5173/monitor-proxy/ldoh/');
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports external-open failures instead of leaving a rejected promise behind', async () => {
|
||||
const error = new Error('failed to open');
|
||||
const onError = vi.fn();
|
||||
const safeOpenExternal = createSafeOpenExternal({
|
||||
openExternal: vi.fn().mockRejectedValue(error),
|
||||
onError,
|
||||
});
|
||||
|
||||
safeOpenExternal('https://example.com/docs');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('https://example.com/docs', error);
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
export type DesktopWindowOpenAction = 'allow' | 'deny';
|
||||
|
||||
export type DesktopWindowOpenDetails = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type DesktopWillNavigateEvent = {
|
||||
preventDefault: () => void;
|
||||
};
|
||||
|
||||
export type DesktopWebContentsLike = {
|
||||
setWindowOpenHandler: (
|
||||
handler: (details: DesktopWindowOpenDetails) => { action: DesktopWindowOpenAction },
|
||||
) => void;
|
||||
on: (
|
||||
event: 'will-navigate',
|
||||
listener: (event: DesktopWillNavigateEvent, url: string) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type AttachDesktopNavigationGuardInput = {
|
||||
appUrl: string;
|
||||
openExternal: (url: string) => void;
|
||||
webContents: DesktopWebContentsLike;
|
||||
};
|
||||
|
||||
export type CreateSafeOpenExternalInput = {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
onError: (url: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export function resolveDesktopNavigationAction(
|
||||
targetUrl: string,
|
||||
appUrl: string,
|
||||
): DesktopWindowOpenAction {
|
||||
let appLocation: URL;
|
||||
let targetLocation: URL;
|
||||
|
||||
try {
|
||||
appLocation = new URL(appUrl);
|
||||
targetLocation = new URL(targetUrl, appLocation);
|
||||
} catch {
|
||||
return 'deny';
|
||||
}
|
||||
|
||||
if (targetLocation.protocol === 'about:') {
|
||||
return 'allow';
|
||||
}
|
||||
|
||||
return targetLocation.origin === appLocation.origin ? 'allow' : 'deny';
|
||||
}
|
||||
|
||||
export function createSafeOpenExternal(
|
||||
input: CreateSafeOpenExternalInput,
|
||||
): (url: string) => void {
|
||||
return (url: string) => {
|
||||
void input.openExternal(url).catch((error) => {
|
||||
input.onError(url, error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function attachDesktopNavigationGuard(
|
||||
input: AttachDesktopNavigationGuardInput,
|
||||
): void {
|
||||
input.webContents.setWindowOpenHandler(({ url }) => {
|
||||
const action = resolveDesktopNavigationAction(url, input.appUrl);
|
||||
if (action === 'deny') {
|
||||
void input.openExternal(url);
|
||||
}
|
||||
return { action };
|
||||
});
|
||||
|
||||
input.webContents.on('will-navigate', (event, url) => {
|
||||
if (resolveDesktopNavigationAction(url, input.appUrl) === 'allow') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void input.openExternal(url);
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
resolveDesktopServerPort,
|
||||
resolveDesktopServerWorkingDir,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
describe('desktop runtime helpers', () => {
|
||||
it('builds desktop server env with external listen host and app directories', () => {
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: {
|
||||
AUTH_TOKEN: 'admin-token',
|
||||
PROXY_TOKEN: 'proxy-token',
|
||||
},
|
||||
userDataDir: '/tmp/metapi-data',
|
||||
logsDir: '/tmp/metapi-logs',
|
||||
port: 4312,
|
||||
});
|
||||
|
||||
expect(env.HOST).toBe('0.0.0.0');
|
||||
expect(env.PORT).toBe('4312');
|
||||
expect(env.DATA_DIR).toBe('/tmp/metapi-data');
|
||||
expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs');
|
||||
expect(env.AUTH_TOKEN).toBe('admin-token');
|
||||
expect(env.PROXY_TOKEN).toBe('proxy-token');
|
||||
});
|
||||
|
||||
it('creates the browser URL from the local desktop port', () => {
|
||||
expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312');
|
||||
});
|
||||
|
||||
it('defaults desktop backend port to 4000', () => {
|
||||
expect(resolveDesktopServerPort({})).toBe(4000);
|
||||
});
|
||||
|
||||
it('honors explicit desktop backend port override', () => {
|
||||
expect(resolveDesktopServerPort({
|
||||
METAPI_DESKTOP_SERVER_PORT: '4312',
|
||||
})).toBe(4312);
|
||||
});
|
||||
|
||||
it('uses resources path as backend cwd for packaged desktop builds', () => {
|
||||
expect(resolveDesktopServerWorkingDir({
|
||||
appPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources/app.asar',
|
||||
resourcesPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources',
|
||||
isPackaged: true,
|
||||
})).toBe('C:/Users/test/AppData/Local/Programs/Metapi/resources');
|
||||
|
||||
expect(resolveDesktopServerWorkingDir({
|
||||
appPath: '/workspace/metapi',
|
||||
resourcesPath: '/tmp/electron/resources',
|
||||
isPackaged: false,
|
||||
})).toBe('/workspace/metapi');
|
||||
});
|
||||
|
||||
it('waits until the health probe returns ok', async () => {
|
||||
const fetcher = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 250,
|
||||
intervalMs: 1,
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fails when the health probe never becomes ready', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 10,
|
||||
intervalMs: 1,
|
||||
})).rejects.toThrow('Timed out waiting for metapi desktop server');
|
||||
});
|
||||
|
||||
it('treats non-zero non-signal exits as fatal', () => {
|
||||
expect(isFatalServerExit({ code: 1, signal: null })).toBe(true);
|
||||
expect(isFatalServerExit({ code: 0, signal: null })).toBe(false);
|
||||
expect(isFatalServerExit({ code: null, signal: 'SIGTERM' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
type DesktopServerEnvInput = {
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
userDataDir: string;
|
||||
logsDir: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type WaitForServerReadyInput = {
|
||||
url: string;
|
||||
fetcher?: (input: string, init?: RequestInit) => Promise<{ ok: boolean }>;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
type ServerExitState = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
type DesktopServerWorkingDirInput = {
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
isPackaged: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_DESKTOP_SERVER_PORT = 4000;
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_READY_INTERVAL_MS = 250;
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv {
|
||||
const host = (input.inheritedEnv?.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
|
||||
return {
|
||||
...(input.inheritedEnv || {}),
|
||||
HOST: host,
|
||||
PORT: String(input.port),
|
||||
DATA_DIR: input.userDataDir,
|
||||
BOOSAPI_DESKTOP: '1',
|
||||
BOOSAPI_LOG_DIR: input.logsDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopServerUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export function createDesktopHealthUrl(port: number): string {
|
||||
return `${createDesktopServerUrl(port)}/api/desktop/health`;
|
||||
}
|
||||
|
||||
export function resolveDesktopServerPort(env?: NodeJS.ProcessEnv): number {
|
||||
const forcedPort = Number.parseInt(env?.BOOSAPI_DESKTOP_SERVER_PORT || '', 10);
|
||||
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
|
||||
return DEFAULT_DESKTOP_SERVER_PORT;
|
||||
}
|
||||
|
||||
export function resolveDesktopServerWorkingDir(input: DesktopServerWorkingDirInput): string {
|
||||
return input.isPackaged ? input.resourcesPath : input.appPath;
|
||||
}
|
||||
|
||||
export async function waitForServerReady(input: WaitForServerReadyInput): Promise<void> {
|
||||
const fetcher = input.fetcher || ((url: string, init?: RequestInit) => fetch(url, init));
|
||||
const timeoutMs = input.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const intervalMs = input.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
try {
|
||||
const response = await fetcher(input.url, { method: 'GET' });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Retry until timeout.
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for BoosAPI desktop server');
|
||||
}
|
||||
|
||||
export function isFatalServerExit(exitState: ServerExitState): boolean {
|
||||
return exitState.code !== null && exitState.code !== 0 && !exitState.signal;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE_ROOT = join(process.cwd(), 'src');
|
||||
const TEST_FILE = join(SOURCE_ROOT, 'encoding.mojibake.test.ts');
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
||||
const MOJIBAKE_MARKERS = [
|
||||
'娴嬮',
|
||||
'婵炴垶',
|
||||
'浠婂ぉ宸茬粡',
|
||||
'鏃犳潈杩涜',
|
||||
'閺冪姵娼堟潻',
|
||||
];
|
||||
|
||||
function collectSourceFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stats = statSync(fullPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...collectSourceFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = fullPath.slice(fullPath.lastIndexOf('.'));
|
||||
if (SOURCE_EXTENSIONS.has(extension)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('source text integrity', () => {
|
||||
it('does not contain known mojibake markers', () => {
|
||||
const hits: string[] = [];
|
||||
|
||||
for (const file of collectSourceFiles(SOURCE_ROOT)) {
|
||||
if (file === TEST_FILE) continue;
|
||||
const lines = readFileSync(file, 'utf8').split(/\r?\n/u);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
for (const marker of MOJIBAKE_MARKERS) {
|
||||
if (!line.includes(marker)) continue;
|
||||
hits.push(`${relative(process.cwd(), file)}:${index + 1} contains "${marker}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Upstream error: {0}")]
|
||||
UpstreamError(String),
|
||||
|
||||
#[error("Rate limited")]
|
||||
RateLimited,
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
|
||||
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
|
||||
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": status.canonical_reason(),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod proxy;
|
||||
pub mod routes;
|
||||
pub mod services;
|
||||
pub mod shared;
|
||||
pub mod transformers;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: config::AppConfig,
|
||||
pub db: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: config::AppConfig, db: SqlitePool) -> Self {
|
||||
Self {
|
||||
config,
|
||||
db: Arc::new(db),
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
use axum::serve;
|
||||
use boosapi::{config::AppConfig, db, AppState};
|
||||
use std::net::SocketAddr;
|
||||
use tokio::signal;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
// Load config
|
||||
let config = AppConfig::load()?;
|
||||
tracing::info!("Loaded configuration");
|
||||
|
||||
// Create database pool
|
||||
let pool = db::create_pool(&config.db.url, config.db.max_connections).await?;
|
||||
tracing::info!("Database connected");
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
tracing::info!("Database migrations applied");
|
||||
|
||||
// Build app state
|
||||
let state = AppState::new(config.clone(), pool);
|
||||
|
||||
// Build router
|
||||
let app = boosapi::routes::build_router(state);
|
||||
|
||||
// Start server
|
||||
let addr = SocketAddr::new(config.server.host.parse()?, config.server.port);
|
||||
tracing::info!("Starting server on {addr}");
|
||||
|
||||
serve(
|
||||
tokio::net::TcpListener::bind(addr).await?,
|
||||
app.into_make_service(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
|
||||
tracing::info!("Shutdown signal received, starting graceful shutdown");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::proxy::providers::types::ProviderCategory;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderCapabilities {
|
||||
pub category: ProviderCategory,
|
||||
pub supports_chat: bool,
|
||||
pub supports_responses: bool,
|
||||
pub supports_images: bool,
|
||||
pub supports_audio: bool,
|
||||
pub supports_streaming: bool,
|
||||
pub supports_token_refresh: bool,
|
||||
}
|
||||
|
||||
pub fn detect_capabilities(platform: &str) -> ProviderCapabilities {
|
||||
let normalized = platform.to_ascii_lowercase();
|
||||
let category = match normalized.as_str() {
|
||||
"openai" => ProviderCategory::OpenAi,
|
||||
"anthropic" | "claude" => ProviderCategory::Anthropic,
|
||||
"gemini" | "google" => ProviderCategory::Gemini,
|
||||
"codex" => ProviderCategory::Codex,
|
||||
"gemini_cli" | "gemini-cli" => ProviderCategory::GeminiCli,
|
||||
"antigravity" => ProviderCategory::Antigravity,
|
||||
_ => ProviderCategory::GenericOpenAi,
|
||||
};
|
||||
|
||||
match category {
|
||||
ProviderCategory::Anthropic => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: false,
|
||||
supports_images: false,
|
||||
supports_audio: false,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
ProviderCategory::Gemini | ProviderCategory::GeminiCli => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: false,
|
||||
supports_images: true,
|
||||
supports_audio: false,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
_ => ProviderCapabilities {
|
||||
category,
|
||||
supports_chat: true,
|
||||
supports_responses: true,
|
||||
supports_images: true,
|
||||
supports_audio: true,
|
||||
supports_streaming: true,
|
||||
supports_token_refresh: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum CliProfileKind {
|
||||
Codex,
|
||||
ClaudeCode,
|
||||
GeminiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CliProfile {
|
||||
pub id: &'static str,
|
||||
pub kind: CliProfileKind,
|
||||
pub display_name: &'static str,
|
||||
pub default_model: &'static str,
|
||||
pub env_key_name: &'static str,
|
||||
}
|
||||
|
||||
pub fn builtin_profiles() -> Vec<CliProfile> {
|
||||
vec![
|
||||
CliProfile {
|
||||
id: "codex",
|
||||
kind: CliProfileKind::Codex,
|
||||
display_name: "Codex CLI",
|
||||
default_model: "gpt-5.5",
|
||||
env_key_name: "OPENAI_API_KEY",
|
||||
},
|
||||
CliProfile {
|
||||
id: "claude-code",
|
||||
kind: CliProfileKind::ClaudeCode,
|
||||
display_name: "Claude Code",
|
||||
default_model: "claude-sonnet-4",
|
||||
env_key_name: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
CliProfile {
|
||||
id: "gemini-cli",
|
||||
kind: CliProfileKind::GeminiCli,
|
||||
display_name: "Gemini CLI",
|
||||
default_model: "gemini-2.0-flash",
|
||||
env_key_name: "GEMINI_API_KEY",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use super::retry_policy::classify_failure;
|
||||
use super::types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
pub struct ProxyConductor {
|
||||
pub select_channel: Arc<
|
||||
dyn Fn(String, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>> + Send + Sync,
|
||||
>,
|
||||
pub select_next_channel: Arc<
|
||||
dyn Fn(String, Vec<i64>, Option<Value>) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
pub record_success: Arc<dyn Fn(i64, SuccessMetrics) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub record_failure: Arc<dyn Fn(i64, FailureInfo) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
pub refresh_auth: Option<
|
||||
Arc<
|
||||
dyn Fn(SelectedChannel, FailureInfo) -> BoxFuture<'static, Option<SelectedChannel>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl ProxyConductor {
|
||||
pub async fn execute(&self, input: ExecuteInput) -> ExecuteResult {
|
||||
let mut attempts = 0;
|
||||
let mut exclude_channel_ids = Vec::new();
|
||||
let mut selected = match (self.select_channel)(
|
||||
input.requested_model.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(selected) => selected,
|
||||
None => {
|
||||
return ExecuteResult::Failure {
|
||||
reason: FailureReason::NoChannel,
|
||||
selected: None,
|
||||
status: None,
|
||||
raw_error_text: None,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
return exhausted_failure(
|
||||
selected,
|
||||
None,
|
||||
None,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let ctx = ExecuteAttemptContext {
|
||||
selected: selected.clone(),
|
||||
attempt_index: attempts,
|
||||
exclude_channel_ids: exclude_channel_ids.clone(),
|
||||
};
|
||||
attempts += 1;
|
||||
|
||||
match (input.attempt_fn)(ctx).await {
|
||||
AttemptResult::Success {
|
||||
response,
|
||||
latency_ms,
|
||||
cost,
|
||||
} => {
|
||||
(self.record_success)(
|
||||
selected.channel_id,
|
||||
SuccessMetrics {
|
||||
latency_ms,
|
||||
cost,
|
||||
metadata: Default::default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return ExecuteResult::Success {
|
||||
selected,
|
||||
response,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
AttemptResult::Failure {
|
||||
action,
|
||||
status,
|
||||
raw_error_text,
|
||||
} => {
|
||||
let classified = if matches!(action, AttemptFailureAction::Stop) {
|
||||
AttemptFailureAction::Stop
|
||||
} else {
|
||||
classify_failure(status, raw_error_text.as_deref())
|
||||
};
|
||||
let info = FailureInfo {
|
||||
action: classified.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
(self.record_failure)(selected.channel_id, info.clone()).await;
|
||||
|
||||
match classified {
|
||||
AttemptFailureAction::RetrySameChannel => {
|
||||
continue;
|
||||
}
|
||||
AttemptFailureAction::RefreshAuth => {
|
||||
if let Some(refresh_auth) = &self.refresh_auth {
|
||||
if let Some(refreshed) =
|
||||
refresh_auth(selected.clone(), info.clone()).await
|
||||
{
|
||||
selected = refreshed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Failover => {
|
||||
exclude_channel_ids.push(selected.channel_id);
|
||||
match (self.select_next_channel)(
|
||||
input.requested_model.clone(),
|
||||
exclude_channel_ids.clone(),
|
||||
input.downstream_policy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(next) => {
|
||||
selected = next;
|
||||
}
|
||||
None => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
AttemptFailureAction::Terminal => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Terminal,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
AttemptFailureAction::Stop => {
|
||||
return terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
input.on_terminal_failure.as_deref(),
|
||||
&info,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminal_failure(
|
||||
selected: SelectedChannel,
|
||||
reason: FailureReason,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
info: &FailureInfo,
|
||||
) -> ExecuteResult {
|
||||
if let Some(callback) = on_terminal_failure {
|
||||
callback(selected.clone(), AttemptFailure::from(info)).await;
|
||||
}
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
selected: Some(selected),
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
}
|
||||
}
|
||||
|
||||
async fn exhausted_failure(
|
||||
selected: SelectedChannel,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
on_terminal_failure: Option<
|
||||
&(dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync),
|
||||
>,
|
||||
) -> ExecuteResult {
|
||||
let info = FailureInfo {
|
||||
action: AttemptFailureAction::Stop.as_str().to_string(),
|
||||
status,
|
||||
raw_error_text: raw_error_text.clone(),
|
||||
};
|
||||
terminal_failure(
|
||||
selected,
|
||||
FailureReason::Failed,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
on_terminal_failure,
|
||||
&info,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::FutureExt;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn channel(id: i64) -> SelectedChannel {
|
||||
SelectedChannel {
|
||||
channel_id: id,
|
||||
route_id: Some(10),
|
||||
site_id: 20,
|
||||
account_id: Some(30),
|
||||
base_url: "https://example.test/v1".into(),
|
||||
api_key: "key".into(),
|
||||
model: "gpt-5.5".into(),
|
||||
actual_model: "gpt-5.5".into(),
|
||||
token_name: None,
|
||||
token_value: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_same_channel_then_succeeds() {
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_failures = Arc::new(AtomicUsize::new(0));
|
||||
let recorded_successes = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, _, _| async { Some(channel(2)) }.boxed()),
|
||||
record_success: {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_successes = Arc::clone(&recorded_successes);
|
||||
async move {
|
||||
recorded_successes.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
record_failure: {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
Arc::new(move |_, _| {
|
||||
let recorded_failures = Arc::clone(&recorded_failures);
|
||||
async move {
|
||||
recorded_failures.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
},
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |_| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::RetrySameChannel,
|
||||
status: Some(429),
|
||||
raw_error_text: Some("rate limited".into()),
|
||||
}
|
||||
} else {
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: Some(12),
|
||||
cost: Some(0.1),
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 1);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
assert_eq!(recorded_failures.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(recorded_successes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_selects_next_channel() {
|
||||
let conductor = ProxyConductor {
|
||||
select_channel: Arc::new(|_, _| async { Some(channel(1)) }.boxed()),
|
||||
select_next_channel: Arc::new(|_, excluded, _| {
|
||||
async move {
|
||||
assert_eq!(excluded, vec![1]);
|
||||
Some(channel(2))
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_success: Arc::new(|_, _| async {}.boxed()),
|
||||
record_failure: Arc::new(|_, _| async {}.boxed()),
|
||||
refresh_auth: None,
|
||||
};
|
||||
|
||||
let attempts_seen = Arc::new(AtomicUsize::new(0));
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: "gpt-5.5".into(),
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new({
|
||||
let attempts_seen = Arc::clone(&attempts_seen);
|
||||
move |ctx| {
|
||||
let attempt = attempts_seen.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
assert_eq!(ctx.selected.channel_id, 1);
|
||||
AttemptResult::Failure {
|
||||
action: AttemptFailureAction::Failover,
|
||||
status: Some(500),
|
||||
raw_error_text: None,
|
||||
}
|
||||
} else {
|
||||
assert_eq!(ctx.selected.channel_id, 2);
|
||||
AttemptResult::Success {
|
||||
response: json!({"ok": true}),
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
ExecuteResult::Success {
|
||||
selected, attempts, ..
|
||||
} => {
|
||||
assert_eq!(selected.channel_id, 2);
|
||||
assert_eq!(attempts, 2);
|
||||
}
|
||||
other => panic!("expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod conductor;
|
||||
pub mod retry_policy;
|
||||
pub mod types;
|
||||
|
||||
pub use conductor::ProxyConductor;
|
||||
pub use retry_policy::{
|
||||
classify_failure, is_terminal, should_failover, should_refresh_auth, should_retry_same_channel,
|
||||
};
|
||||
pub use types::{
|
||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||
ExecuteResult, FailureInfo, FailureReason, SelectedChannel, SuccessMetrics,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
use super::types::AttemptFailureAction;
|
||||
|
||||
pub fn classify_failure(status: Option<u16>, _error_text: Option<&str>) -> AttemptFailureAction {
|
||||
match status {
|
||||
Some(429 | 503) => AttemptFailureAction::RetrySameChannel,
|
||||
Some(401) => AttemptFailureAction::RefreshAuth,
|
||||
Some(code) if (500..600).contains(&code) => AttemptFailureAction::Failover,
|
||||
Some(code) if (400..500).contains(&code) => AttemptFailureAction::Terminal,
|
||||
_ => AttemptFailureAction::Failover,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_terminal(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Terminal)
|
||||
}
|
||||
|
||||
pub fn should_retry_same_channel(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RetrySameChannel)
|
||||
}
|
||||
|
||||
pub fn should_refresh_auth(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::RefreshAuth)
|
||||
}
|
||||
|
||||
pub fn should_failover(action: AttemptFailureAction) -> bool {
|
||||
matches!(action, AttemptFailureAction::Failover)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_retryable_rate_limit_and_unavailable() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(429), None),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(503), Some("busy")),
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_auth_server_and_terminal_failures() {
|
||||
assert_eq!(
|
||||
classify_failure(Some(401), None),
|
||||
AttemptFailureAction::RefreshAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(500), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(502), None),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(400), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(Some(404), None),
|
||||
AttemptFailureAction::Terminal
|
||||
);
|
||||
assert_eq!(
|
||||
classify_failure(None, Some("timeout")),
|
||||
AttemptFailureAction::Failover
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_predicates_match_actions() {
|
||||
assert!(is_terminal(AttemptFailureAction::Terminal));
|
||||
assert!(should_retry_same_channel(
|
||||
AttemptFailureAction::RetrySameChannel
|
||||
));
|
||||
assert!(should_refresh_auth(AttemptFailureAction::RefreshAuth));
|
||||
assert!(should_failover(AttemptFailureAction::Failover));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use futures::future::BoxFuture;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SelectedChannel {
|
||||
pub channel_id: i64,
|
||||
pub route_id: Option<i64>,
|
||||
pub site_id: i64,
|
||||
pub account_id: Option<i64>,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub actual_model: String,
|
||||
pub token_name: Option<String>,
|
||||
pub token_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AttemptFailureAction {
|
||||
RetrySameChannel,
|
||||
RefreshAuth,
|
||||
Failover,
|
||||
Terminal,
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AttemptResult {
|
||||
Success {
|
||||
response: Value,
|
||||
latency_ms: Option<u64>,
|
||||
cost: Option<f64>,
|
||||
},
|
||||
Failure {
|
||||
action: AttemptFailureAction,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ExecuteInput {
|
||||
pub requested_model: String,
|
||||
pub downstream_policy: Option<Value>,
|
||||
pub attempt_fn: Box<dyn Fn(ExecuteAttemptContext) -> BoxFuture<'static, AttemptResult> + Send>,
|
||||
pub on_terminal_failure: Option<
|
||||
Box<dyn Fn(SelectedChannel, AttemptFailure) -> BoxFuture<'static, ()> + Send + Sync>,
|
||||
>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecuteAttemptContext {
|
||||
pub selected: SelectedChannel,
|
||||
pub attempt_index: usize,
|
||||
pub exclude_channel_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecuteResult {
|
||||
Success {
|
||||
selected: SelectedChannel,
|
||||
response: Value,
|
||||
attempts: usize,
|
||||
},
|
||||
Failure {
|
||||
reason: FailureReason,
|
||||
selected: Option<SelectedChannel>,
|
||||
status: Option<u16>,
|
||||
raw_error_text: Option<String>,
|
||||
attempts: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FailureReason {
|
||||
NoChannel,
|
||||
Failed,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SuccessMetrics {
|
||||
pub latency_ms: Option<u64>,
|
||||
pub cost: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FailureInfo {
|
||||
pub action: String,
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttemptFailure {
|
||||
pub status: Option<u16>,
|
||||
pub raw_error_text: Option<String>,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
impl From<&FailureInfo> for AttemptFailure {
|
||||
fn from(value: &FailureInfo) -> Self {
|
||||
Self {
|
||||
status: value.status,
|
||||
raw_error_text: value.raw_error_text.clone(),
|
||||
action: value.action.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptFailureAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RetrySameChannel => "retry_same_channel",
|
||||
Self::RefreshAuth => "refresh_auth",
|
||||
Self::Failover => "failover",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Stop => "stop",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Proxy executors — forward requests to upstream APIs
|
||||
//
|
||||
// Each executor:
|
||||
// 1. Transforms the OpenAI-compatible request to the upstream format
|
||||
// 2. Sends the HTTP request
|
||||
// 3. Transforms the response back to OpenAI-compatible format
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::AppState;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Resolved upstream info for a given model request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpstreamRoute {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// Create a shared reqwest client with sensible defaults.
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(32)
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Execute a non-streaming chat completions request.
|
||||
pub async fn execute_chat_completions(
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!("{}/chat/completions", route.base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Execute a streaming chat completions request.
|
||||
/// Returns a vector of SSE data chunks as strings.
|
||||
pub async fn execute_chat_completions_stream(
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!("{}/chat/completions", route.base_url.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "unknown error".to_string());
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the streaming response as chunks
|
||||
let mut chunks = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures::StreamExt;
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
match chunk_result {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
// Parse SSE lines from the chunk
|
||||
for line in text.lines() {
|
||||
if line.starts_with("data: ") {
|
||||
chunks.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(AppError::UpstreamError(format!("Stream error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
/// Generic non-streaming proxy request to any upstream endpoint path.
|
||||
pub async fn execute_proxy_request(
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
path_suffix: &str,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
route.base_url.trim_end_matches('/'),
|
||||
path_suffix.trim_start_matches('/')
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Execute an image generation request.
|
||||
pub async fn execute_image_generation(
|
||||
_state: AppState,
|
||||
route: UpstreamRoute,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let client = http_client();
|
||||
let upstream_url = format!(
|
||||
"{}/images/generations",
|
||||
route.base_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&upstream_url)
|
||||
.header("Authorization", format!("Bearer {}", route.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(format!(
|
||||
"Upstream returned {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Proxy core — OpenAI-compatible request routing
|
||||
//
|
||||
// Architecture:
|
||||
// /v1/chat/completions → auth (downstream key) → route resolution → upstream call → response
|
||||
// /v1/images/generations → ...same flow...
|
||||
// /v1/audio/speech → ...same flow...
|
||||
|
||||
pub mod capabilities;
|
||||
pub mod cli_profiles;
|
||||
pub mod conductor;
|
||||
pub mod executors;
|
||||
pub mod providers;
|
||||
pub mod runtime;
|
||||
pub mod surfaces;
|
||||
pub mod token_router;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Provider profiles & registry
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use registry::ProviderRegistry;
|
||||
pub use types::{ProviderCategory, ProviderProfile, RouteResolution};
|
||||
@@ -0,0 +1,134 @@
|
||||
// Provider registry — holds all configured upstream provider profiles
|
||||
use crate::proxy::providers::types::{ProviderCategory, ProviderProfile, RouteResolution};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Registry of all available provider profiles
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRegistry {
|
||||
providers: HashMap<String, ProviderProfile>,
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
/// Create a new registry with default providers
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Self {
|
||||
providers: HashMap::new(),
|
||||
};
|
||||
registry.register_defaults();
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register the built-in default providers
|
||||
fn register_defaults(&mut self) {
|
||||
// OpenAI
|
||||
self.register(ProviderProfile::openai_compatible(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"https://api.openai.com/v1",
|
||||
));
|
||||
// Anthropic
|
||||
self.register(ProviderProfile::anthropic(
|
||||
"anthropic",
|
||||
"Anthropic",
|
||||
"https://api.anthropic.com/v1",
|
||||
));
|
||||
// Google Gemini
|
||||
self.register(ProviderProfile::gemini(
|
||||
"gemini",
|
||||
"Google Gemini",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
));
|
||||
// Codex
|
||||
self.register(ProviderProfile {
|
||||
id: "codex".into(),
|
||||
name: "OpenAI Codex".into(),
|
||||
category: ProviderCategory::Codex,
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
default_model: Some("codex".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
// Claude Code
|
||||
self.register(ProviderProfile {
|
||||
id: "claude".into(),
|
||||
name: "Claude Code".into(),
|
||||
category: ProviderCategory::Claude,
|
||||
base_url: "https://api.anthropic.com/v1".into(),
|
||||
default_model: Some("claude-sonnet-4".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
// Gemini CLI
|
||||
self.register(ProviderProfile {
|
||||
id: "gemini_cli".into(),
|
||||
name: "Gemini CLI".into(),
|
||||
category: ProviderCategory::GeminiCli,
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta".into(),
|
||||
default_model: Some("gemini-2.0-flash".into()),
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Register a new provider profile
|
||||
pub fn register(&mut self, profile: ProviderProfile) {
|
||||
self.providers.insert(profile.id.clone(), profile);
|
||||
}
|
||||
|
||||
/// Get a provider by ID
|
||||
pub fn get(&self, id: &str) -> Option<&ProviderProfile> {
|
||||
self.providers.get(id)
|
||||
}
|
||||
|
||||
/// List all registered providers
|
||||
pub fn list(&self) -> Vec<&ProviderProfile> {
|
||||
self.providers.values().collect()
|
||||
}
|
||||
|
||||
/// Remove a provider by ID
|
||||
pub fn remove(&mut self, id: &str) -> Option<ProviderProfile> {
|
||||
self.providers.remove(id)
|
||||
}
|
||||
|
||||
/// Resolve a model name to an upstream route.
|
||||
/// Tries to match the model to a provider category, falls back to first OpenAI-compatible provider.
|
||||
pub fn resolve(&self, model: &str, api_key: &str) -> Option<RouteResolution> {
|
||||
// First try to find a provider that supports this model
|
||||
for provider in self.providers.values() {
|
||||
if provider.supports_model(model) {
|
||||
let base_url = provider.base_url.clone();
|
||||
return Some(RouteResolution {
|
||||
provider: provider.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
base_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first OpenAI-compatible provider
|
||||
self.providers
|
||||
.values()
|
||||
.find(|p| {
|
||||
matches!(
|
||||
p.category,
|
||||
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi
|
||||
)
|
||||
})
|
||||
.map(|provider| RouteResolution {
|
||||
provider: provider.clone(),
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
base_url: provider.base_url.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProviderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Provider profile types — upstream API provider definitions
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Provider category — determines request transformation and routing behavior
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ProviderCategory {
|
||||
/// OpenAI-compatible chat/completions API
|
||||
OpenAi,
|
||||
/// Anthropic Messages API
|
||||
Anthropic,
|
||||
/// Google Gemini API
|
||||
Gemini,
|
||||
/// Codex CLI / Responses API
|
||||
Codex,
|
||||
/// Claude Code CLI
|
||||
Claude,
|
||||
/// Gemini CLI
|
||||
GeminiCli,
|
||||
/// Antigravity / assistant-style API
|
||||
Antigravity,
|
||||
/// Generic OpenAI-compatible proxy (new-api, one-api, etc.)
|
||||
GenericOpenAi,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderCategory {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::OpenAi => write!(f, "openai"),
|
||||
Self::Anthropic => write!(f, "anthropic"),
|
||||
Self::Gemini => write!(f, "gemini"),
|
||||
Self::Codex => write!(f, "codex"),
|
||||
Self::Claude => write!(f, "claude"),
|
||||
Self::GeminiCli => write!(f, "gemini_cli"),
|
||||
Self::Antigravity => write!(f, "antigravity"),
|
||||
Self::GenericOpenAi => write!(f, "generic_openai"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider configuration profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub category: ProviderCategory,
|
||||
pub base_url: String,
|
||||
pub default_model: Option<String>,
|
||||
pub requires_api_key: bool,
|
||||
pub supports_streaming: bool,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ProviderProfile {
|
||||
/// Create a standard OpenAI-compatible provider
|
||||
pub fn openai_compatible(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::GenericOpenAi,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an Anthropic Messages API provider
|
||||
pub fn anthropic(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
headers.insert("anthropic-version".into(), "2023-06-01".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::Anthropic,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Gemini API provider
|
||||
pub fn gemini(id: &str, name: &str, base_url: &str) -> Self {
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("Content-Type".into(), "application/json".into());
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
category: ProviderCategory::Gemini,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
default_model: None,
|
||||
requires_api_key: true,
|
||||
supports_streaming: true,
|
||||
headers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this provider handles the given model
|
||||
pub fn supports_model(&self, model: &str) -> bool {
|
||||
let model_lower = model.to_lowercase();
|
||||
match self.category {
|
||||
ProviderCategory::OpenAi | ProviderCategory::GenericOpenAi => {
|
||||
model_lower.contains("gpt")
|
||||
|| model_lower.contains("o1")
|
||||
|| model_lower.contains("o3")
|
||||
|| model_lower.contains("dall-e")
|
||||
}
|
||||
ProviderCategory::Anthropic => model_lower.contains("claude"),
|
||||
ProviderCategory::Gemini => model_lower.contains("gemini"),
|
||||
ProviderCategory::Codex => model_lower.contains("codex"),
|
||||
ProviderCategory::Claude => {
|
||||
model_lower.contains("claude") && model_lower.contains("code")
|
||||
}
|
||||
ProviderCategory::GeminiCli => {
|
||||
model_lower.contains("gemini") && model_lower.contains("cli")
|
||||
}
|
||||
ProviderCategory::Antigravity => {
|
||||
model_lower.contains("antigravity") || model_lower.contains("assistant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of resolving a model to an upstream route
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteResolution {
|
||||
pub provider: ProviderProfile,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub base_url: String,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::proxy::conductor::{FailureInfo, ProxyConductor, SelectedChannel, SuccessMetrics};
|
||||
use crate::proxy::token_router::TokenRouter;
|
||||
use futures::FutureExt;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyRuntime {
|
||||
pub router: TokenRouter,
|
||||
}
|
||||
|
||||
impl ProxyRuntime {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
router: TokenRouter::new(db),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conductor(&self) -> ProxyConductor {
|
||||
let router = self.router.clone();
|
||||
let next_router = self.router.clone();
|
||||
let success_router = self.router.clone();
|
||||
let failure_router = self.router.clone();
|
||||
|
||||
ProxyConductor {
|
||||
select_channel: Arc::new(move |model, _policy| {
|
||||
let router = router.clone();
|
||||
async move { router.resolve_selected(&model).await.ok() }.boxed()
|
||||
}),
|
||||
select_next_channel: Arc::new(move |model, excluded, _policy| {
|
||||
let router = next_router.clone();
|
||||
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
|
||||
}),
|
||||
record_success: Arc::new(move |channel_id: i64, metrics: SuccessMetrics| {
|
||||
let router = success_router.clone();
|
||||
async move {
|
||||
let _ = router.record_success(channel_id, metrics).await;
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_failure: Arc::new(move |channel_id: i64, info: FailureInfo| {
|
||||
let router = failure_router.clone();
|
||||
async move {
|
||||
let _ = router.record_failure(channel_id, info).await;
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
refresh_auth: Some(Arc::new(|selected: SelectedChannel, _info: FailureInfo| {
|
||||
async move { Some(selected) }.boxed()
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProxySurfaceState {
|
||||
sessions: Arc<Mutex<HashMap<String, SurfaceSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SurfaceSession {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub channel_id: Option<i64>,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
impl ProxySurfaceState {
|
||||
pub fn upsert_session(&self, session: SurfaceSession) {
|
||||
self.sessions.lock().insert(session.id.clone(), session);
|
||||
}
|
||||
|
||||
pub fn get_session(&self, id: &str) -> Option<SurfaceSession> {
|
||||
self.sessions.lock().get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_session(&self, id: &str) -> Option<SurfaceSession> {
|
||||
self.sessions.lock().remove(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod router;
|
||||
pub mod types;
|
||||
|
||||
pub use router::TokenRouter;
|
||||
pub use types::{RouteDecision, RouteMatch};
|
||||
@@ -0,0 +1,430 @@
|
||||
use super::types::{RouteDecision, RouteMatch};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::proxy::conductor::{FailureInfo, SelectedChannel, SuccessMetrics};
|
||||
use crate::proxy::executors::UpstreamRoute;
|
||||
use regex::Regex;
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TokenRouter {
|
||||
db: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct RouteRow {
|
||||
id: i64,
|
||||
model_pattern: String,
|
||||
model_mapping: Option<String>,
|
||||
route_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct ChannelRow {
|
||||
channel_id: i64,
|
||||
route_id: i64,
|
||||
account_id: i64,
|
||||
site_id: i64,
|
||||
base_url: String,
|
||||
site_api_key: Option<String>,
|
||||
account_access_token: String,
|
||||
account_api_token: Option<String>,
|
||||
token_name: Option<String>,
|
||||
token_value: Option<String>,
|
||||
source_model: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenRouter {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, model: &str) -> AppResult<UpstreamRoute> {
|
||||
let decision = self.resolve_decision(model, &[]).await?;
|
||||
Ok(UpstreamRoute {
|
||||
base_url: decision.selected.base_url,
|
||||
api_key: decision.selected.api_key,
|
||||
model: decision.selected.actual_model,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn resolve_selected(&self, model: &str) -> AppResult<SelectedChannel> {
|
||||
Ok(self.resolve_decision(model, &[]).await?.selected)
|
||||
}
|
||||
|
||||
pub async fn resolve_next_selected(
|
||||
&self,
|
||||
model: &str,
|
||||
exclude_channel_ids: &[i64],
|
||||
) -> AppResult<SelectedChannel> {
|
||||
Ok(self
|
||||
.resolve_decision(model, exclude_channel_ids)
|
||||
.await?
|
||||
.selected)
|
||||
}
|
||||
|
||||
pub async fn resolve_decision(
|
||||
&self,
|
||||
model: &str,
|
||||
exclude_channel_ids: &[i64],
|
||||
) -> AppResult<RouteDecision> {
|
||||
let route = self.match_route(model).await?.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("No route configured for model '{model}'"))
|
||||
})?;
|
||||
let channel = self
|
||||
.select_channel_for_route(&route.0, model, exclude_channel_ids)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(format!(
|
||||
"No enabled channel configured for model '{}'",
|
||||
model
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(RouteDecision {
|
||||
requested_model: model.to_string(),
|
||||
matched_pattern: route.0.model_pattern,
|
||||
match_kind: route.1,
|
||||
selected: channel,
|
||||
})
|
||||
}
|
||||
|
||||
async fn match_route(&self, model: &str) -> AppResult<Option<(RouteRow, RouteMatch)>> {
|
||||
let rows: Vec<RouteRow> = sqlx::query_as(
|
||||
"SELECT id, model_pattern, model_mapping, route_mode
|
||||
FROM token_routes
|
||||
WHERE enabled = 1
|
||||
ORDER BY id",
|
||||
)
|
||||
.fetch_all(&*self.db)
|
||||
.await?;
|
||||
|
||||
let mut best: Option<(RouteRow, RouteMatch, usize, usize)> = None;
|
||||
for row in rows {
|
||||
if let Some(kind) = match_pattern(model, &row.model_pattern, row.route_mode.as_deref())
|
||||
{
|
||||
let rank = match kind {
|
||||
RouteMatch::Exact => 3,
|
||||
RouteMatch::Wildcard => 2,
|
||||
RouteMatch::Regex => 1,
|
||||
};
|
||||
let specificity = row.model_pattern.chars().filter(|c| *c != '*').count();
|
||||
let replace = best
|
||||
.as_ref()
|
||||
.map(|(_, _, best_rank, best_specificity)| {
|
||||
rank > *best_rank || (rank == *best_rank && specificity > *best_specificity)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if replace {
|
||||
best = Some((row, kind, rank, specificity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(best.map(|(row, kind, _, _)| (row, kind)))
|
||||
}
|
||||
|
||||
async fn select_channel_for_route(
|
||||
&self,
|
||||
route: &RouteRow,
|
||||
requested_model: &str,
|
||||
exclude_channel_ids: &[i64],
|
||||
) -> AppResult<Option<SelectedChannel>> {
|
||||
let rows: Vec<ChannelRow> = sqlx::query_as(
|
||||
"SELECT
|
||||
rc.id AS channel_id,
|
||||
rc.route_id AS route_id,
|
||||
rc.account_id AS account_id,
|
||||
a.site_id AS site_id,
|
||||
s.url AS base_url,
|
||||
s.api_key AS site_api_key,
|
||||
a.access_token AS account_access_token,
|
||||
a.api_token AS account_api_token,
|
||||
at.name AS token_name,
|
||||
at.token AS token_value,
|
||||
rc.source_model AS source_model
|
||||
FROM route_channels rc
|
||||
JOIN accounts a ON a.id = rc.account_id
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
LEFT JOIN account_tokens at ON at.id = rc.token_id AND at.enabled = 1
|
||||
WHERE rc.route_id = ?
|
||||
AND rc.enabled = 1
|
||||
AND a.status = 'active'
|
||||
AND s.status = 'active'
|
||||
AND (rc.cooldown_until IS NULL OR rc.cooldown_until <= datetime('now'))
|
||||
AND (rc.token_id IS NULL OR at.id IS NOT NULL)
|
||||
ORDER BY
|
||||
COALESCE(rc.priority, 0) DESC,
|
||||
rc.consecutive_fail_count ASC,
|
||||
COALESCE(rc.weight, 10) DESC,
|
||||
rc.last_selected_at IS NOT NULL,
|
||||
rc.id ASC",
|
||||
)
|
||||
.bind(route.id)
|
||||
.fetch_all(&*self.db)
|
||||
.await?;
|
||||
|
||||
let row = rows
|
||||
.into_iter()
|
||||
.find(|row| !exclude_channel_ids.contains(&row.channel_id));
|
||||
|
||||
Ok(row.map(|row| {
|
||||
let actual_model = route
|
||||
.model_mapping
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
row.source_model
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| requested_model.to_string());
|
||||
let api_key = row
|
||||
.token_value
|
||||
.clone()
|
||||
.or(row.account_api_token.clone())
|
||||
.unwrap_or(row.account_access_token.clone());
|
||||
|
||||
SelectedChannel {
|
||||
channel_id: row.channel_id,
|
||||
route_id: Some(row.route_id),
|
||||
site_id: row.site_id,
|
||||
account_id: Some(row.account_id),
|
||||
base_url: row.base_url,
|
||||
api_key: api_key
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_then(row.site_api_key.unwrap_or_default()),
|
||||
model: requested_model.to_string(),
|
||||
actual_model,
|
||||
token_name: row.token_name,
|
||||
token_value: row.token_value,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn record_success(&self, channel_id: i64, metrics: SuccessMetrics) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE route_channels
|
||||
SET success_count = COALESCE(success_count, 0) + 1,
|
||||
total_latency_ms = COALESCE(total_latency_ms, 0) + ?,
|
||||
total_cost = COALESCE(total_cost, 0) + ?,
|
||||
consecutive_fail_count = 0,
|
||||
cooldown_level = 0,
|
||||
cooldown_until = NULL,
|
||||
last_used_at = datetime('now'),
|
||||
last_selected_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(metrics.latency_ms.unwrap_or(0) as i64)
|
||||
.bind(metrics.cost.unwrap_or(0.0))
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_failure(&self, channel_id: i64, _info: FailureInfo) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE route_channels
|
||||
SET fail_count = COALESCE(fail_count, 0) + 1,
|
||||
consecutive_fail_count = consecutive_fail_count + 1,
|
||||
cooldown_level = cooldown_level + 1,
|
||||
last_fail_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
trait IfEmptyThen {
|
||||
fn if_empty_then(self, fallback: String) -> String;
|
||||
}
|
||||
|
||||
impl IfEmptyThen for String {
|
||||
fn if_empty_then(self, fallback: String) -> String {
|
||||
if self.is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn match_pattern(model: &str, pattern: &str, route_mode: Option<&str>) -> Option<RouteMatch> {
|
||||
match route_mode.unwrap_or("pattern") {
|
||||
"exact" => (model == pattern).then_some(RouteMatch::Exact),
|
||||
"regex" => Regex::new(pattern)
|
||||
.ok()
|
||||
.and_then(|re| re.is_match(model).then_some(RouteMatch::Regex)),
|
||||
_ if pattern == model => Some(RouteMatch::Exact),
|
||||
_ if pattern.starts_with("regex:") => Regex::new(pattern.trim_start_matches("regex:"))
|
||||
.ok()
|
||||
.and_then(|re| re.is_match(model).then_some(RouteMatch::Regex)),
|
||||
_ if pattern.contains('*') => {
|
||||
wildcard_matches(model, pattern).then_some(RouteMatch::Wildcard)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn wildcard_matches(model: &str, pattern: &str) -> bool {
|
||||
let escaped = pattern
|
||||
.split('*')
|
||||
.map(regex::escape)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".*");
|
||||
Regex::new(&format!("^{escaped}$"))
|
||||
.map(|re| re.is_match(model))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'openai',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
api_key TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE token_routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_pattern TEXT NOT NULL,
|
||||
model_mapping TEXT,
|
||||
route_mode TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE account_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE route_channels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_until TEXT,
|
||||
last_selected_at TEXT,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
total_latency_ms INTEGER DEFAULT 0,
|
||||
total_cost REAL DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
last_fail_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', 'https://api.example/v1', 'site-key')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (1, 1, 'access-key', NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO account_tokens (id, account_id, name, token) VALUES (1, 1, 'primary', 'token-key')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_exact_model_route() {
|
||||
let pool = test_pool().await;
|
||||
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode) VALUES (1, 'gpt-5.5', 'upstream-gpt-5.5', 'exact')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO route_channels (id, route_id, account_id, token_id) VALUES (1, 1, 1, 1)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = TokenRouter::new(Arc::new(pool));
|
||||
let decision = router.resolve_decision("gpt-5.5", &[]).await.unwrap();
|
||||
assert_eq!(decision.match_kind, RouteMatch::Exact);
|
||||
assert_eq!(decision.selected.actual_model, "upstream-gpt-5.5");
|
||||
assert_eq!(decision.selected.api_key, "token-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_wildcard_route_and_skips_excluded_channel() {
|
||||
let pool = test_pool().await;
|
||||
sqlx::query("INSERT INTO token_routes (id, model_pattern, route_mode) VALUES (1, 'gpt-*', 'pattern')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO route_channels (id, route_id, account_id, token_id, priority) VALUES (1, 1, 1, 1, 10)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO route_channels (id, route_id, account_id, token_id, priority) VALUES (2, 1, 1, 1, 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = TokenRouter::new(Arc::new(pool));
|
||||
let decision = router.resolve_decision("gpt-4o", &[1]).await.unwrap();
|
||||
assert_eq!(decision.match_kind, RouteMatch::Wildcard);
|
||||
assert_eq!(decision.selected.channel_id, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::proxy::conductor::SelectedChannel;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteMatch {
|
||||
Exact,
|
||||
Wildcard,
|
||||
Regex,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteDecision {
|
||||
pub requested_model: String,
|
||||
pub matched_pattern: String,
|
||||
pub match_kind: RouteMatch,
|
||||
pub selected: SelectedChannel,
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Account token routes — CRUD for account-level API tokens
|
||||
|
||||
use crate::{
|
||||
db::models::account::AccountToken,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/account-tokens",
|
||||
get(list_account_tokens).post(create_account_token),
|
||||
)
|
||||
.route(
|
||||
"/api/account-tokens/sync-all",
|
||||
post(sync_all_account_tokens),
|
||||
)
|
||||
.route(
|
||||
"/api/account-tokens/{id}",
|
||||
get(get_account_token)
|
||||
.put(update_account_token)
|
||||
.delete(delete_account_token),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountTokensQuery {
|
||||
#[serde(rename = "accountId", alias = "account_id")]
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountTokenRequest {
|
||||
pub account_id: i64,
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAccountTokenRequest {
|
||||
pub account_id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/account-tokens?accountId=
|
||||
pub async fn list_account_tokens(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAccountTokensQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Listing account tokens, account_id filter: {:?}",
|
||||
query.account_id
|
||||
);
|
||||
|
||||
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY token_group, id",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens ORDER BY account_id, token_group, id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": tokens })))
|
||||
}
|
||||
|
||||
/// POST /api/account-tokens
|
||||
pub async fn create_account_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateAccountTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Creating account token for account_id: {}",
|
||||
payload.account_id
|
||||
);
|
||||
|
||||
ensure_account_exists(&state, payload.account_id).await?;
|
||||
|
||||
let token = normalize_create_token(payload.token);
|
||||
let name = payload
|
||||
.name
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "API Token".to_string());
|
||||
|
||||
let value_status = payload
|
||||
.value_status
|
||||
.map(|status| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.unwrap_or_else(|| "valid".to_string());
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO account_tokens \
|
||||
(account_id, name, token, source, token_group, value_status, enabled, is_default, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(&name)
|
||||
.bind(&token)
|
||||
.bind(&payload.source)
|
||||
.bind(&payload.token_group)
|
||||
.bind(&value_status)
|
||||
.bind(payload.enabled.unwrap_or(true))
|
||||
.bind(payload.is_default.unwrap_or(false))
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token_id = result.last_insert_rowid();
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(token_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// GET /api/account-tokens/{id}
|
||||
pub async fn get_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting account token: {}", id);
|
||||
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// PUT /api/account-tokens/{id}
|
||||
pub async fn update_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateAccountTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating account token: {}", id);
|
||||
|
||||
let existing: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
|
||||
|
||||
let account_id = payload.account_id.unwrap_or(existing.account_id);
|
||||
if payload.account_id.is_some() {
|
||||
ensure_account_exists(&state, account_id).await?;
|
||||
}
|
||||
|
||||
let name = payload
|
||||
.name
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(existing.name);
|
||||
|
||||
let token = match payload.token {
|
||||
Some(token) => {
|
||||
let token = token.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(AppError::BadRequest("token cannot be empty".into()));
|
||||
}
|
||||
token
|
||||
}
|
||||
None => existing.token,
|
||||
};
|
||||
|
||||
let value_status = payload
|
||||
.value_status
|
||||
.map(|status| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.unwrap_or(existing.value_status);
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE account_tokens SET \
|
||||
account_id = ?, name = ?, token = ?, source = ?, token_group = ?, value_status = ?, \
|
||||
enabled = ?, is_default = ?, updated_at = ? \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&name)
|
||||
.bind(&token)
|
||||
.bind(payload.source.or(existing.source))
|
||||
.bind(payload.token_group.or(existing.token_group))
|
||||
.bind(&value_status)
|
||||
.bind(payload.enabled.or(existing.enabled))
|
||||
.bind(payload.is_default.or(existing.is_default))
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// DELETE /api/account-tokens/{id}
|
||||
pub async fn delete_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting account token: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Account token with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/account-tokens/sync-all
|
||||
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
|
||||
info!("Sync all account tokens requested");
|
||||
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": "not implemented"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn ensure_account_exists(state: &AppState, account_id: i64) -> AppResult<()> {
|
||||
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
|
||||
.bind(account_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if account_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Account with id {} not found",
|
||||
account_id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_create_token(token: Option<String>) -> String {
|
||||
token
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty())
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use crate::{
|
||||
db::models::account::Account,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountsQuery {
|
||||
pub site_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountRequest {
|
||||
pub site_id: i64,
|
||||
pub username: Option<String>,
|
||||
pub access_token: String,
|
||||
pub api_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub extra_config: Option<String>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub username: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub api_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub extra_config: Option<String>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/accounts?site_id=
|
||||
pub async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAccountsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing accounts, site_id filter: {:?}", query.site_id);
|
||||
|
||||
let accounts: Vec<Account> = if let Some(site_id) = query.site_id {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT * FROM accounts WHERE site_id = ? ORDER BY sort_order, id",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, sort_order, id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": accounts })))
|
||||
}
|
||||
|
||||
/// POST /api/accounts
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateAccountRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating account for site_id: {}", payload.site_id);
|
||||
|
||||
if payload.access_token.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("access_token is required".into()));
|
||||
}
|
||||
|
||||
let site_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM sites WHERE id = ?")
|
||||
.bind(payload.site_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if site_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Site with id {} not found",
|
||||
payload.site_id
|
||||
)));
|
||||
}
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config, oauth_provider, oauth_account_key, oauth_project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(payload.site_id)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.access_token)
|
||||
.bind(&payload.api_token)
|
||||
.bind(&status)
|
||||
.bind(payload.checkin_enabled)
|
||||
.bind(&payload.extra_config)
|
||||
.bind(&payload.oauth_provider)
|
||||
.bind(&payload.oauth_account_key)
|
||||
.bind(&payload.oauth_project_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account_id = result.last_insert_rowid();
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(account_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": account }))))
|
||||
}
|
||||
|
||||
/// GET /api/accounts/{id}
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting account: {}", id);
|
||||
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
}
|
||||
|
||||
/// PUT /api/accounts/{id}
|
||||
pub async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateAccountRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating account: {}", id);
|
||||
|
||||
let existing: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET username = ?, access_token = ?, api_token = ?, status = ?, checkin_enabled = ?, extra_config = ?, oauth_provider = ?, oauth_account_key = ?, oauth_project_id = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.or(existing.username))
|
||||
.bind(payload.access_token.as_ref().unwrap_or(&existing.access_token))
|
||||
.bind(payload.api_token.or(existing.api_token))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.checkin_enabled.or(existing.checkin_enabled))
|
||||
.bind(payload.extra_config.or(existing.extra_config))
|
||||
.bind(payload.oauth_provider.or(existing.oauth_provider))
|
||||
.bind(payload.oauth_account_key.or(existing.oauth_account_key))
|
||||
.bind(payload.oauth_project_id.or(existing.oauth_project_id))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
}
|
||||
|
||||
/// DELETE /api/accounts/{id}
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting account: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Account with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::{
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDownstreamKeyRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateDownstreamKeyRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
}
|
||||
|
||||
/// Helper to validate models from a JSON string against a Vec<String>
|
||||
fn validate_supported_models(models: &[String]) -> AppResult<()> {
|
||||
for model in models {
|
||||
if model.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"supported_models contains empty model name".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /api/downstream-api-keys
|
||||
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys");
|
||||
|
||||
let keys: Vec<DownstreamApiKey> =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/downstream-api-keys
|
||||
pub async fn create_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateDownstreamKeyRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating downstream API key: {}", payload.name);
|
||||
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
|
||||
// Auto-generate key
|
||||
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
// Serialize supported_models to JSON string if provided
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
validate_supported_models(models)?;
|
||||
Some(
|
||||
serde_json::to_string(models)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
.bind(&payload.description)
|
||||
.bind(&payload.group_name)
|
||||
.bind(&payload.tags)
|
||||
.bind(enabled)
|
||||
.bind(&payload.expires_at)
|
||||
.bind(payload.max_cost)
|
||||
.bind(payload.max_requests)
|
||||
.bind(&supported_models)
|
||||
.bind(&payload.allowed_route_ids)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key_id = result.last_insert_rowid();
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
|
||||
}
|
||||
|
||||
/// PUT /api/downstream-api-keys/{id}
|
||||
pub async fn update_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateDownstreamKeyRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating downstream API key: {}", id);
|
||||
|
||||
let existing: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("Downstream key with id {} not found", id))
|
||||
})?;
|
||||
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
validate_supported_models(models)?;
|
||||
Some(
|
||||
serde_json::to_string(models)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
|
||||
)
|
||||
} else {
|
||||
existing.supported_models.clone()
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.description.or(existing.description))
|
||||
.bind(payload.group_name.or(existing.group_name))
|
||||
.bind(payload.tags.or(existing.tags))
|
||||
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
|
||||
.bind(payload.expires_at.or(existing.expires_at))
|
||||
.bind(payload.max_cost.or(existing.max_cost))
|
||||
.bind(payload.max_requests.or(existing.max_requests))
|
||||
.bind(&supported_models)
|
||||
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
/// DELETE /api/downstream-api-keys/{id}
|
||||
pub async fn delete_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting downstream API key: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Downstream key with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::{
|
||||
db::models::setting::Setting,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
mod downstream_keys;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
/// Middleware to require admin authentication via Bearer token.
|
||||
async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
request: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
if auth_header != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Build all admin routes. Returns Router<AppState> (without calling .with_state).
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let sites_routes = Router::new()
|
||||
.route(
|
||||
"/api/sites",
|
||||
get(sites::list_sites).post(sites::create_site),
|
||||
)
|
||||
.route(
|
||||
"/api/sites/{id}",
|
||||
get(sites::get_site)
|
||||
.put(sites::update_site)
|
||||
.delete(sites::delete_site),
|
||||
);
|
||||
|
||||
let accounts_routes = Router::new()
|
||||
.route(
|
||||
"/api/accounts",
|
||||
get(accounts::list_accounts).post(accounts::create_account),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}",
|
||||
get(accounts::get_account)
|
||||
.put(accounts::update_account)
|
||||
.delete(accounts::delete_account),
|
||||
);
|
||||
|
||||
let tokens_routes = Router::new()
|
||||
.route(
|
||||
"/api/tokens",
|
||||
get(tokens::list_tokens).post(tokens::create_token),
|
||||
)
|
||||
.route(
|
||||
"/api/tokens/refresh-route",
|
||||
get(tokens::refresh_route).post(tokens::refresh_route),
|
||||
)
|
||||
.route("/api/tokens/route-decision", get(tokens::route_decision))
|
||||
.route(
|
||||
"/api/tokens/{id}",
|
||||
get(tokens::get_token)
|
||||
.put(tokens::update_token)
|
||||
.delete(tokens::delete_token),
|
||||
);
|
||||
|
||||
let downstream_routes = Router::new()
|
||||
.route(
|
||||
"/api/downstream-api-keys",
|
||||
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-api-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
);
|
||||
|
||||
let settings_routes =
|
||||
Router::new().route("/api/settings", get(get_settings).put(update_setting));
|
||||
|
||||
let users_routes = Router::new()
|
||||
.route(
|
||||
"/api/admin/users",
|
||||
get(users::list_users).post(users::create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}",
|
||||
get(users::get_user)
|
||||
.put(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
.merge(accounts_routes)
|
||||
.merge(tokens_routes)
|
||||
.merge(downstream_routes)
|
||||
.merge(settings_routes)
|
||||
.merge(users_routes)
|
||||
.merge(account_tokens::routes())
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
}
|
||||
|
||||
// ── Settings handlers ────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/settings — return all settings as a key-value map.
|
||||
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Getting all settings");
|
||||
|
||||
let rows: Vec<Setting> =
|
||||
sqlx::query_as::<_, Setting>("SELECT key, value FROM settings ORDER BY key")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let map: std::collections::BTreeMap<String, Option<String>> =
|
||||
rows.into_iter().map(|s| (s.key, s.value)).collect();
|
||||
|
||||
Ok(Json(json!({ "data": map })))
|
||||
}
|
||||
|
||||
/// PUT /api/settings — upsert a setting by key.
|
||||
/// Expects JSON body: { "key": "...", "value": "..." }
|
||||
async fn update_setting(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let key = payload
|
||||
.get("key")
|
||||
.and_then(|k| k.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
|
||||
|
||||
let value = payload.get("value").and_then(|v| {
|
||||
if v.is_null() {
|
||||
None
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
info!("Upserting setting: {}", key);
|
||||
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(key)
|
||||
.bind(&value)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"key": key,
|
||||
"value": value
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use crate::{
|
||||
db::models::site::Site,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSiteRequest {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub platform: String,
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateSiteRequest {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
}
|
||||
|
||||
/// GET /api/sites
|
||||
pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing all sites");
|
||||
|
||||
let sites: Vec<Site> =
|
||||
sqlx::query_as::<_, Site>("SELECT * FROM sites ORDER BY sort_order, name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": sites })))
|
||||
}
|
||||
|
||||
/// POST /api/sites
|
||||
pub async fn create_site(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateSiteRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating site: {}", payload.name);
|
||||
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
if payload.url.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("url is required".into()));
|
||||
}
|
||||
if payload.platform.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("platform is required".into()));
|
||||
}
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO sites (name, url, platform, status, api_key, proxy_url, use_system_proxy, custom_headers, global_weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.url)
|
||||
.bind(&payload.platform)
|
||||
.bind(&status)
|
||||
.bind(&payload.api_key)
|
||||
.bind(&payload.proxy_url)
|
||||
.bind(payload.use_system_proxy)
|
||||
.bind(&payload.custom_headers)
|
||||
.bind(payload.global_weight)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let site_id = result.last_insert_rowid();
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": site }))))
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}
|
||||
pub async fn get_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting site: {}", id);
|
||||
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": site })))
|
||||
}
|
||||
|
||||
/// PUT /api/sites/{id}
|
||||
pub async fn update_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateSiteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating site: {}", id);
|
||||
|
||||
let existing: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sites SET name = ?, url = ?, platform = ?, status = ?, api_key = ?, proxy_url = ?, use_system_proxy = ?, custom_headers = ?, global_weight = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.url.unwrap_or(existing.url))
|
||||
.bind(payload.platform.unwrap_or(existing.platform))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.api_key.or(existing.api_key))
|
||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||
.bind(payload.use_system_proxy.or(existing.use_system_proxy))
|
||||
.bind(payload.custom_headers.or(existing.custom_headers))
|
||||
.bind(payload.global_weight.or(existing.global_weight))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": site })))
|
||||
}
|
||||
|
||||
/// DELETE /api/sites/{id}
|
||||
pub async fn delete_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting site: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!("Site with id {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use crate::{
|
||||
db::models::account::AccountToken,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListTokensQuery {
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTokenRequest {
|
||||
pub account_id: i64,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTokenRequest {
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRouteRequest {
|
||||
pub model_pattern: Option<String>,
|
||||
pub route_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// GET /api/tokens?account_id=
|
||||
pub async fn list_tokens(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListTokensQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing tokens, account_id filter: {:?}", query.account_id);
|
||||
|
||||
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY id",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens ORDER BY account_id, id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": tokens })))
|
||||
}
|
||||
|
||||
/// POST /api/tokens
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating token for account_id: {}", payload.account_id);
|
||||
|
||||
// Validate
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
if payload.token.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("token is required".into()));
|
||||
}
|
||||
|
||||
// Verify account exists
|
||||
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
|
||||
.bind(payload.account_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if account_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Account with id {} not found",
|
||||
payload.account_id
|
||||
)));
|
||||
}
|
||||
|
||||
let source = payload.source.unwrap_or_else(|| "manual".to_string());
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
let is_default = payload.is_default.unwrap_or(false);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO account_tokens (account_id, name, token, source, token_group, enabled, is_default) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.token)
|
||||
.bind(&source)
|
||||
.bind(&payload.token_group)
|
||||
.bind(enabled)
|
||||
.bind(is_default)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token_id = result.last_insert_rowid();
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(token_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": token }))))
|
||||
}
|
||||
|
||||
/// GET /api/tokens/{id}
|
||||
pub async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting token: {}", id);
|
||||
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": token })))
|
||||
}
|
||||
|
||||
/// PUT /api/tokens/{id}
|
||||
pub async fn update_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating token: {}", id);
|
||||
|
||||
let existing: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE account_tokens SET name = ?, token = ?, source = ?, token_group = ?, enabled = ?, is_default = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.token.as_ref().unwrap_or(&existing.token))
|
||||
.bind(payload.source.unwrap_or(existing.source.unwrap_or_else(|| "manual".to_string())))
|
||||
.bind(payload.token_group.or(existing.token_group))
|
||||
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
|
||||
.bind(payload.is_default.unwrap_or(existing.is_default.unwrap_or(false)))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": token })))
|
||||
}
|
||||
|
||||
/// DELETE /api/tokens/{id}
|
||||
pub async fn delete_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting token: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Token with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
/// POST /api/tokens/refresh-route
|
||||
pub async fn refresh_route(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RefreshRouteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Refreshing route decision");
|
||||
|
||||
if let Some(route_id) = payload.route_id {
|
||||
sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(route_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let route: Option<crate::db::models::token_route::TokenRoute> =
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
|
||||
.bind(route_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(
|
||||
json!({ "data": { "refreshed": true, "route": route } }),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref model_pattern) = payload.model_pattern {
|
||||
let result = sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE model_pattern LIKE ?"
|
||||
)
|
||||
.bind(format!("%{}%", model_pattern))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(json!({
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"affected_routes": result.rows_affected()
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
// Refresh all routes
|
||||
let result = sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now')"
|
||||
)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"affected_routes": result.rows_affected()
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/tokens/route-decision
|
||||
pub async fn route_decision(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<RefreshRouteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting route decisions");
|
||||
|
||||
let routes: Vec<crate::db::models::token_route::TokenRoute> =
|
||||
if let Some(route_id) = query.route_id {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE id = ? AND enabled = 1")
|
||||
.bind(route_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.map(|r| vec![r])
|
||||
.unwrap_or_default()
|
||||
} else if let Some(ref model_pattern) = query.model_pattern {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE model_pattern LIKE ? AND enabled = 1")
|
||||
.bind(format!("%{}%", model_pattern))
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE enabled = 1 ORDER BY model_pattern")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": routes })))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Admin user management — CRUD for platform users
|
||||
|
||||
use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/admin/users
|
||||
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing admin users");
|
||||
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
||||
Ok(Json(json!({ "data": items })))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{id}
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting user: {}", id);
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
|
||||
info!("Creating user: {}", payload.username);
|
||||
|
||||
if payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("username is required".into()));
|
||||
}
|
||||
if payload.email.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
||||
|
||||
let result =
|
||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.bind(&role)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(json!({ "data": UserResponse::from(user) })),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/users/{id}
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating user: {}", id);
|
||||
|
||||
let existing: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
let password_hash = if let Some(ref pw) = payload.password {
|
||||
if pw.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
Some(
|
||||
hash_password(pw)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.unwrap_or(existing.username))
|
||||
.bind(payload.email.unwrap_or(existing.email))
|
||||
.bind(payload.role.unwrap_or(existing.role))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(password_hash)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id}
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting user: {}", id);
|
||||
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!("User {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// User authentication routes — register, login, profile
|
||||
// These are public (no admin token required, uses JWT)
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::generate_jwt, password::verify_password},
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/me", get(me))
|
||||
.route("/api/users/me", patch(update_me))
|
||||
.route("/api/users/me/password", post(change_password))
|
||||
.route("/api/users/{id}", get(get_user))
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Registering user: {}", payload.email);
|
||||
|
||||
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"email and username are required".into(),
|
||||
));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::BadRequest("Email already registered".into()));
|
||||
}
|
||||
|
||||
let password_hash = crate::auth::password::hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Login: {}", payload.email);
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
|
||||
|
||||
if user.status != "active" {
|
||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||
}
|
||||
|
||||
let valid = verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||
}
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/auth/me — returns current user profile from JWT
|
||||
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// PATCH /api/users/me — update current user profile
|
||||
async fn update_me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
let username = body.get("username").and_then(|v| v.as_str());
|
||||
if let Some(name) = username {
|
||||
if !name.trim().is_empty() {
|
||||
sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||||
.bind(name.trim())
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/users/me/password — change current user password
|
||||
async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
let old_pw = body
|
||||
.get("oldPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("oldPassword is required".into()))?;
|
||||
let new_pw = body
|
||||
.get("newPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("newPassword is required".into()))?;
|
||||
|
||||
if new_pw.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"new password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
let valid = crate::auth::password::verify_password(old_pw, &user.password_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Current password is incorrect".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_hash = crate::auth::password::hash_password(new_pw)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/users/:id — get user profile (self or admin)
|
||||
async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
if user_id != id && role != "admin" {
|
||||
return Err(AppError::Forbidden("Forbidden".into()));
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Inline JWT extract helper for this module ──────────────────────────
|
||||
|
||||
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let uid: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((uid, claims.role))
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Check-in routes — daily account check-in management
|
||||
|
||||
use crate::{
|
||||
db::models::account::Account,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckinLogQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
account_id: Option<i64>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct CheckinLog {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub reward: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct CheckinStats {
|
||||
pub account_id: i64,
|
||||
pub site_name: String,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub last_checkin: Option<String>,
|
||||
pub checkin_enabled: bool,
|
||||
pub balance: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/checkin", get(list_checkin_logs))
|
||||
.route("/api/checkin/stats", get(checkin_stats))
|
||||
.route("/api/checkin/accounts", get(list_checkin_accounts))
|
||||
.route(
|
||||
"/api/checkin/accounts/{id}/toggle",
|
||||
post(toggle_checkin_account),
|
||||
)
|
||||
.route("/api/checkin/run", post(run_checkin))
|
||||
}
|
||||
|
||||
/// GET /api/checkin — list checkin logs
|
||||
async fn list_checkin_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<CheckinLogQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing checkin logs");
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM checkin_logs WHERE 1=1".to_string();
|
||||
let mut params: Vec<String> = vec![];
|
||||
|
||||
if let Some(aid) = q.account_id {
|
||||
sql.push_str(&format!(" AND account_id = {}", aid));
|
||||
}
|
||||
if let Some(ref s) = q.status {
|
||||
sql.push_str(" AND status = ?");
|
||||
params.push(s.clone());
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
|
||||
let mut query = sqlx::query_as::<_, CheckinLog>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let logs: Vec<CheckinLog> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({ "data": logs })))
|
||||
}
|
||||
|
||||
/// GET /api/checkin/stats — checkin statistics
|
||||
async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Checkin stats");
|
||||
|
||||
let rows: Vec<CheckinStats> = sqlx::query_as::<_, CheckinStats>(
|
||||
"SELECT a.id as account_id, s.name as site_name, a.username, a.status,
|
||||
a.last_checkin_at as last_checkin,
|
||||
COALESCE(a.checkin_enabled, 0) as checkin_enabled,
|
||||
a.balance
|
||||
FROM accounts a
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.status = 'active'
|
||||
ORDER BY a.last_checkin_at ASC",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total = rows.len();
|
||||
let checked_today = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.last_checkin
|
||||
.as_deref()
|
||||
.map_or(false, |d| d.starts_with(&today_date()))
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(Json(json!({
|
||||
"total_accounts": total,
|
||||
"checked_today": checked_today,
|
||||
"pending": total - checked_today,
|
||||
"accounts": rows,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/checkin/accounts — list accounts with checkin info
|
||||
async fn list_checkin_accounts(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let accounts: Vec<Value> =
|
||||
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, username")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"site_id": a.site_id,
|
||||
"username": a.username,
|
||||
"status": a.status,
|
||||
"checkin_enabled": a.checkin_enabled,
|
||||
"last_checkin_at": a.last_checkin_at,
|
||||
"balance": a.balance,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "data": accounts })))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/accounts/{id}/toggle — enable/disable checkin
|
||||
async fn toggle_checkin_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Toggling checkin for account: {}", id);
|
||||
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
let new_value = !account.checkin_enabled.unwrap_or(true);
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET checkin_enabled = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(new_value)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "success": true, "checkin_enabled": new_value }),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/run — trigger checkin for all enabled accounts
|
||||
async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Running checkin for all enabled accounts");
|
||||
|
||||
let accounts: Vec<Account> = sqlx::query_as::<_, Account>(
|
||||
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut results: Vec<Value> = vec![];
|
||||
for account in &accounts {
|
||||
// Simple balance bonus checkin (matches Node.js basic behavior)
|
||||
let bonus = 0.01; // $0.01 per day
|
||||
sqlx::query("UPDATE accounts SET balance = COALESCE(balance, 0) + ?, last_checkin_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(bonus)
|
||||
.bind(account.id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
sqlx::query("INSERT INTO checkin_logs (account_id, status, message, reward) VALUES (?, 'success', 'Daily checkin completed', ?)")
|
||||
.bind(account.id)
|
||||
.bind(format!("+${:.4}", bonus))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
results.push(json!({
|
||||
"account_id": account.id,
|
||||
"username": account.username,
|
||||
"status": "success",
|
||||
"reward": bonus,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"processed": results.len(),
|
||||
"results": results,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Get today's date as YYYY-MM-DD
|
||||
fn today_date() -> String {
|
||||
use chrono::Utc;
|
||||
Utc::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// ComfyUI proxy routes — forward to local ComfyUI instance
|
||||
//
|
||||
// These routes proxy requests to a local ComfyUI server for AI video/image workflow editing.
|
||||
// All business routes require downstream API key or admin token authentication.
|
||||
// The /api/comfyui/proxy/* route serves static assets (public).
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Default ComfyUI backend address.
|
||||
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
|
||||
|
||||
/// Shared HTTP client
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── Auth helper (same pattern as proxy module) ────────────────────────
|
||||
|
||||
async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
// Admin token passes
|
||||
if auth == state.config.auth.auth_token {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
if !key.enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(()),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Unauthorized("Invalid token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get ComfyUI base URL from env or default
|
||||
fn comfyui_base() -> String {
|
||||
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Business routes (require auth)
|
||||
.route("/api/comfyui/prompt", post(proxy_post_prompt))
|
||||
.route("/api/comfyui/history", get(proxy_get_history))
|
||||
.route("/api/comfyui/history/{id}", get(proxy_get_history_by_id))
|
||||
.route("/api/comfyui/queue", get(proxy_get_queue))
|
||||
.route("/api/comfyui/view", get(proxy_get_view))
|
||||
// Static proxy (public — assets/JS/CSS)
|
||||
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
|
||||
// HTML page (public)
|
||||
.route("/comfyui", get(comfyui_page))
|
||||
}
|
||||
|
||||
// ── Business route handlers (require auth) ───────────────────────────
|
||||
|
||||
/// POST /api/comfyui/prompt
|
||||
async fn proxy_post_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response> {
|
||||
info!("POST /api/comfyui/prompt");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("POST", "/prompt", None, Some(body)).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history
|
||||
async fn proxy_get_history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/history");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/history", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history/{id}
|
||||
async fn proxy_get_history_by_id(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/history/{}", id);
|
||||
check_auth(&state, &headers).await?;
|
||||
let path = format!("/history/{}", urlencoding(&id));
|
||||
proxy_to_comfyui("GET", &path, None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/queue
|
||||
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/queue");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/queue", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
|
||||
async fn proxy_get_view(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/view");
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let qs = build_query_string(¶ms);
|
||||
let path = format!("/view{}", qs);
|
||||
proxy_to_comfyui_binary("GET", &path).await
|
||||
}
|
||||
|
||||
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
|
||||
|
||||
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
|
||||
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/proxy/{}", path);
|
||||
let target_path = if path.is_empty() || path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), target_path);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
if content_type.contains("html") {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
// Rebrand: ComfyUI → BoosAPI
|
||||
let rebranded = text
|
||||
.replace("ComfyUI", "BoosAPI")
|
||||
.replace("comfyui", "BoosAPI");
|
||||
// Rewrite URLs
|
||||
let rewritten = rewrite_proxy_urls(&rebranded);
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
rewritten,
|
||||
)
|
||||
.into_response())
|
||||
} else if content_type.contains("text") || content_type.contains("json") {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
text,
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /comfyui — HTML page with iframe
|
||||
async fn comfyui_page() -> Html<&'static str> {
|
||||
Html(
|
||||
r##"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI - ComfyUI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
|
||||
</body>
|
||||
</html>"##,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Rewrite URLs in HTML so asset paths go through the proxy
|
||||
fn rewrite_proxy_urls(html: &str) -> String {
|
||||
// Replace src="/...", href="/...", action="/..." with /api/comfyui/proxy/...
|
||||
let mut result = String::with_capacity(html.len() * 12 / 10);
|
||||
let mut chars = html.char_indices().peekable();
|
||||
|
||||
while let Some((_, ch)) = chars.next() {
|
||||
if ch == 's' || ch == 'h' || ch == 'a' {
|
||||
// Look ahead for rc="/", ref="/", ction="/"
|
||||
let snippet: String = chars.clone().take(6).map(|(_, c)| c).collect();
|
||||
let full = format!("{}{}", ch, snippet);
|
||||
if full.starts_with("src=\"/")
|
||||
|| full.starts_with("href=\"/")
|
||||
|| full.starts_with("action=\"/")
|
||||
{
|
||||
let attr_end = if full.starts_with("src=\"") {
|
||||
4
|
||||
} else if full.starts_with("href=\"") {
|
||||
5
|
||||
} else {
|
||||
7
|
||||
};
|
||||
result.push_str(&full[..attr_end]);
|
||||
result.push_str("/api/comfyui/proxy/");
|
||||
// Skip the rest of the attribute
|
||||
for _ in 0..snippet.len() {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Build query string from params
|
||||
fn build_query_string(params: &HashMap<String, String>) -> String {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut parts: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
|
||||
.collect();
|
||||
parts.sort(); // deterministic ordering
|
||||
format!("?{}", parts.join("&"))
|
||||
}
|
||||
|
||||
/// Simple URL encoding (handles the chars we need)
|
||||
fn urlencoding(s: &str) -> String {
|
||||
s.replace('%', "%25")
|
||||
.replace(' ', "%20")
|
||||
.replace('?', "%3F")
|
||||
.replace('&', "%26")
|
||||
.replace('#', "%23")
|
||||
.replace('/', "%2F")
|
||||
}
|
||||
|
||||
/// Proxy to ComfyUI — returns JSON/text response
|
||||
async fn proxy_to_comfyui(
|
||||
method: &str,
|
||||
path: &str,
|
||||
_query: Option<&HashMap<String, String>>,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
|
||||
let req = match method {
|
||||
"POST" => {
|
||||
let body_str = body
|
||||
.map(|b| b.to_string())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
}
|
||||
_ => client.get(&url),
|
||||
};
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
text,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Proxy to ComfyUI — returns binary response (for images)
|
||||
async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
|
||||
let req = client.get(&url);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||
use tracing::info;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
||||
|
||||
## 规则
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
||||
|
||||
## 结构化标记格式
|
||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
||||
|
||||
【CHARACTER】
|
||||
name: 林澈
|
||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
||||
【/CHARACTER】
|
||||
|
||||
【SCENE】
|
||||
id: 1
|
||||
description: 飞船驾驶舱内景
|
||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
||||
【/SCENE】
|
||||
|
||||
【TTS】
|
||||
character: 林澈
|
||||
text: 我们准备出舱检查
|
||||
emotion: 平静
|
||||
【/TTS】
|
||||
|
||||
【WORKFLOW】
|
||||
```json
|
||||
{"comfyui_workflow": {}}
|
||||
```
|
||||
【/WORKFLOW】
|
||||
|
||||
## 关键要求
|
||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSections {
|
||||
characters: Vec<CharacterSection>,
|
||||
scenes: Vec<SceneSection>,
|
||||
tts_list: Vec<TtsSection>,
|
||||
workflow_json: Option<String>,
|
||||
text_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CharacterSection {
|
||||
name: String,
|
||||
description: String,
|
||||
prompt_front: String,
|
||||
prompt_back: String,
|
||||
prompt_left: String,
|
||||
prompt_right: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SceneSection {
|
||||
id: u32,
|
||||
description: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TtsSection {
|
||||
character: String,
|
||||
text: String,
|
||||
emotion: String,
|
||||
}
|
||||
|
||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let mut sections = ParsedSections::default();
|
||||
sections.text_content = full_text.to_string();
|
||||
|
||||
// Parse CHARACTER blocks
|
||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
||||
for cap in char_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut char_section = CharacterSection::default();
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
|
||||
char_section.name = val.trim().to_string();
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
char_section.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_front = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_back = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_left = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_right = val.to_string();
|
||||
}
|
||||
}
|
||||
if !char_section.name.is_empty() {
|
||||
sections.characters.push(char_section);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SCENE blocks
|
||||
let scene_re = Regex::new(r"(?s)【SCENE】\s*(.*?)【/SCENE】").unwrap();
|
||||
for cap in scene_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut scene = SceneSection {
|
||||
id: 0,
|
||||
description: String::new(),
|
||||
prompt: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.id = val.parse().unwrap_or(0);
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
scene.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.prompt = val.to_string();
|
||||
}
|
||||
}
|
||||
if !scene.prompt.is_empty() {
|
||||
sections.scenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TTS blocks
|
||||
let tts_re = Regex::new(r"(?s)【TTS】\s*(.*?)【/TTS】").unwrap();
|
||||
for cap in tts_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut tts = TtsSection {
|
||||
character: String::new(),
|
||||
text: String::new(),
|
||||
emotion: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.character = val.to_string();
|
||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.text = val.to_string();
|
||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.emotion = val.to_string();
|
||||
}
|
||||
}
|
||||
if !tts.text.is_empty() {
|
||||
sections.tts_list.push(tts);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse WORKFLOW blocks
|
||||
let wf_re = Regex::new(r"(?s)【WORKFLOW】\s*```(?:json)?\s*(.*?)```\s*【/WORKFLOW】").unwrap();
|
||||
if let Some(cap) = wf_re.captures(full_text) {
|
||||
let wf = cap.get(1).unwrap().as_str().trim().to_string();
|
||||
sections.workflow_json = Some(wf);
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("image response parse failed: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("image upstream returned {}: {}", status, data));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("TTS request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("TTS upstream returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("TTS response read failed: {}", e))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
}
|
||||
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
||||
{
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||
.unwrap_or_default();
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
));
|
||||
}
|
||||
|
||||
let messages = body
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
for message in messages {
|
||||
if message.get("role").and_then(Value::as_str).is_none()
|
||||
|| message.get("content").and_then(Value::as_str).is_none()
|
||||
{
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = messages.clone();
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
|
||||
|
||||
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||
upstream_messages.push(json!({
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
}));
|
||||
upstream_messages.extend(messages);
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
info!("comfyui-agent: sending chat request to upstream");
|
||||
|
||||
let response = match client
|
||||
.post(&upstream_url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": upstream_messages,
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("服务暂时不可用,请稍后重试。({})", e),
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "服务暂时不可用,请稍后重试",
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Phase 1: Stream the LLM text response
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
_ = tx.closed() => {
|
||||
return;
|
||||
}
|
||||
chunk = stream.next() => chunk,
|
||||
};
|
||||
|
||||
let Some(chunk) = chunk else {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n\n[错误: {}]", error),
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let mut line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
|
||||
|
||||
if line_bytes.last() == Some(&b'\n') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
if line_bytes.last() == Some(&b'\r') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
let line = line.trim().to_string();
|
||||
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = line.trim_start_matches("data: ").trim().to_string();
|
||||
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = parsed
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if !content.is_empty() {
|
||||
accumulated_text.push_str(&content);
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": content,
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse structured sections from accumulated text
|
||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||
let sections = parse_sections(&accumulated_text);
|
||||
|
||||
let has_structured_data = !sections.characters.is_empty()
|
||||
|| !sections.scenes.is_empty()
|
||||
|| !sections.tts_list.is_empty()
|
||||
|| sections.workflow_json.is_some();
|
||||
|
||||
if !has_structured_data {
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a processing indicator
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "\n\n🔄 正在处理结构化数据..."
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate images for each character (4 angles each)
|
||||
for (char_idx, character) in sections.characters.iter().enumerate() {
|
||||
// Send character_prompts event
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "character_prompts",
|
||||
"character": character.name,
|
||||
}),
|
||||
);
|
||||
|
||||
let angles = [
|
||||
("正面", &character.prompt_front, "front"),
|
||||
("背面", &character.prompt_back, "back"),
|
||||
("左侧", &character.prompt_left, "left"),
|
||||
("右侧", &character.prompt_right, "right"),
|
||||
];
|
||||
|
||||
for (angle_label, prompt, angle_key) in &angles {
|
||||
if prompt.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"comfyui-agent: generating image for {} - {}",
|
||||
character.name, angle_label
|
||||
);
|
||||
|
||||
// Add a short delay between image gen requests to avoid rate limiting
|
||||
if char_idx > 0 || *angle_key != "front" {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "image",
|
||||
"url": url,
|
||||
"character": character.name,
|
||||
"angle": angle_key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} [{}] 图片生成失败,请重试", character.name, angle_label),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate images for each scene
|
||||
for scene in §ions.scenes {
|
||||
info!("comfyui-agent: generating scene image for scene {}", scene.id);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Still send scene info even without image
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TTS for each dialogue
|
||||
for tts in §ions.tts_list {
|
||||
info!("comfyui-agent: generating TTS for {}", tts.character);
|
||||
|
||||
// Choose voice based on emotion
|
||||
let voice = match tts.emotion.as_str() {
|
||||
"激动" | "愤怒" => "nova",
|
||||
"悲伤" | "恐惧" => "shimmer",
|
||||
_ => "alloy",
|
||||
};
|
||||
|
||||
match call_tts(&tts.text, voice).await {
|
||||
Ok(audio_bytes) => {
|
||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
||||
// Since SSE can't easily send binary, we store to file and send URL
|
||||
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
|
||||
tts.character.replace(' ', "_"),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
if let Ok(()) = tokio::fs::write(&audio_path, &audio_bytes).await {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "tts",
|
||||
"character": tts.character,
|
||||
"text": tts.text,
|
||||
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("comfyui-agent: TTS generation failed: {}", e);
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} 的语音生成失败", tts.character),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Send ComfyUI workflow if present
|
||||
if let Some(wf_json) = §ions.workflow_json {
|
||||
info!("comfyui-agent: sending workflow JSON");
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "comfyui_workflow",
|
||||
"content": wf_json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Signal done with summary
|
||||
let mut summary = String::from("\n\n✅ **处理完成!**");
|
||||
if !sections.characters.is_empty() {
|
||||
summary.push_str(&format!("\n- 角色: {} 个", sections.characters.len()));
|
||||
}
|
||||
if !sections.scenes.is_empty() {
|
||||
summary.push_str(&format!("\n- 场景: {} 个", sections.scenes.len()));
|
||||
}
|
||||
if !sections.tts_list.is_empty() {
|
||||
summary.push_str(&format!("\n- TTS: {} 段", sections.tts_list.len()));
|
||||
}
|
||||
if sections.workflow_json.is_some() {
|
||||
summary.push_str("\n- ComfyUI 工作流: ✅ 已生成");
|
||||
}
|
||||
send_sse(&tx, json!({ "type": "text", "content": summary }));
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Events routes — system event notifications
|
||||
|
||||
use crate::{
|
||||
db::models::event::Event,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEventsQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
#[serde(rename = "type")]
|
||||
filter_type: Option<String>,
|
||||
read: Option<String>, // "true" | "false"
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/events", get(list_events).delete(clear_events))
|
||||
.route("/api/events/count", get(unread_count))
|
||||
.route("/api/events/{id}/read", post(mark_read))
|
||||
.route("/api/events/read-all", post(mark_all_read))
|
||||
}
|
||||
|
||||
/// GET /api/events
|
||||
async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListEventsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing events");
|
||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
||||
let mut bind_idx = 0;
|
||||
let mut params: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = q.filter_type {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
||||
params.push(t.clone());
|
||||
}
|
||||
if let Some(ref r) = q.read {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read = 1");
|
||||
} else if r == "false" {
|
||||
sql.push_str(" AND read = 0");
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
|
||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({ "data": events })))
|
||||
}
|
||||
|
||||
/// GET /api/events/count — unread count
|
||||
async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events WHERE read = 0")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "count": count.0 })))
|
||||
}
|
||||
|
||||
/// POST /api/events/{id}/read
|
||||
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
info!("Marking event {} as read", id);
|
||||
sqlx::query("UPDATE events SET read = 1 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/events/read-all
|
||||
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Marking all events as read");
|
||||
sqlx::query("UPDATE events SET read = 1 WHERE read = 0")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/events
|
||||
async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Clearing all events");
|
||||
sqlx::query("DELETE FROM events")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn health_check(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Simple DB ping
|
||||
sqlx::query("SELECT 1").execute(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "ok",
|
||||
"database": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, Uri},
|
||||
response::{Html, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod checkin;
|
||||
mod comfyui;
|
||||
mod comfyui_agent;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
mod proxy;
|
||||
mod search;
|
||||
mod site_announcements;
|
||||
mod stats;
|
||||
mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
mod user_api_keys;
|
||||
mod video_agent;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
|
||||
.unwrap_or_else(|_| "dist/web".to_string());
|
||||
|
||||
// Build the API router
|
||||
let api_routes = Router::new()
|
||||
.route("/healthz", get(health::health_check))
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(comfyui::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(events::routes())
|
||||
.merge(checkin::routes())
|
||||
.merge(monitor::routes())
|
||||
.merge(oauth::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(update_center::routes())
|
||||
.merge(user::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.merge(video_agent::routes())
|
||||
.with_state(state);
|
||||
|
||||
// Static file serving with SPA fallback
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", req.uri().path())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.merge(api_routes)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Monitor routes — LDOH incident dashboard proxy with cookie auth
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
||||
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
||||
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/monitor/config",
|
||||
get(get_monitor_config).put(update_monitor_config),
|
||||
)
|
||||
.route("/api/monitor/session", post(create_session))
|
||||
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
|
||||
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
|
||||
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
|
||||
}
|
||||
|
||||
/// GET /api/monitor/config — returns whether LDOH cookie is configured
|
||||
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let raw = cookie
|
||||
.and_then(|(v,)| {
|
||||
serde_json::from_str::<serde_json::Value>(&v)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let configured = !raw.is_empty();
|
||||
let masked = if configured {
|
||||
mask_cookie_value(&raw)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"ldohCookieConfigured": configured,
|
||||
"ldohCookieMasked": masked,
|
||||
})))
|
||||
}
|
||||
|
||||
/// PUT /api/monitor/config — save LDOH cookie
|
||||
async fn update_monitor_config(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let cookie = body
|
||||
.get("ldohCookie")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if cookie.is_empty() {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
return Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": "LDOH Cookie 已清空",
|
||||
"ldohCookieConfigured": false,
|
||||
})));
|
||||
}
|
||||
|
||||
let normalized = normalize_ldoh_cookie(cookie);
|
||||
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
|
||||
return Err(AppError::BadRequest("Cookie 格式无效".into()));
|
||||
}
|
||||
|
||||
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.bind(&escaped)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": "LDOH Cookie 已保存",
|
||||
"ldohCookieConfigured": true,
|
||||
"ldohCookieMasked": mask_cookie_value(&normalized),
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/monitor/session — returns cookie value for client to set
|
||||
async fn create_session(State(state): State<AppState>) -> Json<Value> {
|
||||
let token = &state.config.auth.auth_token;
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
|
||||
async fn ldoh_proxy_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Auth check via cookie
|
||||
let cookies = parse_cookies(
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Missing or invalid monitor session".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get stored LDOH cookie
|
||||
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let ldoh_cookie = stored
|
||||
.and_then(|(v,)| {
|
||||
serde_json::from_str::<serde_json::Value>(&v)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if ldoh_cookie.is_empty() {
|
||||
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
let clean_path = if path.is_empty() || path == "/" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
|
||||
|
||||
let mut req = client
|
||||
.get(&target_url)
|
||||
.header("cookie", &ldoh_cookie)
|
||||
.header(
|
||||
"accept",
|
||||
headers
|
||||
.get("accept")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("*/*"),
|
||||
)
|
||||
.header(
|
||||
"accept-language",
|
||||
headers
|
||||
.get("accept-language")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
|
||||
)
|
||||
.header(
|
||||
"user-agent",
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("boosapiMonitorProxy/1.0"),
|
||||
);
|
||||
|
||||
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
if let Some(ctl) = resp
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
builder = builder.header("cache-control", ctl);
|
||||
}
|
||||
|
||||
// Rewrite text content
|
||||
if content_type.contains("text/html")
|
||||
|| content_type.contains("javascript")
|
||||
|| content_type.contains("text/css")
|
||||
|| content_type.contains("application/json")
|
||||
{
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
return Ok(builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok(builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(idx) = part.find('=') {
|
||||
let key = part[..idx].trim().to_string();
|
||||
let val = part[idx + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
map.insert(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn mask_cookie_value(cookie_text: &str) -> String {
|
||||
let raw = cookie_text.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let value = raw.split('=').nth(1).unwrap_or(raw);
|
||||
if value.len() <= 10 {
|
||||
format!("{}****", &value[..2.min(value.len())])
|
||||
} else {
|
||||
format!("{}****{}", &value[..6], &value[value.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ldoh_cookie(raw: &str) -> String {
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.contains("ld_auth_session=") {
|
||||
if let Some(first) = trimmed.split(';').next() {
|
||||
if first.trim().starts_with("ld_auth_session=") {
|
||||
return first.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("ld_auth_session={}", trimmed)
|
||||
}
|
||||
|
||||
fn rewrite_proxy_text(text: &str) -> String {
|
||||
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
|
||||
.replace(
|
||||
"https:\\/\\/ldoh.105117.xyz\\/",
|
||||
"\\/monitor-proxy\\/ldoh\\/",
|
||||
)
|
||||
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
|
||||
.replace("src='/", "src='/monitor-proxy/ldoh/")
|
||||
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
|
||||
.replace("href='/", "href='/monitor-proxy/ldoh/")
|
||||
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
|
||||
.replace("action='/", "action='/monitor-proxy/ldoh/")
|
||||
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
|
||||
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
|
||||
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
|
||||
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
|
||||
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// OAuth routes — provider management, session handling, connection management
|
||||
// Simplified implementation — complex OAuth flow operations return 501
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Providers
|
||||
.route("/api/oauth/providers", get(list_providers))
|
||||
.route(
|
||||
"/api/oauth/providers/{provider}/start",
|
||||
post(start_provider_flow),
|
||||
)
|
||||
// Sessions
|
||||
.route("/api/oauth/sessions/{state}", get(get_session))
|
||||
.route(
|
||||
"/api/oauth/sessions/{state}/manual-callback",
|
||||
post(manual_callback),
|
||||
)
|
||||
// Connections
|
||||
.route("/api/oauth/connections", get(list_connections))
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/rebind",
|
||||
post(rebind_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/proxy",
|
||||
post(update_connection_proxy),
|
||||
)
|
||||
.route("/api/oauth/connections/import", post(import_connections))
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/refresh-quota",
|
||||
post(refresh_connection_quota),
|
||||
)
|
||||
.route(
|
||||
"/api/oauth/connections/refresh-quota-batch",
|
||||
post(refresh_quota_batch),
|
||||
)
|
||||
// Route units
|
||||
.route("/api/oauth/route-units", post(create_route_unit))
|
||||
.route(
|
||||
"/api/oauth/route-units/{id}",
|
||||
put(update_route_unit).delete(delete_route_unit),
|
||||
)
|
||||
// Callback
|
||||
.route("/api/oauth/callback", get(oauth_callback_page))
|
||||
// Delete connection
|
||||
.route("/api/oauth/connections/{id}", post(delete_connection))
|
||||
}
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let token = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/providers — public
|
||||
async fn list_providers() -> Json<Value> {
|
||||
Json(json!({
|
||||
"defaults": {
|
||||
"proxyRequired": false,
|
||||
"useSystemProxy": false,
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"type": "openai",
|
||||
"enabled": true,
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"type": "anthropic",
|
||||
"enabled": true,
|
||||
},
|
||||
{
|
||||
"id": "google",
|
||||
"name": "Google AI",
|
||||
"type": "google",
|
||||
"enabled": true,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/providers/{provider}/start
|
||||
async fn start_provider_flow(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_provider): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth provider flow start: {}", _provider);
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth provider flow not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/sessions/{state}
|
||||
async fn get_session(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(state): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth session lookup: {}", state);
|
||||
Err(AppError::NotFound(format!(
|
||||
"OAuth session not found: {}",
|
||||
state
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/sessions/{state}/manual-callback
|
||||
async fn manual_callback(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(state): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth manual callback: {}", state);
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth manual callback not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Connections ────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/connections
|
||||
async fn list_connections(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &state)?;
|
||||
info!("Listing OAuth connections");
|
||||
|
||||
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, i64, Option<String>, String, Option<String>, Option<String>, Option<String>, String)>(
|
||||
"SELECT id, site_id, username, access_token, oauth_provider, oauth_account_key, oauth_project_id, status \
|
||||
FROM accounts WHERE oauth_provider IS NOT NULL ORDER BY id"
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, site_id, username, _access_token, oauth_provider, oauth_account_key, oauth_project_id, status)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"siteId": site_id,
|
||||
"username": username,
|
||||
"oauthProvider": oauth_provider,
|
||||
"oauthAccountKey": oauth_account_key,
|
||||
"oauthProjectId": oauth_project_id,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "data": accounts, "total": accounts.len() })))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/rebind
|
||||
async fn rebind_connection(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth rebind not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/proxy
|
||||
async fn update_connection_proxy(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth proxy update not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/import
|
||||
async fn import_connections(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth import not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/refresh-quota
|
||||
async fn refresh_connection_quota(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth quota refresh not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/refresh-quota-batch
|
||||
async fn refresh_quota_batch(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth batch quota refresh not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Route Units ────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/oauth/route-units
|
||||
async fn create_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit creation not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/oauth/route-units/{id}
|
||||
async fn update_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit update not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/oauth/route-units/{id}
|
||||
async fn delete_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit deletion not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Callback ───────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/callback — public, returns HTML for popup window
|
||||
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
|
||||
info!("OAuth callback received");
|
||||
let html = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8" /><title>OAuth Callback</title></head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
OAuth callback received. You can close this window.
|
||||
</body>
|
||||
</html>"#;
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/html; charset=utf-8")],
|
||||
Html(html),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/oauth/connections/{id} — delete connection
|
||||
async fn delete_connection(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth connection deletion not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
// Organizations, members, and invitations routes
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::organization::{Invitation, Organization, OrganizationMember},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user_id: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((user_id, claims.role))
|
||||
}
|
||||
|
||||
/// Check if user is org admin (owner or admin role).
|
||||
async fn require_org_admin(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> AppResult<()> {
|
||||
let member: Option<OrganizationMember> =
|
||||
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
match member {
|
||||
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => {
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(AppError::Forbidden("Admin access required".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user is a member of an org.
|
||||
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
|
||||
let member: Option<OrganizationMember> =
|
||||
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(member.is_some())
|
||||
}
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListOrgsQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Organizations CRUD
|
||||
.route(
|
||||
"/api/organizations",
|
||||
get(list_organizations).post(create_organization),
|
||||
)
|
||||
.route("/api/organizations/plans", get(list_plans))
|
||||
.route(
|
||||
"/api/organizations/{org_id}",
|
||||
get(get_organization).patch(update_organization),
|
||||
)
|
||||
// Members
|
||||
.route("/api/organizations/{org_id}/members", get(list_members))
|
||||
.route(
|
||||
"/api/organizations/{org_id}/members/{user_id}",
|
||||
patch(update_member_role).delete(remove_member),
|
||||
)
|
||||
// Invitations
|
||||
.route(
|
||||
"/api/organizations/{org_id}/invitations",
|
||||
get(list_invitations).post(create_invitation),
|
||||
)
|
||||
.route(
|
||||
"/api/organizations/{org_id}/invitations/{inv_id}",
|
||||
delete(cancel_invitation),
|
||||
)
|
||||
// Public invitation routes (no org prefix)
|
||||
.route("/api/invitations/{token}", get(get_invitation))
|
||||
.route("/api/invitations/{token}/accept", post(accept_invitation))
|
||||
}
|
||||
|
||||
// ── Organization Handlers ──────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations
|
||||
async fn list_organizations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListOrgsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
|
||||
let orgs: Vec<Organization> = sqlx::query_as(
|
||||
r#"SELECT o.* FROM organizations o
|
||||
INNER JOIN organization_members om ON om.org_id = o.id
|
||||
WHERE om.user_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organizations": orgs })))
|
||||
}
|
||||
|
||||
/// POST /api/organizations
|
||||
async fn create_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
let name = body
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
|
||||
let slug = body
|
||||
.get("slug")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
|
||||
|
||||
let result = sqlx::query("INSERT INTO organizations (name, slug) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("UNIQUE") {
|
||||
AppError::BadRequest("Organization slug already exists".into())
|
||||
} else {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to create organization: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let org_id = result.last_insert_rowid();
|
||||
|
||||
// Add creator as owner
|
||||
sqlx::query("INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "organization": org }))))
|
||||
}
|
||||
|
||||
/// GET /api/organizations/plans
|
||||
async fn list_plans() -> AppResult<Json<Value>> {
|
||||
// Static plan definitions matching Node.js listAllPlans()
|
||||
let plans = json!([
|
||||
{
|
||||
"id": "free",
|
||||
"name": "免费",
|
||||
"description": "基础功能",
|
||||
"limits": { "members": 3, "sites": 2 },
|
||||
"price": 0,
|
||||
},
|
||||
{
|
||||
"id": "pro",
|
||||
"name": "专业版",
|
||||
"description": "进阶功能",
|
||||
"limits": { "members": 20, "sites": 20 },
|
||||
"price": 9900,
|
||||
},
|
||||
{
|
||||
"id": "enterprise",
|
||||
"name": "企业版",
|
||||
"description": "全功能",
|
||||
"limits": { "members": 100, "sites": 100 },
|
||||
"price": 49900,
|
||||
},
|
||||
]);
|
||||
Ok(Json(json!({ "plans": plans })))
|
||||
}
|
||||
|
||||
/// GET /api/organizations/:org_id
|
||||
async fn get_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
if !is_org_member(&state.db, org_id, user_id).await? {
|
||||
return Err(AppError::Forbidden(
|
||||
"You are not a member of this organization".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match org {
|
||||
Some(o) => Ok(Json(json!({ "organization": o }))),
|
||||
None => Err(AppError::NotFound("Organization not found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/organizations/:org_id
|
||||
async fn update_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let name = body.get("name").and_then(|v| v.as_str());
|
||||
let slug = body.get("slug").and_then(|v| v.as_str());
|
||||
|
||||
if name.is_none() && slug.is_none() {
|
||||
return Err(AppError::BadRequest("No fields to update".into()));
|
||||
}
|
||||
|
||||
if let Some(s) = slug {
|
||||
sqlx::query("UPDATE organizations SET slug = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(s)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(n) = name {
|
||||
sqlx::query("UPDATE organizations SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(n)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organization": org })))
|
||||
}
|
||||
|
||||
// ── Member Handlers ────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations/:org_id/members
|
||||
async fn list_members(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
if !is_org_member(&state.db, org_id, user_id).await? {
|
||||
return Err(AppError::Forbidden(
|
||||
"You are not a member of this organization".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
|
||||
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC",
|
||||
)
|
||||
.bind(org_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"orgId": m.org_id,
|
||||
"userId": m.user_id,
|
||||
"role": m.role,
|
||||
"status": m.status,
|
||||
"joinedAt": m.joined_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "members": members })))
|
||||
}
|
||||
|
||||
/// PATCH /api/organizations/:org_id/members/:user_id
|
||||
async fn update_member_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
|
||||
|
||||
if !["owner", "admin", "member"].contains(&role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Role must be one of: owner, admin, member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
|
||||
.bind(role)
|
||||
.bind(org_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/organizations/:org_id/members/:user_id
|
||||
async fn remove_member(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Invitation Handlers ────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations/:org_id/invitations
|
||||
async fn list_invitations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let invitations: Vec<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC")
|
||||
.bind(org_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "invitations": invitations })))
|
||||
}
|
||||
|
||||
/// POST /api/organizations/:org_id/invitations
|
||||
async fn create_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let email = body
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("member");
|
||||
|
||||
if !["admin", "member"].contains(&role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Role must be one of: admin, member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate token
|
||||
let token = format!("inv-{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
let expires_at = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::days(7))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO invitations (org_id, email, token, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(email)
|
||||
.bind(&token)
|
||||
.bind(role)
|
||||
.bind(user_id)
|
||||
.bind(&expires_at)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let inv: Invitation = sqlx::query_as("SELECT * FROM invitations WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "invitation": inv }))))
|
||||
}
|
||||
|
||||
/// DELETE /api/organizations/:org_id/invitations/:inv_id
|
||||
async fn cancel_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, inv_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
sqlx::query("UPDATE invitations SET status = 'cancelled' WHERE id = ? AND org_id = ?")
|
||||
.bind(inv_id)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Public Invitation Handlers ─────────────────────────────────────────
|
||||
|
||||
/// GET /api/invitations/:token — public, no auth required
|
||||
async fn get_invitation(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let invitation: Option<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
|
||||
.bind(&token)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match invitation {
|
||||
Some(i) => {
|
||||
// Check expiry
|
||||
let expired = i.expires_at.as_ref().map_or(false, |e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if expired {
|
||||
return Err(AppError::NotFound("Invitation not found or expired".into()));
|
||||
}
|
||||
|
||||
// Get org name
|
||||
let org_name: Option<(String,)> =
|
||||
sqlx::query_as("SELECT name FROM organizations WHERE id = ?")
|
||||
.bind(i.org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"invitation": {
|
||||
"id": i.id,
|
||||
"orgId": i.org_id,
|
||||
"orgName": org_name.map(|n| n.0).unwrap_or_default(),
|
||||
"email": i.email,
|
||||
"token": i.token,
|
||||
"role": i.role,
|
||||
"invitedByUsername": "",
|
||||
"status": i.status,
|
||||
"expiresAt": i.expires_at,
|
||||
"createdAt": i.created_at,
|
||||
}
|
||||
})))
|
||||
}
|
||||
None => Err(AppError::NotFound("Invitation not found or expired".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/invitations/:token/accept — JWT auth required
|
||||
async fn accept_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
let invitation: Option<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
|
||||
.bind(&token)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let inv = match invitation {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiry
|
||||
let expired = inv.expires_at.as_ref().map_or(true, |e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
if expired {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Mark invitation as accepted
|
||||
sqlx::query("UPDATE invitations SET status = 'accepted' WHERE id = ?")
|
||||
.bind(inv.id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Add user as member
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(inv.org_id)
|
||||
.bind(user_id)
|
||||
.bind(&inv.role)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(inv.org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organization": org })))
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// Proxy routes — OpenAI-compatible /v1/ endpoints
|
||||
//
|
||||
// These routes handle:
|
||||
// POST /v1/chat/completions — streaming and non-streaming
|
||||
// POST /v1/images/generations — image generation
|
||||
// GET /v1/models — list available models
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
error::{AppError, AppResult},
|
||||
proxy::conductor::{
|
||||
AttemptResult, ExecuteInput, ExecuteResult, ProxyConductor, SelectedChannel,
|
||||
},
|
||||
proxy::executors::{self, UpstreamRoute},
|
||||
proxy::token_router::TokenRouter,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::{stream, FutureExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyAuth {
|
||||
pub token: String,
|
||||
pub key_id: Option<i64>,
|
||||
pub source: DownstreamSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum DownstreamSource {
|
||||
Managed { key_id: i64 },
|
||||
Global,
|
||||
}
|
||||
|
||||
// ── Route definitions ─────────────────────────────────────────────────
|
||||
|
||||
/// Build the /v1/ proxy router
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/v1/completions", post(completions_handler))
|
||||
.route("/v1/embeddings", post(embeddings_handler))
|
||||
.route("/v1/audio/transcriptions", post(audio_transcriptions))
|
||||
.route("/v1/audio/translations", post(audio_translations))
|
||||
.route("/v1/audio/speech", post(audio_speech))
|
||||
.route("/v1/images/generations", post(images_generations))
|
||||
.route("/v1/videos", post(videos_handler))
|
||||
.route("/v1/models", get(list_models))
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /v1/chat/completions
|
||||
async fn chat_completions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("POST /v1/chat/completions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if stream {
|
||||
let chunks = execute_chat_completions_stream_with_conductor(state, model, body).await?;
|
||||
let sse =
|
||||
Sse::new(stream::iter(chunks).map(|chunk| {
|
||||
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
|
||||
}));
|
||||
Ok(sse.into_response())
|
||||
} else {
|
||||
let result = execute_chat_completions_with_conductor(state, model, body).await?;
|
||||
Ok(Json(result).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/completions
|
||||
async fn completions_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/completions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/completions", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/embeddings
|
||||
async fn embeddings_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/embeddings");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/embeddings", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/speech
|
||||
async fn audio_speech(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/speech");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/audio/speech", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/transcriptions — proxy to upstream
|
||||
async fn audio_transcriptions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/transcriptions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/translations — proxy to upstream
|
||||
async fn audio_translations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/translations");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/videos — list/create video generation tasks
|
||||
async fn videos_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/videos");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("default");
|
||||
|
||||
let route = resolve_route(&state, model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/videos", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/images/generations
|
||||
async fn images_generations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/images/generations");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_image_generation(state, route, body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /v1/models
|
||||
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
info!("GET /v1/models");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT tr.model_pattern, COALESCE(tr.model_mapping, tr.model_pattern) FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let models: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(name, model)| {
|
||||
json!({
|
||||
"id": model,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Auth helper ───────────────────────────────────────────────────────
|
||||
|
||||
async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
if !key.enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route Resolution ──────────────────────────────────────────────────
|
||||
|
||||
/// Resolve upstream endpoint + API key for a given model.
|
||||
async fn resolve_route(state: &AppState, model: &str) -> AppResult<UpstreamRoute> {
|
||||
let selected = TokenRouter::new(state.db.clone())
|
||||
.resolve_selected(model)
|
||||
.await?;
|
||||
Ok(selected_channel_to_route(selected))
|
||||
}
|
||||
|
||||
fn selected_channel_to_route(selected: SelectedChannel) -> UpstreamRoute {
|
||||
UpstreamRoute {
|
||||
base_url: selected.base_url,
|
||||
api_key: selected.api_key,
|
||||
model: selected.actual_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Value {
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.insert(
|
||||
"model".to_string(),
|
||||
Value::String(selected.actual_model.clone()),
|
||||
);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn build_conductor(state: &AppState) -> ProxyConductor {
|
||||
let db = state.db.clone();
|
||||
let select_db = db.clone();
|
||||
let select_next_db = db.clone();
|
||||
let success_db = db.clone();
|
||||
let failure_db = db;
|
||||
|
||||
ProxyConductor {
|
||||
select_channel: Arc::new(move |model, _policy| {
|
||||
let router = TokenRouter::new(select_db.clone());
|
||||
async move { router.resolve_selected(&model).await.ok() }.boxed()
|
||||
}),
|
||||
select_next_channel: Arc::new(move |model, excluded, _policy| {
|
||||
let router = TokenRouter::new(select_next_db.clone());
|
||||
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
|
||||
}),
|
||||
record_success: Arc::new(move |channel_id, metrics| {
|
||||
let router = TokenRouter::new(success_db.clone());
|
||||
async move {
|
||||
if let Err(err) = router.record_success(channel_id, metrics).await {
|
||||
tracing::warn!(%err, channel_id, "failed to record proxy success");
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_failure: Arc::new(move |channel_id, info| {
|
||||
let router = TokenRouter::new(failure_db.clone());
|
||||
async move {
|
||||
if let Err(err) = router.record_failure(channel_id, info).await {
|
||||
tracing::warn!(%err, channel_id, "failed to record proxy failure");
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
refresh_auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_chat_completions_with_conductor(
|
||||
state: AppState,
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let conductor = build_conductor(&state);
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: model,
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body_with_selected_model(body.clone(), &ctx.selected);
|
||||
async move {
|
||||
match executors::execute_chat_completions(
|
||||
state,
|
||||
selected_channel_to_route(ctx.selected),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => AttemptResult::Success {
|
||||
response,
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
},
|
||||
Err(err) => AttemptResult::Failure {
|
||||
action: crate::proxy::conductor::AttemptFailureAction::Failover,
|
||||
status: None,
|
||||
raw_error_text: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
conductor_result_to_app_result(result)
|
||||
}
|
||||
|
||||
async fn execute_chat_completions_stream_with_conductor(
|
||||
state: AppState,
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let conductor = build_conductor(&state);
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: model,
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body_with_selected_model(body.clone(), &ctx.selected);
|
||||
async move {
|
||||
match executors::execute_chat_completions_stream(
|
||||
state,
|
||||
selected_channel_to_route(ctx.selected),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(chunks) => AttemptResult::Success {
|
||||
response: Value::Array(chunks.into_iter().map(Value::String).collect()),
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
},
|
||||
Err(err) => AttemptResult::Failure {
|
||||
action: crate::proxy::conductor::AttemptFailureAction::Failover,
|
||||
status: None,
|
||||
raw_error_text: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match conductor_result_to_app_result(result)? {
|
||||
Value::Array(chunks) => chunks
|
||||
.into_iter()
|
||||
.map(|chunk| {
|
||||
chunk.as_str().map(str::to_string).ok_or_else(|| {
|
||||
AppError::UpstreamError("Invalid streaming response from conductor".into())
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(AppError::UpstreamError(
|
||||
"Invalid streaming response from conductor".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn conductor_result_to_app_result(result: ExecuteResult) -> AppResult<Value> {
|
||||
match result {
|
||||
ExecuteResult::Success { response, .. } => Ok(response),
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
..
|
||||
} => Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||
status,
|
||||
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
||||
use axum::http::HeaderValue;
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn test_state(upstream_base_url: String) -> AppState {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE downstream_api_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
description TEXT,
|
||||
group_name TEXT,
|
||||
tags TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at TEXT,
|
||||
max_cost REAL,
|
||||
used_cost REAL DEFAULT 0,
|
||||
max_requests INTEGER,
|
||||
used_requests INTEGER DEFAULT 0,
|
||||
supported_models TEXT,
|
||||
allowed_route_ids TEXT,
|
||||
site_weight_multipliers TEXT,
|
||||
excluded_site_ids TEXT,
|
||||
excluded_credential_refs TEXT,
|
||||
last_used_at TEXT,
|
||||
user_id INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'openai',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
api_key TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE token_routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_pattern TEXT NOT NULL,
|
||||
model_mapping TEXT,
|
||||
route_mode TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE account_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE route_channels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_until TEXT,
|
||||
last_selected_at TEXT,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
total_latency_ms INTEGER DEFAULT 0,
|
||||
total_cost REAL DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
last_fail_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', ?, 'site-key')",
|
||||
)
|
||||
.bind(upstream_base_url)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (1, 1, 'access-key', NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode, enabled) VALUES (1, 'gpt-test', 'upstream-test', 'exact', 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO route_channels (id, route_id, account_id) VALUES (1, 1, 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
AppState::new(
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
log_level: "info".into(),
|
||||
cors_origins: vec![],
|
||||
},
|
||||
db: DbConfig {
|
||||
url: "sqlite::memory:".into(),
|
||||
max_connections: 1,
|
||||
ssl: false,
|
||||
runtime_db_url: None,
|
||||
runtime_db_dialect: None,
|
||||
runtime_db_ssl: None,
|
||||
},
|
||||
auth: AuthConfig {
|
||||
jwt_secret: "secret".into(),
|
||||
jwt_expiry_hours: 1,
|
||||
auth_token: "auth".into(),
|
||||
proxy_token: "proxy".into(),
|
||||
},
|
||||
proxy: ProxyConfig {
|
||||
upstream_timeout_secs: 30,
|
||||
first_byte_timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
},
|
||||
},
|
||||
pool,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_route_uses_token_router_and_conductor() {
|
||||
let state = test_state("http://127.0.0.1:1".to_string()).await;
|
||||
let db = state.db.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", HeaderValue::from_static("Bearer sk-test"));
|
||||
|
||||
let result = chat_completions(
|
||||
State(state),
|
||||
headers,
|
||||
Json(json!({
|
||||
"model": "gpt-test",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(AppError::UpstreamError(_))));
|
||||
let fail_count: i64 =
|
||||
sqlx::query_scalar("SELECT fail_count FROM route_channels WHERE id = 1")
|
||||
.fetch_one(&*db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fail_count > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Search routes — cross-entity search across sites, accounts, tokens, etc.
|
||||
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchBody {
|
||||
query: String,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/search", post(search_handler))
|
||||
}
|
||||
|
||||
/// POST /api/search — search across all entities
|
||||
async fn search_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SearchBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let query = body.query.trim().to_string();
|
||||
let limit = body.limit.unwrap_or(20).max(1).min(100);
|
||||
|
||||
if query.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
"accounts": [], "accountTokens": [], "sites": [],
|
||||
"checkinLogs": [], "proxyLogs": [], "models": [],
|
||||
})));
|
||||
}
|
||||
|
||||
let q = format!("%{}%", query);
|
||||
let per_category = (limit / 6).max(1).min(10);
|
||||
|
||||
// 1. Search sites
|
||||
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, name, url, platform FROM sites WHERE name LIKE ? OR url LIKE ? OR platform LIKE ? LIMIT ?"
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name, url, platform)| json!({"id": id, "name": name, "url": url, "platform": platform}))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Deduplicate by id
|
||||
let mut seen_sites = HashSet::new();
|
||||
let unique_sites: Vec<Value> = sites
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
let id = s["id"].as_i64().unwrap_or(0);
|
||||
seen_sites.insert(id)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Search accounts (with site join)
|
||||
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String, i64, String, String, Option<String>)>(
|
||||
r#"SELECT a.id, a.username, a.status, a.extra_config, s.id, s.name, s.platform, a.access_token
|
||||
FROM accounts a
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
|
||||
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
|
||||
json!({
|
||||
"id": id, "username": username, "status": status,
|
||||
"segment": segment,
|
||||
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Search account tokens (with account + site join)
|
||||
let account_tokens: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String, i64, String, String)>(
|
||||
r#"SELECT t.id, t.name, a.id, a.username, s.id, s.name, s.platform
|
||||
FROM account_tokens t
|
||||
INNER JOIN accounts a ON a.id = t.account_id
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE t.name LIKE ? OR t.token_group LIKE ? OR a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name, acct_id, username, site_id, site_name, platform)| {
|
||||
json!({
|
||||
"id": id, "name": name,
|
||||
"account": {"id": acct_id, "username": username},
|
||||
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 4. Search checkin logs
|
||||
let checkin_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String)>(
|
||||
r#"SELECT c.id, c.message, a.id, a.username
|
||||
FROM checkin_logs c
|
||||
INNER JOIN accounts a ON a.id = c.account_id
|
||||
WHERE c.message LIKE ?
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, message, acct_id, username)| {
|
||||
json!({"id": id, "message": message, "account": {"id": acct_id, "username": username}})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 5. Search proxy logs (by model)
|
||||
let proxy_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, String, i64, String, i64, String)>(
|
||||
r#"SELECT p.id, p.model_requested, p.status, p.http_status, p.created_at,
|
||||
COALESCE(a.id, 0), COALESCE(a.username, '')
|
||||
FROM proxy_logs p
|
||||
LEFT JOIN accounts a ON a.id = p.account_id
|
||||
WHERE p.model_requested LIKE ?
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
|
||||
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 6. Search models (from model_availability + token_model_availability)
|
||||
let token_model_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(
|
||||
r#"SELECT DISTINCT tma.model_name, t.id, a.id, s.id
|
||||
FROM token_model_availability tma
|
||||
INNER JOIN account_tokens t ON t.id = tma.token_id AND t.enabled = 1
|
||||
INNER JOIN accounts a ON a.id = t.account_id AND a.status = 'active'
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE tma.model_name LIKE ? AND tma.available = 1 AND t.value_status = 'ready'
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let direct_model_rows: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||
r#"SELECT DISTINCT ma.model_name, a.id, s.id
|
||||
FROM model_availability ma
|
||||
INNER JOIN accounts a ON a.id = ma.account_id AND a.status = 'active'
|
||||
INNER JOIN sites s ON s.id = a.site_id AND s.status = 'active'
|
||||
WHERE ma.model_name LIKE ? AND ma.available = 1
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Aggregate models
|
||||
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
|
||||
for (model_name, _token_id, account_id, site_id) in token_model_rows {
|
||||
let entry = model_agg.entry(model_name).or_default();
|
||||
entry.0.insert(account_id);
|
||||
entry.1.insert(site_id);
|
||||
}
|
||||
for (model_name, account_id, site_id) in direct_model_rows {
|
||||
let entry = model_agg.entry(model_name).or_default();
|
||||
entry.0.insert(account_id);
|
||||
entry.1.insert(site_id);
|
||||
}
|
||||
|
||||
let mut models: Vec<Value> = model_agg
|
||||
.into_iter()
|
||||
.map(|(name, (accounts_set, sites_set, _tokens_set))| {
|
||||
json!({
|
||||
"name": name,
|
||||
"accountCount": accounts_set.len(),
|
||||
"tokenCount": 0,
|
||||
"siteCount": sites_set.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
let bc = b["accountCount"].as_i64().unwrap_or(0);
|
||||
let ac = a["accountCount"].as_i64().unwrap_or(0);
|
||||
bc.cmp(&ac).then_with(|| {
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.cmp(&b["name"].as_str().unwrap_or(""))
|
||||
})
|
||||
});
|
||||
models.truncate(per_category as usize);
|
||||
|
||||
Ok(Json(json!({
|
||||
"accounts": accounts,
|
||||
"accountTokens": account_tokens,
|
||||
"sites": unique_sites,
|
||||
"checkinLogs": checkin_logs,
|
||||
"proxyLogs": proxy_logs,
|
||||
"models": models,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Site Announcements routes — upstream provider notifications per site
|
||||
|
||||
use crate::{
|
||||
db::models::site_announcement::SiteAnnouncement,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAnnouncementsQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
site_id: Option<i64>,
|
||||
platform: Option<String>,
|
||||
read: Option<String>, // "true" | "false"
|
||||
status: Option<String>, // "active" | "expired" | "dismissed"
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/site-announcements",
|
||||
get(list_announcements).delete(delete_all),
|
||||
)
|
||||
.route("/api/site-announcements/{id}/read", post(mark_read))
|
||||
.route("/api/site-announcements/read-all", post(mark_all_read))
|
||||
}
|
||||
|
||||
/// GET /api/site-announcements — list announcements with optional filters
|
||||
async fn list_announcements(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnnouncementsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing site announcements");
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string();
|
||||
let mut params: Vec<String> = vec![];
|
||||
let mut bind_idx = 0;
|
||||
|
||||
if let Some(site_id) = q.site_id {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND site_id = ?{}", bind_idx));
|
||||
params.push(site_id.to_string());
|
||||
}
|
||||
if let Some(ref platform) = q.platform {
|
||||
if !platform.is_empty() {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
|
||||
params.push(platform.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref r) = q.read {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read_at IS NOT NULL");
|
||||
} else if r == "false" {
|
||||
sql.push_str(" AND read_at IS NULL");
|
||||
}
|
||||
}
|
||||
|
||||
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now)
|
||||
// We fetch all and apply status filter in Rust for simplicity
|
||||
// (the "expired" vs "active" logic references ends_at which is a datetime comparison)
|
||||
|
||||
sql.push_str(" ORDER BY first_seen_at DESC");
|
||||
|
||||
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
// Apply status filter in-memory (matches Node.js behaviour)
|
||||
let filtered = if let Some(ref status) = q.status {
|
||||
let now = chrono::Utc::now().timestamp_millis() as i64;
|
||||
let status = status.trim().to_lowercase();
|
||||
announcements
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
if status == "dismissed" {
|
||||
a.dismissed_at.is_some()
|
||||
} else if status == "expired" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(false, |e| parse_dt_millis(e) < now)
|
||||
} else if status == "active" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(true, |e| parse_dt_millis(e) >= now)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
announcements
|
||||
};
|
||||
|
||||
// Apply pagination in-memory
|
||||
let paginated: Vec<Value> = filtered
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.take(limit as usize)
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"siteId": a.site_id,
|
||||
"platform": a.platform,
|
||||
"sourceKey": a.source_key,
|
||||
"title": a.title,
|
||||
"content": a.content,
|
||||
"level": a.level,
|
||||
"sourceUrl": a.source_url,
|
||||
"startsAt": a.starts_at,
|
||||
"endsAt": a.ends_at,
|
||||
"upstreamCreatedAt": a.upstream_created_at,
|
||||
"upstreamUpdatedAt": a.upstream_updated_at,
|
||||
"firstSeenAt": a.first_seen_at,
|
||||
"lastSeenAt": a.last_seen_at,
|
||||
"readAt": a.read_at,
|
||||
"dismissedAt": a.dismissed_at,
|
||||
"rawPayload": a.raw_payload,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(Value::Array(paginated)))
|
||||
}
|
||||
|
||||
/// POST /api/site-announcements/{id}/read
|
||||
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
info!("Marking site announcement {} as read", id);
|
||||
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/site-announcements/read-all
|
||||
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Marking all site announcements as read");
|
||||
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/site-announcements
|
||||
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Deleting all site announcements");
|
||||
sqlx::query("DELETE FROM site_announcements")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch.
|
||||
fn parse_dt_millis(s: &str) -> i64 {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") {
|
||||
dt.and_utc().timestamp_millis()
|
||||
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) {
|
||||
dt.timestamp_millis()
|
||||
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") {
|
||||
dt.and_utc().timestamp_millis()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Stats routes — usage analytics queries
|
||||
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelBySiteQuery {
|
||||
site_id: Option<i64>,
|
||||
days: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteDistributionQuery {
|
||||
days: Option<i64>,
|
||||
refresh: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/stats/model-by-site", get(model_by_site))
|
||||
.route("/api/stats/site-distribution", get(site_distribution))
|
||||
}
|
||||
|
||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||
async fn model_by_site(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ModelBySiteQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Stats model-by-site: site_id={:?}, days={:?}",
|
||||
q.site_id, q.days
|
||||
);
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
|
||||
// Calculate cutoff date
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let rows = if let Some(site_id) = q.site_id {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE site_id = ? AND local_day >= ?
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE local_day >= ?
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Aggregate by model
|
||||
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> =
|
||||
std::collections::HashMap::new();
|
||||
for (model, _day, calls, spend, tokens) in rows {
|
||||
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
|
||||
entry.0 += calls;
|
||||
entry.1 += spend;
|
||||
entry.2 += tokens;
|
||||
}
|
||||
|
||||
let mut models: Vec<Value> = model_map
|
||||
.into_iter()
|
||||
.map(|(model, (calls, spend, tokens))| {
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
b["calls"]
|
||||
.as_i64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&a["calls"].as_i64().unwrap_or(0))
|
||||
});
|
||||
|
||||
Ok(Json(json!({ "models": models })))
|
||||
}
|
||||
|
||||
/// GET /api/stats/site-distribution?days=...&refresh=...
|
||||
async fn site_distribution(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SiteDistributionQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Stats site-distribution: days={:?}", q.days);
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
// Sum per-site usage
|
||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
|
||||
FROM sites s
|
||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY SUM(m.total_spend) DESC"
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let distribution: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, calls, spend, tokens)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Background tasks routes — list and inspect async operations
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::{self, Stream};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio_stream::StreamExt;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListTasksQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/tasks", get(list_tasks))
|
||||
.route("/api/tasks/{id}", get(get_task))
|
||||
.route(
|
||||
"/api/update-center/tasks/{id}/stream",
|
||||
get(stream_task_logs),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/tasks — list background tasks
|
||||
async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListTasksQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let limit = q.limit.unwrap_or(50).max(1).min(500);
|
||||
info!("Listing background tasks, limit={}", limit);
|
||||
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
||||
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "tasks": tasks })))
|
||||
}
|
||||
|
||||
/// GET /api/tasks/{id} — get a single background task
|
||||
async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
info!("Getting background task: {}", id);
|
||||
|
||||
let task: Option<BackgroundTask> =
|
||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match task {
|
||||
Some(t) => Ok(Json(json!({ "success": true, "task": t }))),
|
||||
None => Ok(Json(
|
||||
json!({ "success": false, "message": "task not found" }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
|
||||
async fn stream_task_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
|
||||
info!("Streaming task logs for: {}", id);
|
||||
|
||||
// Check task exists
|
||||
let task: Option<BackgroundTask> =
|
||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("DB error: {e}"),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let task = match task {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err((
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
// Stream task state with polling
|
||||
let initial_status = task.status.clone();
|
||||
let is_done = initial_status != "pending" && initial_status != "running";
|
||||
|
||||
let state_clone = state.clone();
|
||||
let stream = async_stream::stream! {
|
||||
// Yield current task status
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("status")
|
||||
.data(json!({ "status": initial_status }).to_string()));
|
||||
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("log")
|
||||
.data(json!({
|
||||
"seq": 1,
|
||||
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
|
||||
"createdAt": task.created_at.as_deref().unwrap_or(""),
|
||||
}).to_string()));
|
||||
|
||||
if is_done {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": initial_status }).to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for updates
|
||||
let poll_id = id.clone();
|
||||
for i in 0..120 { // Poll up to 120 times (2 minutes)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
let current: Option<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT * FROM background_tasks WHERE id = ?"
|
||||
)
|
||||
.bind(&poll_id)
|
||||
.fetch_optional(&*state_clone.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match current {
|
||||
Some(t) => {
|
||||
let status = t.status;
|
||||
let updated_at = t.updated_at.unwrap_or_default();
|
||||
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("log")
|
||||
.data(json!({
|
||||
"seq": i + 2,
|
||||
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
|
||||
"createdAt": updated_at,
|
||||
}).to_string()));
|
||||
|
||||
if status != "pending" && status != "running" {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": status }).to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": "timeout" }).to_string()));
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream))
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// Update Center routes — deploy/rollback orchestration via helper API
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const UC_CONFIG_KEY: &str = "update_center_config";
|
||||
|
||||
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
|
||||
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
|
||||
|
||||
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
|
||||
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/update-center/status", get(get_status))
|
||||
.route("/api/update-center/check", post(check_status))
|
||||
.route("/api/update-center/config", put(update_config))
|
||||
.route("/api/update-center/deploy", post(deploy_handler))
|
||||
.route("/api/update-center/rollback", post(rollback_handler))
|
||||
}
|
||||
|
||||
/// GET /api/update-center/status
|
||||
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
|
||||
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
"helperBaseUrl": config.get("helperBaseUrl"),
|
||||
"namespace": config.get("namespace"),
|
||||
"releaseName": config.get("releaseName"),
|
||||
"helperTokenConfigured": !helper_token.is_empty(),
|
||||
"lastDeploy": last_deploy,
|
||||
"lastRollback": last_rollback,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/check — refresh status cache
|
||||
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Same as get_status for now
|
||||
get_status(State(state)).await
|
||||
}
|
||||
|
||||
/// PUT /api/update-center/config
|
||||
async fn update_config(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let serialized = serde_json::to_string(&body)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
|
||||
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(UC_CONFIG_KEY)
|
||||
.bind(&serialized)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"config": body,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/deploy
|
||||
async fn deploy_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let helper_base = config
|
||||
.get("helperBaseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let namespace = config
|
||||
.get("namespace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let chart_ref = config
|
||||
.get("chartRef")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let image_repo = config
|
||||
.get("imageRepository")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if helper_base.is_empty()
|
||||
|| namespace.is_empty()
|
||||
|| chart_ref.is_empty()
|
||||
|| helper_token.is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Update center not fully configured".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_tag = body
|
||||
.get("targetTag")
|
||||
.or_else(|| body.get("targetVersion"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
if target_tag.is_empty() {
|
||||
return Err(AppError::BadRequest("targetTag is required".into()));
|
||||
}
|
||||
|
||||
// Extract owned values for the spawn
|
||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
||||
let namespace_owned = namespace.to_string();
|
||||
let chart_ref_owned = chart_ref.to_string();
|
||||
let image_repo_owned = image_repo.to_string();
|
||||
let release_name = config
|
||||
.get("releaseName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let target_tag_owned = target_tag.to_string();
|
||||
let helper_token_owned = helper_token;
|
||||
let title = format!("部署 {}", target_tag);
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Create background task
|
||||
sqlx::query(
|
||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
||||
)
|
||||
.bind(&task_id)
|
||||
.bind(TASK_TYPE_DEPLOY)
|
||||
.bind(&title)
|
||||
.bind(format!(
|
||||
"Starting deploy of {} to {}/{}",
|
||||
target_tag, namespace, chart_ref
|
||||
))
|
||||
.bind(DEDUPE_KEY_DEPLOY)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Call deploy helper in background (fire-and-forget via tokio)
|
||||
let client = reqwest::Client::new();
|
||||
let deploy_url = format!("{}/api/deploy", helper_base_owned);
|
||||
let task_id_clone = task_id.clone();
|
||||
let db_pool = state.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"namespace": namespace_owned,
|
||||
"releaseName": release_name,
|
||||
"chartRef": chart_ref_owned,
|
||||
"imageRepository": image_repo_owned,
|
||||
"targetTag": target_tag_owned,
|
||||
});
|
||||
|
||||
match client
|
||||
.post(&deploy_url)
|
||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_code = status.as_u16();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Deploy completed (HTTP {})", status_code))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
} else {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!(
|
||||
"Helper returned HTTP {}: {}",
|
||||
status_code, body_text
|
||||
))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Helper unreachable: {}", e))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"reused": false,
|
||||
"task": {
|
||||
"id": task_id,
|
||||
"type": TASK_TYPE_DEPLOY,
|
||||
"title": title,
|
||||
"status": "running",
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/rollback
|
||||
async fn rollback_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let helper_base = config
|
||||
.get("helperBaseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let namespace = config
|
||||
.get("namespace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Update center not fully configured".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_revision = body
|
||||
.get("targetRevision")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
if target_revision.is_empty() {
|
||||
return Err(AppError::BadRequest("targetRevision is required".into()));
|
||||
}
|
||||
|
||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
||||
let namespace_owned = namespace.to_string();
|
||||
let release_name = config
|
||||
.get("releaseName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let target_revision_owned = target_revision.to_string();
|
||||
let helper_token_owned = helper_token;
|
||||
let title = format!("回退 {}", target_revision);
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
||||
)
|
||||
.bind(&task_id)
|
||||
.bind(TASK_TYPE_ROLLBACK)
|
||||
.bind(&title)
|
||||
.bind(format!(
|
||||
"Starting rollback to {} in {}",
|
||||
target_revision, namespace
|
||||
))
|
||||
.bind(DEDUPE_KEY_ROLLBACK)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let rollback_url = format!("{}/api/rollback", helper_base_owned);
|
||||
let task_id_clone = task_id.clone();
|
||||
let db_pool = state.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"namespace": namespace_owned,
|
||||
"releaseName": release_name,
|
||||
"targetRevision": target_revision_owned,
|
||||
});
|
||||
|
||||
match client
|
||||
.post(&rollback_url)
|
||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_code = status.as_u16();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Rollback completed (HTTP {})", status_code))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
} else {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!(
|
||||
"Helper returned HTTP {}: {}",
|
||||
status_code, body_text
|
||||
))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Helper unreachable: {}", e))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"reused": false,
|
||||
"task": {
|
||||
"id": task_id,
|
||||
"type": TASK_TYPE_ROLLBACK,
|
||||
"title": title,
|
||||
"status": "running",
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_helper_token() -> String {
|
||||
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
|
||||
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
|
||||
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(UC_CONFIG_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some((v,)) => serde_json::from_str(&v)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
|
||||
None => Ok(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
|
||||
let row: Option<(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, status, message, error, created_at, started_at, finished_at
|
||||
FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(task_type)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(
|
||||
|(id, status, message, error, created_at, started_at, finished_at)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"type": task_type,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"error": error,
|
||||
"createdAt": created_at,
|
||||
"startedAt": started_at,
|
||||
"finishedAt": finished_at,
|
||||
})
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/v1/user/profile", get(get_profile).put(update_profile))
|
||||
.route("/v1/user/api-keys", get(list_api_keys).post(create_api_key))
|
||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateProfilePayload {
|
||||
username: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateApiKeyPayload {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
expires_at: Option<String>,
|
||||
max_requests: Option<i64>,
|
||||
max_cost: Option<f64>,
|
||||
}
|
||||
|
||||
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
|
||||
}
|
||||
|
||||
async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({ "user": user })))
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let current: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
let username = payload.username.unwrap_or(current.username);
|
||||
let email = payload.email.unwrap_or(current.email);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users
|
||||
SET username = ?, email = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
get_profile(State(state), headers).await
|
||||
}
|
||||
|
||||
async fn list_api_keys(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "keys": keys })))
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateApiKeyPayload>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
|
||||
let token = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys
|
||||
(name, key, description, enabled, expires_at, max_requests, max_cost, user_id)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(payload.name)
|
||||
.bind(&token)
|
||||
.bind(payload.description)
|
||||
.bind(payload.expires_at)
|
||||
.bind(payload.max_requests)
|
||||
.bind(payload.max_cost)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key: DownstreamApiKey = sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "key": key }))))
|
||||
}
|
||||
|
||||
async fn delete_api_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("API key not found".into()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// User-level API key routes — JWT-authenticated CRUD scoped to current user.
|
||||
// Backed by the same downstream_api_keys table as the admin route.
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/user-api-keys",
|
||||
get(list_user_keys).post(create_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/user-api-keys/{id}",
|
||||
patch(update_user_key).delete(delete_user_key),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract user_id and role from JWT in Authorization header.
|
||||
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user_id: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((user_id, claims.role))
|
||||
}
|
||||
|
||||
/// GET /api/user-api-keys — list keys for the authenticated user
|
||||
async fn list_user_keys(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Listing user API keys for user {}", user_id);
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "keys": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/user-api-keys — create a new API key for the authenticated user
|
||||
async fn create_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Creating user API key for user {}", user_id);
|
||||
|
||||
let name = body
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
|
||||
|
||||
let description = body.get("description").and_then(|v| v.as_str());
|
||||
let tags = body.get("tags").map(|v| v.to_string());
|
||||
|
||||
// Generate key: sk-user-<uuid>
|
||||
let key_str = body
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"sk-user-{}",
|
||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
||||
)
|
||||
});
|
||||
|
||||
let enabled = body
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let expires_at = body.get("expiresAt").and_then(|v| v.as_str());
|
||||
let max_cost = body.get("maxCost").and_then(|v| v.as_f64());
|
||||
let max_requests = body.get("maxRequests").and_then(|v| v.as_i64());
|
||||
let supported_models = body.get("supportedModels").map(|v| v.to_string());
|
||||
let allowed_route_ids = body.get("allowedRouteIds").map(|v| v.to_string());
|
||||
let site_weight_multipliers = body.get("siteWeightMultipliers").map(|v| v.to_string());
|
||||
let excluded_site_ids = body.get("excludedSiteIds").map(|v| v.to_string());
|
||||
let excluded_credential_refs = body.get("excludedCredentialRefs").map(|v| v.to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO downstream_api_keys
|
||||
(name, key, description, tags, enabled, expires_at, max_cost, max_requests,
|
||||
supported_models, allowed_route_ids, site_weight_multipliers,
|
||||
excluded_site_ids, excluded_credential_refs, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&key_str)
|
||||
.bind(description)
|
||||
.bind(tags)
|
||||
.bind(enabled)
|
||||
.bind(expires_at)
|
||||
.bind(max_cost)
|
||||
.bind(max_requests)
|
||||
.bind(supported_models)
|
||||
.bind(allowed_route_ids)
|
||||
.bind(site_weight_multipliers)
|
||||
.bind(excluded_site_ids)
|
||||
.bind(excluded_credential_refs)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let new_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "key": new_key }))))
|
||||
}
|
||||
|
||||
/// PATCH /api/user-api-keys/{id} — update a key owned by the authenticated user
|
||||
async fn update_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Updating user API key {} for user {}", id, user_id);
|
||||
|
||||
// Verify ownership
|
||||
let existing: Option<DownstreamApiKey> =
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
return Err(AppError::NotFound("Key not found".into()));
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE
|
||||
let mut sets: Vec<String> = vec![];
|
||||
let mut params: Vec<String> = vec![];
|
||||
let mut bind_idx = 0;
|
||||
|
||||
if let Some(v) = body.get("name").and_then(|v| v.as_str()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("name = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("description") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("description = ?{}", bind_idx));
|
||||
params.push(v.as_str().unwrap_or_default().to_string());
|
||||
}
|
||||
if let Some(v) = body.get("tags") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("tags = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("enabled = ?{}", bind_idx));
|
||||
params.push(if v { "1" } else { "0" }.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("expiresAt").and_then(|v| v.as_str()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("expires_at = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("maxCost").and_then(|v| v.as_f64()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("max_cost = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("maxRequests").and_then(|v| v.as_i64()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("max_requests = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("supportedModels") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("supported_models = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("allowedRouteIds") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("allowed_route_ids = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("excludedSiteIds") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("excluded_site_ids = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("updated_at = ?{}", bind_idx));
|
||||
params.push(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE downstream_api_keys SET {} WHERE id = ?{} AND user_id = ?{}",
|
||||
sets.join(", "),
|
||||
bind_idx + 1,
|
||||
bind_idx + 2
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
q = q.bind(p);
|
||||
}
|
||||
q = q.bind(id).bind(user_id);
|
||||
q.execute(&*state.db).await?;
|
||||
}
|
||||
|
||||
let updated: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "key": updated })))
|
||||
}
|
||||
|
||||
/// DELETE /api/user-api-keys/{id} — delete a key owned by the authenticated user
|
||||
async fn delete_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Deleting user API key {} for user {}", id, user_id);
|
||||
|
||||
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Key not found".into()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
|
||||
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
|
||||
|
||||
{
|
||||
"title": "视频标题",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"gender": "男/女",
|
||||
"age": "年龄描述",
|
||||
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
|
||||
"personality": "性格特征"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": 1,
|
||||
"description": "场景描述",
|
||||
"environment": "环境详细描述(时间、地点、天气、光线等)",
|
||||
"characters": ["出现的角色名"],
|
||||
"action": "场景中发生的动作",
|
||||
"dialogue": [
|
||||
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"style": "整体视觉风格(动画/写实/像素等)"
|
||||
}
|
||||
|
||||
只返回 JSON,不要其他文字。"#;
|
||||
|
||||
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
|
||||
每组提示词应包含:人物姿态、服装、光照、背景。
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"front": "正面提示词",
|
||||
"back": "背面提示词",
|
||||
"left": "左侧面提示词",
|
||||
"right": "右侧面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
|
||||
包含:环境、光照、色调、氛围。
|
||||
返回 JSON:
|
||||
{
|
||||
"prompt": "场景生成提示词",
|
||||
"negative_prompt": "负面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/video-agent/analyze", post(analyze))
|
||||
.route(
|
||||
"/api/video-agent/character-prompts",
|
||||
post(character_prompts),
|
||||
)
|
||||
.route(
|
||||
"/api/video-agent/generate-character",
|
||||
post(generate_character),
|
||||
)
|
||||
.route("/api/video-agent/scene-prompt", post(scene_prompt))
|
||||
.route("/api/video-agent/generate-scene", post(generate_scene))
|
||||
.route("/api/video-agent/generate-tts", post(generate_tts))
|
||||
.route("/api/video-agent/full-workflow", post(full_workflow))
|
||||
}
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
||||
|
||||
let actual = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if actual != expected {
|
||||
return Err(AppError::BadRequest("unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
|
||||
body.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
|
||||
body.get(key)
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn strip_json_markers(s: &str) -> String {
|
||||
let mut text = s.trim().to_string();
|
||||
|
||||
if text.starts_with("```json") {
|
||||
text = text.trim_start_matches("```json").to_string();
|
||||
} else if text.starts_with("```") {
|
||||
text = text.trim_start_matches("```").to_string();
|
||||
}
|
||||
|
||||
if text.ends_with("```") {
|
||||
text = text.trim_end_matches("```").to_string();
|
||||
}
|
||||
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn parse_llm_json(raw: &str) -> AppResult<Value> {
|
||||
let cleaned = strip_json_markers(raw);
|
||||
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"failed to parse LLM JSON response: {}; response: {}",
|
||||
e,
|
||||
cleaned
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", upstream_base());
|
||||
|
||||
info!("video-agent calling LLM upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": prompt }
|
||||
],
|
||||
"temperature": 0.7
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"LLM upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
info!("video-agent calling image upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"image upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
info!("video-agent calling TTS upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"TTS upstream returned {}: {}",
|
||||
status,
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
async fn analyze(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
|
||||
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let parsed = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": parsed
|
||||
})))
|
||||
}
|
||||
|
||||
async fn character_prompts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let character = required_value(&body, "character")?;
|
||||
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompts
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_character(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompts = required_value(&body, "prompts")?;
|
||||
let prompt_obj = prompts
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
|
||||
|
||||
let mut results = serde_json::Map::new();
|
||||
|
||||
for (angle, prompt_value) in prompt_obj {
|
||||
let prompt = prompt_value.as_str().ok_or_else(|| {
|
||||
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
|
||||
})?;
|
||||
|
||||
let url = call_image_gen(prompt).await?;
|
||||
results.insert(angle.clone(), Value::String(url));
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": Value::Object(results)
|
||||
})))
|
||||
}
|
||||
|
||||
async fn scene_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let scene = required_value(&body, "scene")?;
|
||||
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let prompt = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompt
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_scene(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompt = required_str(&body, "prompt")?;
|
||||
let url = call_image_gen(prompt).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"url": url
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_tts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response<Body>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let text = required_str(&body, "text")?;
|
||||
let voice = body
|
||||
.get("voice")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("alloy");
|
||||
|
||||
let audio = call_tts(text, voice).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "audio/mpeg")],
|
||||
audio,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn full_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
let requested_style = body.get("style").and_then(Value::as_str);
|
||||
|
||||
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let analysis = parse_llm_json(&analysis_raw)?;
|
||||
|
||||
let mut characters = Vec::new();
|
||||
|
||||
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
|
||||
for character in chars {
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&prompts_raw)?;
|
||||
|
||||
let mut item = character.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompts".to_string(), prompts);
|
||||
}
|
||||
|
||||
characters.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut scenes = Vec::new();
|
||||
|
||||
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
|
||||
for scene in scene_items {
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
|
||||
|
||||
let mut item = scene.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompt".to_string(), image_prompt);
|
||||
}
|
||||
|
||||
scenes.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let style = analysis
|
||||
.get("style")
|
||||
.cloned()
|
||||
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
|
||||
"style": style,
|
||||
"characters": characters,
|
||||
"scenes": scenes
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildConfig, buildFastifyOptions } from './config.js';
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('defaults to external listen host for server deployments', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4000);
|
||||
expect(config.dataDir).toBe('./data');
|
||||
});
|
||||
|
||||
it('aligns desktop deployments with server deployments for listen host', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '0.0.0.0',
|
||||
METAPI_DESKTOP: '1',
|
||||
PORT: '4312',
|
||||
DATA_DIR: '/tmp/metapi-data',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4312);
|
||||
expect(config.dataDir).toBe('/tmp/metapi-data');
|
||||
});
|
||||
|
||||
it('honors explicit loopback host outside desktop mode', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('defaults telegram api base url to the official endpoint', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
|
||||
expect(config.telegramMessageThreadId).toBe('');
|
||||
});
|
||||
|
||||
it('accepts telegram message thread id from environment', () => {
|
||||
const config = buildConfig({
|
||||
TELEGRAM_MESSAGE_THREAD_ID: '77',
|
||||
});
|
||||
|
||||
expect(config.telegramMessageThreadId).toBe('77');
|
||||
});
|
||||
|
||||
it('ships CLI-aligned OAuth defaults', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.codexClientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann');
|
||||
expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2026-02-06');
|
||||
expect(config.claudeClientId).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
|
||||
expect(config.claudeClientSecret).toBe('');
|
||||
expect(config.geminiCliClientId).toBe('681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com');
|
||||
expect(config.geminiCliClientSecret).toBe('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');
|
||||
});
|
||||
|
||||
it('allows overriding the codex websocket beta gate from environment', () => {
|
||||
const config = buildConfig({
|
||||
CODEX_RESPONSES_WEBSOCKET_BETA: 'responses_websockets=2099-01-01',
|
||||
});
|
||||
|
||||
expect(config.codexResponsesWebsocketBeta).toBe('responses_websockets=2099-01-01');
|
||||
});
|
||||
|
||||
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
||||
|
||||
app.post('/echo', async (request) => {
|
||||
const body = request.body as { text?: string };
|
||||
return { textLength: body.text?.length ?? 0 };
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/echo',
|
||||
payload: { text: largeText },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ textLength: largeText.length });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('trusts forwarded client IP headers for reverse-proxy deployments', async () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
|
||||
app.get('/ip', async (request) => ({
|
||||
ip: request.ip,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ip',
|
||||
remoteAddress: '10.0.0.8',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.5, 10.0.0.8',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ip: '203.0.113.5' });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import type { FastifyServerOptions } from 'fastify';
|
||||
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
||||
|
||||
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||
const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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';
|
||||
export const TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING = 30 * 24 * 60 * 60;
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback = false): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCsvList(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function parseOptionalSecret(value: string | undefined): string {
|
||||
return (value || '').trim();
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string | undefined): unknown {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
||||
const normalized = (value || 'sqlite').trim().toLowerCase();
|
||||
if (normalized === 'mysql') return 'mysql';
|
||||
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
export function normalizeTokenRouterFailureCooldownMaxSec(value: unknown): number | null {
|
||||
const normalized = Number(value);
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) return null;
|
||||
return Math.min(
|
||||
TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||
Math.max(1, Math.trunc(normalized)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
||||
return (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
}
|
||||
|
||||
export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
const dataDir = env.DATA_DIR || './data';
|
||||
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
deployHelperToken: parseOptionalSecret(env.DEPLOY_HELPER_TOKEN || env.UPDATE_CENTER_HELPER_TOKEN),
|
||||
codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID) || DEFAULT_CODEX_CLIENT_ID,
|
||||
claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID) || DEFAULT_CLAUDE_CLIENT_ID,
|
||||
claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET),
|
||||
geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID) || DEFAULT_GEMINI_CLI_CLIENT_ID,
|
||||
geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET) || DEFAULT_GEMINI_CLI_CLIENT_SECRET,
|
||||
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||
checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval'
|
||||
? 'interval' as const
|
||||
: 'cron' as const,
|
||||
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))),
|
||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
|
||||
logCleanupConfigured: false,
|
||||
logCleanupUsageLogsEnabled: parseBoolean(env.LOG_CLEANUP_USAGE_LOGS_ENABLED, false),
|
||||
logCleanupProgramLogsEnabled: parseBoolean(env.LOG_CLEANUP_PROGRAM_LOGS_ENABLED, false),
|
||||
logCleanupRetentionDays: Math.max(1, Math.trunc(parseNumber(env.LOG_CLEANUP_RETENTION_DAYS, 30))),
|
||||
webhookUrl: env.WEBHOOK_URL || '',
|
||||
barkUrl: env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
|
||||
telegramApiBaseUrl: 'https://api.telegram.org',
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: env.TELEGRAM_CHAT_ID || '',
|
||||
telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false),
|
||||
telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(),
|
||||
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
|
||||
smtpHost: env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(env.SMTP_SECURE, false),
|
||||
smtpUser: env.SMTP_USER || '',
|
||||
smtpPass: env.SMTP_PASS || '',
|
||||
smtpFrom: env.SMTP_FROM || '',
|
||||
smtpTo: env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(env.PORT, 4000)),
|
||||
listenHost: parseListenHost(env),
|
||||
dataDir,
|
||||
dbType: parseDbType(env.DB_TYPE),
|
||||
dbUrl: (env.DB_URL || '').trim(),
|
||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
proxyFirstByteTimeoutSec: Math.max(0, Math.trunc(parseNumber(env.PROXY_FIRST_BYTE_TIMEOUT_SEC, 0))),
|
||||
tokenRouterFailureCooldownMaxSec: normalizeTokenRouterFailureCooldownMaxSec(
|
||||
parseNumber(env.TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC, TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING),
|
||||
) ?? TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING,
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyMaxChannelAttempts: Math.max(1, Math.trunc(parseNumber(env.PROXY_MAX_CHANNEL_ATTEMPTS, 3))),
|
||||
proxyStickySessionEnabled: parseBoolean(env.PROXY_STICKY_SESSION_ENABLED, true),
|
||||
proxyStickySessionTtlMs: Math.max(30_000, Math.trunc(parseNumber(env.PROXY_STICKY_SESSION_TTL_MS, 30 * 60 * 1000))),
|
||||
proxySessionChannelConcurrencyLimit: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_CONCURRENCY_LIMIT, 2))),
|
||||
proxySessionChannelQueueWaitMs: Math.max(0, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_QUEUE_WAIT_MS, 1_500))),
|
||||
proxySessionChannelLeaseTtlMs: Math.max(5_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_TTL_MS, 90_000))),
|
||||
proxySessionChannelLeaseKeepaliveMs: Math.max(1_000, Math.trunc(parseNumber(env.PROXY_SESSION_CHANNEL_LEASE_KEEPALIVE_MS, 15_000))),
|
||||
codexUpstreamWebsocketEnabled: parseBoolean(env.CODEX_UPSTREAM_WEBSOCKET_ENABLED, false),
|
||||
responsesCompactFallbackToResponsesEnabled: parseBoolean(env.RESPONSES_COMPACT_FALLBACK_TO_RESPONSES_ENABLED, false),
|
||||
disableCrossProtocolFallback: parseBoolean(env.DISABLE_CROSS_PROTOCOL_FALLBACK, false),
|
||||
proxyDebugTraceEnabled: parseBoolean(env.PROXY_DEBUG_TRACE_ENABLED, false),
|
||||
proxyDebugCaptureHeaders: parseBoolean(env.PROXY_DEBUG_CAPTURE_HEADERS, true),
|
||||
proxyDebugCaptureBodies: parseBoolean(env.PROXY_DEBUG_CAPTURE_BODIES, false),
|
||||
proxyDebugCaptureStreamChunks: parseBoolean(env.PROXY_DEBUG_CAPTURE_STREAM_CHUNKS, false),
|
||||
proxyDebugTargetSessionId: (env.PROXY_DEBUG_TARGET_SESSION_ID || '').trim(),
|
||||
proxyDebugTargetClientKind: (env.PROXY_DEBUG_TARGET_CLIENT_KIND || '').trim(),
|
||||
proxyDebugTargetModel: (env.PROXY_DEBUG_TARGET_MODEL || '').trim(),
|
||||
proxyDebugRetentionHours: Math.max(1, Math.trunc(parseNumber(env.PROXY_DEBUG_RETENTION_HOURS, 24))),
|
||||
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(parseNumber(env.PROXY_DEBUG_MAX_BODY_BYTES, 262_144))),
|
||||
openAiServiceTierRules: parseJsonValue(env.OPENAI_SERVICE_TIER_RULES_JSON || env.OPENAI_SERVICE_TIER_RULES),
|
||||
modelAvailabilityProbeEnabled: parseBoolean(env.MODEL_AVAILABILITY_PROBE_ENABLED, false),
|
||||
modelAvailabilityProbeIntervalMs: Math.max(60_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_INTERVAL_MS, 30 * 60 * 1000))),
|
||||
modelAvailabilityProbeTimeoutMs: Math.max(3_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_TIMEOUT_MS, 15_000))),
|
||||
modelAvailabilityProbeConcurrency: Math.max(1, Math.min(16, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_CONCURRENCY, 1)))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
|
||||
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
|
||||
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
||||
globalBlockedBrands: [] as string[],
|
||||
globalAllowedModels: [] as string[],
|
||||
codexResponsesWebsocketBeta: parseOptionalSecret(env.CODEX_RESPONSES_WEBSOCKET_BETA) || 'responses_websockets=2026-02-06',
|
||||
codexHeaderDefaults: {
|
||||
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
|
||||
betaFeatures: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_BETA_FEATURES),
|
||||
},
|
||||
payloadRules: normalizePayloadRulesConfig(parseJsonValue(env.PAYLOAD_RULES_JSON || env.PAYLOAD_RULES)),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const config = buildConfig(process.env);
|
||||
|
||||
export function buildFastifyOptions(
|
||||
appConfig: ReturnType<typeof buildConfig>,
|
||||
): FastifyServerOptions {
|
||||
return {
|
||||
logger: true,
|
||||
trustProxy: true,
|
||||
bodyLimit: appConfig.requestBodyLimit,
|
||||
};
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountTokenCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
unlimitedQuota: z.boolean().optional(),
|
||||
remainQuota: z.union([z.number(), z.string()]).optional(),
|
||||
expiredTime: z.union([z.number(), z.string()]).optional(),
|
||||
allowIps: z.string().optional(),
|
||||
modelLimitsEnabled: z.boolean().optional(),
|
||||
modelLimits: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenSyncAllPayloadSchema = z.object({
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountTokenBatchPayload = z.output<typeof accountTokenBatchPayloadSchema>;
|
||||
export type AccountTokenCreatePayload = z.output<typeof accountTokenCreatePayloadSchema>;
|
||||
export type AccountTokenSyncAllPayload = z.output<typeof accountTokenSyncAllPayloadSchema>;
|
||||
export type AccountTokenUpdatePayload = z.output<typeof accountTokenUpdatePayloadSchema>;
|
||||
|
||||
function normalizeAccountTokenPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountTokenPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'token') {
|
||||
return 'Invalid token. Expected string.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isDefault') {
|
||||
return 'Invalid isDefault. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected string.';
|
||||
}
|
||||
if (firstPath === 'group') {
|
||||
return 'Invalid group. Expected string.';
|
||||
}
|
||||
if (firstPath === 'unlimitedQuota') {
|
||||
return 'Invalid unlimitedQuota. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'modelLimitsEnabled') {
|
||||
return 'Invalid modelLimitsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
return 'Invalid account token payload.';
|
||||
}
|
||||
|
||||
export function parseAccountTokenCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenCreatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenCreatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenBatchPayload } | { success: false; error: string } {
|
||||
const result = accountTokenBatchPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenUpdatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenUpdatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenSyncAllPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenSyncAllPayload } | { success: false; error: string } {
|
||||
const result = accountTokenSyncAllPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']);
|
||||
|
||||
const accountCreatePayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
accessTokens: z.array(z.string()).optional(),
|
||||
apiToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
skipModelFetch: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountUpdatePayloadSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
apiToken: z.union([z.string(), z.null()]).optional(),
|
||||
status: z.string().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
unitCost: z.union([z.number(), z.null()]).optional(),
|
||||
extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
refreshToken: z.union([z.string(), z.null()]).optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountRebindSessionPayloadSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountHealthRefreshPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountLoginPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const accountVerifyTokenPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountManualModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountBatchPayload = z.output<typeof accountBatchPayloadSchema>;
|
||||
export type AccountCreatePayload = z.output<typeof accountCreatePayloadSchema>;
|
||||
export type AccountHealthRefreshPayload = z.output<typeof accountHealthRefreshPayloadSchema>;
|
||||
export type AccountLoginPayload = z.output<typeof accountLoginPayloadSchema>;
|
||||
export type AccountManualModelsPayload = z.output<typeof accountManualModelsPayloadSchema>;
|
||||
export type AccountRebindSessionPayload = z.output<typeof accountRebindSessionPayloadSchema>;
|
||||
export type AccountUpdatePayload = z.output<typeof accountUpdatePayloadSchema>;
|
||||
export type AccountVerifyTokenPayload = z.output<typeof accountVerifyTokenPayloadSchema>;
|
||||
|
||||
function normalizeAccountsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'siteId') {
|
||||
return 'Invalid siteId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'accessToken') {
|
||||
return 'Invalid accessToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'username') {
|
||||
return 'Invalid username. Expected string.';
|
||||
}
|
||||
if (firstPath === 'password') {
|
||||
return 'Invalid password. Expected string.';
|
||||
}
|
||||
if (firstPath === 'apiToken') {
|
||||
return 'Invalid apiToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'accessTokens') {
|
||||
return 'Invalid accessTokens. Expected string[].';
|
||||
}
|
||||
if (firstPath === 'checkinEnabled') {
|
||||
return 'Invalid checkinEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'unitCost') {
|
||||
return 'Invalid unitCost. Expected number or null.';
|
||||
}
|
||||
if (firstPath === 'credentialMode') {
|
||||
return 'Invalid credentialMode. Expected auto/session/apikey.';
|
||||
}
|
||||
if (firstPath === 'skipModelFetch') {
|
||||
return 'Invalid skipModelFetch. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isPinned') {
|
||||
return 'Invalid isPinned. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sortOrder') {
|
||||
return 'Invalid sortOrder. Expected non-negative integer.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'platformUserId') {
|
||||
return 'Invalid platformUserId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'refreshToken') {
|
||||
return 'Invalid refreshToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tokenExpiresAt') {
|
||||
return 'Invalid tokenExpiresAt. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return '账号 ID 无效';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid account payload.';
|
||||
}
|
||||
|
||||
function parseAccountsPayload<T>(schema: z.ZodType<T>, input: unknown):
|
||||
{ success: true; data: T } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeAccountsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountCreatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountUpdatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountBatchPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountRebindSessionPayload(input: unknown):
|
||||
{ success: true; data: AccountRebindSessionPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountRebindSessionPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountHealthRefreshPayload(input: unknown):
|
||||
{ success: true; data: AccountHealthRefreshPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountHealthRefreshPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountLoginPayload(input: unknown):
|
||||
{ success: true; data: AccountLoginPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountLoginPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountVerifyTokenPayload(input: unknown):
|
||||
{ success: true; data: AccountVerifyTokenPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountVerifyTokenPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountManualModelsPayload(input: unknown):
|
||||
{ success: true; data: AccountManualModelsPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountManualModelsPayloadSchema, input);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const downstreamExcludedCredentialRefSchema = z.union([
|
||||
z.object({
|
||||
kind: z.literal('account_token'),
|
||||
siteId: z.union([z.number(), z.string()]),
|
||||
accountId: z.union([z.number(), z.string()]),
|
||||
tokenId: z.union([z.number(), z.string()]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('default_api_key'),
|
||||
siteId: z.union([z.number(), z.string()]),
|
||||
accountId: z.union([z.number(), z.string()]),
|
||||
}),
|
||||
]);
|
||||
|
||||
const downstreamApiKeyPayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
expiresAt: z.union([z.string(), z.null()]).optional(),
|
||||
maxCost: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
maxRequests: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
supportedModels: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowedRouteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
|
||||
siteWeightMultipliers: z.union([
|
||||
z.string(),
|
||||
z.record(z.string(), z.union([z.number(), z.string()])),
|
||||
]).optional(),
|
||||
excludedSiteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
|
||||
excludedCredentialRefs: z.union([z.string(), z.array(downstreamExcludedCredentialRefSchema)]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const downstreamApiKeyBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
groupOperation: z.string().optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tagOperation: z.string().optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type DownstreamApiKeyBatchPayload = z.output<typeof downstreamApiKeyBatchPayloadSchema>;
|
||||
export type DownstreamApiKeyPayload = z.output<typeof downstreamApiKeyPayloadSchema>;
|
||||
|
||||
function normalizeDownstreamApiKeyPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatDownstreamApiKeyPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '参数无效:请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'key') {
|
||||
return 'Invalid key. Expected string.';
|
||||
}
|
||||
if (firstPath === 'description') {
|
||||
return 'Invalid description. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'groupName') {
|
||||
return 'Invalid groupName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tags') {
|
||||
return 'Invalid tags. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'expiresAt') {
|
||||
return 'Invalid expiresAt. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'maxCost') {
|
||||
return 'Invalid maxCost. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'maxRequests') {
|
||||
return 'Invalid maxRequests. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'supportedModels') {
|
||||
return 'Invalid supportedModels. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'allowedRouteIds') {
|
||||
return 'Invalid allowedRouteIds. Expected string or array.';
|
||||
}
|
||||
if (firstPath === 'siteWeightMultipliers') {
|
||||
return 'Invalid siteWeightMultipliers. Expected JSON object or string.';
|
||||
}
|
||||
if (firstPath === 'excludedSiteIds') {
|
||||
return 'Invalid excludedSiteIds. Expected string or array.';
|
||||
}
|
||||
if (firstPath === 'excludedCredentialRefs') {
|
||||
return 'Invalid excludedCredentialRefs. Expected JSON string or array.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'groupOperation') {
|
||||
return 'Invalid groupOperation. Expected string.';
|
||||
}
|
||||
if (firstPath === 'tagOperation') {
|
||||
return 'Invalid tagOperation. Expected string.';
|
||||
}
|
||||
return '参数无效';
|
||||
}
|
||||
|
||||
function parseDownstreamApiKeyRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeDownstreamApiKeyPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatDownstreamApiKeyPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyBatchPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyBatchPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyBatchPayloadSchema, input);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const backupExportTypeSchema = z.enum(['all', 'accounts', 'preferences']);
|
||||
const migrationDialectSchema = z.enum(['sqlite', 'mysql', 'postgres']);
|
||||
|
||||
const runtimeSettingsPayloadSchema = z.object({
|
||||
modelAvailabilityProbeEnabled: z.boolean().optional(),
|
||||
webhookEnabled: z.boolean().optional(),
|
||||
barkEnabled: z.boolean().optional(),
|
||||
serverChanEnabled: z.boolean().optional(),
|
||||
telegramEnabled: z.boolean().optional(),
|
||||
telegramUseSystemProxy: z.boolean().optional(),
|
||||
smtpEnabled: z.boolean().optional(),
|
||||
smtpSecure: z.boolean().optional(),
|
||||
logCleanupUsageLogsEnabled: z.boolean().optional(),
|
||||
logCleanupProgramLogsEnabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const systemProxyTestPayloadSchema = z.object({
|
||||
proxyUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const databaseMigrationPayloadSchema = z.object({
|
||||
dialect: migrationDialectSchema,
|
||||
connectionString: z.string().trim().min(1),
|
||||
overwrite: z.boolean().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
clearPassword: z.boolean().optional(),
|
||||
exportType: backupExportTypeSchema.optional(),
|
||||
autoSyncEnabled: z.boolean().optional(),
|
||||
autoSyncCron: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavExportPayloadSchema = z.object({
|
||||
type: backupExportTypeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupImportPayloadSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}).passthrough();
|
||||
|
||||
export type BackupWebdavConfigPayload = z.output<typeof backupWebdavConfigPayloadSchema>;
|
||||
export type BackupWebdavExportPayload = z.output<typeof backupWebdavExportPayloadSchema>;
|
||||
export type BackupImportPayload = z.output<typeof backupImportPayloadSchema>;
|
||||
export type DatabaseMigrationPayload = z.output<typeof databaseMigrationPayloadSchema>;
|
||||
export type RuntimeSettingsPayload = z.output<typeof runtimeSettingsPayloadSchema>;
|
||||
export type SystemProxyTestPayload = z.output<typeof systemProxyTestPayloadSchema>;
|
||||
|
||||
function normalizeSettingsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSettingsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'exportType') {
|
||||
return 'Invalid exportType. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'type') {
|
||||
return 'Invalid type. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return '系统代理地址格式无效:需要 string';
|
||||
}
|
||||
if (firstPath === 'dialect') {
|
||||
return 'Invalid dialect. Expected sqlite/mysql/postgres.';
|
||||
}
|
||||
if (firstPath === 'connectionString') {
|
||||
return 'Invalid connectionString. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'overwrite') {
|
||||
return 'Invalid overwrite. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ssl') {
|
||||
return 'Invalid ssl. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return '导入数据格式错误:需要 JSON 对象';
|
||||
}
|
||||
if (firstPath === 'webhookEnabled') {
|
||||
return 'Webhook 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'modelAvailabilityProbeEnabled') {
|
||||
return '批量测活开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'barkEnabled') {
|
||||
return 'Bark 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'serverChanEnabled') {
|
||||
return 'Server 酱开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramEnabled') {
|
||||
return 'Telegram 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramUseSystemProxy') {
|
||||
return 'Telegram 使用系统代理格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpEnabled') {
|
||||
return 'SMTP 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpSecure') {
|
||||
return 'SMTP 安全连接格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupUsageLogsEnabled') {
|
||||
return '自动清理使用日志格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupProgramLogsEnabled') {
|
||||
return '自动清理程序日志格式无效:需要 boolean';
|
||||
}
|
||||
return 'Invalid settings payload.';
|
||||
}
|
||||
|
||||
export function parseRuntimeSettingsPayload(input: unknown):
|
||||
{ success: true; data: RuntimeSettingsPayload } | { success: false; error: string } {
|
||||
const result = runtimeSettingsPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSystemProxyTestPayload(input: unknown):
|
||||
{ success: true; data: SystemProxyTestPayload } | { success: false; error: string } {
|
||||
const result = systemProxyTestPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDatabaseMigrationPayload(input: unknown):
|
||||
{ success: true; data: DatabaseMigrationPayload } | { success: false; error: string } {
|
||||
const result = databaseMigrationPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavConfigPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavConfigPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavConfigPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupImportPayload(input: unknown):
|
||||
{ success: true; data: BackupImportPayload } | { success: false; error: string } {
|
||||
const result = backupImportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavExportPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavExportPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavExportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const requiredTrimmedString = z.string().trim().min(1);
|
||||
const unknownField = z.unknown().optional();
|
||||
|
||||
const siteCreatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString,
|
||||
url: requiredTrimmedString,
|
||||
platform: z.string().trim().optional(),
|
||||
initializationPresetId: z.union([z.string(), z.null()]).optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteUpdatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString.optional(),
|
||||
url: requiredTrimmedString.optional(),
|
||||
platform: requiredTrimmedString.optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDisabledModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDetectPayloadSchema = z.object({
|
||||
url: requiredTrimmedString,
|
||||
}).passthrough();
|
||||
|
||||
export type SiteBatchPayload = z.output<typeof siteBatchPayloadSchema>;
|
||||
export type SiteCreatePayload = z.output<typeof siteCreatePayloadSchema>;
|
||||
export type SiteDetectPayload = z.output<typeof siteDetectPayloadSchema>;
|
||||
export type SiteDisabledModelsPayload = z.output<typeof siteDisabledModelsPayloadSchema>;
|
||||
export type SiteUpdatePayload = z.output<typeof siteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeSitePayloadInput(input: unknown): unknown {
|
||||
if (input === undefined) return {};
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) return input;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatSitePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'url') {
|
||||
return 'Invalid url. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'platform') {
|
||||
return 'Invalid platform. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid site payload.';
|
||||
}
|
||||
|
||||
export function parseSiteCreatePayload(input: unknown):
|
||||
{ success: true; data: SiteCreatePayload } | { success: false; error: string } {
|
||||
const result = siteCreatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteUpdatePayload(input: unknown):
|
||||
{ success: true; data: SiteUpdatePayload } | { success: false; error: string } {
|
||||
const result = siteUpdatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteBatchPayload(input: unknown):
|
||||
{ success: true; data: SiteBatchPayload } | { success: false; error: string } {
|
||||
const result = siteBatchPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDisabledModelsPayload(input: unknown):
|
||||
{ success: true; data: SiteDisabledModelsPayload } | { success: false; error: string } {
|
||||
const result = siteDisabledModelsPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDetectPayload(input: unknown):
|
||||
{ success: true; data: SiteDetectPayload } | { success: false; error: string } {
|
||||
const result = siteDetectPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateCenterVersionSourceSchema = z.enum(['docker-hub-tag', 'github-release']);
|
||||
|
||||
const authChangePayloadSchema = z.object({
|
||||
oldToken: z.string().optional(),
|
||||
newToken: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const monitorConfigPayloadSchema = z.object({
|
||||
ldohCookie: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthStartPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
projectId: z.string().optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthManualCallbackPayloadSchema = z.object({
|
||||
callbackUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionRebindPayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionProxyUpdatePayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthQuotaBatchRefreshPayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthImportPayloadSchema = z.object({
|
||||
data: z.unknown().optional(),
|
||||
items: z.array(z.object({}).passthrough()).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
useSystemProxy: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitStrategySchema = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.trim().toLowerCase();
|
||||
}, z.enum(['round_robin', 'stick_until_unavailable']));
|
||||
|
||||
const oauthRouteUnitCreatePayloadSchema = z.object({
|
||||
accountIds: z.array(z.number().int().positive()).optional(),
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthRouteUnitUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
strategy: oauthRouteUnitStrategySchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
helperBaseUrl: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
releaseName: z.string().optional(),
|
||||
chartRef: z.string().optional(),
|
||||
imageRepository: z.string().optional(),
|
||||
githubReleasesEnabled: z.boolean().optional(),
|
||||
dockerHubTagsEnabled: z.boolean().optional(),
|
||||
defaultDeploySource: updateCenterVersionSourceSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterDeployPayloadSchema = z.object({
|
||||
source: updateCenterVersionSourceSchema.optional(),
|
||||
targetVersion: z.string().optional(),
|
||||
targetTag: z.string().optional(),
|
||||
targetDigest: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterRollbackPayloadSchema = z.object({
|
||||
targetRevision: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AuthChangePayload = z.output<typeof authChangePayloadSchema>;
|
||||
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
|
||||
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
|
||||
export type OauthConnectionProxyUpdatePayload = z.output<typeof oauthConnectionProxyUpdatePayloadSchema>;
|
||||
export type OauthImportPayload = z.output<typeof oauthImportPayloadSchema>;
|
||||
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
|
||||
export type OauthQuotaBatchRefreshPayload = z.output<typeof oauthQuotaBatchRefreshPayloadSchema>;
|
||||
export type OauthRouteUnitCreatePayload = z.output<typeof oauthRouteUnitCreatePayloadSchema>;
|
||||
export type OauthRouteUnitUpdatePayload = z.output<typeof oauthRouteUnitUpdatePayloadSchema>;
|
||||
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
|
||||
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
|
||||
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
|
||||
export type UpdateCenterRollbackPayload = z.output<typeof updateCenterRollbackPayloadSchema>;
|
||||
|
||||
function normalizeSupportRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSupportRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'oldToken') {
|
||||
return 'Invalid oldToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'newToken') {
|
||||
return 'Invalid newToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ldohCookie') {
|
||||
return 'Invalid ldohCookie. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'projectId') {
|
||||
return 'Invalid projectId. Expected string.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'useSystemProxy') {
|
||||
return 'Invalid useSystemProxy. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'accountIds') {
|
||||
return 'Invalid accountIds. Expected positive number array.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'strategy') {
|
||||
return 'Invalid strategy. Expected round_robin/stick_until_unavailable.';
|
||||
}
|
||||
if (firstPath === 'callbackUrl') {
|
||||
return 'Invalid callbackUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return 'Invalid data. Expected object.';
|
||||
}
|
||||
if (firstPath === 'items') {
|
||||
return 'Invalid items. Expected object array.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'helperBaseUrl') {
|
||||
return 'Invalid helperBaseUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'namespace') {
|
||||
return 'Invalid namespace. Expected string.';
|
||||
}
|
||||
if (firstPath === 'releaseName') {
|
||||
return 'Invalid releaseName. Expected string.';
|
||||
}
|
||||
if (firstPath === 'chartRef') {
|
||||
return 'Invalid chartRef. Expected string.';
|
||||
}
|
||||
if (firstPath === 'imageRepository') {
|
||||
return 'Invalid imageRepository. Expected string.';
|
||||
}
|
||||
if (firstPath === 'githubReleasesEnabled') {
|
||||
return 'Invalid githubReleasesEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'dockerHubTagsEnabled') {
|
||||
return 'Invalid dockerHubTagsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'defaultDeploySource') {
|
||||
return 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'targetVersion') {
|
||||
return 'Invalid targetVersion. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetTag') {
|
||||
return 'Invalid targetTag. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetDigest') {
|
||||
return 'Invalid targetDigest. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetRevision') {
|
||||
return 'Invalid targetRevision. Expected string.';
|
||||
}
|
||||
return 'Invalid support route payload.';
|
||||
}
|
||||
|
||||
function parseSupportRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeSupportRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSupportRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAuthChangePayload(input: unknown):
|
||||
{ success: true; data: AuthChangePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(authChangePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseMonitorConfigPayload(input: unknown):
|
||||
{ success: true; data: MonitorConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(monitorConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthStartPayload(input: unknown):
|
||||
{ success: true; data: OauthStartPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthStartPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthManualCallbackPayload(input: unknown):
|
||||
{ success: true; data: OauthManualCallbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthManualCallbackPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionRebindPayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionRebindPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionProxyUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionProxyUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionProxyUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthQuotaBatchRefreshPayload(input: unknown):
|
||||
{ success: true; data: OauthQuotaBatchRefreshPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthQuotaBatchRefreshPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthImportPayload(input: unknown):
|
||||
{ success: true; data: OauthImportPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthImportPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitCreatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitCreatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthRouteUnitUpdatePayload(input: unknown):
|
||||
{ success: true; data: OauthRouteUnitUpdatePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthRouteUnitUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterConfigPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterDeployPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterDeployPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterDeployPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterRollbackPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterRollbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterRollbackPayloadSchema, input);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const routeChannelCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelBatchCreatePayloadSchema = z.object({
|
||||
channels: z.array(z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
}).passthrough()).min(1),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelUpdatePayloadSchema = z.object({
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.union([z.string(), z.null()]).optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteCreatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteUpdatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeRebuildPayloadSchema = z.object({
|
||||
refreshModels: z.boolean().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type RouteChannelBatchCreatePayload = z.output<typeof routeChannelBatchCreatePayloadSchema>;
|
||||
export type RouteChannelCreatePayload = z.output<typeof routeChannelCreatePayloadSchema>;
|
||||
export type RouteChannelUpdatePayload = z.output<typeof routeChannelUpdatePayloadSchema>;
|
||||
export type RouteRebuildPayload = z.output<typeof routeRebuildPayloadSchema>;
|
||||
export type TokenRouteBatchPayload = z.output<typeof tokenRouteBatchPayloadSchema>;
|
||||
export type TokenRouteCreatePayload = z.output<typeof tokenRouteCreatePayloadSchema>;
|
||||
export type TokenRouteUpdatePayload = z.output<typeof tokenRouteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeTokenRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatTokenRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath, secondPath, thirdPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'routeMode') {
|
||||
return 'Invalid routeMode. Expected string.';
|
||||
}
|
||||
if (firstPath === 'modelPattern') {
|
||||
return 'Invalid modelPattern. Expected string.';
|
||||
}
|
||||
if (firstPath === 'displayName') {
|
||||
return 'Invalid displayName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'displayIcon') {
|
||||
return 'Invalid displayIcon. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'modelMapping') {
|
||||
return 'Invalid modelMapping. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'routingStrategy') {
|
||||
return 'Invalid routingStrategy. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sourceRouteIds') {
|
||||
return 'Invalid sourceRouteIds. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'tokenId') {
|
||||
return 'Invalid tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'sourceModel') {
|
||||
return 'Invalid sourceModel. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'priority') {
|
||||
return 'Invalid priority. Expected number.';
|
||||
}
|
||||
if (firstPath === 'weight') {
|
||||
return 'Invalid weight. Expected number.';
|
||||
}
|
||||
if (firstPath === 'refreshModels') {
|
||||
return 'Invalid refreshModels. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'accountId') {
|
||||
return 'Invalid channels[].accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'tokenId') {
|
||||
return 'Invalid channels[].tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'sourceModel') {
|
||||
return 'Invalid channels[].sourceModel. Expected string.';
|
||||
}
|
||||
if (firstPath === 'channels') {
|
||||
return 'Invalid channels. Expected channel array.';
|
||||
}
|
||||
return 'Invalid token route payload.';
|
||||
}
|
||||
|
||||
function parseTokenRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeTokenRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatTokenRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTokenRouteCreatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteUpdatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteBatchPayload(input: unknown):
|
||||
{ success: true; data: TokenRouteBatchPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelBatchCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelBatchCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelBatchCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelUpdatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteRebuildPayload(input: unknown):
|
||||
{ success: true; data: RouteRebuildPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeRebuildPayloadSchema, input);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(128),
|
||||
});
|
||||
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(6).max(128),
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
export type AccountTokenSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface AccountTokenSchemaInspector {
|
||||
dialect: AccountTokenSchemaDialect;
|
||||
tableExists(table: string): Promise<boolean>;
|
||||
columnExists(table: string, column: string): Promise<boolean>;
|
||||
execute(sqlText: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type AccountTokenColumnCompatibilitySpec = {
|
||||
table: 'account_tokens';
|
||||
column: string;
|
||||
addSql: Record<AccountTokenSchemaDialect, string>;
|
||||
};
|
||||
|
||||
export const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [
|
||||
{
|
||||
table: 'account_tokens',
|
||||
column: 'token_group',
|
||||
addSql: {
|
||||
sqlite: 'ALTER TABLE account_tokens ADD COLUMN token_group text;',
|
||||
mysql: 'ALTER TABLE `account_tokens` ADD COLUMN `token_group` TEXT NULL',
|
||||
postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT',
|
||||
},
|
||||
},
|
||||
{
|
||||
table: 'account_tokens',
|
||||
column: 'value_status',
|
||||
addSql: {
|
||||
sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';",
|
||||
mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'",
|
||||
postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'object' && error && 'message' in error) {
|
||||
return String((error as { message?: unknown }).message || '');
|
||||
}
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
async function executeAddColumn(inspector: AccountTokenSchemaInspector, sqlText: string): Promise<void> {
|
||||
try {
|
||||
await inspector.execute(sqlText);
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAccountTokenSchemaCompatibility(inspector: AccountTokenSchemaInspector): Promise<void> {
|
||||
for (const spec of ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS) {
|
||||
const hasTable = await inspector.tableExists(spec.table);
|
||||
if (!hasTable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasColumn = await inspector.columnExists(spec.table, spec.column);
|
||||
if (!hasColumn) {
|
||||
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1, `post_refresh_probe_enabled` BOOLEAN DEFAULT false, `post_refresh_probe_model` VARCHAR(191) DEFAULT '', `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single', `post_refresh_probe_latency_threshold_ms` INT DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `admin_snapshots` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `namespace` TEXT NOT NULL, `snapshot_key` TEXT NOT NULL, `payload` TEXT NOT NULL, `generated_at` VARCHAR(191) NOT NULL, `expires_at` VARCHAR(191) NOT NULL, `stale_until` VARCHAR(191) NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `analytics_projection_checkpoints` (`projector_key` VARCHAR(191) NOT NULL PRIMARY KEY, `time_zone` VARCHAR(191) NOT NULL DEFAULT 'Local', `last_proxy_log_id` INT NOT NULL DEFAULT 0, `watermark_created_at` VARCHAR(191), `recompute_from_id` INT, `recompute_requested_at` VARCHAR(191), `recompute_reason` TEXT, `recompute_started_at` VARCHAR(191), `recompute_completed_at` VARCHAR(191), `last_projected_at` VARCHAR(191), `last_successful_at` VARCHAR(191), `last_error` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `lease_owner` TEXT, `lease_token` TEXT, `lease_expires_at` VARCHAR(191));
|
||||
CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` VARCHAR(191), `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `group_name` TEXT, `tags` TEXT, `excluded_site_ids` JSON, `excluded_credential_refs` JSON);
|
||||
CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `model_day_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `local_day` TEXT NOT NULL, `site_id` INT NOT NULL, `model` TEXT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_units` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `provider` TEXT NOT NULL, `name` TEXT NOT NULL, `strategy` VARCHAR(191) NOT NULL DEFAULT 'round_robin', `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `oauth_route_unit_members` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `unit_id` INT NOT NULL, `account_id` INT NOT NULL, `sort_order` INT DEFAULT 0, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_debug_traces` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `downstream_path` TEXT NOT NULL, `client_kind` TEXT, `session_id` TEXT, `trace_hint` TEXT, `requested_model` TEXT, `downstream_api_key_id` INT, `request_headers_json` JSON, `request_body_json` JSON, `sticky_session_key` TEXT, `sticky_hit_channel_id` INT, `selected_channel_id` INT, `selected_route_id` INT, `selected_account_id` INT, `selected_site_id` INT, `selected_site_platform` TEXT, `endpoint_candidates_json` JSON, `endpoint_runtime_state_json` JSON, `decision_summary_json` JSON, `final_status` TEXT, `final_http_status` INT, `final_upstream_path` TEXT, `final_response_headers_json` JSON, `final_response_body_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_debug_attempts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `trace_id` INT NOT NULL, `attempt_index` INT NOT NULL, `endpoint` TEXT NOT NULL, `request_path` TEXT NOT NULL, `target_url` TEXT NOT NULL, `runtime_executor` TEXT, `request_headers_json` JSON, `request_body_json` JSON, `response_status` INT, `response_headers_json` JSON, `response_body_json` JSON, `raw_error_text` TEXT, `recover_applied` BOOLEAN DEFAULT false, `downgrade_decision` BOOLEAN DEFAULT false, `downgrade_reason` TEXT, `memory_write_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`trace_id`) REFERENCES `proxy_debug_traces`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `deleted_at` VARCHAR(191));
|
||||
CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT, `is_stream` BOOLEAN, `first_byte_latency_ms` INT);
|
||||
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
|
||||
CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), `oauth_route_unit_id` INT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT);
|
||||
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` VARCHAR(191), `ends_at` VARCHAR(191), `upstream_created_at` VARCHAR(191), `upstream_updated_at` VARCHAR(191), `first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `last_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `read_at` VARCHAR(191), `dismissed_at` VARCHAR(191), `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_api_endpoints` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `url` TEXT NOT NULL, `enabled` BOOLEAN DEFAULT true, `sort_order` INT DEFAULT 0, `cooldown_until` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_failed_at` VARCHAR(191), `last_failure_reason` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_day_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `local_day` TEXT NOT NULL, `site_id` INT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_summary_spend` DOUBLE NOT NULL DEFAULT 0, `total_site_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `site_hour_usage` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `bucket_start_utc` TEXT NOT NULL, `site_id` INT NOT NULL, `total_calls` INT NOT NULL DEFAULT 0, `success_calls` INT NOT NULL DEFAULT 0, `failed_calls` INT NOT NULL DEFAULT 0, `total_tokens` INT NOT NULL DEFAULT 0, `total_summary_spend` DOUBLE NOT NULL DEFAULT 0, `total_site_spend` DOUBLE NOT NULL DEFAULT 0, `total_latency_ms` INT NOT NULL DEFAULT 0, `latency_count` INT NOT NULL DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX `admin_snapshots_namespace_key_unique` ON `admin_snapshots` (`namespace`(191), `snapshot_key`(191));
|
||||
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
|
||||
CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `model_day_usage_day_site_model_unique` ON `model_day_usage` (`local_day`(191), `site_id`, `model`(191));
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);
|
||||
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`, `account_id`);
|
||||
CREATE UNIQUE INDEX `proxy_debug_attempts_trace_attempt_unique` ON `proxy_debug_attempts` (`trace_id`, `attempt_index`);
|
||||
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
|
||||
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
|
||||
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
|
||||
CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`, `url`(191));
|
||||
CREATE UNIQUE INDEX `site_day_usage_day_site_unique` ON `site_day_usage` (`local_day`(191), `site_id`);
|
||||
CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191));
|
||||
CREATE UNIQUE INDEX `site_hour_usage_hour_site_unique` ON `site_hour_usage` (`bucket_start_utc`(191), `site_id`);
|
||||
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
|
||||
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
|
||||
CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`);
|
||||
CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
|
||||
CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
|
||||
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191));
|
||||
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191));
|
||||
CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`);
|
||||
CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191));
|
||||
CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191));
|
||||
CREATE INDEX `admin_snapshots_expires_at_idx` ON `admin_snapshots` (`expires_at`);
|
||||
CREATE INDEX `admin_snapshots_stale_until_idx` ON `admin_snapshots` (`stale_until`);
|
||||
CREATE INDEX `analytics_projection_checkpoints_lease_expires_at_idx` ON `analytics_projection_checkpoints` (`lease_expires_at`);
|
||||
CREATE INDEX `analytics_projection_checkpoints_recompute_from_id_idx` ON `analytics_projection_checkpoints` (`recompute_from_id`);
|
||||
CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`);
|
||||
CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`);
|
||||
CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`(191));
|
||||
CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`);
|
||||
CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`);
|
||||
CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191));
|
||||
CREATE INDEX `events_created_at_idx` ON `events` (`created_at`);
|
||||
CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`);
|
||||
CREATE INDEX `events_type_created_at_idx` ON `events` (`type`(191), `created_at`);
|
||||
CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`);
|
||||
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
|
||||
CREATE INDEX `model_day_usage_day_idx` ON `model_day_usage` (`local_day`(191));
|
||||
CREATE INDEX `model_day_usage_model_idx` ON `model_day_usage` (`model`(191));
|
||||
CREATE INDEX `model_day_usage_site_id_idx` ON `model_day_usage` (`site_id`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`, `cooldown_until`);
|
||||
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`, `sort_order`);
|
||||
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);
|
||||
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`, `provider`(191));
|
||||
CREATE INDEX `proxy_debug_attempts_trace_created_at_idx` ON `proxy_debug_attempts` (`trace_id`, `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_created_at_idx` ON `proxy_debug_traces` (`created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_final_status_created_at_idx` ON `proxy_debug_traces` (`final_status`(191), `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_model_created_at_idx` ON `proxy_debug_traces` (`requested_model`(191), `created_at`);
|
||||
CREATE INDEX `proxy_debug_traces_session_created_at_idx` ON `proxy_debug_traces` (`session_id`(191), `created_at`);
|
||||
CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`);
|
||||
CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
|
||||
CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
|
||||
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
|
||||
CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`);
|
||||
CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`);
|
||||
CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191));
|
||||
CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`);
|
||||
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
|
||||
CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`);
|
||||
CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`);
|
||||
CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`);
|
||||
CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`);
|
||||
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
|
||||
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
|
||||
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
|
||||
CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`, `cooldown_until`);
|
||||
CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`, `enabled`, `sort_order`);
|
||||
CREATE INDEX `site_day_usage_day_idx` ON `site_day_usage` (`local_day`(191));
|
||||
CREATE INDEX `site_day_usage_site_id_idx` ON `site_day_usage` (`site_id`);
|
||||
CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
|
||||
CREATE INDEX `site_hour_usage_hour_idx` ON `site_hour_usage` (`bucket_start_utc`(191));
|
||||
CREATE INDEX `site_hour_usage_site_id_idx` ON `site_hour_usage` (`site_id`);
|
||||
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
||||
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
||||
CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191));
|
||||
CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`);
|
||||
CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`);
|
||||
CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191));
|
||||
@@ -1 +0,0 @@
|
||||
-- no schema changes detected for mysql
|
||||
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1, "post_refresh_probe_enabled" BOOLEAN DEFAULT false, "post_refresh_probe_model" TEXT DEFAULT '', "post_refresh_probe_scope" TEXT DEFAULT 'single', "post_refresh_probe_latency_threshold_ms" INTEGER DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "admin_snapshots" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "namespace" TEXT NOT NULL, "snapshot_key" TEXT NOT NULL, "payload" TEXT NOT NULL, "generated_at" TEXT NOT NULL, "expires_at" TEXT NOT NULL, "stale_until" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "analytics_projection_checkpoints" ("projector_key" TEXT NOT NULL PRIMARY KEY, "time_zone" TEXT NOT NULL DEFAULT 'Local', "last_proxy_log_id" INTEGER NOT NULL DEFAULT 0, "watermark_created_at" TEXT, "recompute_from_id" INTEGER, "recompute_requested_at" TEXT, "recompute_reason" TEXT, "recompute_started_at" TEXT, "recompute_completed_at" TEXT, "last_projected_at" TEXT, "last_successful_at" TEXT, "last_error" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "lease_owner" TEXT, "lease_token" TEXT, "lease_expires_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "group_name" TEXT, "tags" TEXT, "excluded_site_ids" JSONB, "excluded_credential_refs" JSONB);
|
||||
CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "model_day_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "local_day" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "model" TEXT NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_units" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "provider" TEXT NOT NULL, "name" TEXT NOT NULL, "strategy" TEXT NOT NULL DEFAULT 'round_robin', "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "oauth_route_unit_members" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "unit_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "sort_order" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("unit_id") REFERENCES "oauth_route_units"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_debug_traces" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "downstream_path" TEXT NOT NULL, "client_kind" TEXT, "session_id" TEXT, "trace_hint" TEXT, "requested_model" TEXT, "downstream_api_key_id" INTEGER, "request_headers_json" JSONB, "request_body_json" JSONB, "sticky_session_key" TEXT, "sticky_hit_channel_id" INTEGER, "selected_channel_id" INTEGER, "selected_route_id" INTEGER, "selected_account_id" INTEGER, "selected_site_id" INTEGER, "selected_site_platform" TEXT, "endpoint_candidates_json" JSONB, "endpoint_runtime_state_json" JSONB, "decision_summary_json" JSONB, "final_status" TEXT, "final_http_status" INTEGER, "final_upstream_path" TEXT, "final_response_headers_json" JSONB, "final_response_body_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "proxy_debug_attempts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "trace_id" INTEGER NOT NULL, "attempt_index" INTEGER NOT NULL, "endpoint" TEXT NOT NULL, "request_path" TEXT NOT NULL, "target_url" TEXT NOT NULL, "runtime_executor" TEXT, "request_headers_json" JSONB, "request_body_json" JSONB, "response_status" INTEGER, "response_headers_json" JSONB, "response_body_json" JSONB, "raw_error_text" TEXT, "recover_applied" BOOLEAN DEFAULT false, "downgrade_decision" BOOLEAN DEFAULT false, "downgrade_reason" TEXT, "memory_write_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("trace_id") REFERENCES "proxy_debug_traces"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "deleted_at" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT, "is_stream" BOOLEAN, "first_byte_latency_ms" INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
|
||||
CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern');
|
||||
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, "oauth_route_unit_id" INTEGER, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
||||
CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT);
|
||||
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TEXT, "ends_at" TEXT, "upstream_created_at" TEXT, "upstream_updated_at" TEXT, "first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "last_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "read_at" TEXT, "dismissed_at" TEXT, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_api_endpoints" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "url" TEXT NOT NULL, "enabled" BOOLEAN DEFAULT true, "sort_order" INTEGER DEFAULT 0, "cooldown_until" TEXT, "last_selected_at" TEXT, "last_failed_at" TEXT, "last_failure_reason" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_day_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "local_day" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_summary_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_site_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "site_hour_usage" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "bucket_start_utc" TEXT NOT NULL, "site_id" INTEGER NOT NULL, "total_calls" INTEGER NOT NULL DEFAULT 0, "success_calls" INTEGER NOT NULL DEFAULT 0, "failed_calls" INTEGER NOT NULL DEFAULT 0, "total_tokens" INTEGER NOT NULL DEFAULT 0, "total_summary_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_site_spend" DOUBLE PRECISION NOT NULL DEFAULT 0, "total_latency_ms" INTEGER NOT NULL DEFAULT 0, "latency_count" INTEGER NOT NULL DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "admin_snapshots_namespace_key_unique" ON "admin_snapshots" ("namespace", "snapshot_key");
|
||||
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
|
||||
CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name");
|
||||
CREATE UNIQUE INDEX "model_day_usage_day_site_model_unique" ON "model_day_usage" ("local_day", "site_id", "model");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_account_unique" ON "oauth_route_unit_members" ("account_id");
|
||||
CREATE UNIQUE INDEX "oauth_route_unit_members_unit_account_unique" ON "oauth_route_unit_members" ("unit_id", "account_id");
|
||||
CREATE UNIQUE INDEX "proxy_debug_attempts_trace_attempt_unique" ON "proxy_debug_attempts" ("trace_id", "attempt_index");
|
||||
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
|
||||
CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
|
||||
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
|
||||
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
|
||||
CREATE UNIQUE INDEX "site_api_endpoints_site_url_unique" ON "site_api_endpoints" ("site_id", "url");
|
||||
CREATE UNIQUE INDEX "site_day_usage_day_site_unique" ON "site_day_usage" ("local_day", "site_id");
|
||||
CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name");
|
||||
CREATE UNIQUE INDEX "site_hour_usage_hour_site_unique" ON "site_hour_usage" ("bucket_start_utc", "site_id");
|
||||
CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url");
|
||||
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
|
||||
CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled");
|
||||
CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id");
|
||||
CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled");
|
||||
CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id");
|
||||
CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider");
|
||||
CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id");
|
||||
CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status");
|
||||
CREATE INDEX "accounts_status_idx" ON "accounts" ("status");
|
||||
CREATE INDEX "admin_snapshots_expires_at_idx" ON "admin_snapshots" ("expires_at");
|
||||
CREATE INDEX "admin_snapshots_stale_until_idx" ON "admin_snapshots" ("stale_until");
|
||||
CREATE INDEX "analytics_projection_checkpoints_lease_expires_at_idx" ON "analytics_projection_checkpoints" ("lease_expires_at");
|
||||
CREATE INDEX "analytics_projection_checkpoints_recompute_from_id_idx" ON "analytics_projection_checkpoints" ("recompute_from_id");
|
||||
CREATE INDEX "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at");
|
||||
CREATE INDEX "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at");
|
||||
CREATE INDEX "checkin_logs_status_idx" ON "checkin_logs" ("status");
|
||||
CREATE INDEX "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled");
|
||||
CREATE INDEX "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at");
|
||||
CREATE INDEX "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name");
|
||||
CREATE INDEX "events_created_at_idx" ON "events" ("created_at");
|
||||
CREATE INDEX "events_read_created_at_idx" ON "events" ("read", "created_at");
|
||||
CREATE INDEX "events_type_created_at_idx" ON "events" ("type", "created_at");
|
||||
CREATE INDEX "model_availability_account_available_idx" ON "model_availability" ("account_id", "available");
|
||||
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
|
||||
CREATE INDEX "model_day_usage_day_idx" ON "model_day_usage" ("local_day");
|
||||
CREATE INDEX "model_day_usage_model_idx" ON "model_day_usage" ("model");
|
||||
CREATE INDEX "model_day_usage_site_id_idx" ON "model_day_usage" ("site_id");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_cooldown_idx" ON "oauth_route_unit_members" ("unit_id", "cooldown_until");
|
||||
CREATE INDEX "oauth_route_unit_members_unit_sort_idx" ON "oauth_route_unit_members" ("unit_id", "sort_order");
|
||||
CREATE INDEX "oauth_route_units_enabled_idx" ON "oauth_route_units" ("enabled");
|
||||
CREATE INDEX "oauth_route_units_site_provider_idx" ON "oauth_route_units" ("site_id", "provider");
|
||||
CREATE INDEX "proxy_debug_attempts_trace_created_at_idx" ON "proxy_debug_attempts" ("trace_id", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_created_at_idx" ON "proxy_debug_traces" ("created_at");
|
||||
CREATE INDEX "proxy_debug_traces_final_status_created_at_idx" ON "proxy_debug_traces" ("final_status", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_model_created_at_idx" ON "proxy_debug_traces" ("requested_model", "created_at");
|
||||
CREATE INDEX "proxy_debug_traces_session_created_at_idx" ON "proxy_debug_traces" ("session_id", "created_at");
|
||||
CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at");
|
||||
CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at");
|
||||
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
|
||||
CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
|
||||
CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
|
||||
CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at");
|
||||
CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at");
|
||||
CREATE INDEX "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id");
|
||||
CREATE INDEX "route_channels_account_id_idx" ON "route_channels" ("account_id");
|
||||
CREATE INDEX "route_channels_oauth_route_unit_id_idx" ON "route_channels" ("oauth_route_unit_id");
|
||||
CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled");
|
||||
CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id");
|
||||
CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id");
|
||||
CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id");
|
||||
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
|
||||
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
|
||||
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
|
||||
CREATE INDEX "site_api_endpoints_site_cooldown_idx" ON "site_api_endpoints" ("site_id", "cooldown_until");
|
||||
CREATE INDEX "site_api_endpoints_site_enabled_sort_idx" ON "site_api_endpoints" ("site_id", "enabled", "sort_order");
|
||||
CREATE INDEX "site_day_usage_day_idx" ON "site_day_usage" ("local_day");
|
||||
CREATE INDEX "site_day_usage_site_id_idx" ON "site_day_usage" ("site_id");
|
||||
CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
|
||||
CREATE INDEX "site_hour_usage_hour_idx" ON "site_hour_usage" ("bucket_start_utc");
|
||||
CREATE INDEX "site_hour_usage_site_id_idx" ON "site_hour_usage" ("site_id");
|
||||
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
||||
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
||||
CREATE INDEX "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name");
|
||||
CREATE INDEX "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available");
|
||||
CREATE INDEX "token_routes_enabled_idx" ON "token_routes" ("enabled");
|
||||
CREATE INDEX "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern");
|
||||
@@ -1 +0,0 @@
|
||||
-- no schema changes detected for postgres
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
|
||||
describe('sqlite default path resolution', () => {
|
||||
let dbModule: DbModule | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (dbModule) {
|
||||
await dbModule.closeDbConnections();
|
||||
dbModule = null;
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('uses an isolated temp sqlite path under vitest when no db env is configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
const sharedRepoPath = resolve('./data/hub.db');
|
||||
|
||||
expect(sqlitePath).not.toBe(sharedRepoPath);
|
||||
expect(sqlitePath).toContain(tmpdir());
|
||||
expect(sqlitePath).toContain('metapi-vitest');
|
||||
});
|
||||
|
||||
it('still honors explicit DATA_DIR when provided', async () => {
|
||||
process.env.DATA_DIR = resolve(tmpdir(), 'metapi-explicit-data-dir');
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
|
||||
expect(sqlitePath).toBe(resolve(process.env.DATA_DIR, 'hub.db'));
|
||||
});
|
||||
|
||||
it('ignores the default repo DATA_DIR under vitest and still isolates sqlite', async () => {
|
||||
process.env.DATA_DIR = './data';
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
|
||||
dbModule = await import('./index.js');
|
||||
const sqlitePath = dbModule.__dbProxyTestUtils.resolveSqlitePath();
|
||||
const sharedRepoPath = resolve('./data/hub.db');
|
||||
|
||||
expect(sqlitePath).not.toBe(sharedRepoPath);
|
||||
expect(sqlitePath).toContain(tmpdir());
|
||||
expect(sqlitePath).toContain('metapi-vitest');
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
|
||||
describe('db proxy query wrapper', () => {
|
||||
let testUtils: DbModule['__dbProxyTestUtils'];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'metapi-db-proxy-wrap-'));
|
||||
const dbModule = await import('./index.js');
|
||||
testUtils = dbModule.__dbProxyTestUtils;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testUtils.resetPostgresJsonTextParsersInstallStateForTests();
|
||||
});
|
||||
|
||||
it('wraps thenable query builders and provides all/get shims', async () => {
|
||||
const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]);
|
||||
const queryLike = {
|
||||
execute,
|
||||
where() {
|
||||
return this;
|
||||
},
|
||||
then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(execute()).then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
|
||||
expect(testUtils.shouldWrapObject(queryLike)).toBe(true);
|
||||
const wrapped = testUtils.wrapQueryLike(queryLike as any);
|
||||
|
||||
const rows = await wrapped.where().all();
|
||||
const row = await wrapped.where().get();
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(rows).toEqual([{ id: 1, name: 'demo' }]);
|
||||
expect(row).toEqual({ id: 1, name: 'demo' });
|
||||
});
|
||||
|
||||
it('provides run shim for thenable query builders', async () => {
|
||||
const execute = vi.fn(async () => [{ changes: 3, lastInsertRowid: 9 }]);
|
||||
const queryLike = {
|
||||
execute,
|
||||
then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) {
|
||||
return Promise.resolve(execute()).then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = testUtils.wrapQueryLike(queryLike as any);
|
||||
const runResult = await wrapped.run();
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 });
|
||||
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes postgres inserts with returning ids for numeric-id tables', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ id: 42 }],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const executor = { query };
|
||||
|
||||
const result = await testUtils.pgProxyQuery(
|
||||
executor as any,
|
||||
'insert into "sites" ("name") values ($1)',
|
||||
['demo'],
|
||||
'execute',
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
text: 'insert into "sites" ("name") values ($1) returning id',
|
||||
values: ['demo'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rows: [{ changes: 1, lastInsertRowid: 42 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes postgres inserts with returning ids for oauth route units', async () => {
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ id: 7 }],
|
||||
rowCount: 1,
|
||||
}));
|
||||
const executor = { query };
|
||||
|
||||
const result = await testUtils.pgProxyQuery(
|
||||
executor as any,
|
||||
'insert into "oauth_route_units" ("site_id", "provider", "name") values ($1, $2, $3)',
|
||||
[1, 'codex', 'pool-a'],
|
||||
'execute',
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith({
|
||||
text: 'insert into "oauth_route_units" ("site_id", "provider", "name") values ($1, $2, $3) returning id',
|
||||
values: [1, 'codex', 'pool-a'],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
rows: [{ changes: 1, lastInsertRowid: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mysql pool options with jsonStrings enabled', () => {
|
||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
|
||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||
jsonStrings: true,
|
||||
});
|
||||
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', true)).toMatchObject({
|
||||
uri: 'mysql://root:pass@db.example.com:3306/metapi',
|
||||
jsonStrings: true,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds postgres pool options with ssl when requested', () => {
|
||||
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', false)).toEqual({
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
});
|
||||
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', true)).toMatchObject({
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('installs postgres JSON text parsers idempotently', () => {
|
||||
const setTypeParser = vi.fn();
|
||||
const fakeTypes = {
|
||||
builtins: {
|
||||
JSON: 114,
|
||||
JSONB: 3802,
|
||||
},
|
||||
setTypeParser,
|
||||
};
|
||||
|
||||
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
|
||||
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
|
||||
|
||||
expect(setTypeParser).toHaveBeenCalledTimes(2);
|
||||
expect(setTypeParser).toHaveBeenNthCalledWith(1, 114, 'text', expect.any(Function));
|
||||
expect(setTypeParser).toHaveBeenNthCalledWith(2, 3802, 'text', expect.any(Function));
|
||||
const parser = setTypeParser.mock.calls[0][2] as (value: string) => string;
|
||||
expect(parser('{"ok":true}')).toBe('{"ok":true}');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('./index.js');
|
||||
type InsertHelpersModule = typeof import('./insertHelpers.js');
|
||||
|
||||
describe('insert helpers', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let insertHelpers: InsertHelpersModule;
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-insert-helpers-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
await import('./migrate.js');
|
||||
const dbModule = await import('./index.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
insertHelpers = await import('./insertHelpers.js');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts positive inserted ids from run results', () => {
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 12 })).toBe(12);
|
||||
expect(insertHelpers.getInsertedRowId({ changes: 1, lastInsertRowid: 0 })).toBeNull();
|
||||
expect(insertHelpers.getInsertedRowId(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when a required inserted id is missing', () => {
|
||||
expect(() => insertHelpers.requireInsertedRowId({ changes: 1, lastInsertRowid: 0 }, 'missing insert id'))
|
||||
.toThrow('missing insert id');
|
||||
});
|
||||
|
||||
it('inserts and reloads rows through the shared helper', async () => {
|
||||
const created = await insertHelpers.insertAndGetById({
|
||||
table: schema.sites,
|
||||
idColumn: schema.sites.id,
|
||||
values: {
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: 0,
|
||||
},
|
||||
insertErrorMessage: 'insert site failed',
|
||||
loadErrorMessage: 'load site failed',
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
name: 'Shared helper site',
|
||||
url: 'https://shared-helper.example.com',
|
||||
platform: 'codex',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index.js';
|
||||
|
||||
type InsertRunResult = {
|
||||
changes?: number;
|
||||
lastInsertRowid?: number;
|
||||
} | null | undefined;
|
||||
|
||||
export function getInsertedRowId(result: InsertRunResult): number | null {
|
||||
const insertedId = Number(result?.lastInsertRowid || 0);
|
||||
return insertedId > 0 ? insertedId : null;
|
||||
}
|
||||
|
||||
export function requireInsertedRowId(result: InsertRunResult, errorMessage: string): number {
|
||||
const insertedId = getInsertedRowId(result);
|
||||
if (insertedId == null) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return insertedId;
|
||||
}
|
||||
|
||||
export async function insertAndGetById<T>(input: {
|
||||
txDb?: typeof db;
|
||||
table: any;
|
||||
idColumn: any;
|
||||
values: Record<string, unknown>;
|
||||
insertErrorMessage: string;
|
||||
loadErrorMessage?: string;
|
||||
}): Promise<T> {
|
||||
const txDb = input.txDb ?? db;
|
||||
const inserted = await txDb.insert(input.table).values(input.values).run();
|
||||
const insertedId = requireInsertedRowId(inserted, input.insertErrorMessage);
|
||||
const created = await txDb.select().from(input.table).where(eq(input.idColumn, insertedId)).get();
|
||||
if (!created) {
|
||||
throw new Error(input.loadErrorMessage ?? input.insertErrorMessage);
|
||||
}
|
||||
return created as T;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
describe('legacySchemaCompat architecture boundaries', () => {
|
||||
it('derives feature-owned legacy mutations from compatibility specs instead of hardcoding a second full whitelist', () => {
|
||||
const source = readSource('./legacySchemaCompat.ts');
|
||||
|
||||
expect(source).toContain('ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('PROXY_FILE_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('SITE_COLUMN_COMPATIBILITY_SPECS');
|
||||
expect(source).toContain('SITE_TABLE_COMPATIBILITY_SPECS');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
||||
|
||||
describe('legacy schema compat boundary', () => {
|
||||
it('allows only explicitly registered legacy upgrade shims', () => {
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN first_byte_latency_ms integer;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
|
||||
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
|
||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS,
|
||||
ensureAccountTokenSchemaCompatibility,
|
||||
type AccountTokenSchemaInspector,
|
||||
} from './accountTokenSchemaCompatibility.js';
|
||||
import {
|
||||
ensureProxyFileSchemaCompatibility,
|
||||
PROXY_FILE_COLUMN_COMPATIBILITY_SPECS,
|
||||
PROXY_FILE_INDEX_COMPATIBILITY_SPECS,
|
||||
PROXY_FILE_TABLE_COMPATIBILITY_SPECS,
|
||||
type ProxyFileSchemaInspector,
|
||||
} from './proxyFileSchemaCompatibility.js';
|
||||
import {
|
||||
ensureRouteGroupingSchemaCompatibility,
|
||||
ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS,
|
||||
ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS,
|
||||
type RouteGroupingSchemaInspector,
|
||||
} from './routeGroupingSchemaCompatibility.js';
|
||||
import {
|
||||
ensureSharedIndexSchemaCompatibility,
|
||||
SHARED_INDEX_COMPATIBILITY_SPECS,
|
||||
type SharedIndexSchemaInspector,
|
||||
} from './sharedIndexSchemaCompatibility.js';
|
||||
import {
|
||||
ensureSiteSchemaCompatibility,
|
||||
SITE_COLUMN_COMPATIBILITY_SPECS,
|
||||
SITE_TABLE_COMPATIBILITY_SPECS,
|
||||
type SiteSchemaInspector,
|
||||
} from './siteSchemaCompatibility.js';
|
||||
|
||||
export type LegacySchemaCompatClassification = 'legacy' | 'forbidden';
|
||||
|
||||
export interface LegacySchemaCompatInspector extends
|
||||
SiteSchemaInspector,
|
||||
RouteGroupingSchemaInspector,
|
||||
ProxyFileSchemaInspector,
|
||||
AccountTokenSchemaInspector,
|
||||
SharedIndexSchemaInspector {}
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_TABLES = [
|
||||
'account_tokens',
|
||||
'token_model_availability',
|
||||
'proxy_video_tasks',
|
||||
'downstream_api_keys',
|
||||
];
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [
|
||||
'sites.status',
|
||||
'route_channels.token_id',
|
||||
'proxy_video_tasks.status_snapshot',
|
||||
'proxy_video_tasks.upstream_response_meta',
|
||||
'proxy_video_tasks.last_upstream_status',
|
||||
'proxy_video_tasks.last_polled_at',
|
||||
'downstream_api_keys.group_name',
|
||||
'downstream_api_keys.tags',
|
||||
'proxy_logs.billing_details',
|
||||
'proxy_logs.is_stream',
|
||||
'proxy_logs.first_byte_latency_ms',
|
||||
'proxy_logs.client_family',
|
||||
'proxy_logs.client_app_id',
|
||||
'proxy_logs.client_app_name',
|
||||
'proxy_logs.client_confidence',
|
||||
'proxy_logs.downstream_api_key_id',
|
||||
];
|
||||
|
||||
const BOOTSTRAP_OWNED_LEGACY_INDEXES = [
|
||||
'token_model_availability_token_model_unique',
|
||||
'proxy_video_tasks_public_id_unique',
|
||||
'proxy_video_tasks_upstream_video_id_idx',
|
||||
'downstream_api_keys_key_unique',
|
||||
'downstream_api_keys_name_idx',
|
||||
'downstream_api_keys_enabled_idx',
|
||||
'downstream_api_keys_expires_at_idx',
|
||||
'proxy_logs_client_app_id_created_at_idx',
|
||||
'proxy_logs_client_family_created_at_idx',
|
||||
'proxy_logs_downstream_api_key_created_at_idx',
|
||||
];
|
||||
|
||||
function normalizeSqlText(sqlText: string): string {
|
||||
return sqlText.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
function extractIndexName(sqlText: string): string | null {
|
||||
const match = normalizeSqlText(sqlText).match(
|
||||
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const LEGACY_COMPAT_TABLES = new Set([
|
||||
...SITE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...PROXY_FILE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||
...BOOTSTRAP_OWNED_LEGACY_TABLES,
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_COLUMNS = new Set([
|
||||
...SITE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `sites.${spec.column}`),
|
||||
...ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...PROXY_FILE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||
...BOOTSTRAP_OWNED_LEGACY_COLUMNS,
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_INDEXES = new Set([
|
||||
...SITE_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => spec.postCreateSql ? Object.values(spec.postCreateSql) : [])
|
||||
.flat()
|
||||
.map((sqlText) => extractIndexName(sqlText))
|
||||
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => Object.values(spec.createSql))
|
||||
.flat()
|
||||
.map((sqlText) => extractIndexName(sqlText))
|
||||
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||
...PROXY_FILE_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||
...BOOTSTRAP_OWNED_LEGACY_INDEXES,
|
||||
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||
]);
|
||||
|
||||
const LEGACY_COMPAT_UPDATES = new Set(
|
||||
SITE_COLUMN_COMPATIBILITY_SPECS
|
||||
.flatMap((spec) => spec.normalizeSql ? Object.values(spec.normalizeSql) : [])
|
||||
.map((sqlText) => normalizeSqlText(sqlText)),
|
||||
);
|
||||
|
||||
export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification {
|
||||
const normalized = normalizeSqlText(sqlText);
|
||||
|
||||
if (LEGACY_COMPAT_UPDATES.has(normalized)) {
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
const createTableMatch = normalized.match(/^create table if not exists [`"]?([a-z0-9_]+)[`"]?/i);
|
||||
if (createTableMatch) {
|
||||
return LEGACY_COMPAT_TABLES.has(createTableMatch[1]) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
const alterTableMatch = normalized.match(
|
||||
/^alter table [`"]?([a-z0-9_]+)[`"]? add column [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
if (alterTableMatch) {
|
||||
const [, tableName, columnName] = alterTableMatch;
|
||||
return LEGACY_COMPAT_COLUMNS.has(`${tableName}.${columnName}`) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
const createIndexMatch = normalized.match(
|
||||
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
|
||||
);
|
||||
if (createIndexMatch) {
|
||||
return LEGACY_COMPAT_INDEXES.has(createIndexMatch[1]) ? 'legacy' : 'forbidden';
|
||||
}
|
||||
|
||||
return 'forbidden';
|
||||
}
|
||||
|
||||
function assertLegacyCompatMutation(sqlText: string): void {
|
||||
if (classifyLegacyCompatMutation(sqlText) === 'forbidden') {
|
||||
throw new Error(`Forbidden legacy schema mutation: ${sqlText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeLegacyCompat(
|
||||
execute: (sqlText: string) => Promise<void>,
|
||||
sqlText: string,
|
||||
): Promise<void> {
|
||||
assertLegacyCompatMutation(sqlText);
|
||||
await execute(sqlText);
|
||||
}
|
||||
|
||||
export function executeLegacyCompatSync(
|
||||
execute: (sqlText: string) => void,
|
||||
sqlText: string,
|
||||
): void {
|
||||
assertLegacyCompatMutation(sqlText);
|
||||
execute(sqlText);
|
||||
}
|
||||
|
||||
function wrapLegacyCompatInspector(inspector: LegacySchemaCompatInspector): LegacySchemaCompatInspector {
|
||||
return {
|
||||
...inspector,
|
||||
execute: async (sqlText: string) => {
|
||||
await executeLegacyCompat((statement) => inspector.execute(statement), sqlText);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureLegacySchemaCompatibility(inspector: LegacySchemaCompatInspector): Promise<void> {
|
||||
const wrappedInspector = wrapLegacyCompatInspector(inspector);
|
||||
await ensureSiteSchemaCompatibility(wrappedInspector);
|
||||
await ensureRouteGroupingSchemaCompatibility(wrappedInspector);
|
||||
await ensureProxyFileSchemaCompatibility(wrappedInspector);
|
||||
await ensureAccountTokenSchemaCompatibility(wrappedInspector);
|
||||
await ensureSharedIndexSchemaCompatibility(wrappedInspector);
|
||||
}
|
||||
@@ -1,633 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
|
||||
function readMigrationJournalEntries(): MigrationJournalEntry[] {
|
||||
const journalPath = join(migrationsDir, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: MigrationJournalEntry[] };
|
||||
return journal.entries ?? [];
|
||||
}
|
||||
|
||||
function applyMigrationSql(sqlite: Database.Database, sqlText: string) {
|
||||
const statements = sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
|
||||
function recordAppliedMigrations(
|
||||
sqlite: Database.Database,
|
||||
journalEntries: MigrationJournalEntry[],
|
||||
) {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
|
||||
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
const hash = createHash('sha256').update(sqlText).digest('hex');
|
||||
insert.run(hash, entry.when);
|
||||
}
|
||||
}
|
||||
|
||||
const STALE_JOURNAL_TIMESTAMP_DRIFT_MS = 4_196_930;
|
||||
|
||||
describe('sqlite migrate bootstrap', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts an already-synced sqlite schema with an empty drizzle journal', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('select created_at from __drizzle_migrations order by created_at asc')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('recovers from duplicate-column errors for single-statement migrations', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0007_account_token_group',
|
||||
when: 1772500000000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0007_account_token_group.sql'),
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772500000000);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers when duplicate-column message appears only in error cause', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-cause-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-cause-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0007_account_token_group',
|
||||
when: 1772500000001,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0007_account_token_group.sql'),
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;\n',
|
||||
);
|
||||
|
||||
const drizzleLikeError = {
|
||||
message: "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n'",
|
||||
cause: {
|
||||
message: 'SqliteError: duplicate column name: token_group',
|
||||
},
|
||||
};
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
drizzleLikeError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772500000001);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('updates only the latest matching migration record when reconciling stale timestamps', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-rowid-reconcile-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
);
|
||||
`);
|
||||
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 10);
|
||||
sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)').run('same-hash', 20);
|
||||
|
||||
const changed = __migrateTestUtils.markMigrationRecordIfMissing(sqlite, {
|
||||
hash: 'same-hash',
|
||||
createdAt: 30,
|
||||
});
|
||||
|
||||
const records = sqlite
|
||||
.prepare('SELECT rowid, hash, created_at FROM "__drizzle_migrations" ORDER BY rowid ASC')
|
||||
.all() as Array<{ rowid: number; hash: string; created_at: number }>;
|
||||
|
||||
expect(changed).toBe(true);
|
||||
expect(records).toEqual([
|
||||
{ rowid: 1, hash: 'same-hash', created_at: 10 },
|
||||
{ rowid: 2, hash: 'same-hash', created_at: 30 },
|
||||
]);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
value_status text DEFAULT 'ready' NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0012_account_token_value_status',
|
||||
when: 1773665311013,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0012_account_token_value_status.sql'),
|
||||
"ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n",
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1773665311013);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule;
|
||||
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.exec(`
|
||||
CREATE TABLE account_tokens (
|
||||
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
token_group text
|
||||
);
|
||||
`);
|
||||
|
||||
const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-multi-'));
|
||||
mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, 'meta', '_journal.json'),
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
tag: '0009_model_availability_is_manual',
|
||||
when: 1772600000000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(tempMigrationsDir, '0009_model_availability_is_manual.sql'),
|
||||
[
|
||||
'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);',
|
||||
'ALTER TABLE `account_tokens` ADD `token_group` text;',
|
||||
].join('\n--> statement-breakpoint\n'),
|
||||
);
|
||||
|
||||
const duplicateColumnError = new Error(
|
||||
"DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `token_group` text;\n' duplicate column name: token_group",
|
||||
);
|
||||
|
||||
const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite,
|
||||
tempMigrationsDir,
|
||||
duplicateColumnError,
|
||||
);
|
||||
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const createdTable = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
|
||||
.get() as { name?: string } | undefined;
|
||||
const applied = sqlite
|
||||
.prepare('SELECT hash, created_at FROM __drizzle_migrations')
|
||||
.all() as Array<{ hash: string; created_at: number }>;
|
||||
|
||||
expect(createdTable?.name).toBe('downstream_api_keys');
|
||||
expect(applied).toHaveLength(1);
|
||||
expect(Number(applied[0].created_at)).toBe(1772600000000);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it('replays missing migrations before marking a duplicate-column migration as applied', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-partial-journal-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const missingTags = new Set([
|
||||
'0006_site_disabled_models',
|
||||
'0007_account_token_group',
|
||||
'0008_sqlite_schema_backfill',
|
||||
'0009_model_availability_is_manual',
|
||||
'0010_proxy_logs_downstream_api_key',
|
||||
'0011_downstream_api_key_metadata',
|
||||
'0012_account_token_value_status',
|
||||
'0013_oauth_multi_provider',
|
||||
// 0008 creates downstream_api_keys, so later table-dependent migrations
|
||||
// must stay missing in this partial-journal fixture too.
|
||||
'0020_downstream_api_key_exclusions',
|
||||
]);
|
||||
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, appliedEntries);
|
||||
|
||||
// Simulate legacy compatibility code adding token_group before the formal 0007 migration ran.
|
||||
sqlite.exec('ALTER TABLE account_tokens ADD COLUMN token_group text;');
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
const disabledModelsTable = verified
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'site_disabled_models'")
|
||||
.get() as { name?: string } | undefined;
|
||||
const downstreamApiKeysTable = verified
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'downstream_api_keys'")
|
||||
.get() as { name?: string } | undefined;
|
||||
|
||||
expect(disabledModelsTable?.name).toBe('site_disabled_models');
|
||||
expect(downstreamApiKeysTable?.name).toBe('downstream_api_keys');
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('recovers sequential duplicate-column migrations when a legacy sqlite schema predates the drizzle journal', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-legacy-schema-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0019_proxy_logs_stream_timing');
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
|
||||
// Simulate SQLite legacy compatibility code partially adding the latest proxy log columns
|
||||
// before drizzle creates its own migration journal.
|
||||
sqlite.exec('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;');
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations ORDER BY created_at ASC')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
const proxyLogColumns = verified
|
||||
.prepare('PRAGMA table_info("proxy_logs")')
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
expect(proxyLogColumns.some((column) => column.name === 'is_stream')).toBe(true);
|
||||
expect(proxyLogColumns.some((column) => column.name === 'first_byte_latency_ms')).toBe(true);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('reconciles stale migration timestamps when the latest migration hash already exists', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-stale-timestamp-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, journalEntries);
|
||||
|
||||
const latestEntry = journalEntries.at(-1);
|
||||
const latestSqlText = latestEntry
|
||||
? readFileSync(join(migrationsDir, `${latestEntry.tag}.sql`), 'utf8')
|
||||
: '';
|
||||
const latestHash = createHash('sha256').update(latestSqlText).digest('hex');
|
||||
// Introduce about 70 minutes of timestamp drift so journal reconciliation has work to do.
|
||||
sqlite
|
||||
.prepare('UPDATE __drizzle_migrations SET created_at = ? WHERE hash = ?')
|
||||
.run((latestEntry?.when ?? 0) - STALE_JOURNAL_TIMESTAMP_DRIFT_MS, latestHash);
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const latestApplied = verified
|
||||
.prepare('SELECT created_at FROM __drizzle_migrations WHERE hash = ? LIMIT 1')
|
||||
.get(latestHash) as { created_at: number } | undefined;
|
||||
|
||||
expect(Number(latestApplied?.created_at)).toBe(latestEntry?.when);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider');
|
||||
|
||||
for (const entry of appliedEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
recordAppliedMigrations(sqlite, appliedEntries);
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight)
|
||||
VALUES
|
||||
(101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1),
|
||||
(202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3);
|
||||
|
||||
INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled)
|
||||
VALUES
|
||||
(101, 'first@example.com', 'token-a', 'active', 0),
|
||||
(202, 'second@example.com', 'token-b', 'disabled', 0);
|
||||
|
||||
INSERT INTO site_disabled_models (site_id, model_name)
|
||||
VALUES
|
||||
(101, 'gpt-5'),
|
||||
(202, 'gpt-5'),
|
||||
(202, 'gpt-5-mini');
|
||||
`);
|
||||
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const sites = verified
|
||||
.prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC')
|
||||
.all() as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
is_pinned: number;
|
||||
sort_order: number;
|
||||
global_weight: number;
|
||||
}>;
|
||||
const accounts = verified
|
||||
.prepare('SELECT username, site_id FROM accounts ORDER BY username ASC')
|
||||
.all() as Array<{ username: string; site_id: number }>;
|
||||
const disabledModels = verified
|
||||
.prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC')
|
||||
.all() as Array<{ site_id: number; model_name: string }>;
|
||||
|
||||
expect(sites).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 101,
|
||||
name: 'Primary Codex',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
}),
|
||||
]);
|
||||
expect(accounts).toEqual([
|
||||
{ username: 'first@example.com', site_id: 101 },
|
||||
{ username: 'second@example.com', site_id: 101 },
|
||||
]);
|
||||
expect(disabledModels).toEqual([
|
||||
{ site_id: 101, model_name: 'gpt-5' },
|
||||
{ site_id: 101, model_name: 'gpt-5-mini' },
|
||||
]);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
|
||||
it('fails fast when duplicate-column recovery exceeds the retry budget', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-retry-budget-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
const migrateModule = await import('./migrate.js');
|
||||
const { __migrateTestUtils } = migrateModule as {
|
||||
__migrateTestUtils: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const runSqliteMigrationRecoveryLoop = __migrateTestUtils
|
||||
.runSqliteMigrationRecoveryLoop as ((input: {
|
||||
runMigrate: () => void;
|
||||
recoverDuplicateColumnMigrationError: (error: unknown) => { tag: string; recoveredCount: number } | null;
|
||||
isSitesPlatformUrlUniqueConflictError: (error: unknown) => boolean;
|
||||
deduplicateLegacySitesForUniqueIndex: () => boolean;
|
||||
closeSqlite: () => void;
|
||||
}) => void) | undefined;
|
||||
const retryBudget = __migrateTestUtils.sqliteMigrationRecoveryRetryBudget as number | undefined;
|
||||
|
||||
const runMigrate = vi.fn(() => {
|
||||
throw new Error('duplicate column name: token_group');
|
||||
});
|
||||
const closeSqlite = vi.fn();
|
||||
|
||||
expect(runSqliteMigrationRecoveryLoop).toBeTypeOf('function');
|
||||
expect(typeof retryBudget).toBe('number');
|
||||
|
||||
expect(() => runSqliteMigrationRecoveryLoop!({
|
||||
runMigrate,
|
||||
recoverDuplicateColumnMigrationError: () => ({
|
||||
tag: '0007_account_token_group',
|
||||
recoveredCount: 1,
|
||||
}),
|
||||
isSitesPlatformUrlUniqueConflictError: () => false,
|
||||
deduplicateLegacySitesForUniqueIndex: () => false,
|
||||
closeSqlite,
|
||||
})).toThrow(/retry budget/i);
|
||||
|
||||
expect(runMigrate).toHaveBeenCalledTimes((retryBudget ?? 0) + 1);
|
||||
expect(closeSqlite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,708 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { config } from '../config.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
type MigrationJournalFile = {
|
||||
entries?: MigrationJournalEntry[];
|
||||
};
|
||||
|
||||
type SchemaMarker = {
|
||||
table: string;
|
||||
column?: string;
|
||||
};
|
||||
|
||||
type MigrationRecord = {
|
||||
createdAt: number;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type RecoveryMigrationRecord = MigrationRecord & {
|
||||
tag: string;
|
||||
};
|
||||
|
||||
type RecoveryMigration = RecoveryMigrationRecord & {
|
||||
statements: string[];
|
||||
};
|
||||
|
||||
type SqliteMigrationRecoveryLoopInput = {
|
||||
runMigrate: () => void;
|
||||
recoverDuplicateColumnMigrationError: (error: unknown) => DuplicateColumnRecoveryResult | null;
|
||||
isSitesPlatformUrlUniqueConflictError: (error: unknown) => boolean;
|
||||
deduplicateLegacySitesForUniqueIndex: () => boolean;
|
||||
closeSqlite: () => void;
|
||||
retryBudget?: number;
|
||||
};
|
||||
|
||||
type LegacySiteRow = {
|
||||
id: number;
|
||||
platform: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status';
|
||||
const SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET = 64;
|
||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||
{ table: 'sites' },
|
||||
{ table: 'settings' },
|
||||
{ table: 'accounts' },
|
||||
{ table: 'checkin_logs' },
|
||||
{ table: 'model_availability' },
|
||||
{ table: 'proxy_logs' },
|
||||
{ table: 'token_routes' },
|
||||
{ table: 'route_channels', column: 'token_id' },
|
||||
{ table: 'account_tokens' },
|
||||
{ table: 'token_model_availability' },
|
||||
{ table: 'events' },
|
||||
{ table: 'sites', column: 'is_pinned' },
|
||||
{ table: 'sites', column: 'sort_order' },
|
||||
{ table: 'accounts', column: 'is_pinned' },
|
||||
{ table: 'accounts', column: 'sort_order' },
|
||||
// 0006: site_disabled_models table
|
||||
{ table: 'site_disabled_models' },
|
||||
// 0007: token_group column on account_tokens
|
||||
{ table: 'account_tokens', column: 'token_group' },
|
||||
// 0009: is_manual column on model_availability
|
||||
{ table: 'model_availability', column: 'is_manual' },
|
||||
// 0010: downstream_api_key_id column on proxy_logs
|
||||
{ table: 'proxy_logs', column: 'downstream_api_key_id' },
|
||||
// 0011: downstream key metadata columns
|
||||
{ table: 'downstream_api_keys', column: 'group_name' },
|
||||
{ table: 'downstream_api_keys', column: 'tags' },
|
||||
// 0012: value_status column on account_tokens
|
||||
{ table: 'account_tokens', column: 'value_status' },
|
||||
// 0019: proxy log stream/timing columns
|
||||
{ table: 'proxy_logs', column: 'is_stream' },
|
||||
{ table: 'proxy_logs', column: 'first_byte_latency_ms' },
|
||||
];
|
||||
|
||||
|
||||
function resolveSqliteDbPath(): string {
|
||||
const raw = (config.dbUrl || '').trim();
|
||||
if (!raw) return resolve(`${config.dataDir}/hub.db`);
|
||||
if (raw === ':memory:') return raw;
|
||||
if (raw.startsWith('file://')) {
|
||||
const parsed = new URL(raw);
|
||||
return decodeURIComponent(parsed.pathname);
|
||||
}
|
||||
if (raw.startsWith('sqlite://')) {
|
||||
return resolve(raw.slice('sqlite://'.length).trim());
|
||||
}
|
||||
return resolve(raw);
|
||||
}
|
||||
|
||||
function resolveMigrationsFolder(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
}
|
||||
|
||||
function tableExists(sqlite: Database.Database, table: string): boolean {
|
||||
const row = sqlite.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function columnExists(sqlite: Database.Database, table: string, column: string): boolean {
|
||||
if (!tableExists(sqlite, table)) return false;
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name?: string }>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function hasRecordedDrizzleMigrations(sqlite: Database.Database): boolean {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
|
||||
const row = sqlite.prepare('SELECT 1 FROM __drizzle_migrations LIMIT 1').get();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function hasVerifiedLegacySchema(sqlite: Database.Database): boolean {
|
||||
return VERIFIED_SCHEMA_MARKERS.every((marker) => (
|
||||
marker.column
|
||||
? columnExists(sqlite, marker.table, marker.column)
|
||||
: tableExists(sqlite, marker.table)
|
||||
));
|
||||
}
|
||||
|
||||
function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord[] {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const records: MigrationRecord[] = [];
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
records.push({
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
});
|
||||
|
||||
if (entry.tag === VERIFIED_BOOTSTRAP_TAG) {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function splitMigrationStatements(sqlText: string): string[] {
|
||||
return sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
function normalizeSqlForMatch(sqlText: string): string {
|
||||
return sqlText
|
||||
.replace(/[\n\r\t]+/g, ' ')
|
||||
.replace(/["`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/;+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extractFailedSqlFromError(error: unknown): string | null {
|
||||
const message = normalizeSchemaErrorMessage(error);
|
||||
const matched = message.match(/Failed to run the query '([\s\S]*?)'/i);
|
||||
const sqlText = matched?.[1]?.trim();
|
||||
return sqlText && sqlText.length > 0 ? sqlText : null;
|
||||
}
|
||||
|
||||
function findMatchingSingleStatementMigration(
|
||||
migrationsFolder: string,
|
||||
failedSqlText: string,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const normalizedFailedSql = normalizeSqlForMatch(failedSqlText);
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
const statements = splitMigrationStatements(migrationSql);
|
||||
if (statements.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeSqlForMatch(statements[0]) !== normalizedFailedSql) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: entry.tag,
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingMigrationByStatement(
|
||||
migrationsFolder: string,
|
||||
failedSqlText: string,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const normalizedFailedSql = normalizeSqlForMatch(failedSqlText);
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!migration.statements.some((statement) => normalizeSqlForMatch(statement) === normalizedFailedSql)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: migration.tag,
|
||||
createdAt: migration.createdAt,
|
||||
hash: migration.hash,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingMigrationByErrorMessage(
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): RecoveryMigrationRecord | null {
|
||||
const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error));
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
|
||||
for (const migration of migrations) {
|
||||
if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
tag: migration.tag,
|
||||
createdAt: migration.createdAt,
|
||||
hash: migration.hash,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
|
||||
return (journal.entries ?? []).map((entry) => {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
return {
|
||||
tag: entry.tag,
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
statements: splitMigrationStatements(migrationSql),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDrizzleMigrationsTable(sqlite: Database.Database): void {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function markMigrationRecordIfMissing(sqlite: Database.Database, record: MigrationRecord): boolean {
|
||||
ensureDrizzleMigrationsTable(sqlite);
|
||||
const existing = sqlite
|
||||
.prepare('SELECT rowid, "created_at" FROM "__drizzle_migrations" WHERE "hash" = ? ORDER BY "created_at" DESC LIMIT 1')
|
||||
.get(record.hash) as { rowid?: number; created_at?: number } | undefined;
|
||||
if (existing) {
|
||||
if (Number(existing.created_at) === record.createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare('UPDATE "__drizzle_migrations" SET "created_at" = ? WHERE rowid = ?')
|
||||
.run(record.createdAt, existing.rowid);
|
||||
return true;
|
||||
}
|
||||
|
||||
sqlite
|
||||
.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)')
|
||||
.run(record.hash, record.createdAt);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
|
||||
const row = sqlite
|
||||
.prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1')
|
||||
.get(record.hash);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
const collected: string[] = [];
|
||||
let cursor: unknown = error;
|
||||
let depth = 0;
|
||||
|
||||
while (cursor && typeof cursor === 'object' && depth < 8) {
|
||||
const current = cursor as { message?: unknown; cause?: unknown };
|
||||
if (current.message !== undefined && current.message !== null) {
|
||||
const text = String(current.message).trim();
|
||||
if (text.length > 0) {
|
||||
collected.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = current.cause;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
if (collected.length > 0) {
|
||||
return collected.join(' | ');
|
||||
}
|
||||
|
||||
return String(error || '');
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column name');
|
||||
}
|
||||
|
||||
function isRecoverableSchemaConflictError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
return lowered.includes('duplicate column')
|
||||
|| lowered.includes('duplicate column name')
|
||||
|| lowered.includes('already exists');
|
||||
}
|
||||
|
||||
function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean {
|
||||
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||
if (!lowered.includes('unique constraint failed: sites.platform, sites.url')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const failedSqlText = extractFailedSqlFromError(error);
|
||||
if (!failedSqlText) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizeSqlForMatch(failedSqlText)
|
||||
=== normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);');
|
||||
}
|
||||
|
||||
function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void {
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
} catch (error) {
|
||||
if (isRecoverableSchemaConflictError(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) {
|
||||
try {
|
||||
sqlite.exec(statement);
|
||||
continue;
|
||||
} catch (retryError) {
|
||||
if (isRecoverableSchemaConflictError(retryError)) {
|
||||
continue;
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recoverMigrationSequence(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
failedMigrationTag: string,
|
||||
): number {
|
||||
const migrations = readRecoveryMigrations(migrationsFolder);
|
||||
const failedMigrationIndex = migrations.findIndex((migration) => migration.tag === failedMigrationTag);
|
||||
if (failedMigrationIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let recoveredCount = 0;
|
||||
for (const migration of migrations.slice(0, failedMigrationIndex + 1)) {
|
||||
if (hasMigrationRecord(sqlite, migration)) {
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
replayMigrationStatements(sqlite, migration.statements);
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return 0;
|
||||
|
||||
let recoveredCount = 0;
|
||||
for (const migration of readRecoveryMigrations(migrationsFolder)) {
|
||||
if (hasMigrationRecord(sqlite, migration)) {
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
replayMigrationStatements(sqlite, migration.statements);
|
||||
if (markMigrationRecordIfMissing(sqlite, migration)) {
|
||||
recoveredCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredCount > 0) {
|
||||
console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`);
|
||||
}
|
||||
|
||||
return recoveredCount;
|
||||
}
|
||||
|
||||
type DuplicateColumnRecoveryResult = {
|
||||
tag: string;
|
||||
recoveredCount: number;
|
||||
};
|
||||
|
||||
function recoverDuplicateColumnMigrationError(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): DuplicateColumnRecoveryResult | null {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedSqlText = extractFailedSqlFromError(error);
|
||||
const matchedMigration = failedSqlText
|
||||
? findMatchingMigrationByStatement(migrationsFolder, failedSqlText)
|
||||
?? findMatchingMigrationByErrorMessage(migrationsFolder, error)
|
||||
: findMatchingMigrationByErrorMessage(migrationsFolder, error);
|
||||
if (!matchedMigration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoveredCount = recoverMigrationSequence(sqlite, migrationsFolder, matchedMigration.tag);
|
||||
if (recoveredCount > 0) {
|
||||
console.warn(`[db] Recovered duplicate-column migration sequence through ${matchedMigration.tag}.`);
|
||||
}
|
||||
return {
|
||||
tag: matchedMigration.tag,
|
||||
recoveredCount,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSqliteMigrationRetryBudgetError(error: unknown, retryBudget: number): Error {
|
||||
const detail = normalizeSchemaErrorMessage(error);
|
||||
return new Error(
|
||||
detail
|
||||
? `[db] Migration recovery exceeded retry budget (${retryBudget} attempts): ${detail}`
|
||||
: `[db] Migration recovery exceeded retry budget (${retryBudget} attempts).`,
|
||||
);
|
||||
}
|
||||
|
||||
function runSqliteMigrationRecoveryLoop(input: SqliteMigrationRecoveryLoopInput): void {
|
||||
const retryBudget = Math.max(1, Math.trunc(input.retryBudget ?? SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET));
|
||||
let recoveryRetries = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
input.runMigrate();
|
||||
return;
|
||||
} catch (error) {
|
||||
const duplicateColumnRecovery = input.recoverDuplicateColumnMigrationError(error);
|
||||
if (duplicateColumnRecovery && duplicateColumnRecovery.recoveredCount > 0) {
|
||||
recoveryRetries += 1;
|
||||
if (recoveryRetries > retryBudget) {
|
||||
input.closeSqlite();
|
||||
throw buildSqliteMigrationRetryBudgetError(error, retryBudget);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (duplicateColumnRecovery) {
|
||||
input.closeSqlite();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recoveredDuplicateSites = (
|
||||
input.isSitesPlatformUrlUniqueConflictError(error)
|
||||
&& input.deduplicateLegacySitesForUniqueIndex()
|
||||
);
|
||||
if (recoveredDuplicateSites) {
|
||||
recoveryRetries += 1;
|
||||
if (recoveryRetries > retryBudget) {
|
||||
input.closeSqlite();
|
||||
throw buildSqliteMigrationRetryBudgetError(error, retryBudget);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
input.closeSqlite();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryRecoverDuplicateColumnMigrationError(
|
||||
sqlite: Database.Database,
|
||||
migrationsFolder: string,
|
||||
error: unknown,
|
||||
): boolean {
|
||||
const recovery = recoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error);
|
||||
return (recovery?.recoveredCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
function rewriteDownstreamSiteWeightMultipliers(
|
||||
sqlite: Database.Database,
|
||||
siteIdMapping: Map<number, number>,
|
||||
): void {
|
||||
if (siteIdMapping.size <= 0) return;
|
||||
if (!tableExists(sqlite, 'downstream_api_keys')) return;
|
||||
if (!columnExists(sqlite, 'downstream_api_keys', 'site_weight_multipliers')) return;
|
||||
|
||||
const rows = sqlite.prepare(`
|
||||
SELECT id, site_weight_multipliers
|
||||
FROM downstream_api_keys
|
||||
WHERE site_weight_multipliers IS NOT NULL
|
||||
AND TRIM(site_weight_multipliers) <> ''
|
||||
`).all() as Array<{ id: number; site_weight_multipliers: string | null }>;
|
||||
|
||||
const update = sqlite.prepare('UPDATE downstream_api_keys SET site_weight_multipliers = ? WHERE id = ?');
|
||||
for (const row of rows) {
|
||||
if (!row.site_weight_multipliers) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.site_weight_multipliers);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
||||
const nextValue = { ...(parsed as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
|
||||
for (const [fromSiteId, toSiteId] of siteIdMapping.entries()) {
|
||||
const fromKey = String(fromSiteId);
|
||||
const toKey = String(toSiteId);
|
||||
if (!(fromKey in nextValue)) continue;
|
||||
if (!(toKey in nextValue)) {
|
||||
nextValue[toKey] = nextValue[fromKey];
|
||||
}
|
||||
delete nextValue[fromKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) continue;
|
||||
update.run(JSON.stringify(nextValue), row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function deduplicateLegacySitesForUniqueIndex(sqlite: Database.Database): boolean {
|
||||
const duplicateGroups = sqlite.prepare(`
|
||||
SELECT platform, url
|
||||
FROM sites
|
||||
GROUP BY platform, url
|
||||
HAVING COUNT(*) > 1
|
||||
`).all() as Array<{ platform: string; url: string }>;
|
||||
|
||||
if (duplicateGroups.length <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectSitesByIdentity = sqlite.prepare(`
|
||||
SELECT id, platform, url
|
||||
FROM sites
|
||||
WHERE platform = ? AND url = ?
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
const rebindAccounts = sqlite.prepare('UPDATE accounts SET site_id = ? WHERE site_id = ?');
|
||||
const mergeDisabledModels = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO site_disabled_models (site_id, model_name, created_at)
|
||||
SELECT ?, model_name, created_at
|
||||
FROM site_disabled_models
|
||||
WHERE site_id = ?
|
||||
`);
|
||||
const deleteDisabledModels = sqlite.prepare('DELETE FROM site_disabled_models WHERE site_id = ?');
|
||||
const deleteSite = sqlite.prepare('DELETE FROM sites WHERE id = ?');
|
||||
|
||||
const siteIdMapping = new Map<number, number>();
|
||||
|
||||
const transaction = sqlite.transaction(() => {
|
||||
for (const group of duplicateGroups) {
|
||||
const sites = selectSitesByIdentity.all(group.platform, group.url) as LegacySiteRow[];
|
||||
if (sites.length <= 1) continue;
|
||||
|
||||
const canonicalSiteId = sites[0]!.id;
|
||||
for (const site of sites.slice(1)) {
|
||||
mergeDisabledModels.run(canonicalSiteId, site.id);
|
||||
deleteDisabledModels.run(site.id);
|
||||
rebindAccounts.run(canonicalSiteId, site.id);
|
||||
siteIdMapping.set(site.id, canonicalSiteId);
|
||||
deleteSite.run(site.id);
|
||||
}
|
||||
}
|
||||
|
||||
rewriteDownstreamSiteWeightMultipliers(sqlite, siteIdMapping);
|
||||
});
|
||||
|
||||
transaction();
|
||||
if (siteIdMapping.size > 0) {
|
||||
console.warn(`[db] Deduplicated ${siteIdMapping.size} legacy site entries before applying sites_platform_url_unique.`);
|
||||
}
|
||||
return siteIdMapping.size > 0;
|
||||
}
|
||||
|
||||
export const __migrateTestUtils = {
|
||||
splitMigrationStatements,
|
||||
normalizeSqlForMatch,
|
||||
extractFailedSqlFromError,
|
||||
findMatchingSingleStatementMigration,
|
||||
findMatchingMigrationByStatement,
|
||||
findMatchingMigrationByErrorMessage,
|
||||
readRecoveryMigrations,
|
||||
markMigrationRecordIfMissing,
|
||||
recoverMigrationSequence,
|
||||
tryRecoverDuplicateColumnMigrationError,
|
||||
isSitesPlatformUrlUniqueConflictError,
|
||||
deduplicateLegacySitesForUniqueIndex,
|
||||
runSqliteMigrationRecoveryLoop,
|
||||
sqliteMigrationRecoveryRetryBudget: SQLITE_MIGRATION_RECOVERY_RETRY_BUDGET,
|
||||
};
|
||||
|
||||
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
|
||||
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
||||
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
||||
|
||||
const records = readVerifiedMigrationRecords(migrationsFolder);
|
||||
if (records.length === 0) return false;
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
|
||||
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
|
||||
const applyBootstrap = sqlite.transaction((migrations: MigrationRecord[]) => {
|
||||
for (const migrationRecord of migrations) {
|
||||
insert.run(migrationRecord.hash, migrationRecord.createdAt);
|
||||
}
|
||||
});
|
||||
|
||||
applyBootstrap(records);
|
||||
console.log('[db] Bootstrapped drizzle migration journal for existing SQLite schema.');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function runSqliteMigrations(): void {
|
||||
const dbPath = resolveSqliteDbPath();
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
|
||||
backfillMissingRecordedMigrations(sqlite, migrationsFolder);
|
||||
|
||||
runSqliteMigrationRecoveryLoop({
|
||||
runMigrate: () => {
|
||||
migrate(drizzle(sqlite), { migrationsFolder });
|
||||
},
|
||||
recoverDuplicateColumnMigrationError: (error) => (
|
||||
recoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error)
|
||||
),
|
||||
isSitesPlatformUrlUniqueConflictError,
|
||||
deduplicateLegacySitesForUniqueIndex: () => deduplicateLegacySitesForUniqueIndex(sqlite),
|
||||
closeSqlite: () => sqlite.close(),
|
||||
});
|
||||
|
||||
sqlite.close();
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
runSqliteMigrations();
|
||||
@@ -1,23 +0,0 @@
|
||||
import pg from 'pg';
|
||||
|
||||
export type PgTypesLike = {
|
||||
builtins: {
|
||||
JSON: number;
|
||||
JSONB: number;
|
||||
};
|
||||
setTypeParser(oid: number, format: 'text', parser: (value: string) => string): void;
|
||||
};
|
||||
|
||||
let postgresJsonTextParsersInstalled = false;
|
||||
|
||||
export function installPostgresJsonTextParsers(typesRegistry: PgTypesLike = pg.types as PgTypesLike): void {
|
||||
if (postgresJsonTextParsersInstalled) return;
|
||||
const identity = (value: string) => value;
|
||||
typesRegistry.setTypeParser(typesRegistry.builtins.JSON, 'text', identity);
|
||||
typesRegistry.setTypeParser(typesRegistry.builtins.JSONB, 'text', identity);
|
||||
postgresJsonTextParsersInstalled = true;
|
||||
}
|
||||
|
||||
export function resetPostgresJsonTextParsersInstallStateForTests(): void {
|
||||
postgresJsonTextParsersInstalled = false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user