feat: add manual balance checker for admin monitor
This commit is contained in:
parent
99cbea30da
commit
cec28df931
159
modules/balance_client.py
Normal file
159
modules/balance_client.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Admin balance fetchers for Kimi (Moonshot), DeepSeek, and Qwen (Aliyun BSS).
|
||||||
|
|
||||||
|
Credentials (read from environment):
|
||||||
|
- MOONSHOT_API_KEY : Bearer token for Kimi
|
||||||
|
- DEEPSEEK_API_KEY : Bearer token for DeepSeek
|
||||||
|
- ALIYUN_ACCESS_KEY_ID : AccessKey ID for Aliyun (Qwen billing)
|
||||||
|
- ALIYUN_ACCESS_KEY_SECRET : AccessKey Secret for Aliyun (Qwen billing)
|
||||||
|
- USD_CNY_RATE : Override USD->CNY rate (default 7.1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
from urllib import parse, request, error
|
||||||
|
|
||||||
|
|
||||||
|
USD_CNY_RATE = float(os.environ.get("USD_CNY_RATE", "7.1"))
|
||||||
|
|
||||||
|
|
||||||
|
def _http_get(url: str, headers: Dict[str, str] | None = None, timeout: int = 8) -> Tuple[Dict[str, Any] | None, str | None]:
|
||||||
|
"""Perform a simple GET request, return (json, error_message)."""
|
||||||
|
req = request.Request(url, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
return json.loads(data.decode("utf-8")), None
|
||||||
|
except Exception as exc: # broad: network/parsing errors
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Kimi (Moonshot) --------
|
||||||
|
def fetch_kimi_balance() -> Dict[str, Any]:
|
||||||
|
api_key = os.environ.get("MOONSHOT_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return {"success": False, "error": "MOONSHOT_API_KEY 未设置"}
|
||||||
|
|
||||||
|
url = "https://api.moonshot.ai/v1/users/me/balance"
|
||||||
|
payload, err = _http_get(url, headers={"Authorization": f"Bearer {api_key}"})
|
||||||
|
if err:
|
||||||
|
return {"success": False, "error": err}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
available = float(data.get("available_balance", 0))
|
||||||
|
voucher = float(data.get("voucher_balance", 0))
|
||||||
|
cash = float(data.get("cash_balance", 0))
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"currency": "USD",
|
||||||
|
"available": available,
|
||||||
|
"available_cny": round(available * USD_CNY_RATE, 2),
|
||||||
|
"voucher": voucher,
|
||||||
|
"cash": cash,
|
||||||
|
"rate": USD_CNY_RATE,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
return {"success": False, "error": f"解析失败: {exc}", "raw": payload}
|
||||||
|
|
||||||
|
|
||||||
|
# -------- DeepSeek --------
|
||||||
|
def fetch_deepseek_balance() -> Dict[str, Any]:
|
||||||
|
api_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return {"success": False, "error": "DEEPSEEK_API_KEY 未设置"}
|
||||||
|
|
||||||
|
url = "https://api.deepseek.com/user/balance"
|
||||||
|
payload, err = _http_get(url, headers={"Authorization": f"Bearer {api_key}"})
|
||||||
|
if err:
|
||||||
|
return {"success": False, "error": err}
|
||||||
|
|
||||||
|
try:
|
||||||
|
infos = payload.get("balance_infos") or []
|
||||||
|
primary = infos[0] if infos else {}
|
||||||
|
total = float(primary.get("total_balance") or 0)
|
||||||
|
granted = float(primary.get("granted_balance") or 0)
|
||||||
|
topped_up = float(primary.get("topped_up_balance") or 0)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"currency": primary.get("currency", "CNY"),
|
||||||
|
"available": total,
|
||||||
|
"granted": granted,
|
||||||
|
"topped_up": topped_up,
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
return {"success": False, "error": f"解析失败: {exc}", "raw": payload}
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Qwen (Aliyun BSS QueryAccountBalance) --------
|
||||||
|
def _percent_encode(val: str) -> str:
|
||||||
|
res = parse.quote(str(val), safe="~")
|
||||||
|
return res.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(params: Dict[str, Any], secret: str) -> str:
|
||||||
|
sorted_params = sorted(params.items(), key=lambda x: x[0])
|
||||||
|
canonicalized = "&".join(f"{_percent_encode(k)}={_percent_encode(v)}" for k, v in sorted_params)
|
||||||
|
string_to_sign = f"GET&%2F&{_percent_encode(canonicalized)}"
|
||||||
|
h = hmac.new((secret + "&").encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha1)
|
||||||
|
return base64.b64encode(h.digest()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_qwen_balance() -> Dict[str, Any]:
|
||||||
|
ak = os.environ.get("ALIYUN_ACCESS_KEY_ID")
|
||||||
|
sk = os.environ.get("ALIYUN_ACCESS_KEY_SECRET")
|
||||||
|
if not ak or not sk:
|
||||||
|
return {"success": False, "error": "缺少 ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET"}
|
||||||
|
|
||||||
|
params: Dict[str, Any] = {
|
||||||
|
"Format": "JSON",
|
||||||
|
"Version": "2017-12-14",
|
||||||
|
"AccessKeyId": ak,
|
||||||
|
"SignatureMethod": "HMAC-SHA1",
|
||||||
|
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"SignatureVersion": "1.0",
|
||||||
|
"SignatureNonce": str(uuid.uuid4()),
|
||||||
|
"Action": "QueryAccountBalance",
|
||||||
|
"RegionId": "cn-hangzhou",
|
||||||
|
}
|
||||||
|
signature = _sign(params, sk)
|
||||||
|
params["Signature"] = signature
|
||||||
|
url = "https://bss.aliyuncs.com/?" + parse.urlencode(params)
|
||||||
|
|
||||||
|
payload, err = _http_get(url)
|
||||||
|
if err:
|
||||||
|
return {"success": False, "error": err}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = payload.get("Data") or {}
|
||||||
|
amount = float(data.get("AvailableAmount") or 0)
|
||||||
|
currency = data.get("Currency", "CNY")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"currency": currency,
|
||||||
|
"available": amount,
|
||||||
|
"cash": float(data.get("AvailableCashAmount") or 0),
|
||||||
|
"raw": payload,
|
||||||
|
}
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
return {"success": False, "error": f"解析失败: {exc}", "raw": payload}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_balances() -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rate_usd_cny": USD_CNY_RATE,
|
||||||
|
"kimi": fetch_kimi_balance(),
|
||||||
|
"deepseek": fetch_deepseek_balance(),
|
||||||
|
"qwen": fetch_qwen_balance(),
|
||||||
|
}
|
||||||
|
|
||||||
@ -240,6 +240,46 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-else-if="activeSection === 'balance'" key="balance" class="panel">
|
||||||
|
<div class="balance-header">
|
||||||
|
<div>
|
||||||
|
<h2>余额查询</h2>
|
||||||
|
<p class="muted">手动刷新,查看 Kimi / DeepSeek / Qwen 余额</p>
|
||||||
|
</div>
|
||||||
|
<div class="balance-actions">
|
||||||
|
<span class="muted" v-if="balanceUpdatedAt">上次刷新:{{ timeAgo(balanceUpdatedAt) }}</span>
|
||||||
|
<button type="button" :disabled="balanceLoading" @click="fetchBalance">
|
||||||
|
{{ balanceLoading ? '查询中...' : '手动刷新' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section v-if="balanceError" class="banner-error">
|
||||||
|
<strong>查询失败:</strong>
|
||||||
|
<span>{{ balanceError }}</span>
|
||||||
|
</section>
|
||||||
|
<div class="balance-grid">
|
||||||
|
<div v-for="card in balanceCards" :key="card.key" class="balance-card" :class="{ error: !card.success }">
|
||||||
|
<div class="balance-card-head">
|
||||||
|
<h3>{{ card.label }}</h3>
|
||||||
|
<span v-if="card.success" class="status-badge online">正常</span>
|
||||||
|
<span v-else class="status-badge danger">失败</span>
|
||||||
|
</div>
|
||||||
|
<div class="balance-amount">
|
||||||
|
<strong v-if="card.amount !== null">{{ card.amount }} {{ card.currency }}</strong>
|
||||||
|
<strong v-else>—</strong>
|
||||||
|
<span v-if="card.amountCny !== null" class="sub">≈ ¥{{ card.amountCny }}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="balance-meta" v-if="card.success">
|
||||||
|
<li v-if="card.extras.voucher !== undefined">券:{{ card.extras.voucher }}</li>
|
||||||
|
<li v-if="card.extras.cash !== undefined">现金:{{ card.extras.cash }}</li>
|
||||||
|
<li v-if="card.extras.granted !== undefined">赠送:{{ card.extras.granted }}</li>
|
||||||
|
<li v-if="card.extras.topped_up !== undefined">充值:{{ card.extras.topped_up }}</li>
|
||||||
|
<li v-if="card.extras.rate">汇率 USD→CNY:{{ card.extras.rate }}</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="muted small">{{ card.error || '未知错误' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section v-else-if="activeSection === 'invites'" key="invites" class="panel">
|
<section v-else-if="activeSection === 'invites'" key="invites" class="panel">
|
||||||
<h2>邀请码</h2>
|
<h2>邀请码</h2>
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
@ -273,7 +313,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
|||||||
|
|
||||||
type Snapshot = Record<string, any> | null;
|
type Snapshot = Record<string, any> | null;
|
||||||
|
|
||||||
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'invites';
|
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const refreshing = ref(false);
|
const refreshing = ref(false);
|
||||||
@ -283,6 +323,10 @@ const bannerError = ref<string | null>(null);
|
|||||||
const autoRefresh = ref(true);
|
const autoRefresh = ref(true);
|
||||||
const activeSection = ref<SectionId>('overview');
|
const activeSection = ref<SectionId>('overview');
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
const balanceLoading = ref(false);
|
||||||
|
const balanceError = ref<string | null>(null);
|
||||||
|
const balanceData = ref<Record<string, any> | null>(null);
|
||||||
|
const balanceUpdatedAt = ref<number | null>(null);
|
||||||
|
|
||||||
const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
||||||
{ id: 'overview', label: '系统总览' },
|
{ id: 'overview', label: '系统总览' },
|
||||||
@ -290,6 +334,7 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
|||||||
{ id: 'users', label: '用户' },
|
{ id: 'users', label: '用户' },
|
||||||
{ id: 'containers', label: '容器' },
|
{ id: 'containers', label: '容器' },
|
||||||
{ id: 'uploads', label: '上传审计' },
|
{ id: 'uploads', label: '上传审计' },
|
||||||
|
{ id: 'balance', label: '余额查询' },
|
||||||
{ id: 'invites', label: '邀请码' }
|
{ id: 'invites', label: '邀请码' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -343,10 +388,36 @@ const scheduleAutoRefresh = () => {
|
|||||||
const handleManualRefresh = () => fetchDashboard(true);
|
const handleManualRefresh = () => fetchDashboard(true);
|
||||||
const handleRetry = () => fetchDashboard(false);
|
const handleRetry = () => fetchDashboard(false);
|
||||||
|
|
||||||
|
const fetchBalance = async () => {
|
||||||
|
balanceLoading.value = true;
|
||||||
|
balanceError.value = null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/admin/balance', { credentials: 'same-origin' });
|
||||||
|
if (!resp.ok) throw new Error(`请求失败:${resp.status}`);
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (!payload.success) throw new Error(payload.error || '未知错误');
|
||||||
|
balanceData.value = payload.data || {};
|
||||||
|
balanceUpdatedAt.value = Date.now();
|
||||||
|
} catch (error) {
|
||||||
|
balanceError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
balanceLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(autoRefresh, () => {
|
watch(autoRefresh, () => {
|
||||||
scheduleAutoRefresh();
|
scheduleAutoRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeSection.value,
|
||||||
|
(val) => {
|
||||||
|
if (val === 'balance' && !balanceData.value && !balanceLoading.value) {
|
||||||
|
fetchBalance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchDashboard(false);
|
fetchDashboard(false);
|
||||||
scheduleAutoRefresh();
|
scheduleAutoRefresh();
|
||||||
@ -392,6 +463,34 @@ const tokenBreakdown = computed(() => {
|
|||||||
return list.sort((a, b) => b.total - a.total);
|
return list.sort((a, b) => b.total - a.total);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const usdToCnyRate = computed(() => balanceData.value?.rate_usd_cny ?? null);
|
||||||
|
|
||||||
|
const balanceCards = computed(() => {
|
||||||
|
const makeCard = (key: string, label: string) => {
|
||||||
|
const data = (balanceData.value as any)?.[key] || {};
|
||||||
|
const success = data.success === true;
|
||||||
|
const currency = data.currency || '—';
|
||||||
|
const amount = data.available ?? data.total ?? null;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
success,
|
||||||
|
currency,
|
||||||
|
amount,
|
||||||
|
amountCny: data.available_cny ?? (currency === 'USD' && usdToCnyRate.value && amount != null
|
||||||
|
? Math.round(amount * usdToCnyRate.value * 100) / 100
|
||||||
|
: null),
|
||||||
|
extras: data,
|
||||||
|
error: success ? null : data.error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
makeCard('kimi', 'Kimi'),
|
||||||
|
makeCard('deepseek', 'DeepSeek'),
|
||||||
|
makeCard('qwen', 'Qwen / Aliyun')
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const metricCards = computed(() => [
|
const metricCards = computed(() => [
|
||||||
{
|
{
|
||||||
title: '注册用户',
|
title: '注册用户',
|
||||||
@ -952,6 +1051,74 @@ th {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card.error {
|
||||||
|
border-color: rgba(189, 93, 58, 0.4);
|
||||||
|
background: rgba(189, 93, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-amount .sub {
|
||||||
|
color: #6a5d4c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-meta {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: #6a5d4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-loading {
|
.admin-loading {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import defaultdict, deque, Counter
|
from collections import defaultdict, deque, Counter
|
||||||
from config.model_profiles import get_model_profile
|
from config.model_profiles import get_model_profile
|
||||||
from modules import admin_policy_manager
|
from modules import admin_policy_manager, balance_client
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
import secrets
|
import secrets
|
||||||
@ -1467,6 +1467,15 @@ def admin_policy_page():
|
|||||||
return send_from_directory(Path(app.static_folder) / 'admin_policy', 'index.html')
|
return send_from_directory(Path(app.static_folder) / 'admin_policy', 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/balance', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_balance_api():
|
||||||
|
"""查询第三方账户余额(Kimi/DeepSeek/Qwen)。"""
|
||||||
|
data = balance_client.fetch_all_balances()
|
||||||
|
return jsonify({"success": True, "data": data})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/assets/<path:filename>')
|
@app.route('/admin/assets/<path:filename>')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user