news 2026/9/25 8:30:43

wp-calypso Dashboard 计费购买管理模块源码指南:TanStack Query/Router 架构下的购买管理实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
wp-calypso Dashboard 计费购买管理模块源码指南:TanStack Query/Router 架构下的购买管理实现
  • 前端
  • CMS

【免费下载链接】wp-calypso

The JavaScript and API powered WordPress.com

项目地址:https://gitcode.com/gh_mirrors/wp/wp-calypso
点击查看免费下载

本指南以 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 约定

购买相关数据由两层封装提供:

  1. fetcher 层:位于@automattic/api-core(packages/api-core/src/upgrades),负责真实的 HTTP 请求(fetchUserPurchases、fetchPurchase、cancelAndRefundPurchase、removePurchase、setPurchaseAutoRenew等)。
  2. 查询层:位于@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 对此给出硬性警告:永远不要在两者之间拷贝逻辑而不转换字段名和取值。

维度DashboardClassic
类型来源@automattic/api-core(packages/api-core/src/upgrades/types.ts#L76)calypso/lib/purchases/types
字段风格snake_case,如purchase.site_slugcamelCase
取值风格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 都会 invalidateuserPurchasesQuery,而它的['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

项目地址:https://gitcode.com/gh_mirrors/wp/wp-calypso
点击查看免费下载
上一篇:如何选对.NET Framework版本:dotnet官方文档对比4.5到4.8.1共11个版本(含支持状态清单)
下一篇:@wepy/use-promisify:让 WePY 小程序 API 全面 Promise 化

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 8:28:13

基于 PaddleNLP SimpleServing 的多标签文本分类服务化部署实战指南

人工智能大模型预训练微调LoRARLHF强化学习分布式训练 【免费下载链接】PaddleNLP Easy-to-use and powerful LLM and SLM library with awesome model zoo. 项目地址: https://gitcode.com/gh_mirrors/pa/PaddleNLP 点击查看 免费下载 多标签文本分类模型&#xf…

作者头像 李华
网站建设 2026/9/25 8:24:08

大模型安全实战:深度伪造与AI滥用防御指南

1. 这不是“防黑客手册”,而是一份给AI工程师的实战安全操作日志“大模型安全深度学习指南:深度伪造与AI滥用专题(2)”——这个标题里藏着三个被严重低估的现实信号:第一,“深度伪造”早已不是实验室里的demo,而是每天…

作者头像 李华
网站建设 2026/9/25 8:24:00

Agent Skills实战:从提示词到可复用技能包,打造稳定高效的AI代理

最近大半年,我一直在和 agent 开发较劲。手上同时在用 Claude Code、Codex 和几个开源的 agent 框架,慢慢发现一个规律:真正决定 agent 好不好用的,往往不是模型本身,而是你有没有给它准备一套拿得出手的 agent skills…

作者头像 李华
网站建设 2026/9/25 8:23:18

VDI 与远程办公场景的进程白名单适配:安当RDM 防勒索落地实践

一、为什么 VDI 与远程办公成了勒索攻击的新焦点 虚拟桌面(VDI)与远程办公的普及,让"终端"这个边界变得模糊。过去我们习惯把防护重心放在物理办公电脑上:装杀毒、打补丁、管 U 盘。但当员工通过远程接入方式登录到数据…

作者头像 李华
网站建设 2026/9/25 8:23:17

局域网共享报0X80070035?从SMB协议排查网络路径

简介:日常使用 Win7 访问局域网共享文件夹时若遇到 0x80070035 错误并提示找不到网络路径,这份 docx 文档可提供完整的排查与处理参考。内容源于实际故障场景,作者先通过 ping 确认网络连通,再逐项检查防火墙、共享服务和系统服务…

作者头像 李华