Next.js + Shopify OAuth 第三方应用接入完整指南

211 阅读2分钟

Next.js + Shopify OAuth 第三方应用接入完整指南

本文将详细介绍如何使用 Next.js 14 (App Router) 创建一个非嵌入式 Shopify 应用,实现从 OAuth 认证到获取商品列表的完整流程。

🎯 项目概述

  • 技术栈: Next.js 14 + TypeScript + Tailwind CSS
  • 架构: 非嵌入式应用 + 无服务器函数
  • 认证: OAuth 2.0 流程
  • 存储: 本地文件系统会话存储

📋 前置准备

1. Shopify Partner 账户设置

# 1. 访问 Shopify Partner Dashboard  
https://partners.shopify.com/  
  
# 2. 创建新应用  
- 应用类型: Public app  
- 应用名称: 自定义  
- 应用URL: https://your-domain.com  
- 重定向URL: https://your-domain.com/api/auth/callback  

2. 开发环境准备

# 创建 Next.js 项目  
npx create-next-app@latest shopify-app --typescript --tailwind --app  
  
# 安装 Shopify 依赖  
npm install @shopify/shopify-api  

🔧 核心实现

1. 环境变量配置

# .env.local  
SHOPIFY_API_KEY=your_api_key  #从你的shopify app 设置界面获取
SHOPIFY_API_SECRET=your_api_secret  #从你的shopify app 设置界面设置
SHOPIFY_SCOPES=read_products,read_orders  
SHOPIFY_HOST=https://your-domain.com # 我们应用的域名 

2. Shopify 配置初始化

// src/lib/shopify.ts  
import { shopifyApi, LATEST_API_VERSION } from '@shopify/shopify-api';  
  
export const shopify = shopifyApi({  
    apiKey: process.env.SHOPIFY_API_KEY!,  
    apiSecretKey: process.env.SHOPIFY_API_SECRET!,  
    scopes: process.env.SHOPIFY_SCOPES!.split(','),  
    hostName: process.env.SHOPIFY_HOST!.replace(/https?:\/\//, ''),  
    apiVersion: LATEST_API_VERSION,  
    isEmbeddedApp: false, // 非嵌入式应用  
});  

3. 会话存储简易实现

shopify 是标准的 OAuth2.0 授权模型,获取的 access_token 这里简单存到文件里,用来实现后边请求商品列表等资源接口,实际也可用用数据库或者redis做持久化存储和缓存管理

// src/lib/session-storage.ts
import fs from "fs";
import path from "path";

const SESSIONS_DIR = path.join(process.cwd(), ".sessions");

interface SessionData {
  id: string;
  shop: string;
  accessToken: string;
  expires?: Date;
}

export class FileSessionStorage {
  static async storeSession(session: SessionData): Promise<boolean> {
    try {
      if (!fs.existsSync(SESSIONS_DIR)) {
        fs.mkdirSync(SESSIONS_DIR, { recursive: true });
      }

      const filePath = path.join(SESSIONS_DIR, `${session.id}.json`);
      fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
      return true;
    } catch (error) {
      console.error("存储会话失败:", error);
      return false;
    }
  }

  static async loadSession(id: string): Promise<SessionData | undefined> {
    try {
      const filePath = path.join(SESSIONS_DIR, `${id}.json`);
      if (!fs.existsSync(filePath)) return undefined;

      const data = fs.readFileSync(filePath, "utf-8");
      return JSON.parse(data);
    } catch (error) {
      console.error("加载会话失败:", error);
      return undefined;
    }
  }
}

4. OAuth 认证流程

认证入口 API

商家提供店铺名称,调用该接口触发认证流程

// src/app/api/auth/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const shop = searchParams.get("shop");

  if (!shop) {
    return NextResponse.json(
      {
        error: "缺少shop参数",
      },
      {
        status: 400,
      },
    );
  }

  // 构建 OAuth 授权 URL
  const scopes = process.env.SHOPIFY_SCOPES!;
  const redirectUri = `${process.env.SHOPIFY_HOST}/api/auth/callback`;
  const state = Math.random().toString(36).substring(7);

  const authUrl =
    `https://${shop}/admin/oauth/authorize?` +
    `client_id=${process.env.SHOPIFY_API_KEY}&` +
    `scope=${scopes}&` +
    `redirect_uri=${redirectUri}&` +
    `state=${state}`;

  // 重定向到商家店铺的授权页面,商家点击安装,完成授权
  return NextResponse.redirect(authUrl);
}

OAuth 回调处理

授权完成获取code,服务端拿code获取 access_token, access_token 可以用来访问 shopify 数据

// src/app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { FileSessionStorage } from "@/lib/session-storage";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get("code");
  const shop = searchParams.get("shop");

  if (!code || !shop) {
    return NextResponse.redirect("/error?message=授权失败");
  }

  try {
    // 交换访问令牌
    const tokenResponse = await fetch(
      `https://${shop}/admin/oauth/access_token`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          client_id: process.env.SHOPIFY_API_KEY,
          client_secret: process.env.SHOPIFY_API_SECRET,
          code,
        }),
      },
    );

    const { access_token } = await tokenResponse.json();

    // 存储会话
    const sessionId = `${shop}_session`; // sessionId是会话token,用来保持登录态
    await FileSessionStorage.storeSession({
      id: sessionId,
      shop,
      accessToken: access_token,
    });

    // 重定向到商品页面
    const response = NextResponse.redirect(
      `${process.env.SHOPIFY_HOST}/products`,
    );
    response.cookies.set("shopify_session", sessionId, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 7, // 7天
    });

    return response;
  } catch (error) {
    console.error("OAuth回调错误:", error);
    return NextResponse.redirect("/error?message=授权处理失败");
  }
}

5. 商品数据获取

Shopify 使用 GraphQL 语法请求接口

// src/app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { FileSessionStorage } from "@/lib/session-storage";

const PRODUCTS_QUERY = `  
query getProducts($first: Int!) {  
    products(first: $first) {  
        edges {  
            node {  
                id  
                title  
                handle  
                status  
                createdAt  
                updatedAt  
                priceRangeV2 {  
                    minVariantPrice {  
                    amount  
                    currencyCode  
                   }  
                }  
                featuredImage {  
                    url  
                    altText  
                }  
            }  
        }  
     }  
}  
`;

export async function GET(request: NextRequest) {
  try {
    const sessionId = request.cookies.get("shopify_session")?.value;
    if (!sessionId) {
      return NextResponse.json(
        {
          error: "未找到会话",
        },
        {
          status: 401,
        },
      );
    }

    const session = await FileSessionStorage.loadSession(sessionId);
    if (!session) {
      return NextResponse.json(
        {
          error: "会话已过期",
        },
        {
          status: 401,
        },
      );
    }

    // 调用 Shopify GraphQL API
    const response = await fetch(
      `https://${session.shop}/admin/api/2024-01/graphql.json`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Shopify-Access-Token": session.accessToken,
        },
        body: JSON.stringify({
          query: PRODUCTS_QUERY,
          variables: {
            first: 50,
          },
        }),
      },
    );

    const data = await response.json();

    if (data.errors) {
      throw new Error(`GraphQL错误: ${JSON.stringify(data.errors)}`);
    }

    return NextResponse.json({
      success: true,
      products: data.data.products.edges.map((edge: any) => edge.node),
      shop: session.shop,
    });
  } catch (error) {
    console.error("获取商品失败:", error);
    return NextResponse.json(
      {
        error: "获取商品失败",
        details: error.message,
      },
      {
        status: 500,
      },
    );
  }
}

6. 前端页面实现

店铺连接页面
// src/app/shopify/page.tsx
"use client";

import { useState } from "react";

export default function ShopifyPage() {
  const [shopUrl, setShopUrl] = useState("");
  const [isConnecting, setIsConnecting] = useState(false);

  const handleConnect = async () => {
    if (!shopUrl.trim()) return;

    setIsConnecting(true);

    // 标准化店铺URL
    let shop = shopUrl.trim();
    if (!shop.includes(".myshopify.com")) {
      shop = `${shop}.myshopify.com`;
    }

    // 跳转到OAuth认证
    window.location.href = `/api/auth?shop=${encodeURIComponent(shop)}`;
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-purple-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
        <h1 className="text-2xl font-bold text-gray-900 mb-6 text-center">
          连接 Shopify 店铺
        </h1>

        <div className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-2">
              店铺地址
            </label>
            <input
              type="text"
              value={shopUrl}
              onChange={(e) => setShopUrl(e.target.value)}
              placeholder="mystore 或 mystore.myshopify.com"
              className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              disabled={isConnecting}
            />
          </div>

          <button
            onClick={handleConnect}
            disabled={!shopUrl.trim() || isConnecting}
            className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
          >
            {isConnecting ? "连接中..." : "连接店铺"}
          </button>
        </div>
      </div>
    </div>
  );
}

商品列表页面
// src/app/products/page.tsx
"use client";

import { useState, useEffect } from "react";
import Image from "next/image";

interface Product {
  id: string;
  title: string;
  status: string;
  priceRangeV2: {
    minVariantPrice: {
      amount: string;
      currencyCode: string;
    };
  };
  featuredImage?: {
    url: string;
    altText?: string;
  };
}

export default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [shop, setShop] = useState("");

  const fetchProducts = async () => {
    try {
      setLoading(true);
      const response = await fetch("/api/products");
      const data = await response.json();

      if (data.success) {
        setProducts(data.products);
        setShop(data.shop);
      } else {
        console.error("获取商品失败:", data.error);
      }
    } catch (error) {
      console.error("请求失败:", error);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchProducts();
  }, []);

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
          <p className="text-gray-600">加载商品中...</p>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-7xl mx-auto px-4">
        <div className="flex justify-between items-center mb-8">
          <div>
            <h1 className="text-3xl font-bold text-gray-900">商品列表</h1>
            <p className="text-gray-600 mt-2">店铺: {shop}</p>
          </div>
          <button
            onClick={fetchProducts}
            className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
          >
            刷新
          </button>
        </div>

        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
          {products.map((product) => (
            <div
              key={product.id}
              className="bg-white rounded-lg shadow-md overflow-hidden"
            >
              {product.featuredImage && (
                <div className="aspect-square relative">
                  <Image
                    src={product.featuredImage.url}
                    alt={product.featuredImage.altText || product.title}
                    fill
                    className="object-cover"
                  />
                </div>
              )}

              <div className="p-4">
                <h3 className="font-semibold text-gray-900 mb-2 line-clamp-2">
                  {product.title}
                </h3>

                <div className="flex justify-between items-center">
                  <span className="text-lg font-bold text-green-600">
                    {product.priceRangeV2.minVariantPrice.currencyCode}{" "}
                    {product.priceRangeV2.minVariantPrice.amount}
                  </span>

                  <span
                    className={`px-2 py-1 rounded-full text-xs font-medium ${
                      product.status === "ACTIVE"
                        ? "bg-green-100 text-green-800"
                        : "bg-gray-100 text-gray-800"
                    }`}
                  >
                    {product.status}
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>

        {products.length === 0 && (
          <div className="text-center py-12">
            <p className="text-gray-500 text-lg">暂无商品</p>
          </div>
        )}
      </div>
    </div>
  );
}