shopify-apps
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseShopify App Development Skill
Shopify应用开发技能
Load with: base.md + typescript.md + react-web.md
For building Shopify apps using Remix, the Shopify App framework, and checkout UI extensions.
加载依赖:base.md + typescript.md + react-web.md
本技能用于指导使用Remix、Shopify应用框架和结账UI扩展开发Shopify应用。
Prerequisites
前置条件
Required Accounts & Tools
所需账户与工具
bash
undefinedbash
undefined1. Shopify Partner Account (free)
1. Shopify合作伙伴账户(免费)
Sign up at: https://partners.shopify.com
2. Development Store
2. 开发店铺
Create in Partner Dashboard → Stores → Add store → Development store
在合作伙伴后台创建:店铺 → 添加店铺 → 开发店铺
3. Shopify CLI
3. Shopify CLI
npm install -g @shopify/cli
npm install -g @shopify/cli
4. Node.js 18.20+ or 20.10+
4. Node.js 18.20+ 或 20.10+
node --version
undefinednode --version
undefinedPartner Dashboard Setup
合作伙伴后台设置
- Create Partner account at partners.shopify.com
- Create a development store for testing
- Create an app in Partner Dashboard → Apps → Create app
- Note your API key and API secret
- 在partners.shopify.com创建合作伙伴账户
- 创建用于测试的开发店铺
- 在合作伙伴后台创建应用:应用 → 创建应用
- 记录你的API密钥和API密钥密码
Quick Start
快速开始
Scaffold New App
搭建新应用
bash
undefinedbash
undefinedCreate new Shopify app with Remix
使用Remix创建新的Shopify应用
shopify app init
shopify app init
Answer prompts:
回答提示问题:
- App name
- 应用名称
- Template: Remix (recommended)
- 模板:Remix(推荐)
- Language: JavaScript or TypeScript
- 语言:JavaScript或TypeScript
Start development
启动开发服务
cd your-app-name
shopify app dev
undefinedcd your-app-name
shopify app dev
undefinedProject Structure
项目结构
shopify-app/
├── app/
│ ├── routes/
│ │ ├── app._index/ # Main app page
│ │ │ └── route.jsx
│ │ ├── app.jsx # App layout with Polaris
│ │ ├── auth.$.jsx # Auth catch-all
│ │ ├── auth.login/ # Login page
│ │ │ └── route.jsx
│ │ ├── webhooks.app.uninstalled.jsx
│ │ ├── webhooks.app.scopes_update.jsx
│ │ └── webhooks.gdpr.jsx # GDPR compliance (REQUIRED)
│ ├── shopify.server.js # Shopify app config
│ ├── db.server.js # Prisma client
│ └── entry.server.jsx
├── extensions/ # Checkout/theme extensions
│ └── my-extension/
│ ├── src/
│ │ └── index.tsx
│ ├── shopify.extension.toml
│ └── package.json
├── prisma/
│ └── schema.prisma # Session storage
├── shopify.app.toml # App configuration
├── package.json
└── vite.config.jsshopify-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 # 会话存储Schema
├── shopify.app.toml # 应用配置文件
├── package.json
└── vite.config.jsApp Configuration
应用配置
shopify.app.toml
shopify.app.toml
toml
undefinedtoml
undefinedApp configuration - managed by Shopify CLI
应用配置 - 由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"
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"
Required: App lifecycle webhooks
必须实现:应用生命周期webhook
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = ["app/scopes_update"]
uri = "/webhooks/app/scopes_update"
[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"
[[webhooks.subscriptions]]
topics = ["app/scopes_update"]
uri = "/webhooks/app/scopes_update"
Required: GDPR compliance webhooks
必须实现:GDPR合规webhook
[[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
undefined[[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
undefinedshopify.server.js
shopify.server.js
javascript
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, // Use GraphQL only
},
});
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;javascript
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;Authentication
身份验证
Route Protection
路由保护
javascript
// 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 }) => {
// This authenticates the request and redirects to login if needed
const { admin, session } = await authenticate.admin(request);
// Now you have access to admin API and session
const shop = session.shop;
return json({ shop });
};
export default function Index() {
const { shop } = useLoaderData();
return <div>Connected to: {shop}</div>;
}javascript
// 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和会话信息
const shop = session.shop;
return json({ shop });
};
export default function Index() {
const { shop } = useLoaderData();
return <div>已连接到:{shop}</div>;
}Webhook Authentication
Webhook身份验证
javascript
// 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}`);
// Clean up shop data on uninstall
await prisma.session.deleteMany({ where: { shop } });
return new Response(null, { status: 200 });
};javascript
// 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(`收到${shop}的${topic} webhook`);
// 应用卸载时清理店铺数据
await prisma.session.deleteMany({ where: { shop } });
return new Response(null, { status: 200 });
};GraphQL Admin API
GraphQL Admin API
Basic Query Pattern
基础查询模式
javascript
// 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;
}javascript
// 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;
}Query with Variables
带变量的查询
javascript
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);
}javascript
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);
}Mutations
变更操作
javascript
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;
}javascript
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;
}Metafields (App Settings Storage)
元字段(应用设置存储)
javascript
// Get metafield
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,
};
}
// Set metafield
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];
}javascript
// 获取元字段
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 Compliance (REQUIRED)
GDPR合规要求(必须实现)
All Shopify apps MUST handle GDPR webhooks. This is required for App Store approval.
javascript
// 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 any customer data you store
// If you don't store customer data, return empty
return json({ customer_data: null });
case "customers/redact":
// Delete customer data
// Example: await deleteCustomerData(payload.customer.id);
return json({ success: true });
case "shop/redact":
// Delete all shop data (48 hours after uninstall)
// Clean up metafields, database records, etc.
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 });
}
};所有Shopify应用必须处理GDPR webhook,这是应用商店审核的必要条件。
javascript
// 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(`收到${shop}的${topic} webhook`);
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: "未处理的webhook主题" }, { status: 400 });
}
};UI with Polaris
使用Polaris构建UI
App Layout
应用布局
javascript
// 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>
);
}javascript
// 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>
);
}Settings Page Pattern
设置页面模式
javascript
// 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>
);
}javascript
// 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: "设置已保存!" });
} 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="应用设置"
primaryAction={{
content: "保存",
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密钥"
value={formState.apiKey}
onChange={(value) => setFormState({ ...formState, apiKey: value })}
autoComplete="off"
/>
<Select
label="启用集成"
options={[
{ label: "已启用", value: "true" },
{ label: "已禁用", value: "false" },
]}
value={String(formState.enabled)}
onChange={(value) =>
setFormState({ ...formState, enabled: value === "true" })
}
/>
</FormLayout>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}Checkout UI Extensions
结账UI扩展
Extension Configuration
扩展配置
toml
undefinedtoml
undefinedextensions/my-extension/shopify.extension.toml
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
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
Access app metafields in extension
在扩展中访问应用元字段
[[extensions.metafields]]
namespace = "your_app"
key = "settings"
undefined[[extensions.metafields]]
namespace = "your_app"
key = "settings"
undefinedExtension Target Locations
扩展目标位置
| Target | Location |
|---|---|
| Thank you page |
| Checkout page |
| Order status |
| Customer account pages |
| Admin product page |
| 目标位置 | 展示区域 |
|---|---|
| 感谢页面 |
| 结账页面 |
| 订单状态页 |
| 客户账户页面 |
| 后台产品页面 |
Extension Component
扩展组件
tsx
// 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;
// Access app metafields
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>
);
}tsx
// 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}>感谢您的购买!</Heading>
<Text>订单#{order.id}已确认</Text>
<Text appearance="subdued">
店铺:{shop.myshopifyDomain}
</Text>
</BlockStack>
</View>
);
}Extension with External API
调用外部API的扩展
tsx
// 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 };
}tsx
// 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 };
}Database (Prisma)
数据库(Prisma)
Session Storage Schema
会话存储Schema
prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or "sqlite" for dev
url = env("DATABASE_URL")
}
// Required for Shopify session storage
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])
}
// Your app's custom models
model AppSettings {
id String @id @default(uuid())
shop String @unique
settings Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // 开发环境可使用"sqlite"
url = env("DATABASE_URL")
}Database Client
Shopify会话存储所需模型
javascript
// app/db.server.js
import { PrismaClient } from "@prisma/client";
let prisma;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
// Prevent multiple instances in development
if (!global.__prisma) {
global.__prisma = new PrismaClient();
}
prisma = global.__prisma;
}
export { prisma };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])
}
Deployment
你的应用自定义模型
Environment Variables
—
bash
undefinedmodel AppSettings {
id String @id @default(uuid())
shop String @unique
settings Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
undefined.env (DO NOT COMMIT)
数据库客户端
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://...
undefinedjavascript
// 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 };Render Deployment
部署
—
环境变量
yaml
undefinedbash
undefinedrender.yaml
.env(请勿提交到版本控制)
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
undefinedSHOPIFY_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://...
undefinedDeploy Commands
Render部署配置
bash
undefinedyaml
undefinedDeploy app to Shopify
render.yaml
shopify app deploy
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
undefinedThis:
部署命令
1. Builds extensions
—
2. Uploads to Shopify
—
3. Creates new app version
—
---bash
undefinedCommon Scopes
将应用部署到Shopify
| Scope | Access |
|---|---|
| View products |
| Create/edit products |
| View orders |
| Create/edit orders |
| View customers |
| Create/edit customers |
| View checkout data |
| Modify checkout |
| View themes |
| Modify themes |
| View metafields/files |
| Modify metafields/files |
shopify app deploy
CLI Commands
该命令会:
—
1. 构建扩展
—
2. 上传到Shopify
—
3. 创建新的应用版本
bash
undefined
---Development
常见权限范围
shopify app dev # Start dev server with tunnel
shopify app dev --reset # Reset app config
| 权限范围 | 访问权限 |
|---|---|
| 查看产品 |
| 创建/编辑产品 |
| 查看订单 |
| 创建/编辑订单 |
| 查看客户 |
| 创建/编辑客户 |
| 查看结账数据 |
| 修改结账流程 |
| 查看主题 |
| 修改主题 |
| 查看元字段/文件 |
| 修改元字段/文件 |
Configuration
CLI命令
shopify app config link # Link to existing app
shopify app config use # Switch config
shopify app env show # Show env vars
bash
undefinedExtensions
开发相关
shopify app generate extension # Create new extension
shopify app build # Build all extensions
shopify app dev # 启动开发服务器并创建隧道
shopify app dev --reset # 重置应用配置
Deployment
配置相关
shopify app deploy # Deploy to Shopify
shopify app versions list # List app versions
shopify app config link # 关联到已存在的应用
shopify app config use # 切换应用配置
shopify app env show # 查看环境变量
Store
扩展相关
shopify app open # Open app in dev store
---shopify app generate extension # 创建新扩展
shopify app build # 构建所有扩展
Testing
部署相关
Unit Tests
—
javascript
// __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');
});
});shopify app deploy # 部署到Shopify
shopify app versions list # 查看应用版本列表
E2E with Playwright
店铺相关
typescript
// e2e/app.spec.ts
import { test, expect } from '@playwright/test';
test('app settings page loads', async ({ page }) => {
// Note: Requires authenticated session
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();
});shopify app open # 在开发店铺中打开应用
---Rate Limits
测试
GraphQL Cost-Based Limits
单元测试
javascript
// Check rate limit status in response
const response = await admin.graphql(`
query {
shop { name }
}
`);
const data = await response.json();
// Rate limit info in extensions
const throttleStatus = data.extensions?.cost?.throttleStatus;
// {
// maximumAvailable: 1000,
// currentlyAvailable: 950,
// restoreRate: 50 // points per second
// }javascript
// __tests__/adminApi.test.js
import { describe, it, expect, vi } from 'vitest';
import { getShopId, setMetafield } from '../app/shopify/adminApi.server';
describe('Admin API', () => {
it('获取店铺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');
});
});Handling Throttling
使用Playwright进行端到端测试
javascript
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; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
return data;
}
throw new Error('Max retries exceeded');
}typescript
// e2e/app.spec.ts
import { test, expect } from '@playwright/test';
test('应用设置页面正常加载', async ({ page }) => {
// 注意:需要已验证的会话
await page.goto('/app');
await expect(page.getByRole('heading', { name: /设置/i })).toBeVisible();
await expect(page.getByLabel('API密钥')).toBeVisible();
});
test('成功保存设置', async ({ page }) => {
await page.goto('/app');
await page.fill('[name="apiKey"]', 'test-key-123');
await page.click('button:has-text("保存")');
await expect(page.getByText('设置已保存')).toBeVisible();
});Checklist
速率限制
Before Development
基于成本的GraphQL限制
- Partner account created
- Development store created
- App created in Partner Dashboard
- Shopify CLI installed
- App scaffolded with Remix template
javascript
// 在响应中查看速率限制状态
const response = await admin.graphql(`
query {
shop { name }
}
`);
const data = await response.json();
// 速率限制信息在extensions字段中
const throttleStatus = data.extensions?.cost?.throttleStatus;
// {
// maximumAvailable: 1000,
// currentlyAvailable: 950,
// restoreRate: 50 // 每秒恢复的点数
// }Before Submission
处理限流
- GDPR webhooks implemented (customers/data_request, customers/redact, shop/redact)
- App uninstall webhook cleans up data
- No hardcoded API keys
- Error handling for all API calls
- Rate limit handling
- Responsive UI (works on mobile admin)
- Polaris components used consistently
- Extension targets correct surfaces
- Privacy policy URL configured
- App listing completed
javascript
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('已达到最大重试次数');
}Security
检查清单
—
开发前
- Session tokens validated
- Webhook HMAC verification (handled by SDK)
- No sensitive data in client-side code
- Environment variables for all secrets
- HTTPS enforced
- 已创建合作伙伴账户
- 已创建开发店铺
- 已在合作伙伴后台创建应用
- 已安装Shopify CLI
- 已使用Remix模板搭建应用
Anti-Patterns
提交审核前
- REST API usage - Use GraphQL Admin API (REST is deprecated)
- Storing secrets in metafields - Use environment variables
- Ignoring rate limits - Implement exponential backoff
- Skipping GDPR webhooks - Required for App Store
- Large GraphQL queries - Paginate, query only needed fields
- Polling for updates - Use webhooks instead
- Custom auth flow - Use Shopify's OAuth flow via SDK
- 已实现GDPR webhook(customers/data_request、customers/redact、shop/redact)
- 应用卸载webhook已实现数据清理
- 代码中没有硬编码的API密钥
- 所有API调用都有错误处理
- 已实现速率限制处理
- UI支持响应式布局(适配移动端后台)
- 一致使用Polaris组件
- 扩展已配置正确的目标位置
- 已配置隐私政策URL
- 已完成应用列表信息填写
—
安全检查
—
- 会话令牌已验证
- Webhook HMAC验证已处理(SDK已内置)
- 客户端代码中没有敏感数据
- 所有密钥都使用环境变量存储
- 已强制使用HTTPS
—
反模式
—
- 使用REST API - 应使用GraphQL Admin API(REST已被弃用)
- 在元字段中存储密钥 - 应使用环境变量
- 忽略速率限制 - 应实现指数退避机制
- 跳过GDPR webhook - 这是应用商店审核的必要条件
- 过大的GraphQL查询 - 应分页查询,仅获取所需字段
- 轮询更新 - 应使用webhook替代
- 自定义身份验证流程 - 应使用SDK提供的Shopify OAuth流程