- 前端
- CMS
【免费下载链接】wp-calypso
The JavaScript and API powered WordPress.com
本指南以 wp-calypso 仓库中 client/dashboard/me/billing-purchases/AGENTS.md 为核心,系统讲解 Dashboard 客户端中规模最大、复杂度最高的计费购买管理区域:其目录结构、数据层约定、Purchase 领域模型、DataViews 列表、取消购买(Cancel Purchase)三流程分流机制,以及 10 条经过线上事故(SHILL 系列)沉淀的工程陷阱。读者读完将掌握在该模块内新增查询、理解取消/退款流程分流、规避 siteless 购买与 Query Key 前缀等关键坑位的完整方法论。
模块定位与目录结构
billing-purchases是基于 TanStack Query / TanStack Router 实现的购买(Purchase)管理模块,位于 client/dashboard/me/billing-purchases。它是 Dashboard 客户端中最大也最复杂的计费区域,与两个相邻计费体系并存:
client/me/purchases/——经典(Classic)计费实现;client/my-sites/checkout/——结账(Checkout)流程。
从目录结构可以看出模块的职责划分(见 AGENTS.md):
billing-purchases/ ├── index.tsx + dataviews.tsx # 购买列表(DataViews 表格) ├── purchase-settings/index.tsx # 重量级组件——见架构决策 #2 ├── cancel-purchase/ # 多步骤取消流程(最复杂区域) │ ├── cancel-purchase-form/ # 调查问卷步骤、产品专属选项 │ └── domain-removal-flow # 域名专属移除步骤 ├── payment-methods/ # use-create-* 工厂 hooks(每种支付方式一个) ├── payment-method-selector/ # 支付方式选择 UI ├── change-payment-method.tsx # 单个购买的支付方式更换 └── add-payment-method.tsx # 独立的"新增卡片"入口其中cancel-purchase/是模块内最复杂的部分:cancel-purchase-form/承载调查问卷步骤与产品专属选项,domain-removal-flow承载域名专属移除步骤。purchase-settings/下的子组件(如 akismet-api-key-card.tsx、jetpack-license-key-card.tsx、upcoming-renewals-dialog.tsx)分别处理各产品类型的专属展示。
兄弟计费区域的隐藏陷阱
client/dashboard/me/下还有两个相邻计费目录,AGENTS.md 明确记录了两处"非显而易见"的坑:
| 目录 | 陷阱 |
|---|---|
billing-payment-methods/ | 删除对话框会查询userPurchasesQuery()以展示受影响的订阅(见 payment-method-delete-dialog.tsx,其中useQuery( userPurchasesQuery() )正是这一依赖的实现) |
billing-tax-details/ | 当can_user_edit === false时为只读——不会报错,只是静默禁用表单 |
数据层:查询封装与 Query Key 约定
购买相关数据由两层封装提供:
- fetcher 层:位于
@automattic/api-core(packages/api-core/src/upgrades),负责真实的 HTTP 请求(fetchUserPurchases、fetchPurchase、cancelAndRefundPurchase、removePurchase、setPurchaseAutoRenew等)。 - 查询层:位于
@automattic/api-queries(packages/api-queries/src/upgrades.ts),基于 TanStack Query 的queryOptions/mutationOptions封装 fetcher。
新增查询的固定流程:先在@automattic/api-core(packages/api-core/src/)中新增 fetcher,再在api-queries中写查询封装。查询不在client/dashboard/data/或client/dashboard/app/queries/中。这是模块的硬性约定。
Query Key 前缀:'upgrades'而非'purchases'
这是 AGENTS.md 重点强调的历史性约定,代码中可直接验证(packages/api-queries/src/upgrades.ts):
userPurchasesQuery()→[ 'upgrades' ]userTransferredPurchasesQuery()→[ 'upgrades', 'transferred' ]sitePurchasesQuery( siteId )→[ 'upgrades', 'site', siteId ]purchaseQuery( purchaseId )→[ 'upgrades', purchaseId ]purchaseCancelFeaturesQuery( ... )→[ 'upgrades', purchaseId, 'cancel-features', variant, targetProductSlug ]
Receipts 使用'receipt'前缀(见 packages/api-queries/src/me-billing-history.ts 的receiptQueryKey),支付方式使用'me'前缀(如[ 'me', 'account-recovery' ]等模式,见 packages/api-queries/src/me-account-recovery.ts)。
关键警告:写错前缀会静默破坏缓存失效(silently breaks cache invalidation)。例如
userPurchasesQuery()的onSuccess会invalidateQueries( userPurchasesQuery() ),如果某处用了错误的 key 前缀,这一条失效链就断了,页面会展示过期数据且无任何报错。
路由器加载器预取
查询通过路由器加载器(router loaders)在 client/dashboard/app/router/me.tsx 中加载。例如:
purchasesIndexRoute预取userPurchasesQuery()、userTransferredPurchasesQuery()、userPaymentMethodsQuery( {} )、allSitesQuery();purchaseSettingsIndexRoute先ensureQueryData( purchaseQuery( parseInt( purchaseId ) ) ),再按条件预取站点与存储数据(见 me.tsx);cancelPurchaseRoute在 loader 中并行加载站点购买、站点功能、产品与计划列表、取消功能清单(见 me.tsx)。
注意parseInt( purchaseId ):URL 参数是字符串,必须转成数字才能传给查询函数(这是陷阱 #9)。
两种 Purchase 类型:绝不混用的领域模型
Dashboard 与经典版使用了完全不同的Purchase类型,AGENTS.md 对此给出硬性警告:永远不要在两者之间拷贝逻辑而不转换字段名和取值。
| 维度 | Dashboard | Classic |
|---|---|---|
| 类型来源 | @automattic/api-core(packages/api-core/src/upgrades/types.ts#L76) | calypso/lib/purchases/types |
| 字段风格 | snake_case,如purchase.site_slug | camelCase |
| 取值风格 | expiry_status为'auto-renewing'、'manual-renew'等 | 不同的字符串值 |
以expiry_status为例,Dashboard 侧完整的取值域定义在 types.ts:
'active':活跃,未来到期,自动续订开启但续订不临近;'auto-renewing':同'active'但续订已临近("即将到期"窗口内);'manual-renew':活跃、未来到期但未开启自动续订、且未到临期——用户必须手动续订;'expiring':活跃、即将到期但未过期、且不会自动续订——"需要关注"状态;'expired':到期日已过,涵盖宽限期(subscription_status === 'active')与已移除两种情形;'included':随父订阅捆绑(如捆绑域名),生命周期由父订阅决定;'one-time-purchase':永不过期的一次性购买。
配套的语义判断函数集中在 client/dashboard/utils/purchase.ts(约 50 个工具函数,isExpiring、isExpiredAndInGracePeriod、isRemoved、mightStillAutoRenew、isIncludedWithPlan、isCloseToExpiration等),它们是本模块状态机的"标准答案"来源。
架构决策一:购买列表使用 DataViews
购买列表由 dataviews.tsx 实现,基于 WordPress@wordpress/dataviews组件体系:
- 定义字段(fields)、过滤器(filters)、操作(actions);
- 通过
usePersistentView()实现响应式列可见性——WIDE_FIELDS、DESKTOP_FIELDS、MOBILE_FIELDS常量按屏幕宽度控制显示哪些列(见 dataviews.tsx#L34-L36); - 默认视图
DEFAULT_VIEW指定表格布局、每页 10 条、按站点降序排序、density: 'balanced'(见 dataviews.tsx#L38-L56)。
转移(transferred)的购买单独拉取(userTransferredPurchasesQuery()),并在列表中禁用管理操作——所有权转移后的购买不允许原用户操作(对应陷阱 #8)。
架构决策二:Purchase Settings 是刻意合并的重量级组件
client/dashboard/me/billing-purchases/purchase-settings/index.tsx 是一个 1800+ 行的单一组件,同时处理:
- 域名查询(
domainQuery、siteDifmWebsiteContentQuery); - 存储查询(
siteMediaStorageQuery相关); - 自动续订状态(
userPurchaseSetAutoRenewQuery); - 续订对话框(
upcoming-renewals-dialog.tsx); - 产品专属 key 展示(Akismet API Key 卡片、Jetpack License Key 卡片)。
AGENTS.md 明确说明:这是有意的合并(intentional consolidation),不是拆分候选。新增任何"购买详情页"级的功能,先找这个文件。CancelOrRemoveActionButton也在其中,它依据自动续订状态而非is_cancelable来决定展示"取消"还是"移除"按钮(见陷阱 #1 与 #4)。
架构决策三:支付方式工厂模式
payment-methods/目录实现工厂模式:每个use-create-*hook 返回一个带 processor 的PaymentMethod对象,供支付方式选择器使用:
use-create-credit-card.tsx——新建信用卡;use-create-existing-cards.tsx——已存卡片;use-create-paypal-express.tsx/use-create-existing-paypal-ppcp.tsx——PayPal 两类;use-create-payment-methods.tsx——汇总入口。
use-create-assignable-payment-methods.tsx 负责将这些对象聚合后按allowedPaymentMethodsQuery()过滤:加载中(allowedPaymentMethods === undefined)时返回空数组(不展示任何支付方式),出错时 fail-open(展示全部)。它的undefined守卫写在最后(第 105-107 行),AGENTS.md 警告不要在它之前加任何条件逻辑(对应陷阱 #2)。
取消购买流程:三种流程类型与 API 映射
取消流程的分流逻辑由 getPurchaseCancellationFlowType() 决定。它不接收 props,而是从Purchase的三个字段派生:is_refundable、hasAmountAvailableToRefund()(即refund_amount > 0)、is_auto_renew_enabled。流程类型定义在 CANCEL_FLOW_TYPE:
| 流程类型 | 触发条件 | 对应 API 调用 |
|---|---|---|
REMOVE | 已过期、宽限期,或(不可退款 且 自动续订已关) | removePurchaseMutation()(DELETE) |
CANCEL_WITH_REFUND | 可退款、退款金额 > 0、自动续订开 | cancelAndRefundPurchaseMutation() |
CANCEL_AUTORENEW | 不可退款、自动续订开 | 关闭自动续订(setPurchaseAutoRenew) |
从源码看,分流顺序(purchase.ts#L840-L862):
export function getPurchaseCancellationFlowType( purchase: Purchase ): CancelFlowType { const isPlanRefundable = purchase.is_refundable; const isPlanAutoRenewing = purchase.is_auto_renew_enabled; if ( isPlanRefundable && hasAmountAvailableToRefund( purchase ) ) { return CANCEL_FLOW_TYPE.CANCEL_WITH_REFUND; // 可退款 → 立即移除并退款 } if ( isExpiredOrRemoved( purchase ) ) { return CANCEL_FLOW_TYPE.REMOVE; // 已过期(且不可退款)→ 移除 } if ( ! isPlanRefundable && isPlanAutoRenewing ) { return CANCEL_FLOW_TYPE.CANCEL_AUTORENEW; // 不可退款且自动续订开 → 关自动续订 } return CANCEL_FLOW_TYPE.REMOVE; // 不可退款且自动续订已关 → 立即移除 }流程细节
- 调查问卷按产品类型变化:Jetpack、域名、套餐、Akismet、Marketplace 各自有不同的步骤(
cancel-purchase-form/step-components/下的upsell-step.tsx、feedback-step.tsx、jetpack-cancellation-offer-step.tsx等)。 - Agency 合作方购买跳过问卷(对应
is_partner_managed/is_host_managed标志)。 - Marketplace 套餐取消会级联到站点上的全部 marketplace 订阅。
Mutation 的执行时机与缓存守卫
use-cancel-mutation-on-confirm.ts 是取消/退款路径的 mutation 封装,它揭示了一个关键机制:
- 快照购买对象(snapshotPurchase):因为两个 mutation 都会 invalidate
userPurchasesQuery,而它的['upgrades']key 是purchaseQuery的['upgrades', id]的前缀,会导致 live purchase 在问卷中途被重新拉取。若不冻结快照,is_auto_renew_enabled翻转成 false 会在问卷下重新推导 flowType,onSurveyComplete就会上报一次从未发生的退款(见 第 46-52 行注释)。 - CANCEL_AUTORENEW 只关自动续订,购买仍留在用户列表中,所以不需要"从列表剥离"的缓存机制;CANCEL_WITH_REFUND 成功后才一次性
setQueryData从列表过滤掉该购买(第 80-83 行)。
onSurveyComplete()是流程的收尾点(cancel-purchase/index.tsx#L1422-L1457):REMOVE提交submitRemovePurchase、CANCEL_AUTORENEW提交submitTurnOffAutoRenew、CANCEL_WITH_REFUND提交submitCancelAndRefundPurchase,随后按流程类型决定跳转到移除后的路由还是回到购买设置页展示"已取消"提示。
常见陷阱清单:10 条实战避坑指南
1. 读取is_partner_managed/is_host_managed,不要自行推导
合作伙伴预置(partner-provisioned,"Jetpack Start")的订阅由合作伙伴计费,WordPress.com 自助管理不适用。后端直接上报两个字段:
is_partner_managed:合作伙伴预置并计费(排除A4A 商店购买);is_host_managed:由主机商(而非代理机构)预置的那部分。
Agency 通过 WordPress.com 购买,可以在这里取消,所以只有is_host_managed才阻止 cancel/remove;取消流程对它们单独跳过问卷。旧代码用partner_name检查 +! isA4ABillingDragonPurchase()以及[ 'agency', 'a4a_agency' ]列表来推导,新代码应直接用标志位。尚未清扫的旧调用点包括purchase-payment-method.tsx、components/purchase-expiry-status/、经典版manage-purchase/index.tsx。
另外is_upgradable、is_cancelable、is_removable、can_explicit_renew在相关场景下服务端都返回 false,客户端只有在可见性由其他因素驱动时才需要自己的判断:CancelOrRemoveActionButton和经典版renderCancelPurchaseNavItem都依据自动续订状态而非is_cancelable;存储附加包没有自己的服务端标志。
2. 支付方式列表加载中为空
allowedPaymentMethods === undefined返回[](不展示任何支付方式);出错则 fail-open(全部展示)。不要在use-create-assignable-payment-methods.tsx的 undefined 守卫之前添加条件逻辑。
3.REMOVE和CANCEL是不同 API
getPurchaseCancellationFlowType对过期购买返回REMOVE,映射到 DELETE 调用,而不是取消。不要假设"取消购买"的所有路径都调用同一个 mutation。对应地,cancelPurchaseRoute的页面标题也会据此显示 "Remove" 还是 "Cancel"(见 me.tsx#L448-L469)。
4. 捆绑购买只能 Remove
expiry_status === 'included'的购买随父套餐续订,is_auto_renew_enabled对它毫无意义,关闭自动续订是 no-op。永远不要为捆绑购买提供 Cancel。只有域名连接(domain_map)可以单独移除;套餐捆绑的其余部分随套餐一起走。参见purchase-settings/index.tsx中的CancelOrRemoveActionButton。
5. CRITICAL:flowType会被静默覆盖
在onSurveyComplete()内部,state.cancelIntent === 'refund'会把CANCEL_AUTORENEW切换成CANCEL_WITH_REFUND。shouldShowRefundEligibilityNotice特性开关也会改变默认路径。源码中的computeEffectiveFlowType(cancel-purchase/index.tsx#L973-L986)是这一逻辑的单一事实来源:当 URL 带intent时以mutationFlowType为准;cancelIntent === 'refund'时强制CANCEL_WITH_REFUND;在 split cancel/remove 开关下,可退款套餐的默认 Cancel 被改为CANCEL_AUTORENEW。
6. 问卷完成状态按购买逐个记录
问卷完成状态存储在用户偏好(user preferences)中,避免重复问卷调查——一个已经完成过问卷的购买不会再出现新问卷。相关代码可见cancelPurchaseSurveyCompleted()的调用(cancel-purchase/index.tsx#L1489-L1491)。
7. Siteless 购买:绝不要触发站点级查询
部分产品(Akismet、Jetpack、Marketplace)使用临时站点(siteless.{jetpack|akismet|marketplace.wp|a4a}.com)。必须用hasQueryableSite( purchase )(utils/purchase.ts#L333-L335,包装purchase.is_attached_to_holding_site)守卫。不要对这些购买触发任何站点级查询——不只是siteBySlugQuery(),还有一切命中/sites/{blog_id}/…的查询(siteFeaturesQuery、sitePurchasesQuery、siteByIdQuery、siteDomainsQuery、cancellationOffersQuery、备份查询等)。展示信息用purchase.domain或purchase.blog_id,并直接跳过依赖站点的 UI。
背后的深层原因(SHILL-2295):用户不是 holding site 的成员,这些请求返回403 authorization_required。AuthProvider(app/auth/index.tsx)订阅整个查询缓存,曾把 403 当作登出会话处理并重定向到/log-in,随即反弹回来形成死循环。现在它的分类器要求statusCode === 401才重定向,403 不再触发跳转,但仍应禁用这些查询——错误会落进缓存,所有下游消费者都得应付它。
hasQueryableSite()是 holding-site 检查,不是可达性检查。购买也可能指向一个所有者已被移除的真实站点(Jetpack 断开连接时 WPCOM 会把用户从博客移除,已删除站点行为相同)。此时blog_id存在、is_attached_to_holding_site为 false,hasQueryableSite()返回true,但/upgrades?site={blog_id}等接口返回403 unauthorized/User cannot access upgrades.。目前没有任何购买字段上报这种情况,因此路由加载器必须对站点级ensureQueryData调用.catch()——未处理的 rejection 会让整个路由失败进入通用500 Error页,用户根本无法进入流程(SHILL-1442)。cancelPurchaseRoute和purchaseSettingsIndexRoute(app/router/me.tsx)都做了这件事。
最后:用enabled守卫查询时,加载条件要读isLoading而不是isPending——被禁用的查询永远保持isPending,基于isPending的守卫会让界面永远停在加载占位符上。
8. 转移(transferred)购买:先查所有权再允许操作
列表中的转移购买由userTransferredPurchasesQuery()单独拉取,其管理操作被禁用(isTransferredOwnership判断见 utils/purchase.ts#L285-L292)。
9. 路由参数是字符串
URL 参数中的purchaseId必须parseInt()后再传给查询函数——purchaseQuery( parseInt( purchaseId ) )是所有相关 route loader 的统一写法。
10.site.options.unmapped_url对.home.blog站点不可信
即使站点的免费域名是.home.blog(或其他.blog子域名),它返回的也是.wordpress.comURL。不要用它渲染用户免费主机名。应读取siteDomainsQuery( siteId )中的真实 WPCOM 域名——找到带wpcom_domain或is_wpcom_staging_domain标志的条目,用它的domain字段。这与经典版site.wpcom_url(即withoutHttp( unmapped_url ))同根同源,参见 client/me/purchases/AGENTS.md 陷阱 #6。
小结
billing-purchases是 wp-calypso Dashboard 中一套高度工程化的计费模块:TanStack Query 管理数据与缓存(以'upgrades'前缀为轴心)、TanStack Router 管理路由与预取、DataViews 驱动响应式购买列表、工厂模式封装支付方式、三流程分流驱动取消/退款/移除。理解其数据层约定与 10 条陷阱,是在该模块安全新增功能、排查线上问题(尤其是 siteless 购买与缓存失效类问题)的前提。
- 前端
- CMS
【免费下载链接】wp-calypso
The JavaScript and API powered WordPress.com
相关推荐
wp-calypso Classic Purchases 模块技术指南:Redux 架构下的购买与账单全生命周期管理
wp calypso Classic Purchases 模块技术指南:Redux 架构下的购买与账单全生命周期管理 本文基于 wp calypso 仓库 cl
前端CMSWordPress.com Calypso 深度解析:Classic Purchases 模块的账单与购买管理架构
WordPress.com Calypso 深度解析:Classic Purchases 模块的账单与购买管理架构 本文以 Calypso(WordPress.
前端CMSwp-calypso 购买页埋点:TrackPurchasePageView 组件的工作原理与实现详解
wp calypso 购买页埋点:TrackPurchasePageView 组件的工作原理与实现详解 本文围绕 wp calypso 中 client/me/
前端CMS
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考