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

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

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

🎯 项目概述

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

📋 前置准备

1. Shopify Partner 账户设置

bash 复制代码
# 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. 开发环境准备

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

🔧 核心实现

1. 环境变量配置

bash 复制代码
# .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 配置初始化

typescript 复制代码
// 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做持久化存储和缓存管理

typescript 复制代码
// 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

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

typescript 复制代码
// 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 数据

typescript 复制代码
// 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 语法请求接口

typescript 复制代码
// 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. 前端页面实现

店铺连接页面
tsx 复制代码
// 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>  
);  
}  
商品列表页面
tsx 复制代码
// 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>  
);  
}  
相关推荐
Lsx_7 分钟前
MultiRepo 和 Monorepo:代码管理的演进与选择
前端·javascript·架构
潘多编程18 分钟前
构建企业级Web应用:AWS全栈架构深度解析
前端·架构·aws
裕波21 分钟前
Vue 与 Vite 生态最新进展:迈向一体化与智能化的未来
前端·vue.js
destinying39 分钟前
当部分请求失败时,前端如何保证用户体验不崩溃?
前端·javascript·程序员
幼儿园技术家1 小时前
Diff算法的简单介绍
前端
陈随易1 小时前
为VSCode扩展开发量身打造的UI库 - vscode-elements
前端·后端·程序员
叁金Coder1 小时前
业务系统跳转Nacos免登录方案实践
前端·javascript·nginx·nacos
蓝倾1 小时前
京东商品销量数据如何获取?API接口调用操作详解
前端·api·fastapi
stoneship1 小时前
满帮微前端
前端
GKDf1sh1 小时前
【前端安全】聊聊 HTML 闭合优先级和浏览器解析顺序
前端·安全·html