feat(notify): 站内通知补齐完成、到期逾期与知识事件
CI / backend (push) Waiting to run

This commit is contained in:
Steven
2026-11-10 13:47:56 +08:00
parent 4b7627d70e
commit f54df70811
15 changed files with 791 additions and 32 deletions
+58 -10
View File
@@ -205,6 +205,48 @@ async def _apply_update(
return item
async def _notify_status_change(
session: AsyncSession,
*,
ws_id: uuid.UUID,
item: Item,
previous: str,
to_status: str,
actor_id: uuid.UUID,
actor_name: str,
) -> None:
"""状态变化的通知。
"完成"单独一种 kind:收件人一眼能看出是结项,而不是又一次普通的状态流转;
其余状态仍走 `task_status_changed`,正文里写清从什么改成了什么。
"""
if to_status == "done":
await notify(
session,
ws_id=ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="task_completed",
item_id=item.id,
title=f"「{item.title}」已完成",
body=f"{actor_name} 把它标记为完成。",
actor_id=actor_id,
)
return
await notify(
session,
ws_id=ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="task_status_changed",
item_id=item.id,
title=f"「{item.title}」变成{TASK_STATUS_CN.get(to_status, to_status)}了",
body=(
f"{actor_name} 把它从 {TASK_STATUS_CN.get(previous, previous)} "
f"改成了 {TASK_STATUS_CN.get(to_status, to_status)}"
),
actor_id=actor_id,
)
# ── 列表 ────────────────────────────────────────────────────────────────
def _apply_filters(
stmt: Select,
@@ -581,6 +623,17 @@ async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep)
}
)
continue
# 批量改状态以前是"悄悄改完":被指派人/创建人收不到任何通知,
# 和单条改状态的行为不一致。这里补上同一条通知。
await _notify_status_change(
session,
ws_id=ctx.ws_id,
item=item,
previous=previous,
to_status=body.status,
actor_id=ctx.user.id,
actor_name=ctx.user.nickname or "同事",
)
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
return ok(
@@ -682,19 +735,14 @@ async def change_status(
payload={"from": previous, "to": body.status},
)
tags = await _tags_of(session, ctx.ws_id, [item.id])
await notify(
await _notify_status_change(
session,
ws_id=ctx.ws_id,
user_ids=(item.creator_id, item.assignee_id),
kind="task_status_changed",
item_id=item.id,
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
body=(
f"{ctx.user.nickname or '同事'} 把它从 "
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
f"{TASK_STATUS_CN.get(body.status, body.status)}"
),
item=item,
previous=previous,
to_status=body.status,
actor_id=ctx.user.id,
actor_name=ctx.user.nickname or "同事",
)
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
+58
View File
@@ -45,6 +45,7 @@ from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
from ..serializers import item_out, tags_of
from ..services.knowledge import create_draft_from_source, find_doc_by_source
from ..services.knowledge_index import reindex_document, search_knowledge
from ..services.notify import notify, workspace_admins
from .attachments import count_for_items, list_for_item
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["知识库"])
@@ -1025,6 +1026,17 @@ async def create_knowledge(body: KnowledgeIn, ctx: MemberDep, session: SessionDe
await session.flush()
await _log(session, item, ctx.user.id, "knowledge_created", {"generated_by": "human"})
await _set_tags_for(session, ws_id, item, body.tag_names)
# 知识是团队资产:新建草稿先告诉能发布的人,别让草稿在列表里躺到没人发现。
await notify(
session,
ws_id=ws_id,
user_ids=await workspace_admins(session, ws_id),
kind="knowledge_draft_created",
item_id=item.id,
title=f"新的知识草稿:{item.title}",
body=f"{ctx.user.nickname or '同事'} 新建了一条知识,核对后可以发布。",
actor_id=ctx.user.id,
)
await audit_record(
session,
workspace_id=ws_id,
@@ -1204,6 +1216,42 @@ async def change_status(
target_id=str(item.id),
payload={"to": to_status},
)
# 知识的状态变化要让相关的人知道:提交审议找管理员核对,
# 发布/归档则回告作者——否则作者不知道自己的草稿已经被团队采纳。
actor_name = ctx.user.nickname or "同事"
if body.action == "submit_review":
await notify(
session,
ws_id=ws_id,
user_ids=await workspace_admins(session, ws_id),
kind="knowledge_review_requested",
item_id=item.id,
title=f"待核对:{item.title}",
body=f"{actor_name} 提交了一份知识,核对后可以发布。",
actor_id=ctx.user.id,
)
elif body.action == "publish":
await notify(
session,
ws_id=ws_id,
user_ids=(doc.author_id,),
kind="knowledge_published",
item_id=item.id,
title=f"「{item.title}」已发布",
body=f"{actor_name} 发布了这份知识,现在可以被团队检索到。",
actor_id=ctx.user.id,
)
elif body.action == "archive":
await notify(
session,
ws_id=ws_id,
user_ids=(doc.author_id,),
kind="knowledge_archived",
item_id=item.id,
title=f"「{item.title}」已归档",
body=f"{actor_name} 把它归档了,检索不再返回它。",
actor_id=ctx.user.id,
)
await session.flush()
if body.action == "publish":
# 发布后立刻建索引:检索只认 published,不在这里建就要等定时任务,
@@ -1270,6 +1318,16 @@ async def promote_to_knowledge(
await _log(
session, doc_item, ctx.user.id, "knowledge_created", {"source_item_id": str(source.id)}
)
await notify(
session,
ws_id=ws_id,
user_ids=await workspace_admins(session, ws_id),
kind="knowledge_draft_created",
item_id=doc_item.id,
title=f"从事项沉淀了知识:{doc_item.title}",
body=f"{ctx.user.nickname or '同事'} 把「{source.title}」沉淀成草稿,核对后可以发布。",
actor_id=ctx.user.id,
)
await audit_record(
session,
workspace_id=ws_id,
+16 -4
View File
@@ -767,11 +767,14 @@ async def delete_link(
# -- 通知 ---------------------------------------------------------------
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
def _notification_out(
n: Notification, item_title: str | None = None, item_kind: str | None = None
) -> dict[str, Any]:
return {
"id": str(n.id),
"kind": n.kind,
"item_id": str(n.item_id) if n.item_id else None,
"item_kind": item_kind,
"item_title": item_title,
"title": n.title,
"body": n.body,
@@ -813,18 +816,27 @@ async def list_notifications(
rows = list(rows[:limit])
titles: dict[uuid.UUID, str] = {}
kinds: dict[uuid.UUID, str] = {}
item_ids = [n.item_id for n in rows if n.item_id]
if item_ids:
for item_id, title in (
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
for item_id, title, kind in (
await session.execute(
select(Item.id, Item.title, Item.kind).where(Item.id.in_(item_ids))
)
).all():
titles[item_id] = title
kinds[item_id] = kind
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
return ok(
{
"notifications": [
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
_notification_out(
n,
titles.get(n.item_id) if n.item_id else None,
kinds.get(n.item_id) if n.item_id else None,
)
for n in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
+8
View File
@@ -112,6 +112,14 @@ class Settings(BaseSettings):
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
attachment_url_ttl_seconds: int = 1800
# ── 站内提醒 ──────────────────────────────────────────
# 到期/逾期提醒是后台轮询,不引入额外组件(见 services/reminders.py)。
# 间隔决定"最晚多久能收到提醒",5 分钟对截止时间以小时计的任务足够。
reminders_enabled: bool = True
reminder_interval_seconds: int = 300
# 提前多久提醒"快到期"。默认 24 小时;改成 0 等于只提醒逾期。
reminder_due_soon_hours: int = 24
# ── 知识检索(M3)──────────────────────────────────────
# 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段;
# 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
+4
View File
@@ -32,6 +32,7 @@ from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
from .logging_setup import get_logger, request_id_ctx, setup_logging
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
from .services import reminders
from .services.wechat_bot import manager as wechat_manager
settings = get_settings()
@@ -53,7 +54,10 @@ async def lifespan(app: FastAPI):
)
# 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。
await wechat_manager.start_all()
# 到期/逾期提醒:多 worker 下靠 advisory lock 保证只有一个进程在扫。
reminders.start()
yield
await reminders.shutdown()
await wechat_manager.shutdown()
await engine.dispose()
+14
View File
@@ -26,6 +26,7 @@ from sqlalchemy import (
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -121,6 +122,16 @@ class Notification(Base, Timestamps):
__table_args__ = (
Index("ix_notification_ws_user_read", "workspace_id", "user_id", "read_at"),
Index("ix_notification_ws_created", "workspace_id", "created_at"),
# 到期/逾期提醒由后台循环生成,可能被重复触发;带 dedupe_key 的写入用
# ON CONFLICT DO NOTHING,把"同一件事只提醒一次"交给数据库保证。
Index(
"uq_notification_dedupe",
"workspace_id",
"user_id",
"dedupe_key",
unique=True,
postgresql_where=text("dedupe_key IS NOT NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
@@ -137,6 +148,9 @@ class Notification(Base, Timestamps):
title: Mapped[str] = mapped_column(String(120), nullable=False)
body: Mapped[str] = mapped_column(String(300), nullable=False, default="")
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 提醒类通知的幂等键(如 task_due_soon:<item>:<due_at>)。
# 普通通知为空,不参与唯一约束。
dedupe_key: Mapped[str | None] = mapped_column(String(120))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+52
View File
@@ -15,8 +15,11 @@ from __future__ import annotations
import uuid
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Member
from ..models_ticket import Notification
TICKET_STATUS_CN = {
@@ -48,6 +51,7 @@ async def notify(
title: str,
body: str = "",
actor_id: uuid.UUID | None = None,
dedupe_key: str | None = None,
) -> int:
"""给若干人各写一条通知,返回实际写入条数。
@@ -67,7 +71,55 @@ async def notify(
item_id=item_id,
title=title[:120],
body=body[:300],
dedupe_key=dedupe_key,
)
)
count += 1
return count
async def notify_once(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_id: uuid.UUID | None,
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
dedupe_key: str,
actor_id: uuid.UUID | None = None,
) -> bool:
"""写一条带幂等键的通知;已经写过就跳过,返回是否真的写入。
后台提醒循环会反复扫同一批任务,靠 `dedupe_key` 的唯一索引把重复挡在数据库层,
比"先查再插"少一次竞态。提醒本身不是业务事实,丢了不致命,重复才烦人。
"""
if user_id is None or user_id == actor_id:
return False
result = await session.execute(
pg_insert(Notification)
.values(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
dedupe_key=dedupe_key,
)
.on_conflict_do_nothing()
)
return result.rowcount > 0
async def workspace_admins(session: AsyncSession, ws_id: uuid.UUID) -> list[uuid.UUID]:
"""工作区里能"发布/把关"的人:owner + admin,只算正常成员。"""
rows = await session.execute(
select(Member.user_id).where(
Member.workspace_id == ws_id,
Member.status == "active",
Member.role.in_(("owner", "admin")),
)
)
return [row[0] for row in rows]
+215
View File
@@ -0,0 +1,215 @@
"""到期与逾期提醒。
为什么是"轮询 + 数据库租约"而不是引入 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