fix: persist site api endpoints and account verification

This commit is contained in:
boos
2026-05-19 11:22:09 +08:00
parent b6aae5f301
commit 812a7c6c07
4 changed files with 564 additions and 34 deletions
+9 -1
View File
@@ -136,7 +136,15 @@ impl TokenRouter {
rc.route_id AS route_id, rc.route_id AS route_id,
rc.account_id AS account_id, rc.account_id AS account_id,
a.site_id AS site_id, a.site_id AS site_id,
s.url AS base_url, COALESCE((
SELECT sae.url
FROM site_api_endpoints sae
WHERE sae.site_id = s.id
AND COALESCE(sae.enabled, 1) = 1
AND (sae.cooldown_until IS NULL OR sae.cooldown_until <= datetime('now'))
ORDER BY COALESCE(sae.sort_order, 0), sae.id
LIMIT 1
), s.url) AS base_url,
s.api_key AS site_api_key, s.api_key AS site_api_key,
a.access_token AS account_access_token, a.access_token AS account_access_token,
a.api_token AS account_api_token, a.api_token AS account_api_token,
+321 -23
View File
@@ -10,6 +10,7 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::time::Duration;
use tracing::info; use tracing::info;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -19,31 +20,70 @@ pub struct ListAccountsQuery {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateAccountRequest { pub struct CreateAccountRequest {
#[serde(alias = "siteId")]
pub site_id: i64, pub site_id: i64,
pub username: Option<String>, pub username: Option<String>,
pub access_token: String, #[serde(alias = "accessToken")]
pub access_token: Option<String>,
#[serde(default, alias = "accessTokens")]
pub access_tokens: Vec<String>,
#[serde(alias = "apiToken")]
pub api_token: Option<String>, pub api_token: Option<String>,
pub status: Option<String>, pub status: Option<String>,
#[serde(alias = "checkinEnabled")]
pub checkin_enabled: Option<bool>, pub checkin_enabled: Option<bool>,
#[serde(alias = "extraConfig")]
pub extra_config: Option<String>, pub extra_config: Option<String>,
#[serde(alias = "oauthProvider")]
pub oauth_provider: Option<String>, pub oauth_provider: Option<String>,
#[serde(alias = "oauthAccountKey")]
pub oauth_account_key: Option<String>, pub oauth_account_key: Option<String>,
#[serde(alias = "oauthProjectId")]
pub oauth_project_id: Option<String>, pub oauth_project_id: Option<String>,
#[serde(alias = "platformUserId")]
pub platform_user_id: Option<i64>,
#[serde(alias = "credentialMode")]
pub credential_mode: Option<String>,
#[serde(alias = "skipModelFetch")]
pub skip_model_fetch: Option<bool>,
#[serde(alias = "refreshToken")]
pub refresh_token: Option<String>,
#[serde(alias = "tokenExpiresAt")]
pub token_expires_at: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateAccountRequest { pub struct UpdateAccountRequest {
pub username: Option<String>, pub username: Option<String>,
#[serde(alias = "accessToken")]
pub access_token: Option<String>, pub access_token: Option<String>,
#[serde(alias = "apiToken")]
pub api_token: Option<String>, pub api_token: Option<String>,
pub status: Option<String>, pub status: Option<String>,
#[serde(alias = "checkinEnabled")]
pub checkin_enabled: Option<bool>, pub checkin_enabled: Option<bool>,
#[serde(alias = "extraConfig")]
pub extra_config: Option<String>, pub extra_config: Option<String>,
#[serde(alias = "oauthProvider")]
pub oauth_provider: Option<String>, pub oauth_provider: Option<String>,
#[serde(alias = "oauthAccountKey")]
pub oauth_account_key: Option<String>, pub oauth_account_key: Option<String>,
#[serde(alias = "oauthProjectId")]
pub oauth_project_id: Option<String>, pub oauth_project_id: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct VerifyTokenRequest {
#[serde(alias = "siteId")]
pub site_id: i64,
#[serde(alias = "accessToken")]
pub access_token: String,
#[serde(alias = "platformUserId")]
pub platform_user_id: Option<i64>,
#[serde(alias = "credentialMode")]
pub credential_mode: Option<String>,
}
/// GET /api/accounts?site_id= /// GET /api/accounts?site_id=
pub async fn list_accounts( pub async fn list_accounts(
State(state): State<AppState>, State(state): State<AppState>,
@@ -84,7 +124,23 @@ pub async fn create_account(
) -> AppResult<(StatusCode, Json<Value>)> { ) -> AppResult<(StatusCode, Json<Value>)> {
info!("Creating account for site_id: {}", payload.site_id); info!("Creating account for site_id: {}", payload.site_id);
if payload.access_token.trim().is_empty() { let mut tokens = payload
.access_tokens
.iter()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
if tokens.is_empty() {
if let Some(token) = payload.access_token.as_deref() {
let token = token.trim();
if !token.is_empty() {
tokens.push(token.to_string());
}
}
}
if tokens.is_empty() {
return Err(AppError::BadRequest("access_token is required".into())); return Err(AppError::BadRequest("access_token is required".into()));
} }
@@ -101,30 +157,189 @@ pub async fn create_account(
} }
let status = payload.status.unwrap_or_else(|| "active".to_string()); let status = payload.status.unwrap_or_else(|| "active".to_string());
let extra_config = build_account_extra_config(
payload.extra_config.as_deref(),
payload.credential_mode.as_deref(),
payload.platform_user_id,
payload.skip_model_fetch,
payload.refresh_token.as_deref(),
payload.token_expires_at,
)?;
let result = sqlx::query( let mut created = Vec::new();
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config, oauth_provider, oauth_account_key, oauth_project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" let mut failed = Vec::new();
)
.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(); for token in tokens {
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?") let result = sqlx::query(
.bind(account_id) "INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config, oauth_provider, oauth_account_key, oauth_project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
.fetch_one(&*state.db) )
.await?; .bind(payload.site_id)
.bind(&payload.username)
.bind(&token)
.bind(&payload.api_token)
.bind(&status)
.bind(payload.checkin_enabled)
.bind(&extra_config)
.bind(&payload.oauth_provider)
.bind(&payload.oauth_account_key)
.bind(&payload.oauth_project_id)
.execute(&*state.db)
.await;
Ok((StatusCode::CREATED, Json(json!({ "data": account })))) match result {
Ok(result) => {
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?;
created.push(account);
}
Err(err) => failed.push(json!({
"status": "failed",
"message": err.to_string(),
})),
}
}
if created.len() + failed.len() > 1 {
return Ok((
StatusCode::CREATED,
Json(json!({
"success": failed.is_empty(),
"batch": true,
"createdCount": created.len(),
"failedCount": failed.len(),
"items": failed,
"data": created,
"accounts": created,
})),
));
}
let account = created
.into_iter()
.next()
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("account was not created")))?;
Ok((
StatusCode::CREATED,
Json(json!({
"success": true,
"id": account.id,
"data": account,
})),
))
}
/// POST /api/accounts/verify-token
pub async fn verify_token(
State(state): State<AppState>,
Json(payload): Json<VerifyTokenRequest>,
) -> AppResult<Json<Value>> {
info!("Verifying account token for site_id: {}", payload.site_id);
let access_token = payload.access_token.trim();
if access_token.is_empty() {
return Ok(Json(json!({
"success": false,
"message": "Token 不能为空",
})));
}
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(payload.site_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", payload.site_id)))?;
let base_url = resolve_site_api_base_url(&state, &site).await?;
let credential_mode = payload
.credential_mode
.as_deref()
.unwrap_or("auto")
.trim()
.to_ascii_lowercase();
let token_type = if credential_mode == "session" {
"session"
} else {
"apikey"
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|err| AppError::Internal(err.into()))?;
let models_url = format!("{}/models", base_url.trim_end_matches('/'));
let response = client
.get(&models_url)
.bearer_auth(access_token)
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await;
match response {
Ok(response) if response.status().is_success() => {
let status = response.status().as_u16();
let body: Value = response.json().await.unwrap_or_else(|_| json!({}));
let models = body
.get("data")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
item.get("id")
.or_else(|| item.get("name"))
.and_then(Value::as_str)
.map(str::to_string)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Json(json!({
"success": true,
"tokenType": token_type,
"message": "Token 验证成功",
"siteId": site.id,
"siteName": site.name,
"baseUrl": base_url,
"upstreamStatus": status,
"modelCount": models.len(),
"models": models,
"userInfo": {
"id": payload.platform_user_id,
"username": payload.platform_user_id.map(|id| id.to_string()),
},
})))
}
Ok(response) => {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
Ok(Json(json!({
"success": false,
"tokenType": token_type,
"message": if text.trim().is_empty() {
format!("上游验证失败:HTTP {}", status)
} else {
format!("上游验证失败:HTTP {} {}", status, text.chars().take(160).collect::<String>())
},
"siteId": site.id,
"baseUrl": base_url,
"upstreamStatus": status,
})))
}
Err(err) => Ok(Json(json!({
"success": false,
"tokenType": token_type,
"message": format!("无法连接上游验证地址:{}", err),
"siteId": site.id,
"baseUrl": base_url,
}))),
}
} }
/// GET /api/accounts/{id} /// GET /api/accounts/{id}
@@ -203,3 +418,86 @@ pub async fn delete_account(
Ok(Json(json!({ "data": { "deleted": true, "id": id } }))) Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
} }
async fn resolve_site_api_base_url(state: &AppState, site: &Site) -> AppResult<String> {
let endpoint: Option<String> = sqlx::query_scalar(
"SELECT url
FROM site_api_endpoints
WHERE site_id = ?
AND COALESCE(enabled, 1) = 1
AND (cooldown_until IS NULL OR cooldown_until <= datetime('now'))
ORDER BY COALESCE(sort_order, 0), id
LIMIT 1",
)
.bind(site.id)
.fetch_optional(&*state.db)
.await?;
let raw_url = endpoint.as_deref().unwrap_or(&site.url).trim();
if raw_url.is_empty() {
return Err(AppError::BadRequest("site url is required".into()));
}
let normalized = raw_url.trim_end_matches('/').to_string();
reqwest::Url::parse(&normalized)
.map_err(|err| AppError::BadRequest(format!("Invalid site API URL: {}", err)))?;
Ok(normalized)
}
fn build_account_extra_config(
raw_extra_config: Option<&str>,
credential_mode: Option<&str>,
platform_user_id: Option<i64>,
skip_model_fetch: Option<bool>,
refresh_token: Option<&str>,
token_expires_at: Option<i64>,
) -> AppResult<Option<String>> {
let mut value = match raw_extra_config.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}) {
Some(raw) => serde_json::from_str::<Value>(raw)
.map_err(|err| AppError::BadRequest(format!("Invalid extra_config JSON: {}", err)))?,
None => json!({}),
};
let Some(obj) = value.as_object_mut() else {
return Err(AppError::BadRequest(
"extra_config must be a JSON object".into(),
));
};
if let Some(mode) = credential_mode
.map(str::trim)
.filter(|mode| !mode.is_empty())
{
obj.insert("credentialMode".to_string(), json!(mode));
}
if let Some(user_id) = platform_user_id {
obj.insert("platformUserId".to_string(), json!(user_id));
}
if let Some(skip) = skip_model_fetch {
obj.insert("skipModelFetch".to_string(), json!(skip));
}
if let Some(refresh_token) = refresh_token
.map(str::trim)
.filter(|token| !token.is_empty())
{
obj.insert("refreshToken".to_string(), json!(refresh_token));
}
if let Some(expires_at) = token_expires_at {
obj.insert("tokenExpiresAt".to_string(), json!(expires_at));
}
if obj.is_empty() {
Ok(None)
} else {
serde_json::to_string(&value)
.map(Some)
.map_err(|err| AppError::Internal(err.into()))
}
}
+2 -1
View File
@@ -8,7 +8,7 @@ use axum::{
http::HeaderMap, http::HeaderMap,
middleware, middleware,
response::Response, response::Response,
routing::{get, put}, routing::{get, post, put},
Json, Router, Json, Router,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -73,6 +73,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/accounts", "/api/accounts",
get(accounts::list_accounts).post(accounts::create_account), get(accounts::list_accounts).post(accounts::create_account),
) )
.route("/api/accounts/verify-token", post(accounts::verify_token))
.route( .route(
"/api/accounts/{id}", "/api/accounts/{id}",
get(accounts::get_account) get(accounts::get_account)
+232 -9
View File
@@ -8,7 +8,7 @@ use axum::{
http::StatusCode, http::StatusCode,
Json, Json,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tracing::info; use tracing::info;
@@ -20,9 +20,25 @@ pub struct CreateSiteRequest {
pub status: Option<String>, pub status: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
#[serde(alias = "externalCheckinUrl")]
pub external_checkin_url: Option<String>,
pub use_system_proxy: Option<bool>, pub use_system_proxy: Option<bool>,
pub custom_headers: Option<String>, pub custom_headers: Option<String>,
pub global_weight: Option<f64>, pub global_weight: Option<f64>,
#[serde(alias = "postRefreshProbeEnabled")]
pub post_refresh_probe_enabled: Option<bool>,
#[serde(alias = "postRefreshProbeModel")]
pub post_refresh_probe_model: Option<String>,
#[serde(alias = "postRefreshProbeScope")]
pub post_refresh_probe_scope: Option<String>,
#[serde(alias = "postRefreshProbeLatencyThresholdMs")]
pub post_refresh_probe_latency_threshold_ms: Option<i64>,
#[serde(alias = "isPinned")]
pub is_pinned: Option<bool>,
#[serde(alias = "sortOrder")]
pub sort_order: Option<i64>,
#[serde(alias = "apiEndpoints")]
pub api_endpoints: Option<Vec<SiteApiEndpointPayload>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -33,9 +49,45 @@ pub struct UpdateSiteRequest {
pub status: Option<String>, pub status: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
#[serde(alias = "externalCheckinUrl")]
pub external_checkin_url: Option<String>,
pub use_system_proxy: Option<bool>, pub use_system_proxy: Option<bool>,
pub custom_headers: Option<String>, pub custom_headers: Option<String>,
pub global_weight: Option<f64>, pub global_weight: Option<f64>,
#[serde(alias = "postRefreshProbeEnabled")]
pub post_refresh_probe_enabled: Option<bool>,
#[serde(alias = "postRefreshProbeModel")]
pub post_refresh_probe_model: Option<String>,
#[serde(alias = "postRefreshProbeScope")]
pub post_refresh_probe_scope: Option<String>,
#[serde(alias = "postRefreshProbeLatencyThresholdMs")]
pub post_refresh_probe_latency_threshold_ms: Option<i64>,
#[serde(alias = "isPinned")]
pub is_pinned: Option<bool>,
#[serde(alias = "sortOrder")]
pub sort_order: Option<i64>,
#[serde(alias = "apiEndpoints")]
pub api_endpoints: Option<Vec<SiteApiEndpointPayload>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SiteApiEndpointPayload {
pub url: String,
pub enabled: Option<bool>,
#[serde(alias = "sortOrder")]
pub sort_order: Option<i64>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
struct SiteApiEndpointRow {
id: i64,
url: String,
enabled: Option<bool>,
sort_order: Option<i64>,
cooldown_until: Option<String>,
last_selected_at: Option<String>,
last_failed_at: Option<String>,
last_failure_reason: Option<String>,
} }
/// GET /api/sites /// GET /api/sites
@@ -46,8 +98,12 @@ pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>>
sqlx::query_as::<_, Site>("SELECT * FROM sites ORDER BY sort_order, name") sqlx::query_as::<_, Site>("SELECT * FROM sites ORDER BY sort_order, name")
.fetch_all(&*state.db) .fetch_all(&*state.db)
.await?; .await?;
let mut data = Vec::with_capacity(sites.len());
for site in sites {
data.push(site_response(&state, site).await?);
}
Ok(Json(json!({ "data": sites }))) Ok(Json(json!({ "data": data })))
} }
/// POST /api/sites /// POST /api/sites
@@ -69,8 +125,16 @@ pub async fn create_site(
let status = payload.status.unwrap_or_else(|| "active".to_string()); let status = payload.status.unwrap_or_else(|| "active".to_string());
let mut tx = state.db.begin().await?;
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO sites (name, url, platform, status, api_key, proxy_url, use_system_proxy, custom_headers, global_weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" "INSERT INTO sites (
name, url, platform, status, api_key, proxy_url, external_checkin_url,
use_system_proxy, custom_headers, global_weight,
post_refresh_probe_enabled, post_refresh_probe_model,
post_refresh_probe_scope, post_refresh_probe_latency_threshold_ms,
is_pinned, sort_order
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&payload.name) .bind(&payload.name)
.bind(&payload.url) .bind(&payload.url)
@@ -78,19 +142,32 @@ pub async fn create_site(
.bind(&status) .bind(&status)
.bind(&payload.api_key) .bind(&payload.api_key)
.bind(&payload.proxy_url) .bind(&payload.proxy_url)
.bind(&payload.external_checkin_url)
.bind(payload.use_system_proxy) .bind(payload.use_system_proxy)
.bind(&payload.custom_headers) .bind(&payload.custom_headers)
.bind(payload.global_weight) .bind(payload.global_weight)
.execute(&*state.db) .bind(payload.post_refresh_probe_enabled)
.bind(&payload.post_refresh_probe_model)
.bind(&payload.post_refresh_probe_scope)
.bind(payload.post_refresh_probe_latency_threshold_ms)
.bind(payload.is_pinned)
.bind(payload.sort_order)
.execute(&mut *tx)
.await?; .await?;
let site_id = result.last_insert_rowid(); let site_id = result.last_insert_rowid();
replace_site_api_endpoints(&mut tx, site_id, payload.api_endpoints.as_deref()).await?;
tx.commit().await?;
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?") let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(site_id) .bind(site_id)
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
Ok((StatusCode::CREATED, Json(json!({ "data": site })))) Ok((
StatusCode::CREATED,
Json(json!({ "data": site_response(&state, site).await? })),
))
} }
/// GET /api/sites/{id} /// GET /api/sites/{id}
@@ -106,7 +183,7 @@ pub async fn get_site(
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?; .ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
Ok(Json(json!({ "data": site }))) Ok(Json(json!({ "data": site_response(&state, site).await? })))
} }
/// PUT /api/sites/{id} /// PUT /api/sites/{id}
@@ -123,8 +200,17 @@ pub async fn update_site(
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?; .ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
let mut tx = state.db.begin().await?;
sqlx::query( 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 = ?" "UPDATE sites SET
name = ?, url = ?, platform = ?, status = ?, api_key = ?,
proxy_url = ?, external_checkin_url = ?, use_system_proxy = ?,
custom_headers = ?, global_weight = ?,
post_refresh_probe_enabled = ?, post_refresh_probe_model = ?,
post_refresh_probe_scope = ?, post_refresh_probe_latency_threshold_ms = ?,
is_pinned = ?, sort_order = ?, updated_at = datetime('now')
WHERE id = ?",
) )
.bind(payload.name.unwrap_or(existing.name)) .bind(payload.name.unwrap_or(existing.name))
.bind(payload.url.unwrap_or(existing.url)) .bind(payload.url.unwrap_or(existing.url))
@@ -132,19 +218,51 @@ pub async fn update_site(
.bind(payload.status.unwrap_or(existing.status)) .bind(payload.status.unwrap_or(existing.status))
.bind(payload.api_key.or(existing.api_key)) .bind(payload.api_key.or(existing.api_key))
.bind(payload.proxy_url.or(existing.proxy_url)) .bind(payload.proxy_url.or(existing.proxy_url))
.bind(
payload
.external_checkin_url
.or(existing.external_checkin_url),
)
.bind(payload.use_system_proxy.or(existing.use_system_proxy)) .bind(payload.use_system_proxy.or(existing.use_system_proxy))
.bind(payload.custom_headers.or(existing.custom_headers)) .bind(payload.custom_headers.or(existing.custom_headers))
.bind(payload.global_weight.or(existing.global_weight)) .bind(payload.global_weight.or(existing.global_weight))
.bind(
payload
.post_refresh_probe_enabled
.or(existing.post_refresh_probe_enabled),
)
.bind(
payload
.post_refresh_probe_model
.or(existing.post_refresh_probe_model),
)
.bind(
payload
.post_refresh_probe_scope
.or(existing.post_refresh_probe_scope),
)
.bind(
payload
.post_refresh_probe_latency_threshold_ms
.or(existing.post_refresh_probe_latency_threshold_ms),
)
.bind(payload.is_pinned.or(existing.is_pinned))
.bind(payload.sort_order.or(existing.sort_order))
.bind(id) .bind(id)
.execute(&*state.db) .execute(&mut *tx)
.await?; .await?;
if let Some(api_endpoints) = payload.api_endpoints.as_deref() {
replace_site_api_endpoints(&mut tx, id, Some(api_endpoints)).await?;
}
tx.commit().await?;
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?") let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(id) .bind(id)
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .await?;
Ok(Json(json!({ "data": site }))) Ok(Json(json!({ "data": site_response(&state, site).await? })))
} }
/// DELETE /api/sites/{id} /// DELETE /api/sites/{id}
@@ -166,3 +284,108 @@ pub async fn delete_site(
Ok(Json(json!({ "data": { "deleted": true, "id": id } }))) Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
} }
async fn site_response(state: &AppState, site: Site) -> AppResult<Value> {
let endpoints = site_api_endpoints(&state, site.id).await?;
let mut value = serde_json::to_value(&site)
.map_err(|err| AppError::Internal(anyhow::anyhow!("serialize site: {err}")))?;
if let Some(obj) = value.as_object_mut() {
obj.insert(
"externalCheckinUrl".to_string(),
json!(site.external_checkin_url),
);
obj.insert("proxyUrl".to_string(), json!(site.proxy_url));
obj.insert("useSystemProxy".to_string(), json!(site.use_system_proxy));
obj.insert("customHeaders".to_string(), json!(site.custom_headers));
obj.insert("globalWeight".to_string(), json!(site.global_weight));
obj.insert(
"postRefreshProbeEnabled".to_string(),
json!(site.post_refresh_probe_enabled),
);
obj.insert(
"postRefreshProbeModel".to_string(),
json!(site.post_refresh_probe_model),
);
obj.insert(
"postRefreshProbeScope".to_string(),
json!(site.post_refresh_probe_scope),
);
obj.insert(
"postRefreshProbeLatencyThresholdMs".to_string(),
json!(site.post_refresh_probe_latency_threshold_ms),
);
obj.insert("isPinned".to_string(), json!(site.is_pinned));
obj.insert("sortOrder".to_string(), json!(site.sort_order));
obj.insert("apiEndpoints".to_string(), json!(endpoints));
}
Ok(value)
}
async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Value>> {
let rows: Vec<SiteApiEndpointRow> = sqlx::query_as(
"SELECT id, url, enabled, sort_order, cooldown_until, last_selected_at, last_failed_at, last_failure_reason
FROM site_api_endpoints
WHERE site_id = ?
ORDER BY sort_order, id",
)
.bind(site_id)
.fetch_all(&*state.db)
.await?;
Ok(rows
.into_iter()
.map(|row| {
json!({
"id": row.id,
"url": row.url,
"enabled": row.enabled.unwrap_or(true),
"sort_order": row.sort_order.unwrap_or(0),
"sortOrder": row.sort_order.unwrap_or(0),
"cooldown_until": row.cooldown_until,
"cooldownUntil": row.cooldown_until,
"last_selected_at": row.last_selected_at,
"lastSelectedAt": row.last_selected_at,
"last_failed_at": row.last_failed_at,
"lastFailedAt": row.last_failed_at,
"last_failure_reason": row.last_failure_reason,
"lastFailureReason": row.last_failure_reason,
})
})
.collect())
}
async fn replace_site_api_endpoints(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
site_id: i64,
endpoints: Option<&[SiteApiEndpointPayload]>,
) -> AppResult<()> {
sqlx::query("DELETE FROM site_api_endpoints WHERE site_id = ?")
.bind(site_id)
.execute(&mut **tx)
.await?;
let Some(endpoints) = endpoints else {
return Ok(());
};
for (index, endpoint) in endpoints.iter().enumerate() {
let url = endpoint.url.trim();
if url.is_empty() {
continue;
}
sqlx::query(
"INSERT INTO site_api_endpoints (site_id, url, enabled, sort_order)
VALUES (?, ?, ?, ?)",
)
.bind(site_id)
.bind(url)
.bind(endpoint.enabled.unwrap_or(true))
.bind(endpoint.sort_order.unwrap_or(index as i64))
.execute(&mut **tx)
.await?;
}
Ok(())
}