Ghost Portal 核心概念与术语指南:从按钮、链接到优惠与礼品订阅的精确语义
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 组件的映射,其中既有 offer、gift、giftRedemption、giftSuccess 等业务页面,也有 signup、signin、accountHome、accountPlan 等基础页面。术语表实质上是这张页面地图之上的"命名契约"。
入口的两个基本形态:环境内控件 vs Portal 链接
理解全部术语前,必须先区分 Portal 世界里的两类入口:
| 形态 | 说明 | 典型例子 |
|---|---|---|
| 环境内控件(ambient controls) | 常驻于站点页面上的交互元素,是否渲染、是否可见由站点配置决定 | Portal 按钮、Checkout 按钮 |
| Portal 链接(Portal links) | 带特定 hash/路径的 URL,如 #/portal/...,是否打开以及打开哪个页面由路径内容决定,与按钮是否隐藏无关 |
Offer 链接、礼品兑换链接、#/portal/signup |
这两类入口互相独立。文档中反复出现的"不控制""独立于"等措辞,本质都是在强调:页面是否可访问由 Portal 链接路由决定,而按钮是否展示只决定"环境内入口"是否存在。这一设计在 app.jsx 的 transformPortalLinksToRelative() 中也有体现——即便 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.jsx 的 handleOfferQuery():
#/portal/offers/:offerId这类路径先经 app.jsx 的customOfferRegex与 app.jsx 的handleSignupQuery()匹配;- 通过
isEligibleMember = !isPaidMember({ member }) || isComplimentaryMember({ member })判断会员资格——这与文档中"排除付费会员但保留 complimentary members"的措辞完全一致; - 不合资格时设置
offer:failed通知并关闭弹窗,消息为"You already have an active subscription."; - 合资格时调用
GhostApi.site.offer({ offerId })拉取优惠数据,随后做两层守卫:isRetentionOffer()(留存优惠不可经链接访问)与isActiveOffer()。
优惠状态守卫的完整逻辑见 helpers.js:isRetentionOffer() 判定 offer.redemption_type === 'retention';isActiveOffer() 要求 offer.status === 'active',且优惠绑定的层级必须未被归档(getProductFromId() 能查到对应产品),无 tier 的优惠仅对留存有效。这些正是"过期、归档、留存优惠链接被忽略"的服务端之外的前端护栏。
Checkout button(结账按钮)
定义:直接开始某会员方案结账的站内控件,让访客跳过优惠页面、直接进入结账。
与 Offer 页的区别:Checkout 按钮不带用户到优惠浏览页,而是直接发起对某会员方案(plan/tier)的购买流程。
源码对应:data-attributes.js 的 planClickHandler() 绑定了 [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.js 的 formSubmitHandler() 中,它向 /members/api/send-magic-link/ 提交 emailType: 'signin' 的请求,并会携带一次性校验令牌 /members/api/integrity-token/ 获取的 integrityToken;请求级别限流与"同邮箱重复尝试"的具体阈值则由服务端 members API 侧控制,前端以 HTTP 状态与错误文案承接。
Gift checkout(礼品结账)
定义:购买者为他人购买礼品订阅的 Portal 旅程——先选择时长与会员层级(tier),然后发起礼品订阅的一次性购买。
术语红线:避免使用"Gift purchase(礼品购买)""Gift page(礼品页)"。原因在于这些词容易与礼品赎回页面、礼品推广入口混淆,"页"字还会误导开发者以为存在一个静态内容页。
源码对应:
- 页面入口在 pages.js:
gift: GiftPage、giftRedemption: GiftRedemptionPage、giftSuccess: 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-success与gift_token、gift_tier、gift_cadence、gift_duration、gift_delivery、gift_delivery_date等参数,Portal 据此渲染giftSuccess页并清理 URL 参数。
Portal gift promotion(Portal 礼品推广)
定义:由发布者控制的、对 Ghost 自有礼品结账入口(entry points)的呈现方式。
关键语义(双重独立):
- 注册旅程与账户旅程的推广互相独立——可以只开一个;
- 两者都不控制礼品结账本身是否可通过直接 Portal 链接使用——即便所有推广都关闭,
#/portal/gift直达链接依然按路由规则工作(前提是站点满足付费会员等前置条件)。
术语红线:避免使用"Gift subscriptions enabled(已启用礼品订阅)""Gifting enabled(已启用赠送)""Global gift promotion(全局礼品推广)"。原因:这些说法暗示存在一个"总开关",而实际控制粒度是分入口的。
源码对应:推广开关映射为站点配置字段,从 app.jsx 的预览参数解析可见两个独立布尔值:portal_signup_gift_promotion 与 portal_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.js 的 getActivePage()——传入未知页面时回退到 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_promotion、portal_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 支付与会员系统的挂件而言,"精确命名"不只是文档洁癖。实践中,三种典型误解都可以靠这套术语消除:
- "Portal 按钮被隐藏,为什么深链还能打开?"——按钮(ambient control)与链接(Portal link)本就独立,
#/portal/...路由由 app.jsx 的linkRegex独立处理,与按钮显隐无关; - "关闭礼品推广后为什么
#/portal/gift还能用?"——推广开关只控制两个环境内入口(注册/账户),不控制深链可达性,这正是"Portal gift promotion"术语要澄清的边界; - "为什么某些访客点了优惠链接毫无反应?"——Offer 链接的静默忽略策略(付费会员、过期/归档/留存优惠)是刻意的资格守卫,前端以"不打开、不报错"的方式实现,见 app.jsx 的资格判定与通知分支。
开发者在编写 Portal 相关代码、issue 与测试时,若统一采用 CONTEXT.md 的词汇表,并对照 pages.js、app.jsx 的路由解析与 gift-subscriptions.ts 的推广判定函数来验证行为,就能在不同团队之间消除歧义,也让"入口、旅程、资格"三者之间的关系始终清晰可见。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00