ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Mailchimp Open Commerce 履约体系详解:api-plugin-fulfillment 基础插件如何构建 Fulfillment Type / Method 扩展框架

Mailchimp Open Commerce 履约体系详解:api-plugin-fulfillment 基础插件如何构建 Fulfillment Type / Method 扩展框架
  • 后端
  • 电商

【免费下载链接】reaction

Project has been discontinued ////// Mailchimp Open Commerce is an API-first, headless commerce platform built using Node.js, React, GraphQL. Deployed via Docker and Kubernetes.

项目地址:https://gitcode.com/gh_mirrors/re/reaction
点击查看免费下载

本篇围绕仓库中 packages/api-plugin-fulfillment/README.md 文档展开,讲解 Mailchimp Open Commerce(Reaction)中履约(Fulfillment)基础插件的定位与实现:它本身不实现任何具体履约方式,而是为 shipping、pickup、digital 等履约类型插件,以及 flatRate、store pickup 等履约方式插件提供统一的注册机制、数据模型、GraphQL API 与报价(quotes)流程。读完本文,你可以理解如何基于这套框架编写自己的履约类型插件和履约方式插件,并清楚从「购物车请求报价」到「用户选中履约方式」的完整调用链。

一、基础插件的角色:README 核心观点的源码印证

README 的核心论点可以归纳为三句话:

  1. 这是一个基础履约插件,其作用是让其他履约类型(shipping、pickup、digital 等)与履约方式(shipping、store、download 等)能够以各自独立的插件形式被引入系统;
  2. 它提供实现履约所需的通用能力(collections、GraphQL schema、queries/mutations、schema 扩展、报价分发等);
  3. 每个新引入的履约类型插件,其对应的具体履约方式也必须作为独立的插件添加。例如pickup类型下可以有store pickup和curb-side pickup两种方式,每一种都是一个单独插件。

仓库的目录结构与这一设计完全对应:

层级插件包职责
基础层api-plugin-fulfillment通用模型、API、注册机制
类型层api-plugin-fulfillment-type-shipping、api-plugin-fulfillment-type-pickup注册履约类型枚举值,并在 startup 时创建类型根记录
方式层api-plugin-fulfillment-method-shipping-flat-rate、api-plugin-fulfillment-method-shipping-dynamic-rate、api-plugin-fulfillment-method-pickup-store实现具体报价逻辑,向类型下注入方法记录

应用侧的插件装配关系可以直接在 apps/reaction/plugins.json 中看到:fulfillment、fulfillmentTypeShipping、fulfillmentTypePickup、fulfillmentMethodShippingFlatRate、fulfillmentMethodShippingDynamicRate、fulfillmentMethodPickupStore被分别独立列出并加载。

二、插件注册:一个插件声明了什么

入口是 src/index.js 中导出的register(app)函数,它通过app.registerPlugin声明了基础插件的全部能力,逐项来看:

  • collections:注册Fulfillment与FulfillmentRestrictions两个 MongoDB 集合,并声明索引:
    • Fulfillment:name: 1、shopId: 1—— 履约类型按店铺隔离,一条文档代表一个店铺下的一种履约类型,其methods数组内嵌该类型下的所有履约方式;
    • FulfillmentRestrictions:methodIds: 1—— 履约限制规则集合(按方法 ID 索引)。
  • graphQL:注入 schema.graphql 与 resolvers。
  • queries / mutations:注入内部查询与变更函数(见第五节)。
  • functionsByType:
    • registerPluginHandler: [registerPluginHandlerForFulfillmentTypes]—— 供子插件注册履约类型名;
    • preStartup/startup—— 启动钩子(见第七节)。
  • contextAdditions:向请求上下文注入fulfillment对象,见下节。
  • simpleSchemas:注册MethodEmptyData。
  • shopSettingsConfig:注册店铺设置项baseFulfillmentTypesForShop(字符串数组,可选),控制「每个新建商品默认附加哪些履约类型」,编辑权限为reaction:legacy:fulfillmentTypes/update:settings。

2.1 注册处理器:子插件如何「登记」履约类型

src/registration.js 维护一个全局对象:

export const fulfillment = { registeredFulfillmentTypes: ["undecided"] }; export function registerPluginHandlerForFulfillmentTypes({ registeredFulfillmentTypes }) { if (registeredFulfillmentTypes) { fulfillment.registeredFulfillmentTypes = fulfillment.registeredFulfillmentTypes.concat(registeredFulfillmentTypes); } FulfillmentTypeDeclaration.validate(fulfillment); }

基础插件自身只预置undecided这一兜底类型(对应 GraphQL 枚举中的默认值)。类型插件则通过插件描述里的registeredFulfillmentTypes字段向该数组追加自己的类型。以 api-plugin-fulfillment-type-pickup/src/index.js 为例:

await app.registerPlugin({ label: "Fulfillment Type Pickup", name: "fulfillment-type-pickup", ... functionsByType: { startup: [startup] }, registeredFulfillmentTypes: ["pickup"] });

registerPluginHandlerForFulfillmentTypes正是框架在加载插件时按functionsByType.registerPluginHandler类型回调的钩子,完成「类型名登记」这一动作。

三、数据模型:SimpleSchema 定义与跨插件 Schema 扩展

3.1 核心 Schema

src/simpleSchemas.js 定义了本插件的文档模型:

FulfillmentTypeSchema(Fulfillment集合的文档结构):

字段类型说明
nameString履约类型显示名,用户可编辑
shopIdString所属店铺 ID
providerProviderSchema内嵌对象:enabled(Boolean)、label(String)、name(String)
fulfillmentTypeString类型标识(如shipping、pickup),应用使用、用户不可编辑
displayMessageTypeString?特定于该类型的 UI 附加提示
methodsFulfillmentMethodSchema[]?该类型下的履约方式数组
createdAt/updatedAtDate时间戳,必填

FulfillmentMethodSchema(内嵌于类型的methods数组):

字段类型说明
costNumber?可选成本
fulfillmentTypesString[] (minCount 1)适用类型(向后兼容字段)
groupString方法所属分组
handlingNumber手续费
enabledBoolean是否启用
labelStringUI 显示名
nameString方法唯一标识(用户可编辑名)
fulfillmentMethodString?应用内部用的方法名(如flatRate),用户不可编辑
displayMessageMethodString?特定于该方法的 UI 附加提示
rateNumber费率

UserEditableFulfillmentMethodSchema只保留了cost、handling、enabled、label、displayMessageMethod、rate六个可编辑字段,用于updateFulfillmentMethod的入参校验。

MethodEmptyData定义了一个空占位对象(gqlType: String+ 可选emptyData: Boolean),作为methodAdditionalData的默认形态。

3.2 extendFulfillmentSchemas:把「类型白名单」注入其他插件

extendFulfillmentSchemas(schemas, allFulfillmentTypesArray)是本插件preStartup阶段调用的关键函数。它用SimpleSchema.extend把「所有已注册履约类型组成的白名单数组」写入其他插件的 schema 定义中,涉及:

  • Product/CatalogProduct的supportedFulfillmentTypes.$(allowedValues);
  • Shipment的type;
  • CartItem的selectedFulfillmentType与supportedFulfillmentTypes.$;
  • CommonOrder的fulfillmentType;
  • orderFulfillmentGroupInputSchema/OrderFulfillmentGroup的type;
  • 同时向SelectedFulfillmentOption和ShippingMethod追加methodAdditionalData字段(SimpleSchema.oneOf(MethodEmptyData),可选),为方式插件扩展自己的附加数据结构预留扩展点。

也就是说,基础插件是全局履约字段校验规则的中枢:类型插件只负责登记类型名,商品、购物车条目、发货单上的履约类型字段合法性则由这里统一收口。

3.3 GraphQL 层的数据类型

schemas/schema.graphql 定义了完整的对外模型:

  • enum FulfillmentType:基础插件只声明undecided一个值,其余枚举值(如shipping、pickup)由类型插件的 schema 追加扩展(源码注释写明 "extended by fulfillment-type plugins");
  • type EmptyData+union AdditionalData = EmptyData:方法附加数据的多态容器,resolvers/index.js 中的AdditionalData.__resolveType依据obj.gqlType做类型解析;方式插件会以相同模式追加 union 成员。例如 api-plugin-fulfillment-method-pickup-store/src/preStartup.js 会在preStartup阶段把自己的 MethodStoreData(含storeId、storeAddress、storeTiming等门店字段)extend 进ShippingMethod与SelectedFulfillmentOption的methodAdditionalData定义;
  • type FulfillmentMethodObj:方法根对象(cost、handling、rate、enabled、label、name、fulfillmentMethod、group、fulfillmentTypes);
  • type CustomFulfillmentMethodObj:在方法对象上额外带出fulfillmentTypeId与shopId,供按类型分页查询方法时使用;
  • type FulfillmentTypeObj/ProviderInfo:类型根对象与供应商信息;
  • Relay 风格的 Connection 分页类型:FulfillmentTypeObjConnection、FulfillmentMethodObjConnection及对应 Edge。

四、GraphQL 查询与变更 API

4.1 Query

schema.graphql 暴露三个查询,Resolver 实现分别位于:

  • fulfillmentType(fulfillmentTypeId, shopId):src/queries/fulfillmentType.js,先校验reaction:legacy:fulfillmentTypes/read权限,再按_id + shopId查询;
  • fulfillmentTypes(shopId, after/before/first/last/offset):src/queries/fulfillmentTypes.js,返回Fulfillment.find({ shopId })游标;
  • fulfillmentMethods(shopId, fulfillmentTypeId, after/before/first/last/offset):src/queries/fulfillmentMethods.js 使用聚合管道先把匹配的Fulfillment文档$unwind: "$methods",再$replaceRoot合并出fulfillmentTypeId与shopId——因为方法是以数组形式内嵌在类型文档里的,聚合是把它「拍平」为独立节点的必要手段。

另外,ShopSettings被扩展了baseFulfillmentTypesForShop: [FulfillmentType]字段(同时扩展了ShopSettingsUpdatesinput),语义是「将添加到每个新建商品上的默认履约类型列表」。

4.2 Mutation

GraphQL 层只暴露两个面向管理端的变更(schema.graphql),而内部 mutations 更完整(src/mutations/index.js):

Mutation说明
updateFulfillmentType(input)更新类型的name、enabled、label、displayMessageType;实现见 src/mutations/updateFulfillmentType.js,校验reaction:legacy:fulfillmentTypes/update权限后按_id + shopId执行$set
updateFulfillmentMethod(input)更新方法的可编辑字段,入参UpdateFulfillmentMethodInfo需给出shopId、fulfillmentTypeId、methodId与UserEditableMethodInput(label/displayMessageMethod/cost/handling/rate/enabled均可选)
selectFulfillmentOptionForGroup(内部 mutation,由 GraphQL resolver 层调用)从购物车的shipmentQuotes中选出指定fulfillmentMethodId的报价,写入该组的shipmentMethod并saveCart;见 src/mutations/selectFulfillmentOptionForGroup.js
updateFulfillmentOptionsForGroup(内部 mutation)重新拉取某履约组的报价(quotes)并回写购物车;见 src/mutations/updateFulfillmentOptionsForGroup.js

4.3 关键 mutation 的实现细节

createFulfillmentType(src/mutations/createFulfillmentType.js):

  • 用FulfillmentTypeSchema.clean(input)清洗入参,并把provider.name强制对齐为name;
  • 明确「虽然 schema 允许methods,但创建类型时不允许同时携带方法」——方法应由方式插件通过createFulfillmentMethod注入,这一点直接呼应了 README 中「方式必须是独立插件」的设计;
  • 幂等处理:若{ shopId, fulfillmentType }已存在则仅Logger.warn并直接返回,不抛错;
  • 新插入前校验reaction:legacy:fulfillmentTypes/create权限,用Random.id()生成_id。

对应的测试 src/mutations/createFulfillmentType.test.js 覆盖了三个路径:缺少必填字段报Name is required、类型已存在时跳过权限校验直接返回、以及正常插入。

createFulfillmentMethod(src/mutations/createFulfillmentMethod.js):校验reaction:legacy:fulfillmentMethods/create权限后,先确认目标类型存在;若该类型下已有同fulfillmentMethod名的方法则幂等返回;否则为方法生成_id、自动把fulfillmentTypes设为[该类型的 fulfillmentType],再用$addToSet: { methods: method }追加到类型文档的methods数组。

五、报价流程:fulfillmentMethodsWithQuotes 的分发机制

这是基础插件「通用能力」中最核心的一环:src/queries/fulfillmentMethodsWithQuotes.js。

流程可以拆解为:

  1. 前置检查:commonOrder.items为空直接返回空数组;调用 extendCommonOrder 把订单条目用 Catalog 中真实的商品/变体数据(而非客户端数据)合并补全,并串联执行所有addShippingRestrictionCustomAttributes类型的函数以收集限制所需的标签与自定义属性;
  2. 按类型分发:commonOrder.fulfillmentType指定本次要报价的履约类型,基础插件从context.getFunctionsOfType("fulfillmentMethodsWithQuotes")中筛选func.key === fulfillmentTypeInGroup的所有 handler 并行执行(Promise.all);
  3. 失败重试一次:各方式插件会把处理失败的请求登记进retrialTargets,首轮结束后若非空则整体重试一轮,仍失败则Logger.warn记录出错的 package;
  4. 返回:所有方式插件写入rates的报价数组。

方式插件正是通过functionsByType.fulfillmentMethodsWithQuotes挂载自己的 handler。以 api-plugin-fulfillment-method-shipping-flat-rate/src/index.js 为例:

functionsByType: { preStartup: [fulfillmentMethodShippingFlatRatePreStartup], validateOrderMethods: [{ key: "flatRate", handler: validateOrderMethodsFlatRate, name: "validateOrderMethodsFlatRate" }], fulfillmentMethodsWithQuotes: [ { key: "shipping", handler: fulfillmentMethodsWithQuotesShippingFlatRate, name: "fulfillmentMethodsWithQuotesShippingFlatRate" } ] }

key: "shipping"即声明「我负责 shipping 类型下的报价」,与类型插件登记的registeredFulfillmentTypes形成闭环。此外 src/queries/getFulfillmentMethodsWithQuotes.js 是 5.0 起标记为 deprecated 的别名,直接转发给fulfillmentMethodsWithQuotes,仅为向后兼容保留。

购物车侧的完整链路在 src/mutations/updateFulfillmentOptionsForGroup.js 中体现:取出购物车 →removeMissingItemsFromCart清理失效商品 →getCommonOrderForCartGroup构造该履约组的 CommonOrder → 调用fulfillmentMethodsWithQuotes拿到 rates → 用getShipmentQuotesQueryStatus归一化出shipmentQuotes与shipmentQuotesQueryStatus(区分pending/success/error三种状态)→ 仅当报价或状态有变化时saveCart。随后用户在结算页做选择,走selectFulfillmentOptionForGroup:校验所选fulfillmentMethodId必须存在于当前组的shipmentQuotes中(防止选择已被刷新掉的报价),再把对应option.method写入组的shipmentMethod。

六、启动流程与数据库迁移

6.1 preStartup:版本检查 + Schema 扩展

src/preStartup.js 做两件事:

  • 通过@reactioncommerce/db-version-check校验reaction_api_plugin_fulfillment命名空间的数据库版本是否为期望的2;setToExpectedIfMissing回调中,只有当Fulfillment与FulfillmentRestrictions两个集合均为空(或不存在)时才把版本直接置为期望值,避免对已有数据误判;
  • 从context.fulfillment.registeredFulfillmentTypes(此时已由各类型插件填好)取值,调用extendFulfillmentSchemas完成上一节所述的白名单注入。

6.2 startup:undecided 兜底类型

src/startup.js 监听appEvents的afterShopCreate事件:每个新店铺创建后,若Fulfillment集合中不存在{ fulfillmentType: "undecided", shopId }记录,则自动插入一条name: "Undecided Type"、provider 为{ enabled: true, label: "Undecided", name: "undecided" }的根记录,作为用户在尚未选定履约类型时的占位。

类型插件遵循同一模式:api-plugin-fulfillment-type-pickup/src/checkAndCreateFulfillmentType.js 在 startup 时为每个店铺幂等创建pickup类型记录;方式插件同理,api-plugin-fulfillment-method-pickup-store/src/startup.js 遍历所有店铺(每个店铺一个事务)确保门店自提方法存在,并监听afterShopCreate覆盖新店铺。

6.3 迁移:从 Shipping 到 Fulfillment 的更名

migrations/2.js(注册于 migrations/index.js)记录了这套模型从「shipping 专用」到「通用履约」的演进:

  • up:用聚合把旧Shipping集合整体$out复制为新Fulfillment集合、FlatRateFulfillmentRestrictions复制为FulfillmentRestrictions;为缺失fulfillmentType的文档补写fulfillmentType: "shipping";用arrayFilters为已有methods批量补写fulfillmentMethod: "flatRate";并把 Groups 中的旧权限reaction:legacy:shippingMethods/*、shippingRestrictions/*映射替换为reaction:legacy:fulfillmentTypes/*、fulfillmentMethods/*、fulfillmentRestrictions/*;
  • down:从受影响 Groups 中pullAll新权限并 drop 两个新集合。

这也解释了为何 GraphQL schema 中仍保留Shipment、ShippingMethod等命名(源码注释中多处提到 "retained for backward compatibility")。

七、不透明 ID 与工具

src/xforms/id.js 定义了reaction/shop、reaction/cart、reaction/fulfillmentGroup、reaction/fulfillmentMethod四个命名空间的 opaque id 编解码函数,其中FulfillmentMethod._id的 resolver(src/resolvers/FulfillmentMethod/index.js)即用encodeFulfillmentMethodOpaqueId把内部 ID 包装为不透明 ID 输出给客户端。

八、本地开发与测试

按 package.json 的声明,该包要求 Node>=14.18.1、npm>=7,依赖simpl-schema、lodash与同仓库的api-utils、reaction-error、random、db-version-check等包。常用脚本:

# 在 packages/api-plugin-fulfillment 目录下 npm test # 运行 jest npm run lint # eslint

测试集中在每个 mutation 同目录的*.test.js(如 createFulfillmentType.test.js、selectFulfillmentOptionForGroup.test.js),通过@reactioncommerce/api-utils/tests/mockContext.js与mockCollection构造 mock 上下文与集合,分别验证必填校验、幂等返回、正常写入三类行为;src/tests/factory.js 则提供了测试用的工厂数据。

九、小结:三层插件架构的完整图景

把 README 的设计主张与源码对照,可以得到这张扩展地图:

  1. 基础插件(本文主角):提供Fulfillment/FulfillmentRestrictions集合、SimpleSchema 模型与全局白名单扩展、GraphQL 查询/变更、registerPluginHandlerForFulfillmentTypes登记机制、fulfillmentMethodsWithQuotes报价分发、购物车选择/刷新报价 mutation,以及undecided兜底类型的 startup 逻辑;
  2. 类型插件:如 fulfillment-type-shipping / fulfillment-type-pickup,只需通过registeredFulfillmentTypes登记类型名、追加 GraphQL 枚举值,并在 startup 时为店铺创建类型根记录;
  3. 方式插件:如 fulfillment-method-shipping-flat-rate / fulfillment-method-pickup-store,通过functionsByType.fulfillmentMethodsWithQuotes(按类型 key)挂载报价实现、通过createFulfillmentMethod注入方法记录、通过preStartup把自己的MethodXxxData扩展进methodAdditionalData,并可追加validateOrderMethods校验器。

对要扩展新履约能力(例如 digital 下载方式或 curb-side pickup)的开发者而言,正确的落点正是 README 所强调的:新建类型插件登记类型名 + 新建方式插件实现报价与附加数据,而不是修改基础插件本身。

  • 后端
  • 电商

【免费下载链接】reaction

Project has been discontinued ////// Mailchimp Open Commerce is an API-first, headless commerce platform built using Node.js, React, GraphQL. Deployed via Docker and Kubernetes.

项目地址:https://gitcode.com/gh_mirrors/re/reaction
点击查看免费下载
上一篇:SkillOpt-Sleep 接入 Codex:用 AGENTS Skills 给本地 Codex Agent 搭建夜间自我进化(Sleep Cycle)实战指南
下一篇:react-admin 锁机制实战:`useLockOnMount` 实现记录级并发编辑保护

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进