feat(notify): 通知拆成「新作品」与「异常」两个开关,异常默认开
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

问题:cookie 过期导致任务失败,但没有任何通知。查下来不是代码问题 ——
notify_enabled 在两个任务上都是 False,而它默认就是关的,事件(run_failed)
也确实生成了,只卡在最后一道闸门。

但那个默认值是错的。代码里的理由是「一条任务列表都推到一个群会很快变吵,所以默认静默」,
这个理由对新作品成立(可能每轮都有),对失败不成立:一次登录态失效意味着这个任务事实上
已经死了,而你不会知道,直到某天发现数据停在几周前。最该被告知的就是这种情况。

现在拆开:
- notify_enabled  —— 推送新作品,可能每轮都有,默认关
- notify_failures —— 推送异常(登录失效/运行失败/没抓到数据),默认开

事件按开关过滤(build_run_message):只勾了「新作品」的任务不该因为一次失败被推消息,
反之亦然,否则拆开开关就没有意义。已有任务由 _ensure_columns 补上 notify_failures=1,
所以会自动开始收到异常推送。

列名 notify_enabled 是历史遗留(它早先是唯一的通知开关),语义已收窄为「新作品」,
用注释写明,不做列重命名 —— 那需要单独的迁移,不值为一个内部工具做。
This commit is contained in:
2026-10-09 13:37:02 +08:00
parent 2e6fa955b0
commit f6ddc46d62
6 changed files with 88 additions and 25 deletions
+8 -2
View File
@@ -107,9 +107,15 @@ class MonitorTask(MonitorBase):
max_comments_count: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
run_timeout_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=3600)
# Push notifications are opt-in per task. A task list that all pushes to one
# webhook turns noisy fast, so silence is the default.
# 通知分成两类,因为它们的性质完全不同:
#
# * `notify_enabled` —— **推送新作品**。可能每轮都有,一条任务列表都推到同一个群
# 会很快变吵,所以默认关。(列名是历史遗留:它早先是唯一的通知开关。)
# * `notify_failures` —— **推送异常**(登录失效 / 运行失败 / 没抓到数据)。频率低,
# 而且一旦发生就意味着这个任务从此**默默采不到任何东西**,你会一直不知道,
# 直到某天发现数据停在几周前。这正是最该被告知的情况,所以默认**开**。
notify_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notify_failures: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Scheduler state. Persisted so the schedule survives an API restart.
next_run_at: Mapped[Optional[int]] = mapped_column(BigInteger, index=True)
+17 -3
View File
@@ -107,14 +107,27 @@ async def build_run_message(
task: MonitorTask,
run: MonitorRun,
) -> Optional[str]:
"""Compose one markdown summary for a finished run, or None if nothing to say."""
"""Compose one markdown summary for a finished run, or None if nothing to say.
事件按开关过滤:只勾了「新作品」的任务,不该因为一次失败被推消息,反之亦然 ——
否则拆开这两个开关就没有意义了。
"""
allowed = []
if task.notify_enabled:
allowed.append(EVENT_NEW_NOTE)
if task.notify_failures:
allowed.extend([EVENT_AUTH_FAILURE, EVENT_RUN_FAILED, EVENT_NO_DATA])
if not allowed:
return None
events = list(
(
await session.scalars(
select(MonitorEvent)
.where(
MonitorEvent.run_id == run.id,
MonitorEvent.type.in_(NOTIFIABLE_EVENT_TYPES),
MonitorEvent.type.in_(allowed),
)
.order_by(MonitorEvent.id)
)
@@ -176,7 +189,8 @@ async def notify_run(session: AsyncSession, task: MonitorTask, run: MonitorRun)
Returns the message that was sent, or None. Never raises.
"""
try:
if not task.notify_enabled:
# 两个开关是分开的:只开「异常」不该因为新作品而发消息,反之亦然。
if not (task.notify_enabled or task.notify_failures):
return None
webhook_url = await get_webhook_url(session)
+3
View File
@@ -183,6 +183,7 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor
max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"],
run_timeout_seconds=payload.get("run_timeout_seconds", 3600),
notify_enabled=payload.get("notify_enabled", False),
notify_failures=payload.get("notify_failures", True),
next_run_at=schedule.next_occurrence(
mode=schedule_mode,
interval_minutes=interval_minutes,
@@ -240,6 +241,7 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An
"max_comments_count",
"run_timeout_seconds",
"notify_enabled",
"notify_failures",
):
if field in payload and payload[field] is not None:
setattr(task, field, payload[field])
@@ -706,6 +708,7 @@ async def list_tasks(
"max_comments_count": task.max_comments_count,
"run_timeout_seconds": task.run_timeout_seconds,
"notify_enabled": task.notify_enabled,
"notify_failures": task.notify_failures,
"next_run_at": task.next_run_at,
"last_run_at": task.last_run_at,
"last_status": task.last_status,