工单池历史积压核销(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 只是为了显式表达意图)。它会:
- 以只读方式打开库(
sqlite3.connect("file:<db>?mode=ro", uri=True))并打印journal_mode = …; - 打印
pending 总数 = N; - 打印
== 将核销 N 张,按原因 ==与== 保留人工 N 张,按原因 ==的分组计数; - 全部列出保留明细(
ticket_no | ticket_type | created_at | title+ 该单的原因); - 打印
[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。