跳到主要内容

Function 扩展开发指南

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——它是纯服务端逻辑,读取购物车上下文、返回计价操作。

为什么是 WASM?

以 WASM 形式运行,让你的逻辑能在 Shoplazza 基础设施上确定性、沙箱化地执行,无需每次购物车变动都回调你的后端。你仍然写 JavaScript,CLI 用 Javy 工具链编译,平台用 Wasmtime 引擎运行。

运行位置:购物车与结账​

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

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

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

备注

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

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

调试改价

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

工作原理​

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(shoplazza auth login)。

  • 已用 shoplazza app init 创建的应用项目。参见构建你的第一个应用。

  • shoplazza.app.toml 配置了正确的权限范围:cart_transform 需要 write_cart_transform(读取购物车数据再加 read_cart_transform)。默认应用模板已自带:

    toml
    scopes = "read_customer write_cart_transform"
备注

你无需自己安装 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"
字段说明
resourceproduct(出现在 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 步)。
函数读取的元字段要精简

单个元字段的值最大 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。

备注

[[metafields_query]] 声明随 app deploy(以及 app dev)提交,单独执行 function release 不会提交声明。

查看已发布函数​

shell
shoplazza app function list

第 6 步 —— 绑定与上线​

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

安装即自动绑定​

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

也就是说:发布函数(第 5 步)→ 安装/发布应用,函数即生效,无需额外的绑定调用。

先发布,再安装
  • 绑定在安装时创建,所以函数必须在应用安装到店铺之前用 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。

自建后端的应用

如果你的应用托管在自己的后端(而非 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. ✅ 按提交审核提交并发布应用。审核通过后,每个安装你应用的商家都会自动获得已绑定、已生效的函数。

配置参考​

shoplazza.extension.toml​

字段设置方说明
name你(通过 --name)扩展名称,同时是 extensions/ 下的目录名
type脚手架恒为 "function"
id首次发布远端 function_id;决定下次 release 走 create 还是 commit
versionfunction 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 命令参考。

故障排查​

  • 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 命名空间之外的独立折扣机制处理。

下一步​