diff --git a/modules/balance_client.py b/modules/balance_client.py new file mode 100644 index 0000000..dd0c56d --- /dev/null +++ b/modules/balance_client.py @@ -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(), + } + diff --git a/static/src/admin/AdminDashboardApp.vue b/static/src/admin/AdminDashboardApp.vue index 3cab4c1..ad76526 100644 --- a/static/src/admin/AdminDashboardApp.vue +++ b/static/src/admin/AdminDashboardApp.vue @@ -232,14 +232,54 @@
{{ timeAgo(upload.timestamp) }} - - {{ upload.accepted ? '通过' : '阻断' }} - - {{ upload.error.message }} -
- + + {{ upload.accepted ? '通过' : '阻断' }} + + {{ upload.error.message }} + + +
+
+
+

余额查询

+

手动刷新,查看 Kimi / DeepSeek / Qwen 余额

+
+
+ 上次刷新:{{ timeAgo(balanceUpdatedAt) }} + +
+
+ +
+
+
+

{{ card.label }}

+ 正常 + 失败 +
+
+ {{ card.amount }} {{ card.currency }} + + ≈ ¥{{ card.amountCny }} +
+
    +
  • 券:{{ card.extras.voucher }}
  • +
  • 现金:{{ card.extras.cash }}
  • +
  • 赠送:{{ card.extras.granted }}
  • +
  • 充值:{{ card.extras.topped_up }}
  • +
  • 汇率 USD→CNY:{{ card.extras.rate }}
  • +
+

{{ card.error || '未知错误' }}

+
+
+

邀请码

@@ -273,7 +313,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; type Snapshot = Record | null; -type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'invites'; +type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites'; const loading = ref(true); const refreshing = ref(false); @@ -283,6 +323,10 @@ const bannerError = ref(null); const autoRefresh = ref(true); const activeSection = ref('overview'); let timer: number | undefined; +const balanceLoading = ref(false); +const balanceError = ref(null); +const balanceData = ref | null>(null); +const balanceUpdatedAt = ref(null); const sectionTabs: Array<{ id: SectionId; label: string }> = [ { id: 'overview', label: '系统总览' }, @@ -290,6 +334,7 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [ { id: 'users', label: '用户' }, { id: 'containers', label: '容器' }, { id: 'uploads', label: '上传审计' }, + { id: 'balance', label: '余额查询' }, { id: 'invites', label: '邀请码' } ]; @@ -343,10 +388,36 @@ const scheduleAutoRefresh = () => { const handleManualRefresh = () => fetchDashboard(true); 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, () => { scheduleAutoRefresh(); }); +watch( + () => activeSection.value, + (val) => { + if (val === 'balance' && !balanceData.value && !balanceLoading.value) { + fetchBalance(); + } + } +); + onMounted(() => { fetchDashboard(false); scheduleAutoRefresh(); @@ -392,6 +463,34 @@ const tokenBreakdown = computed(() => { 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(() => [ { title: '注册用户', @@ -952,6 +1051,74 @@ th { 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 { min-height: 60vh; display: flex; diff --git a/web_server.py b/web_server.py index 0526be0..5a2d9f7 100644 --- a/web_server.py +++ b/web_server.py @@ -21,7 +21,7 @@ import time from datetime import datetime from collections import defaultdict, deque, Counter 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.routing import BaseConverter import secrets @@ -1467,6 +1467,15 @@ def admin_policy_page(): 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/') @login_required @admin_required