Ghost 邮件链接点击追踪全解析:link-redirection 服务中 `/r/` 短链、RedirectEvent 与 last_seen_at 更新的工作流
2026/9/8 16:41:25 网站建设 项目流程

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 完成了三个关键接线:

  1. models.Redirect(Bookshelf 模型)和urlUtils构造LinkRedirectRepository
  2. 若配置hostSettings:linkRedirectsPublicCache:enabled为真,则通过adapterManager.getAdapter('cache:linkRedirectsPublic')注入公共缓存适配器;
  3. urlUtils.getSiteUrl()解析出baseURL,构造LinkRedirectsService,保证生成的短链永远指向当前站点根地址。

第一站:点击/r/{hash},查库并返回 302

请求处理入口

服务对每次请求的处理都在LinkRedirectsService.handleRequest(link-redirects-service.js)中完成,这是一个绑定好this、可直接挂到 Express 路由上的中间件(构造函数里执行了this.handleRequest = this.handleRequest.bind(this))。其过程为:

  1. req.originalUrl构造 URL,交给仓储层按路径名查询重定向记录。
  2. 查不到就直接next()放行,让后续路由(例如静态资源或普通页面)继续处理。
  3. 查到后先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后的对象写回缓存。

仓库构造函数中还对EventRegistrysite.changed事件做了订阅,一旦站点配置变化(如链接被编辑、站点子目录变更、分析设置变更)就cache.reset()全量失效(见 link-redirect-repository.js),源码注释将其称为"a bit of a blunt instrument"(一把略显粗放的锤子),但足以覆盖所有需要失效缓存的场景。

LinkRedirect领域对象与"edited"标记

查到的记录会先经fromModel(link-redirect-repository.js)转换为领域对象。领域类定义在 link-redirect.ts,字段如下:

字段类型含义
link_idObjectID(bson-objectid)主键,未传id时自动生成
fromURL内部短链地址(路径名)
toURL原始目标地址
editedboolean该链接是否被发布者手动编辑过
automationActionRevisionIdstring \| undefined若属于自动化邮件,记录所属的 action revision

其中edited的判断很微妙:updated_atcreated_at相差超过 1000ms 才算编辑过(源码注释提示存在个别边界场景两者几乎同时写入,需要这段毫秒级缓冲)。

第二站:RedirectEvent的订阅者——点击事件入库

事件订阅

点击事件统计由LinkClickTrackingService(link-click-tracking-service.js)承担。它的subscribe()(link-click-tracking-service.js)订阅了RedirectEvent

  1. event.data.url.searchParams.get('m')取回会员 UUID——若 URL 中没有m参数(例如直接访问短链而非从邮件点来)则直接 return,不产生任何点击事件
  2. 用 UUID、link_id与事件时间戳构造一个LinkClick
  3. 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 (?, ?, ?, ?) undefined
select `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),它同时订阅了MemberPageViewEventMemberCommentEventEmailOpenedEventMemberLinkClickEvent,是会员活跃度的统一维护者。文档中的流程可以这样概括:

  1. 事件到达后,先比对事件携带的memberLastSeenAt是否已经晚于"站点时区的今天零点";
  2. 若已更新过,直接结束("If it has, we stop here.");
  3. 若未更新过,才进入事务更新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 trx34
select `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 ? trx34
COMMIT; trx34

进程内缓存:同一会员一天只写一次库

事务之外还有一层进程内加速:LastSeenAtCachelast-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_ENTRYSQLITE_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 包,其官方开发/测试流程同样适用于仓库根目录下的所有包:

  1. git clone该仓库并进入目录;
  2. 在顶层执行pnpm安装全部依赖(pnpm workspace,见根目录 pnpm-workspace.yaml);
  3. 执行质量检查:
    • 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 →LinkClickTrackingServicem参数查members→ 向members_click_events插入并回查 → 派发MemberLinkClickEventLastSeenAtUpdater在"当天首次 + 行锁 + 进程内缓存"三重约束下事务化更新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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询