Files
auto_control/core/logger.py
T
butubb 97cec6e211 feat(通知): 系统级 Webhook 通知子系统(事件目录 + 可插拔适配器 + 防刷屏)
平台此前出了问题只能靠人盯页面。现在各组件统一走 `notifier.notify(事件, **字段)`,
推到企业微信 / 自建服务;**所有可通知点都登记进事件目录,默认全关,用户按 webhook 勾选**。

## 架构(`core/notifier.py` + `core/notify_events.py`)
业务线程调 `notify()` → 只做内存操作(读配置快照/匹配订阅/入队)→ 返回;
后台 1 个 dispatcher(聚合 + 每 hook 限流 + 折叠摘要)+ 3 个 sender(真实 HTTP、退避重试)
负责真正发出去。硬约束:**notify 零 DB、零 HTTP、零阻塞、异常不冒泡**——所以任务线程里
可以直接调(不用 app_context、不用 try/except),但**必须放在所有 `with self._lock` 之外**。

- **事件目录 36 条**(任务批次/单设备/设备/Worker/业务/安装/系统/AI),支持 `task.*` 通配订阅;
  语义分工避免重复告警:`worker.*` 是单次尝试级,`task.device.success/failed` 是唯一权威结论。
- **适配器可插拔**:`wecom`(markdown,按 4096 **字节**截断、超限不截半个汉字)+
  `json`(模板占位符,替换值按 JSON 转义,保存前干跑校验);钉钉/飞书留了插槽(前端置灰)。
- **防打爆四层**:聚合窗口(默认 30s,同批次合并成一条并带样本)→ 令牌桶限流(默认 18/分,
  对齐企微硬限 20)→ 被限流的**折叠成摘要不丢弃** → 有界队列背压。取舍:失败通知最多延迟
  一个窗口,换来群不被刷屏。

## 安全与存储
- 配置只落 `app_meta.notify_webhooks` 一个键(**不建表** → 不涉及备份覆盖红线)。
- URL 本身就是凭据(企微 `?key=`)→ 接口回显/发送记录/日志一律 `mask_url()/scrub()`;
  编辑时留空即不修改;secret 永不回显。DATA_MODEL 的明文凭据告警补上了这一条。
- 发送记录:内存环形缓冲 200 条(重启清空)+ 独立 `logs/notify.log`。

## 接入点(每个都放在锁外、不改 return 顺序)
task_manager(批次开始/结束用新增的 `_BatchTracker` 统一在 finally 计数、单设备成功/失败/
离线/重试/停止/抢占/归还/cron 停止)、device_worker 心跳看门狗、generic 任务选择器连续失效、
apk 安装开始/完成、设备上下线(**状态沿检测**,只报新变化)、备份导出/恢复、经验巡检、
用户登录、服务启停。

## 前端
系统 Tab 新增「通知 / Webhook」子分栏:多条 webhook 列表(URL 打码)+ 编辑弹窗(格式/URL/
密钥/事件勾选树带 ★建议/聚合/限流/自定义模板/预览)+ 发送测试 + 发送记录。

## 自测
- 进程内逻辑 10 组断言全绿:聚合合并、限流+折叠、无配置/全局关静默丢弃、未知事件、
  内部异常不外泄、JSON 转义(标题含引号换行仍合法)、URL/异常消息脱敏、配置校验。
- 端到端(假 webhook 接收端)17 项断言全绿:真实事件投递(user.login / task.batch.no_device)、
  企微请求体形状、**HTTP 200 + errcode 93000 判为失败**、500 重试 3 次、记录里 URL 打码。
- 韧性:webhook 指向黑洞地址时登录耗时 100~114ms(基线 107~133ms,**异步隔离生效**);
  配置写成坏 JSON 服务照常启动、通知静默不发、日志有 error(服务端实测后已复原)。
- 页面:系统 → 通知 面板/弹窗/36 个事件复选框/预览全部正常,无 JS 报错。
- 自测数据已清理(webhook、自建任务、写坏又复原的配置键)。

文档:新增 doc/NOTIFY.md(事件表/配置/格式约束/防刷屏/加事件三步骤/排障)并登记进 doc/README;
API.md §2.11;DATA_MODEL 的 app_meta 键表与明文凭据告警;ARCHITECTURE 线程表/分层/扩展点;
根 README 功能索引与日志表。
2026-09-15 13:55:14 +08:00

87 lines
2.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一日志系统:按模块分文件,带时间戳和级别,自动滚动。
用法:
from core.logger import get_logger
log = get_logger("task") # 写 logs/task.log + 控制台
log.info("开始任务")
log.error("失败", exc_info=True)
模块划分:
core — 核心程序(STF/adb/worker/task_manager)
task — 任务执行(worker 业务逻辑)
web — web_server 请求/管理
action — 操作执行(点赞/评论等)
所有日志同时输出到控制台和对应文件,logs/ 目录自动创建。
文件按 10MB 滚动,保留 5 个历史文件。
"""
import os
import logging
from logging.handlers import RotatingFileHandler
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
# 已创建的 logger,避免重复添加 handler
_LOGGERS = {}
# 模块 → 文件名映射
_MODULE_FILES = {
"core": "core.log",
"task": "task.log",
"web": "web.log",
"action": "action.log",
# 通知单独一个文件:排障时"哪条通知发了/失败了/为什么"要能一眼翻到
"notify": "notify.log",
}
_FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s"
_DATE_FMT = "%Y-%m-%d %H:%M:%S"
def get_logger(name="core"):
"""获取指定模块的 logger。
name: 模块名(core/task/web/action),决定写哪个文件。
也可传子模块名如 "task.generic",会归到 task.log。
返回配置好的 logging.Logger。
"""
if name in _LOGGERS:
return _LOGGERS[name]
# 归类到对应文件:取顶层模块名
top = name.split(".")[0]
filename = _MODULE_FILES.get(top, "core.log")
log = logging.getLogger(name)
log.setLevel(logging.DEBUG)
# 避免向 root logger 传播导致重复输出
log.propagate = False
fmt = logging.Formatter(_FORMAT, _DATE_FMT)
# 文件 handler:10MB 滚动,保留 5 个
file_path = os.path.join(_LOG_DIR, filename)
fh = RotatingFileHandler(file_path, maxBytes=10 * 1024 * 1024,
backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
# 控制台 handler
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(fmt)
log.addHandler(fh)
log.addHandler(ch)
_LOGGERS[name] = log
return log
# 兼容旧代码的 print 风格:紧急排查时可临时用
def log_print(msg, level="info", module="core"):
"""print 的替代品,转发到 logging。"""
log = get_logger(module)
getattr(log, level, log.info)(msg)