关于 medusa-development
medusa-development 是帮助开发者构建和扩展 Medusa.js 无头电商后端的技能。它涵盖 Medusa v2 模块、服务、事件订阅器、API 路由、工作流及外部系统集成。
主要功能
- 01搭建 Medusa v2 项目
- 02创建自定义模块、服务和数据模型
- 03添加 API 路由与事件订阅器
- 04构建工作流和定时任务
- 05扩展管理后台并连接外部系统
适用场景
- 01为无头电商店铺创建后端
- 02实现自定义业务逻辑与 API
- 03根据订单事件触发自动化
- 04将 Medusa 接入 ERP、CMS 或支付服务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill medusa-development安装后在 Claude Code 中输入/medusa-development确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
tile.jsonevals/custom-api-routes-with-validation/criteria.jsontask.mdevals/custom-module-architecture/criteria.jsontask.mdevals/event-subscribers-and-workflows/criteria.jsontask.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)
核心说明
-
设置 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 ], }); -
使用服务创建自定义模块
// 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, }); -
创建事件订阅器
// 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', }; -
添加自定义 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 }); } } -
构建自定义工作流
// 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; } ); -
创建定时任务
// 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