feat: AI余额定时自动同步(CLI脚本+systemd每日timer+批量同步接口+前端按钮)

This commit is contained in:
gouki
2026-08-02 17:43:46 +00:00
parent 2e3e9f8b0b
commit 8ba58caaa8
7 changed files with 116 additions and 2 deletions
+2 -1
View File
@@ -19,7 +19,7 @@ from sqlmodel import Session
from app.core.config import settings from app.core.config import settings
from app.core.seed import seed_providers from app.core.seed import seed_providers
from app.database import assets_engine, init_db from app.database import assets_engine, init_db
from app.routers import agent, assets, monitor, providers, stats from app.routers import agent, assets, monitor, providers, stats, sync
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = BASE_DIR / "static" STATIC_DIR = BASE_DIR / "static"
@@ -53,6 +53,7 @@ app.include_router(providers.router)
app.include_router(stats.router) app.include_router(stats.router)
app.include_router(agent.router) app.include_router(agent.router)
app.include_router(monitor.router) app.include_router(monitor.router)
app.include_router(sync.router)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+19
View File
@@ -0,0 +1,19 @@
"""同步相关路由(批量同步操作)"""
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.core.security import require_api_key
from app.database import get_session
from app.services import sync_service
router = APIRouter(prefix="/api/sync", tags=["sync"])
@router.post(
"/ai-balances",
summary="同步所有 AI 账号余额",
dependencies=[Depends(require_api_key)],
)
def sync_ai_balances(session: Session = Depends(get_session)) -> dict:
return sync_service.sync_all_ai_balances(session)
+27
View File
@@ -295,3 +295,30 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
"currency": ai.currency, "currency": ai.currency,
"last_synced_at": ai.last_synced_at.isoformat(), "last_synced_at": ai.last_synced_at.isoformat(),
} }
def sync_all_ai_balances(session: Session) -> dict:
"""遍历所有 AI 账号资产,逐个刷新余额(单个失败不中断整体)"""
ai_assets = session.exec(
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
).all()
success = 0
failed = 0
errors = []
for asset in ai_assets:
try:
refresh_ai_balance(session, asset.id)
success += 1
except HTTPException as e:
failed += 1
errors.append(f"{asset.name}: {e.detail}")
except Exception as e: # noqa: BLE001
failed += 1
errors.append(f"{asset.name}: {e}")
return {
"total": len(ai_assets),
"success": success,
"failed": failed,
"errors": errors,
"synced_at": datetime.utcnow().isoformat(),
}
+13
View File
@@ -0,0 +1,13 @@
# AI 账号余额同步服务(oneshot,由 timer 触发)
# 部署位置:/etc/systemd/system/vps-sync-ai.service
[Unit]
Description=Sync all AI account balances
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/vps-manager
EnvironmentFile=-/opt/vps-manager/.env
ExecStart=/opt/vps-manager/.venv/bin/python /opt/vps-manager/scripts/sync_ai_balances.py
+13
View File
@@ -0,0 +1,13 @@
# AI 账号余额每日同步定时器
# 部署位置:/etc/systemd/system/vps-sync-ai.timer
[Unit]
Description=Daily sync of AI account balances
[Timer]
OnCalendar=daily
Persistent=true
Unit=vps-sync-ai.service
[Install]
WantedBy=timers.target
+32
View File
@@ -0,0 +1,32 @@
"""定时同步所有 AI 账号余额(供 systemd timer 调用)
用法:python scripts/sync_ai_balances.py
依赖 .env 中的 MASTER_KEY(解密各账号 api_key)。
"""
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlmodel import Session
from app.database import assets_engine, init_db
from app.services import sync_service
def main() -> int:
init_db()
with Session(assets_engine) as session:
result = sync_service.sync_all_ai_balances(session)
print(
f"[AI余额同步] 总计 {result['total']} | 成功 {result['success']} | 失败 {result['failed']}"
)
for err in result["errors"]:
print(f" - {err}")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -1
View File
@@ -226,6 +226,7 @@ const AssetsView = {
<option value="">全部状态</option> <option value="">全部状态</option>
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option> <option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select> </select>
<button @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
<span class="ml-auto text-xs text-slate-400">共 {{ store.assets.length }} 条</span> <span class="ml-auto text-xs text-slate-400">共 {{ store.assets.length }} 条</span>
</div> </div>
@@ -297,7 +298,15 @@ const AssetsView = {
await loadAssets(); await loadAssets();
} catch (e) { alert('刷新失败:' + e.message); } } catch (e) { alert('刷新失败:' + e.message); }
} }
return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance }; async function syncAllAI() {
if (!confirm('同步所有 AI 账号的余额?')) return;
try {
const r = await Api.post('/sync/ai-balances', {});
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
await loadAssets();
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
}, },
}; };