---
url: /zh/guide/query/masking.md
description: 使用静态字段注解为受管 Snapshot 与 EventStream 查询结果配置 Schema 驱动的脱敏。
---

# 字段脱敏

## 适用范围与执行顺序

Gateway 在 Backend 返回节点之后、typed 物化之前固定执行 `SchemaMasker`。该步骤不能由通用请求 Filter 替换或绕过：

```mermaid
flowchart LR
    Backend["QueryBackend ObjectNode"] --> Mask["Framework Mask"]
    Mask --> Dynamic["dynamic ObjectNode"]
    Mask --> Jackson["typed materialization"]
```

Snapshot 与 EventStream 的 typed/dynamic single、list、paged、cursor 以及经 Snapshot Gateway 的 state-only/aggregate-state load 使用同一路径。Mask 只处理当前响应，不修改存储文档、领域对象或通用 Jackson 序列化；count 和聚合行不经过结果 Mask。

## 内建注解

Kotlin 属性通常使用字段 use-site：

```kotlin
import me.ahoo.wow.api.query.mask.KeepMask
import me.ahoo.wow.api.query.mask.Mask

data class AccountState(
    @field:Mask
    val password: String,
    @field:KeepMask(prefix = 3, suffix = 4)
    val phone: String?,
)
```

Mask 注解只能用于 JVM `String`/`String?` 属性；enum、UUID 等类型即使序列化后的 JSON 形状是 String，也会在 Schema 构建时失败关闭，避免 typed 结果无法重新物化。

* `@Mask` 按 Unicode code point 数量把每个 code point 替换为一个 `*`，例如 `A中😀` 变为 `***`。
* `@KeepMask(prefix, suffix)` 按 code point 保留前后部分并遮蔽中间；值太短、无法同时保留两端时全量遮蔽，例如 `13800138000` 变为 `138****8000`，`1234567` 变为 `*******`。
* 缺失值与 `null` 不变，空字符串仍为空字符串。嵌套对象、集合和嵌套字符串数组按 Schema 路径递归处理。

## 自定义 meta-annotation

领域专用规则由带 `@Masking(strategy)` 的运行时注解声明。Strategy 在 Schema 构建时实现 `MaskStrategy<A>.compile`，返回查询时复用的 `CompiledMask`；该过程不需要 KSP。

```kotlin
import me.ahoo.wow.api.query.mask.CompiledMask
import me.ahoo.wow.api.query.mask.MaskStrategy
import me.ahoo.wow.api.query.mask.Masking
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FIELD
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER

@Target(FIELD, PROPERTY_GETTER)
@Retention(RUNTIME)
@Masking(strategy = RedactStrategy::class)
annotation class Redact(val replacement: String = "[redacted]")

object RedactStrategy : MaskStrategy<Redact> {
    override fun compile(annotation: Redact): CompiledMask {
        require(annotation.replacement.isNotEmpty())
        return CompiledMask { value ->
            if (value.isEmpty()) value else annotation.replacement
        }
    }
}
```

Strategy 可以是 Kotlin `object` 或公开无参类。示例不按 UTF-16 code unit 截断输入，并显式保留空字符串；需要保留字符位置时，应像内建实现一样按 Unicode code point 处理。

## Query Schema 合同

`JsonQuerySchemaSource` 在运行时发现字段、Jackson 可见的非 public getter，以及从父类 Kotlin property 或接口 getter 继承的有效注解。规则随 Query Schema 合并和后端 adapter 传递，但公开 `QueryModelSchemaMetadata` 的递归值节点仅以 `masked: Boolean` 暴露脱敏信息；Strategy 类型、注解参数、编译后的规则和可执行函数只存在于内存中。

Gateway 每次订阅只取得一次 Schema，prepare、公共校验、Backend 与响应 Mask 使用同一实例。Mask 遍历定义在 Schema 发布时构建，订阅只使用这一代不可变数据；refresh 发布新实例不会改变在途订阅。Schema 获取失败不会跳过脱敏返回原值。没有 Mask 声明时不遍历响应 JSON。

## 行为矩阵

| 查询或结果 | 行为 |
|---|---|
| Snapshot/EventStream typed `single`、`list`、`paged` | 在 typed 物化前脱敏 |
| Snapshot/EventStream dynamic `single`、`list`、`paged` | 返回已脱敏的 `ObjectNode` |
| Snapshot/EventStream typed/dynamic `cursor` | 对 `CursorPage.list` 脱敏，原样保留 `nextCursor` |
| Snapshot state-only / aggregate-state load | 复用 Snapshot Gateway，同样脱敏 |
| 普通 filter、全文 search、sort | 允许引用 Mask 字段；后端按原值匹配或排序，响应仍脱敏 |
| `CursorQuery` 有效 sort | 必须具有已证明的 CURSOR\_SORT binding、是单值字段、不能带 Mask 规则，也不能通过 projection 或物理 binding alias 指向 masked 字段；否则在 Backend 前拒绝，避免原始排序值或多值数组进入 `nextCursor` |
| 数据查询 `count` | 计数不变；Gateway 仍加载 Schema 完成准入，但 Mask 层不处理字段值 |
| 聚合 group、字段 metric、数值 expression | 公共校验在 Backend 执行前拒绝受 Mask 保护的字段或源别名 |
| 聚合所需 Schema 不可用 | 失败关闭；即使聚合只含 `COUNT` 也不降级执行 |

## 失败关闭边界

| 条件 | 结果 |
|---|---|
| 注解成员不是 JVM String，或被规则覆盖的值域不是字符串/字符串数组，或含 UNKNOWN | Schema 构建失败 |
| 同一成员有多个有效 Mask 注解，或 Schema 分支规则冲突 | Schema conflict |
| Strategy 无法构造，或 `compile` 抛错 | Schema 构建失败，错误保留 |
| 被规则覆盖的响应值为非 String/非 String 数组，Strategy 执行抛错，或自定义 `CompiledMask` 返回 `null` | 当前结果 Publisher 失败，不返回原值 |
| EventStream event item 含非 null payload，但 `bodyType` 缺失、不是字符串或未知 | 当前结果 Publisher 失败 |
| EventStream 顶层 `body` 不是数组，或数组包含非 object event item | 当前结果 Publisher 失败 |

Event projection 完全没有顶层 `body`，或把该事件数组投影为 `null` 时，Mask 安全跳过。顶层 `body` 存在时必须是数组，且每个 event item 都必须是 object。合法 event item 内的 payload 属性 `body` 缺失或为 null，表示 metadata-only 或 payload 已排除；此时没有敏感 payload 可泄漏，不要求 `bodyType`。非 null payload 仍必须携带已知的字符串 `bodyType`；缺失、非字符串或未知类型都会在 Mask 前失败关闭。

明确的未脱敏联合分支（例如 INTEGER）保留原值；字符串分支继续脱敏。Map 的明确 property 优先于 additionalProperties，数组 Item 层级与原生别名必须按共享 Schema 保留，不能扩大到无关兄弟字段。

## 受信原始值边界

* 直接调用 Factory 返回 binding；受信原始访问为 `factory.create(namedAggregate).backend`，会绕过整个 Gateway，包括查询 Filter、错误观察和 Mask。
* 自定义 Factory 在 `QueryBackendBinding` 中配对 Backend 与 `QueryModelSchemaProvider`；自定义 Backend 从不实现 Provider。Provider 不可用时在 Context 与订阅 Backend 前失败关闭，不会跳过 Mask 返回原值。

两者都只适合存储扩展、Backend 合同测试和受信诊断，不能作为普通业务查询入口。

## 迁移与验证

从 V8 Registry/Filter Mask 迁移时，先按 [V9 查询迁移](./v9-query-migration.md)删除旧类型并把规则移到领域字段，再完成以下检查：

1. 通过[查询模型 Schema](./query-model-schema.md)端点确认目标字段只新增 `masked: true`，没有公开策略或参数。
2. 分别验证 Snapshot/EventStream 的 typed、dynamic 与 state-only/aggregate-state load 响应。
3. 验证普通 filter/search/sort 与数据查询 `count` 保持可用；masked cursor sort、group、字段 metric、数值 expression 和 Schema unavailable 聚合失败关闭。
4. 仅在受信测试中验证 direct Factory 返回原始值，并确认存储文档与通用 Jackson 序列化未被改写。

完整执行位置、Filter 顺序和绕过条件见[查询网关](./query-gateway.md)。
