执行语义、约束与安全
本文梳理 Script 在运行时如何工作(顺序、事务、错误、锁),保存时受到哪些静态约束,以及安全模型。语法参见 Statement 目录与值表达式,实战组合参见 Cookbook。
执行顺序
statements从上到下顺序执行。到达Return时在该处终止。- 执行是在处理调用请求的路径上内联进行的。 调用的响应就是执行结果(响应形态参见 Script 概述的请求与响应),一次执行可用的时间在下面的时间预算中讲解。
执行语义
Guard(前置条件)
没有专用的 guard Statement。用 If 和 then:[Return] 表达。条件违反时返回结果,之后的 Statement 不再执行(当然也可以有不带 guard 的 Script)。
{ "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
"then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "statusCode": 402 } ] }无事务与 best-effort 补偿
Script 不是事务。 失败时引擎会尝试对目前已完成的操作进行补偿(compensation)并返回错误原因,但存在以下限制(设计上接受的取舍)。
- 撤销删除会创建新的
sys.id,因此原本指向它的引用会失效。 - 外部副作用(
Http)不可逆(已发出的调用与计费无法撤销)。 - 补偿完全不执行,因而可能残留未补偿状态。
如果确实需要原子性,请由用户用 Script 亲自补偿,或者把不可逆的操作(外部调用等)放到最后。“能连锁执行却无法回滚、看上去却很安全”的顺序最危险。
乐观锁
update/patch 竞争通过 ResourceUpdate 和 ResourcePatch 的 version 来收窄。给出 version(值表达式,Int)后,只有当它与目标当前的 sys.version 一致时才更新,不一致则以版本冲突错误 abort(可用 Try/catch 局部处理)。省略时不做检查,采用 last-write-wins。通常先用 ResourceRead 或 ResourceFind 读取,再传入其 sys.version(参见 Cookbook 中的乐观锁 CAS)。
基于 origin 的写入
写入始终反映到 origin(draft),delivery(CDA/ACDA)的暴露由 publish 控制(ResourceCreate/ResourceUpdate/ResourcePatch 的 publish,或 ResourcePublish/ResourceUnpublish)。
什么算失败
执行过程中如果有一个 Statement 失败,只要没有处理该失败的 Try,执行就会在该处中断。此时引擎会尝试撤销到那时为止已执行的操作。撤销存在限制,详细内容在无事务与 best-effort 补偿中讲解。
失败的 Statement 如果位于 Try 内,就可以在 catch 中处理该失败。在 catch 内通过 /error 引用失败内容({ message })。其中不包含是在哪个 Statement 失败的信息。
以下情况会被视为失败。
- 资源操作失败的情况(对象不存在、版本不一致、不支持的运算等)
Http失败的情况(参见 Http 失败的两种方式)- Media 文件转换失败的情况(参见文件转换失败的情况)
- 超出各 Statement 规定的运行时上限的情况(例如:值长度上限)
Return 不是失败。它是正常地提前结束执行,因此不会转到 catch。
Http 失败的两种方式
Http 的失败按是否作为 status 响应处理分为两种。这一区别决定了 retry 与 ignoreStatusCode 是否适用。
| 区分 | 作为 status 响应处理的情况 | 立即失败的情况 |
|---|---|---|
| 适用情况 | 目标 API 响应 400 以上 status 的情况、超时的情况(status 504)、未能连接目标 API 或未能读取响应的情况(status 502)、响应正文超过 10MiB 的情况(status 502) | responseType 为 "Json" 但成功响应的正文不是 JSON 的情况、要发送的文件超出下载限额的情况、文件 URL 响应非 2xx 的 status 或无法连接文件 URL 的情况、part 值的格式错误的情况 |
retry | 按指定的次数重试 | 不重试 |
ignoreStatusCode: true | 不视为失败,而是把 { status, body } 绑定为结果 | 不适用,按失败处理 |
Try/catch | 可以在 catch 中处理 | 可以在 catch 中处理 |
文件发送过程中失败的条件,在将文件作为正文发送中详细讲解。
服务端聚合仅限条数
条数由 ResourceCount 在服务端统计。它不会读取项目,因此不受处理项目数上限的限制。
sum 与 group-by 没有专用的服务端操作。这类聚合需要通过 ResourceForEach 遍历,并用 SetVar 与 JsonLogic 亲自计算,因此受限于处理项目数上限(不适合数百万条的聚合)。只需要条数时,不要遍历,请使用 ResourceCount。
无等待与延迟
Script 中没有 Delay Statement。Script 只执行一次就结束,不会在内部等待或轮询直到外部 job 完成。
静态约束(保存时校验)
以下会在保存(创建/修改)Script 的时刻检查。违反时保存会被拒绝(不是运行时,而是在编写时失败)。哪种违反会以哪个错误码被拒绝,在错误中讲解。
| 约束 | 值 |
|---|---|
每个定义最多外部调用(Http、EmailSend) | 按套餐(参见定价方案) |
ResourceForEach 处理项目总数最多(未声明 limit 时遍历到该值为止,仍有剩余匹配时触及则失败) | 10,000 |
每个定义最多 SetVar(含嵌套) | 10 |
每个定义最多 Cache(含嵌套,与操作种类无关合并计数) | 5。超过则拒绝保存 |
Loop、ResourceForEach 块内的 Cache | 拒绝保存 |
Cache.key | 仅字面量,最长 128 个字符。写成值表达式则拒绝保存 |
Cache.ttl | 1 到 30 秒之间,省略时为 5 秒。超出该范围则拒绝保存 |
| 每个定义全部 Statement 最多(含嵌套) | 按套餐(参见定价方案) |
Http.retry 上限 | 2 |
一个 Http 的请求正文字段(body、urlBody、multipartBody) | 三者只能写一个。写两个以上则拒绝保存 |
Http.multipartBody 的 part 数 | 1 个以上。为空则拒绝保存 |
multipartBody part 的 value、url | 恰好一个。两者都写或两者都不写则拒绝保存 |
multipartBody part 的 fileName | 仅限写了 url 的 part。没有 url 却写了则拒绝保存 |
Regex.pattern 长度 | 128 个字符 |
每个定义最多 secrets | 20 |
secrets 的 key | 1~64 个字符,仅限英文字母、数字、_、-。有两个相同的 key 则拒绝保存 |
secrets 的 value | 1~4,096 个字符。超出该范围则拒绝保存 |
{ /secrets… } 引用 | 恰好为 /secrets/<已声明的 key>。否则拒绝保存 |
| 创建或删除 ServiceUser 的 Statement | 拒绝保存。只有三个读取 Statement 与 ResourceUpdate、ResourcePatch 接受该资源 |
anonymousCallEnabled 为 true 时 where 中的 createdBy: ":self" | 拒绝保存 |
Media 摄取指令的 transformations(文件转换) | 仅限数组。元素的 type 为 PdfWatermark,color 为 #RRGGBB。text 所写的整个字符串(包括 { /pointer } 的路径)必须是由可用字符组成的一行。违反则拒绝保存 |
PdfWatermark 的字面量 text 长度 | 1~64 个字符。超出该范围则拒绝保存。含有 { /pointer } 的 text 在保存时不检查,而是在执行时按解析后的值检查 |
上表中的固定上限由平台设定,与套餐无关,都是同一个值。而每个定义的全部 Statement 数与外部调用数则是按套餐的上限。这两者不是有效性错误,而是套餐上限,因此超出时保存、修改会以超出套餐上限被拒绝(同一定义在更高套餐上可被允许),升级即可解除。各套餐的具体数值见定价方案。
Media 文件摄取与
Http、EmailSend这样的外部调用不同,不计入每个定义的外部调用上限。
ResourceForEach是拥有子项的复合 Statement,因此它自身不计入外部调用数。 被计入的是onEach内的外部调用 Statement(Http、EmailSend)(静态上按 1 计数,但会随遍历对每一项实际执行)。onEach中可以放入外部调用或 Media 文件摄取,Loop的 body 也是如此。迭代实际循环多少次不会进入这个计数,而是在下面的时间预算中以乘法计入。
值长度上限(运行时)
签名与文本处理 Statement,以及 Cache,对所处理的值的大小设有上限。上限针对的是表达式解析后的值的长度,而不是表达式本身的长度({ /rawPayload } 这十六个字符会指向几十 KB),因此它不是在保存时检查,而是在运行中检查。
- 四者都与其他运行时失败相同,可以用
Try/catch局部处理。 Signature的上限是按实际服务商发送的 body 大小来定的(支付事件为数 KB,订单 Webhook 可达数十 KB)。Hash是放几个拼接字段的位置,因此窄得多。Regex.pattern的 128 个字符是上面静态约束中的保存时检查。这个长度并不是阻止失控匹配的装置((a+)+$只用六个字符就很危险)。阻止失控匹配的是只允许把 pattern 写成字面量的规则与下面的时间预算,而长度所承诺的只是一个人可以阅读和审查的规模。
时间预算(运行时)
一次执行允许的时间最长为 180 秒。实际允许的时间由 Script 定义(发送文件时还包括套餐)决定,等于基础 30 秒加上外部调用可使用的时间。
这个预算适用于整个执行。此外,每一次 Http 请求还必须在该请求的 timeoutMs 内完成。
基础 30 秒
读取和写入资源、获取并处理 Media 文件等不属于外部调用的操作,全部在这 30 秒内处理。不含外部调用的 Script 允许 30 秒。
Statement 各自增加的时间
顺序排列的 Statement 的时间全部相加。
| Statement | 增加的时间 |
|---|---|
Http | timeoutMs(未指定时为 30 秒)。发送文件时按每个文件加上下载时间(见下表),指定了 retry 时再对整体乘以尝试次数(1 + retry) |
EmailSend | timeoutMs(未指定时为 10 秒)。不重试,因此只加一次 |
If | then 与 else 中较长一方的时间 |
Parallel | branches 中最长一个的时间 |
Try | body、catch、finally 的时间之和 |
Loop | body 的时间乘以 maxIterations(未指定时为 10,000)的值 |
ResourceForEach | onEach 的时间乘以 limit(未指定时为 10,000)的值 |
| 其他 Statement(不包含其他 Statement 的) | 不增加。在基础 30 秒内处理 |
迭代内没有外部调用时,即使乘以迭代次数,增加的时间也是 0。因此这类迭代必须在基础 30 秒内完成。
每个文件的下载时间
| Plan | 下载时间 |
|---|---|
| Free | 约 5.2 秒 |
| Basic | 约 10.5 秒 |
| Pro | 约 10.5 秒 |
| Enterprise | 约 107.4 秒 |
这个值是以每秒 10,000,000 字节的速度接收相当于套餐下载限额的数据所需的时间。
计算示例
在 Pro 套餐下,调用一个发送两张照片的 Http(timeoutMs 30 秒,retry 1)的 Script,按如下方式计算。
Http:(30 秒 + 10.5 秒 × 2) × 2 = 约 102 秒- 整个执行:30 秒 + 102 秒 = 约 132 秒
132 秒短于 180 秒,因此该 Script 允许 132 秒。
超过 180 秒的情况
即使计算出的值超过 180 秒,Script 依然会被保存并执行。不过执行到达 180 秒时,会在那一刻中断。
各套餐的数量上限
Script 的每个 Organization 的数量按套餐限制。
| Plan | Script 数量 |
|---|---|
| Free | 10 |
| Basic | 30 |
| Pro | 100 |
| Enterprise | 无限制 |
除此之外,一个 Script 定义所能容纳的 Statement 数与外部调用(Http、EmailSend)数也按套餐限制。保存、修改定义时若超出该套餐的上限则被拒绝,具体数值请参见定价方案。
达到上限后,新建 Script 会被拒绝。
Http 文件下载大小
Http 从 URL 获取要作为请求正文发送的文件时(urlBody、multipartBody 的 url part),每个文件 URL 可获取的大小按套餐限制。遵循拥有该 Script 的 Space 所属 Organization 的套餐。
| Plan | 每个文件 URL 的最大大小 |
|---|---|
| Free | 50MiB |
| Basic | 100MiB |
| Pro | 100MiB |
| Enterprise | 1GiB |
- 限额按每个 URL 分别适用。有多个
urlpart 时,每个文件都必须在该限额以内。 - 尝试发送超出限额的文件时,该
Http会失败(参见 Http 失败的两种方式)。 - 这个限额也是时间预算中所加下载时间的基准。
安全模型
secrets
外部 API 密钥、支付服务商(PG)的密钥(Secret Key)、Webhook 签名密钥、预先计算好的 Basic … 认证值这类机密值,不直接把值写进 Statement,而是在定义的 secrets 中声明,并在 Statement 中以 { /secrets/<key> } 读取。公开的测试密钥也放在同一位置。这样换成真实密钥时,Statement 保持不变,只需修改 secrets 中的值即可。
"secrets": [
{ "key": "LLM_API_KEY", "value": "sk-...", "description": "用于调用 LLM 的 API 密钥" },
{ "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_...", "description": "" }
],
"statements": [
{ "type": "Http", "method": "POST", "url": "https://api.openai.com/v1/chat/completions",
"headers": [ { "key": "Authorization", "value": "Bearer { /secrets/LLM_API_KEY }" } ],
"body": { /* ... */ }, "name": "llm" }
]| 字段 | 必填 | 规则 |
|---|---|---|
key | 必填 | 1~64 个字符,只能使用英文字母、数字、_、-。在一个 Script 内必须唯一。 |
value | 必填 | 1~4,096 个字符的字符串。它是静态值,不会作为模板解析,因此即使在其中放入 { /… },也会按字面原样使用。 |
description | 必填 | 用途说明,0~128 个字符。也可以是空字符串 ""。 |
- 保存:
value由 WEEGLOO 加密后安全保存。Statement 通过{ /secrets/<key> }读取原始值。 - 查询:在返回 Script 的 API 响应(查询、创建、修改)中,
value以原始值返回。能够读取该 Script 的角色都能看到这个值,因此请把读取权限收窄。会员(ServiceUser)无法读取 Script 定义(查询与编写仅限 CMA,而 ACMA 中没有 Script API)。 - 修改:整体修改(PUT)会替换整个定义。如果不带
secrets发送,已声明的机密值会被删除;若 Statement 仍在以{ /secrets/… }引用该值,则保存会被拒绝。因此每次修改时都要一并发送secrets。把查询响应中的值原样发回即可。 - 执行中的值:读取到的值用来做什么,由 Statement 决定。用
Return返回读取到的值,它就会留在执行响应与 ScriptLog 中;放进SetVar、Cache、Content,它就会以原始值留在那里。机密值只发送给需要该值的外部 API。 - App Bundle:Script 被打包为 App Bundle 时,
secrets的value会被遮盖为******,key与description则原样带上。安装该 App Bundle 而生成的 Script 中,机密值处于******状态,因此安装方需要填入真实值,读取该值的 Statement 才能正常工作。写在secrets以外位置(Http的请求头、Signature的secret等)的值不会被遮盖,而是原样带上。
一个 Script 最多可以有 20 个 secrets。指针规则在值表达式的 /secrets中讲解。
执行身份与授权
- 执行身份:执行期间所有资源操作都以调用
/execute的用户身份执行。被创建或修改的资源的createdBy/updatedBy即为调用方,createdBy: ":self"作用域也以调用方为基准解析。匿名调用是例外。 通过/execute/anonymous进入的执行没有调用方,因此两者都以作者为基准解析(匿名调用)。 - 授权边界有两个,而且运行时不会对每个 Statement 重新检查资源权限。
- 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 Statement 所使用的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的 Statement,无论是 leaf 还是拥有块的
ResourceForEach,都要接受这项检查。已经保存过的定义在修改时会再次检查,因此权限被收回之后就无法再保存对该定义的修改。- 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取 Statement 以及
ResourceUpdate、ResourcePatch中写resource: "ServiceUser",作者的 SpaceRolesettings中必须含有SETTING_SERVICE_LOGIN(或SETTING_ALL),参见 SpaceRole 的 settings。因为在系统的其他所有路径上,会员目录也都是由 Space 设置管辖的资源。 - 创建或删除会员的 Statement 在任何角色下都无法保存。 Script 无法创建或删除会员,所以它不是权限不足(
403),而是作为写错的 Statement 被拒绝(400)。也就是说,这不是靠加权限就能补上的空缺。被允许的改动只有ResourceUpdate、ResourcePatch的roleOverride与enableLogin(参见处理会员目录)。
- 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取 Statement 以及
- 调用时(
/execute):只检查调用方的 Script Execute 权限。没有权限则返回403。通过后,各 Statement 的资源权限在运行时不再确认即执行。这与编程中的函数执行权限方式相同。只要有执行函数的权限,其内部各个操作的权限就不会再被询问。匿名调用路径上没有这项检查。 因为没有可检查的调用方,所以开放那条路径等同于把一个 Script 无认证地公开出去。
- 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 Statement 所使用的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的 Statement,无论是 leaf 还是拥有块的
- 禁止直接调用(
directCallEnabled):Script 的directCallEnabled为false时,/execute直接调用本身会被拒绝。该门禁是在通过 Execute 权限检查之后才生效的,因此即使拥有 Execute 权限也会被拦截。没有 Execute 权限的调用方在到达该门禁之前就会收到403。这个门禁只存在于该端点上,因此 Webhook 的关联动作(script)与 Scheduler 照常执行。默认值为true(允许直接调用)。 - 匿名调用(
anonymousCallEnabled):默认值为false。设为true后,仅该 Script 也可以通过无需认证的专用路径(/execute/anonymous)执行,而那时执行身份是作者,而不是调用方。上述两个边界中的调用时检查(Execute 权限)在那条路径上并不存在,因此实质上的认证由 Script 自己完成(校验所收到请求的签名)。开启的条件与保存规则参见匿名调用。 - 所有权作用域:
where过滤器中的createdBy: ":self"表示“仅限当前调用方创建的对象”(例如只查询我自己的钱包)。在允许匿名调用的 Script 中不能使用这个过滤器。因为没有调用方时它会解析为作者,所以作为所有权作用域的原本含义就不成立了。 - 委托的权限(编写注意):把上述两个边界合起来看,Script 的执行等同于受委托作者的权限来执行。调用方只需拥有 Execute 一项即可,Script 内的 Statement 会在作者保存时被授权的范围内照常执行。因此,调用方自己无法完成的资源操作也可能通过 Script 发生。授予作者的权限即是该 Script 的影响范围,所以要谨慎设定 Script 中包含的动作。
摘要检查清单
保存前请确认以下几点。
- 若开启了匿名调用(
anonymousCallEnabled),where中没有createdBy: ":self",并且把校验所收请求的 Statement(如Signature)放在了最前面。 - 外部调用(
Http、EmailSend)数与全部 Statement 数在套餐上限以内,SetVar不超过 10 个,Cache不超过 5 个。 - 若使用了
Cache,key写成了字面量,并且没有放在Loop或ResourceForEach内。 - 用
ResourceForEach遍历大集合时,已声明limit或确认过是可跑完的规模。 - 若含有迭代(
Loop、ResourceForEach),已确认该迭代会以乘法计入时间预算(没有外部调用时,30 秒基础预算就是上限)。 - 外部 API 密钥或签名密钥这类机密值已在
secrets中声明,并以{ /secrets/<key> }读取。没有把值直接写进 Statement 的机密值。 - 若以整体修改(PUT)保存,已一并发送
secrets。 - 用于签名校验的消息取自
{ /rawPayload },而不是/payload。 - 若有处理会员(ServiceUser)的 Statement,作者拥有
SETTING_SERVICE_LOGIN,并且没有放入创建或删除该资源的 Statement。 - 尽量把不可逆的操作(外部调用)放到后面。
- 若用
Http发送文件,请求正文字段(body、urlBody、multipartBody)只写了一个,并已确认每个文件都在各套餐的下载限额以内。 - 如果担心 update/patch 竞争,就使用
ResourceUpdate或ResourcePatch的version。 - 若要返回结果,已明确指定
Return.value。
错误
以下是定义的形态违反静态约束、保存因此被拒绝时出现的错误码。违反值表达式规则的错误码在值表达式的错误中,调用、删除时出现的错误码在端点的错误中。所有资源共通的错误码,请参见通用错误。
| 错误码 | 条件 |
|---|---|
WGL400066 | 一个定义中包含的 Cache Statement 超过 5 个。 |
WGL400068 | Cache Statement 被放在了 Loop 或 ResourceForEach 的块内。 |
WGL400067 | Cache Statement 的 key 中写的不是字面量,而是 { /pointer } 引用。 |
WGL400065 | Cache Statement 的 ttl 超出了允许范围。 |
WGL400063 | Cache Statement 中写入了与 action 不对应的字段(在 Get 中写 ttl,在 Set 中写 defaultValue)。 |
WGL400060 | ResourceCreate、ResourceDelete 以及发布、归档 Statement 的 resource 中写入了 "ServiceUser"。会员目录只接受三个查询 Statement 与 ResourceUpdate、ResourcePatch。 |
WGL400077 | ResourceCount 的 resource 中写入了无法统计的资源。Media 与会员目录(ServiceUser)无法统计。 |
WGL400037 | EmailSend 中同时写了 to 与 toServiceUser,或者两者都留空了。只能写其中恰好一个。 |
WGL400054 | EmailSend 的收件人合计(to 1 人 + cc + bcc)超过 50 人。 |
WGL400039 | EmailSend 的 timeoutMs 超过上限。 |
WGL400101 | EmailSend 中同时写了 body 与 templateBody,或者两者都留空了。只能写其中恰好一个。 |
WGL400102 | EmailSend 的 templateBody.source 不是指向本 Script 的 ResourceRead、ResourceFind 所读取的 Content 的单个指针。指针前后有其他字符时也会被拒绝。 |
WGL400061 | 在允许匿名调用的 Script(anonymousCallEnabled)中,读取 Statement 的 where 中写入了 createdBy: ":self"。 |
WGL400023 | 一个定义中包含的 SetVar Statement 超过 10 个。 |
WGL400026 | Http Statement 的 retry 超过了上限 2。 |
WGL400087 | Http 中写入了两个以上的请求正文字段(body、urlBody、multipartBody)。 |
WGL400088 | Http 的 multipartBody 中一个 part 也没有。 |
WGL400089 | multipartBody 的 part 中 value 与 url 两者都写了,或两者都没写。 |
WGL400090 | multipartBody 的 part 中没有 url 却写了 fileName。 |
WGL400036 | ResourceForEach 超过了可处理的项目数上限。 |
WGL400094 | secrets 中有两个以上使用相同 key 的项目。 |
WGL400095 | 一个定义中包含的 secrets 超过 20 个。 |
WGL400086 | secrets 的 value 超出了 1~4,096 个字符的范围。错误中不会带上 value。 |
WGL400086 | 不含 { /pointer } 的 PdfWatermark 的 text 超出了 1~64 个字符的范围。 |
WGL400096 | Media 摄取指令的 transformations 不是数组,或元素的 type 不是 PdfWatermark,或 PdfWatermark 中没有 text 或其值为对象、数组,或 color 不是 #RRGGBB 格式,或按原样所写的 text 中含有不可用的字符或换行符。 |
WGL429005 | 一个定义中包含的 Statement 总数超过了套餐上限。 |
WGL429006 | 一个定义中包含的外部调用(Http、EmailSend)数超过了套餐上限。 |
WGL403015 | 作者不具备定义中的 Statement 所处理的资源与动作的权限。即使拥有权限,只要那条允许规则上附带了 contentType、createdBy、tag 过滤条件,保存也会被拒绝。 必须是不带条件的允许规则。唯一的例外是 Content Create,此时限定了 contentType 范围的允许规则也被认可,并与 Statement 中所写的 contentType 进行比对(Media Create 没有这个例外)。读取 Statement 的 resource 中写入了 "ServiceUser",而作者没有 SETTING_SERVICE_LOGIN,这种情况也属于该错误码。 |
相关文档
- 值表达式:值与条件规则。
- Statement 目录:各 Statement 的字段与结果。
- Cookbook:完整示例集合。
- Script 资源与端点:
Script资源结构及/execute等 HTTP 端点。 - Script 概述:顶层结构与一次执行可用的时间。
