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,类型名 notificationService | 12.4 |
| 存在 "agit notifier" | 不存在。services/agit 是普通服务包,不调用 RegisterNotifier | 12.2 |
| 外部渠道含 Mastodon | 没有 Mastodon。HookType 常量共 11 个:gitea/gogs/slack/discord/dingtalk/telegram/msteams/feishu/matrix/wechatwork/packagist | 12.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):
| 分组 | 方法(按文件顺序) | 数量 |
|---|---|---|
| 生命周期 | Run | 1 |
| 仓库 | AdoptRepository CreateRepository MigrateRepository DeleteRepository ForkRepository RenameRepository TransferRepository RepoPendingTransfer | 8 |
| Issue | NewIssue IssueChangeStatus DeleteIssue IssueChangeMilestone IssueChangeAssignee PullRequestReviewRequest IssueChangeContent IssueClearLabels IssueChangeTitle IssueChangeRef IssueChangeLabels | 11 |
| Pull Request | NewPullRequest MergePullRequest AutoMergePullRequest PullRequestSynchronized PullRequestReview PullRequestCodeComment PullRequestChangeTargetBranch PullRequestPushCommits PullReviewDismiss | 9 |
| 评论 | CreateIssueComment UpdateComment DeleteComment | 3 |
| Wiki | NewWikiPage EditWikiPage DeleteWikiPage | 3 |
| Release | NewRelease UpdateRelease DeleteRelease | 3 |
| Git 引用 | PushCommits CreateRef DeleteRef SyncPushCommits SyncCreateRef SyncDeleteRef | 6 |
| 包 | PackageCreate PackageDelete | 2 |
| 杂项 | ChangeDefaultBranch CreateCommitStatus WorkflowRunStatusUpdate WorkflowJobStatusUpdate NotificationCountChange StopwatchChanged | 6 |
几个值得注意的签名设计:
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 循环、逐个调用、不聚合错误、不并发。
真正的逻辑集中在四道前置闸门,它们决定"事件要不要发":
- 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 个消费方行为一致——这是"汇聚器不只是哑管道"的证据。
-
属性预加载闸门——部分事件在多播前强制 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。 -
shouldSendCommentChangeNotification只护评论三方法——NewIssue没有任何闸门,IssueChangeStatus也没有。 -
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 处:
| # | 注册点 | 注册时机 | 覆写方法数(约) | 下游 |
|---|---|---|---|---|
| 1 | services/webhook/notifier.go:32 | package init() | 全量约 40 | webhook_sender unique 队列 |
| 2 | services/mirror/notifier.go:16 | package init() | 2(PushCommits/SyncPushCommits) | 同步镜像队列 |
| 3 | services/audit/notifier.go:19 | package init() | 仓库/issue/PR/wiki 类若干 | 审计日志写库(同步) |
| 4 | services/feed/notifier.go:31 | feed.Init() | 21 | 动态流 action 表(同步事务) |
| 5 | services/uinotification/notify.go:36 | uinotification.Init() | 13 | notification-service Simple 队列 |
| 6 | services/indexer/indexer.go:15 | indexer.Init() | issue 变更类 | 索引队列 |
| 7 | services/automerge/automerge.go:35 | automerge.Init(ctx) | PR 合并条件类 | pr_auto_merge 队列 |
| 8 | services/websocket/notifier.go:16 | websocket.Init() | 2(红点/秒表) | pubsub Broker |
| 9 | services/actions/init.go:69 | actions.Init(ctx) | workflow 触发类 | Actions 工作流 |
| 10 | services/mailer/mailer.go:34 | mailer.NewContext,条件注册 if setting.Service.EnableNotifyMail | 16 | mail Simple 队列 |
| 11 | services/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 总线——grepRegisterNotifier无一命中; - 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:187 | PR 创建后 |
notify_service.MergePullRequest(ctx, doer, pr) | services/pull/merge.go:387 | 合并成功后 |
notify_service.PushCommits(...) ×5 | services/repository/push.go:105/127/194、services/release/release.go:135/395 | push_update 队列 worker 内(tag 删除/新建、分支推送) |
notify_service.WorkflowRunStatusUpdate 等 | services/actions/notify.go:81/105/120/133/146 | Actions 运行状态机 |
这揭示调用点的两级结构:
- HTTP 直调级:issue/PR/评论类事件在 handler goroutine 内同步多播——多播的耗时预算就是"每个 notifier 一次 Push",所以 HTTP 响应不受通知影响;
- 队列 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.Push | 1 次 insert + 入队 | 入队即返回,投递在 worker |
| uinotification | issueQueue.Push(opts) | O(1) 入队 | 收件人计算在队列 worker(CreateOrUpdateIssueNotifications) |
| mailer | 收件人查询 + 组装 Message → SendAsync(goroutine 内 mailQueue.Push) | 同步查收件人(多次 DB 查询) | sendAsync 再开 goroutine 推队列,mail_issue.go 的 Load 三件套也在同步段 |
| feed | db.WithTx 内给每个 watcher insert 一条 action | O(关注者数) 同步写库 | 全总线最重的同步 notifier;NotifyWatchers 事务内逐 watcher 算权限(feed/feed.go:100-163) |
| audit | 写审计日志 | 小 | 同步 |
| websocket | HasTopicSubscribers 捐导 → db.Count unread → Publish | 1 次 count(有订阅者时) | 无订阅者直接返回 |
| indexer | 推索引队列 | O(1) | 异步 |
| mirror | syncPushMirrorWithSyncOnCommit 入队 | 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 的工程后果
调用方丢弃返回值(事件函数无返回值)带来四个可观察后果:
- 通知丢失不回滚业务:
CreateHookTask失败只log.Error(webhook/notifier.go各方法统一log.Error("PrepareWebhooks ...")后继续),issue 照常创建; - 顺序只在单事件内保证:两条评论的两次多播若发生在不同请求 goroutine,各自入队顺序不保证与时间序一致——队列 worker 按入队序处理,
CommentID单调递增可作弱序参考; - 无背压到业务:队列满时
Push返回queue.ErrAlreadyInQueue(unique 队列)或超时错误,uinotification 直接_ = ns.issueQueue.Push(opts)忽略(uinotification/notify.go:82等全部调用点); - 测试可整体替换:
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 | 值 | 说明 |
|---|---|---|
NotificationStatusUnread | 1 | 未读(红点计数只数这个) |
NotificationStatusRead | 2 | 已读 |
NotificationStatusPinned | 3 | 置顶(API status-types 默认查 unread+pinned) |
| NotificationSource | 值 | 说明 |
|---|---|---|
NotificationSourceIssue | 1 | 普通 issue |
NotificationSourcePullRequest | 2 | PR(issue.IsPull 时创建函数自动选) |
NotificationSourceCommit | 3 | commit(保留字段) |
NotificationSourceRepository | 4 | 仓库级事件(仅 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 个方法里有三处非平凡逻辑:
NewPullRequest手动圈人(notify.go:142-175):新 PR 没有"issue 级订阅"可依赖(用户还没来得及 watch),所以手动收集GetRepoWatchersIDs(WatchPullRequests)+GetParticipantsIDsByIssueID− poster + mentions,进container.Set[int64]去重后逐个单播;IssueChangeTitle只认 WIP 解除(notify.go:118-129):仅当旧标题是 WIP 前缀且新标题不是(HasWorkInProgressPrefix(oldTitle) && !IsWorkInProgress)才通知——"PR 转正 ready_for_review"值得打扰,普通改标题不值得;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) | 多进程/多实例 |
| Subscribe | RLock 下挂 chan,cancel 用 sync.Once 关闭 | 每 topic 一条 Redis SUBSCRIBE 由本地订阅者共享,最后一个 cancel 才拆 |
| Publish | RLock 横跨整个扇出(防 cancel 在读 slice 与 send 之间关 channel) | PUBLISH gitea-ws-topic:<topic>,HammerContext + 2s 超时 |
| 慢订阅者 | select/default 丢弃并 Trace | 同款 select/default 丢弃 |
| HasTopicSubscribers | len(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);
});
三层容错设计:
- 连接间隙对账:
worker-connected时主动 GET/notifications/new拉一次真值——断线期间 pubsub 按subChanBuffer=8丢的消息在这里补齐(注释直说 "recover any push dropped during the connect gap"); - 轮询退避:
updateNotificationCountWithCallback比较上次与当前计数,有变化回落到MinTimeout,无变化逐次+= TimeoutStep直至MaxTimeout; - 列表渲染防乱序:
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 覆写的方法及其守卫条件(全部核对自源码):
| 方法 | 守卫/特殊逻辑 | 下游入口 | ||
|---|---|---|---|---|
CreateIssueComment | comment.Type 映射 ActionType;PullRequestPush 类型 act=0 | MailParticipantsComment | ||
NewIssue | — | MailParticipants(ActionCreateIssue) | ||
IssueChangeStatus | 按 isPull × isClosed 组合 4 种 ActionType | MailParticipants | ||
IssueChangeTitle | 仅 WIP→ready_for_review(HasWorkInProgressPrefix(oldTitle) && !IsWorkInProgress) | MailParticipants(ActionPullRequestReadyForReview) | ||
NewPullRequest | — | MailParticipants(ActionCreatePullRequest) | ||
PullRequestReview | comment.Type 映射(close/reopen/comment) | MailParticipantsComment | ||
PullRequestCodeComment | 只发 mention | MailMentionsComment | ||
IssueChangeAssignee | !removed && doer!=assignee && preference!=disabled | SendIssueAssignedMail | ||
PullRequestReviewRequest | isRequest && doer!=reviewer && preference!=disabled | SendIssueAssignedMail | ||
MergePullRequest/AutoMergePullRequest | 先 pr.LoadIssue | MailParticipants | ||
PullRequestPushCommits | 加载 comment→issue→repo→pull→baseRepo 五层 + LoadCommentPushCommits | 转调自身 CreateIssueComment | ||
PullReviewDismiss | comment.Review.LoadReviewer | MailParticipantsComment(ActionPullReviewDismissed) | ||
NewRelease | **`rel.IsDraft | rel.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):
| 常量 | 值 | 语义 |
|---|---|---|
EmailNotificationsEnabled | enabled | 收全部(除自己的)——默认 |
EmailNotificationsOnMention | onmention | 只收被 @(fromMention 才过闸) |
EmailNotificationsDisabled | disabled | 全不收(GetMailableUsersByIDs 直接过滤) |
EmailNotificationsAndYourOwn | andyourown | 全收 + 自己的 |
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/FROM | Gitea | 发件人名/地址(FROM 兼旧 USER) |
ENVELOPE_FROM | — | SMTP MAIL FROM 覆盖 |
PROTOCOL | smtp | smtp|smtps|smtp+starttls|smtp+unix|sendmail|dummy |
SMTP_ADDR/SMTP_PORT | —/按端口推断 | 25→smtp、465→smtps、587→smtp+starttls(省 PORT 时自动推断) |
USER/PASSWD | — | 认证(PASSWD 走 secret 加密) |
ENABLE_HELO | false | 显式 HELO |
FORCE_TRUST_SERVER_CERT | false | 跳过证书校验(仅限内网) |
SENDMAIL_PATH/SENDMAIL_TIMEOUT | sendmail/5s | sendmail 协议 |
FROM_DISPLAY_NAME_FORMAT | — | 发件人显示名模板(fromDisplayName) |
SendAsPlainText | false | 纯文本替代 HTML |
SubjectPrefix | — | 主题前缀(如 [Gitea]) |
EmbedAttachmentImages | false | base64 内嵌(12.5.4) |
| 旧键迁移 | — | MAILER_TYPE→PROTOCOL、SMTP_FROM→FROM 等 deprecatedSetting 迁移 |
[service] ENABLE_NOTIFY_MAIL | false | mail notifier 注册开关(12.2.1) |
[mailer.override_header] | — | 覆盖任意邮件头(generateMetadataHeaders 后合并) |
暂停/退订机制的完整栈(从个体到全局):
- 个人
EmailNotificationsPreference=disabled——GetMailableUsersByIDsSQL 层过滤; - 个人
onmention—— 批过滤 only fromMention; - issue 显式 unwatch —— 广播批
visited排除; - 仓库 mute(ignore)—— 最高优先级排除;
- 邮件内
List-Unsubscribemailto token —— 收件人一键退订该 issue(IncomingEmail); - 站点级
ENABLE_NOTIFY_MAIL=false—— notifier 不注册,一封不发; setting.MailService == nil(未配邮件)——MailParticipants开头短路(mail_issue.go:167)。
12.5.6 专项邮件:release、指派、仓库转移
MailNewRelease(mail_release.go:32):WatchReleaseswatchers →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):
| 形态 | RepoID | OwnerID | IsSystemWebhook | Target-Type 头 |
|---|---|---|---|---|
| 仓库 webhook | ≠0 | — | false | repository |
| 组织级 webhook | 0 | ≠0(org) | false | organization |
| 用户级 webhook | 0 | ≠0(user) | false | user |
| 系统 webhook(admin 站点级) | 0 | 0 | true | system |
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:HookContentTypeiota+1 →ContentTypeJSON=1、ContentTypeForm=2(webhook.go:69-73),name 表{"json":1,"form":2};HTTPMethod:空串按 POST 处理(newDefaultRequestswitch),支持 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() |
|---|---|---|---|
HookEventCreate | create | create | ✔ |
HookEventDelete | delete | delete | ✔ |
HookEventFork | fork | fork | ✔ |
HookEventPush | push | push | ✔ |
HookEventIssues | issues | issues | ✔ |
HookEventIssueAssign | issue_assign | issues | ✔ |
HookEventIssueLabel | issue_label | issues | ✔ |
HookEventIssueMilestone | issue_milestone | issues | ✔ |
HookEventIssueComment | issue_comment | issue_comment | ✔ |
HookEventPullRequest | pull_request | pull_request | ✔ |
HookEventPullRequestAssign | pull_request_assign | pull_request | ✔ |
HookEventPullRequestLabel | pull_request_label | pull_request | ✔ |
HookEventPullRequestMilestone | pull_request_milestone | pull_request | ✔ |
HookEventPullRequestComment | pull_request_comment | issue_comment | ✔ |
HookEventPullRequestReviewApproved | pull_request_review_approved | pull_request_approved | ✔ |
HookEventPullRequestReviewRejected | pull_request_review_rejected | pull_request_rejected | ✔ |
HookEventPullRequestReviewComment | pull_request_review_comment | pull_request_comment | ✔ |
HookEventPullRequestSync | pull_request_sync | pull_request | ✔ |
HookEventPullRequestReviewRequest | pull_request_review_request | pull_request | ✔ |
HookEventWiki | wiki | wiki | ✔ |
HookEventRepository | repository | repository | ✔ |
HookEventRelease | release | release | ✔ |
HookEventPackage | package | package | ✔ |
HookEventStatus | status | status | ✔ |
HookEventPullRequestReview | pull_request_review | pull_request_review | ✘(聚合键,源码 FIXME 注释) |
HookEventSchedule | schedule | schedule | ✘(Actions 专用) |
HookEventWorkflowRun | workflow_run | workflow_run | ✔ |
HookEventWorkflowJob | workflow_job | workflow_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 |
| Release | HookReleasePublished/Updated/Deleted = published/updated/deleted |
| Wiki | HookWikiCreated/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: ...})。
非平凡细节三处(源码注释原样):
CreateRef的 FIXME(notifier.go:822):create/delete 事件的Ref: refFullName.ShortName(), // FIXME: should it be a full ref name? But it will break the existing webhooks?Ref发的是短名(feature/x而非refs/heads/feature/x),为兼容老订阅方保留;ForkRepository发两个事件(notifier.go:86-112):源仓库的fork+ 新仓库的repository/created(仅当新属主是组织);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)
}
五道闸门逐条:
- 全局开关
setting.DisableWebhooks(admin 可一键停全站 webhook); - 事件订阅
HasEvent(12.6.1 的三模式 + review 归并); - 空 push 过滤:
len(Commits)==0的 push(如只推了被 gitignore 的东西)对聊天类渠道(slack/discord…)没意义,但集成类(gitea/gogs/drone)必须收到——按w.Type区分。PrepareTestWebhook注释也点名这组 gate(12.9.3); - 分支过滤
checkBranchFilter:glob.Compile(branchFilter)后先试ref.BranchName()再试完整 ref(webhook.go:114-130);空/*/**全通过。getPayloadRef只对 Create/Delete/Push payload 返回 ref——issue/PR 等无 ref payload 天然跳过此闸; - 落库 + 入队:
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 按序执行:
GetWebhookByID拿 hook 配置;defer recover()——panic 记栈不崩 worker(全 notifier 体系唯一的 recover 点);- 选 requester:
webhookRequesters[w.Type],v1 或未注册走newDefaultRequest; - 构造请求后记录
RequestInfo(URL/Method/Headers/Body);自定义 Authorization 头写入前掩码t.RequestInfo.Headers["Authorization"] = "******"——历史记录永不落明文凭证; - CAS 抢占:
MarkTaskDelivered(WHERE is_delivered = false的条件更新,count != 0才继续)——unique 队列之外的第三道防重(队列去重 → handler IsDelivered 检查 → DB CAS); defer写回:Delivered时间戳、UpdateHookTask(IsSucceed + Request/ResponseContent)、UpdateWebhookLastStatus(红绿标);setting.DisableWebhooks时返回 error(记录失败原因);!w.IsActive记 "skipped" 后返回 nil;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 | 载荷类型 | 特殊点 |
|---|---|---|---|---|
| slack | slack.go:318 | newSlackRequest | SlackPayload{Text, Attachments[]} | channel/username/icon 存 Meta |
| discord | discord.go:310 | newDiscordRequest | embeds 结构 | avatar_url 覆盖 |
| dingtalk | dingtalk.go:216 | newDingtalkRequest | 加密/普通 msg | secret 存 Meta 可选签名 |
| telegram | telegram.go:225 | newTelegramRequest | sendMessage | token 在 URL |
| msteams | msteams.go:414 | newMSTeamsRequest | MessageCard/adaptive | — |
| feishu | feishu.go:223 | newFeishuRequest | 卡片 msg | 签名 key |
| matrix | matrix.go:29 | newMatrixRequest | room message | PUT + txnID 幂等(12.6.1) |
| wechatwork | wechatwork.go:205 | newWechatworkRequest | markdown msg | — |
| packagist | packagist.go:137 | newPackagistRequest | Packagist 免费格式 | 只发 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 列:
| 阶段 | IsDelivered | IsSucceed | Delivered |
|---|---|---|---|
| 已入队未投递 | false | false | 0(CreateHookTask 会预填 now) |
| 投递成功 | true | true | 投递时刻 |
| 投递失败(5xx/网络错) | true | false | 投递时刻 |
| 被跳过(hook 停用/全站关闭) | true | false | 投递时刻(记 "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 回队重试。本章补齐完整证据链的另外三环:
- 投递层无 retry 循环:
Deliver单次webhookHTTPClient.Do,grep -r "retry\|Retry" services/webhook/零匹配(本章证伪清单第 7 行); - 失败被刻意记录而非吞掉:
defer里UpdateHookTask写IsSucceed=false+ 响应详情 +UpdateWebhookLastStatus——失败的归宿是三行数据库记录(第 10 章原话),供人工查看与重放; - 重放是人工动作:
ReplayHookTask(12.7.4)由 UI 触发(routers/web/repo/setting/webhook.go:735的ReplayWebhookhandler,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 代码。实际起限速作用的是三层既有机制:
- 队列背压:
webhook_sender是WorkerPoolQueue,[queue.webhook_sender]的MAX_WORKERS/LENGTH(默认 100000)决定并发投递数与积压上限——吞吐上限 = worker 数,这是唯一真正的"速率控制"(配置在第 10 章 10.5); - 单请求超时:
DeliverTimeout=5s——慢 endpoint 5 秒必断,worker 不被长期占用; - 出口限制:
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["routers (HTTP handler)<br/>issue/PR/comment/wiki/release..."]
C2["push_update 队列 worker<br/>services/repository/push.go"]
C3["actions/notify.go<br/>workflow 状态机"]
end
subgraph BUS[notify 总线 services/notify]
REG["RegisterNotifier<br/>go notifier.Run() + append"]
DISP["44 个事件函数<br/>同步 for 多播 + 4 道闸门"]
end
C1 --> DISP
C2 --> DISP
C3 --> DISP
REG --> DISP
DISP -->|同步, fire-and-forget| N1["feed<br/>action 表 (事务同步写)"]
DISP --> N2["uinotification<br/>notification-service 队列"]
DISP --> N3["webhook<br/>hook_task 落库 + webhook_sender 队列"]
DISP --> N4["mailer (条件注册)<br/>mail 队列 → SMTP/sendmail"]
DISP --> N5["websocket<br/>pubsub Broker (memory/redis)"]
DISP --> N6["indexer → 索引队列"]
DISP --> N7["mirror → 镜像同步队列"]
DISP --> N8["automerge → pr_auto_merge 队列"]
DISP --> N9["actions → workflow 触发"]
DISP --> N10["audit → 审计日志 (同步)"]
N2 -->|handler 写库后 NotificationCountChange| DISP
N5 --> WS[["/-/ws 长连接"]]
WS --> SW["SharedWorker<br/>(多 tab 共享)"]
SW --> UI["红点 / 秒表 / logout"]
SW -. worker-unavailable .-> POLL["退避轮询 /notifications/new"]
N3 --> DELIV["Deliver: HMAC 签名<br/>X-Gitea-/X-Gogs-/X-Hub-/X-GitHub- 头<br/>2xx=成功, 无自动重试"]
DELIV --> EXT["外部: slack/discord/dingtalk/<br/>telegram/msteams/feishu/matrix/<br/>wechatwork/packagist/gitea/gogs"]
N3 -. ReplayHookTask (人工) .-> DELIV
subgraph CRON[cron 兜底]
CLEAN["cleanup_hook_task_table<br/>@midnight, OlderThan 168h"]
end
N3 -. 清理历史 .-> CLEAN
12.10.2 事件对照表:业务动作 → 总线方法 → 各 notifier 的反应
| 业务动作 | 总线方法(12.1.1) | uinotification | mailer | webhook EventType | feed ActionType |
|---|---|---|---|---|---|
| 新建 issue | NewIssue | 广播 + mention 单播 | MailParticipants | issues (action=opened) | ActionCreateIssue |
| 评论 | CreateIssueComment | 广播 + mention 单播(pending review 被闸) | MailParticipantsComment | issue_comment/pull_request_comment (created) | ActionCommentIssue/Pull |
| 关闭/重开 | IssueChangeStatus | 广播 | MailParticipants | issues/pull_request (closed/reopened) | ActionClose/Reopen* |
| 新建 PR | NewPullRequest | 手动圈人单播(12.4.2) | MailParticipants | pull_request (opened) | ActionCreatePullRequest |
| 合并 PR | MergePullRequest | 广播 | MailParticipants | pull_request (merged) | ActionMergePullRequest |
| 提交评审 | PullRequestReview | 广播 + mention | MailParticipantsComment | pull_request_review_approved/rejected/comment | ActionApprove/RejectPullRequest |
| WIP 解除 | IssueChangeTitle | 仅 WIP→ready 广播 | 同(ready_for_review 邮件) | pull_request (ready_for_review) | ActionPullRequestReadyForReview |
| 推送 | PushCommits | —(不覆写) | —(不覆写) | push | ActionCommitRepo/PushTag |
| 新建分支/tag | CreateRef | — | — | create | ActionCreateBranch(tag 跳过,PushCommits 已发) |
| 删除分支/tag | DeleteRef | — | — | delete | ActionDeleteBranch(tag 跳过) |
| 新 Release | NewRelease | — | draft/prerelease 跳过 | release (published) | ActionPublishRelease |
| 仓库转移 | RepoPendingTransfer | 同步事务写 | SendRepoTransferNotifyMail | repository (transferred) | ActionTransferRepo |
| 红点变化 | NotificationCountChange | (产生方) | — | — | — |
| 秒表启停 | StopwatchChanged | — | — | — | — |
| workflow 状态 | WorkflowRunStatusUpdate | — | MailActionsTrigger | workflow_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) |
|---|---|---|---|---|---|---|
| gitea | json/form | HMAC sha256 (X-Gitea-Signature) | ✔ | ✘(集成类保留) | ✘ | — |
| gogs | json/form | HMAC (X-Gogs-Signature) | ✔ | ✘ | ✘ | — |
| slack | json | 可选 (X-Hub-Signature-256) | ✔ | ✔ | ✘ | channel, username, icon_url, color |
| discord | json | ✘ | ✔ | ✔ | ✘ | username, avatar_url, color |
| dingtalk | json | 加密可选 | ✔ | ✔ | ✘ | secret |
| telegram | json | ✘(token 鉴权) | ✔ | ✔ | ✘ | —(token 在 URL) |
| msteams | json | ✘ | ✔ | ✔ | ✘ | — |
| feishu | json | 签名可选 | ✔ | ✔ | ✘ | secret |
| matrix | json | ✘ | ✔ | ✔ | ✔(txnID=UUID) | — |
| wechatwork | json | ✘(key 鉴权) | ✔ | ✔ | ✘ | — |
| packagist | json | ✘ | ✔ | ✔ | ✘ | — |
"空 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 本章小结与章节衔接
四条主线收束:
- 总线极简、消费自治:44 方法接口 + 同步 for 多播 + 4 道闸门就是
services/notify的全部;11 个注册点(10 生产 + 1 测试,mailer 条件注册)各自决定同步写库还是入队异步。NullNotifier嵌入让宽接口可落地,fire-and-forget 让可靠性责任完整下放给每个 notifier; - 站内通知是"表 + 推 + 轮询"三层:
notification表(复合索引 u_s_uu)、notification-service队列内的收件人计算(七步筛选)、pubsub Broker 双实现(Memory 单进程 / Redis 跨进程)、SharedWorker 单连接复用与三层容错(对账/退避轮询/序号防乱序)。状态是唯一真源,消息只是提醒; - 邮件是收件人学 + 模板学:五段来源三重排除的收件人链、
onmention/disabled/andyourown四值偏好、按语言 ×100 分批、单文件 subject/body 分割与三级模板回退、Message-ID/In-Reply-To/List-Unsubscribe 线程头。全内存队列无重试,可靠性靠"用户会再问一次"; - 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 枚举是列表页模板分派的依据。