mcpField
M
medusa-development

medusa-development

github.com/finsilabs开发6082 次安装6 天前更新
代码仓库

关于 medusa-development

medusa-development 是帮助开发者构建和扩展 Medusa.js 无头电商后端的技能。它涵盖 Medusa v2 模块、服务、事件订阅器、API 路由、工作流及外部系统集成。

主要功能

  1. 01搭建 Medusa v2 项目
  2. 02创建自定义模块、服务和数据模型
  3. 03添加 API 路由与事件订阅器
  4. 04构建工作流和定时任务
  5. 05扩展管理后台并连接外部系统

适用场景

  1. 01为无头电商店铺创建后端
  2. 02实现自定义业务逻辑与 API
  3. 03根据订单事件触发自动化
  4. 04将 Medusa 接入 ERP、CMS 或支付服务

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill medusa-development

安装后在 Claude Code 中输入/medusa-development确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

tile.json
evals/custom-api-routes-with-validation/
criteria.json
task.md
evals/custom-module-architecture/
criteria.json
task.md
evals/event-subscribers-and-workflows/
criteria.json
task.md

详细介绍

Medusa.js 开发

概述

使用 Medusa.js 构建和扩展无头电商后端,包括自定义服务、订阅器(事件处理器)、API 路由扩展、带迁移的自定义实体以及模块架构。本技能涵盖 Medusa v2 项目设置、依赖注入容器、自定义工作流、管理界面扩展,以及将 Medusa 连接到店面、ERP 和支付服务提供商的集成模式。

何时使用此技能

  • 使用 Medusa 搭建新的无头电商后端时
  • 将自定义业务逻辑构建为 Medusa 服务和工作流时
  • 为店面或管理界面扩展 Medusa API,添加自定义端点时
  • 通过订阅器实现事件驱动的自动化(例如,下单后发送邮件)时
  • 将外部系统(ERP、CMS、履约系统)与 Medusa 集成时

前提条件和平台说明

此技能适用于自定义/无头店面(Node.js、Python 或类似后端)。代码示例使用 TypeScript/Node.js,可适配任何技术栈。

Shopify:Shopify Hydrogen 是 Shopify 的无头框架。使用 Shopify 作为电商后端并搭配自定义前端,或将 Shopify 与其他同类最佳服务组合使用时,MACH/可组合模式同样适用。 WooCommerce:WooCommerce 可通过其 REST API 和 WPGraphQL 作为无头后端。这些模式适用于将前端与 WordPress 解耦的场景。 Magento:Magento 的 GraphQL API 和 PWA Studio 支持无头架构。在 MACH 技术栈中将 Magento 用作后端服务时,这些可组合模式同样适用。

你需要:

  • Node.js 18+(或适配你的后端语言)
  • PostgreSQL(或你偏好的关系型数据库)
  • 用于缓存/队列的 Redis
  • Stripe 账户和 API 密钥
  • 邮件发送服务(SendGrid、AWS SES 或 Postmark)

核心说明

  1. 设置 Medusa 项目

    # Create a new Medusa project
    npx create-medusa-app@latest my-store
    
    # Project structure (Medusa v2)
    # my-store/
    # ├── src/
    # │   ├── api/           # Custom API routes
    # │   ├── jobs/          # Scheduled jobs
    # │   ├── links/         # Module links
    # │   ├── modules/       # Custom modules
    # │   ├── subscribers/   # Event subscribers
    # │   └── workflows/     # Custom workflows
    # ├── medusa-config.ts
    # └── package.json
    
    # Start the development server
    npx medusa develop
    

    配置 medusa-config.ts:

    import { defineConfig, loadEnv } from '@medusajs/framework/utils';
    
    loadEnv(process.env.NODE_ENV || 'development', process.cwd());
    
    export default defineConfig({
      projectConfig: {
        databaseUrl: process.env.DATABASE_URL,
        redisUrl: process.env.REDIS_URL,
        http: {
          storeCors: process.env.STORE_CORS || 'http://localhost:8000',
          adminCors: process.env.ADMIN_CORS || 'http://localhost:9000',
          authCors: process.env.AUTH_CORS || 'http://localhost:8000,http://localhost:9000',
        },
      },
      modules: [
        // Register custom modules here
      ],
    });
    
  2. 使用服务创建自定义模块

    // src/modules/loyalty/service.ts
    import { MedusaService } from '@medusajs/framework/utils';
    import { LoyaltyPoints } from './models/loyalty-points';
    
    class LoyaltyModuleService extends MedusaService({
      LoyaltyPoints,
    }) {
      async awardPoints(customerId: string, points: number, reason: string) {
        return await this.createLoyaltyPointss({
          customer_id: customerId,
          points,
          reason,
          type: 'earned',
        });
      }
    
      async redeemPoints(customerId: string, points: number) {
        const balance = await this.getBalance(customerId);
        if (balance < points) {
          throw new Error(`Insufficient points. Balance: ${balance}, requested: ${points}`);
        }
    
        return await this.createLoyaltyPointss({
          customer_id: customerId,
          points: -points,
          reason: 'redeemed',
          type: 'redeemed',
        });
      }
    
      async getBalance(customerId: string): Promise<number> {
        const records = await this.listLoyaltyPointss({
          customer_id: customerId,
        });
        return records.reduce((sum, r) => sum + r.points, 0);
      }
    }
    
    export default LoyaltyModuleService;
    

    定义数据模型:

    // src/modules/loyalty/models/loyalty-points.ts
    import { model } from '@medusajs/framework/utils';
    
    export const LoyaltyPoints = model.define('loyalty_points', {
      id: model.id().primaryKey(),
      customer_id: model.text(),
      points: model.number(),
      reason: model.text(),
      type: model.enum(['earned', 'redeemed', 'adjusted']),
    });
    

    注册模块:

    // src/modules/loyalty/index.ts
    import LoyaltyModuleService from './service';
    import { Module } from '@medusajs/framework/utils';
    
    export const LOYALTY_MODULE = 'loyaltyModuleService';
    
    export default Module(LOYALTY_MODULE, {
      service: LoyaltyModuleService,
    });
    
  3. 创建事件订阅器

    // src/subscribers/order-placed.ts
    import type { SubscriberArgs, SubscriberConfig } from '@medusajs/framework';
    import { Modules } from '@medusajs/framework/utils';
    import { LOYALTY_MODULE } from '../modules/loyalty';
    
    export default async function orderPlacedHandler({
      event,
      container,
    }: SubscriberArgs<{ id: string }>) {
      const orderId = event.data.id;
    
      const orderService = container.resolve(Modules.ORDER);
      const loyaltyService = container.resolve(LOYALTY_MODULE);
      const logger = container.resolve('logger');
    
      try {
        const order = await orderService.retrieveOrder(orderId, {
          relations: ['items'],
        });
    
        // Award 1 point per dollar spent
        const pointsToAward = Math.floor(order.total / 100);
    
        if (order.customer_id && pointsToAward > 0) {
          await loyaltyService.awardPoints(
            order.customer_id,
            pointsToAward,
            `Order ${order.display_id}`
          );
          logger.info(`Awarded ${pointsToAward} loyalty points for order ${order.display_id}`);
        }
      } catch (error) {
        logger.error(`Failed to award loyalty points for order ${orderId}: ${error.message}`);
      }
    }
    
    export const config: SubscriberConfig = {
      event: 'order.placed',
    };
    
  4. 添加自定义 API 路由

    // src/api/store/loyalty/route.ts
    import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http';
    import { LOYALTY_MODULE } from '../../../modules/loyalty';
    
    // GET /store/loyalty — get current customer's loyalty balance
    export async function GET(req: MedusaRequest, res: MedusaResponse) {
      const customerId = req.auth_context?.actor_id;
    
      if (!customerId) {
        return res.status(401).json({ message: 'Authentication required' });
      }
    
      const loyaltyService = req.scope.resolve(LOYALTY_MODULE);
      const balance = await loyaltyService.getBalance(customerId);
      const history = await loyaltyService.listLoyaltyPointss(
        { customer_id: customerId },
        { order: { created_at: 'DESC' }, take: 20 }
      );
    
      res.json({ balance, history });
    }
    
    // POST /store/loyalty/redeem — redeem points for a discount
    export async function POST(req: MedusaRequest, res: MedusaResponse) {
      const customerId = req.auth_context?.actor_id;
    
      if (!customerId) {
        return res.status(401).json({ message: 'Authentication required' });
      }
    
      const { points } = req.body as { points: number };
    
      if (!points || points <= 0) {
        return res.status(400).json({ message: 'Invalid points amount' });
      }
    
      const loyaltyService = req.scope.resolve(LOYALTY_MODULE);
    
      try {
        const record = await loyaltyService.redeemPoints(customerId, points);
        const newBalance = await loyaltyService.getBalance(customerId);
        res.json({ redeemed: points, newBalance, record });
      } catch (error) {
        res.status(400).json({ message: error.message });
      }
    }
    
  5. 构建自定义工作流

    // src/workflows/award-loyalty-points.ts
    import {
      createWorkflow,
      createStep,
      StepResponse,
    } from '@medusajs/framework/workflows-sdk';
    import { LOYALTY_MODULE } from '../modules/loyalty';
    
    const validatePointsStep = createStep(
      'validate-points',
      async ({ customerId, points }: { customerId: string; points: number }) => {
        if (!customerId) throw new Error('Customer ID required');
        if (points <= 0) throw new Error('Points must be positive');
        return new StepResponse({ customerId, points });
      }
    );
    
    const awardPointsStep = createStep(
      'award-points',
      async (
        { customerId, points, reason }: { customerId: string; points: number; reason: string },
        { container }
      ) => {
        const loyaltyService = container.resolve(LOYALTY_MODULE);
        const record = await loyaltyService.awardPoints(customerId, points, reason);
        return new StepResponse(record, { recordId: record.id });
      },
      // Compensation function for rollback
      async ({ recordId }, { container }) => {
        const loyaltyService = container.resolve(LOYALTY_MODULE);
        await loyaltyService.deleteLoyaltyPoints(recordId);
      }
    );
    
    export const awardLoyaltyPointsWorkflow = createWorkflow(
      'award-loyalty-points',
      (input: { customerId: string; points: number; reason: string }) => {
        const validated = validatePointsStep(input);
        const record = awardPointsStep({
          customerId: validated.customerId,
          points: validated.points,
          reason: input.reason,
        });
        return record;
      }
    );
    
  6. 创建定时任务

    // src/jobs/expire-loyalty-points.ts
    import type { MedusaContainer } from '@medusajs/framework/types';
    import { LOYALTY_MODULE } from '../modules/loyalty';
    
    export default async function expireLoyaltyPointsJob(container: MedusaContainer) {
      const loyaltyService = container.resolve(LOYALTY_MODULE);
      const logger = container.resolve('logger');
    
      // Find points older than 12 months
      const expirationDate = new Date();
      expirationDate.setFullYear(expirationDate.getFullYear() - 1);
    
      const expiredRecords = await loyaltyService.listLoyaltyPointss({
        type: 'earned',
        created_at: { $lt: expirationDate },
      });
    
      let expiredCount = 0;
      for (const record of expiredRecords) {
        if (record.points > 0) {
          await loyaltyService.createLoyaltyPointss({
            customer_id: record.customer_id,
            points: -record.points,
            reason: `Expired: original from ${record.created_at}`,
            type: 'adjusted',
          });
          expiredCount++;
        }
      }
    
      logger.info(`Expired ${expiredCount} loyalty point records.`);
    }
    
    export const config = {
      name: 'expire-loyalty-points',
      schedule: '0 2 * * *', // Daily at 2 AM
    };
    

示例

连接 Next.js 店面
// storefront/lib/medusa-client.ts
import Medusa from '@medusajs/js-sdk';

const medusa = new Medusa({
  baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || 'http://localhost:9000',
  auth: {
    type: 'session',
  },
});

// Fetch products for a collection page
export async function getProducts(collectionId?: string) {
  const { products, count } = await medusa.store.product.list({
    collection_id: collectionId ? [collectionId] : undefined,
    limit: 24,
    fields: '+variants.calculated_price',
  });
  return { products, count };
}

// Add item to cart
export async function addToCart(cartId: string, variantId: string, quantity: number) {
  const { cart } = await medusa.store.cart.createLineItem(cartId, {
    variant_id: variantId,
    quantity,
  });
  return cart;
}

// Fetch loyalty balance (custom endpoint)
export async function getLoyaltyBalance() {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/loyalty`,
    { credentials: 'include' }
  );
  if (!response.ok) throw new Error('Failed to fetch loyalty balance');
  return response.json();
}
自定义支付服务提供商模块
// src/modules/custom-payment/service.ts
import {
  AbstractPaymentProvider,
} from '@medusajs/framework/utils';
import type {
  CreatePaymentProviderSession,
  UpdatePaymentProviderSession,
  ProviderWebhookPayload,
  WebhookActionResult,
} from '@medusajs/framework/types';

class CustomPaymentProviderService extends AbstractPaymentProvider<{}> {
  static identifier = 'custom-payment';

  async initiatePayment(
    data: CreatePaymentProviderSession
  ): Promise<Record<string, unknown>> {
    // Call your payment gateway's API to create a payment session
    const response = await fetch('https://api.custompay.com/v1/sessions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: data.amount,
        currency: data.currency_code,
        metadata: { medusa_cart_id: data.context.cart_id },
      }),
    });
    const session = await response.json();

    return { session_id: session.id, client_token: session.client_token };
  }

  async authorizePayment(
    paymentSessionData: Record<string, unknown>
  ): Promise<{ status: string; data: Record<string, unknown> }> {
    const sessionId = paymentSessionData.session_id as string;

    const response = await fetch(
      `https://api.custompay.com/v1/sessions/${sessionId}`,
      {
        headers: { 'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}` },
      }
    );
    const session = await response.json();

    return {
      status: session.status === 'paid' ? 'authorized' : 'pending',
      data: { ...paymentSessionData, gateway_status: session.status },
    };
  }

  async capturePayment(
    paymentSessionData: Record<string, unknown>
  ): Promise<Record<string, unknown>> {
    const sessionId = paymentSessionData.session_id as string;

    await fetch(`https://api.custompay.com/v1/sessions/${sessionId}/capture`, {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}` },
    });

    return { ...paymentSessionData, captured: true };
  }

  async refundPayment(
    paymentSessionData: Record<string, unknown>,
    refundAmount: number
  ): Promise<Record<string, unknown>> {
    const sessionId = paymentSessionData.session_id as string;

    await fetch(`https://api.custompay.com/v1/sessions/${sessionId}/refund`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.CUSTOM_PAYMENT_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ amount: refundAmount }),
    });

    return { ...paymentSessionData, refunded_amount: refundAmount };
  }

  async cancelPayment(
    paymentSessionData: Record<string, unknown>
  ): Promise<Record<string, unknown>> {
    return { ...paymentSessionData, cancelled: true };
  }

  async deletePayment(
    paymentSessionData: Record<string, unknown>
  ): Promise<Record<string, unknown>> {
    return {};
  }

  async getPaymentStatus(
    paymentSessionData: Record<string, unknown>
  ): Promise<string> {
    return (paymentSessionData.gateway_status as string) || 'pending';
  }

  async getWebhookActionAndData(
    payload: ProviderWebhookPayload
  ): Promise<WebhookActionResult> {
    const event = JSON.parse(payload.rawData as string);

    switch (event.type) {
      case 'payment.captured':
        return { action: 'captured', data: { session_id: event.session_id } };
      case 'payment.failed':
        return { action: 'failed', data: { session_id: event.session_id } };
      default:
        return { action: 'not_supported' };
    }
  }
}

export default CustomPaymentProviderService;

最佳实践

  • 使用模块系统实现封装 —— 每个领域(忠诚度、自定义履约、分析)都应使用独立模块,并配有自己的服务、模型和迁移
  • 始终为工作流步骤添加补偿函数 —— 如果某个步骤可能失败,补偿函数会回滚前一步产生的副作用,以便妥善恢复错误
  • 从容器解析依赖,而不是通过导入引入 —— 对服务使用 container.resolve(),以遵循 DI 配置并支持测试
  • 使用订阅器处理副作用,而非核心逻辑 —— 订阅器应触发通知、同步外部系统并记录事件;将订单处理逻辑保留在工作流中
  • 使用 Zod 验证 API 输入 —— 为请求正文定义 Zod 模式,并使用中间件在处理程序运行前进行验证
  • 为架构变更编写迁移 —— 切勿手动修改数据库;使用 Medusa 的迁移系统,确保变更能在不同环境中重现
  • 使用 Medusa Admin SDK 扩展管理界面 —— 使用 @medusajs/admin-sdk 包通过自定义小组件扩展管理仪表板,而不是另行构建界面
  • 锁定 Medusa 版本 —— Medusa v2 仍在快速演进;在 package.json 中锁定版本,并在升级前进行测试

常见问题

问题解决方案
运行时找不到自定义模块在 medusa-config.ts 的 modules 数组中注册该模块,并运行 npx medusa db:migrate 以应用模型更改
订阅器已触发,但数据已过时订阅器以异步方式运行;请在订阅器处理函数中重新获取实体,而不要依赖事件负载中的数据
API 路由返回 404确保文件路径与 URL 模式匹配:src/api/store/loyalty/route.ts 映射到 /store/loyalty;检查处理函数是否缺少 export
工作流步骤失败且未回滚每个会产生副作用的步骤都需要将补偿函数作为 createStep 的第二个参数
合并后数据库迁移冲突拉取更改后运行 npx medusa db:migrate;如果迁移发生冲突,请生成新的迁移来解决差异
店面出现 CORS 错误在 medusa-config.ts 中配置 storeCors,将店面来源 URL(包括端口)加入其中

相关技能

  • @product-data-modeling
  • @stripe-integration
  • @ecommerce-caching
  • @ecommerce-seo
  • @erp-integration

评论

支持 Markdown