Cookbook(实战示例集)

以完整的 ScriptDefinition 展示各种场景。语法依据请参见 Statement 目录与值表达式,执行与约束请参见执行语义、约束与安全。所有示例中写入的 fields 值都是 Locale 映射({ "<locale>": 值 }),示例 Locale 统一使用 en-US。所有示例都在处理调用请求的路径上内联执行,并以调用的响应正文返回结果。含有外部调用(Http、EmailSend)的示例,其 Statement 的 timeoutMs 会加到执行时间预算上(Http 为 × (1 + retry));若该 Statement 位于迭代(Loop、ResourceForEach)内部,则还会按迭代上限相乘(时间预算)。

目录

基本 CRUD

1. 创建并发布 Content

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "fields": { "title": { "en-US": "{ /payload/fields/title }" }, "body": { "en-US": "{ /payload/fields/body }" } },
      "publish": true, "name": "post" },
    { "type": "Return", "value": { "id": "{ /post/sys/id }" }, "statusCode": 201 } ] }

2. 用计算值 update(浏览量 +1)

{ "method": "Post",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }

3. 汇总我的订单列表并返回

{ "method": "Get",
  "statements": [
    { "type": "SetVar", "var": "orders", "value": [] },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "createdBy": ":self" }, "order": "-sys.createdAt", "from": "Current", "advanced": false,
      "limit": 20, "name": "order",
      "onEach": [
        { "type": "SetVar", "var": "orders", "value": { "$merge": [ "{ /vars/orders }", [ "{ /order }" ] ] } } ] },
    { "type": "Return", "value": { "orders": "{ /vars/orders }" } } ] }

用 createdBy: ":self" 只遍历“属于我的”项目,再用 SetVar 收集并返回。ResourceForEach 不会把遍历结果绑定为集合,因此若要作为列表返回,就要这样自行收集。在时间预算中,遍历按 onEach 所声明时间乘以处理项目数计算。这里 onEach 中没有外部调用,声明时间为 0,因此 30 秒基础预算就是实际上限;若在 onEach 中放入外部调用,那个乘积会直接计入预算,一旦达到上限 180 秒就会在那里中断(时间预算)。如果只需要读取列表并返回,那么与其用 Script 遍历,不如在前端直接调用 CDA/CMA 列表 API 更好。

4. 单条查询后 guard 再审批

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "!=": [ "{ /order/fields/status/en-US }", "pending" ] },
      "then": [ { "type": "Return", "value": { "reason": "not pending" }, "isError": true, "statusCode": 409 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "approved" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

用 ResourceRead 将单条绑定到名称后,可通过 { /order/fields/... } 直接引用。若不存在则在查询时报错(可用 Try 包裹)。

查询与 upsert

5. slug upsert(find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind 会直接绑定第一个匹配项(若无则为 null),并通过 { "!!": "{ /found/sys/id }" } 按是否存在进行分支。

6. 动态字段键与动态 Locale patch

{ "method": "Patch",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }

字段键和 Locale 桶键两者都是 { /ptr } 引用。用于把译文插入到特定的 Locale 桶中。

外部 API

7. 额度 guard、预扣(CAS)、LLM 调用、失败时退款(代表性示例)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_wallet" } },
      "where": { "createdBy": ":self" }, "name": "wallet" },
    { "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "isError": true, "statusCode": 402 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "version": "{ /wallet/sys/version }",
          "fields": { "balance": { "en-US": { "$-": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } },
          "name": "charged" } ],
      "catch": [ { "type": "Return", "value": { "ok": false, "reason": "version conflict, 重试" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
          "headers": [ { "key": "Authorization", "value": "Bearer { /secrets/LLM_API_KEY }" } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
        { "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
      "catch": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
        { "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ],
  "secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "用于调用 LLM 的 API 密钥" } ] }

先用 guard 检查余额是否充足,然后在外部调用之前先扣减。扣减通过 wallet 的 sys.version 施加乐观锁(CAS)。若在读取余额与扣减之间有其他执行改动了 wallet,则因版本不一致而 abort,catch 会返回 409。由于此时尚未发起外部调用,并发请求不会被重复扣减。只有在扣减确定之后才调用 LLM;若该调用失败,则在 catch 中把扣减的金额(cost)重新加回以完成退款(补偿),随后返回 502。这样的顺序是:在不可逆的外部调用之前先确定计费,仅在失败时才进行补偿。LLM API 密钥在 secrets 中声明,并在请求头中以 { /secrets/LLM_API_KEY } 读取。关于补偿的局限,请参见执行语义中的“无事务与补偿”。

8. 将图片(URL)创建为 Media 并附加到 Content

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "{ /secrets/IMAGE_API_KEY }" } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
                  "file":  { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
      "fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "图像生成 API 密钥" } ] }

先以 name 创建 Media,再由 ResourceCreate(Content)把 { /img/sys/id } 插入到引用字段中。Media 与 Content 使用相同的 fields 模型。file 是摄取指令 { source, encoding }。文件摄取没有可声明的时间,因此从 30 秒基础预算中支出,也不计入每个定义的外部调用限额(静态约束)。

9. 将 base64 图片转为 Media

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "{ /secrets/IMAGE_API_KEY }" } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "图像生成 API 密钥" } ] }

10. 审核后有条件 publish 或删除

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "{ /secrets/MODERATION_API_KEY }" } ],
      "body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
    { "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
      "then": [ { "type": "ResourceDelete",  "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
      "else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ],
  "secrets": [ { "key": "MODERATION_API_KEY", "value": "...", "description": "审核 API 密钥" } ] }

11. try/catch:外部失败时 fallback

{ "method": "Post",
  "statements": [
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://primary.api/gen",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/GEN_API_KEY }" } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
      "catch": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "生成失败" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ],
  "secrets": [ { "key": "GEN_API_KEY", "value": "...", "description": "生成 API 密钥" } ] }

12. 为文章填入 AI 摘要与标签

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer { /secrets/LLM_API_KEY }" } ],
      "body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
      "timeoutMs": 15000, "name": "resp" },
 
    { "type": "Try",
      "body": [
        { "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
          "fields": { "summary": { "en-US": "{ /ai/summary }" },
                      "tags":    { "en-US": "{ /ai/tags }" } } },
        { "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ],
  "secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "用于调用 LLM 的 API 密钥" } ] }

文章创建后由模型填入摘要与标签的流程(作为 Webhook 的关联动作挂在 Content.Create 上)。这里的响应是两层的。Http 的 responseType 默认为 Json,所以 API 的响应外层已经是对象,但模型给出的答案本身在 choices/0/message/content 里,是一个字符串。因此还要用 ParseJson 再剥一层,才能以 { /ai/summary }、{ /ai/tags } 取出各个值。标签则按数组原样写入 Array(元素为 ShortText)字段。

即使用结构化输出(response_format)立下约定,一旦响应因长度上限被截断,或模型拒绝该请求,回来的就不是 JSON。所以用 Try 包住,把解析失败转成 502。失败消息中会带上试图解析的文本,可以看清收到了什么。如果某个 API 连响应外层都不是 JSON,就给 Http 加上 responseType: "Text",把 { /resp/body } 直接交给它(参见 Http、ParseJson)。

并行

13. 合并 2 个并行外部调用生成 Content

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "GET", "url": "https://api.a.com/x", "name": "a" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.b.com/y", "name": "b" } ] ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_merged" } },
      "fields": { "left": { "en-US": "{ /a/body/value }" }, "right": { "en-US": "{ /b/body/value }" } } } ] }

14. 注册审核:并行评分后用 and 判定

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "POST", "url": "https://api.fraud.com/score",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/FRAUD_API_KEY }" } ],
          "body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/CREDIT_API_KEY }" } ], "name": "credit" } ] ] },
    { "type": "If",
      "condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
      "then": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
        { "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
      "else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ],
  "secrets": [ { "key": "FRAUD_API_KEY", "value": "...", "description": "欺诈评分 API 密钥" },
               { "key": "CREDIT_API_KEY", "value": "...", "description": "信用评分 API 密钥" } ] }

URL 路径中也可以插入 { /ptr }。分支结果在 join 之后引用。外部调用为 2 个。一个定义能容纳的外部调用数量是各套餐的限额(参见定价方案),请确认是否在该限额之内。

循环与聚合

在时间预算中,Loop 与 ResourceForEach 按 body(onEach)所声明时间乘以迭代上限计算。本节的示例在 body 中没有外部调用,声明时间为 0,因此 30 秒基础预算就是实际上限。迭代实际受限的地方也正是这里(时间预算、Loop)。

15. 用数组输入创建 N 个 Content(Loop over)

{ "method": "Post",
  "statements": [
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
          "fields": { "name": { "en-US": "{ /item/name }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. counted loop(for):槽位种子数据

{ "method": "Post",
  "statements": [
    { "type": "Loop", "for": { "from": 1, "to": 5 }, "name": "i", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_slot" } },
          "fields": { "index": { "en-US": "{ /i }" }, "status": { "en-US": "open" } } } ] } ] }

for 是从 from 到 to 包含两端(整数字面量,step 默认为 1)。name 会把当前计数器绑定到 { /i }。

17. cascade 删除(ForEach、Delete)

{ "method": "Delete",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
      "where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "from": "Current", "advanced": false, "name": "comment",
      "onEach": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /comment/sys/id }" } } } ] },
    { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] }

ResourceForEach 会在内部对匹配项分页并逐项删除,因此无需手动分页即可删除所有(直到平台上限)符合条件的评论,再删除帖子自身。由于 onEach 没有可声明的时间,这个定义的执行时间预算是 30 秒。

18. 循环累加:SetVar 求和

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "total", "value": 0 },
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "row", "maxIterations": 100,
      "body": [ { "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_summary" } },
      "fields": { "totalQty": { "en-US": "{ /vars/total }" } } } ] }

19. 批量处理符合条件的全部项目

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "where": { "fields.status": { "eq": "draft" } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "post",
      "onEach": [
        { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /post/sys/id }" } } } ] } ] }

ResourceForEach 会在内部对匹配项分页,因此无需 cursor 循环(Loop while + SetVar 累加)。找出所有符合条件的 draft 并逐项发布。若数量非常大、难以跑完,可用 limit 设定一次处理的上限,并把 where 设为“未处理”条件,通过重跑接续处理。

20. 用邮箱列表批量收集 id(merge)

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "ids",     "value": [] },
    { "type": "SetVar", "var": "missing", "value": [] },
    { "type": "Loop", "over": "{ /payload/fields/emails }", "name": "email", "maxIterations": 100,
      "body": [
        { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "where": { "fields.email": { "eq": "{ /email }" } }, "name": "acc" },
        { "type": "If", "condition": { "!!": "{ /acc/sys/id }" },
          "then": [ { "type": "SetVar", "var": "ids",     "value": { "$merge": [ "{ /vars/ids }",     [ "{ /acc/sys/id }" ] ] } } ],
          "else": [ { "type": "SetVar", "var": "missing", "value": { "$merge": [ "{ /vars/missing }", [ "{ /email }" ] ] } } ] } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_campaign" } },
      "fields": { "recipients": { "en-US": "{ /vars/ids }" }, "unresolved": { "en-US": "{ /vars/missing }" } } } ] }

读取(ResourceFind)不是外部调用,因此在 Loop body 中是允许的。分别用 merge 累积存在与不存在两种情况。

saga 与并发

21. 支付 saga(Try/catch/finally)

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "fields": { "sku": { "en-US": "{ /payload/fields/sku }" }, "status": { "en-US": "reserved" } },
      "publish": false, "name": "order" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.pay.com/charge",
          "headers": [ { "key": "Authorization", "value": "Bearer { /secrets/PAYMENT_API_KEY }" } ],
          "body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
        { "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
      "catch": [
        { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
        { "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
      "finally": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
          "fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ],
  "secrets": [ { "key": "PAYMENT_API_KEY", "value": "sk-...", "description": "支付 API 密钥" } ] }

预留(draft)之后,若支付成功则确定并 publish 且返回 201;失败时 catch 会删除预留(补偿)并返回 402;finally 始终记录日志。删除式补偿会产生新的 sys.id,因此存在引用断裂的局限(参见执行语义中的“无事务与补偿”)。

22. 乐观锁 CAS

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_stock" } },
      "where": { "fields.sku": { "eq": "{ /payload/fields/sku }" } }, "name": "stock" },
    { "type": "If", "condition": { "<": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] },
      "then": [ { "type": "Return", "value": { "reason": "out of stock" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /stock/sys/id }" } },
          "version": "{ /stock/sys/version }",
          "fields": { "qty": { "en-US": { "$-": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] } } } },
        { "type": "Return", "value": { "ok": true } } ],
      "catch": [
        { "type": "Return", "value": { "reason": "version conflict, 重试" }, "isError": true, "statusCode": 409 } ] } ] }

先读取库存以获取最新的 sys.version,再用该版本进行扣减(version)。若在读取与写入之间有其他执行改动了值,则因版本不一致而 abort,catch 返回 409。库存不足的 guard 位于 Try 之外(正常的提前返回)。

邮件

23. 给每笔订单的买家发送通知邮件(ForEach + EmailSend)

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.notified": { "ne": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "order",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
          "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
          "subject": "配送已开始",
          "body": "<p>您订购的商品已开始配送。</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

遍历尚未发送通知的订单(fields.notified 不为 true 的那些),给每笔订单的买家发送邮件并随即标记 notified。EmailSend 一封邮件只有 1 名收件人,因此多条发送就这样用 ResourceForEach 逐项发送(onEach 中可以放入外部调用)。用 toServiceUser 给出时,会员地址不会进入 Script 变量空间,而是在发送前才 resolve。由于把 where 设为“未处理”,并在 onEach 末尾标记完成,因此即便中途中断,重跑时也会从剩余订单接续(在副作用成功后紧接着的标记失败时,该笔可能在下次执行中重复。at-least-once)。

24. 用在内容工作室里修改的邮件模板发送(templateBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_shipping" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "order",
      "target": { "sys": { "id": "{ /payload/orderId }" } } },
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
      "subject": "您的订单已开始配送 (订单号 { /order/sys/id })",
      "templateBody": {
        "source": "{ /tpl/fields/html/en-US }",
        "data": { "name": "{ /order/fields/buyerName/en-US }",
                  "tracking": "{ /order/fields/trackingNo/en-US }" } } },
    { "type": "Return", "value": { "sent": true } } ] }

把模板放在 Content 里(cnt_mail_shipping 的 html Field),只填充其中的空位。即使市场营销人员在内容工作室里修改文案,Script 也保持不变。模板是这样的。

<p>{ /name },您订购的商品已开始配送。</p>
<p>运单号:{ /tracking }</p>

source 必须是指向本定义所读取的 Content 的单个指针。读取模板的 ResourceRead 要放在 EmailSend 之前。Statement 按书写顺序执行,因此若指向尚未读取的名称,值会为空而导致执行失败。模板的空位读取的只有 data,所以无法从模板直接读取 /payload。指向 data 中不存在的键的空位会变成空字符。

25. 把管理员撰写的新闻稿发给订阅者(autoEscape)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "issue",
      "target": { "sys": { "id": "{ /payload/issueId }" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_newsletter" } } },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_subscriber" } },
      "where": { "fields.active": { "eq": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "sub",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_news" } },
          "to": "{ /sub/fields/email/en-US }",
          "subject": "{ /issue/fields/title/en-US }",
          "autoEscape": false,
          "templateBody": {
            "source": "{ /tpl/fields/html/en-US }",
            "data": { "article": "{ /issue/fields/bodyHtml/en-US }" } } } ] } ] }

新闻稿正文是管理员用富文本写的 HTML,若原样保留转义,标签就会显示为文字。把 autoEscape 设为 false,data 中所含的标记就会被渲染。

请把设为 false 的范围收窄。 这项设置作用于那封 EmailSend 的所有插值。因此本示例的 data 中只放了管理员撰写的 article 一项。若把订阅者姓名这类来自外部的值混进同一个 data,那个值里的标记也会一并被渲染。需要这类值时,请把它放到模板一侧,或者把发送拆开。模板本身也与 autoEscape 无关,始终按原样渲染,因此请确认模板与进入 data 的值是否都只出自对该 Content 拥有写入权限的人之手。

签名校验

支付服务商(PG、MoR)在发送 Webhook 时会给 body 附上签名。接收方在做任何事之前,都必须确认该签名能否用自己持有的密钥复现出来。下面两个示例是实际会分化的两种方式:一种用密钥生成校验码(keyed),另一种把字段与密钥拼接后计算摘要。

26. Webhook 签名校验(拆解打包在一起的请求头、replay window)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "{ /secrets/WEBHOOK_SIGNING_SECRET }",
      "value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
    { "type": "If", "condition": { "!": "{ /verified }" },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "==": [ "{ /order }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ],
  "secrets": [ { "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_9f2c1b7ae4", "description": "支付服务商的 Webhook 签名密钥" } ] }

服务商把时间戳与校验码装在同一个请求头里发来(t=1492774577,v1=<64 个 hex 字符>),因此在拆开请求头之前无法组装出待签名的消息。顺序就是这样定下来的。

  1. Regex 的 Capture 把请求头拆成 { /sig/1 }(时间戳)与 { /sig/2 }(校验码)。索引 0 是整个匹配,1 起是捕获组。格式不符时 { /sig } 为 null,就地以 400 返回。
  2. Signature 以 "<时间戳>.<原文 body>" 作为消息生成校验码,并与 { /sig/2 } 比较。关键在于把消息取自解析前的原文({ /rawPayload })。把解析后的 /payload 重新变成字符串时,空白与数字写法会被规范化,无法回到对方签名时的字节。把两个指针并排放进字符串中会直接拼接,因此不需要任何运算符。
  3. { /verified } 为 false 时返回 401。签名不对与请求头缺失都同为一个 false(不会告诉发送方是哪一种错)。
  4. 即使签名正确,过旧的请求也会被拒绝。{ /now/seconds } 是本次执行开始的时刻,据此检查与签名中携带的时间戳之差是否超过 replay window(这里是 300 秒)。时间戳虽然从请求头里以字符串传入,但算术运算会将其转换为数字。
  5. 只有全部通过之后,才会查找订单并修改其状态。

因为没有外部调用,也就没有可声明的时间,所以会在 30 秒基础预算之内结束,服务商可以就地拿到响应。签名密钥已在 secrets 中声明,因此 WEEGLOO 会将其加密并安全地存储。在 Statement 中以 { /secrets/WEBHOOK_SIGNING_SECRET } 读取。不过,查询该 Script 时会看到原始值,因此请把可以读取该 Script 的角色范围收窄(参见安全模型的 secrets)。

让服务商能够调用这个窗口的办法有两种,分岔点在于该服务商能不能发送自定义请求头。

  • 能发送时,签发一个只带该 Script Execute 权限的令牌,让服务商放进 Authorization 头并调用 /execute。把角色收窄到某一个特定 Script 的做法见 SpaceRole 的 script 权限,令牌见 Space Access Token。这是首选。
  • 不能发送时(只能登记回调 URL、没有附加请求头的设置的服务商),开启该 Script 的 anonymousCallEnabled,并把 /execute/anonymous 地址登记为回调。此时执行会变成作者身份,因此这个 Script 修改的订单的 updatedBy 也会是作者;而且由于没有认证,上面的签名校验就成为这个窗口唯一的认证。条件与保存规则见匿名调用。

上面的定义原样就满足匿名 Script 的条件:它没有使用 createdBy: ":self" 过滤器,而且未通过签名与 replay window 的请求会在碰到任何东西之前就被 Return 截断。

27. 无密钥哈希签名校验

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }{ /secrets/MERCHANT_KEY }" },
    { "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
      "fields": { "status": { "en-US": "paid" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ],
  "secrets": [ { "key": "MERCHANT_KEY", "value": "9f2c1b7ae4", "description": "与支付服务商共享的商户密钥" } ] }

这是不用 HMAC,而是把约定好的若干字段与密钥拼接后计算 SHA256 的方式。Hash 没有 secret 字段。密钥在 secrets 中声明,并在 value 里该方案摆放密钥的位置写成 { /secrets/MERCHANT_KEY }。各方案会把密钥放在前面、后面或中间,而这样写就能表达任何位置。

encoding 要与对方的表示形式一致(Hex、HexUpper、Base64、Base64Url)。与 Signature 不同,结果是字符串,所以要自己做比较,而那个比较是普通的相等比较。value 的上限是 128 个字符,因此对整个 body 计算的方案请使用 Signature。

会员检索

28. 按邮箱查找会员并发放优惠券与通知邮件

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "ServiceUser",
      "where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" },
    { "type": "If", "condition": { "==": [ "{ /member }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "member not found" }, "isError": true, "statusCode": 404 } ] },
 
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_coupon" } },
      "fields": { "code": { "en-US": "WELCOME-{ /member/sys/id }" },
                  "owner": { "en-US": "{ /member/sys/id }" } }, "publish": false, "name": "coupon" },
 
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /member/sys/id }" } },
      "subject": "优惠券已发放",
      "body": "<p>{ /member/nickname },我们给您发放了优惠券 { /coupon/fields/code/en-US }。</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

用一个邮箱地址找到会员,并把其 sys.id 用作优惠券的所有者和邮件收件人。读取会员目录时的规则如下。

  • sys.email 是加密存储的,因此只接受精确匹配一类的运算符(eq、ne、in、nin)。给出 prefix 之类的其他运算符时,不会悄悄返回 0 条,而是执行失败。
  • 没有匹配时 ResourceFind 绑定 null,因此判断是否存在的写法与查找 Content 时相同。
  • 与 Content、Media 不同,会员的字段不是 Locale 映射。像 { /member/nickname } 这样直接引用即可。
  • 发送邮件时不要取出地址,而是把 sys.id 交给 toServiceUser。引擎会在发送前才解析地址,因此会员的地址不会进入 Script 的变量空间。
  • 要保存这个定义,作者的 SpaceRole settings 中必须含有 SETTING_SERVICE_LOGIN。创建或删除会员的 Statement 在任何角色下都无法保存(参见处理会员目录)。

EmailSend 会把 timeoutMs(未声明时为 10 秒)加到执行时间预算上,并在每个定义的外部调用限额中计为一个。

文件传输

29. 将商品照片发送给图像分析服务并记录结果(multipartBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } }, "name": "product" },
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /product/fields/photo/en-US/sys/id }" } }, "name": "photo" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.vision.example/v2/analyze",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/VISION_API_KEY }" } ],
          "multipartBody": [
            { "name": "image", "url": "{ /photo/fields/file/en-US/url }" },
            { "name": "language", "value": "ko" },
            { "name": "features", "value": "labels" },
            { "name": "features", "value": "text" } ],
          "timeoutMs": 20000, "name": "analysis" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /product/sys/id }" } },
          "fields": { "aiLabels": { "en-US": "{ /analysis/body/labels }" },
                      "aiText":   { "en-US": "{ /analysis/body/text }" } } },
        { "type": "Return", "value": { "ok": true, "labels": "{ /analysis/body/labels }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "image analysis failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 502 } ] } ],
  "secrets": [ { "key": "VISION_API_KEY", "value": "...", "description": "图像分析 API 密钥" } ] }

这是卖家登记商品后,从商品照片中提取标签与照片中的文字并记录到商品上的流程(作为 Webhook 的关联动作挂在 Content.Create 上)。通过商品 Content 的 photo 引用字段读取 Media,再把该文件的 url 写入 multipartBody 的文件 part。api.vision.example 是示例用的虚构服务,这里假设该服务返回 labels(字符串数组)与 text(字符串)。aiLabels 是 Array(元素为 ShortText)字段,因此按数组原样写入。请按实际所用服务的响应结构修改 { /analysis/body/... } 路径。

  • part 构成:数组的一个元素就是一个 part,part 按数组顺序发送。image 是指定了 url 的文件 part,其余是指定了 value 的文本 part。像 features 这样同一名称使用多次时,各自作为独立的 part 发送。
  • Content-Type:整个请求始终以 multipart/form-data 发送,因此不要在 headers 中指定。image part 省略了 contentType,因此使用文件 URL 响应的 Content-Type。文件名也被省略,因此文件 URL 的最后一个路径片段会成为文件名。
  • 失败处理:文件超过下载限额、无法从文件 URL 获取文件,或者超时,该 Http 都会失败。目标服务响应 400 以上的 status 时也会失败。本示例没有指定 ignoreStatusCode(默认值 false),因此无论哪种情况都会转到 catch,由 catch 中的 Return 返回 status 502。商品不会被修改。各类失败的处理方式见 Http 失败的两种方式。
  • 时间预算:在 Pro 套餐下,这个 Script 可用约 60.5 秒(基础 30 秒 + timeoutMs 20 秒 + 一张照片的下载时间约 10.5 秒)。计算方法见时间预算。

如果 API 是把一个文件作为整个正文发送的(向上传 URL 执行 PUT 的形式),请使用 urlBody 代替 multipartBody。两个字段的规则以及 Content-Type 的确定顺序见将文件作为正文发送。

文件转换

30. 印有买家邮箱的电子书 PDF 副本(PdfWatermark)

{ "method": "Post",
  "payloadSchema": {
    "type": "object", "required": [ "fields" ],
    "properties": {
      "fields": {
        "type": "object", "required": [ "ebookMediaId", "email", "orderId" ],
        "properties": {
          "ebookMediaId": { "type": "string" },
          "email":   { "type": "string", "minLength": 1, "maxLength": 41, "not": { "pattern": "[^ -~]" } },
          "orderId": { "type": "string", "minLength": 1, "maxLength": 20, "not": { "pattern": "[^A-Za-z0-9-]" } } } } } },
  "statements": [
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /payload/fields/ebookMediaId }" } }, "name": "ebook" },
    { "type": "ResourceCreate", "resource": "Media", "publish": false,
      "fields": {
        "title": { "en-US": "{ /ebook/fields/title/en-US }" },
        "file": { "en-US": {
          "source": "{ /ebook/fields/file/en-US/url }", "encoding": "url",
          "transformations": [
            { "type": "PdfWatermark", "text": "{ /payload/fields/email } · { /payload/fields/orderId }" } ] } } },
      "name": "copy" },
    { "type": "Return", "value": { "mediaId": "{ /copy/sys/id }" } } ] }

这是在确认付款后,制作要交给买家的电子书副本的流程。通过请求 payload 接收原始电子书 Media 的 id、买家邮箱与订单号。原始 Media 保持不变,创建一个每一页都沿对角线印有 邮箱 · 订单号 的新 Media,并返回其 id。

  • 文字长度:模板之外的固定文字是 ·,共 3 个字符。payloadSchema 将邮箱限制在 41 个字符以内、订单号限制在 20 个字符以内,因此印出的文字不会超过 64 个字符。两个值中只要有一个含有允许范围之外的字符(包括换行符),请求就会在执行前被拒绝,因此不会因为文字而在转换阶段失败。邮箱所接受的 ASCII 可打印字符全部包含在可用字符中。
  • 颜色:省略了 color,因此以默认值 #808080(灰色)印出。
  • 发布:这是为某一位买家准备的副本,因此将 publish 设为 false。副本不会通过 CDA/ACDA 传递。
  • 失败处理:原始文件不是 PDF 或已加密时,ResourceCreate 会以 status 422 失败,不会创建新的 Media。失败情况的完整列表见文件转换失败的情况。
  • 时间预算:接收与转换文件的时间从 30 秒基础预算中支出(时间预算)。
  • 购买确认:本示例中没有确认付款与是否购买的步骤。如果保持原样,拥有执行权限的调用方可以用任意 Media id 制作副本。实际使用时,请先读取订单 Content,确认买家与商品一致后再继续。