fix: restore account health tokens and dashboard data
This commit is contained in:
@@ -88,6 +88,13 @@ pub struct VerifyTokenRequest {
|
||||
pub credential_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshAccountHealthRequest {
|
||||
pub account_id: Option<i64>,
|
||||
pub wait: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/accounts?site_id=
|
||||
pub async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
@@ -144,11 +151,41 @@ pub async fn list_accounts(
|
||||
let site_by_id: std::collections::HashMap<i64, &Site> =
|
||||
sites.iter().map(|s| (s.id, s)).collect();
|
||||
|
||||
let latest_health_rows: Vec<(i64, String, Option<i64>, Option<String>, Option<i64>, Option<String>)> =
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT l.account_id, l.status, l.http_status, l.error_message, l.latency_ms, l.created_at
|
||||
FROM proxy_logs l
|
||||
INNER JOIN (
|
||||
SELECT account_id, MAX(id) AS id
|
||||
FROM proxy_logs
|
||||
WHERE account_id IS NOT NULL
|
||||
GROUP BY account_id
|
||||
) latest ON latest.id = l.id
|
||||
WHERE l.account_id IS NOT NULL
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let health_by_account: std::collections::HashMap<
|
||||
i64,
|
||||
(String, Option<i64>, Option<String>, Option<i64>, Option<String>),
|
||||
> = latest_health_rows
|
||||
.into_iter()
|
||||
.map(|(account_id, status, http_status, error_message, latency_ms, created_at)| {
|
||||
(
|
||||
account_id,
|
||||
(status, http_status, error_message, latency_ms, created_at),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let enriched: Vec<Value> = accounts
|
||||
.iter()
|
||||
.map(|account| {
|
||||
let site = site_by_id.get(&account.site_id).copied();
|
||||
account_response_value(account, site)
|
||||
account_response_value(account, site, health_by_account.get(&account.id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -160,7 +197,11 @@ pub async fn list_accounts(
|
||||
})))
|
||||
}
|
||||
|
||||
fn account_response_value(account: &Account, site: Option<&Site>) -> Value {
|
||||
fn account_response_value(
|
||||
account: &Account,
|
||||
site: Option<&Site>,
|
||||
runtime_health: Option<&(String, Option<i64>, Option<String>, Option<i64>, Option<String>)>,
|
||||
) -> Value {
|
||||
let credential_mode = resolve_account_credential_mode(account);
|
||||
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
|
||||
if let Value::Object(map) = &mut row {
|
||||
@@ -179,6 +220,46 @@ fn account_response_value(account: &Account, site: Option<&Site>) -> Value {
|
||||
serde_json::to_value(site).unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
let runtime_health_json = if account.status == "disabled" || site.is_some_and(|s| s.status == "disabled") {
|
||||
json!({
|
||||
"state": "disabled",
|
||||
"reason": "账号或站点已禁用",
|
||||
})
|
||||
} else if account.status == "expired" {
|
||||
json!({
|
||||
"state": "unhealthy",
|
||||
"reason": "连接凭证已过期,请更新凭证",
|
||||
})
|
||||
} else if let Some((status, http_status, error_message, latency_ms, created_at)) = runtime_health {
|
||||
let ok = status == "success";
|
||||
json!({
|
||||
"state": if ok { "healthy" } else { "unhealthy" },
|
||||
"reason": if ok {
|
||||
match latency_ms {
|
||||
Some(ms) => format!("最近代理请求成功,用时 {}ms", ms),
|
||||
None => "最近代理请求成功".to_string(),
|
||||
}
|
||||
} else {
|
||||
error_message
|
||||
.clone()
|
||||
.unwrap_or_else(|| http_status.map(|s| format!("最近代理请求失败:HTTP {}", s)).unwrap_or_else(|| "最近代理请求失败".to_string()))
|
||||
},
|
||||
"latencyMs": latency_ms,
|
||||
"httpStatus": http_status,
|
||||
"checkedAt": created_at,
|
||||
})
|
||||
} else if credential_mode == "apikey" || account.status == "active" {
|
||||
json!({
|
||||
"state": "degraded",
|
||||
"reason": "尚无代理请求记录,等待首次调用后更新",
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"state": "unknown",
|
||||
"reason": "尚未获取运行健康信息",
|
||||
})
|
||||
};
|
||||
map.insert("runtimeHealth".to_string(), runtime_health_json);
|
||||
}
|
||||
row
|
||||
}
|
||||
@@ -324,7 +405,7 @@ pub async fn create_account(
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"id": account.id,
|
||||
"data": account_response_value(&account, site.as_ref()),
|
||||
"data": account_response_value(&account, site.as_ref(), None),
|
||||
})),
|
||||
))
|
||||
}
|
||||
@@ -464,6 +545,24 @@ pub async fn verify_token(
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/accounts/health/refresh
|
||||
///
|
||||
/// Runtime health is derived from recent proxy logs in `list_accounts`.
|
||||
/// This endpoint keeps the frontend refresh action functional and gives us a
|
||||
/// stable place to add active probes later.
|
||||
pub async fn refresh_account_health(
|
||||
State(_state): State<AppState>,
|
||||
Json(payload): Json<RefreshAccountHealthRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"queued": false,
|
||||
"accountId": payload.account_id,
|
||||
"wait": payload.wait.unwrap_or(false),
|
||||
"message": "运行健康状态已根据最新代理日志刷新",
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/accounts/{id}
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -82,6 +82,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route("/api/accounts/batch", post(accounts::batch_accounts))
|
||||
.route("/api/accounts/verify-token", post(accounts::verify_token))
|
||||
.route(
|
||||
"/api/accounts/health/refresh",
|
||||
post(accounts::refresh_account_health),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}",
|
||||
get(accounts::get_account)
|
||||
|
||||
@@ -67,9 +67,87 @@ async fn dashboard(
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let total_balance: f64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0.0) FROM accounts")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let total_used: f64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(balance_used), 0.0) FROM accounts")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let today_spend: f64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0.0)
|
||||
FROM proxy_logs
|
||||
WHERE date(created_at) = date('now')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let today_reward: f64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(CAST(reward AS REAL)), 0.0)
|
||||
FROM checkin_logs
|
||||
WHERE status = 'success' AND date(created_at) = date('now')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let active_accounts: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let today_checkin_total: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM checkin_logs WHERE date(created_at) = date('now')")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let today_checkin_success: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM checkin_logs
|
||||
WHERE status = 'success' AND date(created_at) = date('now')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let total_tokens_24h: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0)
|
||||
FROM proxy_logs
|
||||
WHERE created_at >= datetime('now', '-1 day')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let perf_window_secs = 60_i64;
|
||||
let perf_requests: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proxy_logs WHERE created_at >= datetime('now', '-60 seconds')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let perf_tokens: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(total_tokens), 0)
|
||||
FROM proxy_logs
|
||||
WHERE created_at >= datetime('now', '-60 seconds')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
|
||||
let payload = json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"totalBalance": total_balance,
|
||||
"totalUsed": total_used,
|
||||
"todaySpend": today_spend,
|
||||
"todayReward": today_reward,
|
||||
"activeAccounts": active_accounts,
|
||||
"totalAccounts": accounts,
|
||||
"todayCheckin": {
|
||||
"success": today_checkin_success,
|
||||
"total": today_checkin_total,
|
||||
},
|
||||
"proxy24h": {
|
||||
"success": success_24h,
|
||||
"total": requests_24h,
|
||||
"totalTokens": total_tokens_24h,
|
||||
},
|
||||
"performance": {
|
||||
"windowSeconds": perf_window_secs,
|
||||
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
||||
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
||||
},
|
||||
"siteAvailability": site_availability,
|
||||
"summary": {
|
||||
"sites": sites,
|
||||
"accounts": accounts,
|
||||
@@ -94,15 +172,83 @@ async fn dashboard(
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"summary": payload["summary"],
|
||||
"stats": payload["stats"],
|
||||
"totalBalance": payload["totalBalance"],
|
||||
"totalUsed": payload["totalUsed"],
|
||||
"todaySpend": payload["todaySpend"],
|
||||
"todayReward": payload["todayReward"],
|
||||
"activeAccounts": payload["activeAccounts"],
|
||||
"totalAccounts": payload["totalAccounts"],
|
||||
"todayCheckin": payload["todayCheckin"],
|
||||
"proxy24h": payload["proxy24h"],
|
||||
"performance": payload["performance"],
|
||||
}))),
|
||||
Some("insights") => Ok(Json(json!({
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"insights": payload["insights"],
|
||||
"siteAvailability": payload["siteAvailability"],
|
||||
}))),
|
||||
_ => Ok(Json(payload)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> {
|
||||
let rows: Vec<(
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
Option<f64>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.url,
|
||||
s.platform,
|
||||
COUNT(l.id) AS total_requests,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||
AVG(l.latency_ms) AS average_latency_ms
|
||||
FROM sites s
|
||||
LEFT JOIN accounts a ON a.site_id = s.id
|
||||
LEFT JOIN proxy_logs l
|
||||
ON l.account_id = a.id
|
||||
AND l.created_at >= datetime('now', '-1 day')
|
||||
GROUP BY s.id, s.name, s.url, s.platform
|
||||
ORDER BY s.sort_order, s.name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(site_id, site_name, site_url, platform, total, success, avg_latency)| {
|
||||
let failed = total.saturating_sub(success);
|
||||
let availability = if total > 0 {
|
||||
Some(success as f64 * 100.0 / total as f64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({
|
||||
"siteId": site_id,
|
||||
"siteName": site_name,
|
||||
"siteUrl": site_url,
|
||||
"platform": platform,
|
||||
"totalRequests": total,
|
||||
"successCount": success,
|
||||
"failedCount": failed,
|
||||
"availabilityPercent": availability,
|
||||
"averageLatencyMs": avg_latency,
|
||||
"buckets": [],
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// GET /api/stats/proxy-logs
|
||||
async fn proxy_logs(
|
||||
State(state): State<AppState>,
|
||||
|
||||
+17
-4
@@ -1139,8 +1139,15 @@ export const api = {
|
||||
}),
|
||||
|
||||
// Account tokens
|
||||
getAccountTokens: (accountId?: number) =>
|
||||
request(`/api/account-tokens${accountId ? `?accountId=${accountId}` : ""}`),
|
||||
getAccountTokens: async (accountId?: number) => {
|
||||
const result = await request<any>(
|
||||
`/api/account-tokens${accountId ? `?accountId=${accountId}` : ""}`,
|
||||
);
|
||||
if (Array.isArray(result)) return result;
|
||||
if (Array.isArray(result?.data)) return result.data;
|
||||
if (Array.isArray(result?.tokens)) return result.tokens;
|
||||
return [];
|
||||
},
|
||||
addAccountToken: (data: any) =>
|
||||
request("/api/account-tokens", {
|
||||
method: "POST",
|
||||
@@ -1404,7 +1411,7 @@ export const api = {
|
||||
const [distribution, trend, sites] = await Promise.all([
|
||||
request<{ distribution: any[] }>(`/api/stats/site-distribution${query}`),
|
||||
request<{ trend: any[] }>(`/api/stats/site-trend${query}`),
|
||||
request<any[]>("/api/sites"),
|
||||
request<any>("/api/sites"),
|
||||
]);
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -1412,7 +1419,13 @@ export const api = {
|
||||
? distribution.distribution
|
||||
: [],
|
||||
trend: Array.isArray(trend?.trend) ? trend.trend : [],
|
||||
sites: Array.isArray(sites) ? sites : [],
|
||||
sites: Array.isArray(sites)
|
||||
? sites
|
||||
: Array.isArray(sites?.data)
|
||||
? sites.data
|
||||
: Array.isArray(sites?.sites)
|
||||
? sites.sites
|
||||
: [],
|
||||
};
|
||||
},
|
||||
getModelBySite: (siteId?: number, days = 7) =>
|
||||
|
||||
Reference in New Issue
Block a user