本页译自英文。 阅读英文页面
面向业务应用的通用功能:数据审计、个人数据保护、安全部署、健康检查、会话安全、CSV 与 XLSX 导入、后台任务和审批工作流。所有检查都在服务器端完成,浏览器运行时只负责让使用更顺手。设计时考虑了 KVKK 和 GDPR 这两部隐私法规,但这里的内容并不针对任何国家或行业。
字段级数据审计
AuditLog 会为每次表单提交以及每次对标记了 audit 的 action 的调用写入一条 AuditEntry。对于保存操作,该条目还会写明记录以及每个发生变化的字段:
{"kind":"form","screen":"customer","action":"save_customer","ok":true,"user":"ayse",
"model":"Customer","record":"2","changes":[{"field":"name","old":"Customer 2","new":"Renamed"}]}- 表单会自动完成这些工作:旧值来自屏幕在服务器上加载的记录,新值来自经过验证的
Valid<M>。客户端发送的任何内容都不会被用作旧值,表单不从客户端读取的字段也绝不会显示为已更改。 - API 保存会从其
Valid<M>输入中记录 model 和主键。只有 action 知道数据库里有什么,所以由它自己报告差异:
#[action(permission = "crm.customer.write", audit)]
pub async fn save_customer(c: Valid<Customer>, store: Customers, ctx: Ctx) -> Result<Customer> {
let current = store.get(c.id)?;
ctx.record_changes(¤t, &c);
store.save(c.into_inner())
}在表单保存 action 中也可以这样做:action 的报告会替换表单的报告。
- 值是与语言无关的纯字符串(
1234.56 TRY、2024-05-01、choice 的值)。行版本不记录。被掩码的字段保持掩码状态(见下文)。 - 保存失败时仍会写明记录,但没有变更内容。
字段掩码
用 #[mask] 标记存放个人数据的文本字段。下例中,身份证号显示为 •••• 8901,电子邮件显示为 p••••@example.com,备注显示为 ••••:
#[entity(table = "people")]
pub struct Person {
#[key]
pub id: PersonId,
pub name: String,
#[mask(last4)]
pub national_id: String,
#[mask(email, permission = "hr.contact.unmask")]
pub email: Option<String>,
#[mask]
pub notes: Option<String>,
}- 掩码在服务器端进行,覆盖网格单元格、表单、API 输出、agent 工具,因此也覆盖由单元格生成的 CSV 导出。没有权限时,原始值绝不会到达浏览器或 API 客户端。
- 取消掩码的权限是蛇形命名的
<model>.<field>.unmask(person.national_id.unmask),除非属性中指定了权限。 - 拥有权限的用户起初看到的仍是掩码文本,旁边有一个显示按钮。点击显示会调用
POST /_zeyn/reveal,它会再次检查权限并写入一条审计记录(kind 为reveal,字段记在revealed中)。被拒绝的尝试同样会被审计。 - 拥有权限的 API 客户端可以获得原始值;每个这样的字段都会列在该调用审计记录的
revealed中。 - 表单将被掩码的字段显示为只读文本。保存时会保留已存储的值,除非用户有权显示该字段,并且在显示之后修改了它。空的掩码字段是普通输入框,因此数据录入人员可以一次性填写,之后却无法读回。
- 对无权显示某被掩码字段的用户,针对该字段的过滤和排序会被忽略,这样就无法通过搜索来猜出值。这适用于对网格的每一次读取(分页、导出、对所有行的批量操作、已保存的视图、agent 工具)以及接受
DataQuery的 API action。不要在被掩码的列上添加.filter()。 - 分页游标绝不包含被掩码字段的值。按被掩码字段排序的列表按位置而不是按主键分页。
- 网格绝不按被掩码字段分组;对其调用
group_by会被忽略并记录日志。 - Island 以及其他数据源的输出在任意深度都会被掩码,与 API 输出一样:其中每个 model 的被掩码字段都会以掩码文本输出,敏感 model 的主键会被记录为读取。
- 网格揭示原值时,会通过网格的 action 以主键过滤条件来查找该行,因此该 action 必须支持对其主键字段的过滤。
#[mask]只能用于String和Option<String>,否则无法编译。
敏感记录读取
除了记录谁修改了数据之外,也会记录谁在何时看到了哪些个人数据。
#[entity(table = "cases", sensitive)](或者在#[entity]下方写#[sensitive])用于标记一个 model。每当向用户展示这样的记录时,该调用的审计记录就会在reads中列出它({"model":"Case","id":"2"}):包括网格页中的行、表单、agent 工具结果和 API 输出。读取记录在数据被渲染的地方写入,因此布局内部的组件同样会被计入。#[screen(..., sensitive)]会把每次打开该屏幕记录为一次读取,其model为屏幕名称,id为其 params,适用于展示不与某个 model 绑定的个人数据的屏幕。#[screen(..., reason)]还会在展示任何数据之前询问用户原因。在给出原因之前,服务器会对该页面、其片段、网格、表单和揭示原值的调用返回428以及代码reason_required(整页加载时返回带原因表单的页面)。运行时会弹出对话框询问,发送POST /_zeyn/reason {screen, reason}(3 到 300 个字符),然后重复原请求。原因会保存在一个HttpOnly、SameSite=Strict的 cookie 中 15 分钟,只对给出原因的那个屏幕有效,并写入该屏幕每条审计记录的reason中。Agent 和 API 客户端无法给出原因,因此无法打开这类屏幕。- 对敏感 model 的网格进行导出(CSV、XLSX、PDF)时,每个文件写入一条审计记录,并设置
export:包含 model、格式、行数,以及所请求的排序和过滤条件。它们不会在reads中列出每一行,所以大型导出仍然开销很小;其他 model 的导出不产生额外开销。从sensitive或reason屏幕导出时,还会记录对该屏幕的读取。 - 屏幕中的惰性部分(
POST /_zeyn/node,由惰性标签页使用)会运行拦截器并记录读取,与打开屏幕完全一样。 ctx.record_read(&record)可在应用代码中记录一次读取。- 读取记录由
AuditLog为任何调用写入(包括页面加载),只要展示过敏感记录。没有审计拦截器就不会记录任何内容;持有个人数据的应用应当始终注册一个。
审批工作流
工作流是一个小型的、带类型的状态机。状态是一个 #[choice] 枚举;每次迁移都要指明起始状态、结束状态和所需的权限:
#[choice]
pub enum LeaveState { Draft, Submitted, Approved, Rejected }
pub fn leave_flow() -> Workflow<LeaveState> {
Workflow::new()
.transition("submit", LeaveState::Draft, LeaveState::Submitted, "people.leave.request")
.transition("approve", LeaveState::Submitted, LeaveState::Approved, "people.leave.approve")
.transition("reject", LeaveState::Submitted, LeaveState::Rejected, "people.leave.approve")
}
#[action(permission = "people.leave.request", audit)]
pub async fn move_leave(req: TransitionRequest<LeaveRequestId>, store: LeaveStore, ctx: Ctx) -> Result<LeaveState> {
let mut leave = store.get(req.id)?;
leave.state = leave_flow().apply(&ctx, leave.state, &req.transition, req.comment.as_deref())?;
store.save(leave)
}apply在服务器上运行,状态从存储中读取。未知的迁移返回404,记录已不在起始状态返回409(有人先一步移动了它),缺少权限返回403,评论超过 1000 个字符返回422。- 每次迁移都会写入审计记录的
transition:名称、from、to 和评论;用户和时间在记录本身中。 Transitions::of(flow, state, id, action)显示当前状态、一个可选的评论,以及用户可以从当前状态发起的每个迁移对应的一个按钮(Workflow::allowed)。记录主键来自服务器上的屏幕,因此客户端无法移动其他记录。Agent 将其视为一个表单工具。- 按钮标签来自目录中的
workflow.<name>;.label(...)可手动设置一个。 Workflow::check会报告无效的权限,以及从同一状态声明了两次的迁移;请在测试中调用它。- showcase 中的请假申请就使用了它:保存为草稿、提交,然后批准或拒绝。
安全部署
- 每个响应都带有
x-zeyn-build,每个页面都带有<meta name="zeyn-build">。设置了ZEYN_BUILD_ID时,标识即为它的值(字母、数字、.、_、-,最多 64 个字符),否则是可执行文件的哈希。zeyn::server::build_id()返回该标识,可用于日志。 - 当运行时看到来自另一个构建的响应时,会显示一条礼貌的提示:有新版本可用,准备好后请重新加载。它绝不会自行重新加载。
- 用户在表单中输入的内容会按屏幕、params 和表单保存在
sessionStorage中,并在重新加载后恢复,同时给出提示和一个丢弃按钮。被掩码的、密码、文件和隐藏输入绝不会被保存。草稿会保留输入时的行版本,因此如果在此期间有人修改了该记录,保存草稿会因冲突而被拒绝。保存成功后草稿会被清除;超过一天的草稿会被丢弃。sessionStorage只存在于该浏览器标签页中。
健康检查
- 进程运行期间,
GET /_zeyn/health返回200 ok。用于存活探测。 GET /_zeyn/ready会同时运行通过App::readiness(name, check)添加的每个检查,每个限时五秒,并返回200 {"status":"ready"}或503 {"status":"not_ready","failed":["database"]}。只显示检查名称,从不显示错误。- 两者都位于
base_path之下(/billing/_zeyn/health),响应带有Cache-Control: no-store,且无需登录。 - 就绪检查的结果会复用一秒钟(
READY_CACHE),并且同一时刻只有一个请求运行这些检查,所以大量探测请求不会给数据库造成负载。 - 健康检查从不受速率限制,因此繁忙的探测不会把自己锁在门外而导致健康的应用被重启。就绪检查会计入速率限制,除非它来自
probe_ips中的地址。 - IP 规则仍然适用:
deny_ips始终生效,allow_only_ips也生效,除非探测地址列在App::probe_ips([...])中,该设置只对它开放这两个端点。
开发延迟
App::dev_latency(Duration::from_millis(400)) 会延迟每一次调用(页面、片段、网格、表单、API、MCP 和运行时调用;静态资源和健康探测除外),让开发者像处于慢速网络下的用户那样看到加载状态和重复点击。它只在 debug 构建中有效:在 release 构建中该方法什么也不做,延迟代码也会被编译掉,所以误留在代码中也不会拖慢生产环境。启用期间,启动时会输出一条警告。
会话安全
登录来自单点登录代理、内置的 OpenID Connect 登录或 bearer 令牌(见 docs/auth.md),因此这些功能只需要认证器告知用户是谁,对于重新认证,还需要告知其登录时间 (Identity::authenticated_at;ForwardAuth::auth_time("x-auth-time") 读取 Unix 秒数或 RFC 3339;DevAuth 将每个请求都报告为刚刚登录)。
App::idle_timeout(Duration)会让运行时在结束前idle_warning(默认一分钟)通过一个带倒计时和“保持登录”按钮的警告对话框提醒用户,该按钮调用POST /_zeyn/keepalive,以便前面的代理看到有活动。应用在任何标签页中的输入都会计入。到时间后,用户会被带到logout_url,否则带到带有return_to的login_url,否则看到一个已退出的屏幕。表单草稿会保留。空闲超时只用于关闭被遗忘的屏幕;结束真正的会话是身份提供方的工作。#[action(permission = "...", reauth)]要求在App::reauth_within(默认五分钟)之内登录过。否则无论客户端发送什么,服务器都会返回401以及代码reauth_required(在 API 上对应的 problem 类型为urn:zeyn:problem:reauth_required)。运行时会请用户在新标签页中重新登录(reauth_url,否则login_url),并在用户返回后重复该请求,因此已输入的内容不会丢失。没有登录时间的身份永远无法通过。
CSV 导入
#[action(permission = "people.leave.import", audit)]
pub async fn import_leaves(rows: Vec<Valid<LeaveRequest>>, store: LeaveStore) -> Result<usize> { ... }
Screen::new("Import").body(Import::of(import_leaves).max_rows(1000))- 用户选择一个 CSV 文件。运行时将其按 UTF-8 读取,如果不是有效的 UTF-8,则按 Windows-1254 读取(土耳其语电子表格),并把文本放在 JSON 请求体中发送到
POST /_zeyn/import(无需 multipart;最大 8 MB)。服务器返回摘要、前十行的预览、每行的错误,以及可下载的错误报告。 - 解析:丢弃字节顺序标记,根据表头行检测
,、;或制表符,支持"引号(引号内可含分隔符和换行),支持\r\n或\n。 - 限制在读取文件时检查,而不是事后检查:读到行数上限即停止,超过 512 个单元格的表头会被拒绝,数据行最多比表头多保留一个单元格(因此多余的值仍会被报告)。XLSX 文件最多读取与表头列数相同的列,其表头最多只能指明 model 可导入的列数,超过 16 MB 的共享字符串部分会在解包任何内容之前被拒绝。
- 列按字段名或应用中任意语言的标签匹配,忽略大小写以及土耳其语带点和不带点的 i。未知列、只读列、主键列和行版本列会被拒绝。
- 每一行的读取和验证都与该 model 的表单完全一致:值按该语言用户的输入习惯解析(
1.234,56、05.12.2026),应用字段规则和 model 自己的validate。 - 导入只把有效行发送给 action;
.all_or_nothing()则在每一行都有效之前不导入任何内容。导入时,服务器会重新读取并验证文件;预览中的任何内容都不被信任。这两个步骤都需要该 action 的权限,并且导入会被审计。 .max_rows(n)限制行数(默认 5000,最大不超过 50000)。错误报告会中和电子表格公式。被掩码的字段在预览中以掩码形式显示。
XLSX 导入
启用 xlsx Cargo feature 后,同一个组件也可以接受 Excel 工作簿(.xlsx)。运行时以 base64 发送文件(在同一个 JSON 请求体中用 "xlsx" 代替 "csv"),服务器使用 calamine 读取第一个工作表,并进入与 CSV 相同的逐行验证、预览和错误报告流程。
- 单元格保留其类型:数字被读作数字,日期单元格被读作日期,与用户的语言无关,因此在土耳其语会话中
1234.5仍然是 1.234,50。日期单元格中的挂钟时间按用户的时区读取,就像表单那样。choice 按值或按标签匹配。 - 限制:文件最大 8 MB,压缩包解压后最多 64 MB、最多 1000 个部分,这些检查基于压缩包声明的大小,在解包任何内容之前进行。单元格逐个读取,读到行数上限即停止,因此一百万行的工作表的开销不会超过该上限。不是工作簿、受密码保护或被截断的文件会得到明确的错误。
- 只读取第一个工作表。公式按其缓存的值计算。
后台任务
App::new("Billing")
.job_concurrency(2)
.job("send_reminders", Schedule::every(Duration::from_secs(3600)).retry(3, Duration::from_secs(30)), send_reminders)
.job("rebuild_index", Schedule::manual(), rebuild_index)
.jobs_permission("ops.jobs.read")
.serve_until("0.0.0.0:3000", shutdown_signal())- 任务是
async fn(Ctx) -> Result<()>。它的上下文包含应用的服务、配置和默认语言,以及身份job:<name>,该身份没有任何权限。 Schedule::every(d)按间隔运行;Schedule::manual()只在被触发时运行。Action 通过可注入的Jobs服务触发任务:jobs.trigger("rebuild_index")。任务在运行期间被触发,则会在之后再运行一次;同一个任务绝不会同时运行两次。.retry(times, first_delay)会在运行失败后重试,等待时间每次加倍,最长十分钟。- 同时最多运行
job_concurrency个任务(默认四个)。 - 状态(状态、运行次数、失败次数、上次开始和结束时间、上次错误代码)在代码中是
jobs.status(),对拥有jobs_permission的用户则是GET /_zeyn/jobs。错误详情只会写入服务器日志。 App::serve_until(addr, signal)在信号完成时停止接收请求,让正在运行的请求完成,取消重试等待,并给正在运行的任务 30 秒。- 没有存储时,任务保存在内存中:进程停止时,正在等待或正在运行的工作会丢失,而且有多台服务器时,每台都运行自己的调度。
持久化任务
App::new("Billing")
.job_store(SqlJobStore::new(db))
.queued_job("send_invoice", Schedule::manual().retry(5, Duration::from_secs(10)), send_invoice)
.job("close_day", Schedule::every(Duration::from_secs(86_400)), close_day)
async fn send_invoice(ctx: Ctx, invoice_id: String) -> Result<()> { ... }
jobs.enqueue("send_invoice", "42").await?;JobStore保存运行记录:enqueue、schedule、带租约(工作进程 id 和结束时间)的claim、heartbeat、complete和fail。MemoryJobStore(最多 10,000 条运行记录,最先丢弃最旧的失败记录)是默认实现;SqlJobStore(featurepostgres、oracle)把运行记录保存在zeyn_jobs中。create_job_tables(dialect, prefix)提供用于迁移的语句。- 设置了存储后,
Schedule::every任务会把下一次运行时间保存在存储中,因此重启不会改变它,并且每个间隔只有一台服务器运行。trigger也会在那里排入一次运行。 queued_job任务会获得每次运行的负载(最多MAX_JOB_PAYLOAD,4000 字节);普通任务也可以入队,但会忽略它。enqueue_after可延迟一次运行。- 认领(claim)会取走本服务器所知任务中到期的运行,最旧的优先,并持有
JOB_LEASE(60 秒),在运行期间每 20 秒续期一次。只有当工作进程仍持有租约时,complete和fail才会更改一次运行。工作进程已停止的运行会在其租约结束后被再次认领;丢失的那次尝试也计入次数,因此让其服务器崩溃的运行最终会以失败结束。 - 失败的尝试会等待退避时间(每次加倍,最长
MAX_BACKOFF_MS,十分钟),只要还有剩余次数,然后以failed状态及其错误代码保留。计划任务则前进到下一个间隔。 - 工作进程每隔
JOB_POLL(一秒)查找到期的运行,并在同一服务器上enqueue之后立即查找。每次认领最多取走与空闲任务槽数量相同的运行,因此内存不会随队列增长。 - PostgreSQL 在一条
UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED) RETURNING中完成认领。Oracle 11g 及更高版本在事务中使用ROWNUM和FOR UPDATE SKIP LOCKED选取 id,更新它们,并在提交之前读回。Oracle 无法对加锁的 select 排序,因此那里到期的运行没有固定的取用顺序。时间是 Unix 毫秒,存放在BIGINT或NUMBER(19)列中。 - 设置了存储时,同一个任务的计划运行和被触发的运行可能重叠;不得同时运行两次的任务应自行加以保护(例如使用行锁)。