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:
gouki
2026-09-22 22:26:42 +00:00
parent 7045843db8
commit 61372f7ceb
16 changed files with 561 additions and 34 deletions
+30 -3
View File
@@ -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 -5
View File
@@ -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
+3
View File
@@ -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,
) )
) )
+3
View File
@@ -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
View File
@@ -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]:
+11 -1
View File
@@ -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)}
+14 -7
View File
@@ -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)
+11 -4
View File
@@ -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:
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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));
+5 -2
View File
@@ -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; }
+3 -1
View File
@@ -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) {
+121 -1
View File
@@ -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"
}
+195
View File
@@ -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
+113
View File
@@ -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) == ""