# Function 扩展开发指南

> 端到端构建 Shoplazza Function 扩展：用 JavaScript 编写购物车计价逻辑，编译为 WASM，用 CLI 发布、绑定到店铺并上线。

**Function 扩展**让你的应用把自定义逻辑运行在 **Shoplazza 的服务器上**，而不是浏览器里。你用 JavaScript 编写逻辑，Shoplazza CLI 把它编译成 WebAssembly（WASM）模块，平台在商家店铺的指定环节沙箱化、确定性地运行它。

Function 开发是**应用开发的一部分**：函数存在于应用项目中，以你应用的 `client_id` 发布，并随应用一起上线。本指南覆盖从脚手架到上线的完整链路。

## 函数能做什么

当前的函数类型是 **`cart_transform`**，用于调整买家购物车中的**行项目（line item）计价**。当一个 `cart_transform` 函数被绑定到店铺后，Shoplazza 运行你的代码并把结果应用到每一个购物车行。

一个 `cart_transform` 函数可以修改每行的两个字段：

| 输出字段 | 效果 | 约束 |
|----------|------|------|
| `price.adjustment_fixed_price` | **加在商品原价上**的金额。该行最终单价 = 原价 + `adjustment_fixed_price`，行小计按 `单价 × 数量` 重算。 | 十进制字符串，**非负**，≤ `999999999`，保留 2 位小数 |
| `title` | 覆盖行项目展示的**标题**。覆盖后的标题只在结账页显示，购物车页面仍显示原标题。 | ≤ 255 字符 |

唯一支持的操作是 `update`（修改已有行）。函数**没有 UI**——它是纯服务端逻辑，读取购物车上下文、返回计价操作。

:::info 为什么是 WASM？
以 WASM 形式运行，让你的逻辑能在 Shoplazza 基础设施上确定性、沙箱化地执行，无需每次购物车变动都回调你的后端。你仍然写 JavaScript，CLI 用 [Javy](https://github.com/bytecodealliance/javy) 工具链编译，平台用 Wasmtime 引擎运行。
:::

## 运行位置：购物车**与**结账

你发布的**同一个** `cart_transform` 函数会自动在以下两种场景运行——你不需要写两份：

| 场景 | 何时运行 | 影响什么 |
|------|---------|---------|
| **购物车页** | **加入购物车**，以及行的**自定义属性变化**时（纯删除某行不会重跑） | 买家在购物车看到的 `price` 与 `line_price` |
| **结账** | 结账**创建**、在结账页**增删改商品行**、结账**校验**时——都在折扣**之前** | 订单据以构建的行价格 |

因此函数算出的改价会反映在购物车页，并被带入结账。结账时，你的函数总是在订单/行折扣**之前**运行，折扣在你改后的价格基础上叠加。订单级、运费级折扣由独立的折扣端点处理，**不属于** `cart_transform`；`cart_transform` 函数只改**行价与行标题**。

:::note
由于函数会在每一次重新计价的事件（购物车加购/更新、结账创建/变更/校验）时重跑，请把逻辑写成**由当前购物车内容确定性推导**——不要依赖上一次运行的状态——以确保购物车与结账结果始终一致。

少数店铺仍在使用旧版结账，这些店铺在结账阶段只计算折扣，不运行 `cart_transform`。使用新版结账的店铺不受影响。
:::

:::tip 调试改价
上面「每次重新计价都会重跑」有一个例外：购物车里已有的商品行再增加数量时，会沿用该行加购时算出的价格。改了函数或元字段值后，先删除该行再重新加购，才能看到当前函数的计算结果。
:::

## 工作原理

```
  src/index.js
      │  javy build
      ▼
  <name>.<hash>.wasm
      │  function release / app deploy
      ▼
  函数运行时   （注册表 + WASM 引擎：Wasmtime + Javy）
      ▲
      │  run(输入 JSON) ──▶ 输出 JSON
      │
  购物车服务 / 结账服务
      │  读取该店铺已绑定的 cart_transform 端点
      ▼
  把 {operation:{update:[…]}} 应用到每一行的价格
```

- **函数运行时** —— 存储函数源码 + 编译后 WASM 的注册表，以及 WASM 执行引擎（Wasmtime + Javy/QuickJS）。它用 JSON 输入运行你的模块、返回 JSON 输出。
- **购物车 / 结账服务** —— 买家操作时，查出该店铺绑定的函数，调用 函数运行时 执行，再把结果应用到行价。

## 前置条件

- 已安装并授权 **[Shoplazza CLI](/zh-CN/docs/app/developer-tools/shoplazza-cli)**（`shoplazza auth login`）。
- 已用 `shoplazza app init` 创建的应用项目。参见[构建你的第一个应用](/zh-CN/docs/app/developer-tools/shoplazza-cli/tutorials/build-your-first-app)。
- `shoplazza.app.toml` 配置了正确的权限范围：`cart_transform` 需要 `write_cart_transform`（读取购物车数据再加 `read_cart_transform`）。默认应用模板已自带：

  ```toml
  scopes = "read_customer write_cart_transform"
  ```

:::note
你**无需**自己安装 Javy。首次编译时，CLI 会自动下载并锁定正确的 Javy 版本（当前 `v5.0.1`）并校验 checksum。
:::

---

## 第 1 步 —— 创建函数扩展

在应用项目根目录下执行：

```shell
shoplazza app extension create --type function --name my-function
```

名称必须是**纯目录名**——小写字母、数字、`-` 或 `_`，以字母或数字开头（`^[a-z0-9][a-z0-9_-]*$`）。路径分隔符和 `..` 会被拒绝。

生成结构：

```
extensions/
└── my-function/
    ├── .gitignore
    ├── package.json
    ├── shoplazza.extension.toml   # 扩展配置（id、name、type = "function"）
    └── src/
        └── index.js               # 你的函数入口
```

生成的 `shoplazza.extension.toml`：

```toml
id = ""
name = "my-function"
type = "function"
```

`id` 在首次发布前为空，首次发布后 CLI 会把函数 id 回写进来（第 5 步）。

---

## 第 2 步 —— 编写函数逻辑

逻辑写在 `extensions/my-function/src/index.js`。函数遵循 **Javy 执行模型**：运行时从 `stdin` 读取 JSON **输入**，计算后把 JSON **输出**写到 `stdout`。

### 输入：购物车上下文

函数在 `stdin` 收到如下 JSON（字段名即真实契约）：

```jsonc
{
  "cart": {
    "line_items": [
      {
        "id": "line-id",                 // 行项目 id（作为操作目标）
        "quantity": 2,
        "properties": "[]",              // 行自定义属性的 JSON 字符串
        "product": {
          "product_id": "...",
          "variant_id": "...",
          "price": "19.90",              // 当前单价（十进制字符串）
          "product_title": "T-Shirt",
          "metafields": [                 // 你声明拉取的商品 metafield（见下）
            { "namespace": "custom", "key": "markup", "value": 3 }
          ]
        }
      }
    ],
    "attributes": "[]"                   // 购物车级属性的 JSON 字符串
  },
  "currency_settings": { "actual_rate": "1.0" },
  "shop": {
    "metafields": [                       // 你声明拉取的店铺 metafield
      { "namespace": "custom", "key": "vip", "value": true }
    ]
  }
}
```

只有当函数**声明**了要读取哪些元字段时，`metafields` 才会出现（每个函数最多 5 条查询，见[声明要读取的元字段](#声明要读取的元字段)）。

### 声明要读取的元字段

函数不能自己查数据。除了购物车本身，函数校验买家选择、计算价格所需的数据（比如附加费、价目表、规则）都要放在商品或店铺的元字段里。平台在函数运行前加载你声明的元字段，放进函数输入。

在 `shoplazza.extension.toml` 里为函数需要的每个元字段（metafield）写一个 `[[metafields_query]]` 块：

```toml
id = ""
name = "my-function"
type = "function"

[[metafields_query]]
resource = "product"
namespace = "custom"
key = "markup"
```

| 字段 | 说明 |
|------|------|
| `resource` | `product`（出现在 `cart.line_items[].product.metafields`）或 `shop`（出现在 `shop.metafields`） |
| `namespace` | 元字段的命名空间 |
| `key` | 元字段的键 |

每条声明过的元字段以 `{ namespace, key, value }` 的形式传入。商品没有设置某个声明过的元字段时，它的 `metafields` 数组里就没有这一条。

- 最多 **5** 条。
- CLI 在 `app deploy`（以及 `app dev`）时校验声明，并随发布一起提交。声明不合法会直接让发布失败，例如：
  - `function "my-function" input_query: metafields_query has 6 entries, max 5`
  - `function "my-function" input_query: metafields_query[0] resource "order" invalid, must be "product" or "shop"`
- 声明在商家安装应用时生效（见[第 6 步](#第-6-步--绑定与上线)）。

:::tip 函数读取的元字段要精简
单个元字段的值最大 1 MB。函数读取的那条建议控制在 10 KB 左右以内，因为函数每次运行都会加载它。

同一份数据既要用来渲染商品页、又要给函数计价时，拆成两个元字段：一个给店铺前台渲染，可以大；另一个给函数，只放函数要读的字段。只声明第二个。这样函数输入更小，改页面展示也不会影响函数。
:::

### 输出：计价操作

把如下 JSON 写到 `stdout`：

```jsonc
{
  "operation": {
    "update": [
      {
        "id": "line-id",                          // 必须匹配某个购物车行 id
        "price": { "adjustment_fixed_price": "10.00" },   // 最终单价 = 19.90 + 10.00 = 29.90
        "title": "T-Shirt (定制)"                  // 可选
      }
    ]
  }
}
```

- `adjustment_fixed_price` 是加在该行原单价上的金额（非负、≤ `999999999`、2 位小数）。
- `title` 可选（≤ 255 字符）。
- 只识别 `update` 操作；其他键被忽略。

### 入口文件参考

这个示例按商品元字段 `custom.markup` 的值给商品加价，元字段按[声明要读取的元字段](#声明要读取的元字段)声明。原价 100、`custom.markup = 5` 的商品最终单价为 105，结账页标题变成 `<原标题> (+5)`。没有该元字段的商品行保持不变。

```javascript
// extensions/my-function/src/index.js

const input = readInput();
const result = run(input);
writeOutput(result);

// 按商品元字段 custom.markup 加价：有值的行输出 adjustment_fixed_price = markup，平台把它加在原价上
// （最终单价 = 原价 + markup），并在标题后标出加价金额；没有值的行不输出，保持原价。
// 元字段需要在 shoplazza.extension.toml 的 [[metafields_query]] 里声明，否则函数输入里拿不到。
function run(input) {
  const update = [];

  input.cart.line_items.forEach((lineItem) => {
    const metafields = lineItem.product.metafields || [];
    const markupField = metafields.find(
      (metafield) => metafield.namespace === "custom" && metafield.key === "markup"
    );
    if (!markupField) {
      return;
    }

    const markup = parseFloat(markupField.value);
    // adjustment_fixed_price 必须在 0 到 999999999 之间：任何一行不合法，整个输出都会被拒绝
    if (!Number.isFinite(markup) || markup < 0 || markup > 999999999) {
      return;
    }

    update.push({
      id: lineItem.id,
      price: {
        adjustment_fixed_price: markup.toFixed(2),
      },
      title: `${lineItem.product.product_title} (+${markup})`,
    });
  });

  return { operation: { update } };
}

function readInput() {
  const chunkSize = 1024;
  const inputChunks = [];
  let totalBytes = 0;
  // 读取全部可用字节
  while (1) {
    const buffer = new Uint8Array(chunkSize);
    // 标准输入的文件描述符
    const fd = 0;
    const bytesRead = Javy.IO.readSync(fd, buffer);
    totalBytes += bytesRead;
    if (bytesRead === 0) {
      break;
    }
    inputChunks.push(buffer.subarray(0, bytesRead));
  }
  // 把所有分块拼成一个 Uint8Array
  const { finalBuffer } = inputChunks.reduce(
    (context, chunk) => {
      context.finalBuffer.set(chunk, context.bufferOffset);
      context.bufferOffset += chunk.length;
      return context;
    },
    { bufferOffset: 0, finalBuffer: new Uint8Array(totalBytes) }
  );

  return JSON.parse(new TextDecoder().decode(finalBuffer));
}

// 把输出写到标准输出
function writeOutput(output) {
  const encodedOutput = new TextEncoder().encode(JSON.stringify(output));
  const buffer = new Uint8Array(encodedOutput);
  // 标准输出的文件描述符
  const fd = 1;
  Javy.IO.writeSync(fd, buffer);
}
```

### 校验与失败行为

- 平台先校验**全部**操作，再应用**任意**一条——**全有或全无**。若有一行校验失败（负价/超限价、标题超 255 字符、未知行 id 等），整个输出被拒绝。
- 每个绑定端点带一个 **`block_on_failure`** 开关：
  - `true` → 函数失败会**阻断**购物车/结账操作并报错。
  - `false` → 函数结果被**丢弃**，流程以未改价继续。

### 保证可编译且快

Javy 只编译 JavaScript 的一个子集。为确保构建稳定、运行在预算内：

- 入口自包含在 `src/index.js` 内；如有依赖请打包进这一个文件。
- 避免 Node.js / 浏览器 API（`fs`、`fetch`、`window`、`document`、定时器）。只有 `stdin`/`stdout` 可用。
- 做算术、快速返回。引擎强制以下硬限制：

| 限制 | 值 |
|------|-----|
| 指令预算（fuel） | 约 11,000,000 |
| 线性内存 | 10 MB |
| 单次执行时长 | 约 300 ms |
| 输入 JSON 大小 | 10 MB |
| 输出 JSON 大小 | 20 MB |
| 编译后 WASM 大小 | 2 MB |
| 单个元字段的值 | 1 MB |

---

## 第 3 步 —— 编译为 WASM

本地编译（无需登录、不上传），尽早发现构建错误：

```shell
shoplazza app function compile --name my-function
```

该命令运行 `javy build extensions/my-function/src/index.js`，产物写到：

```
app-deploy/my-function.<md5-of-index.js>.wasm
```

语法错误或不支持的 API 会在此以 Javy 错误失败。

---

## 第 4 步 —— 本地测试

启动开发服务器并在前台验证：

```shell
shoplazza app dev
```

按终端提示操作（输入测试店铺域名，把 App URL / Redirect URL 填入 Partner Admin，打开 Install URL）。然后在前台：

1. **加入商品到购物车** → 确认价格/行小计体现了你的函数。
2. **进入结账** → 确认改价被带入结账。

---

## 第 5 步 —— 发布函数

可以发布单个函数，也可以发布整个应用。

### 发布单个函数

```shell
shoplazza app function release --name my-function
```

`release` 会编译并上传函数到当前激活应用：

- **首次发布（create）** —— 在 `cart_transform` 命名空间下以你应用的 `client_id` 注册新函数，返回 `function_id` 和 `version`。
- **后续发布（commit）** —— 复用已记录的 `function_id`，自增**补丁**版本号，提交新版本。版本号为语义化（`MAJOR.MINOR.PATCH`）且必须**严格递增**——后端会以 `Version not match` 拒绝非递增版本。CLI 自动处理递增并把权威值回写：

```toml
id = "1234567890123456789"
name = "my-function"
type = "function"
version = "1.0.1"
```

### 发布整个应用

要把所有扩展（主题、结账、函数）一起构建发布并切出新的**应用版本**：

```shell
shoplazza app deploy
```

`app deploy` 只把函数 `id` 回写到 `shoplazza.extension.toml`，不回写 `version`。

迭代单个函数用 `function release`；发布完整应用版本用 `app deploy`。

:::note
`[[metafields_query]]` 声明随 `app deploy`（以及 `app dev`）提交，单独执行 `function release` 不会提交声明。
:::

### 查看已发布函数

```shell
shoplazza app function list
```

---

## 第 6 步 —— 绑定与上线

已发布的函数要在店铺运行，必须**绑定**到该店铺。绑定是**自动**的——基础场景下你无需调用任何接口。

### 安装即自动绑定

商家安装你的应用时，平台会用默认配置把你**已发布**的函数绑定到他的店铺：失败**不**阻断买家（`block_on_failure: false`），并加载 [`[[metafields_query]]`](#声明要读取的元字段) 里声明的元字段。之后函数就在该店铺的加入购物车、购物车更新、结账（创建/变更/校验）时运行。应用被卸载时，平台会自动解绑。

也就是说：**发布函数（第 5 步）→ 安装/发布应用**，函数即生效，**无需额外的绑定调用**。

:::warning 先发布，再安装
- 绑定在安装时创建，所以函数必须在应用安装到店铺**之前**用 `app deploy` 发布。函数发布前就已安装应用的店铺不会运行该函数。
- 已经安装应用的店铺会继续运行绑定时的函数版本。之后再发布新的函数版本不会自动切换，用 `PATCH` 修改绑定配置也不会切换。要让测试店铺运行新版本，需要卸载并重新安装应用。
- 重新安装会创建新的绑定，绑定 `id` 会变化。
:::

### 可选：自定义或管理绑定（OpenAPI）

只有当你要超出默认行为时才用购物车转换 OpenAPI——失败时阻断、修改元字段声明、或查看/覆盖绑定：

| 方法与路径 | 作用 |
|-----------|------|
| `GET /openapi/2025-06/function/cart-transform` | 查询该店铺的绑定列表 |
| `POST /openapi/2025-06/function/cart-transform` | 绑定函数（或用自定义配置重绑） |
| `PATCH /openapi/2025-06/function/cart-transform/{id}` | 更换函数或修改配置 |

用店铺的 access token 调用：`Access-Token: {token}`，host 为 `https://{店铺域名}`。请求体：

```jsonc
{
  "function_id": "1234567890123456789",   // 回写到 shoplazza.extension.toml 的函数 id
  "block_on_failure": false,               // true = 函数报错时阻断购物车/结账
  "input_query": "{\"metafields_query\":[{\"resource\":\"product\",\"namespace\":\"custom\",\"key\":\"markup\"}]}"
}
```

- `block_on_failure`：
  - `true` —— 函数失败会让加购请求失败。店铺前台收到通用错误（`{"state":"server_error","message":"Service error, please try again later"}`），不会带出函数的具体错误。
  - `false`（安装默认值）—— 丢弃函数结果，商品按原价加入购物车。
- `input_query` —— 可选 JSON 字符串，声明要加载进函数输入的元字段，格式与 CLI 根据 `[[metafields_query]]` 提交的一致。最多 5 条，超过返回 `metafields query max limit is 5`。`PATCH` 时 `input_query` 传空字符串或不传，会保留当前声明。

`GET` 在 `data.cart_transforms` 里返回每个绑定的 `id`、`function_id`、`block_on_failure`，以及 `data.count`，不返回 `input_query`。

:::note 自建后端的应用
如果你的应用托管在自己的后端（而非 CLI 的 dev 隧道），把 App URL / Redirect URL 配到 Partner 后台（`shoplazza app config push`）、在后端处理 OAuth 即可。**函数绑定依然在安装时自动完成**——它挂在安装事件上，不依赖 `shoplazza app dev`。
:::

### 上线检查清单

由于 Function 开发是应用开发的一部分，上线遵循常规应用生命周期：

1. ✅ `function release`（或 `app deploy`）成功并把版本置为生效；`shoplazza app function list` 能看到函数与版本。
2. ✅ 应用已有部署版本（`shoplazza app versions`）且具备 `write_cart_transform` 权限。
3. ✅ 在开发/测试店铺验证通过：购物车与结账都显示了改价。
4. ✅ 函数要读取的元字段已在 `[[metafields_query]]` 里声明，并通过 `app deploy` 发布。（可选）若需要失败阻断，应用调用购物车转换 OpenAPI 设置 `block_on_failure`。
5. ✅ 按[提交审核](/zh-CN/docs/app/getting-started/create-public-app/submit-review)提交并发布应用。审核通过后，每个安装你应用的商家都会自动获得已绑定、已生效的函数。

---

## 配置参考

### `shoplazza.extension.toml`

| 字段 | 设置方 | 说明 |
|------|--------|------|
| `name` | 你（通过 `--name`） | 扩展名称，同时是 `extensions/` 下的目录名 |
| `type` | 脚手架 | 恒为 `"function"` |
| `id` | 首次发布 | 远端 `function_id`；决定下次 release 走 create 还是 commit |
| `version` | `function release` | 上次发布的版本号，由 `function release` 回写（`app deploy` 不回写），用于计算下一次递增 |
| `[[metafields_query]]` | 你 | 函数要读取的元字段：`resource`（`product` 或 `shop`）、`namespace`、`key`，最多 5 条。见[声明要读取的元字段](#声明要读取的元字段) |

### `shoplazza.app.toml`

| 字段 | 说明 |
|------|------|
| `scopes` | 必须包含 `write_cart_transform`（读取购物车数据再加 `read_cart_transform`） |

## 命令参考

| 命令 | 作用 |
|------|------|
| `shoplazza app extension create --type function --name <name>` | 生成一个新的函数扩展 |
| `shoplazza app function compile --name <name>` | 本地把 `src/index.js` 编译为 WASM（不发布） |
| `shoplazza app function release --name <name>` | 编译并发布单个函数 |
| `shoplazza app function list` | 列出当前激活应用的函数扩展 |
| `shoplazza app deploy` | 构建并发布整个应用，切出新版本 |

完整参数参考：**[`app` 命令参考](/zh-CN/docs/app/developer-tools/shoplazza-cli/commands/app)**。

## 故障排查

- **`extension "my-function" already exists at …`** —— `extensions/` 下已有同名目录。换一个名字，或删掉旧目录。
- **`function entry not found`** —— `extensions/<name>/` 下缺少 `src/index.js`。检查 `--name`，或重新生成。
- **`javy build failed`** —— `src/index.js` 有语法错误或使用了不支持的 API。任何 Javy stderr 都会失败（即使退出码为 0）。精简后重编译。
- **`invalid --name`** —— 名称含路径分隔符或非法字符。使用 `^[a-z0-9][a-z0-9_-]*$`。
- **release 时报 `Version not match`** —— toml 的 `version` 与后端不同步。运行 `function list` 对齐。
- **`app deploy` 时报 `input_query: metafields_query has 6 entries, max 5` 或 `resource "order" invalid`** —— `[[metafields_query]]` 超过 5 条，或 `resource` 不是 `product` / `shop`。改好 toml 后重新发布。
- **发布新的函数版本后店铺价格没变** —— 已安装应用的店铺会沿用原来的绑定。在测试店铺卸载并重新安装应用，再删除购物车里的旧商品行、重新加购。
- **店铺里价格没变** —— 确认应用已安装、该店铺已绑定 `cart_transform` 端点；若输出被静默丢弃，检查校验是否通过、`block_on_failure` 行为是否符合预期。
- **函数超限** —— 命中 fuel/时长/输出上限时，减少每行计算量，只输出真正改动的行。

## 常见问题

**一个函数同时覆盖购物车和结账吗？**
是。发布一次即可；同一函数在购物车页（加入/更新）和结账创建时都会运行。

**函数能涨价也能降价吗？**
只能加价或保持原价。`adjustment_fixed_price` 必须是非负数，并且加在原价上，所以函数不能降价。需要降价时使用折扣功能。

**函数能做订单级或运费级折扣吗？**
不能——`cart_transform` 只改**行价与行标题**。订单/运费级折扣由 `cart_transform` 命名空间之外的独立折扣机制处理。

## 下一步

- **[`app` 命令参考](/zh-CN/docs/app/developer-tools/shoplazza-cli/commands/app)** —— 函数子命令的全部参数。
- **[构建你的第一个应用](/zh-CN/docs/app/developer-tools/shoplazza-cli/tutorials/build-your-first-app)** —— 端到端应用开发流程。
- **[提交审核](/zh-CN/docs/app/getting-started/create-public-app/submit-review)** —— 把应用发布给商家。
- **[用函数按商品加收附加费](/docs/app/use-cases/product-merchandising/add-surcharge-by-metafield)** —— 按商品元字段给购物车商品行计价的完整示例。
