Medusa Loyalty 插件深度解析:Gift Cards 与 Store Credit 模块的实现与演进(@medusajs/loyalty-plugin)
2026/9/10 7:12:20 网站建设 项目流程

Medusa Loyalty 插件深度解析:Gift Cards 与 Store Credit 模块的实现与演进(@medusajs/loyalty-plugin)

【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa

导读

@medusajs/loyalty-plugin是 Medusa v2 开源生态中的官方忠诚度插件,为电商应用提供礼品卡(Gift Cards)与店铺余额(Store Credit)两大能力。本文以该插件的 CHANGELOG.md 为骨架,结合其源码实现,梳理插件的模块架构、配置方式、兑换码生成、购物车/下单扣款链路、并发防超扣机制、过期校验、Admin 集成与版本演进,帮助你理解其内部原理,并能在自己的 Medusa 项目中正确安装、配置与二次开发。

插件概览与定位

@medusajs/loyalty-plugin在 package.json 中描述为 "Medusa Plugin: Loyalty - Gift Cards",当前版本 2.20.1,与 Medusa 核心@medusajs/medusa2.20.x 版本线保持同步发布。它不是一个单一模块,而是打包了两个独立 Module加**一组工作流(Workflows)**的复合插件:

  • Loyalty 模块loyalty):核心实体是GiftCard礼品卡;
  • Store Credit 模块store_credit):核心实体是StoreCreditAccount店铺余额账户与AccountTransaction账户流水;
  • 二者通过模块链接(Module Links)与订单、购物车、客户等核心模块关联。

从 types/modules.ts 可以看到插件内部通过枚举注册了两个模块名:LOVALTY = "loyalty"STORE_CREDIT = "store_credit",后续所有工作流都通过PluginModule.LOYALTY/PluginModule.STORE_CREDIT在容器中解析对应服务。

模块架构:GiftCard 与 Store Credit 的双模块设计

Loyalty 模块与 GiftCard 数据模型

Loyalty 模块的服务定义在 modules/loyalty/service.ts,它通过MedusaService({ GiftCard })自动获得基于GiftCard模型的 CRUD 能力,并额外暴露getOptions()方法返回插件配置项。

礼品卡模型定义在 modules/loyalty/models/gift-card.ts,表名为loyalty_gift_card,关键字段如下:

字段类型说明
id主键,前缀gcard礼品卡 ID
status枚举,默认pending可取pending/redeemed,见 types/loyalty/module.ts 中的GiftCardStatus
valuebigNumber礼品卡面值
codetext,唯一且可搜索兑换码
currency_codetext,可搜索ISO 三位货币代码
expires_atdateTime,可空过期时间,为空表示永不过期
reference/reference_idtext,可空来源资源类型与 ID(如order与订单 ID)
line_item_idtext,可空作为商品被购买时的行项目 ID
note/metadatatext / json备注与自定义元数据

对应的类型定义ModuleGiftCard/ModuleCreateGiftCard/ModuleUpdateGiftCard位于 types/loyalty/module.ts,其中ModuleCreateGiftCardcode注释明确说明"未提供时将自动生成"。

Store Credit 模块:账户 + 流水账本

Store Credit 模块的服务实现在 modules/store-credit/service.ts,是插件中业务逻辑最重的部分。它基于两个模型:

  • store-credit-account.ts:表名store_credit_account,ID 前缀sc_acc,包含codecurrency_codecustomer_idmetadata,并对customer_id + currency_code建立了唯一索引(仅当customer_id IS NOT NULL时生效),保证同一客户同一币种只能有一个账户。
  • account-transaction.ts:流水表,typecredit/debit(见 types/store-credit/module.ts 的TransactionType枚举)。

值得注意的设计决策是:账户余额并非存储字段,而是由流水实时聚合得出retrieveAccountStats方法通过 SQL 对store_credit_account_transaction表做条件聚合——SUM(CASE WHEN at.type = 'credit' THEN at.amount ELSE -at.amount END)计算余额,分别统计creditsdebits总额,返回ModuleAccountStats(含balance/credits/debits)。这意味着每一笔变动都会留下不可篡改的流水痕迹,余额永远是"算出来的"。

服务还通过白名单机制限制可更新字段:updateStoreCreditAccounts只允许更新idmetadata,因为"变更客户或货币代码会导致余额跟踪异常"。此外,由于MedusaService按模型名生成方法(deleteAccountTransactions),而公开接口约定为deleteTransactions,服务中实现了转发方法避免运行时TypeError(该问题在代码注释中有完整说明)。

安装与配置

依赖与 peer 范围

从 package.json 可以看到,插件的运行时 peer 依赖包括@medusajs/medusa@medusajs/framework@medusajs/dashboard等,且除核心包外的 peer 依赖均标记为 optional。React 系依赖(reactreact-domreact-router-dom)也是 optional——只有需要加载插件自带的管理后台页面时才需要。

CHANGELOG 中 2.19.0 版本将react-router-dom的可选 peer 范围放宽为^6.30.4 || ^7.0.0,意味着无论宿主项目使用 react-router 6 还是 7,插件都能干净安装;2.16.0 版本则做过一次三个包(draft-order、dashboard、loyalty-plugin)之间的 react-router-dom 版本对齐。

插件配置项:prefix 与 sections

插件接受两个配置项,定义在 types/loyalty/module.ts 的LoyaltyPluginOptions中,并在medusa-config中传给插件:

export type LoyaltyPluginOptions = { /** * 生成的礼品卡兑换码前缀,默认 "GIFT" * @example "GC" → GC-XXXX-XXXX-XXXX-XXXX */ prefix?: string /** * 兑换码中 4 字符分组的段数,默认 4 * @example 3 → GIFT-XXXX-XXXX-XXXX */ sections?: number }

medusa-config.ts中启用插件的配置方式如下:

module.exports = defineConfig({ plugins: [ { resolve: "@medusajs/loyalty-plugin", options: { prefix: "GC", // 自定义前缀 sections: 4, // 4 组 × 4 字符 }, }, ], })

createGiftCardsStep(见 workflows/gift-cards/steps/create-gift-cards.ts)会从容器中解析 Loyalty 模块并调用module.getOptions()拿到这两个配置值,再传给兑换码生成器。

兑换码生成与自定义 Code(2.17.2 新能力)

自动生成算法

默认兑换码由 utils/code-generator.ts 中的generateCode(prefix = "GIFT", sections = 4)生成。核心要点:

  • 字符集为ABCDEFGHJKLMNPQRSTUVWXYZ23456789,刻意排除了易混淆的0/O1/I
  • crypto.randomBytes生成密码学安全随机字节,并按需取模映射到字符集;
  • 代码总长度为sections * 4个字符,按每 4 字符一组用连字符拼接;
  • 带前缀时输出形如GIFT-XXXX-XXXX-XXXX-XXXX,不带前缀则直接输出分组串。

自定义 Code 支持

2.17.2 版本新增"礼品卡支持自定义 code"能力。实现上,createGiftCardsStep中先用isPresent(giftCard.code)判断调用方是否传入了自定义 code——只有未传入时才调用generateCode自动生成,随后才调用module.createGiftCards(input)落库。因此调用方传的 code 会原样保留(唯一性约束由模型的code.unique()保证)。

对应地,Admin 创建礼品卡接口(api/admin/gift-cards/validators.ts)的AdminCreateGiftCardschema 中codez.string().optional(),即不填则自动生成、填了则使用自定义值。

核心业务流程:创建 → 兑换 → 加购 → 下单扣款 → 认领

创建礼品卡(Admin)

Admin 侧POST /admin/gift-cards(api/admin/gift-cards/route.ts)将请求体交给createGiftCardsWorkflow,创建后再通过 Query Graph 回读返回完整对象。创建 schema 要求currency_codevaluez.number().min(1)),可选codestatus(默认pending)、expires_atreferencereference_idline_item_idnotemetadata

兑换礼品卡(Store)

兑换发生在客户侧。redeemGiftCardWorkflow(workflows/gift-cards/workflows/redeem-gift-card.ts)的执行链路是:

  1. gift_card_id查询礼品卡;
  2. 查询是否已关联 store credit 账户;
  3. validateGiftCardRedeemStep校验——已兑换(status === REDEEMED)或已有关联账户则报错;
  4. createStoreCreditAccountsStep按礼品卡币种创建匿名 store credit 账户;
  5. createLinksWorkflow建立gift_card ↔ store_credit_account链接;
  6. creditAccountsWorkflowreference: "gift_card"reference_id: 兑换码记入一笔 credit 流水,note 为 "Gift card redemption";
  7. updateGiftCardsWorkflow将礼品卡状态置为redeemed
  8. 返回带余额与流水明细的账户。

添加到购物车

Store 侧POST /store/carts/:id/gift-cards(api/store/carts/[id]/gift-cards/route.ts)调用addGiftCardToCartWorkflow(workflows/carts/workflows/add-gift-card-to-cart.ts)。该工作流内置三层校验:

  • validateGiftCardStep:礼品卡必须存在;
  • validateCartGiftCardStep:礼品卡未重复加购未过期isGiftCardExpired)、币种与购物车一致
  • validateGiftCardBalancesStep:账户余额必须大于 0。

通过校验后,取min(账户余额, 购物车总额)创建购物车 credit line(reference: "gift-card"),建立cart ↔ gift_card链接,并触发refreshCartItemsWorkflow刷新购物车。

值得关注的是该工作流暴露了一个validate钩子(createHook("validate", ...)),允许开发者在不改动插件源码的前提下,通过addGiftCardToCartWorkflow.hooks.validate(...)注入自定义校验逻辑——这正是 Medusa Workflows SDK 的扩展点设计。

下单扣款与防超扣

购物车完成时,插件通过completeCartWorkflow.hooks.orderCreated钩子(workflows/hooks/after-order-created.ts)触发cloneCartGiftCardsToOrderWorkflow,把购物车上的礼品卡链接克隆到订单上,并调用confirmCartCreditLinesWorkflow(workflows/carts/workflows/confirm-cart-credit-lines.ts)完成实际扣款。

confirmCartCreditLinesWorkflow的逻辑:

  1. 校验购物车上所有礼品卡未过期(validateGiftCardsNotExpiredStep,防止过期余额在结算时被扣走);
  2. 通过gift_card_store_credit_account链接把 credit line 映射到 store credit 账户;
  3. referencestore-creditgift-card的 credit line 调用debitAccountsWorkflow逐笔借记,note 为 "Gift card usage"。

2.20.0 的 "lock account on debit" 修复就落在这里。由于余额由流水聚合而来,借记是典型的"先查余额再插入流水"(check-then-insert)操作。在 Postgres 默认的 READ COMMITTED 隔离级别下,两个并发结账事务互相看不到对方未提交的借记,可能双双通过余额校验造成超扣。修复实现在lockAccountsForUpdate_(modules/store-credit/service.ts):

  • 在同一事务内对涉及的所有账户执行SELECT ... FOR UPDATE行级锁,把同一账户的借记串行化;
  • 账户 ID 先去重再排序锁定,避免跨账户借记时相互死锁;
  • 若当前不在事务上下文中,则主动抛错而不是静默回退到非事务连接——因为非事务连接会立即释放锁,等于悄悄重新引入超扣竞态。

认领礼品卡

claimGiftCardWorkflow(workflows/gift-cards/workflows/claim-gift-card.ts)允许注册客户把已兑换的匿名礼品卡余额转入自己的账户。前置校验(validateClaimGiftCardInputStep)要求:礼品卡已有关联的 store credit 账户、账户有 code、且客户has_account === true("Only customers with an account can claim a gift card")。校验通过后委托claimStoreCreditAccountWorkflow完成余额转移,对应 Store 接口为POST /store/store-credit-accounts/claim(api/store/store-credit-accounts/claim/route.ts),入参为code与当前登录客户 ID。

过期时间校验(2.20.0 完善)

2.20.0 的 "validate gift card expiry dates" 修复围绕isGiftCardExpired工具函数(utils/gift-card.ts)展开:expires_at为空则永不过期,否则将过期时间与当前时间按 UTC 时间戳比较,expires_at <= now即视为过期。该函数被购物车添加校验、下单前校验等多个工作流复用,并在 utils/tests/gift-card.spec.ts 与 workflows/carts/workflows/tests/validate-gift-card-expiry.spec.ts 中有对应单测覆盖。

同版本还包含两个配套修复:fix(loyalty-plugin): edit gift card product following global product options change(跟随核心产品模块全局 Product Options 变更调整礼品卡商品编辑逻辑),以及fix(loyalty-plugin): lock account on debit(上文已述)。

Admin 集成与平台级兼容

认证类型适配(2.18.0)

2.18.0 修复了fix(loyalty-plugin): honor the configured admin auth type in the admin SDK instead of hard-coding session auth, fixing 401s and empty pages under ADMIN_AUTH_TYPE=jwt。此前插件的 Admin 客户端代码把认证方式硬编码为 session,当宿主项目通过ADMIN_AUTH_TYPE=jwt启用 JWT 认证时,请求会返回 401、页面空白。修复后 Admin SDK 改为读取服务端实际配置的认证类型。这与 admin/lib/sdk.ts 中的 SDK 初始化逻辑对应。

默认货币列表扩展(2.18.0 / 2.16.0)

插件 Admin 端维护了一份硬编码货币映射表 admin/lib/currencies.ts。CHANGELOG 中三个版本分别向默认货币列表补充了新币种:

  • 2.18.0:伊朗里亚尔IRT、安哥拉宽扎AOA(后者同时修复了 Admin 区域编辑器遇到未知货币 code 时的崩溃);
  • 2.16.0:冈比亚达拉西GMD——若缺失该条目,Admin 中遍历store.supported_currencies做查找的页面会抛TypeError: Cannot read properties of undefined (reading 'code')

2.16.0 还修复了fix(loyalty-plugin): respect user locale in currency formatting,即货币金额格式化遵循用户 locale,而不是写死某种格式。

Admin 界面扩展机制(2.16.0)

2.16.0 引入的LayoutComposer/ 插件注入区(injection zones)机制让插件可以将自己的页面(礼品卡列表、礼品卡商品、Store Credit 账户)注入 Admin 布局。插件的 Admin 路由位于 src/admin/routes 下,包含gift-cards(列表、创建、详情、过期/备注编辑)与store-credit-accounts(列表、创建、详情、充值)两大板块,另有sales-channel-gift-cardscustomer-store-credit-widgetorder-gift-cards-widget等注入式 widget(见 src/admin/widgets)。

构建与工程化(2.18.0 / 2.14.0)

  • 2.18.0 调整了插件构建流程,以处理"插件构建过程中循环依赖(cyclic deps)"问题,并顺带修复db命令在容器初始化失败时退出码不为 1 的问题;
  • 2.14.0(开源版本)完成了若干工程化收尾:迁移到Zod v4migrate to Zod v4)、为服务方法补充 tsdocs 并新增index.ts模型导出、移除礼品卡删除操作并清理代码、修复礼品卡商品分区显示与过期日期错误提示;
  • 2.14.2 移除未使用的参数并导出 step;
  • 2.17.2 补充了 packagebugs元数据。

数据关联:Module Links

插件通过模块链接把礼品卡、账户与核心模块打通,链接文件集中在 src/links:

链接文件关联对象语义
cart-gift-cards-link.tscart ↔ gift_card购物车应用了哪些礼品卡
order-gift-cards-link.tsorder ↔ gift_card订单关联的礼品卡
customer-store-credit-account-link.tsstore_credit_account ↔ customer客户拥有的账户(只读、一对一)
gift-card-store-credit.tsgift_card ↔ store_credit_account礼品卡背后的余额账户
order-line-item-gift-card-link.tsorder_line_item ↔ gift_card作为商品购买的礼品卡行项目

这些链接使插件各工作流可以直接用 Query Graph 跨模块读取关联数据(例如add-gift-card-to-cart工作流中读取cart.gift_cards.code),而无需破坏模块隔离。

版本演进速览(2.14.0 → 2.20.1)

从 CHANGELOG.md 可以还原插件从开源到当前版本的演进主线:

版本关键变更
2.14.0插件正式开源open source loyalty plugin),Zod v4 迁移,tsdocs 补齐,移除删除礼品卡操作
2.15.x依赖滚动更新(无独立功能变更)
2.16.0LayoutComposer 插件注入区、GMD 货币、locale 感知的货币格式化、react-router-dom 版本对齐
2.17.2自定义兑换码支持Adding an option for custom codes in gift-cards)、bugs 元数据
2.18.0IRT / AOA 货币、Admin 认证类型适配(ADMIN_AUTH_TYPE=jwt、构建循环依赖修复
2.19.0react-router-dompeer 范围放宽为^6.30.4 \|\| ^7.0.0
2.20.0借记时锁定账户(防超扣)、礼品卡商品跟随全局产品选项、过期日期验证
2.20.1依赖版本对齐(@medusajs/ui@4.2.3等)

总结

@medusajs/loyalty-plugin是一个"小而完整"的复合插件:用GiftCard模型承载礼品卡语义,用"账户 + 流水账本"的 Store Credit 模块承载真实余额,通过模块链接打通购物车、订单与客户,再用一组可扩展的工作流串联"创建 → 兑换 → 加购 → 结算扣款 → 认领"全链路。其核心设计值得借鉴:

  • 余额不落库、流水即账本,保证每一分钱的变动可追溯;
  • 行级锁 + 确定性锁序,在 READ COMMITTED 下消灭并发超扣;
  • createHook扩展点 + 模块链接,让开发者不 fork 即可注入自定义逻辑。

如果你正在 Medusa 项目中规划礼品卡或余额体系,可以直接启用该插件,并参考 src/workflows 下的实现,在其基础上定制自己的忠诚度玩法。

【免费下载链接】medusaThe world's most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa

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

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

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

立即咨询