fix: restore account health tokens and dashboard data

This commit is contained in:
boos
2026-05-21 17:07:18 +02:00
parent 0207ba748f
commit 65d2f1bbe5
4 changed files with 269 additions and 7 deletions
+102 -3
View File
@@ -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>,
+4
View File
@@ -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)
+146
View File
@@ -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
View File
@@ -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) =>