# 开发嵌入式应用

> Node.js + Express 教程第二篇：让 Shoplazza 应用跑在后台 iframe 中，并用 App Bridge 与 Session Token 完成鉴权。

这是"使用 Node.js + Express.js 构建 Shoplazza App"系列的第二篇。在上一篇[开发应用](/docs/app/getting-started/create-public-app/develop-app)中，你构建了一个**独立应用**：它在自己的浏览器标签页中打开，通过 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 校验与独立应用**完全一致**，区别只在应用的**运行方式**和前后端的**身份认证机制**。

| 维度 | 独立应用（开发应用篇） | 嵌入式应用（本篇） |
|---|---|---|
| 运行位置 | 独立浏览器标签页，自有域名 | 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` |

相关概念见 [应用类型](/docs/app/getting-started/app-types#embedded-apps-method) 和 [App Bridge 概览](/docs/app/developer-tools/app-bridge/)。

## 前置需求

- 已完成[开发应用](/docs/app/getting-started/create-public-app/develop-app)，或熟悉其中的 OAuth 流程与 HMAC 校验
- 拥有 [Shoplazza Partners](https://partners.shoplazza.com) 账号（应用在第一步由 CLI 创建，第二步开启嵌入）
- 已在 Partners 创建测试店铺
- Node.js 版本 ≥ 18

## 流程概览

从搭建项目到在店铺中测试的整体步骤如下：

```mermaid
flowchart LR
    A[搭建项目基础] --> B[在 Partners 开启嵌入]
    B --> C[实现 OAuth 授权流程]
    C --> D[验证 Session Token]
    D --> E[返回嵌入式页面]
    E --> F[实现受保护的 API 端点]
    F --> G[用 Vite + App Bridge 编写前端]
    G --> H[构建应用]
    H --> I[测试应用]
```

运行时，前端的每个请求都会携带 Session Token，由后端校验。下面的时序展示它如何签发与校验：

```mermaid
sequenceDiagram
    participant U as 商家浏览器前端
    participant AB as App Bridge
    participant SL as Shoplazza
    participant BE as 你的后端
    U->>AB: app.init 后 getSessionToken
    AB->>SL: 请求 Session Token
    SL-->>AB: 已签名的 Session Token JWT
    AB-->>U: Session Token
    U->>BE: 带 Bearer token 请求 /api/shop-info
    BE->>BE: 用 CLIENT_SECRET 校验 JWT 并读取 dest
    BE-->>U: 返回受保护的店铺数据
```

## 第一步：搭建项目基础

项目搭建、ngrok 内网穿透、`.env` 文件、OAuth 工具函数都与[开发应用](/docs/app/getting-started/create-public-app/develop-app)相同。这里直接复用，只新增嵌入式应用特有的部分。

### 1.1 创建项目并复用开发应用篇的工具函数

用 Shoplazza CLI 初始化应用工程：

```bash
shoplazza app init --name "embedded-app-demo"
cd embedded-app-demo
```

:::note
账号属于多个合作伙伴时加 `--partner <partner-id>`；应用已在 Partners 存在时改用 `shoplazza app init --client-id <your-client-id>` 关联——详见[开发应用第 1.2 节](/docs/app/getting-started/create-public-app/develop-app)。
:::

从开发应用篇的项目中复制以下两个文件，内容不变：

- `utils/index.js`——环境变量检查、HMAC 校验、`isValidShop` 和 `state` 存储（见[开发应用](/docs/app/getting-started/create-public-app/develop-app) 4.1 节）
- `utils/sqlite.js`——以店铺域名为主键的 `saveToken` / `getShopToken`（见[开发应用](/docs/app/getting-started/create-public-app/develop-app) 4.4 节）

### 1.2 安装依赖

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

前四个（`express`、`axios`、`dotenv`、`sqlite3`）与开发应用篇相同，新增的是嵌入式应用特有的依赖：

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

:::note
`crypto` 是 Node.js 内置模块（用于 HMAC 校验），**不要**通过 npm 安装。
:::

### 1.3 配置启动脚本

前端现在需要打包，把 `package.json` 的 `scripts` 更新如下——`start` 覆盖第 1 篇添加的条目，`build` 和 `dev` 为新增（保留 CLI 模板自带的条目）：

```json
{
  "scripts": {
    "build": "vite build",
    "start": "npm run build && nodemon index.js",
    "dev": "nodemon index.js"
  }
}
```

- `build`：用 Vite 把 `src/main.js` 打包到 `dist/`
- `start`：先打包前端再启动服务
- `dev`：仅启动服务（前端已打包时使用）

## 第二步：在 Partners 开启嵌入

在 Shoplazza Partners 应用设置中：

1. 开启 **App embed**（嵌入到店铺后台），让应用以 iframe 形式加载到后台中。
2. 设置两个回调地址（使用[开发应用](/docs/app/getting-started/create-public-app/develop-app)第二步中 ngrok 生成的 HTTPS 地址）：
   - `App URL`：`https://xxx.ngrok-free.dev/auth`
   - `Redirect URL`：`https://xxx.ngrok-free.dev/auth/callback`

`.env` 文件与开发应用篇相同（`CLIENT_ID`、`CLIENT_SECRET`、`BASE_URL`、`SCOPES`）。本教程使用 `SCOPES=read_shop`。

## 第三步：实现 OAuth 授权流程

OAuth 路由与开发应用篇相同：`/auth` 校验 HMAC 后跳转到授权页，`/auth/callback` 用 `code` 换取 `access_token` 并存入 SQLite。

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

在项目根目录创建 `index.js`：

```javascript
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")));

// 授权入口——与开发应用篇相同
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}`
  );
});

// 授权回调——与开发应用篇相同，但跳转到前端
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 的完整说明见[开发应用](/docs/app/getting-started/create-public-app/develop-app)第四步和 [App 授权参考](/docs/app/building-blocks/authentication/oauth-flow)。

## 第四步：验证 Session Token

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

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

在 `index.js` 中添加中间件：

```javascript
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](/docs/app/developer-tools/app-bridge/session-token)。

## 第五步：返回嵌入式页面

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

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

在 `index.js` 中添加：

```javascript
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);
});
```

## 第六步：实现受保护的 API 端点

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

在 `index.js` 中添加：

```javascript
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 = 3000;
app.listen(PORT, () =>
  console.log(`Embedded App is listening on port ${PORT}`)
);
```

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

## 第七步：用 Vite + App Bridge 编写前端

### 7.1 配置 Vite

创建 `vite.config.js`，将 `src/main.js` 打包到 `dist/main.js`：

```javascript
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/` 提供：

```html
<!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>
```

:::note
不要在这里写 `<meta name="client-id">`。它由后端在返回页面时注入（见第五步）。
:::

### 7.3 编写前端入口

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

```javascript
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 概览](/docs/app/developer-tools/app-bridge/)。

## 第八步：使用原生组件（可选）

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

在 `src/main.js` 末尾追加：

```javascript
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](/docs/app/developer-tools/app-bridge/actions/backlink) 和 [Redirect](/docs/app/developer-tools/app-bridge/actions/redirect)。完整列表见 [App Bridge actions](/docs/app/developer-tools/app-bridge/actions/)。

## 第九步：构建应用

```bash
npm run start
```

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

```bash
ngrok http 3000
```

## 第十步：测试应用

开发已经完成。前往[测试应用](/docs/app/getting-started/create-public-app/test-app)把应用安装到开发店铺并验证：应用应以 iframe 形式嵌入到后台中打开，页面从"加载中..."切换为"授权完成 ✅"并展示当前店铺信息，修改输入框内容后应在后台顶部弹出原生"保存 / 取消"栏。

## 排错提示

- **`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](/docs/app/developer-tools/app-bridge/actions/)
- **给应用配置收费（可选）**：如果应用不是免费的，见 [集成应用收款](/docs/app/building-blocks/billing/integration-guide)
- **在开发店铺测试**：提审前先见 [测试应用](/docs/app/getting-started/create-public-app/test-app)
- **发布应用**：提交到应用市场，见 [提交审核](/docs/app/getting-started/create-public-app/submit-review)
- **深入授权机制**：见 [App 授权参考](/docs/app/building-blocks/authentication/oauth-flow)
