feat: AI账号api_key加密存储 + 余额刷新接口与前端按钮
This commit is contained in:
@@ -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]:
|
||||
|
||||
+4
-1
@@ -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="当前余额")
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+10
-1
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
+11
-1
@@ -254,6 +254,7 @@ const AssetsView = {
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
@@ -280,6 +281,7 @@ const AssetsView = {
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
@@ -287,7 +289,15 @@ const AssetsView = {
|
||||
</div>
|
||||
</div>`,
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user