Cookbook(实战示例集)

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

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

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "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 }" } } } ] }

先以 name 创建 Media,再由 ResourceCreate(Content)把 { /img/sys/id } 插入到引用字段中。MediaContent 使用相同的 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": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ] }

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "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 }" } } } ] } ] }

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": "...", "secret": true } ],
          "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" } } } ] } ] }

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
      "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 } ] } ] }

文章创建后由模型填入摘要与标签的流程(作为 Webhook 的关联动作挂在 Content.Create 上)。这里的响应是两层的。HttpresponseType 默认为 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 } 直接交给它(参见 HttpParseJson)。

并行

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": "...", "secret": true } ],
          "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": "...", "secret": true } ], "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 } ] } ] }

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

循环与聚合

在时间预算中,LoopResourceForEach 按 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 是从 fromto 包含两端(整数字面量,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 sk-...", "secret": true } ],
          "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 }" } } } ] } ] }

预留(draft)之后,若支付成功则确定并 publish 且返回 201;失败时 catch 会删除预留(补偿)并返回 402finally 始终记录日志。删除式补偿会产生新的 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 的那些),给每笔订单的买家发送邮件并随即标记 notifiedEmailSend 一封邮件只有 1 名收件人,因此多条发送就这样用 ResourceForEach 逐项发送(onEach 中可以放入外部调用)。用 toServiceUser 给出时,会员地址不会进入 Script 变量空间,而是在发送前才 resolve。由于把 where 设为“未处理”,并在 onEach 末尾标记完成,因此即便中途中断,重跑时也会从剩余订单接续(在副作用成功后紧接着的标记失败时,该笔可能在下次执行中重复。at-least-once)。

签名校验

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

24. 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": "whsec_9f2c1b7ae4",
      "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 } } ] }

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

  1. RegexCapture 把请求头拆成 { /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 秒基础预算之内结束,服务商可以就地拿到响应。用于校验的 secretHttp 请求头的 secret: true 不同,不是加密存储,因此请把可以读取该 Script 的角色范围收窄(参见安全模型的 secret 请求头)。

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

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

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

25. 无密钥哈希签名校验

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" },
    { "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 } } ] }

这是不用 HMAC,而是把约定好的若干字段与密钥拼接后计算 SHA256 的方式。Hash 没有 secret 字段,密钥(9f2c1b7ae4)要按该方案摆放的位置直接写进 value 里。因为各方案会把密钥放在前面、后面或中间,所以这种写法才能表达所有位置。

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

会员检索

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

{ "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。创建、修改、删除会员的语句在任何角色下都无法保存(参见读取会员目录)。

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