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 |
value | bigNumber | 礼品卡面值 |
code | text,唯一且可搜索 | 兑换码 |
currency_code | text,可搜索 | ISO 三位货币代码 |
expires_at | dateTime,可空 | 过期时间,为空表示永不过期 |
reference/reference_id | text,可空 | 来源资源类型与 ID(如order与订单 ID) |
line_item_id | text,可空 | 作为商品被购买时的行项目 ID |
note/metadata | text / json | 备注与自定义元数据 |
对应的类型定义ModuleGiftCard/ModuleCreateGiftCard/ModuleUpdateGiftCard位于 types/loyalty/module.ts,其中ModuleCreateGiftCard的code注释明确说明"未提供时将自动生成"。
Store Credit 模块:账户 + 流水账本
Store Credit 模块的服务实现在 modules/store-credit/service.ts,是插件中业务逻辑最重的部分。它基于两个模型:
- store-credit-account.ts:表名
store_credit_account,ID 前缀sc_acc,包含code、currency_code、customer_id、metadata,并对customer_id + currency_code建立了唯一索引(仅当customer_id IS NOT NULL时生效),保证同一客户同一币种只能有一个账户。 - account-transaction.ts:流水表,
type取credit/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)计算余额,分别统计credits与debits总额,返回ModuleAccountStats(含balance/credits/debits)。这意味着每一笔变动都会留下不可篡改的流水痕迹,余额永远是"算出来的"。
服务还通过白名单机制限制可更新字段:updateStoreCreditAccounts只允许更新id与metadata,因为"变更客户或货币代码会导致余额跟踪异常"。此外,由于MedusaService按模型名生成方法(deleteAccountTransactions),而公开接口约定为deleteTransactions,服务中实现了转发方法避免运行时TypeError(该问题在代码注释中有完整说明)。
安装与配置
依赖与 peer 范围
从 package.json 可以看到,插件的运行时 peer 依赖包括@medusajs/medusa、@medusajs/framework、@medusajs/dashboard等,且除核心包外的 peer 依赖均标记为 optional。React 系依赖(react、react-dom、react-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/O、1/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 中code为z.string().optional(),即不填则自动生成、填了则使用自定义值。
核心业务流程:创建 → 兑换 → 加购 → 下单扣款 → 认领
创建礼品卡(Admin)
Admin 侧POST /admin/gift-cards(api/admin/gift-cards/route.ts)将请求体交给createGiftCardsWorkflow,创建后再通过 Query Graph 回读返回完整对象。创建 schema 要求currency_code、value(z.number().min(1)),可选code、status(默认pending)、expires_at、reference、reference_id、line_item_id、note、metadata。
兑换礼品卡(Store)
兑换发生在客户侧。redeemGiftCardWorkflow(workflows/gift-cards/workflows/redeem-gift-card.ts)的执行链路是:
- 按
gift_card_id查询礼品卡; - 查询是否已关联 store credit 账户;
validateGiftCardRedeemStep校验——已兑换(status === REDEEMED)或已有关联账户则报错;createStoreCreditAccountsStep按礼品卡币种创建匿名 store credit 账户;createLinksWorkflow建立gift_card ↔ store_credit_account链接;creditAccountsWorkflow以reference: "gift_card"、reference_id: 兑换码记入一笔 credit 流水,note 为 "Gift card redemption";updateGiftCardsWorkflow将礼品卡状态置为redeemed;- 返回带余额与流水明细的账户。
添加到购物车
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的逻辑:
- 校验购物车上所有礼品卡未过期(
validateGiftCardsNotExpiredStep,防止过期余额在结算时被扣走); - 通过
gift_card_store_credit_account链接把 credit line 映射到 store credit 账户; - 对
reference为store-credit或gift-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-cards、customer-store-credit-widget、order-gift-cards-widget等注入式 widget(见 src/admin/widgets)。
构建与工程化(2.18.0 / 2.14.0)
- 2.18.0 调整了插件构建流程,以处理"插件构建过程中循环依赖(cyclic deps)"问题,并顺带修复
db命令在容器初始化失败时退出码不为 1 的问题; - 2.14.0(开源版本)完成了若干工程化收尾:迁移到Zod v4(
migrate to Zod v4)、为服务方法补充 tsdocs 并新增index.ts模型导出、移除礼品卡删除操作并清理代码、修复礼品卡商品分区显示与过期日期错误提示; - 2.14.2 移除未使用的参数并导出 step;
- 2.17.2 补充了 package
bugs元数据。
数据关联:Module Links
插件通过模块链接把礼品卡、账户与核心模块打通,链接文件集中在 src/links:
| 链接文件 | 关联对象 | 语义 |
|---|---|---|
| cart-gift-cards-link.ts | cart ↔ gift_card | 购物车应用了哪些礼品卡 |
| order-gift-cards-link.ts | order ↔ gift_card | 订单关联的礼品卡 |
| customer-store-credit-account-link.ts | store_credit_account ↔ customer | 客户拥有的账户(只读、一对一) |
| gift-card-store-credit.ts | gift_card ↔ store_credit_account | 礼品卡背后的余额账户 |
| order-line-item-gift-card-link.ts | order_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.0 | LayoutComposer 插件注入区、GMD 货币、locale 感知的货币格式化、react-router-dom 版本对齐 |
| 2.17.2 | 自定义兑换码支持(Adding an option for custom codes in gift-cards)、bugs 元数据 |
| 2.18.0 | IRT / AOA 货币、Admin 认证类型适配(ADMIN_AUTH_TYPE=jwt)、构建循环依赖修复 |
| 2.19.0 | react-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),仅供参考