feat: 平台预设字典+凭证加密+分库备份+Agent采集+移动端优先前端重做

This commit is contained in:
gouki
2026-08-01 23:10:31 +00:00
parent 1dac5d8735
commit e672086b67
31 changed files with 1792 additions and 421 deletions
+64 -13
View File
@@ -9,6 +9,7 @@ from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import (
AIAccount,
Asset,
@@ -17,6 +18,7 @@ from app.models.asset import (
DomainDetail,
VPSDetail,
)
from app.models.provider import Provider
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
@@ -56,19 +58,69 @@ def _get_detail(session: Session, asset: Asset):
return session.exec(select(model).where(model.asset_id == asset.id)).first()
def _to_read(asset: Asset, detail) -> AssetRead:
def _detail_to_read(detail):
"""详情模型转输出模型(VPS 计算凭证存在标记,不输出敏感明文)"""
if isinstance(detail, VPSDetail):
read = VPSDetailRead.model_validate(detail)
read.has_ssh_key = bool(detail.ssh_key_encrypted)
read.has_password = bool(detail.password_encrypted)
return read
if isinstance(detail, DomainDetail):
return DomainDetailRead.model_validate(detail)
if isinstance(detail, AIAccount):
return AIAccountRead.model_validate(detail)
return None
def _provider_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联平台显示名"""
if asset.provider_id:
provider = session.get(Provider, asset.provider_id)
if provider:
return provider.name
return None
def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = _provider_name(session, asset)
if isinstance(detail, VPSDetail):
read.vps_detail = VPSDetailRead.model_validate(detail)
read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail):
read.domain_detail = DomainDetailRead.model_validate(detail)
read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount):
read.ai_detail = AIAccountRead.model_validate(detail)
read.ai_detail = _detail_to_read(detail)
return read
def _build_detail(asset_type: AssetType, asset_id: int, detail_in):
"""构建详情模型(VPS 加密凭证)"""
_, model = DETAIL_MAP[asset_type]
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
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)
return model(asset_id=asset_id, **detail_in.model_dump())
def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
"""更新详情字段(VPS 加密凭证;凭证为 None 时保留原值)"""
if asset_type == AssetType.VPS:
data = detail_in.model_dump(exclude={"ssh_key", "password"})
for key, value in data.items():
setattr(existing, key, value)
if detail_in.ssh_key is not 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)
else:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@@ -96,12 +148,12 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
detail = None
if model is not None and detail_in is not None:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
session.commit()
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def get_asset(session: Session, asset_id: int) -> AssetRead:
@@ -109,7 +161,7 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
return _to_read(asset, _get_detail(session, asset))
return _to_read(session, asset, _get_detail(session, asset))
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
@@ -136,12 +188,11 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
).first()
if detail_in is not None:
if existing:
for key, value in detail_in.model_dump().items():
setattr(existing, key, value)
_apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
detail = existing
else:
detail = model(asset_id=asset.id, **detail_in.model_dump())
detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
else:
detail = existing
@@ -150,7 +201,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
session.refresh(asset)
if detail is not None:
session.refresh(detail)
return _to_read(asset, detail)
return _to_read(session, asset, detail)
def delete_asset(session: Session, asset_id: int) -> None:
@@ -190,7 +241,7 @@ def list_assets(
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all()
return [_to_read(a, _get_detail(session, a)) for a in assets]
return [_to_read(session, a, _get_detail(session, a)) for a in assets]
# --------------------------------------------------------------------------- #
@@ -241,6 +292,6 @@ def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
for a in assets:
delta = (a.expiry_date - today).days
if 0 <= delta <= days:
result.append(_to_read(a, _get_detail(session, a)))
result.append(_to_read(session, a, _get_detail(session, a)))
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
return result
+85
View File
@@ -0,0 +1,85 @@
"""平台/服务商业务逻辑"""
from typing import List, Optional
from fastapi import HTTPException, status
from sqlmodel import Session, select
from app.core import crypto
from app.models.provider import Provider, ProviderCategory
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
def _to_read(provider: Provider) -> ProviderRead:
read = ProviderRead.model_validate(provider)
read.has_api_config = bool(provider.api_config_encrypted)
return read
def list_providers(
session: Session,
category: Optional[ProviderCategory] = None,
enabled: Optional[bool] = None,
) -> List[ProviderRead]:
stmt = select(Provider)
if category is not None:
stmt = stmt.where(Provider.category == category)
if enabled is not None:
stmt = stmt.where(Provider.enabled == enabled)
stmt = stmt.order_by(Provider.category.asc(), Provider.slug.asc())
return [_to_read(p) for p in session.exec(stmt).all()]
def get_provider(session: Session, provider_id: int) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return _to_read(provider)
def create_provider(session: Session, data: ProviderCreate) -> ProviderRead:
exists = session.exec(select(Provider).where(Provider.slug == data.slug)).first()
if exists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"slug 已存在:{data.slug}"
)
provider_data = data.model_dump(exclude={"api_config"})
provider = Provider(**provider_data)
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def update_provider(
session: Session, provider_id: int, data: ProviderUpdate
) -> ProviderRead:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
fields = data.model_dump(exclude_unset=True, exclude={"api_config"})
for key, value in fields.items():
setattr(provider, key, value)
if data.api_config is not None:
provider.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(provider)
session.commit()
session.refresh(provider)
return _to_read(provider)
def delete_provider(session: Session, provider_id: int) -> None:
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
session.delete(provider)
session.commit()
def get_api_config_plain(session: Session, provider_id: int) -> Optional[str]:
"""获取平台 API 配置明文(受保护接口使用)"""
provider = session.get(Provider, provider_id)
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
return crypto.decrypt(provider.api_config_encrypted)