feat: 综合平台services多服务支持+SW网络优先缓存修复+适配层/定时任务完善
This commit is contained in:
+146
-61
@@ -3,7 +3,7 @@
|
||||
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -30,6 +30,10 @@ from app.schemas.asset import (
|
||||
VPSDetailRead,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("vps-manager.assets")
|
||||
|
||||
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
|
||||
DETAIL_MAP = {
|
||||
AssetType.VPS: ("vps_detail", VPSDetail),
|
||||
@@ -140,11 +144,49 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
|
||||
setattr(existing, key, value)
|
||||
|
||||
|
||||
def _get_details_batch(session: Session, assets: list) -> dict:
|
||||
"""批量预加载所有资产的详情和 Provider 名,避免 N+1 查询"""
|
||||
if not assets:
|
||||
return {}
|
||||
asset_ids = [a.id for a in assets]
|
||||
# 批量查 Provider 名
|
||||
provider_ids = {a.provider_id for a in assets if a.provider_id}
|
||||
provider_map = {}
|
||||
if provider_ids:
|
||||
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
|
||||
provider_map[p.id] = p.name
|
||||
# 批量查各类型详情
|
||||
detail_map = {}
|
||||
for asset_type, (_, model) in DETAIL_MAP.items():
|
||||
typed_ids = [a.id for a in assets if a.asset_type == asset_type]
|
||||
if typed_ids:
|
||||
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
|
||||
detail_map[d.asset_id] = d
|
||||
return {"providers": provider_map, "details": detail_map}
|
||||
|
||||
|
||||
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
|
||||
"""用批量预加载的数据组装 AssetRead(避免逐个查询)"""
|
||||
read = AssetRead.model_validate(asset)
|
||||
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
||||
read.provider_name = batch["providers"].get(asset.provider_id)
|
||||
detail = batch["details"].get(asset.id)
|
||||
if isinstance(detail, VPSDetail):
|
||||
read.vps_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, DomainDetail):
|
||||
read.domain_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, AIAccount):
|
||||
read.ai_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, CloudflareDetail):
|
||||
read.cloudflare_detail = _detail_to_read(detail)
|
||||
return read
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情"""
|
||||
def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
|
||||
"""创建资产及详情(不 commit,由调用方统一提交)"""
|
||||
item = DETAIL_MAP.get(data.asset_type)
|
||||
detail_in = None
|
||||
model = None
|
||||
@@ -162,17 +204,21 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
)
|
||||
asset = Asset(**asset_data)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,但不提交
|
||||
|
||||
detail = None
|
||||
if model is not None and detail_in is not None:
|
||||
detail = _build_detail(data.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
session.commit()
|
||||
session.refresh(detail)
|
||||
return asset
|
||||
|
||||
return _to_read(session, asset, detail)
|
||||
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情(单次事务提交)"""
|
||||
asset = _create_asset_no_commit(session, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
logger.info("创建资产 id=%s name=%s type=%s", asset.id, asset.name, asset.asset_type)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
@@ -183,8 +229,8 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(仅更新传入字段)"""
|
||||
def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate) -> Asset:
|
||||
"""更新资产主表及详情(不 commit,由调用方统一提交)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -197,7 +243,6 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
session.add(asset)
|
||||
|
||||
# 详情表:以更新后的 asset_type 为准
|
||||
detail = None
|
||||
item = DETAIL_MAP.get(asset.asset_type)
|
||||
if item:
|
||||
field, model = item
|
||||
@@ -209,22 +254,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
if existing:
|
||||
_apply_detail_update(asset.asset_type, existing, detail_in)
|
||||
session.add(existing)
|
||||
detail = existing
|
||||
else:
|
||||
detail = _build_detail(asset.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
else:
|
||||
detail = existing
|
||||
return asset
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(单次事务提交)"""
|
||||
asset = _update_asset_no_commit(session, asset_id, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
if detail is not None:
|
||||
session.refresh(detail)
|
||||
return _to_read(session, asset, detail)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def delete_asset(session: Session, asset_id: int) -> None:
|
||||
"""删除资产及其详情"""
|
||||
"""删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -233,46 +278,72 @@ def delete_asset(session: Session, asset_id: int) -> None:
|
||||
session.delete(detail)
|
||||
session.delete(asset)
|
||||
session.commit()
|
||||
logger.info("删除资产 id=%s name=%s", asset_id, asset.name)
|
||||
_cleanup_metrics_for_asset(asset_id)
|
||||
|
||||
|
||||
def _cleanup_metrics_for_asset(asset_id: int) -> None:
|
||||
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog"""
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||
|
||||
with Session(metrics_engine) as ms:
|
||||
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
|
||||
for row in ms.exec(select(model).where(model.asset_id == asset_id)).all():
|
||||
ms.delete(row)
|
||||
ms.commit()
|
||||
|
||||
|
||||
def export_assets(session: Session) -> dict:
|
||||
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = []
|
||||
for asset in assets:
|
||||
read = _to_read(session, asset, _get_detail(session, asset))
|
||||
read = _to_read_batch(session, asset, batch)
|
||||
result.append(read.model_dump(mode="json"))
|
||||
return {
|
||||
"count": len(result),
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"assets": result,
|
||||
}
|
||||
|
||||
|
||||
def import_assets(session: Session, assets_data: list) -> dict:
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)"""
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
|
||||
|
||||
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
|
||||
"""
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
update_asset(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
create_asset(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
try:
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
if errors:
|
||||
session.rollback()
|
||||
return {"created": 0, "updated": 0, "errors": errors}
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
session.rollback()
|
||||
raise
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@@ -301,15 +372,20 @@ 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(session, a, _get_detail(session, a)) for a in assets]
|
||||
batch = _get_details_batch(session, assets)
|
||||
return [_to_read_batch(session, a, batch) for a in assets]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 统计
|
||||
# --------------------------------------------------------------------------- #
|
||||
def get_overview(session: Session) -> dict:
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计
|
||||
|
||||
性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。
|
||||
"""
|
||||
stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost)
|
||||
rows = session.exec(stmt).all()
|
||||
today = date.today()
|
||||
|
||||
by_type: dict = {}
|
||||
@@ -319,22 +395,24 @@ def get_overview(session: Session) -> dict:
|
||||
year_cost = 0.0
|
||||
month_cost = 0.0
|
||||
|
||||
for a in assets:
|
||||
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1
|
||||
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1
|
||||
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
for asset_type, status_val, expiry_date, cost in rows:
|
||||
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
|
||||
sval = status_val.value if hasattr(status_val, "value") else status_val
|
||||
by_type[tval] = by_type.get(tval, 0) + 1
|
||||
by_status[sval] = by_status.get(sval, 0) + 1
|
||||
if status_val in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
abnormal += 1
|
||||
if a.expiry_date:
|
||||
days = (a.expiry_date - today).days
|
||||
if expiry_date:
|
||||
days = (expiry_date - today).days
|
||||
if 0 <= days <= 30:
|
||||
expiring_30 += 1
|
||||
if a.expiry_date.year == today.year:
|
||||
year_cost += a.cost
|
||||
if a.expiry_date.month == today.month:
|
||||
month_cost += a.cost
|
||||
if expiry_date.year == today.year:
|
||||
year_cost += cost
|
||||
if expiry_date.month == today.month:
|
||||
month_cost += cost
|
||||
|
||||
return {
|
||||
"total": len(assets),
|
||||
"total": len(rows),
|
||||
"by_type": by_type,
|
||||
"by_status": by_status,
|
||||
"abnormal_count": abnormal,
|
||||
@@ -345,13 +423,20 @@ def get_overview(session: Session) -> dict:
|
||||
|
||||
|
||||
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
|
||||
"""N 天内到期资产列表(按剩余天数升序)"""
|
||||
"""N 天内到期资产列表(按剩余天数升序)
|
||||
|
||||
性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。
|
||||
"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
result = []
|
||||
for a in assets:
|
||||
delta = (a.expiry_date - today).days
|
||||
if 0 <= delta <= days:
|
||||
result.append(_to_read(session, a, _get_detail(session, a)))
|
||||
deadline = today + timedelta(days=days)
|
||||
assets = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date >= today,
|
||||
Asset.expiry_date <= deadline,
|
||||
)
|
||||
).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = [_to_read_batch(session, a, batch) for a in assets]
|
||||
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user