嵌入店铺后台
这是"使用 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_token | Session Token(JWT),请求头 Authorization: Bearer <token> |
| 前端形态 | 可纯服务端渲染 HTML | 需打包的前端(Vite),引入 App Bridge SDK |
shop 来源 | 信任 URL ?shop=(可被伪造) | 从已验证的 JWT 解出(无法伪造) |
| 额外依赖 | — | jsonwebtoken、shoplazza-app-bridge、vite |
相关概念见 应用类型 和 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 校验、isValidShop和state存储(见 第 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
前四个(express、axios、dotenv、sqlite3)与第 1 篇相同,新增的是嵌入式应用特有的依赖:
jsonwebtoken:验证前端传来的 Session Token(JWT),这是嵌入式认证的核心shoplazza-app-bridge:App Bridge 前端 SDK,在 iframe 内与后台通信、获取 Session Token、调用原生组件vite:前端打包工具nodemon:开发期自动重启服务
crypto是 Node.js 内置模块(用于 HMAC 校验),不要通过 npm 安装。
1.3 配置启动脚本
前端现在需要打包,修改 package.json 的 scripts:
{
"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 应用设置中:
- 开启 App embed(嵌入到店铺后台),让应用以 iframe 形式加载到后台中。
- 设置两个回调地址(使用 第 1 篇 Step 2 中 ngrok 生成的 HTTPS 地址):
App URL:https://xxx.ngrok-free.dev/authRedirect URL:https://xxx.ngrok-free.dev/auth/callback
.env 文件与第 1 篇相同(CLIENT_ID、CLIENT_SECRET、BASE_URL、SCOPES)。本教程使用 SCOPES=read_shop。
Step 3:实现 OAuth 授权流程
OAuth 路由与第 1 篇相同:/auth 校验 HMAC 后跳转到授权页,/auth/callback 用 code 换取 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 4 和 App 授权参考。
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 还提供 BackLink 和 Redirect。完整列表见 App Bridge actions。
Step 9:构建与测试
9.1 启动应用
npm run start
该命令会先用 Vite 把 src/main.js 打包到 dist/,再在 3001 端口启动 Express。在另一个终端启动 ngrok:
ngrok http 3001
9.2 安装到测试店铺
- 登录 Shoplazza Partners,进入应用 → 应用测试 → 选择店铺 → 安装。
- 跳转到 Shoplazza 授权页,点击安装。
- 授权后,应用以 iframe 形式嵌入到店铺后台中打开。
9.3 验证效果
- 页面从"加载中..."切换为"授权完成 ✅",并展示当前店铺信息(Session Token 验证后从 OpenAPI 获取)。
- 修改输入框内容,后台顶部弹出原生"保存 / 取消"栏。
排错提示
Missing session token/Invalid session token:前端未获取或未携带 Session Token。检查CLIENT_ID是否正确注入、getSessionToken是否报错。- Session Token 过期:token 约 1 分钟过期。每次请求前重新获取,不要缓存。
HMAC validation failed:CLIENT_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 授权参考