Files
vps-manager/app/services/asset_service.py
T
gouki f09c0f6e4b fix(review): 处理全量审查剩余项(M4-M8/M11/M12 + 21 条 low)
后端
- asset_service: delete_asset 先清 subdomains/site_certs 子表(SQLite 不强制
  FK 且 assets.id 无 AUTOINCREMENT,删最大 id 后新建会复用 rowid 导致孤儿误挂);
  metrics.db 清理改 best-effort,不再让已成功的删除返回 500
- provider_service: slug 唯一约束竞态补 IntegrityError 兜底(与 account_service 一致)
- account_service/sync_service: raise HTTPException 补 from e,保留原始异常链
- sync_service: 删死代码 _load_config;新增 BalanceUnsupported,平台无余额接口时
  计入 skipped 而不是静默当作失败;到期日写库前归一
- ai: 删掉 OpenAI 早已下线的 billing 余额接口实现,能力探测如实报「不支持」
- adapters/base: _overridden 改走 MRO,中间层基类实现的能力不再被误判为未实现
- core/timeutils: 删死代码 utcnow_iso(注释声称 Z 后缀,实际输出 +00:00)
- main: lifespan 退出时 await 被 cancel 的清理任务,避免 pending task 被销毁
- ssl_service: 写 site_certs/subdomains 前校验 asset_id 存在,杜绝悬空引用;
  到期阈值贯穿 _judge_status/_apply_probe/check_one/check_all_site_certs
- routers/credentials、accounts: 明文密码 GET 补 Cache-Control: no-store
- routers/stats: /stats/expiring 的 Query 描述与实际语义对齐(不设下界,含已过期)

Agent
- collector: 解析 sshd_config 的 Include(含 sshd_config.d/*.conf,按 sshd 语义
  「首次出现的值生效」),未显式配置的关键字按编译默认值判定,消除假 pass;
  防火墙检测改用 iptables -S 统计 -A 规则行并读 INPUT 策略(-L -n 恒有链头输出)
- reporter: 4xx(除 429)不再重试直接退出,429 优先按 Retry-After 退避

安全与部署
- core/security: require_agent_key 改为对外 fail-closed——tailnet/本机放行,
  外部来源未配 AGENT_KEY 时一律 401(此前全放行,而服务默认监听 0.0.0.0,
  公网上任何人可伪造监控指标与安全检查结果)
- main: 启动时把「未配 API_KEY/AGENT_KEY 则外部来源不可用」打进日志
- run.sh: HOST/PORT 可用环境变量覆盖,并说明默认 0.0.0.0 的权衡
- deploy/setup.sh: 新装时自动生成随机 API_KEY/AGENT_KEY 并提示同步到各 VPS;
  注册 vps-cert-check.timer
- deploy/vps-cert-check.{service,timer}: 新增每日 09:30 的站点证书到期探测
  (此前 check_site_certs.py 没有任何 unit,证书提醒不会自动跑)
- deploy/update.sh: sync_units 对 timer 用 enable --now(新增 timer 只 restart
  不会建开机自启软链),主服务 unit 变更后额外 restart;pip 安装失败不再被吞
- scripts/check_site_certs.py: threshold 参数真正生效并打印
- scripts/make_migration_bundle.py: 缺 assets.db 直接 abort;umask 077 + chmod 600
  (包内含 .env 的 MASTER_KEY 与 escrow,不能默认 644 落在 /tmp)
- scripts/restore_migration_bundle.py: 校验之外补必备文件预检,缺文件在触碰
  目标机状态之前就 abort

前端
- store: 拆分列表口径 assets 与统计口径 allAssets(无筛选时同源,不重复请求);
  删除已无消费者的 expiring/loadExpiring(每次 loadAll 白发一个请求)
- dashboard/monitor/providers/subscriptions/modals: 统计与计数改用 allAssets,
  资产页的筛选(含统计卡片点击跳转带的筛选)不再让其他视图的数字缩水
- modals: 账号同步后补 loadOverview;settings: 导入后改 loadAll
- api: countdownText 支持 unit/empty 选项,monitor/domains 各自的实现改为委托,
  assets/domains/subscriptions 的到期列不再直出「(-3天)」
- servers: cpu/mem/disk 百分比统一判空,采样缺项不再渲染「null%」
- assets: 同步 AI 余额的结果提示区分 skipped(平台无余额接口)与 failed
- modals: 修正凭据库头注释——2FA 动态码是选填 + 可事后补验,与实现一致

测试
- 新增 tests/test_agent_collector.py(8 条)、tests/test_security.py(11 条)
- 全量 113 passed;改动 JS 均通过 node --check,deploy 脚本通过 bash -n
- 浏览器实测:筛选态下总览/监控中心/平台页/付费资产的统计仍为全量口径,
  到期文案与 null 百分比渲染正确,console 零报错
2026-09-23 10:15:52 +00:00

517 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""资产业务逻辑:CRUD + 统计聚合
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
"""
from datetime import date, datetime, timedelta, timezone
from typing import List, Optional
from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import (
Account,
AIAccount,
Asset,
AssetStatus,
AssetType,
CloudflareDetail,
DomainDetail,
VPSDetail,
)
from app.models.provider import Provider
from app.schemas.asset import (
AIAccountRead,
AssetCreate,
AssetRead,
AssetUpdate,
CloudflareDetailRead,
DomainDetailRead,
VPSDetailRead,
)
import logging
logger = logging.getLogger("vps-manager.assets")
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
DETAIL_MAP = {
AssetType.VPS: ("vps_detail", VPSDetail),
AssetType.DOMAIN: ("domain_detail", DomainDetail),
AssetType.AI_AGENT: ("ai_detail", AIAccount),
AssetType.CLOUDFLARE: ("cloudflare_detail", CloudflareDetail),
}
# 允许排序的字段白名单
SORTABLE_FIELDS = {"expiry_date", "name", "created_at", "cost", "updated_at"}
# --------------------------------------------------------------------------- #
# 辅助函数
# --------------------------------------------------------------------------- #
def _days_to_expiry(expiry_date: Optional[date]) -> Optional[int]:
"""计算距到期天数(已过期为负数),无到期日返回 None"""
if not expiry_date:
return None
return (expiry_date - date.today()).days
def _get_detail(session: Session, asset: Asset):
"""根据资产类型读取对应详情记录"""
item = DETAIL_MAP.get(asset.asset_type)
if not item:
return None
_, model = item
return session.exec(select(model).where(model.asset_id == asset.id)).first()
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):
read = AIAccountRead.model_validate(detail)
read.has_api_key = bool(detail.api_key_encrypted or detail.api_key)
return read
if isinstance(detail, CloudflareDetail):
return CloudflareDetailRead.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 _account_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联账号标识(展示用)"""
if asset.account_id:
account = session.get(Account, asset.account_id)
if account:
return account.name
return None
def _validate_account(session: Session, account_id: Optional[int]) -> None:
"""account_id 非空时确认账号存在,防外键悬空"""
if account_id is not None and not session.get(Account, account_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号不存在:id={account_id}"
)
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)
read.account_name = _account_name(session, asset)
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
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)
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())
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)
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)
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
# 批量查 Account 名
account_ids = {a.account_id for a in assets if a.account_id}
account_map = {}
if account_ids:
for acc in session.exec(select(Account).where(Account.id.in_(account_ids))).all():
account_map[acc.id] = acc.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, "accounts": account_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)
read.account_name = batch.get("accounts", {}).get(asset.account_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_no_commit(session: Session, data: AssetCreate) -> Asset:
"""创建资产及详情(不 commit,由调用方统一提交)"""
item = DETAIL_MAP.get(data.asset_type)
detail_in = None
model = None
if item:
field, model = item
detail_in = getattr(data, field)
if detail_in is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"asset_type={data.asset_type.value} 需要提供 {field}",
)
asset_data = data.model_dump(
exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
)
_validate_account(session, asset_data.get("account_id"))
asset = Asset(**asset_data)
session.add(asset)
session.flush() # 获取 asset.id,但不提交
if model is not None and detail_in is not None:
detail = _build_detail(data.asset_type, asset.id, detail_in)
session.add(detail)
return asset
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:
"""读取单个资产(含详情)"""
asset = session.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
return _to_read(session, asset, _get_detail(session, asset))
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="资产不存在")
main_fields = data.model_dump(
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
)
if "account_id" in main_fields:
_validate_account(session, main_fields["account_id"])
for key, value in main_fields.items():
setattr(asset, key, value)
session.add(asset)
# 详情表:以更新后的 asset_type 为准
item = DETAIL_MAP.get(asset.asset_type)
if item:
field, model = item
detail_in = getattr(data, field)
existing = session.exec(
select(model).where(model.asset_id == asset.id)
).first()
if detail_in is not None:
if existing:
_apply_detail_update(asset.asset_type, existing, detail_in)
session.add(existing)
else:
detail = _build_detail(asset.asset_type, asset.id, detail_in)
session.add(detail)
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)
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="资产不存在")
detail = _get_detail(session, asset)
if detail is not None:
session.delete(detail)
_delete_child_rows(session, asset_id)
session.delete(asset)
session.commit()
logger.info("删除资产 id=%s name=%s", asset_id, asset.name)
# metrics.db 是独立库(可能正在被 Agent 写入而短暂锁定),
# 清理失败不应让已经成功的删除返回 500,否则前端会误报「删除失败」
try:
_cleanup_metrics_for_asset(asset_id)
except Exception as e: # noqa: BLE001
logger.warning(
"清理资产监控数据失败 asset_id=%s:%s(资产已删除,监控数据可后续手动清理)",
asset_id,
e,
)
def _delete_child_rows(session: Session, asset_id: int) -> None:
"""删除 assets.db 中引用该资产的子表行(subdomains / site_certs)
必须显式清理:SQLite 默认不强制外键(PRAGMA foreign_keys=0),残留行会变成孤儿;
而 assets.id 是 INTEGER PRIMARY KEY(无 AUTOINCREMENT),删掉最大 id 后
新建资产必然复用该 id,孤儿行会被误挂到不相干的新资产上。
"""
from sqlmodel import delete
from app.models.ssl import SiteCert, Subdomain
for model in (Subdomain, SiteCert):
session.exec(delete(model).where(model.asset_id == asset_id))
def _cleanup_metrics_for_asset(asset_id: int) -> None:
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog
使用批量 DELETE(而非逐行加载后删除):监控时序数据可能上万行,
逐行删除会全部载入内存且产生数万次 ORM 操作。
"""
from sqlmodel import delete
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):
ms.exec(delete(model).where(model.asset_id == asset_id))
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_batch(session, asset, batch)
result.append(read.model_dump(mode="json"))
return {
"count": len(result),
"exported_at": datetime.now(timezone.utc).isoformat(),
"assets": result,
}
def import_assets(session: Session, assets_data: list) -> dict:
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
"""
created = 0
updated = 0
errors = []
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
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}
def list_assets(
session: Session,
asset_type: Optional[AssetType] = None,
asset_status: Optional[AssetStatus] = None,
is_archived: Optional[bool] = None,
q: Optional[str] = None,
provider: Optional[str] = None,
sort: str = "expiry_date",
order: str = "asc",
) -> List[AssetRead]:
"""资产列表(筛选 + 搜索 + 排序)"""
stmt = select(Asset)
if asset_type is not None:
stmt = stmt.where(Asset.asset_type == asset_type)
if asset_status is not None:
stmt = stmt.where(Asset.status == asset_status)
if is_archived is not None:
stmt = stmt.where(Asset.is_archived == is_archived)
if provider:
# 兼容两种关联方式:provider slug 文本或 provider_id 外键
conds = [Asset.provider == provider]
if provider.isdigit():
conds.append(Asset.provider_id == int(provider))
stmt = stmt.where(or_(*conds))
if q:
pattern = f"%{q}%"
stmt = stmt.where(Asset.name.like(pattern) | Asset.provider.like(pattern))
sort_col = getattr(Asset, sort if sort in SORTABLE_FIELDS else "expiry_date")
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
assets = session.exec(stmt).all()
batch = _get_details_batch(session, assets)
return [_to_read_batch(session, a, batch) for a in assets]
# --------------------------------------------------------------------------- #
# 统计
# --------------------------------------------------------------------------- #
def get_overview(session: Session) -> dict:
"""资产总览:数量分布、异常数、到期预警、支出合计
性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。
"""
stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost)
rows = session.exec(stmt).all()
today = date.today()
by_type: dict = {}
by_status: dict = {}
abnormal = 0
expiring_30 = 0
overdue = 0
year_cost = 0.0
month_cost = 0.0
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 expiry_date:
days = (expiry_date - today).days
if status_val != AssetStatus.CANCELLED:
# 已过期单独计数:过了续费日却还没处理的资产,比 30 天内的更需要被看到,
# 不能因为「不在未来窗口内」就从统计里消失
if days < 0:
overdue += 1
elif days <= 30:
expiring_30 += 1
if expiry_date.year == today.year:
year_cost += cost
if expiry_date.month == today.month:
month_cost += cost
return {
"total": len(rows),
"by_type": by_type,
"by_status": by_status,
"abnormal_count": abnormal,
"expiring_30": expiring_30,
"overdue_count": overdue,
"year_cost": round(year_cost, 2),
"month_cost": round(month_cost, 2),
}
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
"""N 天内到期资产列表,含已过期未处理的(按剩余天数升序,最紧急在前)
过滤条件下推到 SQL 层:到期日上界取 today+days,但不设下界 —— 昨天到期的
资产比 30 天后到期的更该出现在倒计时里;只排除已注销资产。
"""
deadline = date.today() + timedelta(days=days)
assets = session.exec(
select(Asset).where(
Asset.expiry_date.is_not(None),
Asset.expiry_date <= deadline,
Asset.status.notin_([AssetStatus.CANCELLED]),
)
).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