feat: 平台预设字典+凭证加密+分库备份+Agent采集+移动端优先前端重做
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user