Ghost 邮件链接点击追踪全解析:link-redirection 服务中 `/r/` 短链、RedirectEvent 与 last_seen_at 更新的工作流

发布时间:2026/9/8 16:41:31
Ghost 邮件链接点击追踪全解析:link-redirection 服务中 `/r/` 短链、RedirectEvent 与 last_seen_at 更新的工作流 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服务装配入口暴露单例LinkRedirectsServiceWrapperlink-redirects-service.js核心服务短链生成、/r/路由处理、占位符替换link-redirect-repository.js仓储层redirects表的增查与缓存link-redirect.ts领域对象LinkRedirectredirect-event.js领域事件RedirectEvent在 index.js 的init()中wrapper 完成了三个关键接线用models.RedirectBookshelf 模型和urlUtils构造LinkRedirectRepository若配置hostSettings:linkRedirectsPublicCache:enabled为真则通过adapterManager.getAdapter(cache:linkRedirectsPublic)注入公共缓存适配器用urlUtils.getSiteUrl()解析出baseURL构造LinkRedirectsService保证生成的短链永远指向当前站点根地址。第一站点击/r/{hash}查库并返回 302请求处理入口服务对每次请求的处理都在LinkRedirectsService.handleRequestlink-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 记录的首条 SQLselect 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标记查到的记录会先经fromModellink-redirect-repository.js转换为领域对象。领域类定义在 link-redirect.ts字段如下字段类型含义link_idObjectIDbson-objectid主键未传id时自动生成fromURL内部短链地址路径名toURL原始目标地址editedboolean该链接是否被发布者手动编辑过automationActionRevisionIdstring \| undefined若属于自动化邮件记录所属的 action revision其中edited的判断很微妙updated_at与created_at相差超过 1000ms 才算编辑过源码注释提示存在个别边界场景两者几乎同时写入需要这段毫秒级缓冲。第二站RedirectEvent的订阅者——点击事件入库事件订阅点击事件统计由LinkClickTrackingServicelink-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 构造并派发MemberLinkClickEventconst 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的订阅方是LastSeenAtUpdaterlast-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进程内缓存同一会员一天只写一次库事务之外还有一层进程内加速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 doesnt get emitted inside the transaction, so we do it manually——标准事件本应在事务内由模型自动发出但这里为控制事务范围改为手动补发投递发生在COMMIT之后从而避免把 Webhook 发送可能很慢的对外 HTTP 请求拖进数据库事务中。与自动化邮件集成的两条支线README 只叙述了 newsletter 主链路但当前源码中同一服务还承担了两个重要扩展场景一并交代以加深理解。短链的生成与唯一性保障LinkRedirectsService.getSlugUrllink-redirects-service.js使用crypto.randomBytes(4).toString(hex)生成 8 位十六进制短码并拼成r/{slug}循环用getByURL探测直到拿到未被占用的唯一短码。relativeRedirectPrefix()则对外暴露/r/前缀供 Express 路由在已剥离子目录后做精确匹配。自动化的共享重定向与step参数发送自动化automation邮件时同一目标地址在同一 action revision 内会复用同一个共享短链而不是每封邮件新建一条。这是通过getOrAddAutomationRedirectlink-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仅运行 ESLintpnpm 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.jsAPI 层的全量重定向失效触发点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),仅供参考