fix: persist site api endpoints and account verification
This commit is contained in:
@@ -136,7 +136,15 @@ impl TokenRouter {
|
||||
rc.route_id AS route_id,
|
||||
rc.account_id AS account_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,
|
||||
a.access_token AS account_access_token,
|
||||
a.api_token AS account_api_token,
|
||||
|
||||
+321
-23
@@ -10,6 +10,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -19,31 +20,70 @@ pub struct ListAccountsQuery {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountRequest {
|
||||
#[serde(alias = "siteId")]
|
||||
pub site_id: i64,
|
||||
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 status: Option<String>,
|
||||
#[serde(alias = "checkinEnabled")]
|
||||
pub checkin_enabled: Option<bool>,
|
||||
#[serde(alias = "extraConfig")]
|
||||
pub extra_config: Option<String>,
|
||||
#[serde(alias = "oauthProvider")]
|
||||
pub oauth_provider: Option<String>,
|
||||
#[serde(alias = "oauthAccountKey")]
|
||||
pub oauth_account_key: Option<String>,
|
||||
#[serde(alias = "oauthProjectId")]
|
||||
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)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub username: Option<String>,
|
||||
#[serde(alias = "accessToken")]
|
||||
pub access_token: Option<String>,
|
||||
#[serde(alias = "apiToken")]
|
||||
pub api_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
#[serde(alias = "checkinEnabled")]
|
||||
pub checkin_enabled: Option<bool>,
|
||||
#[serde(alias = "extraConfig")]
|
||||
pub extra_config: Option<String>,
|
||||
#[serde(alias = "oauthProvider")]
|
||||
pub oauth_provider: Option<String>,
|
||||
#[serde(alias = "oauthAccountKey")]
|
||||
pub oauth_account_key: Option<String>,
|
||||
#[serde(alias = "oauthProjectId")]
|
||||
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=
|
||||
pub async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
@@ -84,7 +124,23 @@ pub async fn create_account(
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -101,30 +157,189 @@ pub async fn create_account(
|
||||
}
|
||||
|
||||
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(
|
||||
"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 mut created = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
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?;
|
||||
for token in tokens {
|
||||
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(&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}
|
||||
@@ -203,3 +418,86 @@ pub async fn delete_account(
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, put},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
@@ -73,6 +73,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
"/api/accounts",
|
||||
get(accounts::list_accounts).post(accounts::create_account),
|
||||
)
|
||||
.route("/api/accounts/verify-token", post(accounts::verify_token))
|
||||
.route(
|
||||
"/api/accounts/{id}",
|
||||
get(accounts::get_account)
|
||||
|
||||
+232
-9
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
@@ -20,9 +20,25 @@ pub struct CreateSiteRequest {
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
#[serde(alias = "externalCheckinUrl")]
|
||||
pub external_checkin_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
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)]
|
||||
@@ -33,9 +49,45 @@ pub struct UpdateSiteRequest {
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
#[serde(alias = "externalCheckinUrl")]
|
||||
pub external_checkin_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
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
|
||||
@@ -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")
|
||||
.fetch_all(&*state.db)
|
||||
.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
|
||||
@@ -69,8 +125,16 @@ pub async fn create_site(
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
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.url)
|
||||
@@ -78,19 +142,32 @@ pub async fn create_site(
|
||||
.bind(&status)
|
||||
.bind(&payload.api_key)
|
||||
.bind(&payload.proxy_url)
|
||||
.bind(&payload.external_checkin_url)
|
||||
.bind(payload.use_system_proxy)
|
||||
.bind(&payload.custom_headers)
|
||||
.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?;
|
||||
|
||||
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 = ?")
|
||||
.bind(site_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": site }))))
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({ "data": site_response(&state, site).await? })),
|
||||
))
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}
|
||||
@@ -106,7 +183,7 @@ pub async fn get_site(
|
||||
.await?
|
||||
.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}
|
||||
@@ -123,8 +200,17 @@ pub async fn update_site(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
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.url.unwrap_or(existing.url))
|
||||
@@ -132,19 +218,51 @@ pub async fn update_site(
|
||||
.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
|
||||
.external_checkin_url
|
||||
.or(existing.external_checkin_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(
|
||||
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)
|
||||
.execute(&*state.db)
|
||||
.execute(&mut *tx)
|
||||
.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 = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": site })))
|
||||
Ok(Json(json!({ "data": site_response(&state, site).await? })))
|
||||
}
|
||||
|
||||
/// DELETE /api/sites/{id}
|
||||
@@ -166,3 +284,108 @@ pub async fn delete_site(
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user