技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
medusa【数据与研究】Medusa is an open-source headless commerce platform (Node.js, TypeScript, PostgreSQL) that serves products, carts, orders, payments and an admin dashboard over a REST API. Use when a user asks to build an online store backend, create a headless e-commerce platform, set up product management and checkout, build a custom storefront, add Stripe payments…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Medusa is an open-source headless commerce platform (Node.js + PostgreSQL). It provides a complete backend for products, carts, orders, payments, shipping, and customers, exposed through a REST API (/store/* for storefronts, /admin/* for back-office clients), plus an admin dashboard served by the same process. Think of it as the open-source alternative to Shopify's backend, with full control over customization. This skill targets Medusa v2 (2.21 at the time of writing); v1 file layouts, imports and API paths do not work in v2. It covers setup, authentication, product management, checkout, Stripe, custom API routes, subscribers, and the Next.js storefront.
# Scaffolds a monorepo: apps/backend (server + admin) and apps/storefront (Next.js starter)
npx create-medusa-app@latest linen-shop --with-nextjs-starter --no-browser
# --no-browser exits after setup; without it the command keeps serving and opens the admin. Also: --db-url "$DATABASE_URL", --skip-db
cd linen-shop/apps/backend
npx medusa user -e ops@linenshop.dev -p "$MEDUSA_ADMIN_PASSWORD" # create an admin user
npm run dev # medusa develop; keeps running, so use a second terminal for the rest
# API: http://localhost:9000
# Admin: http://localhost:9000/app
# Storefront: http://localhost:8000 (npm run dev in apps/storefront)
npx medusa db:migrate # after upgrading Medusa or adding modules and links
npx medusa db:generate brand # generate a migration for a custom module named "brand"
npx medusa exec ./src/scripts/import-shopify.ts ./data/shopify-products.json # run a script with the container
The installer creates a PostgreSQL database named medusa-linen-shop and writes DATABASE_URL to apps/backend/.env. Redis is not needed in development: the event bus, workflow engine, cache and locking run in memory.
Store routes need a publishable API key (Admin > Settings > Publishable API Keys); admin routes need a JWT or a secret API key.
# Admin: exchange credentials for a JWT
TOKEN=$(curl -s -X POST http://localhost:9000/auth/user/emailpass \
-H 'Content-Type: application/json' \
-d "{\"email\":\"ops@linenshop.dev\",\"password\":\"$MEDUSA_ADMIN_PASSWORD\"}" | jq -r .token)
curl -s http://localhost:9000/admin/api-keys -H "Authorization: Bearer $TOKEN" \
| jq -r '.api_keys[] | select(.type == "publishable") | .token' # pk_fd8eefbf7...
# Store: every /store request carries the publishable key
curl -s "http://localhost:9000/store/products?limit=20" -H "x-publishable-api-key: $MEDUSA_PUBLISHABLE_KEY"
# without the header: 400 {"type":"not_allowed","message":"Publishable API key required ..."}
// src/api/admin/quick-product/route.ts — the file path is the URL: POST /admin/quick-product
// Routes under /admin require an authenticated admin user by default.
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
type QuickProductBody = { title: string; price: number; shipping_profile_id: string }
export async function POST(req: MedusaRequest<QuickProductBody>, res: MedusaResponse) {
const { title, price, shipping_profile_id } = req.body
const { result } = await createProductsWorkflow(req.scope).run({
input: {
products: [{
title, status: "published", shipping_profile_id,
options: [{ title: "Size", values: ["S", "M", "L"] }],
variants: ["S", "M", "L"].map((size) => ({
title: `${title} / ${size}`,
sku: `${title.toUpperCase().replace(/\s+/g, "-")}-${size}`,
options: { Size: size },
prices: [{ amount: price, currency_code: "usd" }], // major units: 39.5 means $39.50
manage_inventory: false,
})),
}],
},
})
res.json({ product: result[0] })
}
# The built-in route does the same; a product must define at least one option
curl -s -X POST http://localhost:9000/admin/products \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Linen Napkin Set","status":"published","options":[{"title":"Color","values":["Sand","Slate"]}]}'
# Storefront: prices are calculated per region, so pass region_id
curl -s "http://localhost:9000/store/products?region_id=$REGION_ID&fields=*variants.calculated_price" \
-H "x-publishable-api-key: $MEDUSA_PUBLISHABLE_KEY"
// lib/checkout.ts — storefront checkout with the JS SDK (npm install @medusajs/js-sdk)
// Flow: cart → line items → email and address → shipping method → payment session → complete
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || "http://localhost:9000",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
export async function checkout(variantId: string, providerId = "pp_system_default") {
const { regions } = await sdk.store.region.list()
let { cart } = await sdk.store.cart.create({ region_id: regions[0].id }) // currency comes from the region
;({ cart } = await sdk.store.cart.createLineItem(cart.id, { variant_id: variantId, quantity: 2 }))
;({ cart } = await sdk.store.cart.update(cart.id, {
email: "marta.keller@posteo.de",
shipping_address: {
first_name: "Marta", last_name: "Keller", address_1: "Lindenstrasse 14",
city: "Berlin", postal_code: "10969", country_code: "de",
},
}))
const { shipping_options } = await sdk.store.fulfillment.listCartOptions({ cart_id: cart.id })
;({ cart } = await sdk.store.cart.addShippingMethod(cart.id, { option_id: shipping_options[0].id }))
// Creates the payment collection and the session; use "pp_stripe_stripe" for Stripe
await sdk.store.payment.initiatePaymentSession(cart, { provider_id: providerId })
const result = await sdk.store.cart.complete(cart.id)
if (result.type !== "order") throw new Error(result.error.message) // type "cart" = completion failed
return result.order // { id: "order_01...", total: 30, ... }
}
The equivalent REST calls are POST /store/carts, POST /store/carts/{id}/line-items, POST /store/carts/{id}, GET /store/shipping-options?cart_id=, POST /store/carts/{id}/shipping-methods, POST /store/payment-collections with {"cart_id"}, POST /store/payment-collections/{id}/payment-sessions with {"provider_id"}, and POST /store/carts/{id}/complete.
The Stripe provider ships inside @medusajs/medusa; there is no extra package to install.
// medusa-config.ts — add a modules array next to the scaffolded projectConfig
module.exports = defineConfig({
projectConfig: { /* unchanged: databaseUrl, http.storeCors/adminCors/authCors, jwtSecret, cookieSecret */ },
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [{
resolve: "@medusajs/medusa/payment-stripe",
id: "stripe",
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
}],
},
},
],
})
Then enable Stripe for each region in Admin > Settings > Regions. The provider ID used in checkout is pp_stripe_stripe. In production, point a Stripe webhook at https://api.linenshop.dev/hooks/payment/stripe_stripe for the events payment_intent.amount_capturable_updated, payment_intent.succeeded, payment_intent.payment_failed and payment_intent.partially_funded.
// src/api/store/search/route.ts — GET /store/search?q=shirt
// Query reads data across modules; routes under /store require the publishable key.
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: products, metadata } = await query.graph({
entity: "product",
fields: ["id", "title", "handle", "variants.sku", "variants.prices.amount", "variants.prices.currency_code"],
filters: { title: { $ilike: `%${req.query.q ?? ""}%` }, status: "published" },
pagination: { take: 20, skip: 0 },
})
res.json({ products, count: metadata?.count })
}
// src/subscribers/order-placed.ts — runs after an order is placed (email, ERP sync, warehouse notice)
// The event payload holds only the ID; load what you need with Query.
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
export default async function orderPlacedHandler({ event: { data }, container }: SubscriberArgs<{ id: string }>) {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: [order] } = await query.graph({
entity: "order",
fields: ["id", "display_id", "email", "total", "currency_code"],
filters: { id: data.id },
})
if (!order?.email) return
// Needs a Notification Module provider (SendGrid, for example) for the "email" channel; without one this call throws
await container.resolve(Modules.NOTIFICATION).createNotifications({
to: order.email, channel: "email", template: "order-confirmation",
data: { display_id: order.display_id, total: order.total, currency_code: order.currency_code },
})
}
export const config: SubscriberConfig = { event: "order.placed" }
User prompt: "I want to build an online clothing store. Open-source backend, custom Next.js frontend, Stripe payments. I need product variants (size, color), inventory tracking, and a proper checkout flow."
The agent will:
npx create-medusa-app@latest linen-shop --with-nextjs-starter --no-browser, which creates apps/backend and apps/storefront, then create the admin user and start both apps (Step 1); the admin is at http://localhost:9000/app.medusa-config.ts (Step 5), set STRIPE_API_KEY in apps/backend/.env and NEXT_PUBLIC_STRIPE_KEY in apps/storefront/.env.local, and enable Stripe for the region in the admin.Size and Color options; for stock tracking leave manage_inventory on and set quantities per stock location in the admin.order.placed subscriber from Step 7 for confirmation emails.Result: a test order placed in the storefront at http://localhost:8000 appears under Orders in the admin, and the server log shows Processing order.placed which has 1 subscribers.
User prompt: "We're paying $300/month for Shopify Plus. Migrate our 500 products to a self-hosted Medusa backend."
Export the products from Shopify as JSON, then import them with a script that runs inside the Medusa container:
// src/scripts/import-shopify.ts
import type { ExecArgs } from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { createProductsWorkflow } from "@medusajs/medusa/core-flows"
import { readFileSync } from "node:fs"
type ShopifyProduct = {
title: string; handle: string; body_html: string
options: { name: string; values: string[] }[]
variants: { title: string; sku: string; price: string; option1: string }[]
}
export default async function importShopify({ container, args }: ExecArgs) {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: [profile] } = await query.graph({ entity: "shipping_profile", fields: ["id"] })
const { data: [channel] } = await query.graph({ entity: "sales_channel", fields: ["id"] })
const { products } = JSON.parse(readFileSync(args[0], "utf8")) as { products: ShopifyProduct[] }
for (let i = 0; i < products.length; i += 25) {
const { result } = await createProductsWorkflow(container).run({
input: {
products: products.slice(i, i + 25).map((p) => ({
title: p.title, handle: p.handle, description: p.body_html, status: "published" as const,
shipping_profile_id: profile.id, sales_channels: [{ id: channel.id }],
options: p.options.map((o) => ({ title: o.name, values: o.values })),
variants: p.variants.map((v) => ({
title: v.title, sku: v.sku, manage_inventory: false,
options: { [p.options[0].name]: v.option1 },
prices: [{ amount: Number(v.price), currency_code: "usd" }], // "89.00" -> 89, not 8900
})),
})),
},
})
console.log(`Imported ${i + result.length} of ${products.length} products`)
}
}
npx medusa exec ./src/scripts/import-shopify.ts ./data/shopify-products.json
# Imported 25 of 500 products ... Imported 500 of 500 products
Each batch is one workflow run, so a failing product rolls back only its batch of 25. Products with more than one option need option2 and option3 mapped the same way; order history is imported separately.
src/api/routes/*, medusa seed, /store/carts/{id}/payment-sessions, admin on port 7001, the medusa-payment-stripe plugin) does not run on v2. There is no in-place upgrade: v2 needs a new database and a data migration.amount: 10 is $10.00, not 10 cents. Dividing or multiplying by 100 out of v1 habit is the most common migration bug.query.graph) on the server./store request needs the x-publishable-api-key header; the key also decides which sales channel's products are visible. A product that is not in the key's sales channel does not appear in the storefront.createProductsWorkflow, completeCartWorkflow, or your own) for anything that writes across modules — they roll back completed steps when a later step fails. Do not call module services directly from routes for multi-step writes.result.type === "order" after completing a cart. type: "cart" means completion failed and Medusa has already cancelled or refunded the payment.ADMIN_CORS, STORE_CORS and AUTH_CORS to real origins. Replace the scaffolded JWT_SECRET and COOKIE_SECRET (supersecret) before deploying.npx medusa build and start from .medusa/server, run medusa db:migrate on every deploy, set projectConfig.redisUrl, and replace the in-memory modules with the Redis event bus, workflow engine, locking and caching modules. Run one instance in server mode and one in worker mode (workerMode in medusa-config.ts).