找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5050

积分

0

好友

688

主题
发表于 2 小时前 | 查看: 4| 回复: 0

最近帮朋友做了个有趣的项目:他想在 Shopify 上卖产品,但需要一个自定义的配置环节。于是,我用 Next.js 16 写了一个手链 DIY 配置器,部署在 Vercel 上,而主站则继续运行在 Shopify 上。整个流程是:客户进入 DIY 页面选珠子、设计手串、点击“完成”——前端调用 Shopify 的 Storefront API 创建购物车,然后将用户跳转到 Shopify 的原生结账页面完成付款。当这一切跑通时,确实让人兴奋:一个自建的 Next.js 应用,就这样无缝对接上了一套成熟的 SaaS 电商系统。

这其实就是业界所谓的 Headless Commerce(无头电商)——让 Shopify 负责商品、库存、订单、支付、物流这些“电商基础设施”,而我们的 Next.js 应用则专注于实现差异化的交互和私有业务逻辑(比如这个 DIY 配置器)。分工明确,各取所长。当然,这套方案也适用于其他想在 Shopify 上对接独立 Web 应用的场景。

然而,实际的对接过程比预想的花了更多功夫。Shopify 在 2025 年底砍掉了老版的“自定义应用”流程,强制所有开发者转向 Dev Dashboard + OAuth 的新模式。官方文档刚更新、界面全新,网上大部分老教程都已失效。这篇文章旨在完整走一遍这条新路径,并把关键代码放出来,希望能帮助正在做类似事情的朋友少绕点弯路。

整体架构设计

先明确一下三方分工:

┌─────────────────┐      Storefront API (GraphQL)
│ diy.xxxx.com  │ ──────────────────────────────┐
│  Next.js 16     │                             ▼
│  (Vercel)       │                 ┌──────────────────┐
│                 │ ◀───── Webhook ────│  Shopify 店铺    │
│  /builder       │                 │  xxxxxh-me       │
│  /admin/beads   │ ──── Admin API ───▶│  .myshopify.com  │
└─────────────────┘                 └──────────────────┘
        ▲                                     │
        │                                     ▼
        │       用户 ──► lxxxx.com/checkouts/...
        └──────── 跳转回 ────────┘              ▲
                                               │
                                      Shopify 原生 checkout
  • DIY 前端 运行在 Vercel,使用 Storefront API 拉取商品(珠子)、生成购物车链接。
  • Shopify 店铺 负责商品库存、订单、支付、物流,这些成熟的模块我们不再重复开发。
  • Admin API 让我们自己的 /admin 页面可以管理“珠子”这类自定义商品数据。
  • Webhook 让 Shopify 在商品/库存发生变动时,能够通知 Vercel 刷新缓存。

客户从独立站选完商品后,跳回主站结账,域名和品牌保持统一(*.xxxx.com)。支付环节直接使用 Shopify 的 checkout,也无需自己操心 PCI 合规问题。可谓一举多得。

第一个挑战:Shopify 的变革,Dev Dashboard 成为唯一路径

进入 Shopify 后台搜索“开发应用”,你会看到一段直白的提示:

Dev Dashboard 是您全新的应用开发平台,与遗留自定义应用相比,它提供了更多的特性和工具。

老手熟悉的“遗留自定义应用”流程——创建应用、勾选权限、一键生成 shpat_xxx token——这条路已被官方明确弃用。或者说,它不再是主流的推荐路线,迟早会面临整改下线。

新的路径是:在 Dev Dashboard 里创建应用 → 创建版本 → 发布 → 安装到店铺 → 通过 OAuth code exchange 换取 token。流程复杂了三四倍,但方向是对的:采用标准化的 OAuth、分离 Client ID 与 Secret、token 可吊销、权限范围可渐进式升级。

在版本创建的表单里,其实还藏着一个“使用旧版安装流程”的勾选框,勾上还能使用老方式。我第一反应是勾上省事,但很快打消了这个念头——Shopify 自己都标注了“deprecated”,今天偷懒明天就得重写。既然早晚要做,不如一次做对。

OAuth 授权码流程的实现

新流程官方推荐的是 Authorization Code Grant,整个数据流如下:

浏览器   ──►  /api/shopify/install          (我们的路由)
        ──►  写 state cookie(CSRF 防御)
        ──►  302 到 https://{shop}/admin/oauth/authorize?...

用户     ──►  看到 scope 列表,点“安装”

Shopify  ──►  302 回 /api/shopify/callback?code=&hmac=&state=&shop=

我们     ──►  校验 shop 白名单
        ──►  校验 state(timing-safe)
        ──►  校验 hmac (timing-safe, hex digest)
        ──►  POST {shop}/admin/oauth/access_token  
             body: {client_id, client_secret, code}
        ◀──  { access_token, scope }
        ──►  用 access_token 调 storefrontAccessTokenCreate mutation
        ◀──  storefront token
        ──►  渲染 HTML 一次性展示两个 token

我翻看了一下现有的业务代码,两个 Shopify client 都只是从环境变量里读取 token:

// src/lib/shopify/admin-client.ts
function getAdminConfig() {
  const domain = process.env.SHOPIFY_DOMAIN ?? "";
  const token = process.env.SHOPIFY_ADMIN_TOKEN ?? "";
  if (!domain || !token) {
    throw new Error("Missing Shopify domain or admin token");
  }
  return {
    endpoint: `https://${domain}/admin/api/2026-04/graphql.json`,
    token,
  };
}

关键点来了:只要能把正确的 token 搞进环境变量,现有的业务代码就完全不需要改动。 所以,我们只需要写一个一次性的 OAuth 回调流程来获取 token。

Install 路由

// src/app/api/shopify/install/route.ts
export async function GET(request: Request) {
  const url = new URL(request.url);
  const shop =
    url.searchParams.get("shop") ?? process.env.SHOPIFY_DOMAIN ?? "";

  if (!isAllowedShop(shop)) {
    return NextResponse.json({ error: "Shop is not allowed." }, { status: 400 });
  }

  const clientId = process.env.SHOPIFY_APP_CLIENT_ID!;
  const scopes = process.env.SHOPIFY_APP_SCOPES!;
  const redirectUri = `${url.origin}/api/shopify/callback`;
  const state = randomBytes(32).toString("hex");

  const cookieStore = await cookies();
  cookieStore.set("shopify_oauth_state", state, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 5 * 60,
    path: "/api/shopify",
  });

  return NextResponse.redirect(
    buildInstallUrl({ shop, clientId, scopes, redirectUri, state }),
    { status: 302 },
  );
}

state 是一个 32 字节的随机 hex 字符串,通过 cookie 存储并限定在 /api/shopify 路径下,设置 SameSite=Lax 以确保 Shopify 跳转回来时 cookie 能被带回。

Callback 路由(最核心)

export async function GET(request: Request) {
  const params = new URL(request.url).searchParams;
  const shop = params.get(“shop”);
  const code = params.get(“code”);
  const state = params.get(“state”);

  // 1. shop 白名单——防止别的店铺打到我们的回调
  if (!isAllowedShop(shop)) {
    return NextResponse.json({ error: “Shop is not allowed.” }, { status: 400 });
  }

  // 2. state 校验——防 CSRF
  const cookieStore = await cookies();
  const savedState = cookieStore.get(“shopify_oauth_state”)?.value;
  if (!savedState || !safeStateCompare(savedState, state!)) {
    return NextResponse.json({ error: “Invalid OAuth state.” }, { status: 400 });
  }

  // 3. HMAC 校验——证明这个回调确实来自 Shopify
  if (!verifyOAuthHmac(params, process.env.SHOPIFY_APP_CLIENT_SECRET!)) {
    return NextResponse.json({ error: “Invalid HMAC signature.” }, { status: 401 });
  }

  // 4. code 换 access_token
  const tokenResult = await exchangeCodeForToken({
    shop: shop!,
    code: code!,
    clientId: process.env.SHOPIFY_APP_CLIENT_ID!,
    clientSecret: process.env.SHOPIFY_APP_CLIENT_SECRET!,
  });

  // 5. 用 admin token 生成 storefront token
  const { token: storefrontToken } = await createStorefrontToken(
    shop!,
    tokenResult.access_token,
  );

  // 6. 渲染 HTML 给用户复制
  return new Response(renderTokenPage({ ... }), {
    status: 200,
    headers: {
      “Content-Type”: “text/html; charset=utf-8”,
      “Cache-Control”: “no-store”,
      “Referrer-Policy”: “no-referrer”,
    },
  });
}

三层校验顺序必须严格:shop → state → hmac,任何一层失败都应立刻终止流程。其中,state 和 hmac 的比较需要使用时间恒定比较(crypto.timingSafeEqual)来防止定时攻击。

注意:HMAC 的两种编码

对接过程中踩到了一个有点隐蔽的细节:Shopify 在两处不同场景都要求验签,但使用的编码方式却不一样。

场景 输入 Digest 编码 对比来源
Webhook 推送 请求的 raw body base64 X-Shopify-Hmac-SHA256 header
OAuth callback 排序后的 query string,& 拼接 hex hmac query 参数

两段代码放在一起看就一目了然了:

// Webhook 验签:base64
const digest = createHmac(“sha256”, secret)
  .update(rawBody, “utf8”)
  .digest(“base64”);

// OAuth 验签:hex
const entries: [string, string][] = [];
for (const [key, value] of searchParams.entries()) {
  if (key === “hmac” || key === “signature”) continue;
  entries.push([key, value]);
}
entries.sort(([a], [b]) => (a < b ? -1 : 1));
const message = entries.map(([k, v]) => `${k}=${v}`).join(“&”);
const digest = createHmac(“sha256”, secret)
  .update(message, “utf8”)
  .digest(“hex”);

两处比较都应使用 crypto.timingSafeEqual

这种细节分布在官方文档的不同 URL 里(webhooks/subscribe/httpsaccess-tokens/authorization-code-grant),第一次对接很容易把 base64 的逻辑套到 OAuth 上,或者反过来——导致签名怎么都对不上。查清官方文档、明确两种编码的边界之后,才能一次写对。

Token 的一次性展示 UI

新流程没有官方的“展示 token”页面——token 是 OAuth 流程中的临时产物,由开发者自行决定如何存储。

对于单店铺场景,最轻量的做法是:在回调里渲染一个极简的 HTML 页面,将 Admin token 和 Storefront token 分别显示,并各配一个“复制”按钮,旁边写清楚对应的环境变量名。用户将它们粘贴到 Vercel 的环境变量配置中,重新部署一次,对接就完成了。之后这个 OAuth 路径就可以下线。

return new Response(
  renderTokenPage({
    shop,
    adminToken: tokenResult.access_token,
    storefrontToken,
    grantedScopes: tokenResult.scope,
  }),
  {
    headers: {
      “Content-Type”: “text/html; charset=utf-8”,
      “Cache-Control”: “no-store”,        // 不进浏览器缓存
      “Referrer-Policy”: “no-referrer”,   // 不通过 Referer 泄漏
    },
  },
);

多店铺的 SaaS 场景另当别论,token 需要持久化到数据库并带上店铺维度的索引。但对于单店,直接用环境变量最轻量,后续需要扩展时再迁移也不迟。

一个让我印象深刻的错误

token 配齐、重新部署完成后,我兴奋地打开 /builder 页面——能看到珠子了!但点击“完成设计”后,却报错了:

ID 为 gid://shopify/ProductVariant/0 的商品不存在。

0?哪来的 0?页面上明明能看到 4 颗珠子,价格、颜色都对啊。

翻看代码找到了原因。问题的根源在于,我们的商品需要先在 Shopify 后台手动添加。

// src/lib/load-beads.ts(修复前)
export async function loadBeads(): Promise<Bead[]> {
  try {
    const beads = await getBeadsFromShopify();
    if (beads.length > 0) return beads;
    return mockBeads;   // ← 静默回落到 mock 数据
  } catch {
    return mockBeads;
  }
}

店铺里还没有打上 bracelet-bead 标签的真实商品,Shopify 返回空数组 → 代码静默回落到 mockBeads。而 mock 数据里的 variant ID 是这样的:

id: “gid://shopify/ProductVariant/mock-white-8”

Shopify 的 GID 解析器期望最后一段是数字,碰到 mock-white-8 会直接解析成 0。所以前端看到的是“假珠子”,送到后端才爆出 ProductVariant/0 不存在 的错误。

修正方法:

function isShopifyConfigured(): boolean {
  return Boolean(
    process.env.SHOPIFY_DOMAIN && process.env.SHOPIFY_STOREFRONT_TOKEN,
  );
}

export async function loadBeads(): Promise<Bead[]> {
  if (!isShopifyConfigured()) return mockBeads;
  try {
    return await getBeadsFromShopify();   // 空数组也照样返回,不再回落
  } catch (error) {
    console.error(“[load-beads] Shopify fetch failed:”, error);
    return [];
  }
}

这个教训很朴素:生产环境的 fallback 机制必须足够清晰。“悄悄用 mock 数据顶一下”这种好心的设计,只会让错误排查变得更困难——一个空页面至少是一个明确的信号,而假数据流入真实后端只会产生诡异的错误。

在架构上的几个小决策

1. 客户端不直连 Shopify

很多 Headless 教程会让客户端直接使用 NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN 调用 Shopify,省去一层代理。我们反其道而行:客户端只调用 /api/shopify/cart 这类自家的路由,token 完全不离开服务端

结果就是:.env 文件里一个 NEXT_PUBLIC_* 前缀的变量都没有,泄漏风险面最小。代价是多了一次网络请求,但在 Vercel 边缘节点和 Shopify CDN 的加持下,增加的延迟几乎感知不到。

2. Admin 与 Storefront 两个 Token 严格分离

Admin token 权限很大(能读写商品、文件、订单),我们只允许它在服务端的 /admin/* 路由里使用。Storefront API token 的权限则被限定在 unauthenticated_* 范围内,即使泄漏也只能进行只读查询和未认证的结账操作。

这种分离并非多此一举,而是 Shopify 权限体系的有意设计。

3. API version 对齐

Dev Dashboard 中 Webhook 的 API 版本是 2026-04,我们的三个客户端(admin、storefront、callback)也全部统一使用 2026-04,避免因字段差异引发的莫名 GraphQL 报错。

最终的环境变量清单

跑通整个流程,只需要这 7 个环境变量(全部位于服务端):

# 部署前填写
SHOPIFY_DOMAIN=xxxx-me.myshopify.com
SHOPIFY_APP_CLIENT_ID=...
SHOPIFY_APP_CLIENT_SECRET=...
SHOPIFY_APP_SCOPES=read_products,write_products,...

# OAuth 回调页面拿到后回填
SHOPIFY_ADMIN_TOKEN=shpat_...
SHOPIFY_STOREFRONT_TOKEN=...

# Webhook 创建后回填
SHOPIFY_WEBHOOK_SECRET=...

此外,在 Shopify 后台还需要完成以下几件事:

  1. Dev Dashboard:创建应用、发布版本(勾选所需权限、填写重定向 URL https://diy.xxxx.com/api/shopify/callback)。
  2. 产品元字段 (Metafields):定义 bead.color_hexbead.categorybead.texture_url,并开启 Storefront API 访问权限
  3. 创建商品:创建打了 bracelet-bead 标签的真实珠子商品,包含 variants、库存和上一步定义的 metafields。
  4. Webhook:创建 Webhook,指向 https://diy.xxxx.com/api/shopify/webhooks

手链DIY配置器前端界面、商品配置与Shopify订单确认页

全部打通后,流程闭环:用户在 diy.xxxx.com/builder 选珠 → 跳转至 Shopify checkout → 支付成功 → Shopify 推送 webhook 回 Vercel → 缓存同步。DIY 交互和电商基础设施各司其职,后续的营销活动、订单统计、库存补货都能直接复用 Shopify 自身成熟的一整套工具。

写在最后

这次对接最大的感受不是“技术本身有多难”——HMAC、OAuth、GraphQL 这些东西,用过 Next.js 或 Express 几年的人都不陌生。真正的难点在于 Shopify 自身的文档和界面正处于快速切换期,新旧流程并存

  • 后台里“开发应用”的按钮明明还亮着,点进去却被引导到全新的 Dev Dashboard。
  • Dev Dashboard 的版本创建表单里,还保留着“使用旧版安装流程”的勾选框,勾上能用但已标注为废弃。
  • StackOverflow、掘金、CSDN 上 2020~2023 年的大量教程,其流程早已失效。
  • 官方的 shopify.dev 文档分散在好几个 URL 中,需要开发者自己拼凑出完整路径。

从零开始,完整走完一次与 Shopify 独立站的对接,大约花了一天时间。回过头看,真正的核心代码量也就三个文件、两百行左右——大部分时间都花在了理清 Shopify 自身新旧流程的边界上。

这篇记录希望能对你有用。如果你也在进行类似的集成,欢迎来 云栈社区 交流探讨。有了这次经验,下次再做类似的事情,估计能节省一半的时间。




上一篇:PCIe总线复位详解:冷、暖、热、FLR,硬件开发与故障调试必知
下一篇:Anthropic成算力路线竞争焦点:谷歌TPU、AWS Trainium与GPU云架构对决
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-20 19:47 , Processed in 0.618122 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表