feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""资产总览统计测试(get_overview)
|
||||
|
||||
覆盖的核心问题:「本年支出」是只把填了费用的资产加起来的和。cost 默认 0,
|
||||
所以没填费用和真的免费在数据上无法区分——合计因此天然是个下限,而不是准数。
|
||||
前端必须能拿到缺口大小(year_cost_missing)才能如实说明,否则用户会把下限
|
||||
当成实际支出。
|
||||
|
||||
顺带回归到期口径:已过期(overdue)与 30 天内到期(expiring_30)分开计数,
|
||||
已注销资产两边都不进。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_stats_overview.py -v
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
import app.models.asset # noqa: F401
|
||||
import app.models.credential # noqa: F401
|
||||
import app.models.monitor # noqa: F401
|
||||
import app.models.provider # noqa: F401
|
||||
import app.models.ssl # noqa: F401
|
||||
from app.models.asset import Asset, AssetStatus, AssetType
|
||||
from app.services.asset_service import get_overview
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine(tmp_path):
|
||||
eng = create_engine(
|
||||
f"sqlite:///{tmp_path / 'stats.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
def _add(engine, name, *, expiry=None, cost=0.0, status=AssetStatus.ACTIVE,
|
||||
asset_type=AssetType.VPS):
|
||||
with Session(engine) as s:
|
||||
s.add(Asset(
|
||||
name=name, asset_type=asset_type, provider="vultr",
|
||||
expiry_date=expiry, cost=cost, status=status,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
def _overview(engine):
|
||||
with Session(engine) as s:
|
||||
return get_overview(s)
|
||||
|
||||
|
||||
def test_empty_db_returns_all_contract_keys(engine):
|
||||
"""空库也必须给出完整结构:/api/stats/overview 是前端总览页的数据源,
|
||||
缺键会让 dashboard.js 渲染出 undefined"""
|
||||
got = _overview(engine)
|
||||
assert got == {
|
||||
"total": 0,
|
||||
"by_type": {},
|
||||
"by_status": {},
|
||||
"abnormal_count": 0,
|
||||
"expiring_30": 0,
|
||||
"overdue_count": 0,
|
||||
"year_cost": 0,
|
||||
"month_cost": 0,
|
||||
"year_cost_missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_year_cost_sums_only_filled_costs(engine):
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=12.5)
|
||||
_add(engine, "also-paid", expiry=today + timedelta(days=3), cost=7.25)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 19.75
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_unfilled_cost_is_counted_as_gap_not_zero_spend(engine):
|
||||
"""cost 默认 0,没填的资产既不进合计、也不能悄悄消失"""
|
||||
today = date.today()
|
||||
_add(engine, "paid", expiry=today, cost=10.0)
|
||||
_add(engine, "unfilled-a", expiry=today + timedelta(days=1))
|
||||
_add(engine, "unfilled-b", expiry=today + timedelta(days=2))
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 10.0
|
||||
assert got["year_cost_missing"] == 2
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_assets_without_expiry_do_not_enter_cost_window(engine):
|
||||
"""没有到期日的资产不属于任何年度窗口:既不该加进合计,也不该算作缺口,
|
||||
否则「另有 N 项未填费用」会把一批跟今年无关的资产算进去"""
|
||||
_add(engine, "no-expiry-unfilled")
|
||||
_add(engine, "no-expiry-paid", cost=99.0)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 0
|
||||
assert got["year_cost_missing"] == 0
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_other_year_expiry_excluded_from_cost(engine):
|
||||
"""跨年资产不进本年合计,也不进本年缺口"""
|
||||
this_year = date.today()
|
||||
other_year = date(this_year.year + 1, 6, 15)
|
||||
_add(engine, "this-year", expiry=this_year, cost=5.0)
|
||||
_add(engine, "next-year-unfilled", expiry=other_year)
|
||||
got = _overview(engine)
|
||||
assert got["year_cost"] == 5.0
|
||||
assert got["year_cost_missing"] == 0
|
||||
|
||||
|
||||
def test_month_cost_only_current_month(engine):
|
||||
today = date.today()
|
||||
# 取本月内的另一天:若今天是 1 号则往后挪,避免算出上/下月的日期
|
||||
same_month = today.replace(day=1) if today.day > 1 else today
|
||||
next_month = (today.replace(day=1) + timedelta(days=32)).replace(day=2)
|
||||
_add(engine, "this-month", expiry=same_month, cost=20.0)
|
||||
_add(engine, "next-month", expiry=next_month, cost=30.0)
|
||||
got = _overview(engine)
|
||||
assert got["month_cost"] == 20.0
|
||||
# 下月那笔仍属于本年
|
||||
assert got["year_cost"] == 50.0
|
||||
|
||||
|
||||
def test_year_cost_rounded_to_two_decimals(engine):
|
||||
"""币种最小单位是分,浮点累加的 0.1+0.2 尾巴不能漏到界面上"""
|
||||
today = date.today()
|
||||
_add(engine, "a", expiry=today, cost=0.1)
|
||||
_add(engine, "b", expiry=today, cost=0.2)
|
||||
assert _overview(engine)["year_cost"] == 0.3
|
||||
|
||||
|
||||
def test_overdue_and_expiring_counted_separately(engine):
|
||||
"""已过期比 30 天内到期更该被看见,不能因为不在未来窗口里就从统计消失"""
|
||||
today = date.today()
|
||||
_add(engine, "overdue", expiry=today - timedelta(days=1))
|
||||
_add(engine, "soon", expiry=today + timedelta(days=10))
|
||||
_add(engine, "far", expiry=today + timedelta(days=90))
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 1
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["total"] == 3
|
||||
|
||||
|
||||
def test_today_counts_as_expiring_not_overdue(engine):
|
||||
"""到期日就是今天:还能续费,属于倒计时而不是已错过"""
|
||||
_add(engine, "due-today", expiry=date.today())
|
||||
got = _overview(engine)
|
||||
assert got["expiring_30"] == 1
|
||||
assert got["overdue_count"] == 0
|
||||
|
||||
|
||||
def test_cancelled_assets_excluded_from_expiry_counts(engine):
|
||||
"""已注销资产不该再触发续费提醒"""
|
||||
today = date.today()
|
||||
_add(engine, "cancelled-overdue", expiry=today - timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
_add(engine, "cancelled-soon", expiry=today + timedelta(days=5),
|
||||
status=AssetStatus.CANCELLED)
|
||||
got = _overview(engine)
|
||||
assert got["overdue_count"] == 0
|
||||
assert got["expiring_30"] == 0
|
||||
# 但仍计入总数:台账要能查到已注销的历史资产
|
||||
assert got["total"] == 2
|
||||
|
||||
|
||||
def test_abnormal_counts_stopped_and_expired(engine):
|
||||
_add(engine, "stopped", status=AssetStatus.STOPPED)
|
||||
_add(engine, "expired", status=AssetStatus.EXPIRED)
|
||||
_add(engine, "active", status=AssetStatus.ACTIVE)
|
||||
got = _overview(engine)
|
||||
assert got["abnormal_count"] == 2
|
||||
assert got["by_status"] == {"stopped": 1, "expired": 1, "active": 1}
|
||||
|
||||
|
||||
def test_by_type_uses_enum_values_not_names(engine):
|
||||
"""前端按 'vps'/'domain' 匹配图标与文案,给出 'VPS' 会全部落到兜底分支"""
|
||||
_add(engine, "v1", asset_type=AssetType.VPS)
|
||||
_add(engine, "d1", asset_type=AssetType.DOMAIN)
|
||||
_add(engine, "d2", asset_type=AssetType.DOMAIN)
|
||||
got = _overview(engine)
|
||||
assert got["by_type"] == {"vps": 1, "domain": 2}
|
||||
Reference in New Issue
Block a user