diff --git a/app/database.py b/app/database.py index d01f52c..3591535 100644 --- a/app/database.py +++ b/app/database.py @@ -64,6 +64,10 @@ def _migrate_assets_db() -> None: cols = {c["name"] for c in insp.get_columns("providers")} if "last_synced_at" not in cols: conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME")) + if insp.has_table("ai_accounts"): + cols = {c["name"] for c in insp.get_columns("ai_accounts")} + if "api_key_encrypted" not in cols: + conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) def get_session() -> Generator[Session, None, None]: diff --git a/app/models/asset.py b/app/models/asset.py index 9d19639..a76b3e8 100644 --- a/app/models/asset.py +++ b/app/models/asset.py @@ -143,7 +143,10 @@ class AIAccount(SQLModel, table=True): index=True, description="服务商:openai/minimax/kimi/agnes/deepseek" ) api_key: Optional[str] = Field( - default=None, description="API Key(后续版本将改为加密存储)" + default=None, description="(已弃用)明文 API Key,新数据用 api_key_encrypted" + ) + api_key_encrypted: Optional[str] = Field( + default=None, description="加密的 API Key(Fernet)" ) plan: Optional[str] = Field(default=None, description="订阅计划,如 ChatGPT Plus") balance: Optional[float] = Field(default=None, description="当前余额") diff --git a/app/routers/assets.py b/app/routers/assets.py index 9a503dc..84e4023 100644 --- a/app/routers/assets.py +++ b/app/routers/assets.py @@ -12,7 +12,7 @@ from app.core.security import require_api_key from app.database import get_session from app.models.asset import AssetStatus, AssetType from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate -from app.services import asset_service +from app.services import asset_service, sync_service router = APIRouter(prefix="/api/assets", tags=["assets"]) @@ -68,3 +68,12 @@ def update_asset( ) def delete_asset(asset_id: int, session: Session = Depends(get_session)): asset_service.delete_asset(session, asset_id) + + +@router.post( + "/{asset_id}/refresh-balance", + summary="刷新 AI 账号余额(调用适配器查询)", + dependencies=[Depends(require_api_key)], +) +def refresh_balance(asset_id: int, session: Session = Depends(get_session)) -> dict: + return sync_service.refresh_ai_balance(session, asset_id) diff --git a/app/schemas/asset.py b/app/schemas/asset.py index 683f03f..d80aa11 100644 --- a/app/schemas/asset.py +++ b/app/schemas/asset.py @@ -141,10 +141,19 @@ class DomainDetailRead(DomainDetailIn): asset_id: int -class AIAccountRead(AIAccountIn): +class AIAccountRead(SQLModel): + """AI 账号输出(不暴露 API Key 明文,仅返回 has_api_key)""" + id: int asset_id: int + provider: str + plan: Optional[str] = None + balance: Optional[float] = None + currency: str = "USD" + monthly_usage: Optional[float] = None + monthly_limit: Optional[float] = None last_synced_at: Optional[datetime] = None + has_api_key: bool = False class AssetRead(AssetBase): diff --git a/app/services/asset_service.py b/app/services/asset_service.py index 620c5b3..05466c7 100644 --- a/app/services/asset_service.py +++ b/app/services/asset_service.py @@ -68,7 +68,9 @@ def _detail_to_read(detail): if isinstance(detail, DomainDetail): return DomainDetailRead.model_validate(detail) if isinstance(detail, AIAccount): - return AIAccountRead.model_validate(detail) + read = AIAccountRead.model_validate(detail) + read.has_api_key = bool(detail.api_key_encrypted or detail.api_key) + return read return None @@ -103,6 +105,10 @@ def _build_detail(asset_type: AssetType, asset_id: int, detail_in): data["ssh_key_encrypted"] = crypto.encrypt(detail_in.ssh_key) data["password_encrypted"] = crypto.encrypt(detail_in.password) return model(asset_id=asset_id, **data) + if asset_type == AssetType.AI_AGENT: + data = detail_in.model_dump(exclude={"api_key"}) + data["api_key_encrypted"] = crypto.encrypt(detail_in.api_key) + return model(asset_id=asset_id, **data) return model(asset_id=asset_id, **detail_in.model_dump()) @@ -116,6 +122,12 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None: existing.ssh_key_encrypted = crypto.encrypt(detail_in.ssh_key) if detail_in.password is not None: existing.password_encrypted = crypto.encrypt(detail_in.password) + elif asset_type == AssetType.AI_AGENT: + data = detail_in.model_dump(exclude={"api_key"}) + for key, value in data.items(): + setattr(existing, key, value) + if detail_in.api_key is not None: + existing.api_key_encrypted = crypto.encrypt(detail_in.api_key) else: for key, value in detail_in.model_dump().items(): setattr(existing, key, value) diff --git a/app/services/sync_service.py b/app/services/sync_service.py index 4e17f33..f32c49f 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -16,6 +16,7 @@ from app.adapters import registry from app.adapters.base import BaseAdapter from app.core import crypto from app.models.asset import ( + AIAccount, Asset, AssetStatus, AssetType, @@ -223,3 +224,74 @@ def sync_provider(session: Session, provider_id: int) -> dict: session.commit() result["last_synced_at"] = provider.last_synced_at.isoformat() return result + + +# AI 服务商 slug -> sdk_type 映射(Provider 未配 sdk_type 时的回退推断) +_AI_SDK_MAP = { + "openai": "openai-api", + "deepseek": "deepseek-api", + "kimi": "moonshot-api", + "moonshot": "moonshot-api", + "minimax": "minimax-api", +} + + +def refresh_ai_balance(session: Session, asset_id: int) -> dict: + """刷新 AI 账号余额:解密 api_key → 适配器 get_account → 更新余额""" + asset = session.get(Asset, asset_id) + if not asset or asset.asset_type != AssetType.AI_AGENT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="非 AI 账号资产" + ) + ai = session.exec(select(AIAccount).where(AIAccount.asset_id == asset_id)).first() + if not ai: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="AI 账号详情不存在" + ) + + sdk_type = None + if asset.provider_id: + provider = session.get(Provider, asset.provider_id) + if provider: + sdk_type = provider.sdk_type + if not sdk_type: + sdk_type = _AI_SDK_MAP.get((ai.provider or "").lower()) + if not sdk_type or not registry.is_supported(sdk_type): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无法确定 AI 适配器({ai.provider})", + ) + + api_key = crypto.decrypt(ai.api_key_encrypted) or ai.api_key + if not api_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key" + ) + + adapter = registry.get_adapter(sdk_type, {"api_key": api_key}) + try: + acc = adapter.get_account() + except NotImplementedError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{sdk_type} 暂不支持余额查询", + ) + except HTTPException: + raise + except Exception as e: # noqa: BLE001 + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}" + ) + + if acc.balance is not None: + ai.balance = acc.balance + ai.currency = acc.currency + ai.last_synced_at = datetime.utcnow() + session.add(ai) + session.commit() + session.refresh(ai) + return { + "balance": ai.balance, + "currency": ai.currency, + "last_synced_at": ai.last_synced_at.isoformat(), + } diff --git a/static/js/app.js b/static/js/app.js index 5e62fc9..9f31666 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -254,6 +254,7 @@ const AssetsView = { {{ Fmt.STATUS_LABELS[a.status] }} + @@ -280,6 +281,7 @@ const AssetsView = {
+
@@ -287,7 +289,15 @@ const AssetsView = { `, setup() { - return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer }; + async function refreshBalance(a) { + if (!confirm('刷新「' + a.name + '」的余额?')) return; + try { + const r = await Api.post('/assets/' + a.id + '/refresh-balance', {}); + alert('余额已更新:' + r.balance + ' ' + r.currency); + await loadAssets(); + } catch (e) { alert('刷新失败:' + e.message); } + } + return { store, Fmt, reload: loadAssets, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance }; }, };