Files
DadaoTask/backend/app/services/reminders.py
T
2026-11-10 13:47:56 +08:00

216 lines
7.4 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.
"""到期与逾期提醒。
为什么是"轮询 + 数据库租约"而不是引入 Celery/APScheduler:
1. 生产是 gunicorn 多 worker,进程内定时器会各跑一遍,通知就重复了。
`pg_try_advisory_lock` 是现成的单实例租约,和微信机器人用的是同一套做法,
不额外增加运维组件。
2. 提醒的准确度要求是"几分钟内",不是秒级;一轮扫描就是一条按
`(workspace_id, status, due_at)` 索引的查询,代价可以忽略。
3. 重复触发用 `notification.dedupe_key` 兜底:扫描器重启、时钟抖动、
人工补跑都不会把同一条提醒写两遍。
扫描本身跨租户(平台级任务),所以先在 `app.reminder_list = 'on'` 的只读口子下
把候选任务列出来,再逐个工作区绑定租户写通知——读可以跨,写必须归位。
"""
from __future__ import annotations
import asyncio
import math
import uuid
from contextlib import suppress
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from sqlalchemy import select, text
from ..config import get_settings
from ..db import SessionFactory, bind_tenant, engine
from ..logging_setup import get_logger
from ..models_task import Item
from .notify import notify_once
logger = get_logger("app.reminders")
_LOCK_NAMESPACE = "dadao.reminders"
# 一轮最多处理这么多条:正常量级远小于它;真到了这个量级,说明该拆任务而不是加锁。
_SCAN_LIMIT = 500
_task: asyncio.Task[None] | None = None
_stopping = asyncio.Event()
async def _candidates() -> list[dict[str, Any]]:
"""列出所有"还没完成、有截止时间、已经进入提醒窗口"的事项。"""
settings = get_settings()
now = datetime.now(timezone.utc)
horizon = now + timedelta(hours=max(1, settings.reminder_due_soon_hours))
async with SessionFactory() as session:
await session.execute(text("SELECT set_config('app.reminder_list', 'on', true)"))
rows = (
await session.execute(
select(
Item.id,
Item.workspace_id,
Item.title,
Item.status,
Item.due_at,
Item.creator_id,
Item.assignee_id,
)
.where(
Item.deleted_at.is_(None),
Item.kind != "knowledge",
Item.status.not_in(("done", "canceled")),
Item.due_at.is_not(None),
Item.due_at <= horizon,
)
.order_by(Item.due_at)
.limit(_SCAN_LIMIT)
)
).all()
return [dict(row._mapping) for row in rows]
def _local(dt: datetime) -> datetime:
return dt.astimezone(ZoneInfo(get_settings().default_timezone))
def _due_soon_message(row: dict[str, Any], now: datetime) -> tuple[str, str]:
due_at = row["due_at"]
hours = max(1, math.ceil((due_at - now).total_seconds() / 3600))
return (
f"「{row['title']}」{hours} 小时内到期",
f"截止时间 {_local(due_at):%m-%d %H:%M},请尽快处理。",
)
def _overdue_message(row: dict[str, Any], now: datetime) -> tuple[str, str]:
due_at = row["due_at"]
late = now - due_at
days = late.days
if days >= 1:
tail = f"已经逾期 {days} 天"
else:
tail = f"已经逾期 {max(1, int(late.total_seconds() // 3600))} 小时"
return (
f"「{row['title']}」已逾期",
f"原定 {_local(due_at):%m-%d %H:%M} 完成,{tail}。",
)
async def _remind_workspace(ws_id: uuid.UUID, rows: list[dict[str, Any]], now: datetime) -> int:
sent = 0
async with SessionFactory() as session, session.begin():
await bind_tenant(session, ws_id, None)
for row in rows:
recipient = row["assignee_id"] or row["creator_id"]
if recipient is None:
continue
due_at: datetime = row["due_at"]
if due_at > now:
title, body = _due_soon_message(row, now)
kind = "task_due_soon"
dedupe = f"task_due_soon:{row['id']}:{due_at.isoformat()}"
else:
title, body = _overdue_message(row, now)
kind = "task_overdue"
today = _local(now).date().isoformat()
dedupe = f"task_overdue:{row['id']}:{due_at.isoformat()}:{today}"
if await notify_once(
session,
ws_id=ws_id,
user_id=recipient,
kind=kind,
item_id=row["id"],
title=title,
body=body,
dedupe_key=dedupe,
):
sent += 1
return sent
async def tick() -> dict[str, int]:
"""扫一轮。返回本轮的统计,供日志与测试使用。"""
settings = get_settings()
if not settings.reminders_enabled:
return {"scanned": 0, "sent": 0, "skipped": 1}
lock_conn = await engine.connect()
try:
got = (
await lock_conn.execute(
text("SELECT pg_try_advisory_lock(hashtext(:ns))"), {"ns": _LOCK_NAMESPACE}
)
).scalar()
if not got:
# 另一个 worker 正在扫,这一轮让给它。
return {"scanned": 0, "sent": 0, "skipped": 1}
now = datetime.now(timezone.utc)
rows = await _candidates()
by_ws: dict[uuid.UUID, list[dict[str, Any]]] = {}
for row in rows:
by_ws.setdefault(row["workspace_id"], []).append(row)
sent = 0
for ws_id, group in by_ws.items():
sent += await _remind_workspace(ws_id, group, now)
if sent:
logger.info(
"到期提醒已发送",
extra={"extra_fields": {"scanned": len(rows), "sent": sent}},
)
return {"scanned": len(rows), "sent": sent, "skipped": 0}
finally:
# session 级 advisory lock 挂在连接上;解锁后连接才能安全回到池里。
# 连接已断时解锁失败无需处理,连接本身也回不了池。
with suppress(Exception):
await lock_conn.execute(
text("SELECT pg_advisory_unlock(hashtext(:ns))"), {"ns": _LOCK_NAMESPACE}
)
await lock_conn.close()
async def _loop() -> None:
settings = get_settings()
while not _stopping.is_set():
try:
await tick()
except asyncio.CancelledError:
raise
except Exception: # pragma: no cover - 单轮失败不该把循环打死
logger.exception("到期提醒扫描失败")
try:
await asyncio.wait_for(
_stopping.wait(), timeout=max(5, settings.reminder_interval_seconds)
)
except asyncio.TimeoutError:
continue
def start() -> None:
"""启动后台提醒循环。测试环境与关闭开关时直接不启动。"""
global _task
settings = get_settings()
if settings.env == "test" or not settings.reminders_enabled:
return
if _task is not None and not _task.done():
return
_stopping.clear()
_task = asyncio.create_task(_loop(), name="dadao-reminders")
async def shutdown() -> None:
global _task
_stopping.set()
if _task is None:
return
_task.cancel()
with suppress(asyncio.CancelledError, Exception):
await _task
_task = None