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")}
|
cols = {c["name"] for c in insp.get_columns("providers")}
|
||||||
if "last_synced_at" not in cols:
|
if "last_synced_at" not in cols:
|
||||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
|
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]:
|
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"
|
index=True, description="服务商:openai/minimax/kimi/agnes/deepseek"
|
||||||
)
|
)
|
||||||
api_key: Optional[str] = Field(
|
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")
|
plan: Optional[str] = Field(default=None, description="订阅计划,如 ChatGPT Plus")
|
||||||
balance: Optional[float] = Field(default=None, description="当前余额")
|
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.database import get_session
|
||||||
from app.models.asset import AssetStatus, AssetType
|
from app.models.asset import AssetStatus, AssetType
|
||||||
from app.schemas.asset import AssetCreate, AssetRead, AssetUpdate
|
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"])
|
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)):
|
def delete_asset(asset_id: int, session: Session = Depends(get_session)):
|
||||||
asset_service.delete_asset(session, asset_id)
|
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
|
asset_id: int
|
||||||
|
|
||||||
|
|
||||||
class AIAccountRead(AIAccountIn):
|
class AIAccountRead(SQLModel):
|
||||||
|
"""AI 账号输出(不暴露 API Key 明文,仅返回 has_api_key)"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
asset_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
|
last_synced_at: Optional[datetime] = None
|
||||||
|
has_api_key: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AssetRead(AssetBase):
|
class AssetRead(AssetBase):
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ def _detail_to_read(detail):
|
|||||||
if isinstance(detail, DomainDetail):
|
if isinstance(detail, DomainDetail):
|
||||||
return DomainDetailRead.model_validate(detail)
|
return DomainDetailRead.model_validate(detail)
|
||||||
if isinstance(detail, AIAccount):
|
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
|
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["ssh_key_encrypted"] = crypto.encrypt(detail_in.ssh_key)
|
||||||
data["password_encrypted"] = crypto.encrypt(detail_in.password)
|
data["password_encrypted"] = crypto.encrypt(detail_in.password)
|
||||||
return model(asset_id=asset_id, **data)
|
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())
|
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)
|
existing.ssh_key_encrypted = crypto.encrypt(detail_in.ssh_key)
|
||||||
if detail_in.password is not None:
|
if detail_in.password is not None:
|
||||||
existing.password_encrypted = crypto.encrypt(detail_in.password)
|
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:
|
else:
|
||||||
for key, value in detail_in.model_dump().items():
|
for key, value in detail_in.model_dump().items():
|
||||||
setattr(existing, key, value)
|
setattr(existing, key, value)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.adapters import registry
|
|||||||
from app.adapters.base import BaseAdapter
|
from app.adapters.base import BaseAdapter
|
||||||
from app.core import crypto
|
from app.core import crypto
|
||||||
from app.models.asset import (
|
from app.models.asset import (
|
||||||
|
AIAccount,
|
||||||
Asset,
|
Asset,
|
||||||
AssetStatus,
|
AssetStatus,
|
||||||
AssetType,
|
AssetType,
|
||||||
@@ -223,3 +224,74 @@ def sync_provider(session: Session, provider_id: int) -> dict:
|
|||||||
session.commit()
|
session.commit()
|
||||||
result["last_synced_at"] = provider.last_synced_at.isoformat()
|
result["last_synced_at"] = provider.last_synced_at.isoformat()
|
||||||
return result
|
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"><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">
|
<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==='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="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>
|
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -280,6 +281,7 @@ const AssetsView = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
<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==='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="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +289,15 @@ const AssetsView = {
|
|||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
setup() {
|
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