重构记录:博客通知系统改造:统一闸门、本地静音与攻击告警
作者:leap
日期:2026-09-10
个人博客:https://vue2.xyz
前言
我的博客后台有一套邮件通知:新评论、新设备登录、登录失败、网盘被下载、服务器资源告警。功能看着是全的,但它这半年一直往两个相反的方向惹我——本地起服务调试时,CPU 和内存的告警邮件一封接一封;而真被扫描器扫的时候,它安静得像根本没装。这篇记录我把这 6 个散落的发信点收敛成一套闸门、补上环境静音与攻击告警的全过程。
一、先看清旧版到底出了什么问题
旧版的发信点一共 6 处,每处各写各的 HTML 模板、各查各的开关:
| 场景 | 位置 | 检查总开关? | 类型开关 | 冷却 |
|---|---|---|---|---|
| 新评论 | 评论提交 | 检查 | comment_notify_enabled |
无 |
| 评论审核通过 | 后台审核 | 检查 | 上者 + comment_notify_pending_only |
无 |
| 新设备登录 | 登录成功 | 检查 | new_device_notify_enabled |
有 |
| 连续登录失败 | 登录失败 | 检查 | login_failure_notify_enabled |
有 |
| 网盘被下载 | 下载接口 | 检查 | pan_notify_enabled |
30 秒去重 |
| 服务器资源告警 | 定时线程 | 不检查 | 无 | 有 |
一眼就能看到问题:只有资源告警那一行没检查总开关。它的判断条件写的是"配置了收件邮箱就发",完全不看 notify_enabled。也就是说我把后台的「启用通知」关掉,CPU / 内存 / 磁盘的告警照样往邮箱里灌——这就是本地调试被轰炸的真正原因,我一直以为是"监控太敏感",其实是我关的那个开关根本没被读。
顺着往下查,又翻出三个问题:
- 时间差 8 小时。登录类告警的模板里直接展示
datetime.now(timezone.utc),我收到"新设备登录"的邮件,时间看着像 8 小时前发生的; - 模板插值没转义。登录失败告警把请求里的
username直接拼进 HTML——攻击者可以用一个恶意用户名,往我的告警邮件里塞钓鱼链接; - 本地跑
systemctl。服务无响应时自动重启的逻辑在 Windows 本地也照跑,每轮检查刷一条注定失败的错误日志。
而我最想补的那个能力,恰好完全没有:被攻击时不会通知。路径黑名单扫描、恶意 UA 拦截、IP 封禁,这三条拦截链路全是 403 了事,一条通知都没有。我能知道有人爆破后台(登录失败会发信),但有人扫我整站,我毫不知情。
二、第一层:统一模板层,域名只从环境变量来
改造的第一个决定:不再有第二份模板。
原来的 6 个模板各有各的字段,有的带后台链接,有的连时间都没有,全都缺站点名和域名。代码重复倒在其次,真正的麻烦是——我改一次样式要改 6 个地方,改漏一个就是"有个邮件长得不一样"。
所以先抽一个渲染函数出来:
def build_email(title: str, rows, *, ctx: dict, action=None,
level: str = 'info', intro: str = '') -> str:
"""构造统一邮件 HTML。
ctx 由 utils.site.get_site_context(db) 提供(站点名 / 域名 / 环境标识)
rows 是 (标签, 值) 序列;值默认 HTML 转义,要放链接请用 link()
"""
color, level_label = _LEVELS.get(level, _LEVELS['info'])
tr_html = []
for label, value in rows:
cell = str(value) if isinstance(value, SafeHtml) else html_lib.escape(str(value))
tr_html.append(f'<tr><td>{html_lib.escape(str(label))}</td>'
f'<td>{cell}</td></tr>')
# …… 头部站点名 + 等级徽标 + 环境徽标,正文表格,按钮,页脚原理:所有会变的内容默认走 html_lib.escape。需要放链接的场景,用一个 SafeHtml 标记类隔离:
class SafeHtml(str):
"""由本模块构造、已完成转义的 HTML 片段;build_email 不再二次转义。"""
__slots__ = ()
def link(text, url) -> SafeHtml:
return SafeHtml(f'<a href="{html_lib.escape(str(url), quote=True)}">{html_lib.escape(str(text))}</a>')优势:调用方"想把链接塞进邮件"时,只能通过 link() / lines() 这两个出口,而它们自己会转义。也就是说转义漏不了——因为除了这两个函数,没有任何办法构造出 SafeHtml。我不用再靠"记得写 escape"这种纪律来保证安全。
域名则是另一件事,我给自己定了条硬规则:业务代码里不允许出现域名。所有域名、站点名、站内链接都从一个地方取:
# utils/site.py —— 域名/站点名的唯一出口
from config import ENV_LABEL, IS_LOCAL_ENV, SITE_URL
def site_host() -> str:
"""站点域名(不硬编码,取自环境变量 SITE_URL)"""
return urlsplit(SITE_URL).hostname or SITE_URL
def admin_url(path: str = "") -> str:
return f"{SITE_URL}/admin{path}"SITE_URL 是环境变量:本地启动不注入,默认就是 http://localhost:3000;服务器上由 systemd 注入真实域名。换域名不用改一行业务代码。
三、第二层:统一闸门,一条路径决定发不发
模板统一了,但"该不该发"还是散在 6 个地方。这是更要命的一半——旧版的坑就出在这儿。
我把判断收敛成一个函数:
def maybe_send(cfg, kind, subject, html, *, to='',
cooldown_sec=0, cooldown_key='') -> str:
"""统一发送闸门,返回 SENT / SKIPPED / FAILED。"""
recipient = to or cfg.get('notify_email', '')
if not recipient:
return SKIPPED
if str(cfg.get('notify_enabled', '')).lower() != 'true':
return SKIPPED # 总开关:取消默认=关
if is_env_muted(cfg, kind):
return SKIPPED # 环境静音
key = cooldown_key or f'{kind}:{subject}'
if cooldown_active(key, cooldown_sec):
return SKIPPED # 冷却期内
if rate_limited(cfg):
return SKIPPED # 全局限流
if not send_email(recipient, subject, html, cfg):
return FAILED
_cooldown[key] = time.time()
return SENTflowchart LR
A["各场景触发"] --> B{"总开关 + 类型开关"}
B -->|关| X["丢弃"]
B -->|开| C{"环境静音"}
C -->|本地且命中类别| X
C -->|放行| D{"冷却 / 限流"}
D -->|命中| X
D -->|放行| E["send_email()"]原理:闸门顺序是刻意的——收件人 → 总开关 → 环境静音 → 全局限流 → 冷却 → 实发。总开关放在最前面,是为了让"关掉通知"这件事真的意味着全都不发(旧版就栽在这一步)。冷却和限流放最后,因为它们依赖前面的判定结果,也最便宜。
优势:新增一个发信场景时,我只需要组装内容、调一次 maybe_send,四道闸门自动生效。再也不可能出现"某个场景忘了检查总开关"这种事——因为没有任何一条绕过闸门的发送路径。(之前那个坑,本质上是"闸门有 6 份实现",那我漏掉一份是迟早的事。)
环境静音则是我这次最想要的东西。判定不引入新配置,直接看已有的环境变量:
# config.py
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", ""}
def _is_local_site_url(url: str) -> bool:
host = (urlsplit(url).hostname or "").lower()
return host in _LOCAL_HOSTS or host.endswith(".local")
IS_LOCAL_ENV = _is_local_site_url(SITE_URL)通知按用途分成四类,本地默认静音前两类:
| 类别 | 覆盖场景 | 本地默认 |
|---|---|---|
resource |
CPU / 内存 / 磁盘超阈值 | 静音 |
service |
后端无响应 / 数据库异常 | 静音 |
security |
登录失败、新设备、攻击告警 | 发送 |
business |
新评论、审核通过、网盘下载 | 发送 |
原理:本地跑 nuxt dev 时编译、热重载都会把 CPU 顶起来,这类指标在开发机上毫无参考价值,静音掉最省心。而安全类和业务类保留——因为本地调试时我正好需要它们来验证模板渲染对不对。
这里我踩过一个隐性的坑值得单说:环境判定是 config.py 模块加载时算一次的,如果只在前端猜 localhost,就会出现"前后端两处口径"的老问题(这个项目历史上就吃过这个亏)。所以我让后端算好,通过 GET /api/admin/monitor/config 把 detected_env 回给前端展示,前端不自己判断。
四、第三层:攻击告警,采集与判定必须分离
补上攻击告警是这个改造里最有意思的部分,因为它有一个绕不开的约束:安全中间件跑在每一个请求上。
我先试过最直接的做法——拦截到攻击就发一封邮件。写下来就意识到不对:被扫描的时候一秒可能几十个请求,按这个写法,扫描器会直接把我邮箱打成筛子,同时 SMTP 的秒级阻塞会拖垮整个站点。
所以最终拆成两半:
def record_event(kind: str, ip: str, path: str = '', ua: str = '',
rule: str = '', detail: str = '') -> None:
"""记录一次拦截事件。
只能做内存操作——本函数位于请求路径上,任何 IO 都会拖慢全站。
"""
ip = _safe_ip(ip)
if not ip or kind not in KIND_LABELS:
return
now = time.time()
with _lock:
bucket = _buckets.setdefault((kind, ip), _new_bucket(kind, ip))
bucket['ts'].append(now)
if path or ua:
bucket['samples'].append({'t': now, 'path': path, 'ua': ua})中间件那边只加一行:
if path_is_blocked(path, get_enabled_path_rules(db)):
_record('path_blocked', ip, path=path, ua=ua, rule='路径黑名单')
decision = JSONResponse(status_code=403, content={"detail": "Forbidden"})真正的判定交给一个独立线程,每 60 秒扫一次内存桶:
sequenceDiagram
participant R as 请求
participant M as 安全中间件
participant B as 内存事件桶
participant T as 守护线程
participant S as SMTP
R->>M: 命中路径黑名单
M->>M: 返回 403
M->>B: record_event(内存计数,无 IO)
T->>B: 每 60 秒取一次桶
T->>T: 窗口内命中数 >= 阈值?
T->>S: 发一封汇总邮件原理:请求路径上只做一次内存计数(几微秒),所有 IO——查库、查 IP 归属地、连 SMTP——都挪到线程里。攻击再猛,请求侧的开销也是恒定的。
优势:这个结构顺带解决了"防轰炸"。三层拦在前面:
- 窗口聚合:同一 IP 在 5 分钟内命中次数达到阈值(默认 10 次)才发一封,扫描器的零星试探不打扰我;
- 同源冷却:同一 IP 30 分钟内只发一封,持续攻击时变成"每半小时一份进展汇总";
- 全局限流:所有邮件共用每分钟 5 封的上限,防止攻击告警和评论通知叠加把 SMTP 打爆。
邮件内容也尽量给足——只发"有人攻击你"是没用的,得能直接判断要不要处理:
攻击类型:敏感路径扫描
来源 IP:203.0.113.x(北京 / 联通)
命中次数:37 次 / 5 分钟(告警阈值 10 次)
命中规则:路径黑名单
首次命中:2026-09-10 19:02:11
最近命中:2026-09-10 19:03:48
请求样本:1. 19:03:41 /.env | Mozilla/5.0 (compatible; xx/1.0)阈值这些参数我做成了后台可配,并且在设置页写了每个值的推荐理由(为什么是 10 次、5 分钟、30 分钟),而不是丢一个空输入框让人猜。
还有一个刻意的设计:告警线程不挂在「启用监控」开关下。我关掉资源监控是因为不想收 CPU 告警,但这不代表我连被攻击都不想知道——两件事的开关必须独立。
五、踩坑记录
坑 1:「已通知」标记打早了,告警会被静默吞掉
问题:上线前我找了一轮独立代码审查,揪出一个我自己没意识到的设计错误。我原本是在选出待发列表时就给桶打上"已通知"标记,然后才去发信。
原因:一旦 maybe_send 返回的是"被限流跳过"或"发信失败",标记已经打上了,这个 IP 在 30 分钟冷却期内再打我多少次都不会重试。而分布式攻击下,每分钟 5 封的全局上限很容易被打满——第 6 个来源 IP 就会被静默吞掉。也就是说:攻击规模越大,我越收不到告警。这个 bug 恰好长在这次改造最核心的能力上。
解决:标记只在真正发出之后才打,并且区分三种结果:
result = maybe_send(cfg, KIND_SECURITY, subject, html, cooldown_sec=cooldown_sec)
with _lock:
if result == SENT:
bucket['notified_ts'] = now # 只有真发出去了才算已通知
bucket['retry_after_ts'] = 0.0
elif result == FAILED:
bucket['retry_after_ts'] = now + 300 # SMTP 故障:5 分钟后再试
# SKIPPED(被限流):什么都不打,下个周期自然重试坑 2:来源 IP 直接进邮件主题,是个邮件头注入面
问题:还是审查发现的。攻击告警的邮件主题里拼了来源 IP:f"攻击告警:{label}({ip})"。
原因:这个 IP 来自客户端可控的 X-Real-IP / X-Forwarded-For,而项目里清洗 IP 的函数只做了一个 strip()。如果 IP 字符串里带上 \r\n,它就会一路进到 msg['Subject']——邮件头部出现换行,就可以注入额外的邮件头。生产环境有 nginx 覆写 X-Real-IP 挡着,但本地直连后端、或者哪天代理配置变了,这个面就露出来了。
解决:入口按 IP 字面量严格校验,非法的直接丢弃,同时给发信函数加一道兜底:
_IP_RE = re.compile(r'^[0-9a-fA-F:.]{1,45}$')
def _safe_ip(raw: str) -> str:
"""只接受规范 IP 字面量,否则返回空串(调用方据此丢弃事件)。"""
ip = (raw or '').strip()
if not ip or not _IP_RE.match(ip):
return ''
try:
ipaddress.ip_address(ip)
except ValueError:
return ''
return ip# 兜底:所有主题都从这里过一道,剔除换行
msg['Subject'] = str(subject).replace('\r', ' ').replace('\n', ' ')原理:ipaddress.ip_address() 是标准库的严格解析,比正则可靠。至于主题的换行剔除——它覆盖的是所有主题来源,而不只是 IP 这一处,属于防呆成本极低、收益明确的那种兜底。
坑 3:部署后的冒烟探测报 API 挂了,其实是竞态
问题:部署脚本收尾会在服务器本机做冒烟探测,那次它报 API 健康检查: HTTP 000(期望 200),但同一批的"前台首页 200""后台接口 401"都是正常的。
原因:探测跑在 systemctl restart 之后、uvicorn 还没绑定上端口的那个窗口里。HTTP 000 不是"服务挂了",是"curl 连不上"——两种完全不同的含义。
解决:先看服务状态、再复测,别急着回滚:
systemctl is-active blog-api blog-web
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8001/api/health # 200
journalctl -u blog-api -n 25 --no-pager -o cat # Application startup complete复测确认服务正常、日志显示启动完成且无异常,才判定是误报。这条经验我记进了项目笔记——"000"和"500"要当成两件事处理,前者多半是连接问题(进程没起/端口不通),后者才是服务内部出错。
六、新旧对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 模板 | 6 份各自手写,字段不一 | 1 个 build_email(),统一带站点名/域名/环境/按钮/页脚 |
| 域名来源 | 部分硬编码在模板字符串里 | 只从环境变量 SITE_URL 读,utils/site.py 是唯一出口 |
| 总开关 | 5 处生效,资源告警绕过 | 唯一闸门,全部场景生效 |
| 冷却是 | 各发信点自己维护 dict | 闸门内统一维护,口径一致 |
| 本地调试 | 与生产无差别,被资源告警轰炸 | 默认静音资源+服务类,可在后台改 |
| 被攻击时 | 无任何通知 | 路径扫描/恶意 UA/自动封禁三类汇总告警 |
| 时间显示 | 登录类告警差 8 小时 | 统一中国时区 |
| 插值安全 | 未转义,可注入 | 默认转义 + SafeHtml 隔离出口 |
| 发信总量 | 无上限 | 每分钟 5 封全局上限 |
七、总结
- "6 份实现"本身就是 bug 的温床。总开关失效这个坑,根因不是写错一行判断,而是同一件事有 6 个地方各自实现——漏掉一份是迟早的事。收敛成唯一入口之后,这类错误结构性地不可能再发生。
- 闸门顺序是有意义的。总开关必须排在所有条件之前,否则"关掉通知"这个语义就是不完整的。改造前资源告警把总开关放在条件之后(准确说是根本没放),于是它成了唯一不听指挥的那个。
- 请求路径上不能有 IO。攻击告警如果按"拦截即发信"写,攻击者用一百个请求就能让我邮箱瘫痪。采集(内存计数)与判定(线程聚合)分离,是这类"高频事件 + 慢速副作用"场景的通用解法。
- "已通知"标记要对齐"真的发出去了"。这类"提前打标记"的错误很隐蔽:功能自测正常,只有在限流或下游故障的叠加态下才暴露,而那个时刻恰好是你最需要它工作的时候。
- 任何被拼进邮件头/日志/HTML 的外部输入都要先校验。来源 IP 这种"看起来就是个字符串"的东西,恰恰来自客户端可控的请求头;HTML 转义只能救正文,救不了邮件头。
- 部署后的健康检查要给服务一点启动时间。HTTP 000 与 5xx 是两个完全不同的问题,先看
is-active和启动日志再下结论,能避免一次没必要的回滚。
文档版本:v1.0
最后更新:2026-09-10
作者:leap
个人博客:https://vue2.xyz

