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>, 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= /// GET /api/accounts?site_id=
pub async fn list_accounts( pub async fn list_accounts(
State(state): State<AppState>, State(state): State<AppState>,
@@ -144,11 +151,41 @@ pub async fn list_accounts(
let site_by_id: std::collections::HashMap<i64, &Site> = let site_by_id: std::collections::HashMap<i64, &Site> =
sites.iter().map(|s| (s.id, s)).collect(); 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 let enriched: Vec<Value> = accounts
.iter() .iter()
.map(|account| { .map(|account| {
let site = site_by_id.get(&account.site_id).copied(); 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(); .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 credential_mode = resolve_account_credential_mode(account);
let mut row = serde_json::to_value(account).unwrap_or(json!({})); let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Value::Object(map) = &mut row { 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), 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 row
} }
@@ -324,7 +405,7 @@ pub async fn create_account(
Json(json!({ Json(json!({
"success": true, "success": true,
"id": account.id, "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} /// GET /api/accounts/{id}
pub async fn get_account( pub async fn get_account(
State(state): State<AppState>, 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/batch", post(accounts::batch_accounts))
.route("/api/accounts/verify-token", post(accounts::verify_token)) .route("/api/accounts/verify-token", post(accounts::verify_token))
.route(
"/api/accounts/health/refresh",
post(accounts::refresh_account_health),
)
.route( .route(
"/api/accounts/{id}", "/api/accounts/{id}",
get(accounts::get_account) get(accounts::get_account)
+146
View File
@@ -67,9 +67,87 @@ async fn dashboard(
) )
.fetch_one(&*state.db) .fetch_one(&*state.db)
.await?; .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!({ let payload = json!({
"generatedAt": chrono::Utc::now().to_rfc3339(), "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": { "summary": {
"sites": sites, "sites": sites,
"accounts": accounts, "accounts": accounts,
@@ -94,15 +172,83 @@ async fn dashboard(
"generatedAt": payload["generatedAt"], "generatedAt": payload["generatedAt"],
"summary": payload["summary"], "summary": payload["summary"],
"stats": payload["stats"], "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!({ Some("insights") => Ok(Json(json!({
"generatedAt": payload["generatedAt"], "generatedAt": payload["generatedAt"],
"insights": payload["insights"], "insights": payload["insights"],
"siteAvailability": payload["siteAvailability"],
}))), }))),
_ => Ok(Json(payload)), _ => 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 /// GET /api/stats/proxy-logs
async fn proxy_logs( async fn proxy_logs(
State(state): State<AppState>, State(state): State<AppState>,
+17 -4
View File
@@ -1139,8 +1139,15 @@ export const api = {
}), }),
// Account tokens // Account tokens
getAccountTokens: (accountId?: number) => getAccountTokens: async (accountId?: number) => {
request(`/api/account-tokens${accountId ? `?accountId=${accountId}` : ""}`), 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) => addAccountToken: (data: any) =>
request("/api/account-tokens", { request("/api/account-tokens", {
method: "POST", method: "POST",
@@ -1404,7 +1411,7 @@ export const api = {
const [distribution, trend, sites] = await Promise.all([ const [distribution, trend, sites] = await Promise.all([
request<{ distribution: any[] }>(`/api/stats/site-distribution${query}`), request<{ distribution: any[] }>(`/api/stats/site-distribution${query}`),
request<{ trend: any[] }>(`/api/stats/site-trend${query}`), request<{ trend: any[] }>(`/api/stats/site-trend${query}`),
request<any[]>("/api/sites"), request<any>("/api/sites"),
]); ]);
return { return {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
@@ -1412,7 +1419,13 @@ export const api = {
? distribution.distribution ? distribution.distribution
: [], : [],
trend: Array.isArray(trend?.trend) ? trend.trend : [], 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) => getModelBySite: (siteId?: number, days = 7) =>