首页
/ Ghost Portal 核心概念与术语指南:从按钮、链接到优惠与礼品订阅的精确语义

Ghost Portal 核心概念与术语指南:从按钮、链接到优惠与礼品订阅的精确语义

2026-09-07 15:37:20作者:温玫谨Lighthearted

Portal(apps/portal)是嵌入 Ghost 站点、面向访客的会员界面挂件,承载会员注册、登录、结账、优惠(Offer)兑换、账户管理以及面向访客的礼品订阅旅程。其技术实现(详见 package.json)以 @tryghost/portal 发布,采用 React 构建并按需加载。由于同一功能往往存在多种叫法,Ghost 团队在 CONTEXT.md 中为 Portal 定义了一套权威产品术语,用于统一代码、测试、设计与文档中的沟通口径。本文以这份术语规范为主线,逐条展开其精确语义,并结合仓库源码揭示每个概念背后的真实实现与调用链,帮助开发者、测试工程师与内容运营在同一套语言体系下准确描述与排查 Portal 行为。

为什么 Portal 需要一套"术语宪法"

Portal 的交互面横跨多种入口形态与状态组合:同一个访客可能经由站内按钮、深链(Deep Link)、邮件魔法链接或 Stripe 回跳进入同一页面;同一个会员可能同时拥有免费会员身份与付费订阅,或者作为礼品接收方处于"被赠予访问权"的状态。如果"赠送礼品""礼品页""开启礼品"这类模糊说法被混用,很容易掩盖真实的产品边界——例如"Portal 按钮被隐藏是否影响特定链接打开 Portal?""隐藏注册页礼品入口是否等于禁用了礼品结账?"。这些问题的答案都不是直观的,只有通过精确的术语定义才能澄清。

从源码结构看,这套术语体系与 Portal 的页面注册表一一对应:pages.js 中维护了全部可用页面及其 UI 组件的映射,其中既有 offergiftgiftRedemptiongiftSuccess 等业务页面,也有 signupsigninaccountHomeaccountPlan 等基础页面。术语表实质上是这张页面地图之上的"命名契约"。

入口的两个基本形态:环境内控件 vs Portal 链接

理解全部术语前,必须先区分 Portal 世界里的两类入口:

形态 说明 典型例子
环境内控件(ambient controls) 常驻于站点页面上的交互元素,是否渲染、是否可见由站点配置决定 Portal 按钮、Checkout 按钮
Portal 链接(Portal links) 带特定 hash/路径的 URL,如 #/portal/...,是否打开以及打开哪个页面由路径内容决定,与按钮是否隐藏无关 Offer 链接、礼品兑换链接、#/portal/signup

这两类入口互相独立。文档中反复出现的"不控制""独立于"等措辞,本质都是在强调:页面是否可访问由 Portal 链接路由决定,而按钮是否展示只决定"环境内入口"是否存在。这一设计在 app.jsxtransformPortalLinksToRelative() 中也有体现——即便 Portal 按钮被隐藏,页面中的 a[href*="#/portal"]a[href*="#/share"] 链接依然会被规范化处理并保持可点击。

Portal 术语逐条解析

Portal button(Portal 按钮)

定义:站内常驻的"环境控制",让访客在不点击特定 Portal 链接的情况下也能打开 Portal。

关键语义:Portal 按钮不控制某个特定 Portal 链接是否会打开 Portal——链接是否生效完全取决于其路径内容。按钮的存在与否、显隐状态都不影响深链直达。

源码对应:按钮的交互入口由 trigger-button.jsx 实现。此外,app.jsx 中的 setupCustomTriggerButton() 会扫描页面上的 [data-portal] 元素并统一绑定点击事件:点击后读取 data-portal 属性作为页面路径,交给 getPageFromLinkPath() 解析成 { page, pageQuery, pageData },最终通过 dispatchAction('openPopup', ...) 打开弹窗。因此"任何带 data-portal 属性的元素都是一个 Portal 触发点"正是该术语的 DOM 级体现。

Offer link(优惠链接)

定义:用于查看并接受某项会员优惠的 Portal 链接。Offer 链接总是打开优惠页面——即使 Portal 按钮被隐藏——并且仅对符合优惠资格的访客生效。

资格边界(不符合者被静默忽略)

  • 已有付费订阅的现有付费会员(不含免费会员与免费赠阅/优惠会员,即 complimentary members);
  • 已过期(expired)的优惠;
  • 已归档(archived)的优惠;
  • 留存型(retention)优惠——留存优惠只在取消订阅流程中出现,不可通过链接直达。

对以上任一情况,链接会被静默忽略:Portal 既不打开,也不报错。另外,从源码看 data-attributes.js[data-members-cancel-subscription] 的逻辑是留存优惠的唯一正常触发入口:当存在 redemption_type === 'retention' 的优惠时,点击"取消订阅"不再直接调用 API,而是打开 Portal 的 accountPlan 页面并携带 action: 'cancel'

源码对应:offer 路由的解析与资格判定集中在 app.jsxhandleOfferQuery()

  • #/portal/offers/:offerId 这类路径先经 app.jsxcustomOfferRegexapp.jsxhandleSignupQuery() 匹配;
  • 通过 isEligibleMember = !isPaidMember({ member }) || isComplimentaryMember({ member }) 判断会员资格——这与文档中"排除付费会员但保留 complimentary members"的措辞完全一致;
  • 不合资格时设置 offer:failed 通知并关闭弹窗,消息为"You already have an active subscription.";
  • 合资格时调用 GhostApi.site.offer({ offerId }) 拉取优惠数据,随后做两层守卫:isRetentionOffer()(留存优惠不可经链接访问)与 isActiveOffer()

优惠状态守卫的完整逻辑见 helpers.jsisRetentionOffer() 判定 offer.redemption_type === 'retention'isActiveOffer() 要求 offer.status === 'active',且优惠绑定的层级必须未被归档(getProductFromId() 能查到对应产品),无 tier 的优惠仅对留存有效。这些正是"过期、归档、留存优惠链接被忽略"的服务端之外的前端护栏。

Checkout button(结账按钮)

定义:直接开始某会员方案结账的站内控件,让访客跳过优惠页面、直接进入结账

与 Offer 页的区别:Checkout 按钮不带用户到优惠浏览页,而是直接发起对某会员方案(plan/tier)的购买流程。

源码对应data-attributes.jsplanClickHandler() 绑定了 [data-members-plan] 元素(绑定见 data-attributes.js)。点击后读取 data-members-plan 中的方案名,通过 getCheckoutSessionDataFromPlanAttribute() 组装价格数据,再依次调用 /members/api/session 获取身份、向 /members/api/create-stripe-checkout-session/ 发起 POST 创建 Stripe Checkout Session,最终通过 window.location.assign(url)stripe.redirectToCheckout() 进入 Stripe 收银台。值得注意的细节是:对于已登录会员,请求会附带 checkoutType: 'upgrade' 的元数据,用于区分升级与全新订阅。

Checkout attempt(结账尝试)

定义:一次发起会员方案结账的尝试。

保护机制:结账尝试受请求限制保护,限制维度包括:

  • 面向结账流量总量的限流;
  • 同一邮箱地址重复尝试的限流。

拒绝后的分流行为:当一次结账尝试因为"已存在有效的付费订阅"而被拒绝时:

  • 未登录访客会被继续带入登录邮箱流程(sign-in email flow)——系统仍向其发送登录邮件,让该邮箱的既有订阅者先登录;
  • 已登录会员则被直接告知其已拥有活跃订阅——不再发送任何登录邮件

这一"先登录再处理"的设计避免了对既有订阅者的重复扣费尝试,同时保证了未登录状态下无法获知某邮箱是否已有订阅的隐私边界。从 Portal 前端看,signin 表单对应的邮件发送逻辑集中在 data-attributes.jsformSubmitHandler() 中,它向 /members/api/send-magic-link/ 提交 emailType: 'signin' 的请求,并会携带一次性校验令牌 /members/api/integrity-token/ 获取的 integrityToken;请求级别限流与"同邮箱重复尝试"的具体阈值则由服务端 members API 侧控制,前端以 HTTP 状态与错误文案承接。

Gift checkout(礼品结账)

定义:购买者为他人购买礼品订阅的 Portal 旅程——先选择时长会员层级(tier),然后发起礼品订阅的一次性购买

术语红线:避免使用"Gift purchase(礼品购买)""Gift page(礼品页)"。原因在于这些词容易与礼品赎回页面、礼品推广入口混淆,"页"字还会误导开发者以为存在一个静态内容页。

源码对应

  • 页面入口在 pages.jsgift: GiftPagegiftRedemption: GiftRedemptionPagegiftSuccess: GiftSuccessPage
  • 时长目录由服务端定义并镜像在前端 gift-subscriptions.ts 中:GIFT_DURATION_CATALOGUE = [1, 3, 6, 12](1/3/6/12 个月),并与 gift-checkout-offer.js 保持同步;
  • 时长与 tier 的价格计算在 gift-subscriptions.ts:12 个月对应年度价,其余时长按月度价 × 月数折算,并要求价格币种、金额合法(hasValidPrice());
  • 可选时长并非固定 4 种,而是动态过滤:getAvailableGiftDurations() 只返回当前站点配置下"存在可购买礼品产品"的时长(gift-subscriptions.ts)——若站点未启用付费会员或未开放年度方案,12 个月选项便不会出现;
  • 结账成功后的回跳由 app.jsx 处理:URL 携带 stripe=gift-purchase-successgift_tokengift_tiergift_cadencegift_durationgift_deliverygift_delivery_date 等参数,Portal 据此渲染 giftSuccess 页并清理 URL 参数。

Portal gift promotion(Portal 礼品推广)

定义:由发布者控制的、对 Ghost 自有礼品结账入口(entry points)的呈现方式。

关键语义(双重独立)

  1. 注册旅程与账户旅程的推广互相独立——可以只开一个;
  2. 两者都不控制礼品结账本身是否可通过直接 Portal 链接使用——即便所有推广都关闭,#/portal/gift 直达链接依然按路由规则工作(前提是站点满足付费会员等前置条件)。

术语红线:避免使用"Gift subscriptions enabled(已启用礼品订阅)""Gifting enabled(已启用赠送)""Global gift promotion(全局礼品推广)"。原因:这些说法暗示存在一个"总开关",而实际控制粒度是分入口的。

源码对应:推广开关映射为站点配置字段,从 app.jsx 的预览参数解析可见两个独立布尔值:portal_signup_gift_promotionportal_account_gift_promotion。前端判定逻辑集中在 gift-subscriptions.ts

export function canShowSignupGiftPromotion({ site }: { site: Site | null }): boolean {
  return site?.portal_signup_gift_promotion === true && canShowGiftPromotion({ site });
}

export function canShowAccountGiftPromotion({ site }: { site: Site | null }): boolean {
  return site?.portal_account_gift_promotion === true && canShowGiftPromotion({ site });
}

canShowGiftPromotion() 的底层是"当前至少存在一个可购买的礼品时长/产品"(gift-subscriptions.ts)——即推广开关是必要条件但非充分条件,即便开关为真,没有可购礼品时推广也不会展示。这解释了"发布者控制"的真实含义:是发布者在配置,但不是发布者说了算的全部。

Signup gift entry point(注册礼品入口)

定义:Portal 在注册旅程中为"替他人购买会员"的访客提供的推广位。

关键语义

  • 它是 Portal 推广体系里注册侧的那一半;
  • 可见性与账户礼品入口(account gift entry point)互相独立

术语红线:避免使用"Gift signup(礼品注册)""Account gift entry point(账户礼品入口)"来指代它。

源码对应signup-gift-promotion.tsx 在注册页中渲染,且通过 canShowSignupGiftPromotion({ site }) 判定是否展示——与上文 portal_signup_gift_promotion 字段一一对应。也就是说,在注册流程中看到"送会员给别人"的入口,与登录后账户页里是否出现同类入口,是两条互不影响的代码路径与配置项。

Account gift entry point(账户礼品入口)

定义:Portal 在账户旅程中为**付费会员与 complimentary members(免费赠阅会员)**提供的"为他人购买礼品"的推广位。

排除范围(不向其呈现)

  • 免费会员(free members);
  • 正在接收赠阅访问的会员(members receiving gifted access)——即礼品接收方不会再被引导去买礼品。

术语红线:避免使用"Gift continuation(礼品延续)""Gift-recipient card(礼品接收者卡片)"。

源码对应:入口组件为 give-gift-card.tsx,它挂在账户首页(accountHome,路径 #/portal/account)下,并通过 canShowAccountGiftPromotion({ site }) 控制显示;其展示对象限定(仅付费/赠阅会员,对免费会员与礼品接收方隐藏)在 CONTEXT.md 中有权威定义,属于账户旅程的受众过滤规则。

Gift redemption page(礼品兑换页)

定义:由**兑换链接(redemption link)打开的 Portal 页面,访客或已登录会员在此查看并领取(claim)**一份礼品订阅。

术语红线:避免使用"Gift Link page"。因为"Gift Link"容易与送礼人发送的、处于"待领取"状态的链接混淆,而该页面的准确语义是"兑换动作发生的页面"。

源码对应:兑换路由由 app.jsx 的正则 giftRedemptionRegex = /^\/portal\/gift\/redeem\/([^/?#]+)\/?$/ 匹配,token 经 app.jsx 解码后调用 fetchGiftRedemptionData() 请求 GhostApi.gift.fetchRedemptionData({ token })(见 app.jsx)。请求期间通过 startGiftRedemptionRequest()/invalidateGiftRedemptionRequest() 的请求 ID 竞态保护来丢弃过期响应。成功后渲染 gift-redemption-page.jsx;失败则移除 Portal 链接并弹出 giftRedemption:failed 通知(错误文案统一由 gift-redemption-notification 提供,如 GIFT_NOT_FOUND 映射)。

术语禁忌速查表

文档在每个术语下都附有"避免使用"清单。核心意图是用"入口/旅程/页面"的精确结构替代"功能/开关"的笼统表述

应使用(规范) 避免使用(易混淆) 混淆根源
Gift checkout Gift purchase、Gift page 暗示存在"购买页"这一静态概念
Portal gift promotion Gift subscriptions enabled、Gifting enabled、Global gift promotion 暗示存在单一总开关
Signup gift entry point Gift signup、Account gift entry point 混淆注册/账户两侧入口
Account gift entry point Gift continuation、Gift-recipient card 混淆入口与接收方相关概念
Gift redemption page Gift Link page 混淆"兑换页"与"待领取链接"

术语检查同样作用于测试代码。例如 Portal 在 test 目录中按行为而非按页面命名测试;前端路由是否解析为期望页面的可验证判据是 pages.jsgetActivePage()——传入未知页面时回退到 signup,这一回退行为本身也解释了为何"无效的 Portal 链接不应产生可见异常"。

从术语到实现:一张语义映射总表

将全部术语归纳为页面 + 入口 + 守卫三个维度,可得到下面的对照:

术语 Portal 页面/路径 关键入口 主要守卫/前置条件
Portal button 任意页面 data-portal 元素 / TriggerButton
Offer link offer#/portal/offers/:id 深链 非付费或 complimentary、优惠 active、非 retention/已归档
Checkout button Stripe 结账(无 Portal 页) data-members-plan 需有效价格
Checkout attempt signin / 拒绝提示 结账 API 结账限流、同邮箱重复尝试
Gift checkout gift#/portal/gift 深链 + 推广位 付费会员已启用、时长有价
Portal gift promotion signup / account 页内推广位 portal_signup_gift_promotionportal_account_gift_promotion 存在可购买礼品
Signup gift entry point 注册页内 signup-gift-promotion.tsx canShowSignupGiftPromotion
Account gift entry point 账户首页内 give-gift-card.tsx 付费/complimentary 会员、非礼品接收方
Gift redemption page giftRedemption#/portal/gift/redeem/:token 兑换深链 token 有效、请求竞态保护

结论:这套语言体系的价值

对 Portal 这样横跨营销站点、Stripe 支付与会员系统的挂件而言,"精确命名"不只是文档洁癖。实践中,三种典型误解都可以靠这套术语消除:

  1. "Portal 按钮被隐藏,为什么深链还能打开?"——按钮(ambient control)与链接(Portal link)本就独立,#/portal/... 路由由 app.jsxlinkRegex 独立处理,与按钮显隐无关;
  2. "关闭礼品推广后为什么 #/portal/gift 还能用?"——推广开关只控制两个环境内入口(注册/账户),不控制深链可达性,这正是"Portal gift promotion"术语要澄清的边界;
  3. "为什么某些访客点了优惠链接毫无反应?"——Offer 链接的静默忽略策略(付费会员、过期/归档/留存优惠)是刻意的资格守卫,前端以"不打开、不报错"的方式实现,见 app.jsx 的资格判定与通知分支。

开发者在编写 Portal 相关代码、issue 与测试时,若统一采用 CONTEXT.md 的词汇表,并对照 pages.jsapp.jsx 的路由解析与 gift-subscriptions.ts 的推广判定函数来验证行为,就能在不同团队之间消除歧义,也让"入口、旅程、资格"三者之间的关系始终清晰可见。

登录后查看全文
热门项目推荐
相关项目推荐