fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。
- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
(Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
(dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
(check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
前端走 Fmt.countdownText
验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
This commit is contained in:
+30
-3
@@ -7,7 +7,30 @@
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from typing import Optional
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_date(value: Any) -> Optional[date]:
|
||||||
|
"""把到期日归一化为 date 对象(接受 date / datetime / ISO 字符串,解析失败返回 None)
|
||||||
|
|
||||||
|
各厂商返回形态不一('2027-03-15'、'2027-03-15T00:00:00Z'、'2027-03-15T00:00:00+00:00')。
|
||||||
|
必须统一成 date 再交给上层:expiry_date 是 SQLAlchemy Date 列,
|
||||||
|
直接塞字符串会在写库时抛 TypeError,导致整批同步在循环末尾的 commit 上全量失败。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(text[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -38,12 +61,16 @@ class NormalizedDomain:
|
|||||||
external_id: str
|
external_id: str
|
||||||
domain_name: str
|
domain_name: str
|
||||||
registrar: Optional[str] = None
|
registrar: Optional[str] = None
|
||||||
expiry_date: Optional[str] = None # ISO 日期字符串 YYYY-MM-DD
|
expiry_date: Optional[date] = None # 到期日 date;厂商原始值请过 parse_iso_date
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
raw: dict = field(default_factory=dict)
|
raw: dict = field(default_factory=dict)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return asdict(self)
|
d = asdict(self)
|
||||||
|
# date 不可直接 JSON 序列化,转回 ISO 字符串(对外形态与旧契约一致)
|
||||||
|
if d.get("expiry_date") is not None:
|
||||||
|
d["expiry_date"] = d["expiry_date"].isoformat()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunn
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.adapters.base import BaseAdapter, NormalizedDomain
|
from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date
|
||||||
from app.adapters.registry import register
|
from app.adapters.registry import register
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class CloudflareAdapter(BaseAdapter):
|
|||||||
return {"ok": False, "message": str(e)}
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
def list_domains(self) -> list:
|
def list_domains(self) -> list:
|
||||||
# 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期)
|
# 先拉取 Registrar 域名到期日映射(domain_name -> date)
|
||||||
expiry_map = self._registrar_expiry_map()
|
expiry_map = self._registrar_expiry_map()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -69,7 +69,7 @@ class CloudflareAdapter(BaseAdapter):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _registrar_expiry_map(self) -> dict:
|
def _registrar_expiry_map(self) -> dict:
|
||||||
"""拉取 Cloudflare Registrar 域名到期日映射 {domain_name: YYYY-MM-DD}
|
"""拉取 Cloudflare Registrar 域名到期日映射 {domain_name: date}
|
||||||
|
|
||||||
流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。
|
流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。
|
||||||
若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。
|
若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。
|
||||||
@@ -92,6 +92,9 @@ class CloudflareAdapter(BaseAdapter):
|
|||||||
name = d.get("id") or d.get("domain_name") or ""
|
name = d.get("id") or d.get("domain_name") or ""
|
||||||
expires_at = d.get("expires_at")
|
expires_at = d.get("expires_at")
|
||||||
if name and expires_at:
|
if name and expires_at:
|
||||||
# expires_at 为 ISO8601 时间戳,截取日期部分
|
# expires_at 为 ISO8601 时间戳,必须解析成 date 再交给上层:
|
||||||
expiry[name] = expires_at[:10]
|
# 直接塞截取的字符串会在写库时触发 SQLite Date 类型错误,整批同步失败
|
||||||
|
parsed = parse_iso_date(expires_at)
|
||||||
|
if parsed:
|
||||||
|
expiry[name] = parsed
|
||||||
return expiry
|
return expiry
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.adapters.base import (
|
|||||||
BaseAdapter,
|
BaseAdapter,
|
||||||
NormalizedDomain,
|
NormalizedDomain,
|
||||||
NormalizedVPS,
|
NormalizedVPS,
|
||||||
|
parse_iso_date,
|
||||||
)
|
)
|
||||||
from app.adapters.registry import register
|
from app.adapters.registry import register
|
||||||
|
|
||||||
@@ -92,6 +93,8 @@ class DigitalOceanAdapter(BaseAdapter):
|
|||||||
external_id=d.get("name"),
|
external_id=d.get("name"),
|
||||||
domain_name=d.get("name"),
|
domain_name=d.get("name"),
|
||||||
registrar="digitalocean",
|
registrar="digitalocean",
|
||||||
|
# /domains 用 expiry_date,部分账号下会返回 expires_at,两者都兼容
|
||||||
|
expiry_date=parse_iso_date(d.get("expiry_date") or d.get("expires_at")),
|
||||||
raw=d,
|
raw=d,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.adapters.base import (
|
|||||||
BaseAdapter,
|
BaseAdapter,
|
||||||
NormalizedDomain,
|
NormalizedDomain,
|
||||||
NormalizedVPS,
|
NormalizedVPS,
|
||||||
|
parse_iso_date,
|
||||||
)
|
)
|
||||||
from app.adapters.registry import register
|
from app.adapters.registry import register
|
||||||
|
|
||||||
@@ -93,6 +94,8 @@ class VultrAdapter(BaseAdapter):
|
|||||||
external_id=d.get("domain"),
|
external_id=d.get("domain"),
|
||||||
domain_name=d.get("domain"),
|
domain_name=d.get("domain"),
|
||||||
registrar="vultr",
|
registrar="vultr",
|
||||||
|
# 非 Vultr 注册的域名 expiry_date 为空,解析失败统一归为 None
|
||||||
|
expiry_date=parse_iso_date(d.get("expiry_date")),
|
||||||
raw=d,
|
raw=d,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-4
@@ -4,11 +4,14 @@
|
|||||||
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
from sqlmodel import Session, SQLModel, create_engine
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
|
logger = logging.getLogger("vps-manager.db")
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DATA_DIR = BASE_DIR / "data"
|
DATA_DIR = BASE_DIR / "data"
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -367,21 +370,28 @@ def _backfill_account_credentials(conn) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2. 无账号的 AI 资产按 provider 挂靠(account_id 外键 + account 字符串兼容)
|
# 2. 无账号的 AI 资产按 provider 挂靠(account_id 外键 + account 字符串兼容)
|
||||||
|
# 注意:SQLAlchemy Enum 列存的是成员名('AI_AGENT')而不是枚举值('ai_agent'),
|
||||||
|
# 写值会让这个分支静默零命中(同文件 _migrate_account_credentials_to_vault 用的 'PASSWORD' 即成员名)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
|
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
|
||||||
"JOIN ai_accounts ai ON ai.asset_id = a.id "
|
"JOIN ai_accounts ai ON ai.asset_id = a.id "
|
||||||
"WHERE a.asset_type = 'ai_agent' AND a.account_id IS NULL AND (a.account IS NULL OR a.account = '')"
|
"WHERE a.asset_type = 'AI_AGENT' AND a.account_id IS NULL AND (a.account IS NULL OR a.account = '')"
|
||||||
)
|
)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
attached = 0
|
||||||
for asset_id, provider in rows:
|
for asset_id, provider in rows:
|
||||||
if not provider:
|
if not provider:
|
||||||
continue
|
continue
|
||||||
acc_id = _ensure_account(provider, provider)
|
# 沿用步骤 1 的「{slug}-默认」命名:同平台已有账号时直接复用。
|
||||||
|
# 用裸 provider 名会建出「deepseek」与「deepseek-默认」两个语义重复的账号,
|
||||||
|
# 而新建那个没凭证,在 UI 上点同步/测试会直接报「缺少凭证配置」
|
||||||
|
acc_id = _ensure_account(f"{provider}-默认", provider)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.text("UPDATE assets SET account = :acc, account_id = :aid WHERE id = :id"),
|
sa.text("UPDATE assets SET account = :acc, account_id = :aid WHERE id = :id"),
|
||||||
{"acc": provider, "aid": acc_id, "id": asset_id},
|
{"acc": provider, "aid": acc_id, "id": asset_id},
|
||||||
)
|
)
|
||||||
|
attached += 1
|
||||||
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
|
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
|
||||||
key_row = conn.execute(
|
key_row = conn.execute(
|
||||||
sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"),
|
sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"),
|
||||||
@@ -396,11 +406,14 @@ def _backfill_account_credentials(conn) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
sa.text(
|
sa.text(
|
||||||
"UPDATE accounts SET api_config_encrypted = :cfg "
|
"UPDATE accounts SET api_config_encrypted = :cfg "
|
||||||
"WHERE name = :name AND COALESCE(platform,'') = :platform "
|
"WHERE id = :aid "
|
||||||
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
|
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
|
||||||
),
|
),
|
||||||
{"cfg": cfg_enc, "name": provider, "platform": provider},
|
{"cfg": cfg_enc, "aid": acc_id},
|
||||||
)
|
)
|
||||||
|
if attached:
|
||||||
|
# 这段迁移每次启动都会重跑(幂等),有实际改动时必须留痕迹便于事后核对
|
||||||
|
logger.info("凭证下沉迁移:本次将 %s 个 AI 资产挂靠到账号", attached)
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session, None, None]:
|
def get_session() -> Generator[Session, None, None]:
|
||||||
|
|||||||
@@ -22,13 +22,23 @@ def send_telegram(message: str) -> dict:
|
|||||||
json={
|
json={
|
||||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||||
"text": message,
|
"text": message,
|
||||||
"parse_mode": "HTML",
|
# 不传 parse_mode:消息里嵌的是资产名/平台名等用户数据,
|
||||||
|
# 含 < > & 时 HTML 解析会让整条消息 400 失败(提醒根本发不出去),
|
||||||
|
# 而纯文本提醒本来也不需要任何标签
|
||||||
"disable_web_page_preview": True,
|
"disable_web_page_preview": True,
|
||||||
},
|
},
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return {"ok": True, "channel": "telegram", "message": "发送成功"}
|
return {"ok": True, "channel": "telegram", "message": "发送成功"}
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
# Telegram 把拒收原因放在响应体里(bot 被拉黑、chat_id 错等),
|
||||||
|
# 只留状态码无法排查,把正文一并带回
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"channel": "telegram",
|
||||||
|
"message": f"HTTP {e.response.status_code}:{e.response.text[:200]}",
|
||||||
|
}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return {"ok": False, "channel": "telegram", "message": str(e)}
|
return {"ok": False, "channel": "telegram", "message": str(e)}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
|||||||
return expiring
|
return expiring
|
||||||
|
|
||||||
|
|
||||||
|
def day_text(days: int) -> str:
|
||||||
|
"""单项到期文案(提醒消息与 check_renewals 脚本共用)
|
||||||
|
|
||||||
|
days 可以为负(已过期),直接拼成「{days} 天后到期」会得到「-3 天后到期」;
|
||||||
|
前端对应的统一文案是 Fmt.countdownText,两边保持一致。
|
||||||
|
"""
|
||||||
|
if days < 0:
|
||||||
|
return f"已过期 {-days} 天"
|
||||||
|
if days == 0:
|
||||||
|
return "今天到期"
|
||||||
|
return f"{days} 天后"
|
||||||
|
|
||||||
|
|
||||||
def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
||||||
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
|
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
|
||||||
if not expiring:
|
if not expiring:
|
||||||
@@ -46,13 +59,7 @@ def build_renewal_message(expiring: list, threshold_days: int) -> str:
|
|||||||
head = f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)"
|
head = f"⏰ 资产续费提醒({threshold_days} 天内到期,共 {len(expiring)} 项)"
|
||||||
lines = [head, ""]
|
lines = [head, ""]
|
||||||
for asset, days in expiring:
|
for asset, days in expiring:
|
||||||
if days < 0:
|
lines.append(f"• {asset.name} [{asset.provider}] ({day_text(days)}) 到期 {asset.expiry_date}")
|
||||||
tag = f"(已过期 {-days} 天)"
|
|
||||||
elif days == 0:
|
|
||||||
tag = "(今天到期)"
|
|
||||||
else:
|
|
||||||
tag = f"({days} 天后)"
|
|
||||||
lines.append(f"• {asset.name} [{asset.provider}] {tag} 到期 {asset.expiry_date}")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
|
|||||||
logger = logging.getLogger("vps-manager.sync")
|
logger = logging.getLogger("vps-manager.sync")
|
||||||
|
|
||||||
from app.adapters import registry
|
from app.adapters import registry
|
||||||
from app.adapters.base import BaseAdapter
|
from app.adapters.base import BaseAdapter, parse_iso_date
|
||||||
from app.core import crypto
|
from app.core import crypto
|
||||||
from app.core.timeutils import utcnow
|
from app.core.timeutils import utcnow
|
||||||
from app.models.asset import (
|
from app.models.asset import (
|
||||||
@@ -227,11 +227,14 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
|||||||
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||||
created = updated = 0
|
created = updated = 0
|
||||||
for dom in adapter.list_domains():
|
for dom in adapter.list_domains():
|
||||||
|
# 适配器契约上 expiry_date 已是 date;这里再归一一次,
|
||||||
|
# 防止旧适配器/自定义适配器返回字符串时整个 commit 抛 TypeError
|
||||||
|
expiry = parse_iso_date(dom.expiry_date)
|
||||||
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
|
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
|
||||||
if existing:
|
if existing:
|
||||||
existing.status = _norm_status(dom.status)
|
existing.status = _norm_status(dom.status)
|
||||||
if dom.expiry_date:
|
if expiry:
|
||||||
existing.expiry_date = dom.expiry_date
|
existing.expiry_date = expiry
|
||||||
if account_id:
|
if account_id:
|
||||||
existing.account_id = account_id
|
existing.account_id = account_id
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
@@ -252,7 +255,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, ac
|
|||||||
external_id=dom.external_id,
|
external_id=dom.external_id,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
status=_norm_status(dom.status),
|
status=_norm_status(dom.status),
|
||||||
expiry_date=dom.expiry_date,
|
expiry_date=expiry,
|
||||||
)
|
)
|
||||||
session.add(asset)
|
session.add(asset)
|
||||||
session.flush() # 获取 asset.id,统一在循环外提交
|
session.flush() # 获取 asset.id,统一在循环外提交
|
||||||
@@ -297,11 +300,15 @@ def sync_account(session: Session, account_id: int) -> dict:
|
|||||||
try:
|
try:
|
||||||
result["vps"] = _sync_vps(session, provider, adapter, account.id)
|
result["vps"] = _sync_vps(session, provider, adapter, account.id)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
# 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续,
|
||||||
|
# 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500
|
||||||
|
session.rollback()
|
||||||
result["vps_error"] = str(e)
|
result["vps_error"] = str(e)
|
||||||
if caps["list_domains"]:
|
if caps["list_domains"]:
|
||||||
try:
|
try:
|
||||||
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
|
session.rollback()
|
||||||
result["domains_error"] = str(e)
|
result["domains_error"] = str(e)
|
||||||
if caps["get_account"]:
|
if caps["get_account"]:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ def main() -> int:
|
|||||||
init_db()
|
init_db()
|
||||||
with Session(assets_engine) as session:
|
with Session(assets_engine) as session:
|
||||||
result = renewal_service.check_renewals(session, threshold_days=threshold)
|
result = renewal_service.check_renewals(session, threshold_days=threshold)
|
||||||
|
# days 可为负(已过期),汇总行拆开统计,单项文案复用 day_text(与提醒消息一致)
|
||||||
|
overdue = sum(1 for item in result["expiring"] if item["days"] < 0)
|
||||||
print(
|
print(
|
||||||
f"[续费检查] 阈值 {result['threshold_days']} 天 | 即将到期 {result['expiring_count']} 项"
|
f"[续费检查] 阈值 {result['threshold_days']} 天 | 已过期 {overdue} 项 | "
|
||||||
|
f"即将到期 {result['expiring_count'] - overdue} 项"
|
||||||
)
|
)
|
||||||
for item in result["expiring"]:
|
for item in result["expiring"]:
|
||||||
print(f" - {item['name']} [{item['provider']}] {item['days']} 天后到期")
|
print(
|
||||||
|
f" - {item['name']} [{item['provider']}] "
|
||||||
|
f"{renewal_service.day_text(item['days'])}(到期 {item['expiry_date']})"
|
||||||
|
)
|
||||||
for n in result.get("notifications", []):
|
for n in result.get("notifications", []):
|
||||||
status = "成功" if n["ok"] else f"失败:{n['message']}"
|
status = "成功" if n["ok"] else f"失败:{n['message']}"
|
||||||
print(f" 通知[{n['channel']}]: {status}")
|
print(f" 通知[{n['channel']}]: {status}")
|
||||||
|
|||||||
+5
-2
@@ -405,9 +405,12 @@ const AccountsViewModal = {
|
|||||||
await Promise.all([loadAccounts(), loadAssets()]);
|
await Promise.all([loadAccounts(), loadAssets()]);
|
||||||
} catch (e) { alert('同步失败:' + e.message); }
|
} catch (e) { alert('同步失败:' + e.message); }
|
||||||
}
|
}
|
||||||
// 每次打开重置展开状态
|
// 每次打开重置展开状态;关闭时连带收掉密码弹窗
|
||||||
|
// 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发:
|
||||||
|
// 不主动清理就会留下 OTP 轮询定时器与驻留的明文密码,重开弹窗还会复现旧内容
|
||||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
if (show) { for (const k of Object.keys(expanded)) delete expanded[k]; }
|
||||||
|
else closePwd();
|
||||||
});
|
});
|
||||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
||||||
async function viewPwd(a) {
|
async function viewPwd(a) {
|
||||||
|
|||||||
+14
-2
@@ -413,15 +413,20 @@ async function copyAccountPassword(a) {
|
|||||||
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
|
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
|
||||||
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
|
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
|
||||||
const otpState = Vue.reactive({});
|
const otpState = Vue.reactive({});
|
||||||
|
// 取码请求代号:stopOtp 会推进代号,让在途请求返回后不再写状态/重启倒计时
|
||||||
|
// (否则「请求在途时收起」会让已收起的条目在后台持续轮询动态码)
|
||||||
|
const otpGen = {};
|
||||||
async function fetchOtp(id) {
|
async function fetchOtp(id) {
|
||||||
// 先经 Proxy 创建占位对象,再取回响应式代理。
|
// 先经 Proxy 创建占位对象,再取回响应式代理。
|
||||||
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
|
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
|
||||||
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
|
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
|
||||||
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
|
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
|
||||||
const s = otpState[id];
|
const s = otpState[id];
|
||||||
|
const gen = (otpGen[id] = (otpGen[id] || 0) + 1);
|
||||||
s.loading = true;
|
s.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await Api.get('/credentials/' + id + '/otp');
|
const r = await Api.get('/credentials/' + id + '/otp');
|
||||||
|
if (otpGen[id] !== gen) return; // 期间已被 stopOtp 取消
|
||||||
s.code = r.code;
|
s.code = r.code;
|
||||||
s.expiresIn = r.expires_in;
|
s.expiresIn = r.expires_in;
|
||||||
if (s.timer) clearInterval(s.timer);
|
if (s.timer) clearInterval(s.timer);
|
||||||
@@ -431,11 +436,18 @@ async function fetchOtp(id) {
|
|||||||
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
|
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (e) { store.error = e.message; }
|
} catch (e) { store.error = e.message; }
|
||||||
finally { s.loading = false; }
|
finally { if (otpGen[id] === gen) s.loading = false; }
|
||||||
}
|
}
|
||||||
function stopOtp(id) {
|
function stopOtp(id) {
|
||||||
|
otpGen[id] = (otpGen[id] || 0) + 1;
|
||||||
const s = otpState[id];
|
const s = otpState[id];
|
||||||
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
|
if (!s) return;
|
||||||
|
if (s.timer) { clearInterval(s.timer); s.timer = null; }
|
||||||
|
// 停表同时清掉旧码:otpState 是全局且条目常驻,留着会让下次展开
|
||||||
|
// 在重新取码前闪一下已过期的码(用户有可能把旧码复制走)
|
||||||
|
s.code = '';
|
||||||
|
s.expiresIn = 0;
|
||||||
|
s.loading = false;
|
||||||
}
|
}
|
||||||
function stopAllOtp() {
|
function stopAllOtp() {
|
||||||
for (const id of Object.keys(otpState)) stopOtp(Number(id));
|
for (const id of Object.keys(otpState)) stopOtp(Number(id));
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ const SettingsView = {
|
|||||||
notifyResult.value = '检查中…';
|
notifyResult.value = '检查中…';
|
||||||
try {
|
try {
|
||||||
const r = await Api.post('/notify/renewals?send=true', {});
|
const r = await Api.post('/notify/renewals?send=true', {});
|
||||||
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
// days 可为负(已过期),文案统一走 Fmt.countdownText,避免直出「-3 天后」
|
||||||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
const overdue = r.expiring.filter(i => i.days < 0).length;
|
||||||
|
let text = (overdue ? '已过期 ' + overdue + ' 项 + ' : '') +
|
||||||
|
'即将到期 ' + (r.expiring_count - overdue) + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||||
|
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + Fmt.countdownText(i.days)).join('\n');
|
||||||
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||||||
notifyResult.value = text;
|
notifyResult.value = text;
|
||||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ const VaultView = {
|
|||||||
const open = forceOpen ? true : !expanded[c.id];
|
const open = forceOpen ? true : !expanded[c.id];
|
||||||
expanded[c.id] = open;
|
expanded[c.id] = open;
|
||||||
if (!c.has_otp) return;
|
if (!c.has_otp) return;
|
||||||
if (open && !otpState[c.id]) fetchOtp(c.id);
|
// 每次展开都重取:otpState 是全局的、收起只清 timer 不删条目,
|
||||||
|
// 若按「不存在才拉」判断,再次展开会显示上一次的旧码且倒计时冻在 0
|
||||||
|
if (open) fetchOtp(c.id);
|
||||||
if (!open) stopOtp(c.id);
|
if (!open) stopOtp(c.id);
|
||||||
}
|
}
|
||||||
async function reveal(c) {
|
async function reveal(c) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
运行:.venv/bin/pytest tests/test_credential_migration.py -v
|
运行:.venv/bin/pytest tests/test_credential_migration.py -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,7 +18,15 @@ from sqlmodel import Session, SQLModel, create_engine, select
|
|||||||
import app.database as db
|
import app.database as db
|
||||||
from app.core import crypto
|
from app.core import crypto
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.asset import AIAccount, Account, Asset, CloudflareDetail, DomainDetail, VPSDetail
|
from app.models.asset import (
|
||||||
|
AIAccount,
|
||||||
|
Account,
|
||||||
|
Asset,
|
||||||
|
AssetType,
|
||||||
|
CloudflareDetail,
|
||||||
|
DomainDetail,
|
||||||
|
VPSDetail,
|
||||||
|
)
|
||||||
from app.models.credential import Credential, LoginType
|
from app.models.credential import Credential, LoginType
|
||||||
from app.models.provider import Provider
|
from app.models.provider import Provider
|
||||||
from app.models.ssl import SiteCert, Subdomain
|
from app.models.ssl import SiteCert, Subdomain
|
||||||
@@ -215,3 +224,114 @@ def test_migration_backfills_otp_verified(legacy_engine):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
assert rows[0][1] is not None # 已绑 2FA → 视为已验证
|
assert rows[0][1] is not None # 已绑 2FA → 视为已验证
|
||||||
assert rows[1][1] is None # 未绑定 → 保持空
|
assert rows[1][1] is None # 未绑定 → 保持空
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_orphan_ai_asset(
|
||||||
|
engine, slug: str = "openai", name: str = "OpenAI", api_key: str = "sk-test-123"
|
||||||
|
) -> int:
|
||||||
|
"""造迁移前现场:一个没挂账号的 AI 资产 + 其 ai_accounts 详情"""
|
||||||
|
with Session(engine) as s:
|
||||||
|
provider = s.exec(select(Provider).where(Provider.slug == slug)).first()
|
||||||
|
if not provider:
|
||||||
|
provider = Provider(slug=slug, name=name, category="ai_agent", sdk_type=f"{slug}-api")
|
||||||
|
s.add(provider)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(provider)
|
||||||
|
asset = Asset(
|
||||||
|
name=f"{name} 账号",
|
||||||
|
asset_type=AssetType.AI_AGENT,
|
||||||
|
provider=slug,
|
||||||
|
provider_id=provider.id,
|
||||||
|
external_id=f"ai-{slug}",
|
||||||
|
)
|
||||||
|
s.add(asset)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(asset)
|
||||||
|
s.add(
|
||||||
|
AIAccount(
|
||||||
|
asset_id=asset.id,
|
||||||
|
provider=slug,
|
||||||
|
api_key_encrypted=crypto.encrypt(api_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
assert asset.account_id is None # 前置:确实还没挂靠
|
||||||
|
return asset.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_attaches_ai_assets_using_enum_member_name(legacy_engine):
|
||||||
|
"""AI 资产挂靠迁移必须用 Enum 成员名 'AI_AGENT'
|
||||||
|
|
||||||
|
SQLAlchemy Enum 列存的是成员名而不是枚举值('ai_agent'),
|
||||||
|
迁移 SQL 写值会静默零命中——AI 资产永远挂不上账号,且没有任何报错。
|
||||||
|
"""
|
||||||
|
engine = legacy_engine
|
||||||
|
asset_id = _seed_orphan_ai_asset(engine)
|
||||||
|
|
||||||
|
# 先把存储形态钉住:真库里 asset_type 存的就是成员名
|
||||||
|
with engine.connect() as conn:
|
||||||
|
assert conn.execute(sa.text("SELECT asset_type FROM assets")).scalar() == "AI_AGENT"
|
||||||
|
|
||||||
|
db._migrate_assets_db()
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
asset = s.get(Asset, asset_id)
|
||||||
|
accounts = s.exec(select(Account)).all()
|
||||||
|
assert len(accounts) == 1
|
||||||
|
acc = accounts[0]
|
||||||
|
# 按 ai_accounts.provider 找/建账号(沿用「{slug}-默认」命名),回填外键与历史字符串字段
|
||||||
|
assert (acc.name, acc.platform) == ("openai-默认", "openai")
|
||||||
|
assert asset.account_id == acc.id
|
||||||
|
assert asset.account == "openai"
|
||||||
|
# 账号尚无 API 配置时,把资产的 api_key 重组 JSON 后加密写入
|
||||||
|
assert json.loads(crypto.decrypt(acc.api_config_encrypted)) == {"api_key": "sk-test-123"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_reuses_existing_platform_account(legacy_engine):
|
||||||
|
"""同平台已有账号(步骤 1 由平台凭证下沉建出)时必须复用,不建重复账号
|
||||||
|
|
||||||
|
否则会同时存在「deepseek」与「deepseek-默认」两个账号,
|
||||||
|
新建那个没凭证,在 UI 上点同步/测试只会得到「缺少凭证配置」。
|
||||||
|
"""
|
||||||
|
engine = legacy_engine
|
||||||
|
with Session(engine) as s:
|
||||||
|
s.add(
|
||||||
|
Provider(
|
||||||
|
slug="deepseek",
|
||||||
|
name="DeepSeek",
|
||||||
|
category="ai_agent",
|
||||||
|
sdk_type="deepseek-api",
|
||||||
|
api_config_encrypted=crypto.encrypt(json.dumps({"api_key": "sk-platform"})),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
asset_id = _seed_orphan_ai_asset(engine, "deepseek", "DeepSeek", "sk-asset")
|
||||||
|
|
||||||
|
db._migrate_assets_db()
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
accounts = s.exec(select(Account)).all()
|
||||||
|
assert len(accounts) == 1 # 复用而不是另建一个
|
||||||
|
assert accounts[0].name == "deepseek-默认"
|
||||||
|
assert s.get(Asset, asset_id).account_id == accounts[0].id
|
||||||
|
# 账号已有平台级配置时不得被资产级 key 覆写
|
||||||
|
assert json.loads(crypto.decrypt(accounts[0].api_config_encrypted)) == {
|
||||||
|
"api_key": "sk-platform"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_ai_assets_is_idempotent(legacy_engine):
|
||||||
|
"""重跑不得重复建账号,也不得覆写已有的账号级 API 配置"""
|
||||||
|
engine = legacy_engine
|
||||||
|
_seed_orphan_ai_asset(engine)
|
||||||
|
|
||||||
|
db._migrate_assets_db()
|
||||||
|
db._migrate_assets_db()
|
||||||
|
db._migrate_assets_db()
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
assert len(s.exec(select(Account)).all()) == 1
|
||||||
|
assert len(s.exec(select(Asset)).all()) == 1
|
||||||
|
assert json.loads(crypto.decrypt(s.exec(select(Account)).first().api_config_encrypted)) == {
|
||||||
|
"api_key": "sk-test-123"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""域名到期日链路与同步容错测试
|
||||||
|
|
||||||
|
覆盖三处回归:
|
||||||
|
|
||||||
|
1. 适配器产出的 expiry_date 必须是 date(NormalizedDomain 契约)。
|
||||||
|
直接塞 ISO 字符串会在写库时抛 TypeError,而 _sync_domains 的 commit 在循环外,
|
||||||
|
一个域名解析失败就会让整批域名同步全量回滚。
|
||||||
|
2. _sync_domains 写库前再归一一次,兼容仍返回字符串的旧/自定义适配器。
|
||||||
|
3. sync_account 单个能力失败后必须 rollback:否则 Session 停在 pending-rollback,
|
||||||
|
后面更新 last_synced_at 的 commit 会抛 PendingRollbackError,接口直接 500,
|
||||||
|
真实错误被掩盖成一条无信息的服务器异常。
|
||||||
|
|
||||||
|
运行:.venv/bin/pytest tests/test_domain_expiry.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
# create_all 需要 metadata 里齐备所有被外键引用的表(accounts.credential_id → credentials、
|
||||||
|
# assets.provider_id → providers 等),而 app/models/__init__.py 不导入子模块,
|
||||||
|
# 所以必须显式导入全部模型模块;否则单跑本文件会抛 NoReferencedTableError
|
||||||
|
# (跑全量套件时因其他测试先导入了模型而意外通过)
|
||||||
|
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.adapters.base import NormalizedDomain, parse_iso_date
|
||||||
|
from app.adapters.cloudflare import CloudflareAdapter
|
||||||
|
from app.adapters.digitalocean import DigitalOceanAdapter
|
||||||
|
from app.adapters.vultr import VultrAdapter
|
||||||
|
from app.models.asset import Account, Asset, DomainDetail
|
||||||
|
from app.models.provider import Provider
|
||||||
|
from app.services import sync_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def engine(tmp_path):
|
||||||
|
eng = create_engine(
|
||||||
|
f"sqlite:///{tmp_path / 'expiry.db'}", connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(eng)
|
||||||
|
yield eng
|
||||||
|
eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 到期日归一化 ----------------------------- #
|
||||||
|
def test_parse_iso_date_accepts_provider_shapes():
|
||||||
|
"""各厂商返回形态不一,统一解析为 date"""
|
||||||
|
assert parse_iso_date("2027-03-15") == date(2027, 3, 15)
|
||||||
|
assert parse_iso_date("2027-03-15T00:00:00Z") == date(2027, 3, 15)
|
||||||
|
assert parse_iso_date("2027-03-15T00:00:00+00:00") == date(2027, 3, 15)
|
||||||
|
assert parse_iso_date(date(2027, 3, 15)) == date(2027, 3, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_iso_date_bad_input_returns_none():
|
||||||
|
"""脏数据归 None,不能让整批同步炸掉"""
|
||||||
|
assert parse_iso_date(None) is None
|
||||||
|
assert parse_iso_date("") is None
|
||||||
|
assert parse_iso_date(" ") is None
|
||||||
|
assert parse_iso_date("not-a-date") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloudflare_domain_expiry_is_date():
|
||||||
|
"""Cloudflare Registrar 返回 ISO8601 时间戳,必须解析成 date 再交给上层"""
|
||||||
|
adapter = CloudflareAdapter({"api_token": "x"})
|
||||||
|
accounts = {"result": [{"id": "acc1"}]}
|
||||||
|
registrar = {"result": [{"id": "example.com", "expires_at": "2027-03-15T00:00:00Z"}]}
|
||||||
|
zones = {
|
||||||
|
"result": [{"id": "z1", "name": "example.com", "status": "active"}],
|
||||||
|
"result_info": {"total_pages": 1, "page": 1},
|
||||||
|
}
|
||||||
|
with patch.object(CloudflareAdapter, "_get", side_effect=[accounts, registrar, zones]):
|
||||||
|
domains = adapter.list_domains()
|
||||||
|
assert len(domains) == 1
|
||||||
|
assert isinstance(domains[0].expiry_date, date)
|
||||||
|
assert domains[0].expiry_date == date(2027, 3, 15)
|
||||||
|
# to_dict 对外仍是 ISO 字符串(JSON 可序列化,形态与旧契约一致)
|
||||||
|
assert domains[0].to_dict()["expiry_date"] == "2027-03-15"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vultr_and_do_map_domain_expiry():
|
||||||
|
"""Vultr / DO 的域名到期日此前完全没映射,导致这两家域名永远不进续费提醒"""
|
||||||
|
vultr = VultrAdapter({"api_key": "x"})
|
||||||
|
vultr_resp = {
|
||||||
|
"domains": [{"domain": "a.com", "expiry_date": "2027-01-02T00:00:00+00:00"}],
|
||||||
|
"meta": {"links": {}},
|
||||||
|
}
|
||||||
|
with patch.object(VultrAdapter, "_get", return_value=vultr_resp):
|
||||||
|
assert vultr.list_domains()[0].expiry_date == date(2027, 1, 2)
|
||||||
|
|
||||||
|
do = DigitalOceanAdapter({"api_key": "x"})
|
||||||
|
do_resp = {"domains": [{"name": "b.com", "expiry_date": "2027-01-03T00:00:00Z"}], "links": {}}
|
||||||
|
with patch.object(DigitalOceanAdapter, "_get", return_value=do_resp):
|
||||||
|
assert do.list_domains()[0].expiry_date == date(2027, 1, 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- 同步写库 ----------------------------- #
|
||||||
|
class _FakeAdapter:
|
||||||
|
"""最小适配器:只实现同步链路用到的接口"""
|
||||||
|
|
||||||
|
required_config: list = []
|
||||||
|
|
||||||
|
def __init__(self, domains):
|
||||||
|
self.config = {}
|
||||||
|
self._domains = domains
|
||||||
|
|
||||||
|
def capabilities(self):
|
||||||
|
return {"list_vps": False, "list_domains": True, "get_account": False}
|
||||||
|
|
||||||
|
def list_domains(self):
|
||||||
|
return self._domains
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_provider_account(engine) -> tuple[int, int]:
|
||||||
|
with Session(engine) as s:
|
||||||
|
provider = Provider(
|
||||||
|
slug="cloudflare", name="Cloudflare", category="cloudflare", sdk_type="cloudflare-api"
|
||||||
|
)
|
||||||
|
s.add(provider)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(provider)
|
||||||
|
account = Account(name="me@example.com", platform="cloudflare")
|
||||||
|
s.add(account)
|
||||||
|
s.commit()
|
||||||
|
s.refresh(account)
|
||||||
|
return provider.id, account.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_domains_coerces_string_expiry(engine):
|
||||||
|
"""适配器若仍返回字符串,写库前归一为 date,不应整批失败"""
|
||||||
|
provider_id, account_id = _seed_provider_account(engine)
|
||||||
|
adapter = _FakeAdapter(
|
||||||
|
[
|
||||||
|
NormalizedDomain(
|
||||||
|
external_id="z1",
|
||||||
|
domain_name="a.com",
|
||||||
|
registrar="cloudflare",
|
||||||
|
expiry_date="2027-03-15", # 故意给字符串
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with Session(engine) as s:
|
||||||
|
result = sync_service._sync_domains(s, s.get(Provider, provider_id), adapter, account_id)
|
||||||
|
assert result == {"created": 1, "updated": 0}
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
asset = s.exec(select(Asset)).first()
|
||||||
|
assert asset.expiry_date == date(2027, 3, 15)
|
||||||
|
detail = s.exec(select(DomainDetail)).first()
|
||||||
|
assert detail.domain_name == "a.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_domains_updates_existing_expiry(engine):
|
||||||
|
"""重复同步走更新分支:到期日按新值刷新,不新建资产"""
|
||||||
|
provider_id, account_id = _seed_provider_account(engine)
|
||||||
|
|
||||||
|
def _run(expiry):
|
||||||
|
adapter = _FakeAdapter(
|
||||||
|
[NormalizedDomain(external_id="z1", domain_name="a.com", expiry_date=expiry)]
|
||||||
|
)
|
||||||
|
with Session(engine) as s:
|
||||||
|
return sync_service._sync_domains(s, s.get(Provider, provider_id), adapter, account_id)
|
||||||
|
|
||||||
|
assert _run("2027-03-15") == {"created": 1, "updated": 0}
|
||||||
|
assert _run("2028-03-15") == {"created": 0, "updated": 1}
|
||||||
|
with Session(engine) as s:
|
||||||
|
assert s.exec(select(Asset)).first().expiry_date == date(2028, 3, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_account_survives_failed_capability(engine, monkeypatch):
|
||||||
|
"""单个能力写库失败后必须回滚,否则 last_synced_at 的 commit 抛 PendingRollbackError"""
|
||||||
|
provider_id, account_id = _seed_provider_account(engine)
|
||||||
|
# domain_name=None 违反 Asset.name NOT NULL → flush 失败,Session 进入 pending-rollback
|
||||||
|
adapter = _FakeAdapter([NormalizedDomain(external_id="z1", domain_name=None)])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sync_service,
|
||||||
|
"_build_adapter_for_account",
|
||||||
|
lambda session, account: (session.get(Provider, provider_id), adapter),
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
result = sync_service.sync_account(s, account_id) # 修复前此处抛 PendingRollbackError
|
||||||
|
|
||||||
|
# 真实错误被如实带回,而不是被 500 掩盖
|
||||||
|
assert result.get("domains") is None
|
||||||
|
assert "domains_error" in result
|
||||||
|
assert result["last_synced_at"]
|
||||||
|
with Session(engine) as s:
|
||||||
|
assert s.get(Account, account_id).last_synced_at is not None
|
||||||
|
# 失败的那批不残留半条数据
|
||||||
|
assert s.exec(select(Asset)).first() is None
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""提醒送达与到期文案测试
|
||||||
|
|
||||||
|
覆盖两处回归:
|
||||||
|
|
||||||
|
1. Telegram 不能带 parse_mode=HTML:消息里嵌的是资产名/平台名等用户数据,
|
||||||
|
含 < > & 时整条消息会被 Telegram 拒收(400),提醒静默发不出去;
|
||||||
|
失败时还必须把响应正文带回,否则只看到状态码根本无从排查。
|
||||||
|
2. days 可以为负(已过期),文案统一走 renewal_service.day_text,
|
||||||
|
不能直出「-37 天后到期」;前端对应 Fmt.countdownText,两边一致。
|
||||||
|
|
||||||
|
运行:.venv/bin/pytest tests/test_notification.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import notification_service, renewal_service
|
||||||
|
|
||||||
|
|
||||||
|
class _OkResp:
|
||||||
|
status_code = 200
|
||||||
|
text = "ok"
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_telegram_omits_parse_mode(monkeypatch):
|
||||||
|
"""用户数据里的 < > & 不能让整条提醒被拒收"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
captured["url"] = url
|
||||||
|
captured["payload"] = json
|
||||||
|
return _OkResp()
|
||||||
|
|
||||||
|
monkeypatch.setattr(notification_service.httpx, "post", fake_post)
|
||||||
|
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr(settings, "TELEGRAM_CHAT_ID", "123")
|
||||||
|
|
||||||
|
text = "⏰ 资产续费提醒\n• <测试&资产> [a&b] (已过期 3 天) 到期 2026-08-15"
|
||||||
|
result = notification_service.send_telegram(text)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
# 关键:不再声明 HTML 解析模式,纯文本原样送达
|
||||||
|
assert "parse_mode" not in captured["payload"]
|
||||||
|
assert captured["payload"]["text"] == text
|
||||||
|
assert captured["payload"]["chat_id"] == "123"
|
||||||
|
assert captured["payload"]["disable_web_page_preview"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_telegram_failure_returns_response_body(monkeypatch):
|
||||||
|
"""拒收原因在响应体里,只留状态码无法排查"""
|
||||||
|
request = httpx.Request("POST", "https://api.telegram.org/bottok/sendMessage")
|
||||||
|
response = httpx.Response(400, request=request, text='{"description":"chat not found"}')
|
||||||
|
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
raise httpx.HTTPStatusError("400 Bad Request", request=request, response=response)
|
||||||
|
|
||||||
|
monkeypatch.setattr(notification_service.httpx, "post", fake_post)
|
||||||
|
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr(settings, "TELEGRAM_CHAT_ID", "123")
|
||||||
|
|
||||||
|
result = notification_service.send_telegram("x")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["channel"] == "telegram"
|
||||||
|
assert "400" in result["message"]
|
||||||
|
assert "chat not found" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_telegram_skips_when_unconfigured(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "TELEGRAM_BOT_TOKEN", "")
|
||||||
|
result = notification_service.send_telegram("x")
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert "未配置" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"days,expected",
|
||||||
|
[(-37, "已过期 37 天"), (-1, "已过期 1 天"), (0, "今天到期"), (1, "1 天后"), (30, "30 天后")],
|
||||||
|
)
|
||||||
|
def test_day_text_never_prints_negative(days, expected):
|
||||||
|
assert renewal_service.day_text(days) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsset:
|
||||||
|
"""build_renewal_message 只用到 name / provider / expiry_date"""
|
||||||
|
|
||||||
|
def __init__(self, name, provider, expiry_date):
|
||||||
|
self.name = name
|
||||||
|
self.provider = provider
|
||||||
|
self.expiry_date = expiry_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_renewal_message_separates_overdue():
|
||||||
|
"""已过期与即将到期分开计数,且不出现「-37 天后」这类文案"""
|
||||||
|
msg = renewal_service.build_renewal_message(
|
||||||
|
[
|
||||||
|
(_FakeAsset("ChatGPT Plus", "openai", "2026-08-15"), -37),
|
||||||
|
(_FakeAsset("vps-1", "vultr", "2026-10-01"), 11),
|
||||||
|
],
|
||||||
|
threshold_days=30,
|
||||||
|
)
|
||||||
|
assert "已过期 1 项" in msg
|
||||||
|
assert "30 天内到期 1 项" in msg
|
||||||
|
assert "(已过期 37 天)" in msg
|
||||||
|
assert "(11 天后)" in msg
|
||||||
|
assert "-37" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_renewal_message_empty_when_nothing_expiring():
|
||||||
|
assert renewal_service.build_renewal_message([], threshold_days=30) == ""
|
||||||
Reference in New Issue
Block a user