---
title: "记录事件"
description: "log.audit 和 log.audit.deny、独立 audit()、withAudit、defineAuditAction 和 defineAuditCatalog 注册表，以及 auditDiff"
canonical_url: "https://evlog.zhcndoc.com/use-cases/audit/recording"
---
# 记录事件

> log.audit 和 log.audit.deny、独立 audit()、withAudit、defineAuditAction 和 defineAuditCatalog 注册表，以及 auditDiff

五个 API 覆盖了审计记录的所有形态：请求内、被拒绝、独立、自动埋点和类型化。

## `log.audit()`

`log.audit()` 是 `log.set({ audit: ... })` 加上尾部采样强制保留的语法糖：

```typescript
log.audit({
  action: 'invoice.refund',
  actor: { type: 'user', id: user.id },
  target: { type: 'invoice', id: 'inv_889' },
  outcome: 'success',
})

// 严格等价于：
log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } })
```

这是你最常使用的形式。审计事件会落在与请求其余部分相同的宽事件上。

## `log.audit.deny()`

`log.audit.deny(reason, fields)` 用于记录被 AuthZ 拒绝的操作。大多数团队都会忘记记录拒绝，但这恰恰是审计员和安全团队最关心的内容：

<code-group>

```typescript [输入]
if (!user.canRefund(invoice)) {
  log.audit.deny('权限不足', {
    action: 'invoice.refund',
    actor: { type: 'user', id: user.id },
    target: { type: 'invoice', id: invoice.id },
  })
  throw createError({ status: 403, message: 'Forbidden' })
}
```

```json [输出 — 已拒绝]
{
  "level": "warn",
  "service": "billing-api",
  "method": "POST",
  "path": "/api/invoices/inv_889/refund",
  "status": 403,
  "duration": "12ms",
  "durationMs": 12,
  "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_intruder" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "denied",
    "reason": "权限不足",
    "version": 1,
    "idempotencyKey": "ak_d12c3a4f5b6e7d8c",
    "context": {
      "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
      "ip": "203.0.113.7"
    }
  }
}
```

</code-group>

## 独立 `audit()`

对于非请求上下文（任务、脚本、CLI），请使用独立的 `audit()`：

<code-group>

```typescript [scripts/cleanup.ts]
import { audit } from 'evlog'

audit({
  action: 'cron.cleanup',
  actor: { type: 'system', id: 'cron' },
  target: { type: 'job', id: 'cleanup-stale-sessions' },
  outcome: 'success',
})
```

```json [输出 — 宽事件]
{
  "level": "info",
  "service": "billing-api",
  "audit": {
    "action": "cron.cleanup",
    "actor": { "type": "system", "id": "cron" },
    "target": { "type": "job", "id": "cleanup-stale-sessions" },
    "outcome": "success",
    "version": 1,
    "idempotencyKey": "ak_2b8e1f9d4c6a7b3e"
  }
}
```

</code-group>

<note>

独立 `audit()` 事件没有 `requestId`、`context.ip` 或 `userAgent`，因为没有可供其丰富信息的请求。当这对取证很重要时，请手动添加你自己的上下文（`context: { jobId, queue, runId }`）。

</note>

## `defineAuditAction()`

在一个地方定义审计动作，避免魔法字符串，并让 `target` 获得完整的类型安全：

```typescript
import { defineAuditAction } from 'evlog'

const refund = defineAuditAction('invoice.refund', { target: 'invoice' })

log.audit(refund({
  actor: { type: 'user', id: user.id },
  target: { id: 'inv_889' }, // 类型推断为 'invoice'
  outcome: 'success',
}))
```

将此与 [架构 → 动作命名](https://evlog.zhcndoc.com/use-cases/audit/schema#action-naming) 中的动作字典配合使用。

## `defineAuditCatalog()`

对于不止寥寥几个动作的情况，请将它们分组到一个类型化的 **catalog** 中，而不是逐个声明 `defineAuditAction`。与错误 catalog 的约定相同：`UPPER_SNAKE_CASE` 键、`lower.dot.case` 前缀，最终的 wire `action` 为 `${prefix}.${KEY}`。

<code-group>

```typescript [audit/billing.ts]
import { defineAuditCatalog } from 'evlog'

export const billingAudit = defineAuditCatalog('billing', {
  INVOICE_REFUND: {
    target: 'invoice',
    severity: 'high',
    requiresChanges: true,
    description: '将发票退款给客户',
    redactPaths: ['cardNumber'],
  },
  INVOICE_CREATE:      { target: 'invoice' },
  INVOICE_VOID:        { target: 'invoice', severity: 'high', requiresReason: true },
  SUBSCRIPTION_CANCEL: { target: 'subscription', severity: 'high' },
})
```

```typescript [server/api/refund.post.ts]
import { billingAudit } from '~/audit/billing'

log.audit(billingAudit.INVOICE_REFUND({
  actor: { type: 'user', id: user.id },
  target: { id: 'inv_889' }, // 类型推断为 'invoice'
  outcome: 'success',
}))
```

</code-group>

每个条目都会生成一个围绕 `defineAuditAction` 的轻量封装（target 类型在定义时固定，action 名称自动加前缀）。catalog 元数据会在每个工厂以及 `_actions` / `_prefix` 上暴露：

```typescript
billingAudit.INVOICE_REFUND.action           // 'billing.INVOICE_REFUND'（字面量类型）
billingAudit.INVOICE_REFUND.target           // 'invoice'
billingAudit.INVOICE_REFUND.severity         // 'high'
billingAudit.INVOICE_REFUND.requiresChanges // true
billingAudit.INVOICE_REFUND.redactPaths      // ['cardNumber']
billingAudit._actions                        // readonly ['billing.INVOICE_REFUND', ...]
```

<table>
<thead>
  <tr>
    <th>
      条目字段
    </th>
    
    <th>
      用途
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        target
      </code>
    </td>
    
    <td>
      在调用处注入的默认 <code>
        target.type
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        description
      </code>
    </td>
    
    <td>
      面向人类的标签，用于文档、SIEM 规则和审查工具
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        severity
      </code>
    </td>
    
    <td>
      <code>
        'low' | 'medium' | 'high' | 'critical'
      </code>
      
       —— 告警和审查优先级
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        requiresChanges
      </code>
    </td>
    
    <td>
      说明调用方应附加 <code>
        changes
      </code>
      
      （例如通过 <code>
        auditDiff
      </code>
      
      ）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        requiresReason
      </code>
    </td>
    
    <td>
      说明调用方应附加 <code>
        reason
      </code>
      
      （尤其是拒绝场景）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        redactPaths
      </code>
    </td>
    
    <td>
      该动作上 <code>
        auditDiff({ redactPaths: [...] })
      </code>
      
       的默认路径
    </td>
  </tr>
</tbody>
</table>

### `defineAuditAction` 与 `defineAuditCatalog`：何时选择

二者都会生成相同的调用处工厂形状。按规模选择：

- **defineAuditAction(action, opts?)**：一次性动作，或在非常大的代码仓库中按文件组织动作。它对应 `defineError`。其效果等同于只有一个条目的 catalog，但不会推导前缀：你需要直接写出完整的 wire `action`。
- **defineAuditCatalog(prefix, map)**：将超出少量相关动作的内容归入同一个前缀下。它对应 `defineErrorCatalog`。wire `action` 会自动推导为 `${prefix}.${KEY}`，catalog 元数据（`_actions`、`_prefix`）会暴露出来以供自省，并且只需一行 `declare module 'evlog'`，即可将整个集合呈现在类型化的 `AuditAction` 联合类型中。

你可以在同一个代码库中混用二者。将跨领域的一次性动作保留为 `defineAuditAction`，将有界上下文（`billing`、`auth`、`subscription`）组织为 catalog。

### 无处不在的类型安全动作（可选）

通过扩展 `RegisteredAuditCatalogs` 来模仿错误 catalog 的增强：

```typescript
import type { billingAudit } from './audit/billing'

declare module 'evlog' {
  interface RegisteredAuditCatalogs {
    billing: typeof billingAudit
  }
}
```

这会在类型化的 `AuditAction` 导出上呈现所有已注册动作的联合类型，适用于共享辅助函数、仪表盘以及可安全重构的比较逻辑。

<callout color="primary" icon="i-lucide-arrow-right" to="https://evlog.zhcndoc.com/learn/catalogs">

**进一步了解。** 专门的 [Catalogs 页面](https://evlog.zhcndoc.com/learn/catalogs) 讲解了错误和审计 catalogs 的扩展路径（单文件 → 文件夹 → 功能 → npm 包），以及 npm 打包、组合模式和类型增强的深入解析。

</callout>

## `auditDiff()`

对于会修改数据的操作，请使用 `auditDiff()` 生成紧凑、支持脱敏感知的 JSON Patch：

<warning>

**不要把整行数据库记录直接传给 auditDiff()。** 在做 diff 之前，请移除计算列、哈希密码、内部标志以及大型 JSON blob。`changes` 的重点是*语义上发生了什么变化*（状态从 `paid` → `refunded`），而不是*字节层面发生了什么变化*（`lastModified` 时间戳变了）。冗长嘈杂的 `changes` 字段会让审计日志最先变得不可读。

</warning>

<code-group>

```typescript [输入]
import { auditDiff } from 'evlog'

const before = await db.users.byId(id)
const after = await db.users.update(id, patch)

log.audit({
  action: 'user.update',
  actor: { type: 'user', id: actorId },
  target: { type: 'user', id },
  outcome: 'success',
  changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }),
})
```

```json [输出 — changes 补丁]
{
  "audit": {
    "action": "user.update",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "user", "id": "usr_99" },
    "outcome": "success",
    "changes": [
      { "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" },
      { "op": "replace", "path": "/role", "from": "member", "to": "admin" },
      { "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" }
    ],
    "version": 1,
    "idempotencyKey": "ak_5e7d8f9a0b1c2d3e"
  }
}
```

</code-group>

## `withAudit()`：自动埋点

开发者经常忘记调用 `log.audit()`。把函数包装起来，就再也不会漏记：

<tip>

**何时进行包装，何时手动调用。** 对于*纯粹值得审计的动作*（退款、删除、更改角色、重置密码），请使用包装函数：结果判定会自动完成，你也不会意外跳过调用。当审计只是更大处理程序中的多个决策之一，或者你需要在动作完成*之前*发出审计（例如“用户请求删除”）时，请坚持手动调用 `log.audit()`。

</tip>

<code-group>

```typescript [输入]
import { withAudit, AuditDeniedError } from 'evlog'

const refundInvoice = withAudit(
  { action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) },
  async (input: { id: string }, ctx) => {
    if (!ctx.actor) throw new AuditDeniedError('匿名退款被拒绝')
    return await db.invoices.refund(input.id)
  },
)

await refundInvoice({ id: 'inv_889' }, {
  actor: { type: 'user', id: user.id },
  correlationId: requestId,
})
```

```json [输出 — 成功]
{
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "success",
    "version": 1,
    "idempotencyKey": "ak_8f3c4b2a1e5d6f7c",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  }
}
```

```json [输出 — 失败]
{
  "level": "error",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "failure",
    "reason": "Stripe 错误：charge 已退款",
    "version": 1,
    "idempotencyKey": "ak_4c5d6e7f8a9b0c1d",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  },
  "error": {
    "name": "StripeError",
    "message": "charge already refunded",
    "stack": "..."
  }
}
```

```json [输出 — 拒绝]
{
  "level": "warn",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "system", "id": "anonymous" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "denied",
    "reason": "匿名退款被拒绝",
    "version": 1,
    "idempotencyKey": "ak_d12c3a4f5b6e7d8c",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  }
}
```

</code-group>

结果判定：

- `fn` 返回成功 → `outcome: 'success'`。
- `fn` 抛出 `AuditDeniedError`（或任何 `status === 403` 的错误）→ `outcome: 'denied'`，错误消息会成为 `reason`。
- 其他抛出的错误 → `outcome: 'failure'`，随后重新抛出。

---

- [模式](https://evlog.zhcndoc.com/use-cases/audit/schema)
- [汇聚与完整性](https://evlog.zhcndoc.com/use-cases/audit/pipeline)


## Sitemap

See the full [sitemap](https://evlog.zhcndoc.com/sitemap.md) for all pages.
