工单状态转变通知(ticket-notify.py)¶
仓库路径:
deploy/nfos/monitoring/ticket-notify.py| 线上路径:/opt/Cy/services/system.d/nfos/monitoring/ticket-notify.py语言:python3 | 定时器:nfos-ticket-notify.timer
用途¶
把「工单状态转变」这一段接上。nfarmsd 在开单与工单更新时只向 NATS 总线发布 ticket.created / ticket.updated,全平台没有消费者;告警有守卫定时调 notify.py 发信,工单这一段是断的。本脚本做的事:
轮询 GET /tickets → 与本地状态快照比对 → 有「新开单」或「状态转变」时,
调用 notify.py 的 ticket 类别(MAIL_ONLINE 发件 + 告警飞书机器人)发送通知
四条硬约束(源码 docstring,全部写进了实现):
| # | 约束 | 实现方式 |
|---|---|---|
| 1 | 零第三方依赖 | 只用标准库 + 同目录的 notify.py |
| 2 | 首轮只登记不发信 | 状态文件不存在时把存量工单写进快照并退出——绝不因「第一次跑」把历史工单全推一遍 |
| 3 | 发送成功才落盘 | 两个渠道都失败时保留旧状态、下轮重试,不会静默丢通知 |
| 4 | 逐条留痕 | 状态转变一律 channels=(True, True) 强制走邮件 + 飞书,不因工单级别是 info 就被 LEVEL_CHANNELS 跳过 |
外加一条身份约定:与守卫相同,走本机回环 + X-User-* 身份头(不持有密钥)。
何时用¶
- 不用手工跑:
nfos-ticket-notify.timer每 300 秒一轮(单调计时器,开机后 120 秒首跑)。 - 首次部署到新节点:可以先
--seed-only显式登记存量工单,避免首轮把历史单推一遍(虽然首轮本来就会自动登记)。 - 核对「会发什么」而不发:
--dry-run(不发送、不落盘)。 - 通道自检:
--self-test(发一条自检通知,不发工单内容)。 - 排查「某张单为什么没通知」:看状态文件
state/ticket-notify-state.json与该单的level/ 标题——NOTIFY_LEVELS只放行critical/high,标题命中测试标记的会被suppressed。
命令与参数¶
以下参数逐字取自 main() 的 argparse 定义。
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
--api |
字符串 | http://127.0.0.1:9990(环境变量 NFARMSD_API 可覆盖) |
nfarmsd API(默认 …) |
--state |
路径 | /opt/Cy/services/system.d/nfos/state/ticket-notify-state.json(前缀由 NFOS_DIR 决定) |
状态快照文件 |
--limit |
int | 500 |
每轮拉取工单条数上限 |
--dry-run |
开关 | False |
只打印将要发送的通知,不发送 |
--seed-only |
开关 | False |
只登记存量工单,不发通知 |
--self-test |
开关 | False |
发送一条通道自检通知 |
共 6 个参数。
不带参数时的默认行为¶
这是 timer 的线上形式(ExecStart=/usr/bin/python3 …/monitoring/ticket-notify.py,无参数):按 --api 默认值拉本机 nfarmsd 的 GET /tickets?limit=500,与 state/ticket-notify-state.json 比对,把「新开单 / 状态转变」的通知真发出去(邮件 + 飞书)。
注意:不带参数 = 真发。任何「先看看」的意图都必须显式加
--dry-run。首次运行(状态文件不存在)会自动只登记不发信。
影响行为的常量(源码内置,可用环境变量覆盖)¶
| 常量 / 环境变量 | 默认 | 作用 |
|---|---|---|
FRESH_MINUTES / NFOS_TICKET_FRESH_MINUTES |
60 |
只对「开单时间在这么久以内」的工单发开单通知(分钟);状态转变看 updated_at。防止状态文件重建 / 被截断时把陈年工单当「新单」补发 |
MAX_NOTIFY_PER_RUN / NFOS_TICKET_MAX_NOTIFY |
20 |
单轮最多发多少条通知(防「状态文件丢了 → 一次补发上千条」) |
STATE_KEEP_DAYS / NFOS_TICKET_STATE_KEEP_DAYS |
30 |
状态里超过这么久没更新的条目按时间清理(不是「本轮没拉到就删」) |
NOTIFY_LEVELS |
{"critical", "high"} |
低于这一档不发通知(按用户告警策略:high 以下只进汇总) |
TEST_TITLE_MARKERS |
("[TK-", "[tk-", "SELFTEST", "selftest", "[自测", "ZZ-", "[TEST", "验收") |
标题里命中就不通知,日志记 suppressed |
IDENT_ID / NFOS_TICKET_IDENT |
nfos-ticket-notify |
注入的 X-User-ID / X-User-Name |
IDENT_ROLES / NFOS_TICKET_ROLES |
service |
注入的 X-User-Roles |
示例¶
D=/opt/Cy/services/system.d/nfos
# 1) timer 的线上形式(真发)
/usr/bin/python3 "$D/monitoring/ticket-notify.py"
# 2) 只看会发什么:不发送、不落盘
sudo python3 "$D/monitoring/ticket-notify.py" --dry-run
# 3) 只登记存量工单,不发通知(新节点首跑前想先"静默对齐"状态时用)
sudo python3 "$D/monitoring/ticket-notify.py" --seed-only
# 4) 通道自检(不发工单内容)
sudo python3 "$D/monitoring/ticket-notify.py" --self-test
# 5) 指向别处演练(沙箱 / 备节点核对),状态文件也换到临时路径
sudo python3 "$D/monitoring/ticket-notify.py" \
--api http://127.0.0.1:9990 --state /var/tmp/ticket-notify-state.json --dry-run
# 6) 缩小拉取面做定点核对
sudo python3 "$D/monitoring/ticket-notify.py" --limit 20 --dry-run
依赖与权限¶
| 项 | 内容 |
|---|---|
| 解释器 | python3 |
| 同目录模块 | notify.py(必需;导入失败会打印 {"level":"ERROR","module":"ticket-notify","message":"无法导入 notify.py:…"} 并 sys.exit(1)) |
| 第三方模块 | 无(标准库 argparse / json / os / sys / time / urllib / signal) |
| 外部命令 | 无 |
| root | 需要。timer 单元 User=root,注释写明「只读本机 nfarmsd API(127.0.0.1:9990)与写状态快照;通知凭据由 monitoring/notify.py 从 secrets/notification.env(600 root)读取」 |
sudo -n |
不需要 |
| 读取 | GET {api}/tickets?limit=N(本机回环,带 X-User-* 身份头);secrets/notification.env(经 notify.py) |
| 写入 | --state 指定的状态快照文件(每发一条就落盘一次);notify.py 的 state/notify-mail-throttle.json |
| 网络 | 本机回环 HTTP(nfarmsd)+ 出站 HTTPS(飞书)+ SMTP(smtp.feishu.cn)。凭据取值见 secrets/notification.env(不得转录) |
| 超时预算 | 单元 TimeoutStartSec=300(注释写明:原来是 120s,会在「积压补发」时被 systemd 杀掉,而被杀那一刻状态还没落盘 → 下一轮把同一批工单重复通知) |
幂等与失败语义¶
- 幂等键:状态快照文件
state/ticket-notify-state.json({工单 id → {status, level, title, updated_at, …}})。 - 首轮只登记:
load_state()返回None(文件不存在或不可读)时视为首跑 → 本轮只写快照、不发信。日志:首次运行:已登记存量工单,不发通知。 - 每发一条就落盘一次(
save_state(args.state, state)在循环内),并额外装了SIGTERM/SIGINT处理器在退出前落盘——双保险,防止「被 systemd 杀掉但没存状态 → 下一轮重复通知」。 - 推进状态的条件是「任一渠道成功或渠道未配置」:
mail is True/feishu is True/mail == "skipped"/feishu == "skipped"任一成立即推进。两个渠道都失败才保留旧状态、failures += 1,下一轮重试。 (源码注释记下了旧实现的坑:原来「只有 mail 成功才推进」→ mail 失败时状态不推进 → 每轮重复推飞书。) - 三道「通知前闸门」(都只影响发不发,不影响状态推进):时效性
FRESH_MINUTES、级别NOTIFY_LEVELS、预算MAX_NOTIFY_PER_RUN;另加测试标题标记。被跳过的原因分别计入suppressed.{"too_old","too_old_transition","level","test","budget"},可在日志里审计。 - 状态清理按时间,不按「本轮是否拉到」——后者在
/tickets只返回最新 N 条时会把旧单记录删掉,旧单重新出现时被当成新单(正是通知风暴的机制之一)。 - 平台不可达时不落盘、不丢状态:
fetch_tickets抛URLError/OSError/ValueError时记平台不可达,本轮跳过(不落盘、不丢状态)并返回 1。 - 一半状态的风险:状态文件「发一条、存一次」,最坏情况是重复通知一条(不会漏通知)。首次
save_state失败(末尾那次)返回 1,但循环内已经落了盘,已通知的单不会重发。 - 退出码(源码 docstring 与实现一致):
| 退出码 | 含义 |
|---|---|
0 |
正常(含首轮只登记、--dry-run、--self-test 邮件通道可用) |
1 |
平台不可达;或末尾 save_state 失败(状态文件不可写);或 notify.py 导入失败;或 --self-test 邮件通道不可用 |
2 |
参数错误(--limit <= 0 时打印 limit 必须为正整数) |
常见错误与排查¶
| 现象 | 代码里的对应分支 | 排查步骤 |
|---|---|---|
日志 平台不可达,本轮跳过(不落盘、不丢状态),退出码 1 |
fetch_tickets 的 except |
curl -s http://127.0.0.1:9990/tickets?limit=1、systemctl status nfarmsd-api.service;状态文件没有被破坏,恢复后下轮继续 |
日志 无法导入 notify.py:…,退出码 1 |
顶部 from notify import Notifier 的 except |
ls -l "$D/monitoring/notify.py"、python3 -c "import sys;sys.path.insert(0,'$D/monitoring');import notify" |
limit 必须为正整数,退出码 2 |
if args.limit <= 0: |
传正整数 |
| 同一张单被重复通知好几轮 | 状态推进条件 / 落盘时机 | ① 看单元日志是否有 收到信号,已落盘状态后退出;② 确认 mail/feishu 是否两个都为 False(只有这时才留旧状态重试);③ 确认 TimeoutStartSec=300 没被改小(注释:120s 会造成这个循环) |
| 收到陈年工单的「开单」通知 | 时效性闸门 FRESH_MINUTES(默认 60 分钟) |
说明状态文件被重建 / 被截断;闸门会拦住超过 60 分钟的单。必要时临时提高 NFOS_TICKET_FRESH_MINUTES 做一次对齐 |
收到 [TK-…] / SELFTEST / 验收 之类的测试单通知 |
TEST_TITLE_MARKERS 应已拦住 |
检查标题是否包含标记串(是子串匹配);确属残留可把标记补进常量 |
| 工单一条都没通知 | NOTIFY_LEVELS / 首轮登记 / 老单 |
① 确认不是首轮(日志 首次运行:已登记存量工单,不发通知);② 工单级别是否在 {critical, high};③ 用 --dry-run 看 would_notify 计数 |
日志有 suppressed,但计数看着对不上 |
三个闸门按顺序短路(时效性 → 级别 → 测试标记 → 预算) | 按这个顺序逐项核对,被前面闸门拦下的不会计入后面 |
| 邮件收不到、飞书到了 | notify.py 邮件按类别限速(ticket 类别 900s) |
见 notify.md;状态仍会推进(skipped 也算成功),这是刻意的 |
回滚方式¶
本脚本不修改系统配置或其他库,副作用只有:状态快照文件、notify.py 的邮件限速状态、以及已发出的通知(不可撤回)。
D=/opt/Cy/services/system.d/nfos
BK=$D/state/unit-backups
# 1) 停掉通知(可逆、最快)
sudo systemctl stop nfos-ticket-notify.timer
sudo systemctl disable nfos-ticket-notify.timer
# 2) 回滚状态快照(例如状态被污染导致重复通知)
TS=$(date +%Y%m%d-%H%M%S)
sudo install -d -m 755 "$BK"
sudo cp -a "$D/state/ticket-notify-state.json" "$BK/ticket-notify-state.json.bak-$TS" 2>/dev/null
# 重置 = 下一轮重新「首轮只登记、不发信」
sudo rm -f "$D/state/ticket-notify-state.json"
sudo systemctl start nfos-ticket-notify.service
# 3) 回滚脚本本体
sudo cp -a "$D/monitoring/ticket-notify.py" "$BK/ticket-notify.py.bak-$TS"
sudo cp -a "$BK/ticket-notify.py.bak-<时间戳>" "$D/monitoring/ticket-notify.py"
sudo chown root:root "$D/monitoring/ticket-notify.py"
sudo chmod 644 "$D/monitoring/ticket-notify.py"
# 4) 回滚 unit(改过的话)——模板见 timers.md 第 11 节
# sudo cp -a "$BK/nfos-ticket-notify.timer.bak-<TS>" /etc/systemd/system/nfos-ticket-notify.timer
# sudo cp -a "$BK/nfos-ticket-notify.service.bak-<TS>" /etc/systemd/system/nfos-ticket-notify.service
# sudo systemctl daemon-reload && sudo systemctl restart nfos-ticket-notify.timer
备份落点铁律:备份放
/opt/Cy/services/system.d/nfos/state/unit-backups/(现场既有格式<unit 文件名>.bak-YYYYMMDD-HHMMSS)或仓库侧/opt/Cy/nfarms/.backup/。 绝不能放在/etc/systemd/system/—— 该目录下出现多余 fragment 会触发 PS-002 完整性告警。 见 timers.md 第 11 节。
相关 CHANGELOG¶
| CHANGELOG | 章节标题 |
|---|---|
docs/CHANGELOG-2026-09-26.md |
§12.2 修复(deploy/nfos/monitoring/ticket-notify.py,sha a64f3c863fc328eb…) |
docs/CHANGELOG-2026-09-26.md |
§12.1 根因(三条叠加,缺一条都不会这么吵) |
docs/CHANGELOG-2026-09-26.md |
§12.3 顺带查出的更严重问题:邮件发件箱被服务商限流,邮件全丢 |
docs/CHANGELOG-2026-09-26.md |
§4.1 问题:备节点推不出「工单号 / 处理链接」两行 |
docs/CHANGELOG-2026-09-26.md |
§2. 工单池历史积压核销 |
docs/CHANGELOG-2026-09-26.md |
§9. 通知腿 NameError 缺陷(P0,已修复) |