☰
深入解读 wp-calypso plans 数据存储的 next Mock 体系:从旧版 Plan 到 PlanNext 的渐进式迁移
2026/10/9 12:12:28 网站建设 项目流程
  • 前端
  • CMS

【免费下载链接】wp-calypso

The JavaScript and API powered WordPress.com

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

导读

packages/data-stores/src/plans/mock/next/README.md只有一句纲领性描述:/mock/next下的 Mock 数据将逐步演进,以通知并覆盖(inform & override)/mock目录下的旧版 Mock。这篇技术文章将以这一迁移策略为主线,结合@automattic/data-storesplans 模块的源码与测试,带你完整理解mock/next中SitePlan/PlanNext两个新数据结构、价格与推介优惠(intro offer)的字段语义,以及它们如何被useIntroOffers、useCurrentPlan等 Hook 消费,最终掌握为 WordPress.com 套餐(Plan)相关功能编写、演进测试 Mock 数据的方法。

一、为什么需要mock/next:新旧 Mock 的演进背景

wp-calypso 的 plans 数据存储位于 packages/data-stores/src/plans,其中mock/目录承载了大量用于测试的假数据。当 API 数据结构发生演进时,直接原地修改旧 Mock 会破坏既有测试的稳定性,因此仓库引入了mock/next目录,作为新结构 Mock 的孵化区:

  • 旧 Mock 位于 mock/,由index.ts统一导出;
  • 新 Mock 位于 mock/next/,包含独立的README.md、index.ts与store/plans.ts;
  • 二者的汇合点位于 mock/index.ts,其中通过export * from './next'将新 Mock 一并导出,形成“新数据先共存、后覆盖旧数据”的迁移路径。

从源码结构看,mock/next的演进目标是用新的PlanNext接口逐步取代旧的Plan接口。在 types.ts 中明确写着:

"This is the new interface for API Plans that will replace the existing Plan interface above. The existing Plan interface will be removed once this interface is fully implemented."

也就是说,mock/next不只是新增几个常量,它承载了 plans 模块「接口换代」的完整过渡策略:新 Mock 先与旧 Mock 并行存在,测试逐步切换到新数据结构,最终移除旧接口。

二、mock/next目录结构与导出机制

mock/next的目录结构非常精简:

packages/data-stores/src/plans/mock/next/ ├── README.md # 迁移说明 ├── index.ts # 统一导出入口 └── store/ └── plans.ts # 核心 Mock 数据定义
  • index.ts 只有一行export * from './store/plans',将store/plans.ts中定义的所有常量(NEXT_STORE_SITE_PLAN_PERSONAL、NEXT_STORE_SITE_PLAN_BUSINESS、NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT、NEXT_STORE_PLAN_PERSONAL、NEXT_STORE_PLAN_BUSINESS)作为命名导出暴露出去;
  • 所有导出的常量统一以NEXT_STORE_为前缀,与旧 Mock(STORE_前缀)在命名空间上天然隔离,避免命名冲突,同时直观标识“新一代”Mock 数据;
  • 命名约定中SITE_PLAN对应SitePlan接口,PLAN对应PlanNext接口。

这种「子目录 + 独立 index + 命名前缀」的组织方式,与旧 Mock 中 store/plans.ts、store/products.ts、store/features.ts 的STORE_前缀风格一脉相承,保持了整个 mock 体系的阅读一致性。

三、核心数据结构:SitePlan与PlanNext的字段语义

mock/next/store/plans.ts中的所有 Mock 常量都严格受 types.ts 中 TypeScript 接口的约束。理解这些接口是正确使用 Mock 数据的前提。

3.1 新PlanNext接口

export interface PlanNext { /* START: Same SitePlan/PlanNext props */ planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; pricing: PlanPricing; /* END: Same SitePlan/PlanNext props */ productNameShort: string; pathSlug?: string; }

其中pricing使用PlanPricing类型(types.ts),核心字段为:

字段类型说明
billPeriod-1 \| (typeof PERIOD_LIST)[number]计费周期,-1表示免费计划,365表示按年计费,31表示按月计费
currencyCodestring货币代码,如USD、EUR
introOfferPlanIntroductoryOffer \| null推介优惠(首次购买折扣),无优惠时为null
originalPrice{ monthly, full }原始价格,monthly为月价,full为整期总价,二者可为null
discountedPrice{ monthly, full }折后价,null表示无折扣

PlanIntroductoryOffer的字段(types.ts):

  • formattedPrice:格式化价格字符串(已标注@deprecated,建议改用formatCurrency基于monthly/full计算);
  • rawPrice.monthly/rawPrice.full:按最小货币单位(分)表示的原始价格;
  • intervalUnit:'year'或'month',代码中对此做了硬性假设以计算月价/总价;
  • intervalCount:计费间隔数量;
  • isOfferComplete:优惠是否已完整呈现(用于 UI 判断展示形态)。

3.2 站点套餐SitePlan接口

export interface SitePlan { planSlug: PlanSlugFromProducts; productSlug: PlanSlugFromProducts; productId: number; pricing: SitePlanPricing; currentPlan?: boolean; // 是否为站点当前生效套餐 hasRedeemedDomainCredit?: boolean; expiry?: string; // 仅当前套餐返回,到期时间 purchaseId?: number; // 当前套餐的购买 ID(由接口 id 重映射而来) }

SitePlanPricing在PlanPricing基础上移除了billPeriod,并增加了:

  • hasSaleCoupon?: boolean:是否存在销售优惠券;
  • costOverrides?: CostOverride[]:成本覆盖(如促销/优惠券导致的改价),CostOverride包含doesOverrideOriginalCost、firstUnitOnly、newPrice、oldPrice、overrideCode、percentage等字段(types.ts)。

值得注意的是 types.ts 特意用/* START: Same SitePlan/PlanNext props */注释标注了SitePlan与PlanNext共有的属性块,说明二者在设计上刻意保持对齐,为后续统一接口做准备。

四、store/plans.tsMock 数据逐条拆解

4.1 站点套餐:NEXT_STORE_SITE_PLAN_PERSONAL

export const NEXT_STORE_SITE_PLAN_PERSONAL: SitePlan = { planSlug: 'personal-bundle', productSlug: 'personal-bundle', productId: 1, pricing: { currencyCode: 'USD', introOffer: null, // 无推介优惠 originalPrice: { monthly: 400, full: 4800 }, discountedPrice: { monthly: null, full: null }, // 无折扣 }, };

它代表了「Personal 站点套餐」在没有促销、没有优惠券场景下的标准数据:月价 400(即 $4.00,按最小货币单位计),年付总价 4800($48.00),introOffer与discountedPrice均为null,是测试「无优惠」路径的标准夹具。

4.2 站点套餐:NEXT_STORE_SITE_PLAN_BUSINESS

export const NEXT_STORE_SITE_PLAN_BUSINESS: SitePlan = { planSlug: 'business-bundle', productSlug: 'business-bundle', productId: 2, pricing: { introOffer: { formattedPrice: '$150.00', rawPrice: { monthly: 1250, full: 15000 }, intervalUnit: 'year', intervalCount: 1, isOfferComplete: false, }, originalPrice: { monthly: 2500, full: 30000 }, discountedPrice: { monthly: null, full: null }, currencyCode: 'USD', }, };

Business 套餐的 Mock 展示了带推介优惠的完整价格形态:

  • 原始年价full: 30000($300.00)、月价2500($25.00);
  • 推介优惠价rawPrice.full: 15000($150.00),恰好是原价的一半,formattedPrice: '$150.00'与之对应;
  • intervalUnit: 'year'、intervalCount: 1表示“按年计费、一次性优惠”;
  • isOfferComplete: false表示该优惠对象尚未完整展示(通常用于 UI 渐变呈现场景)。

4.3 当前套餐:NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT

export const NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT: SitePlan = { ...NEXT_STORE_SITE_PLAN_BUSINESS, currentPlan: true, };

通过对象展开继承NEXT_STORE_SITE_PLAN_BUSINESS的全部字段,仅将currentPlan置为true。这是最小化数据变体(minimal variant)的经典写法:只需表达「当前生效的 Business 套餐」这一差异,其余价格信息全部复用父常量,避免重复维护两份几乎相同的价格数据。

4.4 全局套餐:NEXT_STORE_PLAN_PERSONAL与NEXT_STORE_PLAN_BUSINESS

export const NEXT_STORE_PLAN_PERSONAL: PlanNext = { planSlug: 'personal-bundle', productSlug: 'personal-bundle', productId: 1, productNameShort: 'Personal', pricing: { billPeriod: 365, currencyCode: 'USD', introOffer: null, originalPrice: { monthly: 400, full: 4800 }, discountedPrice: { monthly: null, full: null }, }, }; export const NEXT_STORE_PLAN_BUSINESS: PlanNext = { planSlug: 'business-bundle', productSlug: 'business-bundle', productId: 2, productNameShort: 'Business', pricing: { billPeriod: 365, currencyCode: 'USD', introOffer: { formattedPrice: '$300.00', rawPrice: { monthly: 2500, full: 30000 }, intervalUnit: 'year', intervalCount: 1, isOfferComplete: false, }, originalPrice: { monthly: 2500, full: 30000 }, discountedPrice: { monthly: null, full: null }, }, };

与对应的SitePlan版本相比,PlanNext增加了两个关键差异:

  1. productNameShort:如'Personal'、'Business',直接来自 plans 详情接口的product_name_short字段,用于展示套餐简称;
  2. pricing.billPeriod: 365:PlanNext的PlanPricing携带计费周期,而SitePlan的SitePlanPricing将其移除(因为站点套餐的计费周期由站点上下文决定,不需要在价格对象中重复携带)。

另外,NEXT_STORE_PLAN_BUSINESS的introOffer价格为$300.00(full: 30000),与NEXT_STORE_SITE_PLAN_BUSINESS的$150.00(full: 15000)不同——这正是两个 Mock 各自代表的不同 API 来源:PlanNext对应全局/plans端点,SitePlan对应/sites/[siteId]/plans端点,同一套餐在两个端点上可能返回不同的优惠价格,而useIntroOffers的优先级逻辑正是依赖这一差异设计的。

五、Mock 数据的真实消费链路:useIntroOffers与测试验证

5.1 Hook 实现:SitePlans 优先于 Plans

mock/next的数据并非闲置,它们被 plans 模块的 React Hooks 直接消费。use-intro-offers.ts 的实现逻辑是:

  1. 同时调用useSitePlans({ siteId })与usePlans({ coupon })获取两路数据;
  2. 取两者 planSlug 的并集,逐 slug 读取价格中的pricing.introOffer;
  3. 若同一 slug 在 SitePlans 与 Plans 中都存在,sitePlans.data优先(源码中??运算符实现:sitePlans?.data?.[ planSlug ] ?? plans?.data?.[ planSlug ]);
  4. 无优惠时返回null,数据未加载完成时返回undefined。

这解释了为什么NEXT_STORE_SITE_PLAN_BUSINESS($150 优惠)与NEXT_STORE_PLAN_BUSINESS($300 优惠)刻意设置了不同价格——正是为了测试“站点套餐的优惠覆盖全局套餐优惠”这一优先级行为。

5.2 测试用例:优先级语义的验证

use-intro-offers.ts 测试 通过jest.mock分别注入useSitePlans与usePlans的返回值,并直接引用MockData.NEXT_STORE_*常量作为夹具:

测试场景SitePlans 注入Plans 注入断言结果
SitePlans 优惠优先Business($150 优惠)Business($300 优惠)取 SitePlans 的$150优惠
无优惠返回 nullPersonal(无优惠)Personal(无优惠){ 'personal-bundle': null }
仅 Plans 有优惠data: {}Business($300 优惠)取 Plans 的$300优惠
并集 + 优先级合并Business($150 优惠)Personal(无优惠)+ Business($300 优惠){ 'personal-bundle': null, 'business-bundle': $150 优惠 }

注意测试文件从'../../mock'导入MockData,而 mock/index.ts 已通过export * from './next'把NEXT_STORE_*常量全部导出——这意味着新 Mock 在旧 mock 入口即可见,测试无需修改导入路径,这也是mock/next与旧 mock 共存策略带来的直接红利。

5.3 其他消费方:useCurrentPlan

use-current-plan.ts 测试 同样引用NEXT_STORE_*常量。NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT(currentPlan: true)正是为这类“查询当前生效套餐”的 Hook 设计的夹具,它验证了currentPlan布尔标记如何驱动useCurrentPlan从站点套餐列表中筛选出当前套餐。

六、新旧 Mock 对照:从STORE_到NEXT_STORE_的差异一览

维度旧 Mock(mock/store)新 Mock(mock/next/store)
前缀STORE_(如STORE_PLAN_PREMIUM)NEXT_STORE_(如NEXT_STORE_PLAN_BUSINESS)
数据接口Plan(types.ts:title、description、features、storage等展示型字段)PlanNext/SitePlan(productSlug、productId、pricing等价格型字段)
价格结构PlanProduct(rawPrice、price、annualPrice、annualDiscount)与Plan分离PlanPricing(originalPrice、discountedPrice、introOffer)内聚在pricing对象中
推介优惠无独立结构(旧PlanProduct仅有annualDiscount)PlanIntroductoryOffer(formattedPrice、rawPrice、intervalUnit、intervalCount、isOfferComplete)
免费套餐STORE_PRODUCT_FREE(productId: 1、billPeriod: 'ANNUALLY'、rawPrice: 0)新 Mock 中未定义免费套餐(billPeriod: -1的语义保留在 types.ts 的PlanPricing类型中)

旧 Mock 的STORE_PRODUCT_PREMIUM_ANNUALLY(store/products.ts)通过annualDiscount: 42表达“按年付省 42%”的折扣,而新 Mock 用introOffer+discountedPrice两个维度更精细地表达优惠。从源码结构看,新结构将「原价、折后价、推介优惠」统一收拢进pricing对象,使价格逻辑集中、类型安全,这正是它要逐步取代旧Plan/PlanProduct分离式结构的原因。

七、实践指南:如何基于mock/next编写与演进测试 Mock

7.1 引入方式

在测试文件中直接引用常量:

import * as MockData from '../../mock'; // 站点套餐(带推介优惠) const businessSitePlan = MockData.NEXT_STORE_SITE_PLAN_BUSINESS; // 当前生效的 Business 套餐 const currentBusinessPlan = MockData.NEXT_STORE_SITE_PLAN_BUSINESS_CURRENT; // 全局套餐(无优惠) const personalPlan = MockData.NEXT_STORE_PLAN_PERSONAL;

若需要更贴近真实的 API 返回形状,可直接使用 mock/apis/plans.ts 中的API_PLAN_PRICE_*(如API_PLAN_PRICE_FREE、API_PLAN_PRICE_PREMIUM_ANNUALLY、API_PLAN_PRICE_PREMIUM_MONTHLY)——它们模拟了public-api.wordpress.com的/plans与/sites/[siteId]/plans端点返回的 snake_case 字段,包括product_slug、bill_period、raw_price_integer、orig_cost_integer、currency_code等。

7.2 新 Mock 的演进规则

  1. 在mock/next中新增常量:命名遵循NEXT_STORE_前缀,字段严格对齐 types.ts 中的SitePlan/PlanNext接口;
  2. 通过store/index.ts或mock/index.ts的export *链暴露:新增常量自动随 mock/index.ts 的export * from './next'导出,无需修改任何消费方导入路径;
  3. 用对象展开减少重复:对仅需微调差异的场景(如currentPlan),复用既有常量...NEXT_STORE_SITE_PLAN_BUSINESS;
  4. 测试逐步切换:先让新 Mock 与旧 Mock 共存,新测试优先引用NEXT_STORE_*,待旧Plan接口全面移除后,旧STORE_*Mock 即可删除——这正是 README 所述「inform & override」的完整闭环。

7.3 适用范围与前提

  • mock/next是 plans 数据存储的测试专用数据,不参与运行时数据请求;生产环境的价格数据仍来自usePlans/useSitePlans查询(见 queries/);
  • Mock 数据中的价格单位是最小货币单位(整数分),与 types.ts 中raw_price_integer的语义一致,浮点计算已标注废弃;
  • mock/next目前仅覆盖 Personal 与 Business 两个套餐,免费套餐、月付变体等场景仍需结合 mock/apis/plans.ts 中的旧 API Mock 或自行扩展。

结语

mock/next是 wp-calypso plans 数据存储「接口换代」的过渡孵化器:它以NEXT_STORE_前缀的新PlanNext/SitePlan数据结构,通过pricing对象内聚原价、折后价与推介优惠,与旧Plan/PlanProduct分离式结构并存,再由useIntroOffers等 Hook 以「SitePlans 优先」的规则消费。理解这条演进链路,你就能在@automattic/data-stores中写出与官方测试同构、随新接口迁移的健壮 Mock 数据。

  • 前端
  • CMS

【免费下载链接】wp-calypso

The JavaScript and API powered WordPress.com

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

相关推荐

上一篇:为什么你的AI Agent需要treg?告别逐个注册API账号的终极理由
下一篇:从 LLT 到 FUZZ:chardet4cj 四层测试体系完整揭秘与用例编写指南

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询