跳转到内容

消息与通知

fastapi-react-admin 的通知分三块:站内消息(顶栏铃铛 + 消息中心页,经 Socket.IO 实时更新);管理员发给全员或指定部门 / 角色 / 人的通知公告;以及一层渠道路由 —— 按通知类型决定这条消息要不要再走邮件、短信,或推到飞书、钉钉、企业微信的群机器人。所有外部发送都先在调用方的数据库事务里写成投递行,再由 Celery worker 发出去,所以审批回滚了,邮件就不会先到。

站内消息存在 sys_notification,分四类:系统、公告、任务(后台导出)、审批。写入一行后,服务端推一个 Socket.IO 事件 notification:new,不带任何内容;前端收到只是重新走 REST 拉列表和未读数。所以权限只在 REST 一处判。未读数在每次进页面时也走 REST 拉一次 —— 断线期间到达的通知照样能在红点上看到。未读数接口失败时,铃铛角标显示 !,而不是什么都不显示(那和「没有未读」长得一模一样)。

全员广播在库里只有一行,不按人展开;每个人的已读状态记在 sys_notification_read。账号创建之前写的广播对这个账号算已读,新同事第一次登录不会看到几十个历史红点 —— 「必须确认」的公告除外。

审批人收到新待办时不写站内消息,审批中心的待办角标靠另一个 socket 事件 workflow:todo 刷新(见审批流)。

通知公告能带什么?谁能看到?

Section titled “通知公告能带什么?谁能看到?”

公告正文是富文本(Tiptap 编辑器),最多挂 20 个附件,另有这些开关:

设置效果
类型 · 级别通知 / 公告 · 普通 / 重要 / 紧急
置顶排在前面;置顶 + 紧急 + 未读的公告还会以横幅出现(最多 3 条)
过期时间 / 定时发布过了 expire_time 就看不到;publish_time 之前谁都看不到。Celery 任务 notice.sync_inbox 每 60 秒跑一次,负责到点发出、过期撤回
弹窗登录后弹出,读过才不再弹(最多 10 条,带正文)
必须确认读者要点「我已阅读」;「全部已读」会跳过这些行。必须同时开弹窗,否则 422
登录页可见不登录也能看,每 IP 每分钟 30 次;最多返回 5 条,摘要是去掉标签后的 200 字纯文本
发布范围部门(含所有下级部门)、角色(只认启用的)、用户。一行都没有 = 全员。「登录页可见」和发布范围同时设置会被 422 拒绝

「谁看得见」只有一份定义:crud_notice.py 里的 readable_clause(viewer) —— 没删、显示中、已到发布时间、没过期,并且看的人在发布范围里。读者列表、详情、弹窗、横幅、附件下载权限都用它,收件箱读的也是同一张受众表,所以不会出现「铃铛里有这条,点进去看不到、附件下不了」。隐藏、过期、未到发布时间、不在范围里的公告一律返回 404 —— 返回 403 等于告诉对方「这条存在」。有 sys:notice:add 的管理员能看阅读统计:发布范围里谁读了、谁没读。

附件和公告在同一个事务里提交,每个附件过三道校验:文件存在且不在回收站;发布人自己读得了(否则有发公告权限的人拿到别人私有文件的 ID,就能借公告公开给所有人);不能是公开文件。

一条通知怎么决定走邮件、短信还是群机器人?

Section titled “一条通知怎么决定走邮件、短信还是群机器人?”

每个插件在自己的 notification_kinds.py 里声明通知类型,通知插件启动时扫描。下面是 plugin/workflow/notification_kinds.py 里的原样代码:

register_notification_kind(
'workflow.remind',
label='审批催办',
category=NotificationCategory.WORKFLOW,
defaults={'inapp': 'forced', 'email': 'default_on', 'sms': 'default_off'},
sms_template='workflow_remind',
sms_params=lambda title, content: {'title': title[:_SMS_TITLE_MAX]},
)

service/routing.py 对每个收件人 × 每条外部渠道看三样东西:管理员的系统策略(off / default_on / default_off / forced,在 消息推送 → 通知策略 里改)、本人的个人偏好(个人中心 → 通知偏好,forced 的格子是锁住的)、这个人有没有地址。站内渠道永远是 forced。类型没声明的渠道,策略页上也开不出来。短信必须给业务模板 —— 国内短信只能发审核过的模板;而且只发给本人验证过的手机号,管理员录入的号会记一行 skipped / phone_unverified,不发。

内置的类型和默认值:

类型站内邮件短信群机器人
workflow.remind 审批催办、workflow.timeout 审批超时提醒必发默认开默认关—
workflow.sent_back 申请被退回必发默认开——
workflow.result 审批结果必发默认关—可订阅
workflow.cc 抄送、workflow.withdrawn 审批被撤回必发默认关——
workflow.task_assigned 新的待办不写站内默认关——
announcement 通知公告必发——可订阅
open.webhook.disabled Webhook 端点已停用必发默认开—可订阅
system 系统通知、export.done / export.failed 导出完成 / 失败必发默认关——

全员广播的类型(公告、每日问候)永远不走邮件和短信:站内一行广播,到外部渠道就成了全员群发。

业务代码只有一个入口:notification_service.py 里的 create_in_tx(db, kind=..., user_ids=..., title=..., content=..., link=...)。它在你的事务里写站内行和投递行,不 commit,返回一个 Dispatch;提交之后调它的 after_commit() 推 socket、踢 worker。

Celery 任务 notification.deliver_pending 每 15 秒扫一次,每次提交后也会立刻踢一脚。worker 用 UPDATE … SET status = 'sending' WHERE id = ? AND status IN (…) 抢占,rowcount 恰好是 1 才发。失败的按 60、300、1800、7200 秒退避重试(默认 NOTIFICATION_RETRY_MAX = 4)。短信和群机器人「可能已经发出去了」的结果(连上之后超时)记成 unknown,不重试 —— 重试就是重复发。邮件则对连接错误和超时一律重试:重复一封邮件的代价小于漏掉一条催办。停在 sending 超过 600 秒的行归为 unknown,不会改回待发。

消息推送 → 投递记录 列出每一行的状态(pending / sending / sent / failed / exhausted / skipped / unknown)和原因,列表里手机号、邮箱打码,failed / exhausted / unknown 的行可以手动重发。默认保留 30 天。

飞书(含 open.larksuite.com)、钉钉、企业微信,在 消息推送 → 群机器人 里配置。机器人订阅的是声明了 bot=True 的类型,目前是通知公告、审批结果、Webhook 端点已停用。对着「你」说话的类型(抄送、新的待办、被退回)刻意不开放订阅:推到群里,每个人读到的都是「你」。设了发布范围的公告不推群 —— 机器人发进的是整个群,管不了范围;之后范围放宽成全员时补推一次。

  • 一条通知 × 一个机器人一行投递,和收件人有几个无关。
  • 地址白名单:Webhook 必须落在平台的官方前缀上(协议、主机、端口、路径逐项比;带 userinfo、带 . / .. 路径段的拒绝;不跟随重定向)。私有化部署配 NOTIFICATION_BOT_EXTRA_URL_PREFIXES,生产环境只许 https。
  • 签名:飞书、钉钉支持加签;企业微信没有加签,地址里的 key 就是凭据。
  • 限频在发之前自己数:飞书 100 条/分钟 + 5 条/秒,钉钉、企业微信 20 条/分钟。超了就挪到下一分钟,不算一次重试。
  • 只发纯文本,不用 markdown:标题是用户填的,走 markdown 就能塞进一条看起来像系统发的链接。企业微信正文按 2048 字节截断,不是按字符。
  • 读超时记为 unknown、不重试 —— 群里同一条出现两次,比重复一封邮件显眼得多。

机器人消息和邮件里的链接由 NOTIFICATION_WEB_BASE_URL 拼出(默认 http://localhost:8888),部署前要改成真实访问地址。接一个飞书机器人的完整步骤见群机器人接入指南。

Webhook 地址本身就是凭据:拿到它就能往群里发消息。地址和加签密钥用 NOTIFICATION_ENCRYPT_SECRET_KEY(32 字节,即 64 位十六进制)加密存储;没配这个密钥时直接拒绝保存,不会静默存明文。接口只返回打码后的 webhook_hint,入参里的 webhook_url / sign_secret 在操作日志里会被脱敏。

不那么显眼的一处是 httpx 自己:它在 INFO 级别会打一行 HTTP Request: POST <完整 URL>。第一次真发飞书时,token 就这样进了访问日志。第一版修复只管住了 API 进程,而真正往群里发消息的 Celery worker 有自己的日志配置。现在修复放在 common/log.py 的模块顶层(logging.getLogger('httpx').setLevel(logging.WARNING)),有两条测试守着,其中一条在子进程里按 celery -l info 的方式配日志。

渠道真实环境的证据(出自插件分册)
飞书群机器人2026-10-30,开了签名校验的自定义机器人:测试消息 465 ms 进群;公告经 worker 投递,第 1 次就成功;错密钥回 19021,归为 sign_failed、不重试
钉钉、企业微信群机器人还没在真群里发过,签名和回包分类靠官方文档 + 假服务器
短信插件阿里云 2026-10-24 真发过(验证码模板);腾讯云还没有,靠真 SDK 打本地假服务器的契约测试
经路由发的邮件 / 短信测试里发送层是假的;邮件「5xx 永久失败、4xx 重试」的分类来自 aiosmtplib 的异常层级,不是真服务器的回包
  • 群机器人只按通知类型订阅,不能再按流程或部门过滤。
  • 推群和推人走同一个入口,所以一条可推群的通知如果一个收件人都没有(比如管理员终止自己发起的单),群里也收不到。群机器人指南的练习 2 专门讲怎么把两者拆开。
  • 公告表单的发布范围下拉调的是用户 / 部门 / 角色列表接口,只有发公告权限、没有这三个列表权限的人,看到的是一行「没有权限查看这些数据」。

pytest 在 plugin/notification/tests/ 收集到 117 条(群机器人 66 条,假平台用 httpx.MockTransport;路由与投递 42 条;收件箱 9 条),app/admin/tests/api_v1/test_notice_*.py 公告 52 条,plugin/sms/tests/ 133 条,plugin/workflow/tests/test_notification_channels.py 3 条。例如 test_rollback_takes_the_delivery_rows_with_it、test_sms_needs_a_phone_the_owner_verified、test_read_all_does_not_acknowledge_for_the_reader。按分册记录,公告二期那组测试在 PostgreSQL 16、SQL Server 2022、MySQL 8.4 上都跑过。Playwright 覆盖铃铛、公告、策略与个人偏好,还有一条群机器人用例:测试消息打到假飞书,按官方算法验签。

打开 fra.wubunan.com,用 admin / 123456 登录。铃铛在顶栏,侧边栏有 消息中心;公告在 系统管理 → 通知公告;通知策略、投递记录、群机器人在 消息推送 下;自己的渠道偏好在 个人中心 → 通知偏好。