拓冰建站拓冰建站
首页 / 资讯中心 / 正文

如何在 Medusa 中实现捆绑商品(Bundled Products)功能?

如何在 Medusa 中实现捆绑商品Bundled Products功能【免费下载链接】medusaThe worlds most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa如果你想在 Medusa 电商应用中销售捆绑商品——把多个商品组合成一个 Bundle让顾客一起购买、按各自商品选规格variant并且可以分开履约——内置的 Inventory Kit 只能覆盖一部分场景它支持多件商品共用一个商品变体的库存但不支持给 Bundle 单独定价也不支持把 Bundle 内的商品分开履约。Medusa 官方在 Bundled Products Recipe 中给出的做法是用自定义的 Bundled Product Module Workflow API 路由把完整的捆绑商品功能搭出来。这篇文章按照官方示例教程 Implement Bundled Products in Medusa 的路径展开带你完成定义 Bundle 数据模型并建立数据库迁移、把 Bundle 与现有 Product 模型建立 Link、实现创建捆绑商品加入购物车从购物车移除三条核心流程、并在 Medusa Admin 与 Next.js Starter Storefront 中完成对接。完整代码较长时可对照仓库中的示例教程文档补全。准备条件与安装 Medusa 应用官方示例要求的环境Node.js v20.19.0 或 v22.12.0Next.js Starter Storefront 指南 另注明需低于 v25Git CLI 工具PostgreSQL执行安装命令npx create-medusa-applatest安装过程会先询问项目名当被问及是否安装 Next.js Starter Storefront 时选择 Yes。安装完成后得到一个 monorepository后端在apps/backend目录商店端在apps/storefront目录。安装成功后会自动打开 Medusa Admin 并提示创建管理员用户创建后即可登录。若安装报错参考 create-medusa-app 排错指南。后续说明中后端定制的文件路径都相对于apps/backend目录商店端文件路径相对于apps/storefront目录。创建 Bundled Product Module在src/modules/bundled-product下创建自定义模块。它包含两个数据模型、一个服务和模块定义。定义 Bundle 与 BundleItem 数据模型创建src/modules/bundled-product/models/bundle.tsimport { model } from medusajs/framework/utils import { BundleItem } from ./bundle-item export const Bundle model.define(bundle, { id: model.id().primaryKey(), title: model.text(), items: model.hasMany(() BundleItem, { mappedBy: bundle, }), })创建src/modules/bundled-product/models/bundle-item.tsimport { model } from medusajs/framework/utils import { Bundle } from ./bundle export const BundleItem model.define(bundle_item, { id: model.id().primaryKey(), quantity: model.number().default(1), bundle: model.belongsTo(() Bundle, { mappedBy: items, }), })Bundle表示捆绑商品本身id、title、与BundleItem的一对多关系BundleItem表示 Bundle 内的一个条目quantity默认1。创建模块服务与模块定义创建src/modules/bundled-product/service.ts。服务继承MedusaService后会为两个数据模型自动生成 CRUD 方法如createBundles、retrieveBundleItem、deleteBundles无需手写import { MedusaService } from medusajs/framework/utils import { Bundle } from ./models/bundle import { BundleItem } from ./models/bundle-item export default class BundledProductModuleService extends MedusaService({ Bundle, BundleItem, }) { }创建src/modules/bundled-product/index.ts导出模块定义。模块名只能是字母数字和下划线这里用bundledProductimport { Module } from medusajs/framework/utils import BundledProductsModuleService from ./service export const BUNDLED_PRODUCT_MODULE bundledProduct export default Module(BUNDLED_PRODUCT_MODULE, { service: BundledProductsModuleService, })在medusa-config.ts中把模块注册进modules数组resolve指向模块目录module.exports defineConfig({ // ... modules: [ { resolve: ./src/modules/bundled-product, }, ], })生成并执行数据库迁移数据模型对应数据库表需要生成迁移文件。在项目目录运行db:generate后跟模块名npx medusa db:generate bundledProduct执行后src/modules/bundled-product下会出现migrations目录。再把迁移应用到数据库npx medusa db:migrate此时Bundle和BundleItem对应的表已在数据库中创建。将 Bundle 与 Product 模型建立 LinkMedusa 的模块之间是隔离的不能直接在模块间建外键关系而是通过 Link 关联不同模块的数据模型。这里需要两条 LinkBundle↔ Product 模块的Product让 Bundle 拥有一个真正的 Medusa 商品从而复用价格、销售渠道等现成能力BundleItem↔Product让每个 Bundle 条目关联一个已有商品顾客购买时从该商品的 variants 中选择。Link 定义在src/links目录下。创建src/links/bundle-product.tsimport { defineLink } from medusajs/framework/utils import ProductModule from medusajs/medusa/product import BundledProductsModule from ../modules/bundled-product export default defineLink( BundledProductsModule.linkable.bundle, ProductModule.linkable.product )创建src/links/bundle-item-product.ts。第一个参数用对象形式并设置isList: true表示一个商品可关联多个 Bundle 条目一对多import { defineLink } from medusajs/framework/utils import ProductModule from medusajs/medusa/product import BundledProductsModule from ../modules/bundled-product export default defineLink( { linkable: BundledProductsModule.linkable.bundleItem, isList: true, }, ProductModule.linkable.product )每条 Link 会在数据库中建一张存放关联 ID 的表因此再运行一次迁移npx medusa db:migrate创建创建捆绑商品工作流工作流只需用自己实现前两个 step其余createProductsWorkflow、createRemoteLinkStep、useQueryGraphStep都由medusajs/medusa/core-flows包提供。createBundleStep创建src/workflows/steps/create-bundle.ts。注意 step 函数必须返回StepResponse第二个参数是传给补偿函数compensation function的数据——工作流执行失败时用它回滚本步骤的副作用import { createStep, StepResponse } from medusajs/framework/workflows-sdk import BundledProductModuleService from ../../modules/bundled-product/service import { BUNDLED_PRODUCT_MODULE } from ../../modules/bundled-product type CreateBundleStepInput { title: string } export const createBundleStep createStep( create-bundle, async ({ title }: CreateBundleStepInput, { container }) { const bundledProductModuleService: BundledProductModuleService container.resolve(BUNDLED_PRODUCT_MODULE) const bundle await bundledProductModuleService.createBundles({ title, }) return new StepResponse(bundle, bundle.id) }, async (bundleId, { container }) { if (!bundleId) { return } const bundledProductModuleService: BundledProductModuleService container.resolve(BUNDLED_PRODUCT_MODULE) await bundledProductModuleService.deleteBundles(bundleId) } )createBundleItemsStep创建src/workflows/steps/create-bundle-items.ts输入是 Bundle ID 和要创建的条目数组补偿逻辑用删除条目实现import { createStep, StepResponse } from medusajs/framework/workflows-sdk import { BUNDLED_PRODUCT_MODULE } from ../../modules/bundled-product import BundledProductModuleService from ../../modules/bundled-product/service type CreateBundleItemsStepInput { bundle_id: string items: { quantity: number }[] } export const createBundleItemsStep createStep( create-bundle-items, async ({ bundle_id, items }: CreateBundleItemsStepInput, { container }) { const bundledProductModuleService: BundledProductModuleService container.resolve(BUNDLED_PRODUCT_MODULE) const bundleItems await bundledProductModuleService.createBundleItems( items.map((item) ({ bundle_id, quantity: item.quantity, })) ) return new StepResponse(bundleItems, bundleItems.map((item) item.id)) }, async (itemIds, { container }) { if (!itemIds?.length) { return } const bundledProductModuleService: BundledProductModuleService container.resolve(BUNDLED_PRODUCT_MODULE) await bundledProductModuleService.deleteBundleItems(itemIds) } )组合工作流创建src/workflows/create-bundled-product.ts。工作流按序完成建 Bundle → 建 Bundle 条目 → 建关联的 Medusa 商品 → 建 Bundle↔商品 Link → 建条目↔商品 Link → 用 Query 取回结果。工作流内操作数据必须用transform因为 Medusa 在应用启动时就构建工作流的内部表示而不是执行时import { CreateProductWorkflowInputDTO } from medusajs/framework/types import { createWorkflow, transform, WorkflowResponse } from medusajs/framework/workflows-sdk import { createBundleStep } from ./steps/create-bundle import { createBundleItemsStep } from ./steps/create-bundle-items import { createProductsWorkflow, createRemoteLinkStep, useQueryGraphStep } from medusajs/medusa/core-flows import { BUNDLED_PRODUCT_MODULE } from ../modules/bundled-product import { Modules } from medusajs/framework/utils export type CreateBundledProductWorkflowInput { bundle: { title: string product: CreateProductWorkflowInputDTO items: { product_id: string quantity: number }[] } } export const createBundledProductWorkflow createWorkflow( create-bundled-product, ({ bundle: bundleData }: CreateBundledProductWorkflowInput) { const bundle createBundleStep({ title: bundleData.title, }) const bundleItems createBundleItemsStep({ bundle_id: bundle.id, items: bundleData.items, }) const bundleProduct createProductsWorkflow.runAsStep({ input: { products: [bundleData.product], }, }) createRemoteLinkStep([{ [BUNDLED_PRODUCT_MODULE]: { bundle_id: bundle.id, }, [Modules.PRODUCT]: { product_id: bundleProduct[0].id, }, }]) const bundleProducttemLinks transform({ bundleData, bundleItems, }, (data) { return data.bundleItems.map((item, index) ({ [BUNDLED_PRODUCT_MODULE]: { bundle_item_id: item.id, }, [Modules.PRODUCT]: { product_id: data.bundleData.items[index].product_id, }, })) }) createRemoteLinkStep(bundleProducttemLinks).config({ name: create-bundle-product-items-links, }) // retrieve bundled product with items const { data } useQueryGraphStep({ entity: bundle, fields: [*, items.*], filters: { id: bundle.id, }, }) return new WorkflowResponse(data[0]) } )暴露管理端 API 路由在src/api/admin/bundled-products/route.ts创建 POST 路由执行上面的工作流。以/admin开头的路由默认受保护只有已认证的管理员能访问import { AuthenticatedMedusaRequest, MedusaResponse, } from medusajs/framework/http import { z } from medusajs/framework/zod import { AdminCreateProduct, } from medusajs/medusa/api/admin/products/validators import { createBundledProductWorkflow, CreateBundledProductWorkflowInput, } from ../../../workflows/create-bundled-product export const PostBundledProductsSchema z.object({ title: z.string(), product: AdminCreateProduct(), items: z.array(z.object({ product_id: z.string(), quantity: z.number(), })), }) type PostBundledProductsSchema z.infertypeof PostBundledProductsSchema export async function POST( req: AuthenticatedMedusaRequestPostBundledProductsSchema, res: MedusaResponse ) { const { result: bundledProduct, } await createBundledProductWorkflow(req.scope) .run({ input: { bundle: req.validatedBody, } as CreateBundledProductWorkflowInput, }) res.json({ bundled_product: bundledProduct, }) }注意自 Medusa v2.13.0 起Zod 应从medusajs/framework/zod导入。在src/api/middlewares.ts中注册请求体校验中间件import { defineMiddlewares, validateAndTransformBody, } from medusajs/framework/http import { PostBundledProductsSchema } from ./admin/bundled-products/route export default defineMiddlewares({ routes: [ { matcher: /admin/bundled-products, methods: [POST], middlewares: [ validateAndTransformBody(PostBundledProductsSchema), ], }, ], })再给同一个文件追加 GET 路由用 Query 跨模块取回 Bundle 列表queryConfig携带分页与字段配置export async function GET( req: AuthenticatedMedusaRequest, res: MedusaResponse ) { const query req.scope.resolve(query) const { data: bundledProducts, metadata: { count, take, skip } {}, } await query.graph({ entity: bundle, ...req.queryConfig, }) res.json({ bundled_products: bundledProducts, count: count || 0, limit: take || 15, offset: skip || 0, }) }并在src/api/middlewares.ts的routes数组中增加 GET 的 Query 配置。defaults指定默认取回 Bundle、其关联商品以及各条目及其商品defaultLimit为每页条数import { validateAndTransformQuery } from medusajs/framework/http import { createFindParams } from medusajs/medusa/api/utils/validators // 在 routes 数组中追加 { matcher: /admin/bundled-products, methods: [GET], middlewares: [ validateAndTransformQuery(createFindParams(), { defaults: [ id, title, product.*, items.*, items.product.*, ], isList: true, defaultLimit: 15, }), ], }在 Medusa Admin 中管理捆绑商品后端就绪后在管理端建一个页面让管理员查看和创建 Bundle。完整代码以官方示例教程的 Step 7、Step 8 为准关键步骤如下初始化 JS SDK。创建src/admin/lib/sdk.tsJS SDK 已随 Medusa 应用安装import Medusa from medusajs/js-sdk export const sdk new Medusa({ baseUrl: http://localhost:9000, debug: process.env.NODE_ENV development, auth: { type: session, }, })管理端定制使用session认证类型。创建 UI 路由。创建src/admin/routes/bundled-products/page.tsx导出页面组件和defineRouteConfig含label与icon这样侧边栏会出现 Bundled Products 入口。页面内用 Medusa UI 的DataTableuseDataTable渲染表格通过sdk.client.fetch(/admin/bundled-products, { method: GET, query: { limit, offset } })拉取分页数据。创建表单。创建src/admin/components/create-bundled-product.tsx以FocusModal承载表单Bundle 标题输入框 每个条目的商品下拉选择数据来自sdk.admin.product.list滚动到底部时增量加载与数量输入。提交时handleCreate调用sdk.client.fetch(/admin/bundled-products, { method: POST, body })请求体形如{ title, product: { title, options: [{ title: Default, values: [default] }], status: published, variants: [{ title, prices: [], // 教程未在此处设置价格 options: { Default: default }, manage_inventory: false, }], }, items: items.map((item) ({ product_id: item.product_id, quantity: item.quantity, })), }成功后关闭弹窗、toast.success提示并刷新bundled-products查询。最后把该组件放进page.tsx的DataTable.Toolbar中作为 Create 按钮。编辑关联商品。创建 Bundle 后通过表格中的 View Product 链接进入关联商品页面完成两件事设置商品所属的销售渠道否则顾客看不到设置 shipping profile顾客结账时才能选到对应运费选项价格可以在此页的 variant 上编辑。将捆绑商品加入购物车商店端加购时顾客要为 Bundle 内每个条目选中一个 variant例如相机包的 Black 或 Blue。后端流程是校验并准备条目 → 对购物车加锁 → 调用addToCartWorkflow→ 取回更新的购物车 → 释放锁。只需自实现prepareBundleCartDataStep。创建src/workflows/steps/prepare-bundle-cart-data.ts。它对每个 Bundle 条目做两步校验未选 variant 抛错、所选 variant 不属于该条目商品也抛错然后产出购物车条目数量为条目在 Bundle 中的数量 × 加入的 Bundle 数量并把bundle_id和 Bundle 数量写入 line item 的metadata——这一步是后面整组移除的依据import { InferTypeOf, ProductDTO } from medusajs/framework/types import { Bundle } from ../../modules/bundled-product/models/bundle import { createStep, StepResponse } from medusajs/framework/workflows-sdk import { MedusaError } from medusajs/framework/utils import { BundleItem } from ../../modules/bundled-product/models/bundle-item type BundleItemWithProduct InferTypeOftypeof BundleItem { product: ProductDTO } export type PrepareBundleCartDataStepInput { bundle: InferTypeOftypeof Bundle { items: BundleItemWithProduct[] } quantity: number items: { item_id: string variant_id: string }[] } export const prepareBundleCartDataStep createStep( prepare-bundle-cart-data, async ({ bundle, quantity, items }: PrepareBundleCartDataStepInput) { const bundleItems bundle.items.map((item: BundleItemWithProduct) { const selectedItem items.find((i) i.item_id item.id) if (!selectedItem) { throw new MedusaError( MedusaError.Types.INVALID_DATA, No variant selected for bundle item ${item.id} ) } const variant item.product.variants.find((v) v.id selectedItem.variant_id ) if (!variant) { throw new MedusaError( MedusaError.Types.INVALID_DATA, Variant ${ selectedItem.variant_id } is invalid for bundle item ${item.id} ) } return { variant_id: selectedItem.variant_id, quantity: item.quantity * quantity, metadata: { bundle_id: bundle.id, quantity: quantity, }, } }) return new StepResponse(bundleItems) } )可选分支给条目自定义价格。若要绕过 variant 默认价格在上面返回对象中加unit_price例如unit_price: 100条目就会按该价格加入币种跟随购物车币种购物车币种为usd时即 $100。创建src/workflows/add-bundle-to-cart.ts组合完整流程import { createWorkflow, transform, WorkflowResponse, } from medusajs/framework/workflows-sdk import { acquireLockStep, addToCartWorkflow, releaseLockStep, useQueryGraphStep, } from medusajs/medusa/core-flows import { prepareBundleCartDataStep, PrepareBundleCartDataStepInput, } from ./steps/prepare-bundle-cart-data type AddBundleToCartWorkflowInput { cart_id: string bundle_id: string quantity: number items: { item_id: string variant_id: string }[] } export const addBundleToCartWorkflow createWorkflow( add-bundle-to-cart, ({ cart_id, bundle_id, quantity, items }: AddBundleToCartWorkflowInput) { const { data } useQueryGraphStep({ entity: bundle, fields: [ id, items.*, items.product.*, items.product.variants.*, ], filters: { id: bundle_id, }, options: { throwIfKeyNotFound: true, }, }) const itemsToAdd prepareBundleCartDataStep({ bundle: data[0], quantity, items, } as unknown as PrepareBundleCartDataStepInput) acquireLockStep({ key: cart_id, timeout: 2, ttl: 10, }) addToCartWorkflow.runAsStep({ input: { cart_id, items: itemsToAdd, }, }) const { data: updatedCarts } useQueryGraphStep({ entity: cart, filters: { id: cart_id }, fields: [id, items.*], }).config({ name: refetch-cart }) releaseLockStep({ key: cart_id, }) return new WorkflowResponse(updatedCarts[0]) } )创建商店端路由src/api/store/carts/[id]/line-item-bundles/route.ts并在src/api/middlewares.ts中对POST /store/carts/:id/line-item-bundles注册validateAndTransformBody(PostCartsBundledLineItemsSchema)中间件。请求体字段bundle_id必填、quantity可选默认1、items每项含item_id与variant_idimport { MedusaRequest, MedusaResponse } from medusajs/framework/http import { z } from medusajs/framework/zod import { addBundleToCartWorkflow, } from ../../../../../workflows/add-bundle-to-cart export const PostCartsBundledLineItemsSchema z.object({ bundle_id: z.string(), quantity: z.number().default(1), items: z.array(z.object({ item_id: z.string(), variant_id: z.string(), })), }) type PostCartsBundledLineItemsSchema z.infer typeof PostCartsBundledLineItemsSchema export async function POST( req: MedusaRequestPostCartsBundledLineItemsSchema, res: MedusaResponse ) { const { result: cart } await addBundleToCartWorkflow(req.scope) .run({ input: { cart_id: req.params.id, bundle_id: req.validatedBody.bundle_id, quantity: req.validatedBody.quantity || 1, items: req.validatedBody.items, }, }) res.json({ cart, }) }此外再建一个查询路由src/api/store/bundle-products/[id]/route.tsGET/store/bundle-products/:id它用 Query 取回 Bundle 及其条目的商品、variants、options并传入QueryContext({ region_id, currency_code })来自 query 参数保证取回正确的价格供商店端展示每个条目可选项与价格。详见示例教程 Step 10。在商店端展示并购买捆绑商品对apps/storefrontNext.js Starter Storefront的改动完整代码见官方示例教程 Step 11核心改动点src/lib/data/products.ts新增getBundleProduct(id, { currency_code, region_id })请求/store/bundle-products/:id同时把listProducts的返回类型扩展为每个产品可能带bundle字段。商品详情页src/app/[countryCode]/(main)/products/[handle]/page.tsx调用listProducts时传fields: *bundle若产品带bundle再调getBundleProduct取回完整 Bundle。src/lib/data/cart.ts新增addBundleToCart先getOrSetCart(countryCode)拿到购物车再POST /store/carts/:id/line-item-bundles成功后revalidateTag刷新carts与fulfillment缓存。新建src/modules/products/components/bundle-actions/index.tsxuse client列出 Bundle 内每个商品缩略图、标题、价格单品只有一个 variant 时自动预选否则展示OptionSelect让顾客选规格全部条目都选定后按钮文案从 Select all variants 变为 Add bundle to cart 并可用。src/modules/products/templates/product-actions-wrapper/index.tsx当bundleprop 存在时直接渲染BundleActions并把bundleprop 沿ProductTemplate一路从商品详情页传入。从购物车移除整个捆绑商品教程的最后一个功能是顾客移除 Bundle 内任意一条购物车条目时把该 Bundle 的所有条目一起移除。工作流src/workflows/remove-bundle-from-cart.ts全部由core-flows的现成步骤组合用useQueryGraphStep取回购物车及其items→ 用transform过滤出metadata.bundle_id等于目标 Bundle 的条目 ID →acquireLockStep加锁 →deleteLineItemsWorkflow.runAsStep({ input: { cart_id, ids } })删除 → 再取回更新后的购物车 →releaseLockStep释放锁最后WorkflowResponse返回更新后的购物车。API 路由src/api/store/carts/[id]/line-item-bundles/[bundle_id]/route.ts导出DELETE处理器执行上述工作流并返回{ cart }。商店端在src/lib/data/cart.ts增加removeBundleFromCart(bundleId)DELETE /store/carts/:cartId/line-item-bundles/:bundleId给src/modules/common/components/delete-button/index.tsx的DeleteButton增加bundle_idprophandleDelete中若有bundle_id就走removeBundleFromCart否则走原有deleteLineItem。在src/modules/cart/components/item/index.tsx与src/modules/layout/components/cart-dropdown/index.tsx中把bundle_id{item.metadata?.bundle_id as string}传给DeleteButton按钮文案在条目属于 Bundle 时显示 Remove bundle。验证功能是否生效启动两个服务# Medusa application项目目录 npm run dev# Storefrontapps/storefront 目录 npm run dev若使用 yarn 初始化改用对应的 yarn 命令。按下面的顺序核对各阶段结果管理端打开http://localhost:9000/app登录侧边栏出现 Bundled Products 入口首次进入时表格为空尚未创建 Bundle。创建 Bundle在 Bundled Products 页点 Create填标题、为每个条目选商品并填数量提交后弹窗关闭并提示 Bundled product created successfully表格中出现该 Bundle。建议在创建前先把会进 Bundle 的商品如 Camera、Camera Bag建好。编辑关联商品通过表格 View Product 进入关联商品设置销售渠道与 shipping profile。商店端打开http://localhost:8000进入 Store 页面商品列表能看到 Bundle 的关联商品点进去能看到 Items in Bundle 及各条目。若看不到商品检查 Bundle 关联商品和 Bundle 内各商品是否都加入了默认销售渠道教程明确列出的排查点。加购为所有条目选完规格后 Add to cart 按钮可用点击后购物车中出现各 Bundle 条目按条目各自的 variant 与数量。下单与分开履约用含 Bundle 条目的购物车下单后回到 Medusa Admin可以对 Bundle 内的条目分别履约fulfill。整组移除购物车中 Bundle 条目显示 Remove bundle 按钮点击任一条该 Bundle 的所有条目都被移出购物车。限制与下一步需要注意的边界内置 Inventory Kit 方案不支持给 Bundle 单独定价和分开履约 Bundle 内商品这也是本文走自定义模块的原因Bundle 关联商品的价格默认在商品页 variant 上设置教程默认代码未设置prices。加入购物车的条目按 variant 默认价格进入购物车要自定义价格需在prepareBundleCartDataStep中加入unit_price币种以购物车币种为准。商店端展示前提是 Bundle 关联商品与 Bundle 内商品都加入了相应销售渠道。官方教程给出的后续扩展方向可按需实现本文未展开增加 API 路由以更新购物车中的 Bundle 及其条目在 Medusa Admin 的 Bundled Products 页补充更多 CRUD 管理功能定制商店端让购物车把 Bundle 作为一个整体展示而不是拆成多条目用自定义逻辑为捆绑商品定价。完整的分步代码、截图与细节说明见仓库内教程Implement Bundled Products in Medusa。【免费下载链接】medusaThe worlds most flexible commerce platform for agents and developers项目地址: https://gitcode.com/GitHub_Trending/me/medusa创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门