feat: AI余额定时自动同步(CLI脚本+systemd每日timer+批量同步接口+前端按钮)
This commit is contained in:
+2
-1
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user