Gitea源码深度剖析-第12章 通知与 Webhook 体系

0 阅读56分钟

Gitea源码深度剖析-第12章 通知与 Webhook 体系

本章拆开 Gitea 的"通知"与"外部集成"两大出口:站内红点、邮件、动态流、Webhook、审计日志、索引更新……全部业务事件都从同一个进程内多播总线 services/notify 发出,再由 10 个注册的 notifier 各自决定怎么消费。前 11 章与后 3 章的完整导航见第 1 章 1.8 节;第 1 章 1.6.5 节已给出这条总线的 30 行核心与注册表速览,第 10 章 10.7.2–10.7.4 节已讲过通知全链路、notify 总线多播与"webhook 无自动重试"的结论。本章不重复那些内容:聚焦 notifier 的实现细节、站内/邮件通知的收件人计算与实时推送、Webhook 的数据模型、事件体系与 payload 构造,并把投递可靠性放回第 10 章的队列语境里收尾。

12.0.1 本章证伪清单(先校准再展开)

任务描述中的若干说法在源码里不成立,按"Never assume, verify before claim"逐一核实后更正如下(每条后文都有对应小节展开):

任务描述中的假设实测结论详见
Webhook 事件是"位掩码 HOOK_EVENT_*"不存在位掩码,也不存在 HOOK_EVENT_ 前缀常量。实际是 HookEventType 字符串类型常量("push"、"issues"…),订阅关系存成 HookEvents map[HookEventType]bool JSON 到 webhook.events TEXT 列12.6
有一个 "database notifier"没有叫 database 的 notifier。持久化动作分散在 feed(动态流 action 表)、uinotification(notification 表)、indexer(索引)三处12.2
站内通知是 "ui notifier"实际包名是 services/uinotification,类型名 notificationService12.4
存在 "agit notifier"不存在。services/agit 是普通服务包,不调用 RegisterNotifier12.2
外部渠道含 Mastodon没有 Mastodon。HookType 常量共 11 个:gitea/gogs/slack/discord/dingtalk/telegram/msteams/feishu/matrix/wechatwork/packagist12.8
payload 抽象叫 "gitea_utils"实际是 services/webhook/payloader.go 的泛型接口 payloadConvertor[T] + general.go 的 16 个共享文案函数12.8
webhook 有"限速"机制services/webhook 内 grep ratelimit|limiter|retry|Retry 零匹配:无内建限速、无自动重试,只有队列背压与 5 秒投递超时12.9
第 1 章说"11 处注册"、第 10 章说"注册点 11 处(生产 10)"两章口径均与实测一致:grep RegisterNotifier( 命中 11 个 Go 文件 = 生产 10 处 + 测试注入 1 处(transfer_test.go);其中 mailer 是条件注册(ENABLE_NOTIFY_MAIL,默认 false),所以默认配置运行时 9 个,开启邮件后 10 个12.2
Notifier 接口方法数量精确数为 44 个(含 Run)12.1

12.1 Notifier 接口与汇聚器多播

12.1.1 接口全貌:44 个方法就是通知面

services/notify/notifier.go 用 89 行定义了 Notifier 接口。它没有嵌入任何基础接口,也没有可选方法——实现者必须全部实现,惯例是内嵌 NullNotifier(见 12.1.2)。按业务域分组列出全部 44 个方法(逐一核对自 notifier.go:20-89):

分组方法(按文件顺序)数量
生命周期Run1
仓库AdoptRepository CreateRepository MigrateRepository DeleteRepository ForkRepository RenameRepository TransferRepository RepoPendingTransfer8
IssueNewIssue IssueChangeStatus DeleteIssue IssueChangeMilestone IssueChangeAssignee PullRequestReviewRequest IssueChangeContent IssueClearLabels IssueChangeTitle IssueChangeRef IssueChangeLabels11
Pull RequestNewPullRequest MergePullRequest AutoMergePullRequest PullRequestSynchronized PullRequestReview PullRequestCodeComment PullRequestChangeTargetBranch PullRequestPushCommits PullReviewDismiss9
评论CreateIssueComment UpdateComment DeleteComment3
WikiNewWikiPage EditWikiPage DeleteWikiPage3
ReleaseNewRelease UpdateRelease DeleteRelease3
Git 引用PushCommits CreateRef DeleteRef SyncPushCommits SyncCreateRef SyncDeleteRef6
包PackageCreate PackageDelete2
杂项ChangeDefaultBranch CreateCommitStatus WorkflowRunStatusUpdate WorkflowJobStatusUpdate NotificationCountChange StopwatchChanged6

几个值得注意的签名设计:

  • Sync* 三个方法(SyncPushCommits/SyncCreateRef/SyncDeleteRef)与普通 Push* 平行存在,专用于镜像同步产生的推送——普通推送走 push_update 队列,镜像推送来自 mirror 服务,两者在 feed notifier 里处理不同(镜像推送按 ActionMirrorSyncPush 记录,且 IsPull 时跳过);
  • NotificationCountChange(ctx, userID) 与 StopwatchChanged(ctx, user) 不是业务事件,而是UI 状态同步信号——注释明说"Callers must invoke this after any DB write affecting the user's unread count"(notify.go:420)。它们是站内红点实时推送的入口;
  • WorkflowRunStatusUpdate/WorkflowJobStatusUpdate 的注释要求调用方优先用 services/actions/notify.go 的封装,因为那里负责解析正确的 sender 与配对通知(notify.go:402-418);
  • PullRequestReviewRequest 挂在 Issue 分组而非 PR 分组,因为它的签名收的是 issue *issues_model.Issue(评审请求在模型层挂在 issue 上)。

12.1.2 NullNotifier:43 个空方法的嵌入基类

services/notify/null.go 用 227 行给出接口的全量空实现:

// NullNotifier implements a blank notifier
type NullNotifier struct{}

var _ Notifier = &NullNotifier{}

// Run places a place holder function
func (*NullNotifier) Run() {
}

// NewIssue places a place holder function
func (*NullNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
}
...

所有 notifier 都以 notify_service.NullNotifier 内嵌字段起步,只覆写关心的方法——例如 wsNotifier 只覆写 NotificationCountChange 与 StopwatchChanged 两个方法(services/websocket/notification_notifier.go:20-22、stopwatch_notifier.go:18),mirrorNotifier 只覆写 PushCommits/SyncPushCommits 两个。这是 44 方法宽接口能够落地的前提:接口宽、实现窄、默认无操作。代价是隐式的——任何 notifier 都"接收"全部 44 类事件,只是大多数不做事;新增事件方法时,编译器会强迫 NullNotifier 补上空实现,而其余 10 个 notifier 自动继承,不会漏。

用 var _ Notifier = &wsNotifier{} 这类编译期断言验证实现完整性,是每个 notifier 文件的固定开头。

12.1.3 汇聚器:40 行同步 for 循环 + 四道前置闸门

services/notify/notify.go 的汇聚器(多播总线)核心极简:

var notifiers []Notifier

// RegisterNotifier providers method to receive notify messages
func RegisterNotifier(notifier Notifier) {
	go notifier.Run()
	notifiers = append(notifiers, notifiers) // 实际为 append(notifiers, notifier)
}

// NewIssue notifies new issue to notifiers
func NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
	for _, notifier := range notifiers {
		notifier.NewIssue(ctx, issue, mentions)
	}
}

(上例第一处 append 为排版示意,真实代码是 notifiers = append(notifiers, notifier),见 notify.go:23-26。)44 个事件函数全是同一模板:同步 for 循环、逐个调用、不聚合错误、不并发。

真正的逻辑集中在四道前置闸门,它们决定"事件要不要发":

  1. Pending review 闸门——CreateIssueComment/UpdateComment/DeleteComment 三个入口共用:
func shouldSendCommentChangeNotification(ctx context.Context, comment *issues_model.Comment) bool {
	if err := comment.LoadReview(ctx); err != nil {
		log.Error("LoadReview: %v", err)
		return false
	} else if comment.Review != nil && comment.Review.Type == issues_model.ReviewTypePending {
		// Pending review comments updating should not triggered
		return false
	}
	return true
}

未提交的 review 草稿(逐字评论)不触发任何通知。闸门放在总线层而不是各 notifier,保证 10 个消费方行为一致——这是"汇聚器不只是哑管道"的证据。

  1. 属性预加载闸门——部分事件在多播前强制 Load,失败即吞掉整个事件(不广播):

    • NewPullRequest:pr.LoadIssue + pr.Issue.LoadPoster(notify.go:109-120);
    • PullRequestReview:review.LoadReviewer(notify.go:130-138);
    • PullRequestCodeComment:comment.LoadPoster(notify.go:141-149);
    • NewRelease:rel.LoadAttributes(notify.go:195-203)。

    设计动机:这些字段是几乎所有 notifier(webhook payload 的 sender、feed 的 ActUserID、邮件的收件人)都要用的。在总线做一次,避免每个 notifier 重复查库;失败时宁可不通知,也不能发出半成品 payload。

  2. shouldSendCommentChangeNotification 只护评论三方法——NewIssue 没有任何闸门,IssueChangeStatus 也没有。

  3. NotificationCountChange/StopwatchChanged 无闸门无 Load——它们参数最小(一个 userID),直接多播。

12.1.4 同步多播的语义与故障边界

多播发生在调用方的 goroutine 里(通常是 HTTP handler 或某条队列的 worker),这意味着三件事:

  • 顺序保证:同一事件对 notifier 的调用顺序 = 注册顺序,且每个 notifier 的方法返回后才调下一个。webhook notifier 若在构造 payload 时慢(它内部只做 CreateHookTask + 入队,很快),会推迟后面 notifier 的执行;
  • 无隔离:任何一个 notifier 的方法 panic,会沿调用栈炸掉整个事件广播——汇聚器没有 recover。唯一的 recover 在 services/webhook/deliver.go:153-160 的 Deliver(那里包了 defer recover(),因为投递走网络,panic 风险真实存在);notifier 层不 recover 是刻意的:notifier 属于站内代码,panic 意味着 bug,应尽早暴露(开发/测试环境 PanicInDevOrTesting 亦同此理);
  • fire-and-forget:事件函数返回 void,调用方拿不到任何 notifier 的执行结果。可靠性完全由各 notifier 内部的队列承担——这是第 1 章 1.6.5 节的结论,也是第 10 章 10.7.2 节"关掉邮件不影响 webhook"的机制解释。

各 notifier 的同步/异步边界(本章 12.3 有完整表):多播本身永远同步,异步发生在 notifier 内部——只有 feed 与 audit 两个 notifier 在多播调用里同步写库,其余 8 个一律只 Push 队列(或如 wsNotifier 只读一次 Count 再 Publish)。

12.2 注册清单与启动时序

12.2.1 精确计数:10 处生产注册 + 1 处测试注入

grep RegisterNotifier( 全仓实测(命中 12 个文件,其中 docs/tech-article/chapter-14 是文章示例,排除),Go 代码共 11 处:

#注册点注册时机覆写方法数(约)下游
1services/webhook/notifier.go:32package init()全量约 40webhook_sender unique 队列
2services/mirror/notifier.go:16package init()2(PushCommits/SyncPushCommits)同步镜像队列
3services/audit/notifier.go:19package init()仓库/issue/PR/wiki 类若干审计日志写库(同步)
4services/feed/notifier.go:31feed.Init()21动态流 action 表(同步事务)
5services/uinotification/notify.go:36uinotification.Init()13notification-service Simple 队列
6services/indexer/indexer.go:15indexer.Init()issue 变更类索引队列
7services/automerge/automerge.go:35automerge.Init(ctx)PR 合并条件类pr_auto_merge 队列
8services/websocket/notifier.go:16websocket.Init()2(红点/秒表)pubsub Broker
9services/actions/init.go:69actions.Init(ctx)workflow 触发类Actions 工作流
10services/mailer/mailer.go:34mailer.NewContext,条件注册 if setting.Service.EnableNotifyMail16mail Simple 队列
11services/repository/transfer_test.go:31测试内注入 feed.NewNotifier()—用 feed 替身验证转移通知

计数口径说明(回应第 1 章 1.6.5 的"11 处"与第 10 章 10.7.2 的"注册点 11 处(生产 10)"):两章与本次实测完全一致——11 = 10 生产 + 1 测试。唯一需要补的细节是第 10 处 mailer 受 ENABLE_NOTIFY_MAIL 控制(modules/setting/mailer.go:274-280,MustBool() 默认 false):默认配置运行时 9 个 notifier,开启邮件后 10 个;第 10 章表中"生产 10 个"按 mailer 恒计入。

12.2.2 两类注册时机:package init() 与显式 Init()

注册分两个阶段,启动顺序由 routers/init.go 的 InitWebInstalled 决定:

// package init():import 即注册,早于任何 Init 调用
func init() {
	notify_service.RegisterNotifier(NewNotifier()) // services/webhook/notifier.go:31
}

webhook、mirror、audit 三个 notifier 走 package init()——只要二进制 import 了对应包就在 main() 之前完成注册。它们不依赖任何前置服务,所以敢放在 init()。

其余 7 个(mailer/feed/uinotification/indexer/automerge/websocket/actions)由 routers/init.go 按依赖顺序显式初始化(节选,序号即执行序;webhook.Init 本身不注册 notifier——注册已在 init() 完成——它建的是投递队列):

mailer.NewContext(ctx)              // 128:条件注册 mail notifier + 建 mail 队列
mustInit(feed_service.Init)         // 130:注册动态流 notifier
mustInit(uinotification.Init)       // 131:注册站内通知 notifier + 建队列
mustInit(indexer_service.Init)      // 151:注册索引 notifier
mustInit(webhook.Init)              // 154:建 webhook_sender 队列 + HTTP client + 启动回灌
mustInitCtx(ctx, automerge.Init)    // 156:注册自动合并 notifier
mustInit(websocket_service.Init)    // 158:pubsub.Init → 注册 wsNotifier
mustInitCtx(ctx, actions_service.Init) // 168:注册 Actions notifier

三个关键依赖关系:

  • websocket 必须先初始化 pubsub。services/websocket/notifier.go:11-18 明写"the pubsub broker must be ready before the notifier starts publishing to it",Init() 先 pubsub.Init()(按 setting.Websocket.PubsubType 换成 Memory 或 Redis Broker)再 RegisterNotifier;否则 publishUserEvent 会落到包级默认的空 MemoryBroker 上(services/pubsub/broker.go:31-35 注释解释:默认值让 CLI 入口不 nil 检查);
  • mailer 是唯一的条件注册。EnableNotifyMail=false 时 mail notifier 根本不进 notifiers 切片,MailParticipants 里的 setting.MailService == nil 短路只是第二道保险(mail_issue.go:167-170);
  • mustInit 失败即 log.Fatal(routers/init.go:60),任何 notifier 建队失败都会阻止站点启动——呼应第 10 章 10.7.4 的兜底矩阵。

package init() 型注册点有个隐含行为:webhook.Init()(services/webhook/deliver.go:274)建的 webhook_sender 队列晚于 webhookNotifier 注册(notifier.go:31)——若在两者之间有事件进来,enqueueHookTask 会因 hookQueue == nil 崩溃。实际上不会发生:InitWebInstalled 同步走完这段窗口才开始服务 HTTP 请求。

12.2.3 为什么没有 "agit notifier" 与 "database notifier"

  • agit:services/agit/ 是处理 AGit-flow 推送评论的服务包,它产生事件(通过 repository.PushUpdates 进入 push 队列)但不消费 notify 总线——grep RegisterNotifier 无一命中;
  • database:任务描述里的 "database notifier" 大概率指动态流与站内通知这两个写库消费者,但它们是两个独立 notifier(feed、uinotification),加上 indexer 共三个"落库型"消费方,没有统一的 database notifier。第 1 章 1.6.5 的表用的也是真实名(feed/uinotification/indexer)。

12.3 调用点与同步/异步语义

12.3.1 真实调用点:三个进入总线的口子

业务代码不直接 import 各 notifier,只调 notify_service.Xxx。grep notify_service\.(NewIssue|MergePullRequest|PushCommits|CreateIssueComment|NewPullRequest)\( 实测的入口分布:

入口位置上下文
notify_service.NewIssue(ctx, issue, mentions)services/issue/issue.go:69创建 issue 的事务提交后(同步调用)
notify_service.CreateIssueComment(...)services/issue/comments.go:92、services/pull/review.go:152评论落库后
notify_service.NewPullRequest(ctx, pr, mentions)services/pull/pull.go:187PR 创建后
notify_service.MergePullRequest(ctx, doer, pr)services/pull/merge.go:387合并成功后
notify_service.PushCommits(...) ×5services/repository/push.go:105/127/194、services/release/release.go:135/395push_update 队列 worker 内(tag 删除/新建、分支推送)
notify_service.WorkflowRunStatusUpdate 等services/actions/notify.go:81/105/120/133/146Actions 运行状态机

这揭示调用点的两级结构:

  1. HTTP 直调级:issue/PR/评论类事件在 handler goroutine 内同步多播——多播的耗时预算就是"每个 notifier 一次 Push",所以 HTTP 响应不受通知影响;
  2. 队列 worker 级:push 类事件先入 push_update 队列(services/repository/push.go:33-48 的 CreateSimpleQueue("push_update", ...)),由 worker 在推送钩子处理完毕后多播。git push 的通知天然异步,因为要先 gitRepo.GetCommit、UpdateIssuesCommit、算 CompareURL、截断 FeedMaxCommitNum(push.go:181-194)。

12.3.2 一条 git push 的完整通知序列

pushQueueHandleUpdates(services/repository/push.go:62-210)对一个 ref 的推送会连续发出多个总线事件:

  • 新分支:pushNewBranch → 随后 notify_service.PushCommits(...)(push.go:194);
  • 更新分支:pushUpdateBranch(内部 AddTestPullRequestTask)→ PushCommits;
  • 删分支:pushDeleteBranch(内部发 DeleteRef);
  • 新 tag:PushCommits + CreateRef(push.go:127-136);
  • 删 tag:PushCommits(NewCommitID 置空对象 ID)+ DeleteRef(push.go:105-114)。

注意次序:先 PushCommits 后 CreateRef。这让 webhook 订阅者先收到 push payload 再收到 create payload,与 GitHub 生态一致。同时 WatchIfAuto(push.go:205)把推送者自动设为仓库 watcher——推送者从下一次事件起会出现在收件人计算里(12.4/12.5 的收件人链都依赖 watcher 表)。

12.3.3 各 notifier 的同步/异步语义对照

notifier多播方法内做的第一件事同步部分的耗时说明
webhook构造 api payload → CreateHookTask(DB insert)+ hookQueue.Push1 次 insert + 入队入队即返回,投递在 worker
uinotificationissueQueue.Push(opts)O(1) 入队收件人计算在队列 worker(CreateOrUpdateIssueNotifications)
mailer收件人查询 + 组装 Message → SendAsync(goroutine 内 mailQueue.Push)同步查收件人(多次 DB 查询)sendAsync 再开 goroutine 推队列,mail_issue.go 的 Load 三件套也在同步段
feeddb.WithTx 内给每个 watcher insert 一条 actionO(关注者数) 同步写库全总线最重的同步 notifier;NotifyWatchers 事务内逐 watcher 算权限(feed/feed.go:100-163)
audit写审计日志小同步
websocketHasTopicSubscribers 捐导 → db.Count unread → Publish1 次 count(有订阅者时)无订阅者直接返回
indexer推索引队列O(1)异步
mirrorsyncPushMirrorWithSyncOnCommit 入队O(1)异步
automerge推 pr_auto_merge 队列O(1)异步
actions触发 workflow(入队)O(1)异步

为什么 feed 敢同步写:动态流的读模型就是 action 表,插入必须与业务事务语义紧邻;且 watcher 数量在中小实例可接受。它的边界保护在 notifyWatchers 内:Content 超 MySQL TEXT 65535 上限时,JSON 类内容置 "{}"、普通内容截断(feed/feed.go:38-47,注释坦言 FIXME)。

为什么 mailer 把收件人查询放同步段:mailIssueCommentToParticipants 要读 issue/repo/poster 属性(mail_issue.go:30-38 三个 Load),这些依赖调用方刚改过的数据,同步读能拿到最新值;真正的发送(可能几百封)进 mail 队列。

12.3.4 fire-and-forget 的工程后果

调用方丢弃返回值(事件函数无返回值)带来四个可观察后果:

  1. 通知丢失不回滚业务:CreateHookTask 失败只 log.Error(webhook/notifier.go 各方法统一 log.Error("PrepareWebhooks ...") 后继续),issue 照常创建;
  2. 顺序只在单事件内保证:两条评论的两次多播若发生在不同请求 goroutine,各自入队顺序不保证与时间序一致——队列 worker 按入队序处理,CommentID 单调递增可作弱序参考;
  3. 无背压到业务:队列满时 Push 返回 queue.ErrAlreadyInQueue(unique 队列)或超时错误,uinotification 直接 _ = ns.issueQueue.Push(opts) 忽略(uinotification/notify.go:82 等全部调用点);
  4. 测试可整体替换:transfer_test.go:31 注册 feed.NewNotifier() 作为观察替身,正因总线是"注册即多播"的可注入切面。

12.4 站内通知:Notification 模型、收件人计算与实时推送

站内通知分三层:模型层(models/activities 的 Notification 表)、服务层(services/uinotification 入队 + services/notifications 状态变更)、推送层(services/websocket + services/pubsub + 浏览器 SharedWorker)。

12.4.1 Notification 结构与两组枚举

models/activities/notification.go:51-73:

// Notification represents a notification
type Notification struct {
	ID     int64 `xorm:"pk autoincr"`
	UserID int64 `xorm:"NOT NULL"`
	RepoID int64 `xorm:"NOT NULL"`

	Status NotificationStatus `xorm:"SMALLINT NOT NULL"`
	Source NotificationSource `xorm:"SMALLINT NOT NULL"`

	IssueID   int64 `xorm:"NOT NULL"`
	CommitID  string
	CommentID int64

	UpdatedBy int64 `xorm:"NOT NULL"`

	Issue      *issues_model.Issue    `xorm:"-"`
	Repository *repo_model.Repository `xorm:"-"`
	Comment    *issues_model.Comment  `xorm:"-"`
	User       *user_model.User       `xorm:"-"`

	CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
	UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
}

两组枚举都从 iota + 1 起算(0 留作零值无效):

NotificationStatus值说明
NotificationStatusUnread1未读(红点计数只数这个)
NotificationStatusRead2已读
NotificationStatusPinned3置顶(API status-types 默认查 unread+pinned)
NotificationSource值说明
NotificationSourceIssue1普通 issue
NotificationSourcePullRequest2PR(issue.IsPull 时创建函数自动选)
NotificationSourceCommit3commit(保留字段)
NotificationSourceRepository4仓库级事件(仅 repo 转移通知用到)

索引设计(TableIndices(),notification.go:76-112):核心是复合索引 u_s_uu(user_id, status, updated_unix)——红点计数查询 WHERE user_id=? AND status=1 正好吃满前两列,列表页 user_id+status+order by updated_unix 也能走索引免排序。此外还有 user_id/repo_id/status/source/issue_id/commit_id/updated_by 七个单列索引(由原 struct tag 迁移而来,注释明说"previously defined in struct tags")——偏冗余,但换来了任意过滤组合的可用性。

一个反直觉的 NOTICE(updateIssueNotification,notification.go:176 起):更新已有通知时 CommentID 只在通知已是已读状态时才更新。语义是:未读通知上的 CommentID 指向"最初触发它的那条评论",用于未读列表的锚点跳转;若每次都更新,用户点红点会跳到最新评论而非触发点。已读通知没有锚点语义,才允许跟随最新活动。

CreateRepoTransferNotification(notification.go:119-155)是唯一往 Source=Repository 写记录的函数:仓库转移时给新属主(组织则给所有有权创建 org repo 的成员,跳过 bot)插一条 unread 通知,IssueID=0——列表渲染时按 Source 分派模板。

12.4.2 uinotification:一个队列 + 一个 handler 的极简服务

services/uinotification/notify.go 全部核心:

type (
	notificationService struct {
		notify_service.NullNotifier
		issueQueue *queue.WorkerPoolQueue[issueNotificationOpts]
	}

	issueNotificationOpts struct {
		IssueID              int64
		CommentID            int64
		NotificationAuthorID int64
		ReceiverID           int64 // 0 -- ALL Watcher
	}
)

func handler(items ...issueNotificationOpts) []issueNotificationOpts {
	ctx := graceful.GetManager().ShutdownContext()
	for _, opts := range items {
		notifiedIDs, err := activities_model.CreateOrUpdateIssueNotifications(ctx, opts.IssueID, opts.CommentID, opts.NotificationAuthorID, opts.ReceiverID)
		if err != nil {
			log.Error("Was unable to create issue notification: %v", err)
			continue
		}
		for _, userID := range notifiedIDs {
			notify_service.NotificationCountChange(ctx, userID)
		}
	}
	return nil
}

要点:

  • 队列类型:queue.CreateSimpleQueue("notification-service", handler)(NewNotifier,notify.go:44-51),队列建失败直接 log.Fatal——通知是站点基础能力,呼应第 10 章 10.7.4 的"Fatal 档";
  • ReceiverID 是收件人开关:0 表示"按订阅关系算全体收件人",>0 表示只通知这一个人(mention、被指派人、被请求评审者走这条路径)。所以一次带 3 个 mention 的评论会入 4 条任务:1 条广播 + 3 条单播;
  • handler 尾部的二次广播:每写完一批通知,对每个 notifiedIDs 调 notify_service.NotificationCountChange——这正是 wsNotifier 收到后查 Count 并推给浏览器的信号(12.4.4)。注意它调用的是总线,所以又会多播给全部 notifier;但除 wsNotifier 外没有其他人覆写该方法,实际只有一跳;
  • 队列返回 nil:handler 永不交还 unhandled,写库失败只记日志——与 webhook 同款 fire-and-forget(第 10 章 10.7.4 的"降级继续"档,但因 Simple 队列本身不持久化,失败即丢)。

覆写的 13 个方法里有三处非平凡逻辑:

  1. NewPullRequest 手动圈人(notify.go:142-175):新 PR 没有"issue 级订阅"可依赖(用户还没来得及 watch),所以手动收集 GetRepoWatchersIDs(WatchPullRequests) + GetParticipantsIDsByIssueID − poster + mentions,进 container.Set[int64] 去重后逐个单播;
  2. IssueChangeTitle 只认 WIP 解除(notify.go:118-129):仅当旧标题是 WIP 前缀且新标题不是(HasWorkInProgressPrefix(oldTitle) && !IsWorkInProgress)才通知——"PR 转正 ready_for_review"值得打扰,普通改标题不值得;
  3. RepoPendingTransfer 是唯一的同步写(notify.go:260-267):db.WithTx 直接调 CreateRepoTransferNotification,不开队列——转移通知必须与转移事务成败一致,不能异步丢了。

其余方法(CreateIssueComment/NewIssue/IssueChangeStatus/MergePullRequest/PullRequestReview/IssueChangeAssignee/PullRequestReviewRequest…)全是模板:构造 issueNotificationOpts 后 _ = ns.issueQueue.Push(opts),错误丢弃。

12.4.3 收件人计算:createOrUpdateIssueNotifications 的七步筛选

models/activities/notification_list.go:83-192,在一个事务内完成:

if receiverID > 0 {
	toNotify = make(container.Set[int64], 1)
	toNotify.Add(receiverID)
} else {
	toNotify = make(container.Set[int64], 32)
	issueWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, true)   // ① issue 级 watch
	toNotify.AddMultiple(issueWatches...)
	if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {   // ② WIP PR 排除仓库级 watch
		watchType := util.Iif(issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues)
		repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, watchType)
		toNotify.AddMultiple(repoWatches...)
	}
	issueParticipants, err := issue.GetParticipantIDsByIssue(ctx)               // ③ 参与者(评论者/评审者)
	issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID)     // ④ 指派人
	if issue.IsPull {
		issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID) // ⑤ 被请求评审者
	}
	delete(toNotify, notificationAuthorID)                                     // ⑥ 去掉触发者本人
	issueUnWatches, err := issues_model.GetIssueWatchersIDs(ctx, issueID, false)// ⑦ 去掉显式 unwatch
	for _, id := range issueUnWatches { toNotify.Remove(id) }
}

// muting the repository outranks every other source, including mentions
ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, issue.RepoID)              // ⑧ 仓库级 ignore 最高优先
for _, id := range ignorers { toNotify.Remove(id) }

随后逐 user 过滤(155-190 行):IsTypeBot 跳过、CheckRepoUnitUser 按 issue/PR 单元读权限拦截(私有仓库协作者之外的人即使残留 ID 也进不来),最后按"已存在→update / 不存在→create"落库并返回 notifiedIDs。

与邮件收件人(12.5.2)对照的三个差异:

维度站内通知邮件
receiverID>0 单播有(mention/指派/评审请求)无(邮件走 fromMention 标志混批)
显式 unwatch 的 mention依然通知(unwatch 只在广播分支剔除)不发(visited.AddMultiple 在 mention 批之后、广播批之前,统一排除)
仓库 ignore两者都最高优先同左
权限过滤CheckRepoUnitUser 同款同款

FindNotificationOptions(同文件 35-70 行)是列表查询的统一入口,ToConds 拼 UserID/Status/Source/IssueID/UpdatedBefore/After 条件;routers/api/v1/notify/notifications.go 的 getFindNotificationOptions 把 API 参数映射过去——status-types 默认 ["unread","pinned"](不传 all=true 时),subject-type=issue|pull|commit|repository 映射到四个 Source 常量。

12.4.4 实时推送:pubsub Broker 的双实现

推送链的信号源是 NotificationCountChange。wsNotifier 的实现(services/websocket/notification_notifier.go:26-39):

func (n *wsNotifier) NotificationCountChange(ctx context.Context, userID int64) {
	if !pubsub.DefaultBroker.HasTopicSubscribers(pubsub.UserTopic(userID)) {
		return
	}
	count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
		UserID: userID,
		Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
	})
	if err != nil { ... return }
	publishUserEvent(userID, EventNotificationCount, notificationCountEventData{Count: count})
}

先捐导后查询:HasTopicSubscribers 为 false(该用户没开任何 WebSocket 连接)就直接返回,省掉一次 Count 查询。这就是 Broker 接口里 HasTopicSubscribers 存在的全部理由——注释称之为 "an optimization hint for publishers that would otherwise do a DB lookup just to discover nobody is listening"(services/pubsub/broker.go:25-28)。

Broker 接口与双实现(services/pubsub/):

type Broker interface {
	Subscribe(topic string) (<-chan []byte, func())  // cancel 幂等、关闭 channel
	Publish(topic string, msg []byte)                // 非阻塞:慢订阅者丢消息不堵发布者
	HasTopicSubscribers(topic string) bool           // 无法廉价回答的后端恒返 true
}

const subChanBuffer = 8 // 落后 8 条即开始丢

var DefaultBroker Broker = NewMemoryBroker() // CLI 入口跳过 Init 也不 nil 崩

func UserTopic(userID int64) string { return fmt.Sprintf("user-%d", userID) }
MemoryBroker(memory.go)RedisBroker(redis.go)
适用单实例(默认 PUBSUB_TYPE=memory)多进程/多实例
SubscribeRLock 下挂 chan,cancel 用 sync.Once 关闭每 topic 一条 Redis SUBSCRIBE 由本地订阅者共享,最后一个 cancel 才拆
PublishRLock 横跨整个扇出(防 cancel 在读 slice 与 send 之间关 channel)PUBLISH gitea-ws-topic:<topic>,HammerContext + 2s 超时
慢订阅者select/default 丢弃并 Trace同款 select/default 丢弃
HasTopicSubscriberslen(subs)>0 真实回答恒 true(注释:PUBSUB NUMSUB 是 per-node,集群模式会漏;"publishers do the upstream lookup unconditionally")
断线恢复不适用readLoop 收消息失败→拆掉整个 topic 并关所有 sub channel→WebSocket handler 收到 channel 关闭即 return→浏览器重连→重新 Subscribe 重建

redis.go 的两处注释值得原样转述,因为它们解释了两个微妙设计:

  • Subscribe 慢路径故意不等 SUBSCRIBE 确认:"don't wait for it here, that would put a Redis round-trip in the WebSocket handshake to close a sub-millisecond window in which a publish is missed — harmless, since a client receives nothing at all until its handshake completes";
  • 建 topic 时用 context.Background 而非 shutdown ctx 的 ping 重试(10 次 × 1s),"to ride out docker-compose start-order races"(与 modules/queue 同款处理)。

Init()(init.go:15-29)按 setting.Websocket.PubsubType 选择 memory/redis,未知值返回 error 被 mustInit 放大为启动失败。

12.4.5 WebSocket 端点与消息线格式

routers/web/websocket/websocket.go 的 Serve 处理 /-/ws:

  • 非 Upgrade 请求(健康检查、爬虫)直接回 426 Upgrade Required,不让 Accept 报错刷日志(50-58 行);
  • 未登录连接 close(3000)——IANA library-reserved 范围首码,注释解释这是给 SharedWorker 的哨兵:"your cookie is gone" ≠ 网络抖动,收到 3000 应停止重连(26-30 行);
  • 订阅 pubsub.UserTopic(ctx.Doer.ID) 后进入 select 循环:30s ping(10s 超时)、10s 写超时(防慢客户端饿死 ping ticker)、shutdown 退出;
  • filterLogout(36-48 行):logout 事件只透传给目标 session(SessionID 匹配或为空表示全部),其他连接丢弃——多标签页里"一个标签退出只踢一个"。

消息线格式是 services/websocket/events.go 定义的双段协议,文件头注释 "Wire contract with web_src/js/user-events.sharedworker.ts — keep in sync":

const (
	EventNotificationCount = "notification-count"
	EventStopwatches       = "stopwatches"
	EventLogout            = "logout"
)

// 线上字节 = eventType + "\n" + JSON{eventType, eventData}
buf.WriteString(eventType); buf.WriteByte('\n'); json.MarshalWrite(buf, ...)

ExtractUserEventMessage 用 bytes.Cut(b, []byte("\n")) 拆回两段。第一段 eventType 是免解析路由:SharedWorker 不反序列化 JSON 就能把事件分发给对应 tab 的回调;第二段才是 UserEventMessage[T]{EventType, EventData} 的 JSON(同一信息冗余出现两次,换取两侧的独立可用)。注意 MakeUserEventMessage 里 json v1 adds extra "\n" 的 TrimSuffix——JSON 编码器的换行尾巴会破坏 Cut 语义,必须掐掉。

12.4.6 浏览器端:SharedWorker 单连接复用与轮询降级

web_src/js/user-events.sharedworker.ts 让同一 origin 的所有 tab 共享一条 WebSocket:

  • 连接成功向各 tab 广播 worker-connected;断线按 1s 起指数退避到 60s、每次 ×50%~150% 随机抖动(防惊群)重连;
  • 连续 3 次失败发 worker-unavailable——通知页面据此降级;
  • 收到 close code 3000 立即停止重连(与服务端哨兵呼应);其他关闭码继续退避重试。

web_src/js/features/notification.ts 注册三个回调:

onUserEvent('notification-count', (msg) => { receiveUpdateCount(msg.eventData.count) });
// On each (re)connect, reconcile the count from the server to recover any push dropped during the connect gap.
onUserEvent('worker-connected', () => { updateNotificationCount(); updateNotificationTable() });
onUserEvent('worker-unavailable', () => {
  if (pollerStarted) return;
  pollerStarted = true;
  startPeriodicPoller(notificationSettings.MinTimeout);
});

三层容错设计:

  1. 连接间隙对账:worker-connected 时主动 GET /notifications/new 拉一次真值——断线期间 pubsub 按 subChanBuffer=8 丢的消息在这里补齐(注释直说 "recover any push dropped during the connect gap");
  2. 轮询退避:updateNotificationCountWithCallback 比较上次与当前计数,有变化回落到 MinTimeout,无变化逐次 += TimeoutStep 直至 MaxTimeout;
  3. 列表渲染防乱序:updateNotificationTable 给每次请求编号(sequence-number),响应回来若编号不是最新则丢弃——避免慢响应覆盖新内容(81 行 parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber)。

因此"红点永远会动"的保证来自推送 + 对账 + 轮询三层,WebSocket 只是第一层的加速器。

12.4.7 状态变更的推送一致性:services/notifications 包

光有"产生通知时推送"不够——读掉一条通知同样要让红点减一。services/notifications/status.go 的包注释说明其存在理由:

// Package notifications wraps activities_model notification-status mutations
// with the matching real-time push, so route handlers cannot forget either side.

四个函数 SetIssueReadBy/SetNotificationStatus/SetManyNotificationStatuses/MarkAllRead 全是同一模式:先调 activities_model 的变更,仅在 changed 时补一次 notify_service.NotificationCountChange(ctx, userID)。SetIssueReadBy 和 MarkAllRead 有 changed/changed > 0 判断(重复点"全部已读"第二次不产生推送),SetNotificationStatus 无条件推(单条状态变更总是有效)。路由层只允许调这个包,不允许直接调 model 层——"cannot forget either side" 是用目录结构强制的约定。

轮询路径(12.4.6 的 /notifications/new)与 API GET /notifications/new(routers/api/v1/notify/notifications.go:18-36)都直接 db.Count unread——计数永远现算,不存冗余计数列,所以"推送丢了"只会造成延迟,不会造成永久错值。

12.5 邮件通知:services/mailer 全解

邮件是 mailer notifier 消费总线后的产物。全链路:事件 → 收件人计算 → 按语言分批 → 模板渲染 → mail 队列 → sender(SMTP/sendmail/dummy)。

12.5.1 mailNotifier:16 个覆写方法与各自的触发条件

services/mailer/notify.go 覆写的方法及其守卫条件(全部核对自源码):

方法守卫/特殊逻辑下游入口
CreateIssueCommentcomment.Type 映射 ActionType;PullRequestPush 类型 act=0MailParticipantsComment
NewIssue—MailParticipants(ActionCreateIssue)
IssueChangeStatus按 isPull × isClosed 组合 4 种 ActionTypeMailParticipants
IssueChangeTitle仅 WIP→ready_for_review(HasWorkInProgressPrefix(oldTitle) && !IsWorkInProgress)MailParticipants(ActionPullRequestReadyForReview)
NewPullRequest—MailParticipants(ActionCreatePullRequest)
PullRequestReviewcomment.Type 映射(close/reopen/comment)MailParticipantsComment
PullRequestCodeComment只发 mentionMailMentionsComment
IssueChangeAssignee!removed && doer!=assignee && preference!=disabledSendIssueAssignedMail
PullRequestReviewRequestisRequest && doer!=reviewer && preference!=disabledSendIssueAssignedMail
MergePullRequest/AutoMergePullRequest先 pr.LoadIssueMailParticipants
PullRequestPushCommits加载 comment→issue→repo→pull→baseRepo 五层 + LoadCommentPushCommits转调自身 CreateIssueComment
PullReviewDismisscomment.Review.LoadReviewerMailParticipantsComment(ActionPullReviewDismissed)
NewRelease**`rel.IsDraftrel.IsPrerelease` 直接 return**MailNewRelease
RepoPendingTransfer—SendRepoTransferNotifyMail
WorkflowRunStatusUpdate—MailActionsTrigger(Actions 触发的邮件,模板 mail/repo/actions/workflow_run)

注意 PullRequestPushCommits 的写法——它把"PR 收到新推送"降级复用为一次普通评论通知(末行 m.CreateIssueComment(...)),避免为 push 单独写一套收件人逻辑。

12.5.2 收件人计算:五段来源 + 三重排除

services/mailer/mail_issue.go 的 mailIssueCommentToParticipants(28-113 行)是新 issue 与评论共用的收件人引擎。收集顺序(unfiltered 切片的组装):

// =========== Original poster ===========
unfiltered[0] = comment.Issue.PosterID
// =========== Assignees ===========
ids, err := issues_model.GetAssigneeIDsByIssue(...)
// =========== Participants (i.e. commenters, reviewers) ===========
ids, err = issues_model.GetParticipantsIDsByIssueID(...)
// =========== Issue watchers ===========
ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, true)
// =========== Repo watchers ===========
// Make repo watchers last, since it's likely the list with the most users
if !(comment.Issue.IsPull && comment.Issue.PullRequest.IsWorkInProgress(ctx) && comment.ActionType != activities_model.ActionCreatePullRequest) {
	watchType := util.Iif(comment.Issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues)
	ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID, watchType)
	unfiltered = append(ids, unfiltered...)   // 注意:repo watchers 插到头部
}

append(ids, unfiltered...) 把 repo watchers 放最前,而注释说 "Make repo watchers last"——两处"last"指的不是切片位置而是后面的处理:repo watchers 人最多,放在查询序列末尾是为了最后才做最贵的 GetRepoWatchersIDs,切片顺序本身无语义(收件集合是 set 语义,经 visited 去重)。

三重排除(顺序敏感,全部在 visited container.Set[int64] 上做):

// muting the repository outranks every other source, including mentions
ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, comment.Issue.RepoID)
visited.AddMultiple(ignorers...)                          // ① 仓库 mute:最高优先,先入 visited 就谁也加不进来

// Avoid mailing the doer
if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification {
	visited.Add(comment.Doer.ID)                          // ② 触发者自己(除非 andyourown 或 ForceDoer)
}

// =========== Mentions ===========  先发 mention 批
mailIssueCommentBatch(ctx, comment, mentions, visited, true)

// Avoid mailing explicit unwatched
ids, err = issues_model.GetIssueWatchersIDs(ctx, comment.Issue.ID, false)
visited.AddMultiple(ids...)                              // ③ 显式 unwatch:只排除广播批,拦不住 mention 批

unfilteredUsers, err := user_model.GetMailableUsersByIDs(ctx, unfiltered, false)
mailIssueCommentBatch(ctx, comment, unfilteredUsers, visited, false)

①→②→mention→③→广播 的次序承载三条业务规则:仓库级 mute 压倒一切(包括被 @ 到);自己不收自己的信;显式取消订阅 issue 的人仍会收到"被 @"的信(unwatch 只是对广播的 opt-out)。ForceDoerNotification 唯一实例是自动合并(MailParticipants 里 forceDoerNotification := opType == ActionAutoMergePullRequest,mail_issue.go:178)——自动合并的 doer 是触发者,需要收到确认信。

mailIssueCommentBatch(115-161 行)是最终闸门,四道过滤 + 分批:

const MailBatchSize = 100 // batch size used in mailIssueCommentBatch

for _, user := range users {
	if !user.IsMailable() { continue }                       // 未激活/保留地址等
	if !(preference == Enabled || preference == AndYourOwn ||
		fromMention && preference == OnMention) { continue }  // 个人偏好四选一
	if !visited.Add(user.ID) { continue }                    // 去重
	if !access_model.CheckRepoUnitUser(..., checkUnit) { continue } // 单元读权限
	langMap[user.Language] = append(langMap[user.Language], user)
}
for lang, receivers := range langMap {
	for i := ((len(receivers) - 1) / MailBatchSize) * MailBatchSize; i >= 0; i -= MailBatchSize {
		msgs, err := composeIssueCommentMessages(ctx, comment, lang, receivers[i:], fromMention, "issue comments")
		SendAsync(msgs...)
		receivers = receivers[:i]
	}
}

按 Language 分组(每组独立模板语言),组内从尾部倒序切 100 人一批——((len-1)/100)*100 起步保证最后一批不足 100 也被覆盖。批的意义是"每批一次模板渲染、一次队列 Push",而非限制总量(composeIssueCommentMessages 会把同一 subject/body 复制 N 份 Message,收件人互不可见——注释 "Make sure to compose independent messages to avoid leaking user emails",12.5.4)。

个人偏好常量(models/user/user.go:83-92):

常量值语义
EmailNotificationsEnabledenabled收全部(除自己的)——默认
EmailNotificationsOnMentiononmention只收被 @(fromMention 才过闸)
EmailNotificationsDisableddisabled全不收(GetMailableUsersByIDs 直接过滤)
EmailNotificationsAndYourOwnandyourown全收 + 自己的

user.go:1135-1148 的两个查询用 In("email_notifications_preference", ...) 区分"含 onmention"与"不含"两档——mention 批查三值、广播批查两值。

12.5.3 模板系统:一个文件两段、三级回退、13 个 .tmpl

subject/body 分割(modules/templates/mail.go):邮件模板是普通 .tmpl 文件,subject 与 body 用一行三连减号分隔:

var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)

// readTemplateContent 内:
loc := mailSubjectSplit.FindIndex(content)
if loc != nil {
	subjectContent, bodyContent = content[0:loc[0]], content[loc[1]:]
}
_, err = renderer.SubjectTemplates.New(name).Parse(string(subjectContent))
return bodyContent, nil

subject 走 text/template(禁 HTML),body 走 html/template。同一渲染器还做别名注册:mail/repo/issue/comment 可以按去掉 mail/ 前缀的短名 repo/issue/comment 被引用(aliases[alias] = name,146-151 行)。

MailRenderer() 是带 double-check locking 的全局单例(233-266 行),非生产环境挂文件 watcher 热重载(WatchLocalChanges → MailRendererReload),生产环境改模板必须重启。MockTemplate(196-215 行)供测试临时替换模板并返回还原函数。

三级回退(mail_issue_common.go:217-278 的 actionToTemplate):

template = "mail/repo/" + typeName + "/" + name        // ① mail/repo/{issue|pull}/{name}
ok := LoadedTemplates().BodyTemplates.HasTemplate(template)
if !ok && typeName != "issue" {
	template = "mail/repo/issue/" + name                // ② pull 退回 issue 目录
	ok = ...HasTemplate(template)
}
if !ok {
	template = "mail/repo/" + typeName + "/default"     // ③ 同级 default
	ok = ...
}
if !ok {
	template = "mail/repo/issue/default"                // ④ 兜底
}

name 由 ActionType/commentType/reviewType 三段 switch 推导:new/comment/close/reopen/merge/review_dismissed/ready_for_review/approve/reject/review/code/assigned/push/default 共 14 个名字。

实测模板清单(templates/mail/**/*.tmpl,13 个):

templates/mail/base/head.tmpl            base/footer.tmpl
templates/mail/repo/issue/default.tmpl   repo/issue/assigned.tmpl
templates/mail/repo/transfer.tmpl        repo/release.tmpl   repo/collaborator.tmpl
templates/mail/repo/actions/workflow_run.tmpl
templates/mail/org/team_invite.tmpl
templates/mail/user/auth/{activate,activate_email,register_notify,reset_passwd}.tmpl

关键事实:不存在 mail/repo/pull/ 目录——所有 PR 邮件都经回退②落到 mail/repo/issue/default。这个"缺失"是设计而非遗漏:default 模板按 .ActionName 分派文案(approve/reject/merge/push…),issue 与 PR 共用一份 HTML 壳。

default.tmpl 里能看到三类内容控制:mention 开头插 mail.issue.x_mentioned_you;ActionName 驱动的动作句(close/reopen/merge/approve/reject/review/review_dismissed/ready_for_review/new);push 力促内容走 IsForcePush 分支显示 old/new commit 链接、否则显示 TrN(len(Commits)) 复数句,随后 range .ReviewComments 渲染代码评审块。{{if .CanReply}} 由 setting.IncomingEmail.Enabled && commentType != PullRequestPush 决定——邮件里是否给"回复"提示。

12.5.4 Message 组装:线程头、退订头、元数据头

composeIssueCommentMessages(mail_issue_common.go:47-215)为每个收件人独立构造 Message,关键头:

msg.SetHeader("Message-ID", msgID)          // generateMessageIDForIssue
msg.SetHeader("In-Reply-To", reference)     // issue 级 reference
references := []string{reference}
...
msg.SetHeader("References", references...)
msg.SetHeader("List-Unsubscribe", listUnsubscribe...)  // <issue URL> 或 <mailto:unsub-token@...>
  • Message-ID 生成规则(generateMessageIDForIssue,280-305 行):有 comment 用 /comment/{id};无 comment 的动作(close/reopen/merge/ready)用 /{action}/{毫秒时间戳}——同 issue 同动作发两次必须是不同 Message-ID,否则邮件客户端把第二次当重复丢弃;
  • In-Reply-To/References 让同 issue 的邮件串成一个线程(issue 级 reference 恒定,评论级 msgID 变化);
  • 退订双通道(IncomingEmail.Enabled 时):token.CreateToken(UnsubscribeHandlerType, recipient, payload) 生成每收件人唯一的 mailto token 进 List-Unsubscribe;reply token 则进 ReplyTo 与 List-Post,用户直接回信即成为评论(services/mailer/incoming/,routers/init.go:159 的 mailer_incoming.Init)。List-Unsubscribe 无 IncomingEmail 时退化为 issue URL 链接;
  • 每收件人独立 Message 防邮箱泄漏——绝不 To 多人。

渲染前置(47-100 行):markdown.RenderString 渲染正文(WithUseAbsoluteLink(true)——邮件里相对链接会断);EmbedAttachmentImages 开启时 mailAttachmentBase64Embedder 把图片附件内嵌 base64(预算 maxEmailBodySize = 9_000_000 字节,超限跳过嵌入);review 评论把 Review.CodeComments 二维切片摊平进 ReviewComments。

主题处理(125-133 行):先执行 subject 模板 → sanitizeSubject(mail.go,256 rune 截断 + Q 编码防非 ASCII 头损坏)→ util.IfZero(subject, fallback) 兜底 → emoji.ReplaceAliases。fallback 是 [repo/full/name] title (#index),非 new 动作加 Re: 前缀。

generateAdditionalHeadersForIssue(307-321 行)叠加三组头:generateMetadataHeaders(List-ID/List-Archive/X-Gitea-*/X-GitLab-Project——兼容 GitLab 风格过滤器)、generateSenderRecipientHeaders(发/收件人,可被 [mailer.override_header] 覆盖)、generateReasonHeaders(按 ActionType 给 X-Gitea-Reason 等),外加 X-Gitea-Issue-ID/X-Gitea-Issue-Link/X-GitLab-Issue-IID——邮件客户端与外部工单系统据此归档。

12.5.5 队列、sender 与 SMTP 配置

services/mailer/mailer.go 的 NewContext(25-64 行)一函数完成四件事:

if setting.Service.EnableNotifyMail {
	notify_service.RegisterNotifier(NewNotifier())      // 条件注册
}

switch setting.MailService.Protocol {
case "sendmail": sender = &sender_service.SendmailSender{}
case "dummy":    sender = &sender_service.DummySender{}
default:         sender = &sender_service.SMTPSender{} // smtp/smtps/smtp+starttls/smtp+unix 都落这里
}

mailQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "mail", func(items ...*sender_service.Message) []*sender_service.Message {
	for _, msg := range items {
		gomailMsg := msg.ToMessage()
		if err := sender_service.Send(sender, msg); err != nil {
			log.Error("Failed to send emails %s: %s - %v", ...)
		}
	}
	return nil // 永不回队重试
})
go graceful.GetManager().RunWithCancel(mailQueue)

发送失败只记日志、handler 返回 nil——邮件同样无自动重试(与 webhook 同构;重发是人工/用户动作,如验证邮件的 180s 缓存限流,见第 10 章 10.11.5 表)。

SendAsync(66-80 行)再开一个 goroutine 逐条 mailQueue.Push——注意 var SendAsync = sendAsync 的可变包变量形态,测试可替换为同步假实现。

[mailer] 配置全键(modules/setting/mailer.go,代码默认值):

键默认说明
NAME/FROMGitea发件人名/地址(FROM 兼旧 USER)
ENVELOPE_FROM—SMTP MAIL FROM 覆盖
PROTOCOLsmtpsmtp|smtps|smtp+starttls|smtp+unix|sendmail|dummy
SMTP_ADDR/SMTP_PORT—/按端口推断25→smtp、465→smtps、587→smtp+starttls(省 PORT 时自动推断)
USER/PASSWD—认证(PASSWD 走 secret 加密)
ENABLE_HELOfalse显式 HELO
FORCE_TRUST_SERVER_CERTfalse跳过证书校验(仅限内网)
SENDMAIL_PATH/SENDMAIL_TIMEOUTsendmail/5ssendmail 协议
FROM_DISPLAY_NAME_FORMAT—发件人显示名模板(fromDisplayName)
SendAsPlainTextfalse纯文本替代 HTML
SubjectPrefix—主题前缀(如 [Gitea])
EmbedAttachmentImagesfalsebase64 内嵌(12.5.4)
旧键迁移—MAILER_TYPE→PROTOCOL、SMTP_FROM→FROM 等 deprecatedSetting 迁移
[service] ENABLE_NOTIFY_MAILfalsemail notifier 注册开关(12.2.1)
[mailer.override_header]—覆盖任意邮件头(generateMetadataHeaders 后合并)

暂停/退订机制的完整栈(从个体到全局):

  1. 个人 EmailNotificationsPreference=disabled —— GetMailableUsersByIDs SQL 层过滤;
  2. 个人 onmention —— 批过滤 only fromMention;
  3. issue 显式 unwatch —— 广播批 visited 排除;
  4. 仓库 mute(ignore)—— 最高优先级排除;
  5. 邮件内 List-Unsubscribe mailto token —— 收件人一键退订该 issue(IncomingEmail);
  6. 站点级 ENABLE_NOTIFY_MAIL=false —— notifier 不注册,一封不发;
  7. setting.MailService == nil(未配邮件)—— MailParticipants 开头短路(mail_issue.go:167)。

12.5.6 专项邮件:release、指派、仓库转移

  • MailNewRelease(mail_release.go:32):WatchReleases watchers → GetMailableUsersByIDs → 删除 publisher 本人与无权限者 → 按语言分批 → mail/repo/release 模板。draft/prerelease 在 notifier 层就被拦(12.5.1),不会走到这里;
  • SendIssueAssignedMail(mail_issue.go:194-227):收件人由调用方给定(单个 assignee/reviewer),跳过通用收件人链,只按语言分组后 composeIssueCommentMessages(..., "issue assigned")——ActionType(0) 使 actionToTemplate 落到 commentType 分支,最终模板 mail/repo/issue/assigned;
  • SendRepoTransferNotifyMail(mail_repo.go:27):仓库转移通知,模板 mail/repo/transfer,与站内 CreateRepoTransferNotification(12.4.1)成对出现——转移事件同时产生站内 + 邮件两条通知。

12.6 Webhook 数据模型与事件常量全表

12.6.1 Webhook 结构:一张表四种挂载范围

models/webhook/webhook.go:122-145:

type Webhook struct {
	ID                        int64 `xorm:"pk autoincr"`
	RepoID                    int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook
	OwnerID                   int64 `xorm:"INDEX"`
	IsSystemWebhook           bool
	URL                       string `xorm:"url TEXT"`
	Name                      string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"`
	HTTPMethod                string `xorm:"http_method"`
	ContentType               HookContentType
	Secret                    string `xorm:"TEXT"`
	Events                    string `xorm:"TEXT"`
	*webhook_module.HookEvent `xorm:"-"`
	IsActive                  bool                      `xorm:"INDEX"`
	Type                      webhook_module.HookType   `xorm:"VARCHAR(16) 'type'"`
	Meta                      string                    `xorm:"TEXT"` // store hook-specific attributes
	LastStatus                webhook_module.HookStatus // Last delivery status
	HeaderAuthorizationEncrypted string `xorm:"TEXT"`
	CreatedUnix timeutil.TimeStamp `xorm:"created"`
	UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}

四种挂载范围由 RepoID/OwnerID/IsSystemWebhook 组合判定,addDefaultHeaders 里有同一判定的现身(deliver.go:111-125,同时决定 X-Gitea-Hook-Installation-Target-Type):

形态RepoIDOwnerIDIsSystemWebhookTarget-Type 头
仓库 webhook≠0—falserepository
组织级 webhook0≠0(org)falseorganization
用户级 webhook0≠0(user)falseuser
系统 webhook(admin 站点级)00truesystem

Events 的双表示:DB 列是 Events string(TEXT 存 JSON),内存里是指针 *HookEvent(xorm:"-" 不落库)。AfterLoad 反序列化、UpdateEvent 序列化:

func (w *Webhook) AfterLoad() {
	w.HookEvent = &webhook_module.HookEvent{}
	if err := json.Unmarshal([]byte(w.Events), w.HookEvent); err != nil {
		log.Error("Unmarshal[%d]: %v", w.ID, err)
	}
}

HookEvent 的真实形状(modules/webhook/events.go)——证伪"位掩码":

type HookEvents map[HookEventType]bool

func (he HookEvents) Get(evt HookEventType) bool { return he[evt] }

type HookEvent struct {
	PushOnly       bool   `json:"push_only"`
	SendEverything bool   `json:"send_everything"`
	ChooseEvents   bool   `json:"choose_events"`
	BranchFilter   string `json:"branch_filter"`

	HookEvents `json:"events"`
}

三个 bool 模式互斥(UI 三选一),JSON 实例形如 {"push_only":false,"send_everything":false,"choose_events":true,"branch_filter":"main","events":{"push":true,"issues":true}}。选 map 而非位掩码的收益:加事件无需迁移(新键自动进 JSON),代价是多占几位存储——对 TEXT 列无所谓。

HasEvent(webhook.go:171-184)是三模式 + 一个归并的匹配器:

func (w *Webhook) HasEvent(evt webhook_module.HookEventType) bool {
	if w.SendEverything { return true }
	if w.PushOnly { return evt == webhook_module.HookEventPush }
	checkEvt := evt
	switch evt {
	case webhook_module.HookEventPullRequestReviewApproved,
		webhook_module.HookEventPullRequestReviewRejected,
		webhook_module.HookEventPullRequestReviewComment:
		checkEvt = webhook_module.HookEventPullRequestReview
	}
	return w.HookEvents[checkEvt]
}

三个 review 子事件(approved/rejected/comment)统一归并到 pull_request_review 聚合键再查 map——UI 上用户勾一个"评审"框,实际由三个细粒度事件触发。EventsArray() 则反向:SendEverything 展开 AllEvents() 全集、PushOnly 只回 push、否则列出 map 中 true 的键。

其余字段速记:

  • ContentType:HookContentType iota+1 → ContentTypeJSON=1、ContentTypeForm=2(webhook.go:69-73),name 表 {"json":1,"form":2};
  • HTTPMethod:空串按 POST 处理(newDefaultRequest switch),支持 GET(payload 进 query)与 PUT(仅 Matrix,deliver.go:71-85——用 txnID 拼幂等 URL);
  • Secret:明文存 TEXT,HMAC 签名密钥;
  • HeaderAuthorizationEncrypted:必须经 HeaderAuthorization()/SetHeaderAuthorization()(secret.EncryptSecret(setting.SecretKey, ...))加解密——可选的自定义 Authorization 头;
  • IsActive:三道闸(入队前 ListWebhookOptions.IsActive=true 过滤、投递前 !w.IsActive 短路、历史仍记录);
  • Meta:渠道特定配置(Slack 的 channel/username/icon、Discord 的 username/avatar_url 等),各渠道自行 unmarshal;
  • LastStatus:HookStatus{None=0, Succeed=1, Fail=2},每次投递后更新,列表页红绿标。

12.6.2 事件常量全表(31 个 HookEventType)

modules/webhook/type.go:10-43 完整清单:

常量值Event() 归并AllEvents()
HookEventCreatecreatecreate✔
HookEventDeletedeletedelete✔
HookEventForkforkfork✔
HookEventPushpushpush✔
HookEventIssuesissuesissues✔
HookEventIssueAssignissue_assignissues✔
HookEventIssueLabelissue_labelissues✔
HookEventIssueMilestoneissue_milestoneissues✔
HookEventIssueCommentissue_commentissue_comment✔
HookEventPullRequestpull_requestpull_request✔
HookEventPullRequestAssignpull_request_assignpull_request✔
HookEventPullRequestLabelpull_request_labelpull_request✔
HookEventPullRequestMilestonepull_request_milestonepull_request✔
HookEventPullRequestCommentpull_request_commentissue_comment✔
HookEventPullRequestReviewApprovedpull_request_review_approvedpull_request_approved✔
HookEventPullRequestReviewRejectedpull_request_review_rejectedpull_request_rejected✔
HookEventPullRequestReviewCommentpull_request_review_commentpull_request_comment✔
HookEventPullRequestSyncpull_request_syncpull_request✔
HookEventPullRequestReviewRequestpull_request_review_requestpull_request✔
HookEventWikiwikiwiki✔
HookEventRepositoryrepositoryrepository✔
HookEventReleasereleaserelease✔
HookEventPackagepackagepackage✔
HookEventStatusstatusstatus✔
HookEventPullRequestReviewpull_request_reviewpull_request_review✘(聚合键,源码 FIXME 注释)
HookEventSchedulescheduleschedule✘(Actions 专用)
HookEventWorkflowRunworkflow_runworkflow_run✔
HookEventWorkflowJobworkflow_jobworkflow_job✔

准确计数:常量 28 个(表中 28 行),AllEvents() 返回 26 个(不含 pull_request_review 聚合键与 schedule)。两处源码注释透露历史包袱:

// once a new event added here, please also added to AllEvents() function
// FIXME: This event should be a group of pull_request_review_xxx events
HookEventPullRequestReview HookEventType = "pull_request_review"

Event() 方法把细粒度事件归并成接收方看到的事件名(写进 X-Gitea-Event 头与 payload 的 X 聚合维度),而 X-Gitea-Event-Type 头保留细粒度原值(deliver.go:109-141)——两层事件名各司其职。

IsPullRequestReview() 单列三个 review 事件(与 HasEvent 的归并 switch 同集合),IsPullRequest() 则用 Event()=="pull_request" 判定——归并函数同时充当分类函数。

12.6.3 HookType、payload action 常量与 content type

HookType(type.go:116-131,11 个,type alias = string):

const (
	GITEA      HookType = "gitea"
	GOGS       HookType = "gogs"
	SLACK      HookType = "slack"
	DISCORD    HookType = "discord"
	DINGTALK   HookType = "dingtalk"
	TELEGRAM   HookType = "telegram"
	MSTEAMS    HookType = "msteams"
	FEISHU     HookType = "feishu"
	MATRIX     HookType = "matrix"
	WECHATWORK HookType = "wechatwork"
	PACKAGIST  HookType = "packagist"
)

与 modules/setting/webhook.go:44 的 Webhook.Types 硬编码列表、modules/structs/hook.go:56 的 CreateHookOptionConfig enum 三方一致(无 Mastodon)。Gitea/Gogs 不需要注册 requester(走 default 构造,12.7.2),其余 9 个各有 RegisterWebhookRequester(12.8)。

payload 内的 action 常量(modules/structs/hook.go,issue 系 16 个):

组常量 = 值
issue/PR 主动作opened closed reopened edited deleted assigned unassigned label_updated label_cleared synchronized milestoned demilestoned reviewed review_requested review_request_removed
评论HookIssueCommentCreated/Edited/Deleted = created/edited/deleted
ReleaseHookReleasePublished/Updated/Deleted = published/updated/deleted
WikiHookWikiCreated/Edited/Deleted = created/edited/deleted
仓库HookRepoCreated/Deleted/Renamed = created/deleted/renamed
包HookPackageCreated/Deleted = created/deleted

注意 label_updated/label_cleared 是 Gitea 扩展值(GitHub 用 labeled/unlabeled)——外部集成按 X-Gitea-Event-Type 区分时要知道这份差异。HookIssueAction 同时服务 issue 与 PR payload(PR 复用 issue 动作集),IsPullRequestReview 三个事件的 payload action 是 reviewed(pull_request_review.go 的 HookIssueReviewed)。

12.7 派发链路与 Payload 构造

12.7.1 webhookNotifier:方法 → 事件 → payload 的三段映射

services/webhook/notifier.go(1077 行)覆写约 40 个方法,全部是同一模板:

func (m *webhookNotifier) IssueClearLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) error {
	// ① 加载 payload 需要的属性
	if err := issue.LoadPoster(ctx); err != nil { ... return }
	if err := issue.LoadRepo(ctx); err != nil { ... return }
	// ② 算权限(进 payload 的 repository 字段带 viewer 权限)
	permission, _ := access_model.GetIndividualUserRepoPermission(ctx, issue.Repo, issue.Poster)
	// ③ 按 isPull 分派事件类型与 payload 类型
	if issue.IsPull {
		err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventPullRequestLabel, &api.PullRequestPayload{
			Action: api.HookIssueLabelCleared, ...
		})
	} else {
		err = PrepareWebhooks(ctx, EventSource{Repository: issue.Repo}, webhook_module.HookEventIssueLabel, &api.IssuePayload{
			Action: api.HookIssueLabelCleared, ...
		})
	}
	// ④ 错误只记日志
	if err != nil { log.Error("PrepareWebhooks [is_pull: %v]: %v", issue.IsPull, err) }
}

isPull 二分贯穿全文件:同一业务动作(改标题、关 issue、加标签、改里程碑、换指派)在 PR 上发 pull_request* 系事件,在 issue 上发 issues* 系事件——这就是 HasEvent 的 Event() 归并为何把两族分别收拢的原因。

EventSource{Repository, Owner} 是范围选择器:PrepareWebhooks 用它收集三层 webhook(12.7.3)。绝大多数方法只给 Repository;PackageCreate/PackageDelete 是少数给 Owner 的(包可挂用户/组织级,notifyPackage 构造 EventSource{Owner: ...})。

非平凡细节三处(源码注释原样):

  1. CreateRef 的 FIXME(notifier.go:822):
    Ref: refFullName.ShortName(), // FIXME: should it be a full ref name? But it will break the existing webhooks?
    
    create/delete 事件的 Ref 发的是短名(feature/x 而非 refs/heads/feature/x),为兼容老订阅方保留;
  2. ForkRepository 发两个事件(notifier.go:86-112):源仓库的 fork + 新仓库的 repository/created(仅当新属主是组织);
  3. CreateCommitStatus 用 httplib.MakeAbsoluteURL——status payload 的 target URL 必须绝对化,否则外部 CI 面板点不开。

事件 → 方法的另一侧映射(PrepareWebhooks 被调处的 grep)覆盖 28 个事件中的 24 个实际派发(schedule 由 Actions 内部处理,pull_request_review 聚合键不直接发,package 走 owner 范围,status 来自 CreateCommitStatus)。

12.7.2 PrepareWebhook:入队前的五道闸门

services/webhook/webhook.go:159-204:

func PrepareWebhook(ctx context.Context, w *webhook_model.Webhook, event webhook_module.HookEventType, p api.Payloader) error {
	// Skip sending if webhooks are disabled.
	if setting.DisableWebhooks { return nil }

	if !w.HasEvent(event) { return nil }

	// Avoid sending "0 new commits" to non-integration relevant webhooks (e.g. slack, discord, etc.).
	// Integration webhooks (e.g. drone) still receive the required data.
	if pushEvent, ok := p.(*api.PushPayload); ok &&
		w.Type != webhook_module.GITEA && w.Type != webhook_module.GOGS &&
		len(pushEvent.Commits) == 0 {
		return nil
	}

	// If payload has no associated branch (e.g. it's a new tag, issue, etc.), branch filter has no effect.
	if ref := getPayloadRef(p); ref != "" {
		if !checkBranchFilter(w.BranchFilter, ref) { return nil }
	}

	payload, err := p.JSONPayload()
	...
	task, err := webhook_model.CreateHookTask(ctx, &webhook_model.HookTask{
		HookID:         w.ID,
		PayloadContent: string(payload),
		EventType:      event,
		PayloadVersion: 2,
	})
	...
	return enqueueHookTask(task.ID)
}

五道闸门逐条:

  1. 全局开关 setting.DisableWebhooks(admin 可一键停全站 webhook);
  2. 事件订阅 HasEvent(12.6.1 的三模式 + review 归并);
  3. 空 push 过滤:len(Commits)==0 的 push(如只推了被 gitignore 的东西)对聊天类渠道(slack/discord…)没意义,但集成类(gitea/gogs/drone)必须收到——按 w.Type 区分。PrepareTestWebhook 注释也点名这组 gate(12.9.3);
  4. 分支过滤 checkBranchFilter:glob.Compile(branchFilter) 后先试 ref.BranchName() 再试完整 ref(webhook.go:114-130);空/*/** 全通过。getPayloadRef 只对 Create/Delete/Push payload 返回 ref——issue/PR 等无 ref payload 天然跳过此闸;
  5. 落库 + 入队:PayloadVersion: 2——存原始事件 JSON,渠道转换推迟到投递时(12.7.4)。

PrepareTestWebhook(132-157 行)只过闸 1 和落库——忽略订阅与分支过滤,让"Test Push Event"按钮在任何配置下都能验证连通性(routers/web/repo/setting/webhook.go:725 调用)。

PrepareWebhooks(206-254 行)是三层收集器:

// ① repo 级
repoHooks, err := db.Find[webhook_model.Webhook](ctx, ListWebhookOptions{RepoID: ..., IsActive: optional.Some(true)})
// ② owner 级(repo.MustOwner 或 EventSource.Owner)
ownerHooks, err := db.Find[...](ctx, ListWebhookOptions{OwnerID: owner.ID, IsActive: optional.Some(true)})
// ③ admin 系统级
systemHooks, err := webhook_model.GetSystemWebhooks(ctx, optional.Some(true))

for _, w := range ws { if err := PrepareWebhook(ctx, w, event, p); err != nil { return err } }

顺序即执行序:repo → owner → system,IsActive 在 SQL 就过滤。任何一层 PrepareWebhook 返回 error 会中断后续层(return err)——同一事件一个 hook 建 task 失败,后面的 hook 不再尝试(错误处理偏保守,但失败本身极罕见:只有 DB 挂了才会发生)。

12.7.3 PayloadVersion 1/2 与 v2 的渠道转换推迟

HookTask.PayloadContent 的语义由 PayloadVersion 决定(hooktask.go:50-53 注释):

版本PayloadContent 内容谁写谁读
1最终发给 URL 的 JSON(已按渠道转换)历史行(v28 之前)Deliver:PayloadVersion==1 || requester==nil → default request
2原始事件 JSON(api.PushPayload 等)PrepareWebhook/PrepareTestWebhook(现默认)渠道 requester:newPayload 按 EventType 反序列化并转换

CreateHookTask 强制要求版本非零(if t.PayloadVersion == 0 { return nil, errors.New("missing HookTask.PayloadVersion") },hooktask.go:126-128)——防调用方忘填导致新行按 v1 解读。

v2 的收益在 Deliver(deliver.go:164-167):

newRequest := webhookRequesters[w.Type]
if t.PayloadVersion == 1 || newRequest == nil {
	newRequest = newDefaultRequest
}

转换推迟到投递时刻意味着:同一份原始事件,给 Slack 发 A 格式、给 gitea 订阅方发原格式,互不影响;且历史 v1 行仍可投递/重放(走 default,原样转发)。

12.7.4 webhook_sender 队列与 handler

投递由 webhook.Init(deliver.go:274-290)拉起:

hookQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "webhook_sender", handler)
if hookQueue == nil { return errors.New("unable to create webhook_sender queue") }
go graceful.GetManager().RunWithCancel(hookQueue)
go graceful.GetManager().RunWithShutdownContext(populateWebhookSendingQueue)
  • unique 队列:同 task ID 不重复入队(worker 池队列的去重键是 int64 taskID),配合 ErrAlreadyInQueue 在 enqueueHookTask 被吞(webhook.go:106-112);
  • handler 三层检查(webhook.go:78-104):GetHookTaskByID(不存在则 warn/continue)→ task.IsDelivered(已被并行路径投递则跳过)→ Deliver;返回恒 nil——失败不回队(第 10 章 10.11.3 已证,12.9 收尾);
  • HTTP client:Timeout = DeliverTimeout(默认 5s),Transport 来自 egress.NewWebhookPolicy()(出口白名单 ALLOWED_HOST_LIST + 代理 PROXY_URL/PROXY_HOSTS,v28 起 hostlist 迁到 [security] 节,deprecatedSetting 保留旧键),SkipTLS_VERIFY 覆写 InsecureSkipVerify;
  • 启动回灌 populateWebhookSendingQueue(292-320 行):以 FindUndeliveredHookTaskIDs(lowerID) 每批 100 循环上翻,把历史未投递行重新入队——崩溃重启后未完成的投递自动续跑(第 10 章 10.4.4"策略一")。ctx.Done() 时优雅退出("Shutdown before Webhook Sending queue finishing being populated")。

ReplayHookTask(webhook.go:257-264 + models/webhook/hooktask.go:154-171)是人工重放:复制一行新 task(新 UUID,PayloadContent/EventType/PayloadVersion 原样拷贝)再入队——原行的 Request/Response 历史保留,重放产生独立记录。

12.7.5 Deliver:签名、头、状态记录的完整过程

deliver.go:147-269 按序执行:

  1. GetWebhookByID 拿 hook 配置;
  2. defer recover()——panic 记栈不崩 worker(全 notifier 体系唯一的 recover 点);
  3. 选 requester:webhookRequesters[w.Type],v1 或未注册走 newDefaultRequest;
  4. 构造请求后记录 RequestInfo(URL/Method/Headers/Body);自定义 Authorization 头写入前掩码 t.RequestInfo.Headers["Authorization"] = "******"——历史记录永不落明文凭证;
  5. CAS 抢占:MarkTaskDelivered(WHERE is_delivered = false 的条件更新,count != 0 才继续)——unique 队列之外的第三道防重(队列去重 → handler IsDelivered 检查 → DB CAS);
  6. defer 写回:Delivered 时间戳、UpdateHookTask(IsSucceed + Request/ResponseContent)、UpdateWebhookLastStatus(红绿标);
  7. setting.DisableWebhooks 时返回 error(记录失败原因);!w.IsActive 记 "skipped" 后返回 nil;
  8. webhookHTTPClient.Do;成功判定 resp.StatusCode/100 == 2;响应体读取限 1MB(util.ReadWithLimit(resp.Body, 1024*1024))。

默认请求与签名头(newDefaultRequest/addDefaultHeaders,deliver.go:34-143):

// 双算法签名:一次 MultiWriter 同时算 sha1 与 sha256
sig1 := hmac.New(sha1.New, secret)
sig256 := hmac.New(sha256.New, secret)
_, err := io.MultiWriter(sig1, sig256).Write(payloadContent)

req.Header.Add("X-Gitea-Delivery", t.UUID)
req.Header.Add("X-Gitea-Event", event)            // Event() 归并后的名
req.Header.Add("X-Gitea-Event-Type", eventType)   // 细粒度原名
req.Header.Add("X-Gitea-Signature", signatureSHA256)
req.Header.Add("X-Gitea-Hook-Installation-Target-Type", targetType)
req.Header.Add("X-Gogs-Delivery", t.UUID)         // ... 同款 Gogs 三件套
req.Header.Add("X-Hub-Signature", "sha1="+signatureSHA1)
req.Header.Add("X-Hub-Signature-256", "sha256="+signatureSHA256)
req.Header["X-GitHub-Delivery"] = []string{t.UUID}  // ... 同款 GitHub 四件套(含 Target-Type)

三生态头并存:X-Gitea-(原生)、X-Gogs-(兼容 Gogs 订阅方)、X-Hub-Signature(256)/X-GitHub-*(GitHub webhook 生态,任何 GitHub 校验库可直接用)。Form content type 时 payload 包进 payload= 表单字段(url.Values{"payload": []string{...}})。

12.7.6 push 事件通知扇出时序图

综合 12.3.2(push 队列)、12.7.1-12.7.4(webhook 派发)与 12.4(站内红点),一次 git push 的全扇出:

sequenceDiagram
    autonumber
    participant G as git push (HTTP/SSH)
    participant RQ as push_update 队列<br/>services/repository/push.go
    participant NB as notify 总线<br/>services/notify/notify.go
    participant FE as feed notifier
    participant UI as uinotification
    participant WH as webhookNotifier
    participant HQ as webhook_sender unique 队列
    participant EXT as 外部 URL (Slack 等)
    participant WS as wsNotifier → pubsub
    participant BR as 浏览器 SharedWorker

    G->>RQ: PushUpdates(opts) (接收钩子入队即返回)
    RQ->>RQ: pushQueueHandleUpdates: GetCommit/<br/>UpdateIssuesCommit/CompareURL/截断 FeedMaxCommitNum
    RQ->>NB: notify_service.PushCommits(ctx, pusher, repo, opts, commits)
    NB->>FE: 同步: db.WithTx 给每个 watcher insert action
    NB->>UI: 同步: issueQueue.Push (push 不产生站内通知, 仅镜像场景)
    NB->>WH: 同步: 构造 api.PushPayload
    WH->>HQ: CreateHookTask(PayloadVersion=2) + enqueueHookTask
    Note over HQ: unique 去重; 100ms 攒批派发 (第10章 10.3)
    HQ->>EXT: Deliver: HMAC 签名 + X-Gitea-Event: push<br/>2xx 记 IsSucceed, 否则仅落库不重试
    RQ->>NB: notify_service.CreateRef (新 tag/branch 场景)
    Note over NB: 各 notifier 同上模式再扇出一次
    HQ-->>BR: (评论/PR 场景经) NotificationCountChange<br/>→ pubsub Publish(user-N)
    BR-->>BR: notification-count → 红点 +1

时序要点:feed 的同步写库发生在 push 队列 worker 里(不在 HTTP 请求里)——这正是 push_update 队列存在的理由(12.3.1 的两级结构);webhook 的入队与投递之间隔着 unique 队列的 100ms 攒批,投递失败只体现为 hook_task 行上的 is_succeed=false。

12.8 外部集成渠道与 payload 抽象复用

12.8.1 渠道注册总表

9 个外部渠道 + 2 个原生格式共 11 种 HookType,前 9 个在各自文件的 package init() 里注册 requester:

渠道注册点requester载荷类型特殊点
slackslack.go:318newSlackRequestSlackPayload{Text, Attachments[]}channel/username/icon 存 Meta
discorddiscord.go:310newDiscordRequestembeds 结构avatar_url 覆盖
dingtalkdingtalk.go:216newDingtalkRequest加密/普通 msgsecret 存 Meta 可选签名
telegramtelegram.go:225newTelegramRequestsendMessagetoken 在 URL
msteamsmsteams.go:414newMSTeamsRequestMessageCard/adaptive—
feishufeishu.go:223newFeishuRequest卡片 msg签名 key
matrixmatrix.go:29newMatrixRequestroom messagePUT + txnID 幂等(12.6.1)
wechatworkwechatwork.go:205newWechatworkRequestmarkdown msg—
packagistpackagist.go:137newPackagistRequestPackagist 免费格式只发 push/release(包仓库场景)
gitea——原生 api payload 原样IsValidHookTaskType 硬编码放行
gogs——同上(兼容格式)同上

IsValidHookTaskType(webhook.go:37-43):name==GITEA || name==GOGS 直接 true,否则查 webhookRequesters map——type 校验与 requester 注册表是同一真源的两面。setting.Webhook.Types(11 个字符串)只用于 UI 下拉与 API enum。

12.8.2 payloadConvertor[T]:泛型接口统一 15 类事件

services/webhook/payloader.go 是抽象的核心:

// payloadConvertor defines the interface to convert system payload to webhook payload
type payloadConvertor[T any] interface {
	Create(*api.CreatePayload) (T, error)
	Delete(*api.DeletePayload) (T, error)
	Fork(*api.ForkPayload) (T, error)
	Issue(*api.IssuePayload) (T, error)
	IssueComment(*api.IssueCommentPayload) (T, error)
	Push(*api.PushPayload) (T, error)
	PullRequest(*api.PullRequestPayload) (T, error)
	Review(*api.PullRequestPayload, webhook_module.HookEventType) (T, error)
	Repository(*api.RepositoryPayload) (T, error)
	Release(*api.ReleasePayload) (T, error)
	Wiki(*api.WikiPayload) (T, error)
	Package(*api.PackagePayload) (T, error)
	Status(*api.CommitStatusPayload) (T, error)
	WorkflowRun(*api.WorkflowRunPayload) (T, error)
	WorkflowJob(*api.WorkflowJobPayload) (T, error)
}

15 个方法对应 15 类 api payload。每个渠道实现这个接口(如 slackConvertor 实现 payloadConvertor[SlackPayload]),然后:

func newPayload[T any]( rc payloadConvertor[T], data []byte, event webhook_module.HookEventType) (t T, err error) {
	switch event {
	case webhook_module.HookEventCreate:
		return convertUnmarshalledJSON(rc.Create, data)
	...
	case webhook_module.HookEventIssues, webhook_module.HookEventIssueAssign,
		webhook_module.HookEventIssueLabel, webhook_module.HookEventIssueMilestone:
		return convertUnmarshalledJSON(rc.Issue, data)
	case webhook_module.HookEventIssueComment, webhook_module.HookEventPullRequestComment:
		return convertUnmarshalledJSON(rc.IssueComment, data)
	...
	case webhook_module.HookEventPullRequestReviewApproved, webhook_module.HookEventPullRequestReviewRejected,
		webhook_module.HookEventPullRequestReviewComment:
		return convertUnmarshalledJSON(func(p *api.PullRequestPayload) (T, error) {
			return rc.Review(p, event)
		}, data)
	...
	}
	return t, fmt.Errorf("newPayload unsupported event: %s", event)
}

newPayload 是 EventType → payload 方法的第二张映射(第一张在 webhookNotifier 的方法体里)。两处值得注意:

  • 4 个 issue 系事件 → 同一个 Issue 方法、6 个 PR 系 → PullRequest、2 个 comment 系 → IssueComment——渠道侧只需按 payload 形态实现,不必理会事件粒度;
  • IssueComment 分支的三段历史注释(PR #12310/#12046/#11940)解释了为什么 pull_request_comment 也走 IssueComment:历史上部分调用点传 PullRequestPayload 造成不一致,最终统一为 IssueCommentPayload,并引用 modules/actions/workflows.go:183 的类型断言为证。

newJSONRequest/prepareJSONRequest(93-122 行)把转换结果 json.MarshalIndent(payload, "", " ")(两格缩进,人可读)→ Content-Type: application/json → 可选 addDefaultHeaders(带签名;有些渠道不用 HMAC,传 false)。

渠道实现示例(slack.go:303-319):

func newSlackRequest(_ context.Context, w *webhook_model.Webhook, t *webhook_model.HookTask) (*http.Request, []byte, error) {
	meta := &SlackMeta{}
	if err := json.Unmarshal([]byte(w.Meta), meta); err != nil { ... }
	var pc payloadConvertor[SlackPayload] = slackConvertor{
		Channel: meta.Channel, Username: meta.Username, IconURL: meta.IconURL, Color: meta.Color,
	}
	return newJSONRequest(pc, w, t, true)
}

func init() { RegisterWebhookRequester(webhook_module.SLACK, newSlackRequest) }

三段式:unmarshal Meta → 构造 convertor(携带渠道配置)→ newJSONRequest。w.Meta 是渠道配置的通用载体(12.6.1)。

12.8.3 general.go:16 个共享文案函数

services/webhook/general.go 提取了所有渠道都要的"事件一句话摘要"逻辑,函数清单(grep ^func 实测 16 个):

noneLinkFormatter / htmlLinkFormatter          —— 链接风格抽象(Slack 用 markdown 链接, 其他可能纯文本)
getPullRequestInfo / getIssuesInfo / getIssuesCommentInfo   —— 结构化字段拆解
getIssuesPayloadInfo / getPullRequestPayloadInfo             —— (text, issueTitle, extraMarkdown, color)
getReleasePayloadInfo / getWikiPayloadInfo / getIssueCommentPayloadInfo
getRepoRenamedFrom / getPackagePayloadInfo / getStatusPayloadInfo
getWorkflowRunPayloadInfo / getWorkflowJobPayloadInfo
ToHook —— model → api.Hook 转换(路由层序列化用)

linkFormatter 是函数类型参数(func(url, text string) string),各渠道传自己的格式化器——Slack 传 SlackLinkFormatter(<url|text> 语法),Discord 传 htmlLinkFormatter。这层复用 + 泛型 convertor,构成"加一个新渠道只需实现 15 个方法 + 一个 requester"的扩展成本。getPullRequestPayloadInfo 返回的 color int 是十六进制色值(GitHub 系绿/红/紫),进 Slack attachment 的 Color 字段。

Review 方法(slack.go:248-266)展示了事件名的第三种用途:parseHookPullRequestEventType(event) 把 pull_request_review_approved 翻译成面向人的 "approved/rejected/commented" 动作文案——EventType(细粒度)在渠道层重新派上用场,若只有 Event() 归并名就丢信息了。

12.9 投递可靠性:状态记录、失败处理与"没有重试"

12.9.1 HookTask 结构与四字段状态机

models/webhook/hooktask.go:44-65:

type HookTask struct {
	ID             int64  `xorm:"pk autoincr"`
	HookID         int64  `xorm:"index"`
	UUID           string `xorm:"unique"`
	PayloadContent string `xorm:"LONGTEXT"`
	// PayloadVersion number to allow for smooth version upgrades:
	//  - PayloadVersion 1: PayloadContent contains the JSON as sent to the URL
	//  - PayloadVersion 2: PayloadContent contains the original event
	PayloadVersion int `xorm:"DEFAULT 1"`

	EventType   webhook_module.HookEventType
	IsDelivered bool
	Delivered   timeutil.TimeStampNano

	// History info.
	IsSucceed       bool
	RequestContent  string `xorm:"LONGTEXT"`
	RequestInfo     *HookRequest  `xorm:"-"`
	ResponseContent string `xorm:"LONGTEXT"`
	ResponseInfo    *HookResponse `xorm:"-"`
}

状态由四个字段编码,没有显式 state 列:

阶段IsDeliveredIsSucceedDelivered
已入队未投递falsefalse0(CreateHookTask 会预填 now)
投递成功truetrue投递时刻
投递失败(5xx/网络错)truefalse投递时刻
被跳过(hook 停用/全站关闭)truefalse投递时刻(记 "skipped" Trace)

RequestInfo/ResponseInfo 是 xorm:"-" 的内存视图,BeforeUpdate 序列化进 RequestContent/ResponseContent,AfterLoad 反向拆(hooktask.go:72-99)——XORM hook 的标准用法。CreateHookTask 生成 uuid.New().String() 作为 delivery ID(X-Gitea-Delivery 头)。

HookTasks(ctx, hookID, page) 按 setting.Webhook.PagingNum(默认 10)分页倒序取历史——这是历史查看 UI 的数据源。

12.9.2 失败处理:无自动重试的证据链(引第 10 章)

第 10 章 10.11.3 已给出核心证据:handler 返回恒 nil,任何失败都不会让 item 进入 unhandled 回队重试。本章补齐完整证据链的另外三环:

  1. 投递层无 retry 循环:Deliver 单次 webhookHTTPClient.Do,grep -r "retry\|Retry" services/webhook/ 零匹配(本章证伪清单第 7 行);
  2. 失败被刻意记录而非吞掉:defer 里 UpdateHookTask 写 IsSucceed=false + 响应详情 + UpdateWebhookLastStatus——失败的归宿是三行数据库记录(第 10 章原话),供人工查看与重放;
  3. 重放是人工动作:ReplayHookTask(12.7.4)由 UI 触发(routers/web/repo/setting/webhook.go:735 的 ReplayWebhook handler,743 行调用)。

历史查看 UI 的完整链路:webhook 设置页 handler 里 ctx.Data["History"], err = w.History(ctx, 1)(routers/web/repo/setting/webhook.go:654,第 1 页)→ 模板渲染每条 task 的 UUID/事件/时间/成功标/请求响应详情 → 单条可"Redeliver"(ReplayWebhook)或整 hook "Test"(TestWebhook → PrepareTestWebhook,677/725 行)。失败可见、可手动重放、不自动重试是三位一体的产品决策:webhook 下游可能是 CI,自动重试风暴的代价高于人工点一次按钮。

12.9.3 启动恢复与清理:回灌 + cron 两件套

回灌(populateWebhookSendingQueue,12.7.4):每次启动把 is_delivered=false 的历史行按 100/批重新入队——所以"进程在投递前崩溃"的 task 会随下次启动补投。边界:若崩溃发生在 MarkTaskDelivered 之后、写 IsSucceed 之前,行已是 is_delivered=true,不会回灌(永久卡在"已投递但无结果")——概率极低(两步间只有网络请求),且 history UI 会显示这条无响应记录。

清理(services/cron/tasks_basic.go:124-137):

func registerCleanupHookTaskTable() {
	RegisterTaskFatal("cleanup_hook_task_table", &CleanupHookTaskConfig{
		BaseConfig: BaseConfig{
			Enabled:    true,
			RunAtStart: false,
			Schedule:   "@midnight",
		},
		CleanupType:  "OlderThan",
		OlderThan:    168 * time.Hour,
		NumberToKeep: 10,
	}, func(ctx context.Context, _ *user_model.User, config *CleanupHookTaskConfig) error {
		return webhook.CleanupHookTaskTable(ctx, webhook.ToHookTaskCleanupType(config.CleanupType), config.OlderThan, config.NumberToKeep)
	})
}

两种模式(models/webhook/hooktask.go:198-263):

模式SQL 语义默认参数
OlderThan(默认)删 is_delivered=true AND delivered < now-olderThan 的行168h(7 天),每日 0 点
PerWebhook逐 hook 保留最近 NumberToKeep=10 条已投递行,删更早NumberToKeep=10

OlderThan 模式只删已投递行——未投递的(等回灌)永不清,直到被处理。PerWebhook 逐 hook 查询 delivered desc limit 1 offset 10 取第 10 条时间戳再删 <= 该时间的行,中间检查 ctx.Done() 可取消。任务可经 [cron.cleanup_hook_task_table] 改开关与参数(第 10 章 10.9.8 的配置节)。

12.9.4 限速的真相:不存在,但有三层"隐性节流"

证伪清单第 7 行:services/webhook 内无任何 ratelimit/limiter 代码。实际起限速作用的是三层既有机制:

  1. 队列背压:webhook_sender 是 WorkerPoolQueue,[queue.webhook_sender] 的 MAX_WORKERS/LENGTH(默认 100000)决定并发投递数与积压上限——吞吐上限 = worker 数,这是唯一真正的"速率控制"(配置在第 10 章 10.5);
  2. 单请求超时:DeliverTimeout=5s——慢 endpoint 5 秒必断,worker 不被长期占用;
  3. 出口限制:egress.NewWebhookPolicy() 的 ALLOWED_HOST_LIST 白名单 + PROXY_URL——限制目标可达性而非速率。

对"防止打爆下游"的需求,Gitea 的立场是:并发受队列 worker 数控制即可,不做令牌桶/退避重试——因为没有重试,重复打爆的最坏情形不存在。这也解释了为什么 QUEUE_LENGTH=1000(setting/webhook.go:34)与队列 LENGTH 是两回事:前者是 [webhook] 节的历史遗留键(现主要影响派发侧内存缓冲语义),投递侧并发仍由队列配置决定。

12.9.5 可靠性对比小结:三种通知的持久化与丢失面

站内通知邮件Webhook
持久化点notification 表(事务内)不持久化(队列内存项)hook_task 表
队列notification-service(Simple,内存)mail(Simple,内存)webhook_sender(unique + level 后端可持久化,视 [queue] TYPE)
崩溃恢复已写库的不丢;未处理的任务项丢未发的丢(无回灌)is_delivered=false 启动回灌
失败处理handler continue 记日志发送失败记日志落库 IsSucceed=false + 人工重放
重试无无无(第 10 章 10.11.3)
清理用户已读操作无(发完即逝)cron 7 天/每 hook 10 条
用户可见性列表页实时可见收件箱history UI + Redeliver 按钮

三者的可靠性梯度 = 可恢复性梯度:webhook 有表有回灌有 UI,站内有表(但生成侧队列可丢),邮件全内存。这个梯度与"事件的重要程度"无关,而与"用户能否自查"有关——webhook 是对外集成契约,出错用户查不到原因会报 bug;邮件丢了用户顶多少收一封通知。

12.10 架构总图与事件对照

12.10.1 notify 架构全图

flowchart TB
    subgraph CALLERS[调用方]
        C1[&#34;routers (HTTP handler)<br/>issue/PR/comment/wiki/release...&#34;]
        C2[&#34;push_update 队列 worker<br/>services/repository/push.go&#34;]
        C3[&#34;actions/notify.go<br/>workflow 状态机&#34;]
    end

    subgraph BUS[notify 总线 services/notify]
        REG[&#34;RegisterNotifier<br/>go notifier.Run() + append&#34;]
        DISP[&#34;44 个事件函数<br/>同步 for 多播 + 4 道闸门&#34;]
    end

    C1 --> DISP
    C2 --> DISP
    C3 --> DISP
    REG --> DISP

    DISP -->|同步, fire-and-forget| N1[&#34;feed<br/>action 表 (事务同步写)&#34;]
    DISP --> N2[&#34;uinotification<br/>notification-service 队列&#34;]
    DISP --> N3[&#34;webhook<br/>hook_task 落库 + webhook_sender 队列&#34;]
    DISP --> N4[&#34;mailer (条件注册)<br/>mail 队列 → SMTP/sendmail&#34;]
    DISP --> N5[&#34;websocket<br/>pubsub Broker (memory/redis)&#34;]
    DISP --> N6[&#34;indexer → 索引队列&#34;]
    DISP --> N7[&#34;mirror → 镜像同步队列&#34;]
    DISP --> N8[&#34;automerge → pr_auto_merge 队列&#34;]
    DISP --> N9[&#34;actions → workflow 触发&#34;]
    DISP --> N10[&#34;audit → 审计日志 (同步)&#34;]

    N2 -->|handler 写库后 NotificationCountChange| DISP
    N5 --> WS[[&#34;/-/ws 长连接&#34;]]
    WS --> SW[&#34;SharedWorker<br/>(多 tab 共享)&#34;]
    SW --> UI[&#34;红点 / 秒表 / logout&#34;]
    SW -. worker-unavailable .-> POLL[&#34;退避轮询 /notifications/new&#34;]

    N3 --> DELIV[&#34;Deliver: HMAC 签名<br/>X-Gitea-/X-Gogs-/X-Hub-/X-GitHub- 头<br/>2xx=成功, 无自动重试&#34;]
    DELIV --> EXT[&#34;外部: slack/discord/dingtalk/<br/>telegram/msteams/feishu/matrix/<br/>wechatwork/packagist/gitea/gogs&#34;]
    N3 -. ReplayHookTask (人工) .-> DELIV

    subgraph CRON[cron 兜底]
        CLEAN[&#34;cleanup_hook_task_table<br/>@midnight, OlderThan 168h&#34;]
    end
    N3 -. 清理历史 .-> CLEAN

12.10.2 事件对照表:业务动作 → 总线方法 → 各 notifier 的反应

业务动作总线方法(12.1.1)uinotificationmailerwebhook EventTypefeed ActionType
新建 issueNewIssue广播 + mention 单播MailParticipantsissues (action=opened)ActionCreateIssue
评论CreateIssueComment广播 + mention 单播(pending review 被闸)MailParticipantsCommentissue_comment/pull_request_comment (created)ActionCommentIssue/Pull
关闭/重开IssueChangeStatus广播MailParticipantsissues/pull_request (closed/reopened)ActionClose/Reopen*
新建 PRNewPullRequest手动圈人单播(12.4.2)MailParticipantspull_request (opened)ActionCreatePullRequest
合并 PRMergePullRequest广播MailParticipantspull_request (merged)ActionMergePullRequest
提交评审PullRequestReview广播 + mentionMailParticipantsCommentpull_request_review_approved/rejected/commentActionApprove/RejectPullRequest
WIP 解除IssueChangeTitle仅 WIP→ready 广播同(ready_for_review 邮件)pull_request (ready_for_review)ActionPullRequestReadyForReview
推送PushCommits—(不覆写)—(不覆写)pushActionCommitRepo/PushTag
新建分支/tagCreateRef——createActionCreateBranch(tag 跳过,PushCommits 已发)
删除分支/tagDeleteRef——deleteActionDeleteBranch(tag 跳过)
新 ReleaseNewRelease—draft/prerelease 跳过release (published)ActionPublishRelease
仓库转移RepoPendingTransfer同步事务写SendRepoTransferNotifyMailrepository (transferred)ActionTransferRepo
红点变化NotificationCountChange(产生方)———
秒表启停StopwatchChanged————
workflow 状态WorkflowRunStatusUpdate—MailActionsTriggerworkflow_run—

(webhook 列的 Event() 归并名见 12.6.2 表;pull_request_review_approved 的 Event() 归并为 pull_request_approved,而 HasEvent 查聚合键 pull_request_review——三套名字:常量值 / Event() 归并 / HasEvent 聚合键,读代码时要分清。)

12.10.3 外部渠道能力对照

渠道content type签名验证分支过滤空 push 过滤PUT 幂等特有配置 (Meta)
giteajson/formHMAC sha256 (X-Gitea-Signature)✔✘(集成类保留)✘—
gogsjson/formHMAC (X-Gogs-Signature)✔✘✘—
slackjson可选 (X-Hub-Signature-256)✔✔✘channel, username, icon_url, color
discordjson✘✔✔✘username, avatar_url, color
dingtalkjson加密可选✔✔✘secret
telegramjson✘(token 鉴权)✔✔✘—(token 在 URL)
msteamsjson✘✔✔✘—
feishujson签名可选✔✔✘secret
matrixjson✘✔✔✔(txnID=UUID)—
wechatworkjson✘(key 鉴权)✔✔✘—
packagistjson✘✔✔✘—

"空 push 过滤"列来自 PrepareWebhook 闸门 3(12.7.2):仅 gitea/gogs 收 len(Commits)==0 的 push。矩阵渠道的 PUT 特殊性贯穿三层:HTTPMethod=PUT 允许值(newDefaultRequest)、getMatrixTxnID 从 payload 提取 transaction ID 拼 URL、服务端按 txnID 幂等去重。

12.11 本章源码级问答

Q1:为什么 RegisterNotifier 用 go notifier.Run() 而不是让调用方显式启动? 让注册与启动原子绑定——feed.Init() 只调一次 RegisterNotifier,不必记着第二步 go queue.Run()。需要循环的 notifier(uinotification/webhook/mailer)在 Run() 里再开一层 goroutine 包队列(如 notificationService.Run 的 go graceful.GetManager().RunWithCancel(ns.issueQueue)),不需要的(NullNotifier 默认)goroutine 立即退出,零成本。代价是 Run() 的时序不确定——所以它不能承担"依赖就绪"职责,websocket 必须在 Init() 里先 pubsub.Init() 再注册(12.2.2)。

Q2:多播是同步 for 循环,一个 notifier 慢了会不会拖垮 HTTP 请求? 只有两个 notifier 有真实同步成本:feed(O(watchers) 写库)与 mailer(收件人查询)。webhook/uinotification/indexer/mirror/automerge/actions 都是 O(1) 入队。实际上 feed 的最重路径也已被 push 队列移出 HTTP(12.3.1),HTTP 直调级的同步段 ≈ 两次 DB 写 + 若干次入队。真会拖慢的是"11 个 notifier 里任何一个内部没做好"——这正是 NotificationCountChange 加 HasTopicSubscribers 捐导的原因:连 1 次 Count 查询都要省(12.4.4)。

Q3:站内通知与邮件的收件人计算为什么是两套代码? 结果集规则不同(12.4.3 表):mention 单播的集合语义、unwatch 对 mention 的豁免、onmention 偏好——邮件多两层个人偏好与语言分批,站内多 receiverID 单播路径。共同的三块(repo ignore 最高优先、触发者排除、CheckRepoUnitUser 权限闸)各自实现而非抽公共库——两处 GetRepoIgnorersIDs 调用相隔 500 行,抽象收益低于维护成本。这是 Gitea 典型的"重复可容忍,错误的收件人不可容忍"取舍。

Q4:PayloadVersion 为什么搞两版而不是直接升级? 历史行是 v1(存最终 JSON)。若新代码只认 v2,重启后历史 task 的重放会解析失败。Deliver 的 if t.PayloadVersion == 1 || newRequest == nil 让老行走 default 原样转发——平滑升级零迁移。v2 的动机是渠道转换推迟(12.7.3):同一事件多渠道各自格式,落库只存一份原始事件,省 N-1 份冗余存储,且新增渠道不用回填历史。

Q5:为什么 HasEvent 把三个 review 事件归并到 pull_request_review,而 Event() 归并到三个不同名字? 两个函数服务不同读者。HasEvent 服务订阅者:UI 上只有一个"评审"复选框(源码 FIXME 承认该聚合本该是独立事件),勾选后写 map 键 pull_request_review。Event() 服务接收方:GitHub 兼容生态里 approved/rejected 是不同的 X-GitHub-Event 语义,必须保留区分(pull_request_approved/pull_request_rejected)。同一常量在两个方向上映射粒度相反,正是兼容性压力下的分层。

Q6:webhook 失败不重试,那 push 时 endpoint 恰好宕机怎么办? 三段式后果:① 该行 is_delivered=true, is_succeed=false 落库;② 若进程在 MarkTaskDelivered 之前崩溃,行仍是未投递,启动回灌会补投(populateWebhookSendingQueue);③ 已标记投递但失败的行,等用户在 history UI 看到红叉点 Redeliver,或 7 天后被 cron 清掉。没有中间态的自动补偿——这是与 GitHub(24h 内多次自动重试)的明确设计差异,第 10 章 10.11.3 已从队列 handler 返回值证明,本章从 Deliver 单次调用与 grep 无 retry 两面补证。

Q7:Pubsub 慢订阅者丢消息,为什么红点不会永久错? 因为状态不依赖消息内容。pubsub 丢的只是 notification-count 这个"提醒",真值永远在 notification 表里。三层补偿:worker-connected 对账(12.4.6)、轮询兜底(worker-unavailable 后退避 GET /notifications/new)、以及列表页本身每次渲染都现算 Count。消息通道是纯优化——丢了最多延迟,不错值。这与 webhook 恰好相反:webhook 消息即事实(payload 发出去就收不回),丢了必须补(回灌)或留痕(IsSucceed=false)。

Q8:一个仓库推送同时触发 feed、站内、webhook、Actions——谁最可能先完成? 按同步段成本排序:uinotification(纯入队)≈ webhook(1 次 insert + 入队)> actions(入队)> feed(事务写 N 行)> audit(写日志)> websocket(本例无红点信号)。但"完成"要区分入队与落地:落地顺序由各队列 worker 调度决定,通常 notification-service 最先写库(批小),webhook 投递最慢(要等 100ms 攒批 + 网络往返)。没有跨 notifier 的顺序保证——业务代码若依赖"A 通知先于 B 通知",就该在同一个 notifier 方法里完成,而不是指望总线。

Q9:PrepareWebhooks 三层收集会不会给一个事件重复投递? 会——如果同一个 URL 被配在 repo 和 system 两级,PrepareWebhook 会为每个 hook 建独立 task,同一 endpoint 收到两份相同 payload(UUID 不同)。IsActive 过滤与 unique 队列都防不住这种"配置级重复"。X-Gitea-Delivery UUID 是接收方去重的唯一依据——这也是一行三生态 Delivery 头都存在的理由(12.7.5)。

Q10:为什么 NotificationCountChange 要做成总线方法而不是 wsNotifier 的私有入口? 因为生产者不只有 uinotification。services/notifications/status.go 四个状态变更函数(读一条、批量读、全部已读)都在模型层写完后调总线——若这是 wsNotifier 私有方法,路由层就得 import services/websocket 直连推送层,违反"路由只调 services/notifications,services/notifications 只调 notify 总线"的依赖方向(第 1 章 1.3 的分层规则)。总线方法让"状态变了"成为一等事件,未来加第二个消费者(如桌面通知)无需动所有生产者。

12.12 本章小结与章节衔接

四条主线收束:

  1. 总线极简、消费自治:44 方法接口 + 同步 for 多播 + 4 道闸门就是 services/notify 的全部;11 个注册点(10 生产 + 1 测试,mailer 条件注册)各自决定同步写库还是入队异步。NullNotifier 嵌入让宽接口可落地,fire-and-forget 让可靠性责任完整下放给每个 notifier;
  2. 站内通知是"表 + 推 + 轮询"三层:notification 表(复合索引 u_s_uu)、notification-service 队列内的收件人计算(七步筛选)、pubsub Broker 双实现(Memory 单进程 / Redis 跨进程)、SharedWorker 单连接复用与三层容错(对账/退避轮询/序号防乱序)。状态是唯一真源,消息只是提醒;
  3. 邮件是收件人学 + 模板学:五段来源三重排除的收件人链、onmention/disabled/andyourown 四值偏好、按语言 ×100 分批、单文件 subject/body 分割与三级模板回退、Message-ID/In-Reply-To/List-Unsubscribe 线程头。全内存队列无重试,可靠性靠"用户会再问一次";
  4. Webhook 是契约工程:HookEvents map 订阅模型(证伪位掩码)、31→28 个事件常量与三套事件名(常量/Event 归并/HasEvent 聚合)、PayloadVersion 1/2 平滑升级、payloadConvertor[T] 泛型让 9 个外部渠道共享 15 类转换、HMAC 双算法 + 三生态头。可靠性 = 可见性(hook_task 四字段状态机 + history UI + 人工 Redeliver)+ 启动回灌 + cron 清理,没有自动重试(第 10 章 10.11.3 结论,本章 12.9.2 补全证据链)。

与第 10 章(异步基础设施)的关系:本章是第 10 章 10.7 节"通知全链路"的横向展开——那里的 11 notifier 表与评论→红点时序图讲"总线怎么接队列",本章讲"每个消费端内部怎么做";10.11.3 的"webhook 无自动重试"在 12.9.2 得到第二、三重证据;10.7.4 的队列兜底三档在 12.2.2/12.4.2/12.7.4 各有实例(Fatal/return error/降级)。队列的攒批语义、unique 去重、回灌策略仍是本章一切异步行为的底座。

与第 14 章(Actions 与软件包)的关系:本章只到"actions notifier 注册 + workflow_run/workflow_job 事件 + MailActionsTrigger 邮件"的边界(12.10.2 表最后两行)。Actions 如何消费 WorkflowRunStatusUpdate/WorkflowJobStatusUpdate 触发 workflow、HookEventSchedule 怎样成为第 14 章的主角、PackageCreate/Delete 事件在包页面的应用,都在第 14 章展开。第 14 章 110 行处引用的 notify_service.RegisterNotifier(NewNotifier()) 示意(grep 实测 docs/tech-article/chapter-14-actions-packages.md:110),指回本章 12.2 的真实注册表。

给后续章节的三个接口约定(读 Actions/管理篇时会再遇到):事件名三件套(HookEventType 常量值 / Event() 归并名 / HasEvent 聚合键);X-Gitea-Event 与 X-Gitea-Event-Type 头的粒度差;notification 表的 Source 枚举是列表页模板分派的依据。