首页
/ Ghost 上下文地图(Context Map):Portal、Gift Subscriptions 与 Gift Links 的领域边界与术语契约

Ghost 上下文地图(Context Map):Portal、Gift Subscriptions 与 Gift Links 的领域边界与术语契约

2026-09-06 18:12:56作者:蔡丛锟

CONTEXT-MAP.md 是 Ghost 仓库中用领域驱动设计(DDD)的“上下文映射”方法组织业务边界的导航文档:它声明了三个业务上下文(Portal、Gift Subscriptions、Gift Links)各自的语言文档位置与职责范围,并用两条关系描述了上下文之间的协作方式。阅读本文后,你将掌握 Ghost 会员/赠礼体系的领域划分、每个上下文的关键术语(含必须避免的歧义用语),以及这些术语在 gifts 服务源码 中的落点,便于在阅读代码或设计新功能时保持与官方术语一致。

1. 上下文地图的作用与结构

CONTEXT-MAP.md 由两部分构成:Contexts(上下文清单)与 Relationships(上下文关系)。每个上下文条目给出两样东西:

  • Path:该上下文详细语言文档(CONTEXT.md)的相对路径;
  • 一句话职责描述:该上下文负责的业务范围。

仓库中这三个上下文分别是:

上下文 语言文档位置 职责范围
Portal apps/portal/CONTEXT.md 嵌入在 Ghost 站点中的访客端会员组件(modal),负责会员注册、登录、付费订阅结账、优惠(offers)、账户管理,以及访客端的礼品订阅旅程
Gift Subscriptions ghost/core/core/server/services/gifts/CONTEXT.md 预付、固定期限的会员访问权,覆盖礼品购买、礼品兑换、礼品过期、礼品消耗,以及向付费订阅的延续(continuation)
Gift Links ghost/core/core/server/services/gift-links/CONTEXT.md 在不创建会员的前提下,对单篇受保护文章/页面进行可分享的访问授权

从源码结构看,上下文与目录一一对应:Portal 是独立前端应用(apps/portal/),Gift Subscriptions 是核心服务端服务模块(ghost/core/core/server/services/gifts/),Gift Links 是另一独立服务端模块(ghost/core/core/server/services/gift-links/)。这种“一个上下文、一个目录、一份 CONTEXT.md”的组织方式,使术语文档始终贴近实现。

上下文文档普遍采用 Language(语言)章节 + 逐条术语定义的形式,并且每条术语都附有 Avoid(应避免使用的同义词)清单。这一机制是上下文地图的核心价值:它把“同一个词在不同上下文里含义不同”的问题显式化,防止团队(以及 AI 助手)在跨模块协作时用错术语。

2. Portal 上下文:访客端入口术语

apps/portal/CONTEXT.md 定义了 9 个术语。Portal 的难点在于:站点上有多种控件都能“打开 Portal”,但它们打开的页面与生效条件各不相同。以下按原文档完整列出,并结合关系小节说明。

2.1 控件与链接类术语

术语 定义(据原文档)
Portal button(Portal 按钮) 站点上的环境式(ambient)控件,允许访客在跟随任何特定 Portal 链接的情况下打开 Portal。它不决定某个特定 Portal 链接是否打开 Portal。
Offer link(优惠链接) 用于查看并接受会员优惠的 Portal 链接。优惠链接总是打开优惠页——即使 Portal 按钮被隐藏——且仅对有资格接受优惠的访客生效。Portal 的优惠触发器就是优惠链接。对无资格的访客——已有付费会员(不含免费/complimentary 会员)、或过期/已归档/挽留(retention)优惠——链接被静默忽略,Portal 不打开。
Checkout button(结账按钮) 直接为某个会员方案发起结账的站点控件,把访客直接送入结账流程,而不是打开优惠页。
Checkout attempt(结账尝试) 为某会员方案发起结账的一次尝试。结账尝试受到跨结账流量的请求限制保护,并对针对同一邮箱地址的重复尝试施加限制。若因已存在活跃付费订阅而被拒绝:已登出访客会被转入“登录邮箱流程”,而已登录会员则被告知已拥有活跃订阅——不会发送登录邮件。

从源码结构看,Gift Subscriptions 服务的 startCheckout 接口(见 gifts README)负责时长目录(duration catalogue)、Portal 方案门槛、层级/计费周期校验与权威价格——这正是上下文文档中“结账尝试”这一语言概念在后端的落点。

2.2 礼品订阅相关术语

Portal 文档中一半术语与礼品订阅旅程相关,它们刻意与“全局开关”解耦:

术语 定义(据原文档) 避免使用的词
Gift checkout(礼品结账) Portal 中买家选择时长与会员层级、发起礼品订阅一次性购买的旅程。 Gift purchase、gift page
Portal gift promotion(Portal 礼品推广) 发布者可控制的、进入礼品结账的 Ghost 自有入口的呈现方式。注册页推广与账户页推广相互独立控制;两者都不决定通过直接 Portal 链接访问礼品结账本身是否可用。 “Gift subscriptions enabled”“gifting enabled”“global gift promotion”
Signup gift entry point(注册礼品入口) 注册旅程中的 Portal 推广,面向想为他人购买会员的访客。其可见性独立于账户礼品入口。 Gift signup、account gift entry point
Account gift entry point(账户礼品入口) 账户旅程中面向付费会员与 complimentary 会员的推广,供其为他人购买礼品。展示给免费会员或正在接受赠礼访问的会员。 Gift continuation、gift-recipient card
Gift redemption page(礼品兑换页) 由兑换链接打开的 Portal 界面,访客或已登录会员可在此查看并领取(claim)礼品订阅。 Gift Link page

这里的关键设计意图是:三个开关维度——注册入口可见性、账户入口可见性、直接链接结账可用性——彼此正交。Avoid 清单明确禁止把“入口推广可见”与“礼品订阅功能是否启用”混为一谈。

3. Gift Subscriptions 上下文:赠礼订阅的完整语言

gifts/CONTEXT.md 是三个上下文中语言最庞大的一份,按“角色 / 购买与领取 / 时间与生命周期”三组共定义 21 个术语。以下完整继承原文档骨架。

3.1 角色(Roles)

术语 定义(据原文档) 避免使用的词
Buyer(买家) 为礼品订阅付款的人。买家无需是会员,若自身有资格也可成为兑换者。 Purchaser、recipient、redeemer
Redeemer(兑换者) 领取礼品订阅并获得其访问权的会员。兑换者由兑换时而非购买时决定。 Recipient、buyer
Recipient(受赠人) 礼品订阅所针对的人。其邮箱只用于投递路由,预留兑换权;无需是会员,在领取之前不会成为兑换者。 Receiver、redeemer

这三者的分离解释了“兑换者由兑换时决定”的语义:受赠人邮箱只是投递通道,任何人持链接均可领取。

3.2 购买与领取(Purchase and claim)

术语 定义(据原文档) 避免使用的词
Gift subscription(礼品订阅) 预付、不可续期的会员权益,为某一会员层级提供固定期限的访问权。从领取时开始,而非从购买时开始。 Gift membership、recurring subscription
Gift purchase(礼品购买) 创建一份可分享礼品订阅的一次性购买。购买完成意味着礼品邮件已发出。 Gift checkout
Giftable offering(可赠售组合) 当前可同时购买的至少一个付费会员层级与赠礼时长的组合。其缺失意味着礼品结账无法提供可购买的礼品。 “Gifting enabled”、“paid memberships enabled”
Delivery method(投递方式) 买家选择移交礼品订阅的方式:由站点邮件发给受赠人,或买家私下达成交换链接。 Delivery mode
Redemption link(兑换链接) 单次使用、限时有效的链接,可借此查看并领取已购买的礼品订阅。持链接者可看到买家姓名、指定受赠人姓名、受赠人邮箱与个人留言;指定受赠人不预留兑换权。 Gift Link
Gift redemption(礼品兑换) 领取一份有资格的礼品订阅的行为。领取者成为兑换者,赠礼访问自该时刻开始。 Gift activation
Gift delivery(礼品投递) 通过邮件向受赠人传达兑换链接。投递本身不领取礼品,也不开启赠礼访问。 Gift redemption、gift activation
Scheduled delivery(定时投递) 受赠人邮件计划在未来某日投递的礼品投递。排定投递时间不会推迟兑换的可领取性。 Scheduled gift、scheduled redemption
Delivery date(投递日期) 为定时投递选择的站点本地日历日期。在站点时区的 09:00 使受赠人邮件到期;选择当天则立即投递。它不影响兑换可领取性。 Delivery time、deliver-at date
Personal message(个人留言) 买家的留言,构成礼品的一部分,可在礼品被展示的各处(含邮件与兑换体验)呈现。 Delivery message、email message
Gift sent(礼品已发送) 受赠人邮件已被站点配置的发信通道(mail transport)接受。即使没有后续服务商回执,也视为投递完成。 “Gift delivered”、provider delivery
Email delivery status(邮件投递状态) 把受赠人邮件交给发信通道的当前进度:pending、sending、sent、failed 或 cancelled。与后续服务商汇报的结果相互独立。 Delivery outcome
Delivery outcome(投递结果) 礼品邮件最近一次服务商汇报的结果,如 delivered、temporarily failed、permanently failed。若发信通道不提供投递遥测,结果保持未知。 “Gift sent”、delivery state

“邮件投递状态”与“投递结果”的切分对应两种可靠性事实:前者是 Ghost 自己控制的“至少交付给通道一次”,后者是服务商(如 Mailgun)异步回传的事件。gifts README 印证了这一点:GiftDeliveryService.recordOutcome(...) 仅保留最新的 Mailgun 投递结果,而发信通道的接受(acceptance)才是权威的“已发送”事实;新记录到的服务商永久失败会以尽力(best-effort)方式向买家发送包含礼品链接的事务性通知,供其手动分享。

3.3 时间与生命周期(Time and lifecycle)

这是整个上下文最精密的部分,四个时间概念严格分离:

术语 定义(据原文档)
Redemption availability(兑换可领取性) 礼品订阅可被领取的时刻。自购买时即开始,所有礼品皆如此——包括受赠人邮件被排期到未来的礼品。
Claim window(领取窗口) 从礼品购买到礼品过期、未兑换礼品可被领取的期间。当投递被排期到未来日期时,领取窗口可能超过 365 天
Expiry anchor(过期锚点) 计算礼品过期所用的站点本地日历日期。定时投递取所选投递日期,其余情况取礼品购买日期。
Gift duration(礼品时长) 赠礼访问的总长度,自兑换起算
Gift cadence(礼品计费周期) 用于给礼品订阅定价的月付或年付基础。它使礼品变为周期性(recurring)订阅。
Gift expiration(礼品过期) 未兑换礼品领取窗口的终点:过期锚点之后 365 个站点日历日;后续邮件处理不会移动它。
Gift consumption(礼品消耗) 已兑换礼品提供访问这一角色的终点:时长走完,或付费订阅接管时。
Consumption reminder(消耗提醒) 发送给兑换者的邮件,每份礼品至多一次、在礼品消耗前最多 7 天发出,以便在赠礼访问结束前安排礼品延续。
Gift continuation(礼品延续) 从赠礼访问启动付费订阅,并在开始计费前把剩余赠礼时长结转(carry forward)

这组术语与源码常量一一对应,constants.ts 给出了精确数值:

export const GIFT_EXPIRY_DAYS = 365;          // 过期锚点后的领取窗口长度
export const GIFT_MAX_SCHEDULE_DAYS = 365;   // 投递排期的最远提前量
export const GIFT_SEND_HOUR = 9;              // 定时投递在站点本地 09:00 发送
export const GIFT_REMINDER_LEAD_DAYS = 7;     // 消耗提醒最早提前量
export const GIFT_REMINDER_FLOOR_DAYS = 3;    // 消耗提醒最晚提前量
export const GIFT_DELIVERY_STALE_AFTER_MS = 60 * 60 * 1000; // 投递“进行中”超过 1 小时视为陈旧
export const GIFT_DELIVERY_EMAIL_TAG = 'gift-delivery';

值得注意的两处实现细节:

  • GIFT_MAX_SCHEDULE_DAYS 的注释明确说明 Portal 端在 apps/portal 的 gift-page.tsx 中持有一份独立副本(GIFT_MAX_SCHEDULE_DAYS),因为 Portal 是独立分发的——两端修改必须同步。这正体现了上下文地图“上下文独立、契约需人工同步”的特征。
  • GIFT_SEND_HOUR 的注释说明 Portal 端从不推导发送时刻,精确发送时刻通过成功 URL 中的 gift_scheduled_at 参数传递——与术语表中“Delivery date 在站点时区 09:00 使邮件到期”的定义严格一致。

4. Gift Links 上下文:与兑换链接的关键区分

gift-links/CONTEXT.md 只有单一术语,但它的存在恰恰是上下文地图最有价值的部分之一:

Gift Link:一个可撤销(revocable)的链接,持有者可借此访问单篇受保护的帖子或页面。它不创建或兑换礼品订阅。 Avoid: Redemption link、gift-subscription link

Gift Links 是一个独立的小型服务端模块,源码见 gift-links 目录(含 service.tsactions.tsmodels.ts 等)。它与 Gift Subscriptions 的关系是名称极易混淆但机制完全不同:一个 Gift Link 授予对单篇内容的一次性可撤销访问、不产生会员身份;而一个 Redemption link 指向整段固定期限的会员访问权。上下文文档通过在两个上下文中互相写入 Avoid 条款(Gift Subscriptions 的“Redemption link”避免使用“Gift Link”,Gift Links 的“Gift Link”避免使用“Redemption link”)来消除这一歧义。

5. 上下文关系(Relationships)

CONTEXT-MAP.md 在 Relationships 一节给出两条关系:

  • Portal ↔ Gift Subscriptions:Portal 呈现礼品订阅的购买与兑换旅程。
  • Gift Subscriptions ↔ Gift Links:礼品订阅的兑换链接声明(claim)固定期限的会员访问权;Gift Link 则授予单篇受保护帖子或页面的访问权。

第一条关系在代码中的体现是清晰的分层:Portal 作为前端旅程发起请求,Gift Subscriptions 服务的 capability-oriented 接口 承接能力调用——README 明确说明“调用方拿不到 Gift 实体、Bookshelf 模型、事务或 Stripe 对象”,只获得稳定 DTO 与读模型。关键接口包括:

  • startCheckout(input):拥有时长目录、Portal 方案门槛、层级/周期校验、权威价格、token、成功参数、元数据、客户与一次性支付;
  • getRedeemable(input) / redeem(input):返回公共兑换 DTO(对应 Portal 的 Gift redemption page);
  • preparePaidContinuation(input):校验活跃礼品并返回稳定 tier/cadence/trial 决策,供正常订阅结账路径使用(对应 Gift continuation 术语);
  • handlePaidSubscriptionActivation(memberId):付费订阅接管时消耗礼品时长;
  • processReminders() / processConsumed() / processExpired():拥有礼品生命周期处理;
  • reassignRedeemer(...):数据导入能力。

第二条关系则如第 4 节所述,本质是术语防混淆契约而非调用关系——两个模块互不调用,只是产品上都需要被明确区分。

6. 生命周期在源码中的落点:调度、投递与恢复

术语表中的生命周期概念(scheduled delivery、consumption reminder、delivery at-least-once)在 gifts/index.tsinit() 中有完整装配,可作为理解上下文语言的实现证据:

  1. 投递调度GiftDeliveryService 配合 SignedFlushScheduler(端点 gifts/flush_deliveries,调度名 gift_delivery)按 GiftDelivery 的 scheduledAt 排期;立即投递在购买后经由进程内事件 SendGiftDeliveryEvent 触发,定时投递在 scheduledAt 到达时进入同一条处理路径——印证术语表“Scheduled delivery 不推迟兑换可领取性”的定义。
  2. 消耗提醒调度:另一个 SignedFlushScheduler(端点 gifts/flush_reminders)以 gift.reminderDueAt() 为排期依据,由 StartGiftReminderFlushEvent 触发 processReminders();周期性任务通过 jobs(含 send-gift-reminders-job.jsclean-gifts-job.ts)注册。
  3. 付费订阅接管DomainEvents 订阅 SubscriptionActivatedEvent,触发 giftService.handlePaidSubscriptionActivation(...)——即 Gift consumption 中“付费订阅接管”这一分支的实现。
  4. 启动恢复recoverPendingDeliveries() 在所有服务初始化完成后运行,重新排期未来投递并恢复上次关闭时中断的发送;README 说明投递领取是原子的、过期的进行中领取在崩溃后会被重试,因此“发信通道接受”是**至少一次(at least once)**语义——对应术语表中 Email delivery status 的 pending/sending 状态存在原因。
  5. 邮件通道选择init()GiftEmailService 同时装配事务性邮件器(GhostMailer,用于买家确认与失败通知)与批量 Mailgun 客户端(MailgunClient,用于受赠人投递),使投递结果可观测;无 Mailgun 时回退到 provider 无关的事务邮件通道。

7. 如何使用这份上下文地图

  1. 跨模块沟通时先查术语:涉及 Portal 入口、优惠链接或礼品功能时,先到 CONTEXT-MAP.md 定位所属上下文,再到对应 CONTEXT.md 使用规范用语,并避开 Avoid 清单中的词(例如不要说“gifting enabled”来表达入口推广)。
  2. 区分两个“link”:Gift Link(gift-links)≠ Redemption link(gifts);前者是内容访问授权,后者是会员权益兑换。
  3. 时间问题按四个概念拆解:谈赠礼时间线时先分清 redemption availability(购买即可领)、claim window(至多锚点+365 天)、gift duration(自兑换起算)、gift expiration(过期锚点+365 站点日历日),数值以 constants.ts 为准。
  4. 以接口边界定位实现:Gift Subscriptions 的能力入口在 gifts/README.md 列出的接口集合,装配与事件订阅在 gifts/index.ts;Portal 侧旅程代码位于 apps/portal/src
登录后查看全文
热门项目推荐
相关项目推荐