Shopify App开发技能
使用:base.md + typescript.md + react-web.md
用于构建使用Remix、Shopify App框架和结账UI扩展的Shopify应用。
来源: Shopify Dev Docs | Shopify CLI | Admin API
先决条件
需要的账户和工具
# 1. Shopify Partner账户(免费)
# 注册地址:https://partners.shopify.com
# 2. 开发商店
# 在Partner Dashboard → Stores → Add store → Development store中创建
# 3. Shopify CLI
npm install -g @shopify/cli
# 4. Node.js 18.20+ 或 20.10+
node --version
合作伙伴仪表板设置
- 在partners.shopify.com创建合作伙伴账户
- 为测试创建开发商店
- 在Partner Dashboard → Apps → Create app中创建应用
- 记录您的API密钥和API密钥
快速开始
创建新应用
# 使用Remix创建新的Shopify应用
shopify app init
# 回答问题:
# - 应用名称
# - 模板:Remix(推荐)
# - 语言:JavaScript或TypeScript
# 开始开发
cd your-app-name
shopify app dev
项目结构
shopify-app/
├── app/
│ ├── routes/
│ │ ├── app._index/ # 主应用页面
│ │ │ └── route.jsx
│ │ ├── app.jsx # 带有Polaris的应用布局
│ │ ├── auth.$.jsx # 认证捕获所有页面
│ │ ├── auth.login/ # 登录页面
│ │ │ └── route.jsx
│ │ ├── webhooks.app.uninstalled.jsx
│ │ ├── webhooks.app.scopes_update.jsx
│ │ └── webhooks.gdpr.jsx # GDPR合规性(必需)
│ ├── shopify.server.js # Shopify应用配置
│ ├── db.server.js # Prisma客户端
│ └── entry.server.jsx
├── extensions/ # 结账/主题扩展
│ └── my-extension/
│ ├── src/
│ │ └── index.tsx
│ ├── shopify.extension.toml
│ └── package.json
├── prisma/
│ └── schema.prisma # 会话存储
├── shopify.app.toml # 应用配置
├── package.json
└── vite.config.js
应用配置
shopify.app.toml
# Shopify CLI管理的应用配置
client_id = "your-api-key"
name = "Your App Name"
handle = "your-app-handle"
application_url = "https://your-app.onrender.com"
embedded = true
[webhooks]
api_version = "2025-01"
# 必需:应用生命周期webhooks
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = ["app/scopes_update"]
uri = "/webhooks/app/scopes_update"
# 必需:GDPR合规性webhooks
[[webhooks.subscriptions]]
compliance_topics = [
"customers/data_request",
"customers/redact",
"shop/redact",
]
uri = "/webhooks/gdpr"
[access_scopes]
scopes = "read_products,write_products"
[auth]
redirect_urls = [
"https://your-app.onrender.com/auth/callback",
"https://your-app.onrender.com/auth/shopify/callback",
]
[pos]
embedded = false
[build]
dev_store_url = "your-dev-store.myshopify.com"
automatically_update_urls_on_dev = true
shopify.server.js
import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
shopifyApp,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import { prisma } from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.January25,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
future: {
unstable_newEmbeddedAuthStrategy: true,
removeRest: true, # 仅使用GraphQL
},
});
export default shopify;
export const apiVersion = ApiVersion.January25;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;
认证
路由保护
// app/routes/app._index/route.jsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../../shopify.server";
export const loader = async ({ request }) => {
// 这会认证请求并在需要时重定向到登录
const { admin, session } = await authenticate.admin(request);
// 现在您可以访问admin API和session
const shop = session.shop;
return json({ shop });
};
export default function Index() {
const { shop } = useLoaderData();
return <div>连接到:{shop}</div>;
}
Webhook认证
// app/routes/webhooks.app.uninstalled.jsx
import { authenticate } from "../shopify.server";
import { prisma } from "../db.server";
export const action = async ({ request }) => {
const { shop, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// 卸载时清理商店数据
await prisma.session.deleteMany({ where: { shop } });
return new Response(null, { status: 200 });
};
GraphQL管理API
基本查询模式
// app/shopify/adminApi.server.js
export async function getShopId(admin) {
const response = await admin.graphql(`
query getShopId {
shop {
id
name
email
myshopifyDomain
}
}
`);
const data = await response.json();
return data.data?.shop;
}
带变量的查询
export async function getProducts(admin, first = 10) {
const response = await admin.graphql(`
query getProducts($first: Int!) {
products(first: $first) {
edges {
node {
id
title
status
variants(first: 5) {
edges {
node {
id
price
inventoryQuantity
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`, {
variables: { first }
});
const data = await response.json();
return data.data?.products?.edges.map(e => e.node);
}
变异
export async function createProduct(admin, input) {
const response = await admin.graphql(`
mutation createProduct($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
}
userErrors {
field
message
}
}
}
`, {
variables: {
input: {
title: input.title,
descriptionHtml: input.description,
status: "DRAFT"
}
}
});
const data = await response.json();
const result = data.data?.productCreate;
if (result?.userErrors?.length > 0) {
throw new Error(result.userErrors.map(e => e.message).join(", "));
}
return result?.product;
}
元字段(应用设置存储)
// 获取元字段
export async function getMetafield(admin, namespace, key) {
const response = await admin.graphql(`
query getShopMetafield($namespace: String!, $key: String!) {
shop {
id
metafield(namespace: $namespace, key: $key) {
id
value
}
}
}
`, {
variables: { namespace, key }
});
const data = await response.json();
const metafield = data.data?.shop?.metafield;
return {
shopId: data.data?.shop?.id,
value: metafield?.value ? JSON.parse(metafield.value) : null,
};
}
// 设置元字段
export async function setMetafield(admin, namespace, key, value, shopId) {
const response = await admin.graphql(`
mutation CreateMetafield($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
`, {
variables: {
metafields: [{
namespace,
key,
type: "json",
value: JSON.stringify(value),
ownerId: shopId,
}]
}
});
const data = await response.json();
const errors = data.data?.metafieldsSet?.userErrors;
if (errors?.length > 0) {
throw new Error(errors.map(e => e.message).join(", "));
}
return data.data?.metafieldsSet?.metafields?.[0];
}
GDPR合规性(必需)
所有Shopify应用必须处理GDPR webhooks。 这是应用商店批准的要求。
// app/routes/webhooks.gdpr.jsx
import { authenticate } from "../shopify.server";
export const action = async ({ request }) => {
const { topic, shop, session } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
switch (topic) {
case "customers/data_request":
// 返回您存储的任何客户数据
// 如果您不存储客户数据,则返回空
return json({ customer_data: null });
case "customers/redact":
// 删除客户数据
// 示例:await deleteCustomerData(payload.customer.id);
return json({ success: true });
case "shop/redact":
// 删除所有商店数据(卸载后48小时)
// 清理元字段、数据库记录等
if (session) {
const { admin } = await authenticate.admin(request);
await admin.graphql(`
mutation metafieldDelete($input: MetafieldsDeleteInput!) {
metafieldsDelete(input: $input) {
deletedId
}
}
`, {
variables: {
input: {
namespace: "your_app",
key: "settings",
ownerType: "SHOP"
}
}
});
}
return json({ success: true });
default:
return json({ error: "Unhandled topic" }, { status: 400 });
}
};
带有Polaris的UI
应用布局
// app/routes/app.jsx
import { Outlet } from "@remix-run/react";
import { AppProvider } from "@shopify/polaris";
import "@shopify/polaris/build/esm/styles.css";
import polarisTranslations from "@shopify/polaris/locales/en.json";
export default function App() {
return (
<AppProvider i18n={polarisTranslations}>
<Outlet />
</AppProvider>
);
}
设置页面模式
// app/routes/app._index/route.jsx
import { useState } from "react";
import { json } from "@remix-run/node";
import { useActionData, useLoaderData, useSubmit } from "@remix-run/react";
import {
Page,
Layout,
Card,
FormLayout,
TextField,
Select,
Banner,
Button,
} from "@shopify/polaris";
import { authenticate } from "../../shopify.server";
import { getMetafield, setMetafield, getShopId } from "../../shopify/adminApi.server";
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const { shopId, value } = await getMetafield(admin, "your_app", "settings");
return json({ shopId, settings: value });
};
export const action = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const formData = await request.formData();
const settings = {
apiKey: formData.get("apiKey"),
enabled: formData.get("enabled") === "true",
};
try {
const shopId = await getShopId(admin);
await setMetafield(admin, "your_app", "settings", settings, shopId.id);
return json({ success: true, message: "Settings saved!" });
} catch (error) {
return json({ error: error.message }, { status: 500 });
}
};
export default function Settings() {
const { settings } = useLoaderData();
const actionData = useActionData();
const submit = useSubmit();
const [formState, setFormState] = useState({
apiKey: settings?.apiKey || "",
enabled: settings?.enabled ?? true,
});
const handleSubmit = () => {
const formData = new FormData();
formData.append("apiKey", formState.apiKey);
formData.append("enabled", String(formState.enabled));
submit(formData, { method: "post" });
};
return (
<Page
title="App Settings"
primaryAction={{
content: "Save",
onAction: handleSubmit,
}}
>
<Layout>
{actionData?.message && (
<Layout.Section>
<Banner tone="success">{actionData.message}</Banner>
</Layout.Section>
)}
{actionData?.error && (
<Layout.Section>
<Banner tone="critical">{actionData.error}</Banner>
</Layout.Section>
)}
<Layout.Section>
<Card>
<FormLayout>
<TextField
label="API Key"
value={formState.apiKey}
onChange={(value) => setFormState({ ...formState, apiKey: value })}
autoComplete="off"
/>
<Select
label="Enable Integration"
options=[
{ label: "Enabled", value: "true" },
{ label: "Disabled", value: "false" },
]}
value={String(formState.enabled)}
onChange={(value) =>
setFormState({ ...formState, enabled: value === "true" })
}
/>
</FormLayout>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
结账UI扩展
扩展配置
# extensions/my-extension/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
name = "My Checkout Extension"
handle = "my-checkout-extension"
type = "ui_extension"
[[extensions.targeting]]
module = "./src/index.tsx"
target = "purchase.thank-you.block.render"
[extensions.capabilities]
api_access = true
network_access = true
# 在扩展中访问应用元字段
[[extensions.metafields]]
namespace = "your_app"
key = "settings"
扩展目标位置
| 目标 | 位置 |
|---|---|
purchase.thank-you.block.render |
感谢页面 |
purchase.checkout.block.render |
结账页面 |
customer-account.order-status.block.render |
订单状态 |
customer-account.page.render |
客户账户页面 |
admin.product-details.block.render |
管理产品页面 |
扩展组件
// extensions/my-extension/src/index.tsx
import {
reactExtension,
useShop,
useAppMetafields,
useApi,
View,
BlockStack,
Heading,
Text,
Button,
Spinner,
} from "@shopify/ui-extensions-react/checkout";
export default reactExtension("purchase.thank-you.block.render", () => (
<Extension />
));
function Extension() {
const shop = useShop();
const { orderConfirmation } = useApi();
const order = orderConfirmation.current.order;
// 访问应用元字段
const metafields = useAppMetafields({
namespace: "your_app",
key: "settings"
});
const settings = metafields[0]?.metafield?.value
? JSON.parse(metafields[0].metafield.value)
: null;
if (!settings?.enabled) {
return null;
}
return (
<View border="base" padding="base">
<BlockStack>
<Heading level={2}>Thank You!</Heading>
<Text>Order #{order.id} confirmed</Text>
<Text appearance="subdued">
Shop: {shop.myshopifyDomain}
</Text>
</BlockStack>
</View>
);
}
带外部API的扩展
// extensions/my-extension/src/hooks/useExternalApi.ts
import { useState, useEffect } from "react";
export function useExternalApi(surveyId: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!surveyId) {
setLoading(false);
return;
}
fetch(`https://api.example.com/surveys/${surveyId}`)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [surveyId]);
return { data, loading, error };
}
数据库(Prisma)
会话存储模式
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" # 或者开发时使用"sqlite"
url = env("DATABASE_URL")
}
// Shopify会话存储所需的
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
@@index([shop])
}
# 您的应用的自定义模型
model AppSettings {
id String @id @default(uuid())
shop String @unique
settings Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
数据库客户端
// app/db.server.js
import { PrismaClient } from "@prisma/client";
let prisma;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
# 阻止开发中多个实例
if (!global.__prisma) {
global.__prisma = new PrismaClient();
}
prisma = global.__prisma;
}
export { prisma };
部署
环境变量
# .env (不要提交)
SHOPIFY_API_KEY=your_api_key
SHOPIFY_API_SECRET=your_api_secret
SCOPES=read_products,write_products
SHOPIFY_APP_URL=https://your-app.onrender.com
DATABASE_URL=postgresql://...
Render部署
# render.yaml
services:
- type: web
name: shopify-app
runtime: node
plan: starter
buildCommand: npm install && npm run setup && npm run build
startCommand: npm run start
envVars:
- key: NODE_ENV
value: production
- key: DATABASE_URL
fromDatabase:
name: shopify-db
property: connectionString
- key: SHOPIFY_API_KEY
sync: false
- key: SHOPIFY_API_SECRET
sync: false
- key: SCOPES
sync: false
- key: SHOPIFY_APP_URL
sync: false
databases:
- name: shopify-db
plan: starter
部署命令
# 部署应用到Shopify
shopify app deploy
# 这将:
# 1. 构建扩展
# 2. 上传到Shopify
# 3. 创建新的应用版本
常见范围
| 范围 | 访问 |
|---|---|
read_products |
查看产品 |
write_products |
创建/编辑产品 |
read_orders |
查看订单 |
write_orders |
创建/编辑订单 |
read_customers |
查看客户 |
write_customers |
创建/编辑客户 |
read_checkouts |
查看结账数据 |
write_checkouts |
修改结账 |
read_themes |
查看主题 |
write_themes |
修改主题 |
read_content |
查看元字段/文件 |
write_content |
修改元字段/文件 |
CLI命令
# 开发
shopify app dev # 启动开发服务器和隧道
shopify app dev --reset # 重置应用配置
# 配置
shopify app config link # 链接到现有应用
shopify app config use # 切换配置
shopify app env show # 显示环境变量
# 扩展
shopify app generate extension # 创建新扩展
shopify app build # 构建所有扩展
# 部署
shopify app deploy # 部署到Shopify
shopify app versions list # 列出应用版本
# 商店
shopify app open # 在开发商店中打开应用
测试
单元测试
# __tests__/adminApi.test.js
import { describe, it, expect, vi } from 'vitest';
import { getShopId, setMetafield } from '../app/shopify/adminApi.server';
describe('Admin API', () => {
it('gets shop ID', async () => {
const mockAdmin = {
graphql: vi.fn().mockResolvedValue({
json: () => Promise.resolve({
data: { shop: { id: 'gid://shopify/Shop/123' } }
})
})
};
const result = await getShopId(mockAdmin);
expect(result.id).toBe('gid://shopify/Shop/123');
});
});
Playwright的E2E
# e2e/app.spec.ts
import { test, expect } from '@playwright/test';
test('app settings page loads', async ({ page }) => {
# 注意:需要经过身份验证的会话
await page.goto('/app');
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
await expect(page.getByLabel('API Key')).toBeVisible();
});
test('saves settings successfully', async ({ page }) => {
await page.goto('/app');
await page.fill('[name="apiKey"]', 'test-key-123');
await page.click('button:has-text("Save")');
await expect(page.getByText('Settings saved')).toBeVisible();
});
速率限制
GraphQL基于成本的限制
# 在响应中检查速率限制状态
const response = await admin.graphql(`
query {
shop { name }
}
`);
const data = await response.json();
# 扩展中的速率限制信息
const throttleStatus = data.extensions?.cost?.throttleStatus;
# {
# maximumAvailable: 1000,
# currentlyAvailable: 950,
# restoreRate: 50 # 每秒点数
# }
处理节流
async function graphqlWithRetry(admin, query, variables, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await admin.graphql(query, { variables });
const data = await response.json();
if (data.errors?.some(e => e.extensions?.code === 'THROTTLED')) {
const waitTime = Math.pow(2, attempt) * 1000; # 指数退避
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return data;
}
throw new Error('Max retries exceeded');
}
清单
开发前
- [ ] 创建合作伙伴账户
- [ ] 创建开发商店
- [ ] 在合作伙伴仪表板中创建应用
- [ ] 安装Shopify CLI
- [ ] 使用Remix模板搭建应用
提交前
- [ ] 实施GDPR webhooks(customers/data_request, customers/redact, shop/redact)
- [ ] 应用卸载webhook清理数据
- [ ] 无硬编码API密钥
- [ ] 所有API调用的错误处理
- [ ] 处理速率限制
- [ ] 响应式UI(在移动管理上工作)
- [ ] 一致使用Polaris组件
- [ ] 扩展目标正确的表面
- [ ] 配置隐私政策URL
- [ ] 完成应用列表
安全
- [ ] 验证会话令牌
- [ ] Webhook HMAC验证(由SDK处理)
- [ ] 客户端代码中无敏感数据
- [ ] 所有机密的环境变量
- [ ] 强制HTTPS
反模式
- REST API使用 - 使用GraphQL管理API(REST已弃用)
- 在metafields中存储机密 - 使用环境变量
- 忽略速率限制 - 实施指数退避
- 跳过GDPR webhooks - 应用商店必需
- 大型GraphQL查询 - 分页,仅查询所需字段
- 轮询更新 - 使用webhooks代替
- 自定义认证流程 - 通过SDK使用Shopify的OAuth流程