Cookbook(实战示例集)
以完整的 ScriptDefinition 展示各种场景。语法依据请参见 Statement 目录与值表达式,执行与约束请参见执行语义、约束与安全。所有示例中写入的 fields 值都是 Locale 映射({ "<locale>": 值 }),示例 Locale 统一使用 en-US。所有示例都在处理调用请求的路径上内联执行,并以调用的响应正文返回结果。含有外部调用(Http·EmailSend)的示例,其语句的 timeoutMs 会加到执行时间预算上(Http 为 × (1 + retry));若该语句位于迭代(Loop·ResourceForEach)内部,则还会按迭代上限相乘(时间预算)。
目录
- 基本 CRUD:1. 创建并发布 Content · 2. 用计算值 update · 3. 汇总我的订单列表并返回 · 4. 单条查询后 guard 再审批
- 查询与 upsert:5. slug upsert · 6. 动态字段键与 Locale patch
- 外部 API:7. 额度预扣(CAS)与 LLM 调用·退款 · 8. 将图片 URL 转为 Media · 9. 将 base64 图片转为 Media · 10. 审核后有条件处理 · 11. try/catch fallback · 12. AI 摘要与标签
- 并行:13. 并行调用后合并 · 14. 注册审核
- 循环与聚合:15. 用数组输入创建 N 个 · 16. counted loop 种子数据 · 17. cascade 删除 · 18. 循环累加求和 · 19. 批量处理符合条件的全部项目 · 20. id 批量收集
- saga 与并发:21. 支付 saga · 22. 乐观锁 CAS
- 邮件:23. 给每笔订单的买家发送通知邮件
- 签名校验:24. Webhook 签名校验 · 25. 无密钥哈希签名校验
- 会员检索:26. 按邮箱查找会员并发放优惠券与通知邮件
基本 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 } 插入到引用字段中。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": "...", "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 上)。这里的响应是两层的。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": "...", "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 个。一个定义能容纳的外部调用数量是各套餐的限额(参见价格方案),请确认是否在该限额之内。
循环与聚合
在时间预算中,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 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 会删除预留(补偿)并返回 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)。
签名校验
支付服务商(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 字符>),因此在拆开请求头之前无法组装出待签名的消息。顺序就是这样定下来的。
Regex的Capture把请求头拆成{ /sig/1 }(时间戳)与{ /sig/2 }(校验码)。索引0是整个匹配,1起是捕获组。格式不符时{ /sig }为null,就地以400返回。Signature以"<时间戳>.<原文 body>"作为消息生成校验码,并与{ /sig/2 }比较。关键在于把消息取自解析前的原文({ /rawPayload })。把解析后的/payload重新变成字符串时,空白与数字写法会被规范化,无法回到对方签名时的字节。把两个指针并排放进字符串中会直接拼接,因此不需要任何运算符。{ /verified }为false时返回401。签名不对与请求头缺失都同为一个false(不会告诉发送方是哪一种错)。- 即使签名正确,过旧的请求也会被拒绝。
{ /now/seconds }是本次执行开始的时刻,据此检查与签名中携带的时间戳之差是否超过 replay window(这里是 300 秒)。时间戳虽然从请求头里以字符串传入,但算术运算会将其转换为数字。 - 只有全部通过之后,才会查找订单并修改其状态。
因为没有外部调用,也就没有可声明的时间,所以会在 30 秒基础预算之内结束,服务商可以就地拿到响应。用于校验的 secret 与 Http 请求头的 secret: true 不同,不是加密存储,因此请把可以读取该 Script 的角色范围收窄(参见安全模型的 secret 请求头)。
让服务商能够调用这个窗口的办法有两种,分岔点在于该服务商能不能发送自定义请求头。
- 能发送时,签发一个只带该 Script Execute 权限的令牌,让服务商放进
Authorization头并调用/execute。把角色收窄到某一个特定 Script 的做法见 SpaceRole 的 script 权限,令牌见 Space Access Token。这是首选。 - 不能发送时(只能登记回调 URL、没有附加请求头的设置的服务商),开启该 Script 的
anonymousCallEnabled,并把/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 秒)加到执行时间预算上,并在每个定义的外部调用限额中计为一个。
相关文档
- Statement 目录:示例中所用语句的字段与结果。
- 值表达式:引用、JsonLogic、Locale 映射规则。
- 执行语义、约束与安全:执行顺序、补偿、乐观锁、约束。
- Script 概述:顶层结构与一次执行可用的时间。
