diff --git a/.monkeycode/specs/audit-log/design.md b/.monkeycode/specs/audit-log/design.md new file mode 100644 index 0000000..d2f673c --- /dev/null +++ b/.monkeycode/specs/audit-log/design.md @@ -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` 排序 +- `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 +审计 +``` + +## 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, + }; +}, +``` + +### 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 | diff --git a/.monkeycode/specs/audit-log/requirements.md b/.monkeycode/specs/audit-log/requirements.md new file mode 100644 index 0000000..b2588d1 --- /dev/null +++ b/.monkeycode/specs/audit-log/requirements.md @@ -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 ` **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)` 索引 +- 审计日志写入使用同步 SQLite 操作,与命令在同一事务外独立写入 +- `/audit` 命令响应时间不超过 500ms(50 条记录内) diff --git a/src/channels/telegram/bot.ts b/src/channels/telegram/bot.ts index 51cfc22..9f992ad 100644 --- a/src/channels/telegram/bot.ts +++ b/src/channels/telegram/bot.ts @@ -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"; @@ -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); diff --git a/src/channels/telegram/commands.ts b/src/channels/telegram/commands.ts index e2e2c62..d2a44db 100644 --- a/src/channels/telegram/commands.ts +++ b/src/channels/telegram/commands.ts @@ -1,5 +1,6 @@ import { InputFile, type Context } from "grammy"; import type { AiDraftService } from "../../domain/ai-drafts.js"; +import type { AuditService } from "../../domain/audit.js"; import { type ConversationService } from "../../domain/conversations.js"; import type { DeliveryService } from "../../domain/deliveries.js"; import { isAiConfigured, type AppConfig } from "../../runtime/config.js"; @@ -10,6 +11,7 @@ export interface CommandDeps { conversations: ConversationService; aiDrafts: AiDraftService; deliveries: DeliveryService; + audit: AuditService; } export interface TopicContext { @@ -50,6 +52,7 @@ export function topicHelpText(): string { "/unban - 解除封禁", "/delete confirm - 删除当前 Topic 并清理数据库会话信息", "/reset confirm - 清空会话消息和草稿,保留联系人映射和 Topic", + "/audit [数量] - 查看本会话最近审计记录,默认 20,最多 50", "/draft - 重新生成 AI 回复草稿", "/draft view - 查看当前草稿", "/draft send - 发送当前草稿给外部用户", @@ -177,6 +180,9 @@ export async function handleTopicCommand( return true; } const updated = await deps.conversations.setConversationRetention(conversation.id, days); + if (updated) { + deps.audit.log({ adminId, conversationId: conversation.id, action: "expire", detail: days === null ? "never" : String(days) }); + } await ctx.reply(updated ? retentionText(updated) : "会话不存在,无法设置销毁策略。"); return true; } @@ -204,6 +210,7 @@ export async function handleTopicCommand( return true; } await deps.conversations.addNote(conversation.id, adminId, args); + deps.audit.log({ adminId, conversationId: conversation.id, action: "note" }); await ctx.reply("内部备注已保存。"); return true; } @@ -228,6 +235,7 @@ export async function handleTopicCommand( return true; } await deps.conversations.addTag(conversation.id, args); + deps.audit.log({ adminId, conversationId: conversation.id, action: "tag", detail: args }); await ctx.reply(`标签已添加:${args}`); return true; } @@ -237,6 +245,7 @@ export async function handleTopicCommand( return true; } await deps.conversations.removeTag(conversation.id, args); + deps.audit.log({ adminId, conversationId: conversation.id, action: "untag", detail: args }); await ctx.reply(`标签已移除:${args}`); return true; } @@ -251,6 +260,7 @@ export async function handleTopicCommand( return true; } await deps.conversations.setPriority(conversation.id, args as "low" | "normal" | "high" | "urgent"); + deps.audit.log({ adminId, conversationId: conversation.id, action: "priority", detail: args }); await ctx.reply(`优先级已设置为 ${args}。`); return true; } @@ -260,16 +270,19 @@ export async function handleTopicCommand( return true; } await deps.conversations.assign(conversation.id, args); + deps.audit.log({ adminId, conversationId: conversation.id, action: "assign", detail: args }); await ctx.reply(`已分配给 ${args}。`); return true; } case "ban": { await deps.conversations.blockContact(contact.id, adminId, args || undefined); + deps.audit.log({ adminId, conversationId: conversation.id, action: "ban", detail: args || undefined }); await ctx.reply("联系人已封禁,后续消息会被拒收。"); return true; } case "unban": { await deps.conversations.unblockContact(contact.id); + deps.audit.log({ adminId, conversationId: conversation.id, action: "unban" }); await ctx.reply("联系人已解除封禁。"); return true; } @@ -279,6 +292,7 @@ export async function handleTopicCommand( return true; } await ctx.api.deleteForumTopic(Number(topicContext.topic.managementChatId), topicContext.topic.messageThreadId); + deps.audit.log({ adminId, conversationId: conversation.id, action: "delete" }); await deps.conversations.deleteConversationData(conversation.id); return true; } @@ -288,17 +302,20 @@ export async function handleTopicCommand( return true; } await deps.conversations.resetConversation(conversation.id); + deps.audit.log({ adminId, conversationId: conversation.id, action: "reset" }); await ctx.reply("会话已重置。联系人映射和 Topic 已保留。"); return true; } case "close": { await deps.conversations.setConversationStatus(conversation.id, "closed"); + deps.audit.log({ adminId, conversationId: conversation.id, action: "close" }); await ctx.reply("会话已关闭。"); return true; } case "open": case "reopen": { await deps.conversations.setConversationStatus(conversation.id, "open"); + deps.audit.log({ adminId, conversationId: conversation.id, action: "reopen" }); await ctx.reply("会话已重新打开。"); return true; } @@ -309,6 +326,7 @@ export async function handleTopicCommand( return true; } await deps.conversations.mute(conversation.id, mutedUntil); + deps.audit.log({ adminId, conversationId: conversation.id, action: "mute", detail: mutedUntil }); await ctx.reply(`已静音至 ${mutedUntil}。`); return true; } @@ -331,6 +349,7 @@ export async function handleTopicCommand( throw error; } deps.aiDrafts.markSent(draft.id); + deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_send", detail: String(draft.id) }); await ctx.reply("草稿已发送给外部用户。"); } catch (error) { const msg = error instanceof Error ? error.message : String(error); @@ -345,6 +364,7 @@ export async function handleTopicCommand( return true; } deps.aiDrafts.markDiscarded(draft.id); + deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_discard", detail: String(draft.id) }); await ctx.reply("草稿已丢弃。"); return true; } @@ -380,6 +400,7 @@ export async function handleTopicCommand( } case "ai_on": { await deps.conversations.setAiEnabled(conversation.id, true); + deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_on" }); const globalEnabled = isAiConfigured(deps.config); const hint = globalEnabled ? "" : "\n\n提示:全局 AI 未开启,需先在控制台启用 AI_DRAFTS_ENABLED。"; await ctx.reply(`已对该会话开启 AI 草稿。${hint}`); @@ -387,9 +408,21 @@ export async function handleTopicCommand( } case "ai_off": { await deps.conversations.setAiEnabled(conversation.id, false); + deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_off" }); await ctx.reply("已对该会话关闭 AI 草稿。该会话不再自动生成回复草稿。"); return true; } + 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; + } default: return false; } diff --git a/src/channels/telegram/messages.ts b/src/channels/telegram/messages.ts index 12f2c2f..d9367d5 100644 --- a/src/channels/telegram/messages.ts +++ b/src/channels/telegram/messages.ts @@ -2,6 +2,7 @@ import type { Context } from "grammy"; import type { Logger } from "pino"; import type { AppConfig } from "../../runtime/config.js"; import type { AiDraftService } from "../../domain/ai-drafts.js"; +import type { AuditService } from "../../domain/audit.js"; import type { ConversationService } from "../../domain/conversations.js"; import type { DeliveryService } from "../../domain/deliveries.js"; import type { PermissionService } from "../../domain/permissions.js"; @@ -17,6 +18,7 @@ export interface TelegramMessageDeps { permissions: PermissionService; rateLimit: RateLimitService; aiDrafts: AiDraftService; + audit: AuditService; logger: Logger; } diff --git a/src/domain/audit.ts b/src/domain/audit.ts new file mode 100644 index 0000000..75f92ec --- /dev/null +++ b/src/domain/audit.ts @@ -0,0 +1,93 @@ +import type { DatabaseSync } from "node:sqlite"; + +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 interface AuditLogInput { + adminId: string; + conversationId: number; + action: string; + detail?: string; +} + +function auditEntryFromRow(row: Record): AuditLogEntry { + return { + id: Number(row.id), + adminId: String(row.admin_id), + conversationId: Number(row.conversation_id), + action: String(row.action), + detail: row.detail === null ? null : String(row.detail), + createdAt: String(row.created_at), + }; +} + +export class AuditService { + constructor(private db: DatabaseSync) {} + + log(input: AuditLogInput): void { + try { + this.db + .prepare( + `INSERT INTO audit_logs (admin_id, conversation_id, action, detail, created_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + input.adminId, + input.conversationId, + input.action, + input.detail ?? null, + new Date().toISOString(), + ); + } catch { + // Audit logging must never break the main command flow. + } + } + + list(opts: AuditListOptions): { items: AuditLogEntry[]; total: number } { + const conditions: string[] = []; + const params: Array = []; + if (opts.conversationId !== undefined) { + conditions.push("conversation_id = ?"); + params.push(opts.conversationId); + } + if (opts.adminId) { + conditions.push("admin_id = ?"); + params.push(opts.adminId); + } + if (opts.action) { + conditions.push("action = ?"); + params.push(opts.action); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = this.db + .prepare( + `SELECT id, admin_id, conversation_id, action, detail, created_at + FROM audit_logs ${where} + ORDER BY created_at DESC, id DESC + LIMIT ? OFFSET ?`, + ) + .all(...params, opts.limit, opts.offset) as Array>; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`) + .get(...params) as { cnt: number }; + return { items: rows.map(auditEntryFromRow), total: countRow.cnt }; + } + + listByConversation(conversationId: number, limit: number): AuditLogEntry[] { + return this.list({ conversationId, limit, offset: 0 }).items; + } +} diff --git a/src/runtime/main.ts b/src/runtime/main.ts index c805671..4489508 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -15,6 +15,7 @@ import { startDeliveryRetryWorker } from "../domain/delivery-retry.js"; import { DeliveryService } from "../domain/deliveries.js"; import { ConversationService } from "../domain/conversations.js"; import { AiDraftService } from "../domain/ai-drafts.js"; +import { AuditService } from "../domain/audit.js"; import { AppSettingsService } from "../domain/app-settings.js"; import { ensureSetupToken, startWebConsole } from "./web-console.js"; import type { IncomingMessage, ServerResponse } from "node:http"; @@ -376,6 +377,26 @@ await startWebConsole({ const deliveries = new DeliveryService(handle.db); deliveries.scheduleRetry(deliveryId); }, + 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) => ({ + id: a.id, + adminId: a.adminId, + conversationId: a.conversationId, + action: a.action, + detail: a.detail, + createdAt: a.createdAt, + })), + total: result.total, + }; + }, }); logger.info({ port: databaseConfig.WEB_CONSOLE_PORT }, "InboxBridge web console started."); await restartRuntime(); diff --git a/src/runtime/web-console.ts b/src/runtime/web-console.ts index 1cbef36..373dd78 100644 --- a/src/runtime/web-console.ts +++ b/src/runtime/web-console.ts @@ -228,6 +228,35 @@ export interface OperationsOverview { uptimeSeconds: number; } +export interface AuditLogView { + id: number; + adminId: string; + conversationId: number; + action: string; + detail: string | null; + createdAt: string; +} + +export const auditActionOptions = [ + "ban", + "unban", + "assign", + "priority", + "close", + "reopen", + "mute", + "delete", + "reset", + "expire", + "ai_on", + "ai_off", + "draft_send", + "draft_discard", + "tag", + "untag", + "note", +] as const; + export interface WebConsoleOptions { settings: AppSettingsService; port: number; @@ -240,6 +269,7 @@ export interface WebConsoleOptions { listConversations: (opts: { page: number; status?: string; pageSize: number }) => { items: ConversationListItemView[]; total: number }; listFailedDeliveries: (opts: { page: number; pageSize: number }) => { items: DeliveryView[]; total: number }; scheduleRetry: (deliveryId: number) => Promise; + listAuditLogs: (opts: { page: number; adminId?: string; action?: string; pageSize: number }) => { items: AuditLogView[]; total: number }; } type SessionKind = "password" | "setup"; @@ -356,6 +386,15 @@ export async function startWebConsole(options: WebConsoleOptions): Promise${escapeHtml(title)} 配置 概览 会话 投递 +审计 ${body} `; @@ -934,6 +974,60 @@ function renderDeliveries( send(res, 200, "text/html; charset=utf-8", opsPage("投递队列 - InboxBridge", body, "deliveries")); } +function renderAuditLogs( + res: ServerResponse, + data: { items: AuditLogView[]; total: number }, + page: number, + adminId: string | undefined, + action: string | undefined, +): void { + const pageSize = 50; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + const actionOptions = auditActionOptions + .map((a) => `${a}`) + .join(""); + const adminValue = adminId ? escapeHtml(adminId) : ""; + const buildQs = (p: number): string => { + const params = new URLSearchParams(); + if (p > 1) params.set("page", String(p)); + if (adminId) params.set("admin_id", adminId); + if (action) params.set("action", action); + const qs = params.toString(); + return qs ? `?${qs}` : ""; + }; + const rows = data.items.length + ? data.items + .map( + (a) => ` + ${a.id} + ${escapeHtml(a.createdAt)} + ${escapeHtml(a.adminId)} + ${a.conversationId} + ${escapeHtml(a.action)} + ${escapeHtml(a.detail ?? "-")} + `, + ) + .join("") + : `没有符合条件的审计记录。`; + const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1) + .map((p) => `${p}`) + .join(""); + const body = ` + 审计日志 (${data.total}) + + 管理员 ID + 操作类型全部${actionOptions} + 筛选 + + + ID时间管理员会话 ID操作详情 + ${rows} + + ${totalPages > 1 ? `${pager}` : ""} + `; + send(res, 200, "text/html; charset=utf-8", opsPage("审计日志 - InboxBridge", body, "audit")); +} + function redirect(res: ServerResponse, location: string): void { res.statusCode = 302; res.setHeader("location", location); diff --git a/src/storage/migrations/0001_initial.ts b/src/storage/migrations/0001_initial.ts index e689436..3c1c58c 100644 --- a/src/storage/migrations/0001_initial.ts +++ b/src/storage/migrations/0001_initial.ts @@ -109,6 +109,16 @@ const statements = [ value TEXT NOT NULL, updated_at TEXT NOT NULL )`, + `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)", ]; export async function migrate(client: DatabaseSync): Promise { diff --git a/test/core.test.ts b/test/core.test.ts index 1dc91d3..b2e5bcf 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -14,6 +14,7 @@ import { PermissionService } from "../src/domain/permissions.js"; import { RateLimitService } from "../src/domain/rate-limit.js"; import { RetentionService } from "../src/domain/retention.js"; import { AiDraftService } from "../src/domain/ai-drafts.js"; +import { AuditService } from "../src/domain/audit.js"; import { createDb, type DbHandle } from "../src/storage/client.js"; import { migrate } from "../src/storage/migrations/0001_initial.js"; import { buildTopicName } from "../src/channels/telegram/topics.js"; @@ -44,6 +45,7 @@ const stubOpsOverview = () => ({ const stubListConversations = () => ({ items: [], total: 0 }); const stubListFailedDeliveries = () => ({ items: [], total: 0 }); const stubScheduleRetry = async () => {}; +const stubListAuditLogs = () => ({ items: [], total: 0 }); beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "inboxbridge-")); @@ -182,6 +184,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; @@ -226,6 +229,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; @@ -255,6 +259,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, telegramWebhook: async () => { throw new Error("webhook failed"); }, @@ -292,6 +297,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }), /EADDRINUSE/, ); @@ -314,6 +320,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -342,6 +349,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -368,6 +376,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -400,6 +409,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -436,6 +446,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -467,6 +478,7 @@ describe("web console", () => { listConversations: stubListConversations, listFailedDeliveries: stubListFailedDeliveries, scheduleRetry: stubScheduleRetry, + listAuditLogs: stubListAuditLogs, }); const port = (server.address() as AddressInfo).port; try { @@ -1001,3 +1013,131 @@ describe("AI draft lifecycle", () => { assert.equal(beforePf.next_retry_at, null); }); }); + +describe("audit log", () => { + it("writes and retrieves audit entries", async () => { + const conversations = new ConversationService(handle.db, 30); + const audit = new AuditService(handle.db); + const bundle = await conversations.getOrCreateConversation({ + platform: "telegram", + externalUserId: "111", + displayName: "User1", + }); + const convId = bundle.conversation.id; + + audit.log({ adminId: "100", conversationId: convId, action: "close" }); + audit.log({ adminId: "200", conversationId: convId, action: "assign", detail: "300" }); + audit.log({ adminId: "100", conversationId: convId, action: "priority", detail: "high" }); + + const logs = audit.listByConversation(convId, 10); + assert.equal(logs.length, 3); + assert.equal(logs[0].action, "priority"); + assert.equal(logs[1].action, "assign"); + assert.equal(logs[2].action, "close"); + assert.equal(logs[1].detail, "300"); + assert.equal(logs[2].detail, null); + }); + + it("lists audit logs with filters and pagination", async () => { + const conversations = new ConversationService(handle.db, 30); + const audit = new AuditService(handle.db); + const bundle = await conversations.getOrCreateConversation({ + platform: "telegram", + externalUserId: "222", + displayName: "User2", + }); + const convId = bundle.conversation.id; + + for (let i = 0; i < 5; i++) { + audit.log({ adminId: "100", conversationId: convId, action: "note" }); + } + for (let i = 0; i < 3; i++) { + audit.log({ adminId: "200", conversationId: convId, action: "close" }); + } + + const byAdmin = audit.list({ adminId: "100", limit: 50, offset: 0 }); + assert.equal(byAdmin.total, 5); + assert.equal(byAdmin.items.length, 5); + + const byAction = audit.list({ action: "close", limit: 50, offset: 0 }); + assert.equal(byAction.total, 3); + + const paged = audit.list({ limit: 2, offset: 0 }); + assert.equal(paged.items.length, 2); + assert.equal(paged.total, 8); + }); + + it("serves audit page behind auth", async () => { + const server = await startWebConsole({ + settings: new AppSettingsService(handle.db), + port: 0, + getStatus: () => ({ bot: "stopped", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: () => ({ items: [], total: 0 }), + }); + const port = (server.address() as AddressInfo).port; + const res = await fetch(`http://127.0.0.1:${port}/operations/audit`); + assert.equal(res.status, 200); + const html = await res.text(); + server.close(); + assert.ok(html.includes("登录")); + }); + + it("renders audit logs in web page", async () => { + const conversations = new ConversationService(handle.db, 30); + const audit = new AuditService(handle.db); + const bundle = await conversations.getOrCreateConversation({ + platform: "telegram", + externalUserId: "333", + displayName: "User3", + }); + audit.log({ adminId: "999", conversationId: bundle.conversation.id, action: "ban", detail: "spam" }); + + const settings = new AppSettingsService(handle.db); + settings.setMany({ WEB_CONSOLE_SETUP_TOKEN: "setup-token" }); + const server = await startWebConsole({ + settings, + port: 0, + getStatus: () => ({ bot: "stopped", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + listAuditLogs: () => ({ + items: [{ + id: 1, + adminId: "999", + conversationId: bundle.conversation.id, + action: "ban", + detail: "spam", + createdAt: "2026-06-29T00:00:00.000Z", + }], + total: 1, + }), + }); + const port = (server.address() as AddressInfo).port; + const loginRes = await fetch(`http://127.0.0.1:${port}/login`, { + method: "POST", + body: new URLSearchParams({ setupToken: "setup-token" }), + redirect: "manual", + }); + const cookie = loginRes.headers.get("set-cookie")?.split(";")[0] ?? ""; + const res2 = await fetch(`http://127.0.0.1:${port}/operations/audit`, { + headers: { cookie }, + }); + const html = await res2.text(); + server.close(); + assert.ok(html.includes("审计日志")); + assert.ok(html.includes("ban")); + assert.ok(html.includes("spam")); + }); +});