Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions .monkeycode/specs/audit-log/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# 审计日志 (Audit Log) 技术设计

## 1. 架构概览

```
commands.ts --(成功后)--> AuditService.log()
|
v
audit_logs 表
^
|
web-console.ts <-- listAuditLogs() -- AuditService.list()
main.ts --------> listAuditLogs 回调 -----> /operations/audit
```

## 2. 数据库变更

### 2.1 audit_logs 表

```sql
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id TEXT NOT NULL,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
action TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS audit_logs_conversation_idx ON audit_logs(conversation_id, created_at DESC);
CREATE INDEX IF NOT EXISTS audit_logs_admin_idx ON audit_logs(admin_id, created_at DESC);
```

追加到 `0001_initial.ts` 的 `statements` 数组末尾(`app_settings` 之后)。无需 `addColumnIfMissing`,因为是全新表。

## 3. AuditService 设计

### 3.1 文件位置
`src/domain/audit.ts`

### 3.2 接口

```typescript
export interface AuditLogEntry {
id: number;
adminId: string;
conversationId: number;
action: string;
detail: string | null;
createdAt: string;
}

export interface AuditListOptions {
conversationId?: number; // 按会话筛选
adminId?: string; // 按管理员筛选
action?: string; // 按操作类型筛选
limit: number;
offset: number;
}

export class AuditService {
constructor(private db: DatabaseSync) {}

log(entry: {
adminId: string;
conversationId: number;
action: string;
detail?: string;
}): void;

list(opts: AuditListOptions): { items: AuditLogEntry[]; total: number };

listByConversation(conversationId: number, limit: number): AuditLogEntry[];
}
```

### 3.3 实现要点

- `log()` 使用 `INSERT INTO audit_logs (admin_id, conversation_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)`
- `created_at` 使用 `new Date().toISOString()`
- `log()` 内部 try/catch,失败时只 console.error(不依赖 pino,因为 AuditService 在命令处理中调用,保持与 ConversationService 同级的纯 domain 服务)
- `list()` 动态拼接 WHERE 子句,按 `created_at DESC` 排序
Comment on lines +78 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

审计写入失败时的错误处理与实现不一致

设计文档写明 log() 失败时“只 console.error”,但 src/domain/audit.ts 的实际实现是空 catch(不记录任何信息)。这与 REQ-7“记录错误日志并继续执行”也存在偏差。请统一设计文档与实际实现,确保审计写入失败时至少输出错误日志以便排查。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.monkeycode/specs/audit-log/design.md around lines 78 - 81,
`AuditService.log()` 的失败处理与设计不一致:当前实现是空 catch,没有输出任何错误信息。请在 `AuditService` 的
`log()` 中捕获写入失败后至少执行一次 `console.error`,并保留继续执行的行为;同时把设计文档里对“只
console.error”的描述与实际实现统一,确保 `log()`、`list()` 以及 `src/domain/audit.ts` 的职责表述一致并符合
REQ-7。

- `listByConversation()` 是 `list()` 的快捷方法,用于 `/audit` 命令

## 4. 命令埋点

### 4.1 CommandDeps 扩展

```typescript
export interface CommandDeps {
config: AppConfig;
conversations: ConversationService;
aiDrafts: AiDraftService;
deliveries: DeliveryService;
audit: AuditService; // 新增
}
```

### 4.2 埋点位置

在 `handleTopicCommand` 的各 case 分支中,命令执行成功后调用 `deps.audit.log()`。埋点清单:

| 命令 | action | detail |
|------|--------|--------|
| /ban | `ban` | 封禁原因(args 或 null) |
| /unban | `unban` | null |
| /assign | `assign` | 分配目标 user_id |
| /priority | `priority` | 新优先级值 |
| /close | `close` | null |
| /reopen | `reopen` | null |
| /mute | `mute` | 静音截止时间 |
| /delete confirm | `delete` | null |
| /reset confirm | `reset` | null |
| /expire | `expire` | 新策略(天数或 "never") |
| /ai_on | `ai_on` | null |
| /ai_off | `ai_off` | null |
| /draft send | `draft_send` | 草稿 ID |
| /draft discard | `draft_discard` | 草稿 ID |
| /tag | `tag` | 标签名 |
| /untag | `untag` | 标签名 |
| /note | `note` | null(备注内容不记入审计,保护隐私) |

### 4.3 新增 /audit 命令

在 `handleTopicCommand` 中新增 case:

```typescript
case "audit": {
const limit = parseLimit(args, 20, 50);
const logs = deps.audit.listByConversation(conversation.id, limit);
if (logs.length === 0) {
await ctx.reply("当前会话暂无审计记录。");
return true;
}
const lines = logs.map(l =>
`${l.createdAt} admin=${l.adminId} ${l.action}${l.detail ? ` (${l.detail})` : ""}`
);
await ctx.reply([`最近 ${logs.length} 条审计记录:`, ...lines].join("\n"));
return true;
}
```

### 4.4 菜单更新

`topicHelpText()` 中 "安全与辅助" 区块新增:
```
/audit [数量] - 查看本会话最近审计记录,默认 20,最多 50
```

## 5. Web 控制台

### 5.1 路由

- `GET /operations/audit` — 审计日志列表页
- 查询参数:`page`(页码)、`admin_id`(管理员筛选)、`action`(操作类型筛选)

### 5.2 WebConsoleOptions 扩展

```typescript
listAuditLogs: (opts: {
page: number;
adminId?: string;
action?: string;
pageSize: number;
}) => { items: AuditLogView[]; total: number };
```

### 5.3 AuditLogView 接口

```typescript
export interface AuditLogView {
id: number;
adminId: string;
conversationId: number;
action: string;
detail: string | null;
createdAt: string;
}
```

### 5.4 渲染

`renderAuditLogs(res, data, page, adminId, action)`:
- 顶部筛选表单:管理员 ID 输入框 + 操作类型下拉框
- 表格列:ID、时间、管理员、会话 ID、操作、详情
- 复用 `opsPage()`,activeNav 新增 `"audit"`

### 5.5 导航

`opsPage()` 的 header 新增 "审计" 链接:
```html
<a href="/operations/audit" class="${activeNav === "audit" ? "active" : ""}">审计</a>
```

## 6. main.ts 接入

### 6.1 import

```typescript
import { AuditService } from "../domain/audit.js";
```

### 6.2 listAuditLogs 回调

```typescript
listAuditLogs: (opts) => {
const audit = new AuditService(handle.db);
const result = audit.list({
adminId: opts.adminId || undefined,
action: opts.action || undefined,
limit: opts.pageSize,
offset: (opts.page - 1) * opts.pageSize,
});
return {
items: result.items.map(a => ({ ...a })),
total: result.total,
};
},
```
Comment on lines +204 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

建议 Web 层复用 AuditService 单例

设计文档中 listAuditLogs 每次请求都 new AuditService(handle.db),而 Telegram 侧(bot.ts)是注入单例。建议 main.ts 也在启动时创建单例并复用,保持两层实例化模式一致,避免不必要的对象创建。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.monkeycode/specs/audit-log/design.md around lines 204 - 218, listAuditLogs
currently instantiates a new AuditService on every call, which is inconsistent
with the Telegram flow that reuses a singleton. Update the web layer so the
AuditService used by listAuditLogs is created once during startup in main.ts and
injected/reused through the handle path, then have listAuditLogs reference that
shared instance instead of calling new AuditService(handle.db) each request.


### 6.3 bot.ts 注入

`createTelegramBot` 中创建 `AuditService` 实例并注入 `deps.audit`。

## 7. 测试计划

1. AuditService.log 写入并查询
2. AuditService.list 分页和筛选
3. AuditService.listByConversation
4. /audit 命令返回审计记录
5. Web 审计页面认证重定向
6. Web 审计页面 HTML 渲染

## 8. 影响范围

| 文件 | 变更类型 |
|------|----------|
| `src/storage/migrations/0001_initial.ts` | 新增 audit_logs 表和索引 |
| `src/domain/audit.ts` | 新建 |
| `src/channels/telegram/commands.ts` | CommandDeps 新增 audit、17 处埋点、新增 /audit 命令 |
| `src/channels/telegram/bot.ts` | createTelegramBot 注入 AuditService |
| `src/runtime/web-console.ts` | 新增 AuditLogView、listAuditLogs 回调、/operations/audit 路由、renderAuditLogs、导航 |
| `src/runtime/main.ts` | import AuditService、listAuditLogs 回调 |
| `test/core.test.ts` | 新增 AuditService 测试、/audit 命令测试、web 审计页面测试、所有 startWebConsole 调用加 stub |
54 changes: 54 additions & 0 deletions .monkeycode/specs/audit-log/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 审计日志 (Audit Log) 需求规格

## 1. 概述

持久化记录白名单管理员在 Topic 内执行的关键操作,便于多管理员协作场景下追溯谁在何时对哪个会话做了什么操作。

## 2. 背景

当前所有管理员命令(ban/assign/delete/reset/close/mute/priority 等)执行后仅回复一条 Telegram 消息,没有持久化记录。多管理员场景下无法回答"谁关闭了这个会话"、"谁重置了消息"、"谁封禁了这个联系人"等问题。

## 3. 范围

### 3.1 In Scope

- 新增 `audit_logs` 表,记录管理员操作
- `AuditService`:写入、查询、统计
- 在以下命令执行成功后埋点:ban/unban/assign/priority/close/reopen/mute/delete/reset/expire/ai_on/ai_off/draft send/draft discard/tag/untag/note
- `/audit [N]` 命令:在当前 Topic 内查看最近 N 条审计记录(默认 20,最多 50)
- Web 控制台 `/operations/audit` 页面:分页审计日志,按管理员 ID 和操作类型筛选

### 3.2 Out of Scope

- 审计日志的自动清理策略(后续随消息保留策略一起处理)
- 审计日志导出(后续随 export 功能扩展)
- 操作撤销/回滚

## 4. 需求 (EARS)

### REQ-1: 审计日志持久化
**WHEN** 管理员执行列入审计清单的命令 **AND** 命令执行成功时,**THE SYSTEM SHALL** 在 `audit_logs` 表中写入一条记录,包含管理员 ID、操作类型、目标会话 ID、操作详情和时间戳。

### REQ-2: 审计记录字段
**THE audit_logs TABLE SHALL** 包含以下字段:id(自增主键)、admin_id(TEXT,执行操作的管理员 Telegram user_id)、conversation_id(INTEGER,目标会话 ID)、action(TEXT,操作类型枚举)、detail(TEXT,可空的附加信息如封禁原因/优先级值/分配目标)、created_at(TEXT,ISO 8601 时间戳)。

### REQ-3: /audit 命令
**WHEN** 管理员在 Topic 内发送 `/audit` 或 `/audit <N>` **AND** 当前会话存在时,**THE SYSTEM SHALL** 返回该会话最近 N 条审计记录(默认 20,最多 50),按时间倒序排列,每条记录显示时间、管理员 ID、操作和详情。

### REQ-4: Web 审计页面认证
**WHEN** 未认证用户访问 `/operations/audit` 时,**THE SYSTEM SHALL** 重定向到登录页,行为与 `/operations/overview` 一致。

### REQ-5: Web 审计页面查询
**WHEN** 已认证管理员访问 `/operations/audit` 时,**THE SYSTEM SHALL** 展示分页审计日志(每页 50 条),支持按管理员 ID 和操作类型筛选,每条记录显示时间、管理员、会话 ID、操作、详情。

### REQ-6: 操作类型枚举
**THE action field SHALL** 使用以下固定值:`ban`、`unban`、`assign`、`priority`、`close`、`reopen`、`mute`、`delete`、`reset`、`expire`、`ai_on`、`ai_off`、`draft_send`、`draft_discard`、`tag`、`untag`、`note`。

### REQ-7: 埋点不阻断主流程
**WHEN** 审计日志写入失败时,**THE SYSTEM SHALL** 记录错误日志并继续执行,不阻断主命令流程,不向管理员暴露审计写入错误。

## 5. 非功能性需求

- 性能:审计日志查询使用 `(conversation_id, created_at DESC)` 复合索引和 `(admin_id)` 索引

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

索引描述与实际定义不一致

非功能性需求中写的 (admin_id) 单列索引,但迁移 SQL 实际创建的是 (admin_id, created_at DESC) 复合索引。建议更新文档以准确反映索引结构,避免后续维护者误解。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.monkeycode/specs/audit-log/requirements.md at line 52, The audit-log
requirements document describes an admin_id single-column index, but the actual
migration defines a composite index with created_at DESC. Update the index
requirement text in the audit-log spec to match the real schema, referencing the
audit log performance requirement entry so it clearly states the (admin_id,
created_at DESC) index alongside the existing (conversation_id, created_at DESC)
index.

- 审计日志写入使用同步 SQLite 操作,与命令在同一事务外独立写入
- `/audit` 命令响应时间不超过 500ms(50 条记录内)
2 changes: 2 additions & 0 deletions src/channels/telegram/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Bot, webhookCallback } from "grammy";
import type { Logger } from "pino";
import type { AppConfig } from "../../runtime/config.js";
import { AiDraftService } from "../../domain/ai-drafts.js";
import { AuditService } from "../../domain/audit.js";
import { ConversationService } from "../../domain/conversations.js";
import { DeliveryService } from "../../domain/deliveries.js";
import { PermissionService } from "../../domain/permissions.js";
Expand All @@ -26,6 +27,7 @@ export function createTelegramBot(config: AppConfig, db: Database, logger: Logge
permissions: new PermissionService(config.TELEGRAM_ADMIN_USER_IDS),
rateLimit: new RateLimitService(config.RATE_LIMIT_WINDOW_SECONDS, config.RATE_LIMIT_MAX_MESSAGES),
aiDrafts: new AiDraftService(db, conversations, config),
audit: new AuditService(db),
logger: logger.child({ module: "telegram.messages" }),
};
registerTelegramUpdates(bot, deps);
Expand Down
Loading