Ghost 邮件链接点击追踪全解析:link-redirection 服务中/r/短链、RedirectEvent 与 last_seen_at 更新的工作流
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
在开启邮件点击分析后,Ghost 会把邮件正文中的每一个外链替换成形如https://{site_url}/r/{redirect hash}?m={member UUID}的追踪链接。当订阅者点击该链接时,Ghost 不仅要 302 重定向回原始地址,还要同步完成点击事件入库、会员last_seen_at更新与member.editedWebhook 触发等一系列分析动作。本文以 link-redirection 服务说明文档 为主体,结合 Ghost monorepo 中 link-redirection、link-tracking、members-events 三个服务的源码实现,完整拆解这一事件驱动链路。读完后你将掌握:redirects/members_click_events表的读写时机、重定向哈希与缓存机制、以及一次点击如何最终落到会员活跃度更新上。
总览:发布者点一下"开统计",Ghost 自动改写全部邮件链接
当发布者发送一封开启了邮箱点击统计(email click analytics)的 newsletter 时,Ghost 会先把邮件内容中所有链接替换为内部重定向链接,其格式为:
https://{site_url}/r/{redirect hash}?m={member UUID}其中/r/是重定向路由前缀,{redirect hash}是一个随机的 8 位十六进制短码(对应redirects表的from字段),?m=参数携带收件会员的 UUID。订阅者在邮件中点击该链接后,请求落到 Ghost 站点:Ghost 查到原始地址并以 302 跳转过去,同时在后台写入点击分析数据。这条"点击 → 302 → 异步记账"的核心链路,被官方文档归纳为下图:
图中的三种颜色区域分别对应三个不同的执行阶段:绿色代表同步的"查询并重定向"部分,红色代表异步的"点击事件入库",蓝色代表异步的"会员活跃度更新"事务,黄色代表收尾的 Webhook 投递。下面按此顺序逐段展开,并落到具体源码位置。
服务的装配:index.js如何接线
link-redirection 目录(ghost/core/core/server/services/link-redirection)由一个门面式 wrapper 启动:
| 文件 | 职责 |
|---|---|
| index.js | 服务装配入口,暴露单例LinkRedirectsServiceWrapper |
| link-redirects-service.js | 核心服务:短链生成、/r/路由处理、占位符替换 |
| link-redirect-repository.js | 仓储层:redirects表的增查与缓存 |
| link-redirect.ts | 领域对象LinkRedirect |
| redirect-event.js | 领域事件RedirectEvent |
在 index.js 的init()中,wrapper 完成了三个关键接线:
- 用
models.Redirect(Bookshelf 模型)和urlUtils构造LinkRedirectRepository; - 若配置
hostSettings:linkRedirectsPublicCache:enabled为真,则通过adapterManager.getAdapter('cache:linkRedirectsPublic')注入公共缓存适配器; - 用
urlUtils.getSiteUrl()解析出baseURL,构造LinkRedirectsService,保证生成的短链永远指向当前站点根地址。
第一站:点击/r/{hash},查库并返回 302
请求处理入口
服务对每次请求的处理都在LinkRedirectsService.handleRequest(link-redirects-service.js)中完成,这是一个绑定好this、可直接挂到 Express 路由上的中间件(构造函数里执行了this.handleRequest = this.handleRequest.bind(this))。其过程为:
- 由
req.originalUrl构造 URL,交给仓储层按路径名查询重定向记录。 - 查不到就直接
next()放行,让后续路由(例如静态资源或普通页面)继续处理。 - 查到后先
DomainEvents.dispatch(RedirectEvent.create({ url, link }))派发事件,再向响应头写入X-Robots-Tag: noindex, nofollow,最后res.redirect(redirectUrl)完成 302 跳转。
这里能注意到一个设计巧思:先派发事件、后响应客户端,因为dispatch是异步队列化投递,并不会阻塞 302 的返回。
存储层查询与缓存
对应官方 README 记录的首条 SQL:
select `redirects`.* from `redirects` where `redirects`.`from` = ? limit ? undefined它由 link-redirect-repository.js 的getByURL发起。在真正落库查询前,仓储会先做两件事:
- 通过
stripSubdirectoryFromPath去掉路径前导斜杠与站点子目录(Ghost 支持将博客部署在子目录下),得到干净的from键;这解释了为什么 SQL 中匹配的只有路径而不是完整 URL——"Only store the pathname (no support for variable query strings)",可变查询字符串不被存储。 - 如果开启了公共缓存,先
cache.get(from);命中则用#fromSerialized直接还原出LinkRedirect对象,完全跳过数据库;未命中则查库并把#serialize后的对象写回缓存。
仓库构造函数中还对EventRegistry的site.changed事件做了订阅,一旦站点配置变化(如链接被编辑、站点子目录变更、分析设置变更)就cache.reset()全量失效(见 link-redirect-repository.js),源码注释将其称为"a bit of a blunt instrument"(一把略显粗放的锤子),但足以覆盖所有需要失效缓存的场景。
LinkRedirect领域对象与"edited"标记
查到的记录会先经fromModel(link-redirect-repository.js)转换为领域对象。领域类定义在 link-redirect.ts,字段如下:
| 字段 | 类型 | 含义 |
|---|---|---|
link_id | ObjectID(bson-objectid) | 主键,未传id时自动生成 |
from | URL | 内部短链地址(路径名) |
to | URL | 原始目标地址 |
edited | boolean | 该链接是否被发布者手动编辑过 |
automationActionRevisionId | string \| undefined | 若属于自动化邮件,记录所属的 action revision |
其中edited的判断很微妙:updated_at与created_at相差超过 1000ms 才算编辑过(源码注释提示存在个别边界场景两者几乎同时写入,需要这段毫秒级缓冲)。
第二站:RedirectEvent的订阅者——点击事件入库
事件订阅
点击事件统计由LinkClickTrackingService(link-click-tracking-service.js)承担。它的subscribe()(link-click-tracking-service.js)订阅了RedirectEvent:
- 从
event.data.url.searchParams.get('m')取回会员 UUID——若 URL 中没有m参数(例如直接访问短链而非从邮件点来)则直接 return,不产生任何点击事件; - 用 UUID、
link_id与事件时间戳构造一个LinkClick; - 走
LinkClickRepository.save(click)落库。
会员查找与插入members_click_events
仓储实现在 link-click-repository.js。其save对应 README 中的三条 SQL:
先按 UUID 找会员:
select `members`.* from `members` where `members`.`uuid` = ? limit ? undefined当配置项linkClickTrackingCacheMemberUuid打开时,这段会员查找会被_.memoize缓存起来(memoizedFindOne),同一 UUID 的重复点击不会反复查库;而如果找不到该 UUID 对应的会员,则会 return 放弃记录,若同时开启了bulkEmail:captureLinkClickBadMemberUuid还会向 Sentry 上报一条LinkClickTrackingService > Member not found消息。
再插入一条点击事件并回查刚插入的行:
insert into `members_click_events` (`created_at`, `id`, `member_id`, `redirect_id`) values (?, ?, ?, ?) undefinedselect `members_click_events`.* from `members_click_events` where `members_click_events`.`id` = ? limit ? undefined派发MemberLinkClickEvent
插入成功后,仓储以会员 ID、会员当前的last_seen_at以及链接 ID 构造并派发MemberLinkClickEvent:
const event = this.#MemberLinkClickEvent.create({ memberId: member.id, memberLastSeenAt: member.get('last_seen_at'), linkId: linkClick.link_id.toHexString(), }, timestamp);值得注意的是事务语义:如果在事务内保存(options.transacting存在),事件会被延迟到transacting.executionPromise成功后再派发(见 link-click-repository.js),从而保证"事务提交成功才对外发事件",避免把失败的操作广播出去。
第三站:LastSeenAtUpdater——把点击换算成"会员今天来过"
按站点时区做"当天首次"判断
MemberLinkClickEvent的订阅方是LastSeenAtUpdater(last-seen-at-updater.js),它同时订阅了MemberPageViewEvent、MemberCommentEvent、EmailOpenedEvent与MemberLinkClickEvent,是会员活跃度的统一维护者。文档中的流程可以这样概括:
- 事件到达后,先比对事件携带的
memberLastSeenAt是否已经晚于"站点时区的今天零点"; - 若已更新过,直接结束("If it has, we stop here.");
- 若未更新过,才进入事务更新
last_seen_at。
具体的日界判断使用了moment-timezone,把事件时间戳换算成站点设置timezone(默认Etc/UTC)下的当天startOf('day'),再与会员现有值比较(last-seen-at-updater.js)。
事务、行锁与双保险
为了避免并发点击导致同一会员被同时重复更新,更新过程被包在一个数据库事务里,并且从 README 记录的实际 SQL 可以看到 Ghost 会先把会员行for update锁住:
BEGIN; trx34 select `members`.* from `members` where `members`.`id` = ? limit ? for update trx34锁内还会做第二次日界判断(这是防御并发竞态的"双保险"):只有在锁内确认last_seen_at仍早于当天零点时才真正更新。此外,更新前还会通过withRelated: ['labels', 'newsletters']预取会员的标签与订阅的 newsletter——官方注释点明了原因:走标准 members API 更新会触发行锁与关联查询之间潜在的死锁风险,因此这里特意绕开 Bookshelf 仓储直接操作,但为了后面补发member.editedWebhook(该事件需要携带 labels 和 newsletters 的 standard includes),仍需在此一并查询:
select `labels`.*, `members_labels`.`member_id` as `_pivot_member_id`, `members_labels`.`label_id` as `_pivot_label_id`, `members_labels`.`sort_order` as `_pivot_sort_order` from `labels` inner join `members_labels` on `members_labels`.`label_id` = `labels`.`id` where `members_labels`.`member_id` in (?) order by `sort_order` ASC for update trx34select `newsletters`.*, `members_newsletters`.`member_id` as `_pivot_member_id`, `members_newsletters`.`newsletter_id` as `_pivot_newsletter_id` from `newsletters` inner join `members_newsletters` on `members_newsletters`.`member_id` in (?) order by `newsletters`.`sort_order` ASC for update trx34随后执行真正的更新——这段update语句的字段列表非常完整,几乎覆盖了members表的全部核心列,其中真正被修改的只有last_seen_at(以patch: true方式保存):
update `members` set `uuid` = ?, `transient_id` = ?, `email` = ?, `status` = ?, `name` = ?, `expertise` = ?, `note` = ?, `geolocation` = ?, `enable_comment_notifications` = ?, `email_count` = ?, `email_opened_count` = ?, `email_open_rate` = ?, `email_disabled` = ?, `last_seen_at` = ?, `last_commented_at` = ?, `created_at` = ?, `updated_at` = ? where `id` = ? trx34更新后为拿到数据库侧最新值会再查一次会员,然后提交事务:
select `members`.* from `members` where `members`.`id` = ? limit ? trx34COMMIT; trx34进程内缓存:同一会员一天只写一次库
事务之外还有一层进程内加速:LastSeenAtCache(last-seen-at-cache,由LastSeenAtUpdater在构造时默认创建)。cachedUpdateLastSeenAt先问缓存shouldUpdateMember(memberId),只有"该会员今天还没更新过"才真正下库;更新前先cache.add(memberId)抢占,失败时cache.remove(memberId)回滚,保证下次事件能重试(last-seen-at-updater.js)。对于点击类事件,这个后台任务还可以整体关闭:只有当配置backgroundJobs:clickTrackingLastSeenAtUpdater不等于false时,LastSeenAtUpdater才会订阅MemberLinkClickEvent。
第四站:member.editedWebhook 投递
由于更新了会员,Ghost 需要按标准行为补发member.editedWebhook。事务提交后,LastSeenAtUpdater手动触发事件总线:
this._events.emit('member.edited', updatedMember);对应 README 中提交事务之后的那段查询——去webhooks表找出所有订阅了member.edited事件的 Webhook 并逐一向接收方投递:
select `webhooks`.* from `webhooks` where `event` = ? trx34代码注释特别指出:"The standard event doesn't get emitted inside the transaction, so we do it manually"——标准事件本应在事务内由模型自动发出,但这里为控制事务范围改为手动补发,投递发生在COMMIT之后,从而避免把 Webhook 发送(可能很慢的对外 HTTP 请求)拖进数据库事务中。
与自动化邮件集成的两条支线
README 只叙述了 newsletter 主链路,但当前源码中同一服务还承担了两个重要扩展场景,一并交代以加深理解。
短链的生成与唯一性保障
LinkRedirectsService.getSlugUrl(link-redirects-service.js)使用crypto.randomBytes(4).toString('hex')生成 8 位十六进制短码并拼成r/{slug};循环用getByURL探测直到拿到未被占用的唯一短码。relativeRedirectPrefix()则对外暴露/r/前缀,供 Express 路由在已剥离子目录后做精确匹配。
自动化的共享重定向与step参数
发送自动化(automation)邮件时,同一目标地址在同一 action revision 内会复用同一个共享短链而不是每封邮件新建一条。这是通过getOrAddAutomationRedirect(link-redirects-service.js)实现的:
- 先按
automationActionRevisionId + to_hash查找已有记录; - 没有则新建并写入,新建时把目标 URL 的 SHA-256 摘要存入
to_hash列——#getToHash使用crypto.createHash('sha256'),因为to列太长不适合建索引,需要用摘要做唯一性查找; - 若并发撞上唯一约束(
ER_DUP_ENTRY或SQLITE_CONSTRAINT*),则把"赢得竞态的并发插入者"查出来直接返回。
在链路消费端(link-click-tracking-service.js),如果事件携带automationActionRevisionId和 URL 上的step参数,点击记录会在事务内交给automationsApi.trackEmailClicked,实现"点击即推进自动化流程"的副作用。
动态会员 UUID 占位符
handleRequest在重定向前还会检查目标地址中是否含%%{uuid}%%占位符(定义于 link-redirects-service.js)。由于该占位符可能以不同编码形态出现(查询串原样保留、路径中被转义为%7B/%7D、甚至双重编码),代码会先尝试decodeURIComponent,失败则把%7B/%7D归一化回花括号,再校验m参数是否匹配 UUID v4 格式正则(/^[0-9a-f]{8}-...$/i)。合法则把占位符替换为当前会员 UUID(用于 Transistor 之类嵌入页的归因),非法或缺失则删除占位符。
测试与开发指引
link-redirection 是一个 monorepo 包,其官方开发/测试流程同样适用于仓库根目录下的所有包:
git clone该仓库并进入目录;- 在顶层执行
pnpm安装全部依赖(pnpm workspace,见根目录 pnpm-workspace.yaml); - 执行质量检查:
pnpm lint:仅运行 ESLint;pnpm test:同时运行 lint 与单元测试。
相关文件地图
围绕该主题可在仓库内继续深入阅读的源码与入口:
- 重定向服务本体:link-redirects-service.js、link-redirect-repository.js
- 领域对象与事件:link-redirect.ts、redirect-event.js
- 点击统计消费端:link-click-tracking-service.js、link-click-repository.js
- 活跃度更新端:last-seen-at-updater.js
- API 层的全量重定向失效触发点:
INVALIDATE_ALL_REDIRECTS = '/r/*'(见 links.js,位于api/endpoints/links.js)
小结:一次点击的全链路数据流向
把整条链路串起来就是:会员在邮件中点下/r/{hash}?m={uuid}→LinkRedirectsService.handleRequest查询redirects(命中公共缓存则跳过 DB)→ 派发RedirectEvent并立即 302 →LinkClickTrackingService按m参数查members→ 向members_click_events插入并回查 → 派发MemberLinkClickEvent→LastSeenAtUpdater在"当天首次 + 行锁 + 进程内缓存"三重约束下事务化更新members.last_seen_at(顺带预取 labels/newsletters)→ 提交事务 → 手动补发member.editedWebhook。整个过程中,HTTP 响应与数据记账被事件总线彻底解耦,既保证了订阅者跳转的低延迟,也把分析写入控制在可控的事务与去重范围内。
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考