跳到主要内容

嵌入店铺后台

这是"使用 Node.js + Express.js 构建 Shoplazza App"系列的第 2 篇。在 第 1 篇 中,你构建了一个独立应用:它在自己的浏览器标签页中打开,通过 URL 上的 shop 参数识别店铺。本篇构建一个嵌入式应用:它运行在 Shoplazza 后台的 iframe 内,通过 App Bridge + Session Token 完成身份认证。

你将构建的应用会在店铺后台内打开,获取并展示当前店铺信息;同时加上 App Bridge 的保存栏,让应用与后台体验一致。

你将学到什么

在本教程中,你将学到如何:

  • 区分嵌入式应用与独立应用
  • 用 App Bridge SDK 在 iframe 内获取 Session Token
  • 在后端验证 Session Token(JWT),从而对请求做身份认证
  • 用 Vite 打包前端,并由 Express 提供静态资源
  • 调用 Shoplazza OpenAPI 读取店铺信息
  • 使用保存栏等 App Bridge 后台原生组件

嵌入式应用 vs 独立应用

OAuth 授权流程和 HMAC 校验与独立应用完全一致,区别只在应用的运行方式和前后端的身份认证机制

维度独立应用(第 1 篇)嵌入式应用(本篇)
运行位置独立浏览器标签页,自有域名Shoplazza 后台的 iframe 内
前后端认证shop URL 参数 + 服务端持有的 access_tokenSession Token(JWT),请求头 Authorization: Bearer <token>
前端形态可纯服务端渲染 HTML需打包的前端(Vite),引入 App Bridge SDK
shop 来源信任 URL ?shop=(可被伪造)从已验证的 JWT 解出(无法伪造)
额外依赖jsonwebtokenshoplazza-app-bridgevite

相关概念见 应用类型App Bridge 概览

前置需求

  • 已完成 第 1 篇,或熟悉其中的 OAuth 流程与 HMAC 校验
  • 已在 Shoplazza Partners 创建应用,并开启了嵌入(见 Step 2)
  • 已在 Partners 创建测试店铺
  • Node.js 版本 ≥ 18

Step 1:搭建项目基础

项目搭建、ngrok 内网穿透、.env 文件、OAuth 工具函数都与第 1 篇相同。这里直接复用,只新增嵌入式应用特有的部分。

1.1 创建项目并复用第 1 篇的工具函数

mkdir embedded-app-demo
cd embedded-app-demo
npm init -y

从第 1 篇项目中复制以下两个文件,内容不变:

  • utils/index.js——环境变量检查、HMAC 校验、isValidShopstate 存储(见 第 1 篇 Step 4.1
  • utils/sqlite.js——以店铺域名为主键的 saveToken / getShopToken(见 第 1 篇 Step 4.4

1.2 安装依赖

npm install express axios dotenv sqlite3 jsonwebtoken shoplazza-app-bridge
npm install -D vite nodemon

前四个(expressaxiosdotenvsqlite3)与第 1 篇相同,新增的是嵌入式应用特有的依赖:

  • jsonwebtoken:验证前端传来的 Session Token(JWT),这是嵌入式认证的核心
  • shoplazza-app-bridge:App Bridge 前端 SDK,在 iframe 内与后台通信、获取 Session Token、调用原生组件
  • vite:前端打包工具
  • nodemon:开发期自动重启服务

crypto 是 Node.js 内置模块(用于 HMAC 校验),不要通过 npm 安装。

1.3 配置启动脚本

前端现在需要打包,修改 package.jsonscripts

{
"scripts": {
"build": "vite build",
"start": "npm run build && nodemon index.js",
"dev": "nodemon index.js"
}
}
  • build:用 Vite 把 src/main.js 打包到 dist/
  • start:先打包前端再启动服务
  • dev:仅启动服务(前端已打包时使用)

Step 2:在 Partners 开启嵌入

在 Shoplazza Partners 应用设置中:

  1. 开启 App embed(嵌入到店铺后台),让应用以 iframe 形式加载到后台中。
  2. 设置两个回调地址(使用 第 1 篇 Step 2 中 ngrok 生成的 HTTPS 地址):
    • App URLhttps://xxx.ngrok-free.dev/auth
    • Redirect URLhttps://xxx.ngrok-free.dev/auth/callback

.env 文件与第 1 篇相同(CLIENT_IDCLIENT_SECRETBASE_URLSCOPES)。本教程使用 SCOPES=read_shop

Step 3:实现 OAuth 授权流程

OAuth 路由与第 1 篇相同:/auth 校验 HMAC 后跳转到授权页,/auth/callbackcode 换取 access_token 并存入 SQLite。

只有一处嵌入式特有的改动:回调完成后跳转到 /,由它返回前端 bundle,而不是返回服务端渲染好的结果。店铺信息随后由前端通过 Session Token 异步获取。

在项目根目录创建 index.js

require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const axios = require("axios");
const jwt = require("jsonwebtoken");

const {
checkEnvMiddleware,
hmacValidator,
isValidShop,
saveState,
consumeState,
CLIENT_ID,
CLIENT_SECRET,
SCOPES,
REDIRECT_URI,
} = require("./utils/index");
const { saveToken, getShopToken } = require("./utils/sqlite");

const app = express();

// 提供 Vite 打包后的前端静态资源
app.use("/static", express.static(path.join(__dirname, "dist")));

// 授权入口——与第 1 篇相同
app.get("/auth", checkEnvMiddleware, hmacValidator, (req, res) => {
const { shop } = req.query;
if (!isValidShop(shop)) {
return res.status(400).send("Invalid shop domain");
}
const state = crypto.randomBytes(16).toString("hex");
saveState(state, shop);

const scopesEncoded = encodeURIComponent(SCOPES);
const redirectUriEncoded = encodeURIComponent(REDIRECT_URI);

res.redirect(
`https://${shop}/admin/oauth/authorize?client_id=${CLIENT_ID}&scope=${scopesEncoded}&redirect_uri=${redirectUriEncoded}&response_type=code&state=${state}`
);
});

// 授权回调——与第 1 篇相同,但跳转到前端
app.get("/auth/callback", checkEnvMiddleware, hmacValidator, async (req, res) => {
const { shop, code, state } = req.query;
if (!isValidShop(shop) || !consumeState(state, shop)) {
return res.status(400).send("Invalid shop or state");
}

const { data } = await axios.post(`https://${shop}/admin/oauth/token`, {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
});

await saveToken(shop, data.access_token);

// 嵌入式差异:跳转到前端入口,而非返回渲染结果
res.redirect("/");
});

HMAC 校验和换取 token 的完整说明见 第 1 篇 Step 4App 授权参考

Step 4:验证 Session Token

这是嵌入式应用区别于独立应用的关键。

由于应用运行在 iframe 中,前端发起的每个请求都会携带一个 Session Token(JWT)。后端用 CLIENT_SECRET 验证 JWT 的签名,从而确认请求确实来自当前登录的店铺与用户。店铺域名从已验证的 token(dest)中解出,而不是信任任何人都能伪造的 shop 参数。

index.js 中添加中间件:

function verifySessionToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing session token" });
}

const token = authHeader.replace("Bearer ", "");

try {
// jwt.verify 会自动校验签名和 exp 过期时间
const decoded = jwt.verify(token, CLIENT_SECRET, { algorithms: ["HS256"] });

// 将已验证 token 中的可信信息附加到请求上
req.session = {
shop: decoded.dest, // 店铺域名(可信)
userId: decoded.sub, // 用户 ID
locale: decoded.locale, // 语言
account: decoded.account, // 用户账号
};

next();
} catch (err) {
return res.status(401).json({ error: "Invalid session token" });
}
}

JWT 的结构、各字段含义以及更多后端验证说明,见 Session Token

Step 5:返回嵌入式页面

入口路由 / 不渲染数据,而是返回前端 HTML,并把 CLIENT_ID 注入到 <meta> 标签,供 App Bridge SDK 读取。

从 Partners 安装时,URL 中可能不带 shop 参数。前端会通过 Session Token 拿到店铺信息,因此该路由无需依赖 shop

index.js 中添加:

app.get("/", (req, res) => {
let html = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf8");

// 注入 CLIENT_ID,供前端 App Bridge SDK 使用
html = html.replace(
"<head>",
`<head>\n <meta name="client-id" content="${CLIENT_ID}">`
);

res.status(200).send(html);
});

Step 6:实现受保护的 API 端点

店铺信息端点用 verifySessionToken 保护。shop 取自已验证的 JWT,再用 SQLite 中的 access_token 调用 OpenAPI。

index.js 中添加:

app.get("/api/shop-info", verifySessionToken, async (req, res) => {
const shop = req.session.shop; // 来自已验证的 JWT——可信

const token = await getShopToken(shop);
if (!token) {
return res.status(401).json({ error: "Shop not authorized", needAuth: true });
}

try {
const { data } = await axios.get(`https://${shop}/openapi/2025-06/shop`, {
headers: { "Access-Token": token },
});
res.json(data);
} catch (err) {
const status = err.response?.status || 500;
res.status(status).json({ error: err.response?.data || err.message });
}
});

const PORT = 3001;
app.listen(PORT, () =>
console.log(`Embedded App is listening on port ${PORT}`)
);

后端到此完成。完整流程为:从 Partners 安装 → /auth(HMAC 校验)→ 授权页 → /auth/callback 换取 token → 跳转 / 返回前端 → 前端携带 Session Token 调用 /api/shop-info

Step 7:用 Vite + App Bridge 编写前端

7.1 配置 Vite

创建 vite.config.js,将 src/main.js 打包到 dist/main.js

import { defineConfig } from "vite";

export default defineConfig({
build: {
outDir: "dist",
rollupOptions: {
input: { main: "src/main.js" },
output: { entryFileNames: "[name].js", format: "es" },
},
},
});

7.2 创建 HTML 页面

在项目根目录创建 index.html。它通过 /static 引入打包后的前端,该目录由 Express 从 dist/ 提供:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shoplazza Embedded App</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 20px; background: #f5f5f5; }
#loading { text-align: center; padding: 40px; color: #666; }
#content { display: none; background: #fff; padding: 20px; border-radius: 8px; }
pre { background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }
</style>
</head>
<body>
<div id="loading">加载中...</div>
<div id="content">
<h1>授权完成 ✅</h1>
<div>店铺信息:</div>
<div id="shop-info"></div>
<div class="save-box">
<input class="save-input" type="text" value="demo">
</div>
</div>

<script type="module" src="/static/main.js"></script>
</body>
</html>

不要在这里写 <meta name="client-id">。它由后端在返回页面时注入(见 Step 5)。

7.3 编写前端入口

创建 src/main.js。它完成嵌入式前端必做的三件事:初始化 App Bridge、获取 Session Token、携带 Bearer 调用后端。

import { app, getSessionToken } from "shoplazza-app-bridge";

// 从后端注入的 meta 标签读取 CLIENT_ID
const meta = document.querySelector('meta[name="client-id"]');
const CLIENT_ID = meta ? meta.content : "";

// 初始化 App Bridge
app.init();

async function fetchShopInfo() {
// Session Token 约 1 分钟过期——每次请求都重新获取
const sessionToken = await getSessionToken(CLIENT_ID);

const response = await fetch("/api/shop-info", {
headers: { Authorization: "Bearer " + sessionToken },
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Request failed");
}

const data = await response.json();
document.getElementById("loading").style.display = "none";
document.getElementById("content").style.display = "block";
document.getElementById("shop-info").innerHTML =
"<pre>" + JSON.stringify(data, null, 2) + "</pre>";
}

fetchShopInfo().catch((err) => {
document.getElementById("loading").textContent = "加载失败:" + err.message;
});

App Bridge 的安装与初始化细节见 App Bridge 概览

Step 8:使用原生组件(可选)

嵌入式应用可以调用后台的原生 UI 组件。下面的示例加入保存栏:当输入框内容变化时,后台顶部弹出"保存 / 取消"栏。

src/main.js 末尾追加:

import { contextualSaveBar } from "shoplazza-app-bridge";

const saveInput = document.querySelector(".save-input");
const initialValue = saveInput.value;

contextualSaveBar.hide();

saveInput.addEventListener("input", (e) => {
if (e.target.value === initialValue) {
contextualSaveBar.hide();
} else {
contextualSaveBar.show();
}
});

contextualSaveBar.setSaveAction({
type: "primary",
text: "保存",
onClick: () => {
console.log("Saved:", saveInput.value);
contextualSaveBar.hide();
},
});

contextualSaveBar.setDiscardAction({
type: "default",
text: "取消",
discardConfirmationModal: true,
onClick: () => {
saveInput.value = initialValue;
contextualSaveBar.hide();
},
});

App Bridge 还提供 BackLinkRedirect。完整列表见 App Bridge actions

Step 9:构建与测试

9.1 启动应用

npm run start

该命令会先用 Vite 把 src/main.js 打包到 dist/,再在 3001 端口启动 Express。在另一个终端启动 ngrok:

ngrok http 3001

9.2 安装到测试店铺

  1. 登录 Shoplazza Partners,进入应用 → 应用测试 → 选择店铺 → 安装。
  2. 跳转到 Shoplazza 授权页,点击安装。
  3. 授权后,应用以 iframe 形式嵌入到店铺后台中打开。

9.3 验证效果

  • 页面从"加载中..."切换为"授权完成 ✅",并展示当前店铺信息(Session Token 验证后从 OpenAPI 获取)。
  • 修改输入框内容,后台顶部弹出原生"保存 / 取消"栏。

排错提示

  • Missing session token / Invalid session token:前端未获取或未携带 Session Token。检查 CLIENT_ID 是否正确注入、getSessionToken 是否报错。
  • Session Token 过期:token 约 1 分钟过期。每次请求前重新获取,不要缓存。
  • HMAC validation failedCLIENT_SECRET 配置错误,或回调 URL 与 Partners 后台不一致。
  • iframe 无法加载:确认 Partners 后台已开启 App embed,且 App URL / Redirect URL/auth/auth/callback 路由一致。
  • 静态资源 404:确认 vite build 已生成 dist/,且 /static 映射正确。

下一步

恭喜你!你构建了一个通过 Session Token 认证、在店铺后台内渲染的嵌入式 Shoplazza App。接下来你可以:

  • 接入前端框架:把 src/main.js 替换为 React 或 Vue 入口,配合 App Bridge 构建更丰富的后台界面
  • 探索更多原生组件:见 App Bridge actions
  • 发布应用:提交到应用市场,见 Shoplazza App 发布
  • 深入授权机制:见 App 授权参考