# 开发独立应用

> Node.js + Express 教程第一篇：用 Shoplazza CLI 初始化应用、用 ngrok 做隧道，并实现独立应用的 OAuth 流程。

本教程是应用开发系列的第一步：先用 Shoplazza CLI 初始化应用工程，在其中用 Node.js + Express.js 跑通 OAuth，再在同一工程里添加[主题扩展](/docs/app/extensions/online-store/add-theme-extension)和[结账扩展](/docs/app/extensions/checkout/add-checkout-extension)。

完成本教程后，你可以选择保留**独立应用**（本教程，在自己的浏览器标签页打开），或继续阅读[开发嵌入式应用](/docs/app/getting-started/create-public-app/embed-in-admin)，让应用运行在店铺后台的 iframe 内。这是同一步骤的两种可选架构——二选一即可，不需要都做完才能进入测试与提审。

在本教程中，您将构建一个应用程序，通过调用 Shoplazza OpenAPI 接口，随机生成一个商品内容。

## 你将学到什么

在本教程中，你将学习如何执行以下任务：

- 如何用 Shoplazza CLI 初始化应用工程

- 如何快速开发一个示例 Shoplazza App

- 如何实现 OAuth 授权流程获取店铺访问权限

- 如何通过 HMAC 验证确保请求安全性

- 如何调用 Shoplazza OpenAPI 创建商品

## 前置需求

- 拥有 [Shoplazza Partners](https://partners.shoplazza.com) 账号（第一步中 CLI 会替你创建应用；如果已在 Partners 创建过应用，也可以直接关联——见[如何创建应用](/docs/app/getting-started/create-public-app/create-app)）

- 已在 Partners 创建了测试店铺

- Node.js 版本 ≥ 18（Node 14 已于 2023-04 EOL，建议使用 Volta / nvm 锁定版本）及 Git

- 基础的 JavaScript 和 Express 框架知识

## 流程概览

```mermaid
flowchart LR
    A[用 CLI 初始化应用工程] --> B[配置 ngrok 实现内网穿透]
    B --> C[配置环境变量]
    C --> D[实现 OAuth 授权流程]
    D --> E[创建应用首页]
    E --> F[调用 OpenAPI 创建商品]
    F --> G["添加扩展（可选）"]
    G --> H[测试应用]
```

## 第一步：用 Shoplazza CLI 初始化应用工程

### 1.1 安装 Shoplazza CLI

```bash
npm i -g shoplazza-cli
```

环境要求与其他安装方式见[安装 Shoplazza CLI](/docs/app/developer-tools/shoplazza-cli/install)。

### 1.2 初始化工程

在你的工作目录执行下面两条命令中的一条，取决于你是否已在 Partners 有一个应用。

**如果你在 Partners 还没有应用：**

```bash
shoplazza app init --name "my-app"
```

该命令会在你的合作伙伴账号下创建一个名为 `my-app` 的新应用，并在当前目录下生成同名工程子目录。

:::note
如果你的账号属于多个合作伙伴（partner），需要加 `--partner <partner-id>` 指定（可用 `shoplazza app list` 查询 ID）。
:::

**如果你已在 [Shoplazza Partners](https://partners.shoplazza.com) 创建过应用：**

```bash
shoplazza app init --client-id <your-client-id>
```

生成的工程结构如下：

```text
my-app/
├── shoplazza.app.toml    # 应用配置：client_id、partner_id、scopes
├── package.json          # 工程脚本与扩展 workspace
├── extensions/           # 扩展目录（当前为空，第七步会用到）
├── .shoplazza/           # CLI 状态（已被 git 忽略）
├── .gitignore
└── README.md
```

然后打开 `shoplazza.app.toml`，把 `scopes` 从模板默认值改成本教程需要的值，与 `.env` 中的 `SCOPES`（第三步配置）保持一致：

```toml
scopes = "write_product"
```

### 1.3 安装依赖

进入工程目录，安装本教程用到的依赖：

```bash
cd my-app
npm install express axios dotenv sqlite3
```

**依赖说明：**

- `express`: Web 应用框架，用于构建 HTTP 服务

- `axios`: HTTP 客户端，用于调用 Shoplazza OpenAPI

- `dotenv`: 环境变量管理工具

- `sqlite3`: 轻量级数据库，用于存储访问令牌

:::note
`crypto` 是 Node.js 内置模块（用于 HMAC 验证），**无需也不要**通过 npm 安装。npm 上同名的 `crypto` 包是废弃/无关包，存在 typosquatting 供应链风险。
:::

### 1.4 创建基础 Express 服务

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

```javascript
const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.send("Shoplazza App is running!");
});

app.listen(3000, () => {
  console.log("Server is listening on port 3000");
});
```

### 1.5 启动服务

在 `package.json` 的 `scripts` 中追加 `start` 条目（保留 CLI 模板自带的条目）：

```json
{
  "scripts": {
    "start": "node index.js"
  }
}
```

然后启动服务：

```bash
npm run start
```

访问 `http://localhost:3000`，你应该看到 "Shoplazza App is running!" 的提示。

## 第二步：配置 ngrok 实现内网穿透

由于 Shoplazza 需要通过公网 URL 访问你的应用，我们需要使用 ngrok 将本地服务暴露到公网。

### 2.1 下载并安装 ngrok

访问 [ngrok 官网](https://ngrok.com/download) 下载对应系统的版本。

**macOS 用户可以使用 Homebrew 安装：**

```bash
brew install ngrok/ngrok/ngrok
```

**或者直接下载二进制文件：**

1. 访问 https://ngrok.com/download

2. 下载对应系统的版本

3. 解压并将 ngrok 移动到系统 PATH 中

### 2.2 注册并获取 authtoken

1. 访问 https://dashboard.ngrok.com/signup 注册账号

2. 登录后访问 https://dashboard.ngrok.com/get-started/your-authtoken

3. 复制你的 authtoken

4. 在终端执行：

```bash
ngrok config add-authtoken YOUR_AUTHTOKEN
```

### 2.3 启动 ngrok

确保你的 Express 服务正在运行（端口 3000），然后在`新的终端窗口`中执行：

```bash
ngrok http 3000
```

你将看到类似以下的输出：

```text
Session Status                online
Account                       your-email@example.com
Version                       3.x.x
Region                        United States (us)
Forwarding                    https://xxx.ngrok-free.dev -> http://localhost:3000
```

访问 `https://xxx.ngrok-free.dev`，你应该看到 "Shoplazza App is running!" 的提示。

## 第三步：配置环境变量

### 3.1 创建 .env 文件

:::note
项目通常会提供 `.env.example` 模板，直接 `cp .env.example .env` 后填入实际值即可。
:::

在项目根目录创建 `.env` 文件：

```bash
CLIENT_ID=your_client_id_here
CLIENT_SECRET=your_client_secret_here
BASE_URL=https://xxx.ngrok-free.dev
SCOPES=write_product
```

`SCOPES` 是应用申请的权限范围，多个权限用空格分隔（例如 `write_product read_order`）。完整权限列表见 [Shoplazza API 权限文档](/docs/app/building-blocks/authentication/access-scopes)。

### 3.2 获取 CLIENT_ID 和 CLIENT_SECRET

1. 登录 [Shoplazza Partners](https://partners.shoplazza.com)

2. 进入你创建的应用详情页

3. 在"应用凭证"或"App credentials"部分找到：**Client ID**（客户端 ID），**Client Secret**（客户端密钥）

4. 将这些值复制到 `.env` 文件中

### 3.3 配置 BASE_URL

将第二步中 ngrok 生成的 HTTPS 地址填入 `BASE_URL`。

```text
BASE_URL=https://xxx.ngrok-free.dev
```

### 3.4 配置应用回调地址

在 Shoplazza Partners 应用设置中，设置下面两个地址：

- `App URL` 设置为: `https://xxx.ngrok-free.dev/auth`

- `Redirect URL` 设置为: `https://xxx.ngrok-free.dev/auth/callback`

### 3.5 补全 .gitignore 文件

CLI 生成的 `.gitignore` 已覆盖 `node_modules` 和 `.shoplazza/`，但没有覆盖本教程产生的敏感信息和本地数据，追加以下条目：

```bash
.env
.env.*
*.db
```

**安全提示：**

- **永远不要将 `.env` 文件提交到代码仓库**

- 建议在生产环境使用环境变量管理服务（如 AWS Secrets Manager、Azure Key Vault 等）

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

本步骤实现 OAuth 2.0 授权码流程。完整的流程原理、HMAC 算法、安全防护与最佳实践，见 [App 授权参考](/docs/app/building-blocks/authentication/oauth-flow)。

### 4.1 创建工具函数和 HMAC 验证

在实现具体的授权路由之前，我们需要准备一些工具函数。

这些函数包括：环境变量检查中间件、HMAC 验证中间件等。HMAC 验证是 OAuth 流程中的关键安全措施，用于确保请求确实来自 Shoplazza 平台，而不是被恶意第三方伪造或篡改。

创建 `utils/index.js` 文件：

```javascript
require("dotenv").config();
const crypto = require("crypto");

const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
const BASE_URL = process.env.BASE_URL;
const SCOPES = process.env.SCOPES;
const REDIRECT_URI = `${BASE_URL}/auth/callback`;

// 校验 shop 域名（防止 SSRF）：允许 *.myshoplaza.com / *.dev.myshoplaza.com / *.stg.myshoplaza.com
const SHOP_DOMAIN_REGEXP = /^[a-z0-9][a-z0-9-]*(\.dev|\.stg)?\.myshoplaza\.com$/i;
function isValidShop(shop) {
  return typeof shop === "string" && SHOP_DOMAIN_REGEXP.test(shop);
}

// 检查必需的环境变量
function checkEnvMiddleware(req, res, next) {
  if (!CLIENT_ID || !CLIENT_SECRET || !BASE_URL) {
    const missingVars = [];
    if (!CLIENT_ID) missingVars.push("CLIENT_ID");
    if (!CLIENT_SECRET) missingVars.push("CLIENT_SECRET");
    if (!BASE_URL) missingVars.push("BASE_URL");

    return res.status(500).send(`环境变量配置缺失: ${missingVars.join(", ")}`);
  }
  next();
}

// 安全比对函数（防止时序攻击）
function secureCompare(a, b) {
  try {
    const A = Buffer.from(a, "hex");
    const B = Buffer.from(b, "hex");
    if (A.length !== B.length) return false;
    return crypto.timingSafeEqual(A, B);
  } catch (e) {
    return false;
  }
}

// HMAC 验证中间件
function hmacValidator(req, res, next) {
  const { hmac } = req.query;
  const map = Object.assign({}, req.query);
  delete map["hmac"]; // 移除 hmac 参数

  // 按字母顺序排序参数
  const sortedKeys = Object.keys(map).sort();
  const message = sortedKeys.map((key) => `${key}=${map[key]}`).join("&");

  // 使用 CLIENT_SECRET 生成 HMAC
  const generated_hash = crypto
    .createHmac("sha256", CLIENT_SECRET)
    .update(message)
    .digest("hex");

  if (!secureCompare(generated_hash, hmac)) {
    return res.status(400).send("HMAC validation failed");
  }
  next();
}

// 简易的 state 临时存储（演示用，生产建议改用 Redis / session）
const stateStore = new Map();
const STATE_TTL_MS = 10 * 60 * 1000; // 10 分钟
function saveState(state, shop) {
  stateStore.set(state, { shop, expireAt: Date.now() + STATE_TTL_MS });
}
function consumeState(state, shop) {
  const entry = stateStore.get(state);
  if (!entry) return false;
  stateStore.delete(state); // 一次性使用
  if (entry.expireAt < Date.now()) return false;
  return entry.shop === shop;
}

module.exports = {
  checkEnvMiddleware,
  hmacValidator,
  isValidShop,
  saveState,
  consumeState,
  CLIENT_ID,
  CLIENT_SECRET,
  BASE_URL,
  SCOPES,
  REDIRECT_URI,
};
```

**代码说明：**

- `checkEnvMiddleware`: 中间件函数，确保所有必需的环境变量都已配置

- `secureCompare`: 使用时间安全的比对方式，防止时序攻击

- `hmacValidator`: HMAC 验证中间件，确保请求来自 Shoplazza

- `isValidShop`: 校验 `shop` 参数是合法的 `*.myshoplaza.com` 域名，**防止 SSRF 攻击**——攻击者可能传入 `evil.com` 让服务端把 token 换取请求发到第三方域。

- `saveState` / `consumeState`: OAuth `state` 临时存储，发起授权时写入、回调时一次性消费并校验，**防止 CSRF 攻击**。

- `REDIRECT_URI`: 自动拼接授权回调地址

:::note
HMAC 验证算法与"为什么需要 HMAC"见 [App 授权参考](/docs/app/building-blocks/authentication/oauth-flow#hmac-验证)。
:::

### 4.2 实现授权入口路由

现在我们来实现授权流程的第一步：构建授权页面的 URL 并重定向用户。

当用户点击安装应用时，应用需要将用户重定向到 Shoplazza 的授权页面，在 URL 中携带应用 ID、权限范围、回调地址等参数。

在项目根目录 `index.js` 中添加 `/auth` 路由：

```javascript
const crypto = require("crypto");
const {
  checkEnvMiddleware,
  hmacValidator,
  isValidShop,
  saveState,
  CLIENT_ID,
  SCOPES,
  REDIRECT_URI,
} = require("./utils/index");

app.get("/auth", checkEnvMiddleware, hmacValidator, (req, res) => {
  const { shop } = req.query;

  // 校验 shop 必须是合法的 *.myshoplaza.com 域名（防止 SSRF）
  if (!isValidShop(shop)) {
    return res.status(400).send("Invalid shop domain");
  }

  const state = crypto.randomBytes(16).toString("hex"); // 随机值，防止 CSRF 攻击
  saveState(state, shop); // 存储 state，回调时校验

  // URL 编码参数
  const scopesEncoded = encodeURIComponent(SCOPES);
  const redirectUriEncoded = encodeURIComponent(REDIRECT_URI);

  // 重定向用户到 Shoplazza 授权页面
  res.redirect(
    `https://${shop}/admin/oauth/authorize?client_id=${CLIENT_ID}&scope=${scopesEncoded}&redirect_uri=${redirectUriEncoded}&response_type=code&state=${state}`
  );
});
```

**参数说明：**

- `shop`: 店铺域名（如 `example.myshoplaza.com`），从查询参数中获取，**必须校验**是合法的 `*.myshoplaza.com` 域名

- `client_id`: 应用的客户端 ID

- `scope`: 请求的权限范围，从环境变量 `SCOPES` 读取；多个权限用空格分隔（如 `write_product read_order`）

- `redirect_uri`: 授权完成后的回调地址

- `response_type`: 固定值 `code`，表示使用授权码模式

- `state`: 随机字符串，用于防止 CSRF 攻击；**必须**在 `/auth` 中存储、`/auth/callback` 中校验（不一致即拒绝）

更多权限范围请参考 [Shoplazza API 权限文档](/docs/app/building-blocks/authentication/access-scopes)。

### 4.3 实现授权回调路由

当用户在 Shoplazza 授权页面点击同意后，Shoplazza 会将用户重定向回我们的应用，并在 URL 中携带授权码（code）。

我们需要实现回调路由来接收这个授权码（code），然后用它换取访问令牌（access_token）。获取到的 access_token 需要妥善存储，因为后续所有的 API 调用都需要使用它来进行身份验证。

在 `index.js` 中添加 `/auth/callback` 路由：

```javascript
const axios = require("axios");
const {
  hmacValidator,
  isValidShop,
  consumeState,
  CLIENT_ID,
  CLIENT_SECRET,
  REDIRECT_URI,
} = require("./utils/index");
const { saveToken } = require("./utils/sqlite");

app.get("/auth/callback", hmacValidator, async (req, res) => {
  const { code, hmac, state, shop } = req.query;

  if (!shop || !hmac || !code || !state) {
    return res.status(400).send("Required parameters missing");
  }

  // 校验 shop 域名（防止 SSRF）
  if (!isValidShop(shop)) {
    return res.status(400).send("Invalid shop domain");
  }

  // 校验 state（防止 CSRF）：从存储取出，与本次 shop 一致才放行
  if (!consumeState(state, shop)) {
    return res.status(400).send("Invalid or expired state");
  }

  try {
    // 使用 code 获取 access_token
    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,
    });

    // 验证响应结构
    if (!data || !data.access_token) {
      console.error("Token exchange failed: missing access_token", data);
      return res.status(502).send("Token exchange failed");
    }

    // 存储 access_token
    await saveToken(shop, data.access_token);

    // 跳转到首页
    res.redirect(`/?shop=${shop}`);
  } catch (err) {
    console.error("OAuth callback error:", err.response?.data || err.message);
    res.status(500).send("OAuth callback failed");
  }
});
```

**Token 接口请求参数：**

- `client_id`: 应用的客户端 ID

- `client_secret`: 应用的客户端密钥

- `code`: 授权码

- `grant_type`: 固定值 `authorization_code`

- `redirect_uri`: 必须与授权时的回调地址一致

**Token 接口响应：**

```json
{
  "token_type": "Bearer",
  "expires_at": 1550546245,
  "access_token": "{YOUR_ACCESS_TOKEN}",
  "refresh_token": "{YOUR_REFRESH_TOKEN}",
  "store_id": "2",
  "store_name": "xxx"
}
```

### 4.4 实现 Token 存储

获取到 access_token 后，我们需要将它持久化存储。

在示例项目中，我们使用 SQLite 数据库来存储每个店铺的访问令牌，以店铺域名作为主键（生产环境建议使用 PostgreSQL、MySQL 等）。这样当店铺再次访问应用时，我们可以直接从数据库中读取令牌，无需重新授权。

创建 `utils/sqlite.js` 文件：

```javascript
const path = require("path");
const sqlite3 = require("sqlite3").verbose();

const DB_PATH = path.resolve(__dirname, "../database/tokens.db");
const db = new sqlite3.Database(DB_PATH);

// 初始化数据库表
const initPromise = new Promise((resolve, reject) => {
  db.run(
    `CREATE TABLE IF NOT EXISTS shop_tokens (
      shop_domain TEXT PRIMARY KEY,
      access_token TEXT NOT NULL,
      updated_time INTEGER NOT NULL
    )`,
    (err) => {
      if (err) return reject(err);
      resolve();
    }
  );
});

// 保存或更新 token
function saveToken(shopDomain, accessToken) {
  return initPromise.then(
    () =>
      new Promise((resolve, reject) => {
        const now = Date.now();
        // UPSERT：shop_domain 已存在则更新 token 与时间，否则插入新行（excluded 指本次待插入的值）
        db.run(
          `INSERT INTO shop_tokens (shop_domain, access_token, updated_time)
           VALUES (?, ?, ?)
           ON CONFLICT(shop_domain) DO UPDATE SET
             access_token = excluded.access_token,
             updated_time = excluded.updated_time`,
          [shopDomain, accessToken, now],
          (err) => {
            if (err) return reject(err);
            resolve();
          }
        );
      })
  );
}

// 获取 token
function getShopToken(shopDomain) {
  return initPromise.then(
    () =>
      new Promise((resolve, reject) => {
        db.get(
          `SELECT access_token FROM shop_tokens WHERE shop_domain = ?`,
          [shopDomain],
          (err, row) => {
            if (err) return reject(err);
            resolve(row?.access_token || null);
          }
        );
      })
  );
}

module.exports = {
  saveToken,
  getShopToken,
};
```

**数据库设计说明：**

- `shop_domain`: 店铺域名（主键）

- `access_token`: 访问令牌

- `updated_time`: 更新时间戳

并且创建 `database` 文件夹：

```bash
mkdir database
```

当应用启动时，会自动创建 `tokens.db` 文件，用于存储每个店铺的 access_token。

:::note
**生产环境建议：**

- 不要使用 `require("sqlite3").verbose()`，`verbose` 会输出额外的调试日志，影响性能与可读性；生产换成 `require("sqlite3")` 即可。
- 数据库路径建议通过环境变量配置（例如 `DB_PATH`），避免相对路径在不同部署环境下出问题。
- SQLite 仅适合演示，正式项目建议使用 PostgreSQL / MySQL 等独立数据库。
:::

## 第五步：创建应用首页

### 5.1 实现首页路由

应用首页是用户授权成功后看到的第一个页面，我们使用该页面调用后端接口创建随机商品。后端接口在收到前端请求后，会先检查该店铺是否已经完成授权（即数据库中是否存在该店铺的 access_token）。如果未授权，则重定向到授权流程；如果已授权，则调用 Shoplazza OpenAPI 创建随机商品。

在项目根目录下 `index.js` 中，改造原始首页路由：

```javascript
const path = require("path");
const { getShopToken } = require("./utils/sqlite");
const { isValidShop } = require("./utils/index");

app.get("/", checkEnvMiddleware, async (req, res) => {
  const { shop } = req.query;

  if (!shop || !isValidShop(shop)) {
    return res.send("请提供合法的 shop 参数。");
  }

  // 检查是否已授权
  const token = await getShopToken(shop);
  if (!token) {
    return res.redirect(`/auth?shop=${shop}`);
  }

  // 返回应用首页（用 sendFile 避免每次请求都同步读文件）
  res.sendFile(path.resolve(__dirname, "index.html"));
});
```

:::note
用 `res.sendFile` 替代 `fs.readFileSync` + `res.send`，避免每次请求都同步读盘；如果首页是纯静态资源，也可以挂 `app.use(express.static(__dirname))` 由 Express 托管。
:::

**逻辑说明：**

1. 从查询参数获取 `shop` 参数

2. 查询数据库检查该店铺是否已授权

3. 如果未授权，重定向到 `/auth` 开始授权流程

4. 如果已授权，返回应用首页 HTML

### 5.2 创建 HTML 页面

现在我们来创建应用的前端界面。

这个页面包含一个简单的按钮，用户点击后会调用后端接口创建随机商品。页面使用原生 HTML 和 JavaScript 实现，通过 fetch API 与后端通信，并实时展示创建结果。

在项目根目录创建 `index.html` 文件：

```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 App Demo</title>
  <style>
    * { box-sizing: border-box; }
    body { 
      margin: 0; 
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 
      background: #f5f5f7; 
      color: #111; 
    }
    .page { 
      max-width: 860px; 
      margin: 24px auto; 
      padding: 0 16px; 
      display: flex; 
      flex-direction: column; 
      gap: 16px; 
    }
    .card { 
      background: #fff; 
      border-radius: 12px; 
      padding: 16px 18px; 
      box-shadow: 0 2px 10px rgba(0,0,0,0.06); 
    }
    .card h2 { margin: 0 0 8px; font-size: 18px; }
    .card p { margin: 6px 0; color: #444; }
    .card pre { 
      background: #f8f8fb; 
      padding: 12px; 
      border-radius: 8px; 
      overflow: auto; 
    }
    .btn { 
      border: none; 
      background: #111; 
      color: #fff; 
      padding: 10px 14px; 
      border-radius: 8px; 
      cursor: pointer; 
    }
    .btn:disabled { opacity: 0.6; cursor: not-allowed; }
    .result { margin-top: 12px; color: #333; font-size: 14px; }
  </style>
</head>
<body>
  <div class="page">
    <div class="card">
      <h2>恭喜创建 Shoplazza App 🎉</h2>
      <p>这是一个简化的演示页面，技术栈如下：</p>
      <ul>
        <li>框架: Express</li>
        <li>界面: 简单 HTML</li>
        <li>API: <a href="https://www.shoplazza.dev/reference/api-usage" target="_blank">Shoplazza OpenAPI</a></li>
        <li>数据库: SQLite</li>
      </ul>
    </div>

    <div class="card">
      <h2>开始创建商品</h2>
      <p>点击按钮创建一个随机商品。</p>
      <button class="btn" id="createBtn">生成商品</button>
      <div class="result" id="createResult"></div>
      <pre id="productResult" style="display: none;"></pre>
    </div>
  </div>

  <script>
    function getShop() {
      const params = new URLSearchParams(window.location.search);
      return params.get("shop");
    }

    const shop = getShop();
    const btn = document.getElementById("createBtn");
    const result = document.getElementById("createResult");
    const productResult = document.getElementById("productResult");
    
    btn.addEventListener("click", async () => {
      if (!shop) {
        result.textContent = "请提供 shop 参数。";
        return;
      }
      
      btn.disabled = true;
      result.textContent = "创建中...";
      productResult.textContent = "";
      
      try {
        const resp = await fetch(`/api/create-product?shop=${encodeURIComponent(shop)}`, {
          method: "POST",
        });
        const data = await resp.json();
        
        if (!data.ok) {
          result.textContent = "创建失败：" + JSON.stringify(data.error);
        } else {
          const product = data.product || {};
          result.textContent = "创建成功：" + (product.title || product.id || "OK");
          productResult.textContent = JSON.stringify(product, null, 2);
          productResult.style.display = "block";
        }
      } catch (e) {
        result.textContent = "创建失败：" + e.message;
        productResult.style.display = "none";
      } finally {
        btn.disabled = false;
      }
    });
  </script>
</body>
</html>
```

**页面功能：**

- 显示应用基本信息

- 提供"生成商品"按钮

- 点击按钮时调用 `/api/create-product` 接口

- 展示创建结果和商品详情

## 第六步：调用 OpenAPI 创建商品

### 6.1 创建随机商品数据生成函数

为了演示商品创建功能，我们需要一个能够生成随机商品数据的函数。这个函数会随机组合商品标题、生成随机价格，并构造符合 Shoplazza OpenAPI 要求的数据结构。

在 `utils/index.js` 中添加：

```javascript
function randomPick(list) {
  return list[Math.floor(Math.random() * list.length)];
}

function randomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function buildRandomProductPayload() {
  const prefix = ["春日", "轻盈", "舒适", "简约", "清新", "自然", "柔软"];
  const material = ["棉麻", "纯棉", "雪纺", "牛仔", "针织", "丝滑"];
  const category = ["T恤", "衬衫", "连衣裙", "外套", "卫衣", "长裤"];
  const suffix = ["款", "风", "系列", "限定", "基础款"];
  
  const title = `${randomPick(prefix)}${randomPick(material)}${randomPick(category)}${randomPick(suffix)}`;
  const price = parseFloat((randomInt(79, 299) + randomInt(0, 99) / 100).toFixed(2));

  return {
    title,
    images: [
      {
        src: "https://placehold.co/800x800",
      },
    ],
    variants: [
      {
        price,
        position: 1,
      },
    ],
  };
}

module.exports = {
  // ... 其他导出
  buildRandomProductPayload,
};
```

**数据结构说明：**

- `title`: 商品标题（随机组合生成）

- `images`: 商品图片数组

    - `src`: 图片 URL

- `variants`: 商品规格数组

    - `price`: 价格

    - `position`: 规格排序位置

### 6.2 实现创建商品接口

现在我们来实现核心功能：调用 Shoplazza OpenAPI 创建商品。这个接口会从数据库获取店铺的 access_token，生成随机商品数据，然后调用 Shoplazza 的商品创建 API。接口需要处理各种可能的错误情况，并返回友好的错误信息。

在 `index.js` 中添加 `/api/create-product` 路由：

```javascript
const { buildRandomProductPayload, isValidShop } = require("./utils/index");

app.post("/api/create-product", checkEnvMiddleware, async (req, res) => {
  const { shop } = req.query;

  if (!shop || !isValidShop(shop)) {
    return res.status(400).json({ ok: false, error: "请提供合法的 shop 参数。" });
  }

  const token = await getShopToken(shop);

  if (!token) {
    return res.status(401).json({ ok: false, error: "未授权，请先完成安装。" });
  }

  try {
    const payload = buildRandomProductPayload();

    const { data } = await axios.post(
      `https://${shop}/openapi/2025-06/products`,
      { product: payload },
      {
        headers: {
          "Access-Token": token,
        },
      }
    );

    res.json({ ok: true, product: data?.product || data });
  } catch (err) {
    const status = err.response?.status || 500;
    const detail = err.response?.data || err.message || "创建失败";

    res.status(status).json({ ok: false, error: detail });
  }
});
```

:::note
创建商品是写操作，应使用 `app.post(...)`（前端 `fetch` 也需带 `method: "POST"`）。`app.get` 在 RESTful 语义中代表只读，且部分浏览器/代理可能缓存 GET 请求或在重定向时重发。
:::

**OpenAPI 接口说明：**

关于 Shoplazza OpenAPI 的详细参数说明、请求格式、响应结构等，请参考 [Shoplazza 商品 API 文档](/docs/app/building-blocks/rest-api/api-usage)。

### 6.3 添加全局错误处理中间件

为防止异步路由中未捕获的异常导致进程崩溃，在 `index.js` **所有路由注册之后**追加一个错误处理中间件：

```javascript
// 注意：错误处理中间件必须是 4 个参数 (err, req, res, next)，且放在所有路由之后
app.use((err, req, res, next) => {
  console.error("Unhandled error:", err);
  if (res.headersSent) return next(err);
  res.status(500).json({ ok: false, error: "Internal server error" });
});
```

如果还存在不经过 Express 路由的异步代码（如全局事件回调），建议同时监听 `process.on("unhandledRejection", ...)` 与 `process.on("uncaughtException", ...)` 做进程级兜底。

## 第七步：为应用添加扩展（可选）

扩展不是独立的应用，而是挂在你刚构建的这个应用（同一个 client ID）下的扩展能力：主题扩展在店铺主题中渲染内容，结账扩展在结账页注入内容。你的工程已经具备扩展开发所需的一切——`extensions/` 目录和应用配置——可以直接创建。

### 7.1 创建扩展

按需要创建对应类型的扩展：

```bash
# 主题扩展：在店铺主题中渲染内容
shoplazza app extension create --type theme --name my-theme-ext --theme-type basic

# 结账扩展：在结账页注入内容
shoplazza app extension create --type checkout --name my-checkout-ext
```

扩展脚手架会生成在 `extensions/` 目录下。主题扩展的 `--theme-type` 可选 `basic`（商家添加到页面 section 的区块）或 `embed`（注入 `</head>` 或 `</body>` 前的全局嵌入）。

### 7.2 开发扩展

按对应类型的指南编写扩展：

- [创建一个主题扩展](/docs/app/extensions/online-store/add-theme-extension)
- [创建一个结账扩展](/docs/app/extensions/checkout/add-checkout-extension)

## 第八步：测试应用

开发到此完成。前往[测试应用](/docs/app/getting-started/create-public-app/test-app)走完整的测试链路：把应用安装到开发店铺，验证应用功能，并在店铺编辑器中检查扩展效果。

## 下一步

恭喜你！你成功使用 Node.js、Express.js、SQLite 创建了一个 Shoplazza App。接下来你可以：

- **发布应用**：将应用发布到 App Store，请参考 [Shoplazza App 发布](/docs/app/getting-started/create-public-app/submit-review)
- **构建嵌入式应用**：用 App Bridge + Session Token 让应用在店铺后台内运行，请参考 [使用 Node.js 和 Express.js 构建嵌入式应用](/docs/app/getting-started/create-public-app/embed-in-admin)
- **主题扩展开发**：参考[创建一个主题扩展](/docs/app/extensions/online-store/add-theme-extension)
- **结账扩展开发**：参考[创建一个结账扩展](/docs/app/extensions/checkout/add-checkout-extension)
- **深入授权与最佳实践**：见 [App 授权参考](/docs/app/building-blocks/authentication/oauth-flow)
