ShopifyApp开发技能 shopify-apps

Shopify App开发技能涉及使用Remix框架、Admin API和结账UI扩展来构建Shopify应用,关键词包括Shopify、Remix、Admin API、结账扩展。

前端开发 0 次安装 0 次浏览 更新于 3/5/2026

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

合作伙伴仪表板设置

  1. 在partners.shopify.com创建合作伙伴账户
  2. 为测试创建开发商店
  3. 在Partner Dashboard → Apps → Create app中创建应用
  4. 记录您的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流程