跳转至

工单状态转变通知(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,已修复)

定时器节奏与日志落点见 timers.md 第 4 节;通道与限速见 notify.md。