feat: AI账号api_key加密存储 + 余额刷新接口与前端按钮

This commit is contained in:
gouki
2026-08-02 17:26:59 +00:00
parent 1e574315bd
commit 2e3e9f8b0b
7 changed files with 124 additions and 5 deletions
+4
View File
@@ -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
View File
@@ -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 KeyFernet"
)
plan: Optional[str] = Field(default=None, description="订阅计划,如 ChatGPT Plus")
balance: Optional[float] = Field(default=None, description="当前余额")
+10 -1
View File
@@ -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
View File
@@ -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):
+13 -1
View File
@@ -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)
+72
View File
@@ -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
View File
@@ -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 };
},
};