跳转至

工单池历史积压核销(ticket-cleanup.py)

仓库路径:deploy/nfos/ticket-cleanup.py | 线上路径:/opt/Cy/services/system.d/nfos/ticket-cleanup.py 语言:python3 | 定时器:无(一次性质检脚本,非守护进程)


用途

按证据把可判定的历史积压工单批量核销(status='closed' + 写 ticket_events 留痕),只保留真正需要人工的单。核销是可回滚的:写入 TSV 清单 + 生成 rollback 命令。

源码 docstring 记下的背景(2026-09-26 实测):

  • pending 工单 10,577 张,其中 9,841 张来自 9/18–9/23 的 EG-001 误报期(回环/内网/云助手外联被当成「公网非白名单外联」,每命中必开 critical 工单);
  • 另有 RS-001 邮件通道(smtp.feishu.cn)误报、IN-001 库文件基线漂移、12h 告警风暴聚合单等一批已定因的历史单;
  • 工单池因此不可用(9,946 张 SEC pending)。

分类规则(按顺序匹配,第一条命中即用)

# 判定条件(摘要) 核销原因(逐字)
1 rule 以 EG- 开头,或标题含「特征 EG-001 / EG-002 / EG-003」 EG-001 误报期:回环/内网/云助手外联被误判为公网外联(2026-09-24 已修复,此后未再触发)
2 标题含「活动告警数」且含「超过阈值」 告警风暴聚合单:风暴源为 EG-001 误报,根因已修复
3 标题含 IN-001 或「完整性破坏」 IN-001 库文件基线漂移:平台自身库维护(security-guard/signature/rule/template/lib-ops)导致,基线已重建
4 标题含 RS-001 RS-001 邮件通道误报:ticket-notify.py 向 smtp.feishu.cn 发信被误判为反弹 Shell,特征库 allow 已补
5 标题含 RS-002 RS-002 自测命令误报:运维自测命令(sudo -n bash -c exec)命中,已加抑制
6 标题含「Web 攻击特征」/ WA-001 / WA-002 WAF 已拦截(抵御成功):按新分级策略不开单
7 标题含「零信任身份头伪造」/ NfosIdentitySpoofNotBlocked / 「告警链路自检」/ NfosAlertSelftestFailing 告警链路自检项(selftest 指标),非真实攻击面事件
8 标题含 NfarmsdDown / 「nfarmsd 公网存活探测失败」/「nfarmsd 指标端点不可达」 nfarmsd 重启窗口的瞬时不可达,当前四单元 active、探活 200
9 标题含 NfosPeerUnreachable /「对端节点整体不可达」 跨云链路瞬时抖动,当前对端可达
10 标题含 soil_moisture / ai.anomaly 农业传感指标历史异常单,条件早已恢复(业务侧,非安全面)
11 标题含 cert-sync-probe.invalid 证书同步探针产物(.invalid 测试证书/私钥),非生产证书

兜底规则:关联告警已恢复 / 已自动处置的老单(>24h) → 核销,原因 关联告警已恢复/已自动处置(历史单)。

明确保留人工(KEEP_PATTERNS,即使上面命中也不关)

  • cloud.99000042(云安全中心外部检出:恶意脚本代码执行)
  • PS-001、PS-002(持久化位置变更:自动处置明确「无法安全修复 → 转人工」)
  • BackupDailyFailed(备份失败:需确认恢复策略)
  • CertificateExpiring(生产证书到期;探针证书除外,规则 11 已单独处理)

另有两条保留:关联告警仍活跃(rule 非空、recovered_at 为空、handled 为空、且 created_at >= fresh_cut,即最近 24 小时内创建),以及无匹配规则。

何时用

  • 不用定时跑:这是一次性质检脚本。线上没有对应 timer。
  • 每次都是先 --dry-run:它打印分类与计数、全部保留明细,不写库。确认无误再 --apply。
  • 执行核销后:它会在 state/ 下写 TSV 清单并打印回滚命令。
  • 要撤销:--rollback <TSV>(按核销清单改回原状态并删除本次事件行)。
  • 改规则之后:--selftest(离线自检,用临时库合成数据)。

命令与参数

以下参数逐字取自 main() 的 argparse 定义。

参数 类型 默认值 说明
--db 路径 /opt/Cy/services/system.d/nfarmsd/data/nfarmsd.db(NFARMSD_DB) 无 help
--apply 开关 False 真正写库(默认 dry-run)
--dry-run 开关 False 只打印,不写库(默认)
--rollback TSV(metavar) 无 按核销清单回滚(改回原状态并删除本次事件)
--selftest 开关 False 离线自检

共 5 个参数。

不带参数时的默认行为

只打印,绝不写库(--apply 是唯一的写库开关;--dry-run 只是为了显式表达意图)。它会:

  1. 以只读方式打开库(sqlite3.connect("file:<db>?mode=ro", uri=True))并打印 journal_mode = …;
  2. 打印 pending 总数 = N;
  3. 打印 == 将核销 N 张,按原因 == 与 == 保留人工 N 张,按原因 == 的分组计数;
  4. 全部列出保留明细(ticket_no | ticket_type | created_at | title + 该单的原因);
  5. 打印 [dry-run] 未写库。加 --apply 执行。,退出码 0。

--dry-run 与 --apply 同时给时,--apply 生效(run(db, a.apply))。

示例

D=/opt/Cy/services/system.d/nfos

# 1) 离线自检(临时库合成数据,不碰生产库)
python3 "$D/ticket-cleanup.py" --selftest

# 2) 只打印分类与计数、全部保留明细(生产库需 root)
sudo python3 "$D/ticket-cleanup.py" --dry-run

# 3) 执行核销(单事务;写 TSV 清单 + 打印回滚命令)
sudo python3 "$D/ticket-cleanup.py" --apply

# 4) 按核销清单回滚
sudo python3 "$D/ticket-cleanup.py" --rollback \
  /opt/Cy/services/system.d/nfos/state/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv

# 5) 指向别的库做演练(不碰生产)
python3 "$D/ticket-cleanup.py" --db /var/tmp/nfarmsd-copy.db --dry-run

# 6) 找回滚清单文件名(脚本在 --apply 时会打印,也可直接列目录)
ls -lt /opt/Cy/services/system.d/nfos/state/ticket-cleanup-*.tsv | head

依赖与权限

项 内容
解释器 python3
第三方模块 无(标准库 argparse / datetime / os / re / sqlite3 / sys / tempfile)
外部命令 无
root 需要(生产库)。docstring 的用法示例全部写 sudo python3 …;库文件属 nfarmsd,且写操作要 BEGIN IMMEDIATE 独占事务
sudo -n 脚本自身不调用 sudo(由调用者用 sudo 起进程)
读取 nfarmsd SQLite(tickets / ticket_events,只读连接 + PRAGMA busy_timeout=30000)
写入(仅 --apply) tickets.status / updated_at、ticket_events(action='close'、actor=ACTOR);TSV 清单 state/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv
写入(--rollback) tickets.status 改回原值、DELETE ticket_events WHERE actor=ACTOR AND action='close'
网络 不联网
关键常量 STATE_DIR(默认 /opt/Cy/services/system.d/nfos/state,可用 NFOS_STATE 覆盖)、ACTOR = "ticket-cleanup-20260926"、fresh_cut = now(UTC) - 24h

幂等与失败语义

  • 幂等(--apply):UPDATE tickets SET status='closed', updated_at=? WHERE id=? AND status='pending'——带 status='pending' 条件,已关闭的单不会被重复处理;重复执行同一批不会再产生新的 ticket_events 行。
  • 单事务:--apply 用 BEGIN IMMEDIATE 包住全部 UPDATE + INSERT + TSV 写入,except 时 con.rollback() 并重新抛出。不会留下「写了一半」的库。
  • 一半状态的风险(TSV 与库):TSV 在事务内写入,事务回滚时库回滚但TSV 文件可能已落盘(文件是 open(...,"w") 直接写的)。因此回滚时若 TSV 内容与库不符,以库为准;--rollback 只按 TSV 里能匹配到 status='closed' 的行生效(UPDATE ... WHERE id=? AND status='closed',用 cur.rowcount 累计实际回滚条数)。
  • 状态文件(幂等键):本脚本没有自己的幂等状态文件,幂等来自数据库条件更新;唯一的状态产物是 TSV 清单。
  • --rollback 的幂等:改回原状态后再次跑同一 TSV,rowcount 为 0(已经不在 closed 态),打印 已回滚 0 张。
  • 退出码:
退出码 含义
0 --dry-run 完成;--apply 成功;--rollback 成功;--selftest 全部通过
1 --selftest 有失败项;--rollback 的 TSV 里没有可回滚记录(打印 TSV 里没有可回滚的记录:…);或未捕获异常(例如库不可写、TSV 不存在)
2 argparse 参数错误(argparse 自身行为)

常见错误与排查

现象 代码里的对应分支 排查步骤
sqlite3.OperationalError: attempt to write a readonly database --apply 的连接是可写连接(不是 mode=ro) 必须以 root 跑:sudo python3 "$D/ticket-cleanup.py" --apply
sqlite3.OperationalError: database is locked PRAGMA busy_timeout=30000 已设 30s 30 秒仍拿不到写锁说明 nfarmsd 侧有长事务。先 --dry-run 确认清单,错峰再 --apply
只打印了分组计数、没有明细 保留明细是全部列出的(第 4 步) 滚动往上找 == 保留明细(全部列出) ==;行格式 ticket_no \| ticket_type \| created_at \| title + -> 原因
想核销的单被保留了 KEEP_PATTERNS 或「关联告警仍活跃」或「无匹配规则」 看该单这一行的 -> 原因:保留人工:<pattern> / 保留人工:关联告警仍活跃 / 保留人工:无匹配规则,需人工判定
老的已恢复单没被核销 兜底规则要求 created_at < fresh_cut 且(recovered_at 或 handled) 检查该单的关联告警是否真的已恢复/已自动处置
--rollback 打印 TSV 里没有可回滚的记录:…,退出码 1 if not ids: TSV 为空文件、路径写错、或全部行都以 # 开头(表头)。核对 TSV 路径与内容
--rollback 打印 已回滚 0 张 UPDATE … WHERE id=? AND status='closed' 的 rowcount 为 0 这些单已被别的途径改过状态;--rollback 只回滚「当前仍是 closed」的行,这是刻意的保守设计
--selftest 返回 1 selftest() 的逐项断言 输出在 stdout;对照改动的规则定位失败项
类型坑:把「历史总量」读成「近 24h」 fresh_cut 与 created_at 的比较 见 docs/CHANGELOG-2026-09-26.md「§2.1 背景:一个把『历史总量』读成『近 24h』的类型坑」

回滚方式

本脚本会改库(tickets.status + 新增 ticket_events 行),是本文档里最需要谨慎的一个。回滚是内建的:

改动面 内容 还原方式
tickets.status / updated_at 被核销的单改成 closed --rollback <TSV> 按清单改回原状态
ticket_events 新增 action='close'、actor='ticket-cleanup-20260926' 的事件行 --rollback 同时 DELETE FROM ticket_events WHERE actor=ACTOR AND action='close' AND ticket_id=?
TSV 清单 state/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv 这是回滚的唯一凭据,核销后必须保留。备份到 state/unit-backups/
脚本本体 ticket-cleanup.py 用版本化备份覆盖回去
D=/opt/Cy/services/system.d/nfos
BK=$D/state/unit-backups
STATE=$D/state

# 1) 找到本次核销清单(脚本 --apply 时已打印过它的路径)
ls -lt "$STATE"/ticket-cleanup-*.tsv | head

# 2) 立刻把清单备份出来(核销后第一件事)
TS=$(date +%Y%m%d-%H%M%S)
sudo install -d -m 755 "$BK"
sudo cp -a "$STATE/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv" \
           "$BK/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv.bak-$TS"

# 3) 回滚(按清单改回原状态并删除本次事件)
sudo python3 "$D/ticket-cleanup.py" --rollback "$STATE/ticket-cleanup-<YYYYMMDD-HHMMSS>.tsv"

# 4) 回滚脚本本体
sudo cp -a "$D/ticket-cleanup.py" "$BK/ticket-cleanup.py.bak-$TS"
sudo cp -a "$BK/ticket-cleanup.py.bak-<时间戳>" "$D/ticket-cleanup.py"
sudo chown root:root "$D/ticket-cleanup.py"
sudo chmod 644       "$D/ticket-cleanup.py"

# 5) 验收:单池状态回到预期
sudo python3 "$D/ticket-cleanup.py" --dry-run | head -20

CHANGELOG 里记录的事故提醒:docs/CHANGELOG-2026-09-26.md「§12.1 根因」指出, 批量核销 10,572 张历史工单时,状态转变(待处理 → 已关闭)曾把上万张陈年单 重新推了一遍给 ticket-notify.py,造成通知风暴。核销前先确认 ticket-notify.md 的时效性闸门(FRESH_MINUTES)在位。

备份落点铁律:清单与脚本备份放 /opt/Cy/services/system.d/nfos/state/unit-backups/ (命名 <文件名>.bak-YYYYMMDD-HHMMSS)或仓库侧 /opt/Cy/nfarms/.backup/。 绝不能放在 /etc/systemd/system/ —— 会触发 PS-002 完整性告警。见 timers.md 第 11 节。

相关 CHANGELOG

CHANGELOG 章节标题
docs/CHANGELOG-2026-09-26.md §2. 工单池历史积压核销
docs/CHANGELOG-2026-09-26.md §2.1 背景:一个把「历史总量」读成「近 24h」的类型坑
docs/CHANGELOG-2026-09-26.md §2.4 分类规则表(可直接复用)
docs/CHANGELOG-2026-09-26.md §2.5 工具能力与回滚
docs/CHANGELOG-2026-09-26.md §6.4 离线自检 3:ticket-cleanup.py --selftest
docs/CHANGELOG-2026-09-26.md §8.3 工单核销:按清单回滚
docs/CHANGELOG-2026-09-26.md §12.1 根因(三条叠加,缺一条都不会这么吵)

工单通知侧的时效性闸门见 ticket-notify.md;定时器全景见 timers.md。