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:
@@ -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