diff --git a/docs/changelog/index.md b/docs/changelog/index.md index bdb41118..6f7bdbb3 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -20,6 +20,18 @@ sidebar: false > - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。 > - 性能调优参数重置, 升级后请重新配置 +## [Unreleased] + +### 新增 + +- 日志存储解耦:新增日志存储抽象(`internal/repository/logstore`),ClickHouse 变为可选项,不启用时由 PostgreSQL/SQLite 承担全部日志功能;新增「切换日志数据库」任务支持 PostgreSQL/SQLite 与 ClickHouse 间数据迁移(迁移期间冻结日志写入,成功后自动切换主库并保留源数据);日志保留时间改为按存储库在业务配置中设置(`log_retention_days_*`),过期清理并入系统垃圾清理每日任务。 + +### 修复 + +- 「切换日志数据库」迁移任务补齐第 6 张表(用户访问日志)的复制与清空;目标为 PostgreSQL 时按源库时间范围预建分区,历史日志可正常迁移;冻结前先排空批写入队列,避免在途日志丢失。 +- 节点监控最新指标读取仅在 ClickHouse 为当前日志库时走 ClickHouse 快速路径,日志库切换后仪表盘不再读到旧库数据。 +- `log_database` / `log_db_migration` 为受保护配置,管理端创建/修改接口均拒绝手动写入。 + ## [v3.4.5] - 2026-08-08 ### 改进 diff --git a/docs/docs.go b/docs/docs.go index e740189e..60b37f10 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1024,7 +1024,7 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", + "description": "分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错)", "produces": [ "application/json" ], @@ -1119,7 +1119,7 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", + "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错)", "produces": [ "application/json" ], @@ -1147,7 +1147,7 @@ const docTemplate = `{ } }, "400": { - "description": "ClickHouse 未启用", + "description": "日志存储未启用", "schema": { "$ref": "#/definitions/response.Any" } @@ -1877,21 +1877,21 @@ const docTemplate = `{ } } }, - "/api/v1/admin/status/clickhouse": { + "/api/v1/admin/status/log-database": { "get": { "security": [ { "SessionCookie": [] } ], - "description": "返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限", + "description": "返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "获取 ClickHouse 运行指标", + "summary": "获取日志数据库状态", "responses": { "200": { "description": "获取成功", @@ -1904,19 +1904,13 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/analytics.ClickHouseOperationalStats" + "$ref": "#/definitions/status.LogDatabaseStatus" } } } ] } }, - "400": { - "description": "ClickHouse 未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, "401": { "description": "未登录", "schema": { @@ -2393,6 +2387,18 @@ const docTemplate = `{ "name": "task_type", "in": "query" }, + { + "type": "string", + "description": "任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先)", + "name": "task_type_prefix", + "in": "query" + }, + { + "type": "string", + "description": "逗号分隔的精确任务类型列表(IN 筛选,优先于前缀)", + "name": "task_types", + "in": "query" + }, { "type": "integer", "default": 1, @@ -8305,80 +8311,6 @@ const docTemplate = `{ } } }, - "/api/v1/d/option/database/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "清理可观测性数据库", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/option.databaseCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.databaseCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/d/option/geoip/lookup": { "post": { "security": [ @@ -14906,33 +14838,26 @@ const docTemplate = `{ } } }, - "analytics.ClickHouseOperationalStats": { + "analytics.BatchWriterStats": { "type": "object", "properties": { - "active_parts": { + "cap": { "type": "integer" }, - "async_insert_bytes": { + "depth": { "type": "integer" }, - "async_insert_queue": { + "drops": { "type": "integer" }, - "batch_writers": { - "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", - "type": "array", - "items": { - "$ref": "#/definitions/batchwriter.Stats" - } + "flush_errors": { + "type": "integer" }, - "database": { + "name": { "type": "string" }, - "pending_mutations": { - "type": "integer" - }, - "total_rows": { - "type": "integer" + "running": { + "type": "boolean" } } }, @@ -15024,29 +14949,6 @@ const docTemplate = `{ } } }, - "batchwriter.Stats": { - "type": "object", - "properties": { - "cap": { - "type": "integer" - }, - "depth": { - "type": "integer" - }, - "drops": { - "type": "integer" - }, - "flush_errors": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "running": { - "type": "boolean" - } - } - }, "cache.updateCacheConfigRequest": { "type": "object", "required": [ @@ -15105,6 +15007,9 @@ const docTemplate = `{ "id": { "type": "integer" }, + "zone_domain": { + "type": "string" + }, "zone_id": { "type": "integer" } @@ -15826,6 +15731,36 @@ const docTemplate = `{ } } }, + "github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats": { + "type": "object", + "properties": { + "active_parts": { + "type": "integer" + }, + "async_insert_bytes": { + "type": "integer" + }, + "async_insert_queue": { + "type": "integer" + }, + "batch_writers": { + "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", + "type": "array", + "items": { + "$ref": "#/definitions/analytics.BatchWriterStats" + } + }, + "database": { + "type": "string" + }, + "pending_mutations": { + "type": "integer" + }, + "total_rows": { + "type": "integer" + } + } + }, "github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta": { "type": "object", "properties": { @@ -18481,46 +18416,6 @@ const docTemplate = `{ } } }, - "option.databaseCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - } - } - }, - "option.databaseCleanupResult": { - "type": "object", - "properties": { - "cleanup_mode": { - "type": "string" - }, - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "eligible_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - }, - "table_ttl_days": { - "type": "integer" - }, - "target": { - "type": "string" - }, - "target_label": { - "type": "string" - } - } - }, "option.geoIPLookupRequest": { "type": "object", "properties": { @@ -19859,6 +19754,33 @@ const docTemplate = `{ } } }, + "status.LogDatabaseStatus": { + "type": "object", + "properties": { + "active_database": { + "type": "string" + }, + "available_targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "clickhouse": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats" + }, + "migration": { + "description": "idle | migrating", + "type": "string" + }, + "retention_days": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, "status.SystemStatusResponse": { "type": "object", "properties": { diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b8652188..eccfbac3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -197,8 +197,6 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 | `node_offline_threshold` | `int` | 在管理后台中判定节点失去心跳并标注为离线状态的无响应阈值(毫秒) | `60000` (60s) | | `agent_update_repo` | `string` | Agent 节点更新下载自身二进制的 Release 仓库源 | `Rain-kl/OpenFlare` | | `geoip_provider` | `string` | GeoIP 提供商,支持 `maxmind` 等,用于 WAF 防护时地域分析 | `ipinfo` | -| `database_auto_cleanup_enabled` | `bool` | 是否在每天凌晨 3:00 自动清理过期观测历史日志(降低数据库空间) | `true` | -| `database_auto_cleanup_retention_days` | `int` | 自动清理观测数据(访问日志、度量曲线、审计等)的默认保留天数 | `30` | ### 5. Uptime Kuma 监控联动同步 | 配置键 (Key) | 数据类型 | 作用说明 | 默认值 | @@ -272,6 +270,20 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环 --- +### 8. 日志存储(Log Database) + +日志存储解耦后的运行时配置:日志主库由「切换日志数据库」任务管理(内部/受保护 key,禁止管理员手动修改),保留天数按存储库分别在业务配置中设置。 + +| 配置键 (Key) | 数据类型 | 作用说明 | 默认值 | +| --- | --- | --- | --- | +| `log_database` | `string` | 当前日志主库(`postgres` / `sqlite` / `clickhouse`)。**内部受保护 key**:仅「切换日志数据库」迁移任务写入,管理员不可手动创建/修改 | 随主库(PostgreSQL 启用时为 `postgres`,否则 `sqlite`;ClickHouse 启用时优先 `clickhouse`) | +| `log_db_migration` | `string` | 日志迁移冻结标记(`migrating` 或空)。**内部受保护 key**:仅迁移任务写入,置位期间日志写入返回 503「日志数据库迁移中,暂不可写」 | 空 | +| `log_retention_days_postgres` | `int` | PostgreSQL 日志库的过期清理保留天数(过期日志由系统垃圾清理每日任务删除) | `90` | +| `log_retention_days_sqlite` | `int` | SQLite 日志库的过期清理保留天数 | `90` | +| `log_retention_days_clickhouse` | `int` | ClickHouse 日志库的过期清理保留天数 | `90` | + +--- + ## 前端构建环境变量 | 环境变量 | 作用 | 默认值 | diff --git a/docs/superpowers/plans/2026-08-08-log-database-decoupling.md b/docs/superpowers/plans/2026-08-08-log-database-decoupling.md new file mode 100644 index 00000000..2d1e7117 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-log-database-decoupling.md @@ -0,0 +1,2539 @@ +# 日志数据库解耦(ClickHouse 可选化)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让日志/分析存储从 ClickHouse 解耦——新增 `internal/repository/logstore` 抽象(PG/SQLite 用 GORM、CH 用现有原生优化),ClickHouse 变为可选;提供「切换日志数据库」迁移任务与按库保留时间配置。 + +**Architecture:** repository 层导出接口 + 配置驱动 provider(`log_database` 系统配置决定激活实现);apps 只面向 `logstore`/`repository` 公开函数,import-lint 测试强制约束;CH 实现包住现有 `analyticsrepo`(零性能损耗);PG/SQLite 共用一套 GORM 实现(方言 SQL 拆 `dialect_*` 小文件)。 + +**Tech Stack:** Go 1.25+、GORM、PostgreSQL/SQLite(主库 goose 双方言)、ClickHouse(原生 driver + 单方言 goose)、Asynq 任务框架、Next.js/TypeScript/shadcn。 + +## Global Constraints + +- 模块:`github.com/Rain-kl/Wavelet`;Go 1.25.7。 +- 分层:`apps → repository → model`;`model` 禁止 import `repository`/`db`;`pkg/util/` 禁止 Gin/GORM/sessions。 +- 路由仅注册于 `internal/router/router.go`;`Serve()` 禁止进程级初始化。 +- 迁移:PG/SQLite 双方言同版本号 goose SQL(`internal/infra/persistence/migrator/goose/{postgres,sqlite}`);CH 单方言(`goose/clickhouse`);禁止 GORM AutoMigrate(**生产**;单测可用 sqlite AutoMigrate 建测试表)。 +- 任务/推送注册:`bootstrap.RegisterTasks()` 等显式装配,禁止 `init()` 注册跨模块集成。 +- API 错误:`response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))`。 +- 系统配置:key 常量在 `internal/model/system_configs.go`;值存字符串;`type` ∈ {`system`,`business`};`visibility` 0/1;goose 双方言 seed。 +- 前端:shadcn `variant` + CSS 变量;页面根 `w-full`;标题 `h1 text-2xl font-semibold tracking-tight`;service 继承 `BaseService`,回调用箭头函数。 +- 日志库合法状态:`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且 `postgres` 仅当 `database.enabled`、`sqlite` 仅当 `!database.enabled`、`clickhouse` 仅当 `clickhouse.enabled`。 +- 完成标准:`go test ./...`、`make swagger`(API 变更时)、`make code-check`、`make format`;goose 三套空库 Up 全量通过。 + +--- + +## 里程碑与文件总览 + +| 文件 | 职责 | +|---|---| +| `internal/model/analytics/filter.go`(新) | 从 analyticsrepo 迁入的过滤/结果 DTO(纯数据) | +| `internal/model/system_configs.go` | 新增 `ConfigKeyLogDatabase`、`ConfigKeyLogDBMigration`、`ConfigKeyLogRetentionDaysPostgres/SQLite/ClickHouse` | +| `internal/repository/logstore/logstore.go`(新) | 导出接口 + `Store` 结构体 + `ErrMigrating` | +| `internal/repository/logstore/provider.go`(新) | `Init(ctx)`/`Active(ctx)`/`Migrating(ctx)`/`Reload`/测试注入 | +| `internal/repository/logstore/postgres_store.go`(新) | GORM 实现(PG/SQLite 共用) | +| `internal/repository/logstore/dialect_postgres.go`、`dialect_sqlite.go`(新) | 方言 SQL 片段 | +| `internal/repository/logstore/clickhouse_store.go`(新) | CH 实现(委托 analyticsrepo) | +| `internal/repository/logstore/hooks.go`(新) | `AccessLogInsertHooks`/`ObservabilityInsertHooks` 注册表(从 repository 迁入) | +| `internal/repository/logstore/imports_test.go`(新) | import-lint 测试 | +| `internal/repository/openflare_access_log_store.go`、`openflare_observability_store.go` | 删除(被 logstore 吸收) | +| `internal/repository/openflare_access_log.go`、`openflare_observability.go` | 改为一行委托 logstore | +| `internal/apps/risk_control/logics.go`、`internal/apps/openflare/chwriter/writer.go` | flush func 与入口改为 logstore;冻结检查 | +| `internal/apps/openflare/tasks/database_cleanup.go` | 清理逻辑迁入 `system_cleanup`;任务下线 | +| `internal/apps/admin/logs/routers.go`、`internal/apps/admin/status/clickhouse.go` | 改走 logstore;状态端点改造 | +| `internal/apps/upload/task/cleanup.go` | 新增日志清理步骤 | +| `internal/apps/openflare/async_tasks.go`、`internal/infra/task/handlers/register.go` | 注册「切换日志数据库」任务;下线清理任务 | +| `internal/apps/openflare/tasks/log_db_switch.go`(新) | 迁移任务 Handler | +| `internal/platform/bootstrap/bootstrap.go` | 启动校验 + logstore 初始化 | +| `internal/infra/config/model.go` | (无新启动配置;校验仅用现有字段) | +| goose:`postgres/20260808NNNN_create_log_tables.sql`、`sqlite/20260808NNNN_create_log_tables.sql` | 6 张原始日志表(PG 分区) | +| goose:`postgres/20260808NNNN_log_retention_configs.sql`、`sqlite/...` | 保留配置 + 旧 key 下线 | +| goose:`postgres/20260808NNNN_drop_database_cleanup_schedule.sql`、`sqlite/...` | 下线 `of_database_auto_cleanup` schedule | +| `internal/apps/admin/system_config/routers.go`、`internal/apps/openflare/option/validate.go` | `log_database`/`log_db_migration` key 保护 | +| `frontend/...` | 任务管理页日志库状态、业务配置「日志保留时间」分组 | +| `docs/changelog/index.md` | `[Unreleased]` 中文条目 | + +--- + +## M1:抽象层与主库日志读写 + +### Task 1: DTO 类型迁入 model/analytics + +**Files:** +- Create: `internal/model/analytics/filter.go` +- Modify: `internal/repository/analytics/access_log.go`、`node_access_log.go`、`node_observability.go`、`access_log_stats.go`、`node_access_log_stats.go`、`node_observability_delete.go` 等(删除本地类型定义,改 import model/analytics) +- Test: `internal/model/analytics/filter_test.go` + +**Interfaces:** +- Consumes: 现有 analyticsrepo 包内类型定义位置。 +- Produces: `analyticsmodel.AccessLogFilter`、`analyticsmodel.NodeAccessLogFilter`、`analyticsmodel.NodeObservabilityFilter`、`analyticsmodel.DailyTrend`、`analyticsmodel.BrowserShare`、`analyticsmodel.TopUser`、`analyticsmodel.NodeAccessLogRegionCount`、`analyticsmodel.NodeAccessLogTrafficSummary`、`analyticsmodel.NodeAccessLogValueCount`、`analyticsmodel.NodeAccessLogNodeAggregate`(字段逐一从 analyticsrepo 原定义复制)。 + +- [ ] **Step 1: 在 `internal/model/analytics/filter.go` 定义迁移类型** + +```go +// Package analytics 定义分析域模型与查询 DTO(纯数据,无 IO)。 +package analytics + +import "time" + +// AccessLogFilter 用户访问日志查询条件。 +type AccessLogFilter struct { + UserID uint64 + Path string + Method string + IP string + Status int32 + Since time.Time + Until time.Time + Page int + PageSize int +} + +// NodeAccessLogFilter 节点访问日志查询条件。 +type NodeAccessLogFilter struct { + NodeID string + RemoteAddr string + Host string + Hosts []string + Path string + Since time.Time + Until time.Time + Page int + PageSize int + SortBy string + SortOrder string +} + +// NodeObservabilityFilter 可观测查询条件。 +type NodeObservabilityFilter struct { + NodeID string + Since time.Time + Limit int +} + +// DailyTrend 每日访问趋势。 +type DailyTrend struct { + Date string + Cnt uint64 +} + +// BrowserShare 浏览器占比。 +type BrowserShare struct { + Browser string + Cnt uint64 +} + +// TopUser 活跃用户排行。 +type TopUser struct { + UserID uint64 + Cnt uint64 +} + +// NodeAccessLogRegionCount 地区访问计数。 +type NodeAccessLogRegionCount struct { + Region string + Count uint64 +} + +// NodeAccessLogTrafficSummary 流量汇总。 +type NodeAccessLogTrafficSummary struct { + RequestCount uint64 + ErrorCount uint64 + UniqueIPCount uint64 + BytesSent uint64 + RequestLength uint64 + NodeCount uint64 +} + +// NodeAccessLogValueCount 维度值计数。 +type NodeAccessLogValueCount struct { + Value string + Count uint64 +} + +// NodeAccessLogNodeAggregate 按节点聚合。 +type NodeAccessLogNodeAggregate struct { + NodeID string + RequestCount uint64 + ErrorCount uint64 + UniqueIPCount uint64 +} +``` + +> 注意:以上字段必须与 `internal/repository/analytics/` 中同名类型**逐字段一致**(比对 `access_log.go`、`node_access_log.go`、`node_access_log_stats.go`、`access_log_stats.go`)。若原类型字段与这里不同,以原类型为准修改本文件,保持语义不变。 + +- [ ] **Step 2: 让 analyticsrepo 使用新类型**——在每个原类型定义处删除定义,替换为类型别名,保证包内调用点零改动: + +```go +// internal/repository/analytics/access_log.go 顶部 +import analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + +type AccessLogFilter = analyticsmodel.AccessLogFilter +``` + +对 `NodeAccessLogFilter`、`NodeObservabilityFilter`、`DailyTrend`、`BrowserShare`、`TopUser`、`NodeAccessLogRegionCount`、`NodeAccessLogTrafficSummary`、`NodeAccessLogValueCount`、`NodeAccessLogNodeAggregate`、`ClickHouseOperationalStats`(及 `ClickHouseOperationalStats` 的字段结构体,含 `BatchWriters []batchwriter.Stats`)同样处理(原类型定义删除,替换为别名)。`ClickHouseOperationalStats` 迁入 `model/analytics` 后,logstore 状态接口可直接引用,CH 实现仍由 analyticsrepo 填充。 + +- [ ] **Step 3: 编译验证** 运行 `go build ./internal/...`,确认无重定义/未使用错误。 +- [ ] **Step 4: 提交** `git add internal/model/analytics/filter.go internal/repository/analytics/ && git commit -m "refactor(analytics): move filter/result DTOs to model/analytics"` + +### Task 2: logstore 接口与 provider 骨架 + +**Files:** +- Create: `internal/repository/logstore/logstore.go` +- Create: `internal/repository/logstore/provider.go` +- Create: `internal/repository/logstore/provider_test.go` + +**Interfaces:** +- Consumes: `analyticsmodel.*` DTO(Task 1)、`model.ConfigKeyLogDatabase`/`ConfigKeyLogDBMigration`(Task 8 定义,本任务先用字符串常量占位并加注释)、`db.DB(ctx)`(`internal/infra/persistence` 的 GORM 句柄)、`repository.GetSystemConfigByKey`。 +- Produces: 接口 `AccessLogStore`/`ObservabilityStore`/`UserAccessLogStore`、结构体 `Store`、`ErrMigrating`、`Init(ctx)`/`Active(ctx)`/`Migrating(ctx)`/`ResetForTest`。 + +- [ ] **Step 1: 写接口与 `Store` 结构体(logstore.go)** + +```go +// Package logstore 提供日志/分析存储抽象:上层只面向本包接口, +// 禁止直接 import internal/repository/analytics 或触碰 db.ChConn/db.ChDB。 +package logstore + +import ( + "context" + "errors" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" +) + +// ErrMigrating 表示日志数据库正在迁移,当前禁止写入。 +var ErrMigrating = errors.New("log database is migrating, writes are disabled") + +// AccessLogStore 节点访问日志(of_node_access_logs)。 +type AccessLogStore interface { + // InsertBatch 为写入入口:冻结检查 + 经 hook 入队(异步),不直接落库。 + InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error + // BatchInsertNodeAccessLogs 为 batchwriter flush 目标:直接批量写入当前存储。 + BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error + + List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) + Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) + RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) + BucketAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) + CountBuckets(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) (int64, error) + BucketDimensions(ctx context.Context, filter model.OpenFlareAccessLogQuery, column string, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketDimension, error) + IPAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery, exactRemoteAddr bool) ([]analyticsmodel.NodeAccessLogIPAggregate, error) + IPSummaries(ctx context.Context, filter model.OpenFlareAccessLogQuery, recentSince time.Time) ([]analyticsmodel.NodeAccessLogIPSummary, error) + CountIPSummaries(ctx context.Context, filter model.OpenFlareAccessLogQuery) (int64, error) + WAFIPAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery) ([]analyticsmodel.NodeAccessLogWAFIPAggregate, error) + IPTrend(ctx context.Context, filter model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogIPTrend, error) + TrafficSummary(ctx context.Context, filter model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) + ValueCounts(ctx context.Context, filter model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) + NodeAggregates(ctx context.Context, filter model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) + DeleteAll(ctx context.Context) (int64, error) + DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) + DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) + // ListForMigration 按 id 升序分页读取(迁移复制用)。 + ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeAccessLog, error) +} + +// ObservabilityStore 可观测 4 表(metric snapshots / edge health / frps / frpc)。 +type ObservabilityStore interface { + InsertMetricSnapshot(ctx context.Context, record *model.OpenFlareMetricSnapshot) error + ListMetricSnapshots(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareMetricSnapshot, error) + DeleteAllMetricSnapshots(ctx context.Context) (int64, error) + DeleteMetricSnapshotsBefore(ctx context.Context, cutoff time.Time) (int64, error) + BatchInsertNodeMetricSnapshots(ctx context.Context, rows []analyticsmodel.NodeMetricSnapshot) error + + InsertEdgeHealth(ctx context.Context, record *model.OpenFlareEdgeHealth) error + ListEdgeHealth(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareEdgeHealth, error) + DeleteAllEdgeHealth(ctx context.Context) (int64, error) + DeleteEdgeHealthBefore(ctx context.Context, cutoff time.Time) (int64, error) + BatchInsertNodeEdgeHealth(ctx context.Context, rows []analyticsmodel.NodeEdgeHealth) error + + InsertNodeObservationFrps(ctx context.Context, record *model.OpenFlareNodeObservationFrps) error + ListNodeObservationFrps(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareNodeObservationFrps, error) + DeleteAllNodeObservationFrps(ctx context.Context) (int64, error) + DeleteNodeObservationFrpsBefore(ctx context.Context, cutoff time.Time) (int64, error) + BatchInsertNodeObsFrps(ctx context.Context, rows []analyticsmodel.NodeObsFrps) error + + InsertNodeObservationFrpc(ctx context.Context, record *model.OpenFlareNodeObservationFrpc) error + ListNodeObservationFrpc(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareNodeObservationFrpc, error) + DeleteAllNodeObservationFrpc(ctx context.Context) (int64, error) + DeleteNodeObservationFrpcBefore(ctx context.Context, cutoff time.Time) (int64, error) + BatchInsertNodeObsFrpc(ctx context.Context, rows []analyticsmodel.NodeObsFrpc) error + + // 迁移复制用:按 id 升序分页读取。 + ListMetricSnapshotsForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeMetricSnapshot, error) + ListEdgeHealthForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeEdgeHealth, error) + ListNodeObsFrpsForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeObsFrps, error) + ListNodeObsFrpcForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.NodeObsFrpc, error) +} + +// UserAccessLogStore 用户访问日志(w_user_access_logs)。 +type UserAccessLogStore interface { + BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error + Count(ctx context.Context, filter analyticsmodel.AccessLogFilter) (uint64, error) + List(ctx context.Context, filter analyticsmodel.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) + GetDailyTrend(ctx context.Context, days int) ([]analyticsmodel.DailyTrend, error) + GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsmodel.BrowserShare, error) + GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsmodel.TopUser, error) +} + +// StatusStore 日志库状态(供管理端状态端点)。 +type StatusStore interface { + ActiveDatabase(ctx context.Context) (string, error) + ClickHouseOperationalStats(ctx context.Context) (*analyticsmodel.ClickHouseOperationalStats, error) // 仅 CH 激活时非 nil +} + +// Store 聚合当前生效日志库的全部域存储。 +type Store struct { + AccessLogs AccessLogStore + Observability ObservabilityStore + UserAccessLogs UserAccessLogStore + Status StatusStore +} +``` + +- [ ] **Step 2: 写 provider(provider.go)** + +```go +package logstore + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/Rain-kl/Wavelet/internal/infra/config" + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" +) + +// logDatabaseKey / logMigrationKey 暂用字符串,Task 8 换为 model.ConfigKey*。 +const ( + logDatabaseKey = "log_database" + logMigrationKey = "log_db_migration" +) + +// ConfigReader 读取系统配置字符串值,由 bootstrap 注入(避免 logstore ↔ repository 循环依赖)。 +type ConfigReader func(ctx context.Context, key string) (string, error) + +var ( + configReader ConfigReader + + storeMu sync.RWMutex + active *Store + activeDB string +) + +// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。 +func SetConfigReader(fn ConfigReader) { configReader = fn } + +func getConfig(ctx context.Context, key string) (string, error) { + if configReader == nil { + return "", errors.New("logstore: config reader not wired") + } + return configReader(ctx, key) +} + +// Active 返回当前生效的日志库 Store。按 log_database 系统配置惰性解析并缓存, +// 配置更新(含迁移任务翻转)后自动重建。 +func Active(ctx context.Context) (*Store, error) { + current, err := resolveDatabase(ctx) + if err != nil { + return nil, err + } + storeMu.RLock() + if active != nil && activeDB == current { + s := active + storeMu.RUnlock() + return s, nil + } + storeMu.RUnlock() + + storeMu.Lock() + defer storeMu.Unlock() + if active != nil && activeDB == current { + return active, nil + } + s, err := buildStore(ctx, current) + if err != nil { + return nil, err + } + active = s + activeDB = current + return s, nil +} + +// Migrating 返回日志库是否处于迁移冻结状态。 +func Migrating(ctx context.Context) bool { + v, err := getConfig(ctx, logMigrationKey) + if err != nil { + return false + } + return v == "migrating" +} + +// Init 在 bootstrap 阶段预热一次激活 store(幂等,失败不致命——首次使用时再解析)。 +func Init(ctx context.Context) { + _, _ = Active(ctx) +} + +// ResetForTest 清空缓存的激活 store 与 reader,便于测试注入。 +func ResetForTest() { + storeMu.Lock() + active = nil + activeDB = "" + storeMu.Unlock() +} + +// Build 直接按目标构造 store(迁移任务复制到目标库时使用,不经 Active 缓存)。 +func Build(ctx context.Context, database string) (*Store, error) { + return buildStore(ctx, database) +} + +// ActiveDatabase 返回当前日志主库名(postgres|sqlite|clickhouse)。 +func ActiveDatabase(ctx context.Context) (string, error) { + return resolveDatabase(ctx) +} + +// resolveDatabase 读取 log_database,缺失时按启动规则 seed 并返回。 +func resolveDatabase(ctx context.Context) (string, error) { + v, err := getConfig(ctx, logDatabaseKey) + if err == nil && v != "" { + return v, nil + } + // 首次启动 seed:CH 启用 → clickhouse;否则随主库。 + defaultDB := "sqlite" + if config.Config.Database.Enabled { + defaultDB = "postgres" + } + if config.Config.ClickHouse.Enabled { + defaultDB = "clickhouse" + } + return defaultDB, nil +} + +// buildStore 按目标构造实现(Task 3-5 提供构造函数)。 +func buildStore(ctx context.Context, database string) (*Store, error) { + switch database { + case "clickhouse": + ch := newClickHouseStore() + return &Store{AccessLogs: ch, Observability: ch, UserAccessLogs: ch, Status: ch}, nil + case "postgres", "sqlite": + g := newGormStore(db.DB(ctx)) + return &Store{AccessLogs: g, Observability: g, UserAccessLogs: g, Status: g}, nil + default: + return nil, fmt.Errorf("unsupported log database: %s", database) + } +} +``` + +(`db.DB(ctx)` 返回 `*gorm.DB`,见 `internal/infra/persistence/postgres.go`;`newGormStore`/`newClickHouseStore` 在 Task 3-5 实现。) + +- [ ] **Step 3: 写 provider 单测(provider_test.go)**——用 `SetStoreForTest` 注入 fake 验证 `Active` 缓存与切换: + +```go +package logstore + +import ( + "context" + "testing" +) + +func TestMigratingReadsConfig(t *testing.T) { + ResetForTest() + SetConfigReader(func(_ context.Context, key string) (string, error) { + if key == logMigrationKey { + return "migrating", nil + } + return "", nil + }) + if !Migrating(context.Background()) { + t.Fatal("Migrating() = false, want true when key=migrating") + } + SetConfigReader(func(_ context.Context, key string) (string, error) { + return "", nil + }) + if Migrating(context.Background()) { + t.Fatal("Migrating() = true, want false when key empty") + } +} + +func TestResolveDatabaseDefaults(t *testing.T) { + ResetForTest() + // 配置缺失时按主库规则 seed(config.Config 默认值由既有测试基建决定)。 + got, err := resolveDatabase(context.Background()) + if err != nil { + t.Fatalf("resolveDatabase: %v", err) + } + if got != "postgres" && got != "sqlite" && got != "clickhouse" { + t.Fatalf("unexpected default log database: %s", got) + } +} +``` + +- [ ] **Step 4: 运行测试** `go test ./internal/repository/logstore/` 期望 PASS。 +- [ ] **Step 5: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): add log store interfaces and provider skeleton"` + +### Task 3: GORM 实现——节点访问日志(AccessLogStore) + +**Files:** +- Create: `internal/repository/logstore/postgres_store.go` +- Create: `internal/repository/logstore/dialect_postgres.go` +- Create: `internal/repository/logstore/dialect_sqlite.go` +- Create: `internal/repository/logstore/postgres_store_test.go` + +**Interfaces:** +- Consumes: `db.DB(ctx)`、`analyticsmodel.*`、`model.OpenFlareAccessLog*`、`hooks` 注册表(Task 5 提供 `QueueNodeAccessLogs`)。 +- Produces: `newGormStore(db *gorm.DB) *gormLogStore`(实现 `AccessLogStore`/`ObservabilityStore`/`UserAccessLogStore`)。 + +- [ ] **Step 1: 写 dialect 小文件** + +`dialect_postgres.go`: +```go +package logstore + +import "gorm.io/gorm" + +// timeBucketSQL 返回 PG 时间分桶表达式(epoch 秒 -> 分桶起点)。 +func timeBucketSQL(column string, bucketSeconds int64) string { + return "to_timestamp(floor(extract(epoch from " + column + ")/" + itoa(bucketSeconds) + ")*" + itoa(bucketSeconds) + ")" +} + +// gormDBForWrite 返回写句柄(PG/SQLite 相同)。 +func gormDBForWrite(db *gorm.DB) *gorm.DB { return db } +``` + +`dialect_sqlite.go`: +```go +package logstore + +import ( + "strconv" + + "gorm.io/gorm" +) + +func timeBucketSQL(column string, bucketSeconds int64) string { + return "(floor(unixepoch(" + column + ")/" + strconv.FormatInt(bucketSeconds, 10) + ")*" + strconv.FormatInt(bucketSeconds, 10) + ")" +} + +func gormDBForWrite(db *gorm.DB) *gorm.DB { return db } +``` + +> 若需要精确到毫秒的分桶(现有 CH 用秒级分桶即可),以现有 `node_access_log_stats.go` 的 bucket 语义为准,两种方言输出同一语义。 + +- [ ] **Step 2: 写 `postgres_store.go`(节点访问日志部分)** + +```go +package logstore + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "gorm.io/gorm" +) + +// gormLogStore 是 PG/SQLite 共用的 GORM 日志存储实现。 +type gormLogStore struct { + db *gorm.DB +} + +func newGormStore(db *gorm.DB) *gormLogStore { return &gormLogStore{db: db} } + +// ensureWritable 冻结期拒绝写入。 +func (s *gormLogStore) ensureWritable(ctx context.Context) error { + if Migrating(ctx) { + return ErrMigrating + } + return nil +} + +// InsertBatch 节点访问日志写入入口:冻结检查后经 hook 入队(异步),与现状一致。 +func (s *gormLogStore) InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error { + if err := s.ensureWritable(ctx); err != nil { + return err + } + rows := make([]analyticsmodel.NodeAccessLog, 0, len(records)) + for _, r := range records { + if r == nil { + continue + } + rows = append(rows, toAnalyticsNodeAccessLog(r)) + } + if h := currentAccessLogHooks().QueueNodeAccessLogs; h != nil { + h(rows) + } + return nil +} + +// BatchInsertNodeAccessLogs 是 batchwriter flush 目标:GORM 分批落库。 +func (s *gormLogStore) BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { + if len(rows) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error +} + +func (s *gormLogStore) List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { + f := toNodeAccessLogFilter(query) + var rows []analyticsmodel.NodeAccessLog + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) + if f.Since.IsZero() == false { + q = q.Where("logged_at >= ?", f.Since) + } + if f.Until.IsZero() == false { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.RemoteAddr != "" { + q = q.Where("remote_addr = ?", f.RemoteAddr) + } + if len(f.Hosts) > 0 { + q = q.Where("host IN ?", f.Hosts) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + if f.Path != "" { + q = q.Where("path = ?", f.Path) + } + order := "logged_at DESC, id DESC" + if f.SortOrder == "asc" { + order = "logged_at ASC, id ASC" + } + if err := q.Order(order).Limit(limitOr(f.PageSize, 100)).Offset(offsetOf(f.Page, f.PageSize)).Find(&rows).Error; err != nil { + return nil, err + } + return fromAnalyticsNodeAccessLogs(rows), nil +} + +func (s *gormLogStore) Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) { + f := toNodeAccessLogFilter(query) + var total, uniqIP, bytesSent int64 + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) + if !f.Since.IsZero() { + q = q.Where("logged_at >= ?", f.Since) + } + if !f.Until.IsZero() { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.RemoteAddr != "" { + q = q.Where("remote_addr = ?", f.RemoteAddr) + } + if len(f.Hosts) > 0 { + q = q.Where("host IN ?", f.Hosts) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + if f.Path != "" { + q = q.Where("path = ?", f.Path) + } + if err := q.Count(&total).Error; err != nil { + return 0, 0, 0, err + } + if err := q.Distinct("remote_addr").Count(&uniqIP).Error; err != nil { + return 0, 0, 0, err + } + if err := q.Select("COALESCE(SUM(bytes_sent),0)").Scan(&bytesSent).Error; err != nil { + return 0, 0, 0, err + } + return total, uniqIP, bytesSent, nil +} + +func (s *gormLogStore) TrafficSummary(ctx context.Context, query model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) { + f := toNodeAccessLogFilter(query) + var out struct { + RequestCount int64 + ErrorCount int64 + UniqueIPCount int64 + BytesSent int64 + RequestLength int64 + NodeCount int64 + } + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}) + if !f.Since.IsZero() { + q = q.Where("logged_at >= ?", f.Since) + } + if !f.Until.IsZero() { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + err := q.Select(` + COUNT(*) AS request_count, + COUNT(*) FILTER (WHERE status_code >= 500) AS error_count, + COUNT(DISTINCT remote_addr) AS unique_ip_count, + COALESCE(SUM(bytes_sent),0) AS bytes_sent, + COALESCE(SUM(request_length),0) AS request_length, + COUNT(DISTINCT node_id) AS node_count`).Scan(&out).Error + if err != nil { + return model.OpenFlareAccessLogTrafficSummary{}, err + } + return model.OpenFlareAccessLogTrafficSummary{ + RequestCount: out.RequestCount, + ErrorCount: out.ErrorCount, + UniqueIPCount: out.UniqueIPCount, + BytesSent: out.BytesSent, + RequestLength: out.RequestLength, + NodeCount: out.NodeCount, + }, nil +} + +func (s *gormLogStore) ValueCounts(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) { + col, ok := nodeAccessLogValueColumn(column) + if !ok { + return nil, fmt.Errorf("unsupported value count column: %s", column) + } + f := toNodeAccessLogFilter(query) + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). + Select(col+" AS value, COUNT(*) AS count") + if !f.Since.IsZero() { + q = q.Where("logged_at >= ?", f.Since) + } + if !f.Until.IsZero() { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + type row struct { + Value string + Count int64 + } + var rows []row + if err := q.Group(col).Order("count DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]model.OpenFlareAccessLogValueCount, len(rows)) + for i, r := range rows { + out[i] = model.OpenFlareAccessLogValueCount{Value: r.Value, Count: r.Count} + } + return out, nil +} + +func (s *gormLogStore) NodeAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) { + f := toNodeAccessLogFilter(query) + type row struct { + NodeID string + RequestCount int64 + ErrorCount int64 + UniqueIPCount int64 + } + var rows []row + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). + Select("node_id, COUNT(*) AS request_count, COUNT(*) FILTER (WHERE status_code >= 500) AS error_count, COUNT(DISTINCT remote_addr) AS unique_ip_count") + if !f.Since.IsZero() { + q = q.Where("logged_at >= ?", f.Since) + } + if !f.Until.IsZero() { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + if err := q.Group("node_id").Order("request_count DESC").Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]model.OpenFlareAccessLogNodeAggregate, len(rows)) + for i, r := range rows { + out[i] = model.OpenFlareAccessLogNodeAggregate{NodeID: r.NodeID, RequestCount: r.RequestCount, ErrorCount: r.ErrorCount, UniqueIPCount: r.UniqueIPCount} + } + return out, nil +} + +func (s *gormLogStore) RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) { + type row struct { + Region string + Count int64 + } + var rows []row + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). + Select("region, COUNT(*) AS count"). + Where("node_id = ? AND region <> '' AND logged_at >= ?", nodeID, since) + if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]*model.OpenFlareAccessLogRegionCount, len(rows)) + for i, r := range rows { + out[i] = &model.OpenFlareAccessLogRegionCount{Region: r.Region, Count: r.Count} + } + return out, nil +} + +func (s *gormLogStore) BucketAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) { + f := toNodeAccessLogFilter(query) + expr := timeBucketSQL("logged_at", bucketSeconds) + type row struct { + Bucket int64 + RequestCount int64 + ErrorCount int64 + } + var rows []row + q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}). + Select(expr+" AS bucket, COUNT(*) AS request_count, COUNT(*) FILTER (WHERE status_code >= 500) AS error_count") + if !f.Since.IsZero() { + q = q.Where("logged_at >= ?", f.Since) + } + if !f.Until.IsZero() { + q = q.Where("logged_at <= ?", f.Until) + } + if f.NodeID != "" { + q = q.Where("node_id = ?", f.NodeID) + } + if f.Host != "" { + q = q.Where("host = ?", f.Host) + } + if err := q.Group(expr).Order("bucket ASC").Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]analyticsmodel.NodeAccessLogBucketAggregate, len(rows)) + for i, r := range rows { + out[i] = analyticsmodel.NodeAccessLogBucketAggregate{Bucket: r.Bucket, RequestCount: r.RequestCount, ErrorCount: r.ErrorCount} + } + return out, nil +} + +func (s *gormLogStore) DeleteAll(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeAccessLog{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("logged_at < ?", cutoff).Delete(&analyticsmodel.NodeAccessLog{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("node_id = ? AND logged_at < ?", nodeID, before).Delete(&analyticsmodel.NodeAccessLog{}) + return res.RowsAffected, res.Error +} +``` + +- [ ] **Step 2b: 补齐 AccessLogStore 剩余聚合方法(必须全部实现 + 编译期断言)** + +`gormLogStore` 必须实现 `AccessLogStore` 的**全部 20 个方法**(当前 Step 1 只含 13 个)。补齐:`CountBuckets`、`BucketDimensions`、`IPAggregates`、`IPSummaries`、`CountIPSummaries`、`WAFIPAggregates`、`IPTrend`。语义以 `internal/repository/analytics/node_access_log_stats.go`(及 `node_access_log.go` 中对应函数)为准,用 GORM/方言 SQL 等价实现: + +- 时间分桶统一返回 epoch 秒整型:PG `(floor(extract(epoch from )/)*)::bigint`;SQLite `(floor(unixepoch()/)*)`(修正 `timeBucketSQL`,保证 PG/SQLite 输出同为 int64 epoch,与 `BucketEpoch` 扫描类型一致)。 +- `CountBuckets`:`SELECT COUNT(*) FROM (SELECT 1 FROM t WHERE ... GROUP BY bucket) x`。 +- `BucketDimensions`:`GROUP BY bucket, ` 返回维度计数。 +- `IPAggregates`:按 remote_addr(或精确 remote_addr)聚合 request_count / error_count / unique host 等,字段对照 `NodeAccessLogIPAggregate`。 +- `IPSummaries` / `CountIPSummaries`:按 IP 汇总近窗口(含最近活跃时间),字段对照 `NodeAccessLogIPSummary`。 +- `WAFIPAggregates`:按 IP 聚合状态码分布,字段对照 `NodeAccessLogWAFIPAggregate`。 +- `IPTrend`:按 IP × 时间桶聚合,字段对照 `NodeAccessLogIPTrend`。 +- **过滤语义对齐 CH**(`node_access_log_filter.go`):remote_addr/host/path 用 `LIKE trim(value)+'%'` 前缀匹配;hosts 用 `lower(trim(host)) IN (...)`;until 用开区间 `<`;node_id 先 trim。 +- 文件底部加编译期断言:`var _ AccessLogStore = (*gormLogStore)(nil)`。 +- 测试:`postgres_store_test.go` 至少覆盖 `CountBuckets`/`IPTrend`(sqlite 内存库写入若干行后断言分桶数量与趋势),其余方法以编译期断言 + 既有语义测试兜底。 + +- [ ] **Step 3: 写 helper(postgres_store.go 同文件底部)** + +```go +func limitOr(v, def int) int { + if v <= 0 { + return def + } + return v +} + +func offsetOf(page, pageSize int) int { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + return (page - 1) * pageSize +} + +func nodeAccessLogValueColumn(column string) (string, bool) { + switch column { + case "remote_addr": + return "remote_addr", true + case "host": + return "host", true + case "path": + return "path", true + case "region": + return "region", true + case "status_code": + return "status_code", true + case "user_agent": + return "user_agent", true + case "cache_status": + return "cache_status", true + } + return "", false +} +``` + +> `toAnalyticsNodeAccessLog`/`fromAnalyticsNodeAccessLogs`/`toNodeAccessLogFilter` 从 `internal/repository/openflare_access_log_store.go` 复制(含 math 边界保护逻辑);Task 6 删除旧文件后这些 helper 不再冲突。 + +- [ ] **Step 4: 写单测(postgres_store_test.go,sqlite 内存库 + AutoMigrate)** + +```go +package logstore + +import ( + "context" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" +) + +func newTestGormStore(t *testing.T) *gormLogStore { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&analyticsmodel.NodeAccessLog{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + return newGormStore(db) +} + +func TestGormBatchInsertAndCount(t *testing.T) { + ResetForTest() + SetConfigReader(func(_ context.Context, _ string) (string, error) { return "", nil }) + s := newTestGormStore(t) + now := time.Now() + rows := []analyticsmodel.NodeAccessLog{ + {ID: 1, NodeID: "n1", LoggedAt: now, RemoteAddr: "1.1.1.1", StatusCode: 200, BytesSent: 100}, + {ID: 2, NodeID: "n1", LoggedAt: now, RemoteAddr: "2.2.2.2", StatusCode: 500, BytesSent: 200}, + } + if err := s.BatchInsertNodeAccessLogs(context.Background(), rows); err != nil { + t.Fatalf("insert: %v", err) + } + total, uniqIP, bytesSent, err := s.Count(context.Background(), model.OpenFlareAccessLogQuery{NodeID: "n1"}) + if err != nil { + t.Fatalf("count: %v", err) + } + if total != 2 || uniqIP != 2 || bytesSent != 300 { + t.Fatalf("count got total=%d uniq=%d bytes=%d", total, uniqIP, bytesSent) + } +} +``` + +(`nodeQuery` 返回 `model.OpenFlareAccessLogQuery{NodeID: "n1"}`;`InsertBatch` 冻结与 hook 测试放 Task 6。) + +- [ ] **Step 5: 运行测试** `go test ./internal/repository/logstore/` 期望 PASS。 +- [ ] **Step 6: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): GORM node access log store"` + +### Task 4: GORM 实现——可观测 4 表 + 用户访问日志 + +**Files:** +- Modify: `internal/repository/logstore/postgres_store.go`(追加方法) +- Modify: `internal/repository/logstore/postgres_store_test.go` + +**Interfaces:** +- Consumes: `model.OpenFlareMetricSnapshot`/`OpenFlareEdgeHealth`/`OpenFlareNodeObservationFrps`/`OpenFlareNodeObservationFrpc`、`analyticsmodel.NodeMetricSnapshot` 等、`currentObservabilityHooks()`(Task 5)。 +- Produces: `gormLogStore` 完整实现 `ObservabilityStore` 与 `UserAccessLogStore`。 + +- [ ] **Step 1: 可观测写入入口 + flush + 查询(追加到 postgres_store.go)** + +```go +// ---- ObservabilityStore ---- + +func (s *gormLogStore) InsertMetricSnapshot(ctx context.Context, record *model.OpenFlareMetricSnapshot) error { + if record == nil { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + if h := currentObservabilityHooks().QueueMetricSnapshot; h != nil { + h(toAnalyticsNodeMetricSnapshot(record)) + } + return nil +} + +func (s *gormLogStore) ListMetricSnapshots(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareMetricSnapshot, error) { + var rows []analyticsmodel.NodeMetricSnapshot + q := s.db.WithContext(ctx).Where("node_id = ? AND captured_at >= ?", nodeID, since).Order("captured_at DESC, id DESC") + if err := q.Limit(limitOr(limit, 100)).Find(&rows).Error; err != nil { + return nil, err + } + return fromAnalyticsNodeMetricSnapshots(rows), nil +} + +func (s *gormLogStore) DeleteAllMetricSnapshots(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeMetricSnapshot{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) DeleteMetricSnapshotsBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("captured_at < ?", cutoff).Delete(&analyticsmodel.NodeMetricSnapshot{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) BatchInsertNodeMetricSnapshots(ctx context.Context, rows []analyticsmodel.NodeMetricSnapshot) error { + if len(rows) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error +} + +// InsertEdgeHealth 等 8 个 entry/list/delete + 3 个 flush 全部与 metric snapshots 同构。 +// 完整模板(以 edge health 为例): + +func (s *gormLogStore) InsertEdgeHealth(ctx context.Context, record *model.OpenFlareEdgeHealth) error { + if record == nil { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + if h := currentObservabilityHooks().QueueEdgeHealth; h != nil { + h(toAnalyticsNodeEdgeHealth(record)) + } + return nil +} + +func (s *gormLogStore) ListEdgeHealth(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareEdgeHealth, error) { + var rows []analyticsmodel.NodeEdgeHealth + if err := s.db.WithContext(ctx).Where("node_id = ? AND captured_at >= ?", nodeID, since). + Order("captured_at DESC, id DESC").Limit(limitOr(limit, 100)).Find(&rows).Error; err != nil { + return nil, err + } + return fromAnalyticsNodeEdgeHealths(rows), nil +} + +func (s *gormLogStore) DeleteAllEdgeHealth(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.NodeEdgeHealth{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) DeleteEdgeHealthBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("captured_at < ?", cutoff).Delete(&analyticsmodel.NodeEdgeHealth{}) + return res.RowsAffected, res.Error +} + +func (s *gormLogStore) BatchInsertNodeEdgeHealth(ctx context.Context, rows []analyticsmodel.NodeEdgeHealth) error { + if len(rows) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + return s.db.WithContext(ctx).CreateInBatches(rows, 500).Error +} + +// FRPS/FRPC 两组按同一模板,替换映射如下: +// FRPS: model.OpenFlareNodeObservationFrps ↔ analyticsmodel.NodeObsFrps;hook=QueueNodeObsFrps;转换 toAnalyticsNodeObsFrps +// FRPC: model.OpenFlareNodeObservationFrpc ↔ analyticsmodel.NodeObsFrpc;hook=QueueNodeObsFrpc;转换 toAnalyticsNodeObsFrpc +// list 列名统一 captured_at;delete 统一 captured_at < cutoff。 +// 转换函数(toAnalyticsNodeEdgeHealth/fromAnalyticsNodeEdgeHealths/toAnalyticsNodeObsFrps/toAnalyticsNodeObsFrpc) +// 从旧 openflare_observability_store.go 复制。 +``` + +> 逐方法补齐(8 个 entry/list/delete + 3 个 flush),表名/模型:`analyticsmodel.NodeEdgeHealth`、`analyticsmodel.NodeObsFrps`、`analyticsmodel.NodeObsFrpc`;model 侧 `OpenFlareEdgeHealth`、`OpenFlareNodeObservationFrps`、`OpenFlareNodeObservationFrpc`。`toAnalyticsNodeEdgeHealth` 等转换函数从旧 `openflare_observability_store.go` 复制。 + +- [ ] **Step 2: 用户访问日志(追加)** + +```go +// ---- UserAccessLogStore ---- + +func (s *gormLogStore) BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error { + if len(logs) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + return s.db.WithContext(ctx).CreateInBatches(logs, 500).Error +} + +func (s *gormLogStore) Count(ctx context.Context, filter analyticsmodel.AccessLogFilter) (uint64, error) { + var total int64 + q := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}) + if filter.UserID != 0 { + q = q.Where("user_id = ?", filter.UserID) + } + if filter.Path != "" { + q = q.Where("path = ?", filter.Path) + } + if filter.Method != "" { + q = q.Where("method = ?", filter.Method) + } + if filter.IP != "" { + q = q.Where("ip = ?", filter.IP) + } + if filter.Status != 0 { + q = q.Where("status = ?", filter.Status) + } + if !filter.Since.IsZero() { + q = q.Where("created_at >= ?", filter.Since) + } + if !filter.Until.IsZero() { + q = q.Where("created_at <= ?", filter.Until) + } + if err := q.Count(&total).Error; err != nil { + return 0, err + } + return uint64(total), nil +} + +func (s *gormLogStore) List(ctx context.Context, filter analyticsmodel.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) { + total, err := s.Count(ctx, filter) + if err != nil { + return nil, 0, err + } + if total == 0 { + return []analyticsmodel.UserAccessLog{}, 0, nil + } + var rows []analyticsmodel.UserAccessLog + q := s.db.WithContext(ctx).Where(buildUserAccessLogWhere(filter)).Order("created_at DESC, id DESC") + if err := q.Limit(pageSize).Offset(offsetOf(page, pageSize)).Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (s *gormLogStore) GetDailyTrend(ctx context.Context, days int) ([]analyticsmodel.DailyTrend, error) { + if days <= 0 { + days = 7 + } + // 镜像 CH access_log_stats.go:起点 = (days-1) 天前当日零点;必须返回恰好 days 个日历日并补零。 + start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour) + type row struct { + Date string + Cnt uint64 + } + var rows []row + err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). + Select(dailyTrendDateSQL()+" AS date, COUNT(*) AS cnt"). + Where("created_at >= ?", start). + Group("date").Order("date ASC").Scan(&rows).Error + if err != nil { + return nil, err + } + counts := make(map[string]uint64, len(rows)) + for _, r := range rows { + counts[r.Date] = r.Cnt + } + out := make([]analyticsmodel.DailyTrend, 0, days) + for i := 0; i < days; i++ { + d := start.AddDate(0, 0, i).Format("2006-01-02") + out = append(out, analyticsmodel.DailyTrend{Date: d, Cnt: counts[d]}) + } + return out, nil +} + +func (s *gormLogStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsmodel.BrowserShare, error) { + return s.userAgentGroupCount(ctx, startTime, "browser") +} + +func (s *gormLogStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsmodel.TopUser, error) { + type row struct { + UserID uint64 + Cnt uint64 + } + var rows []row + err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). + Select("user_id, COUNT(*) AS cnt"). + Where("user_id <> 0 AND created_at >= ?", startTime). + Group("user_id").Order("cnt DESC").Limit(limitOr(limit, 10)).Scan(&rows).Error + if err != nil { + return nil, err + } + out := make([]analyticsmodel.TopUser, len(rows)) + for i, r := range rows { + out[i] = analyticsmodel.TopUser{UserID: r.UserID, Cnt: r.Cnt} + } + return out, nil +} +``` + +> `buildUserAccessLogWhere` 与 `Count` 内联条件一致。**AccessLogFilter 使用单一权威字段集(Task 1 迁入的 CH 原字段)**:`UserIDs []uint64`、`Path`、`StartTime`/`EndTime *time.Time`。GORM 的 Count/List 必须用该字段集并镜像 CH 过滤语义(`user_id IN ?`、`path LIKE '%..%'`、`StartTime >=`、`EndTime <`)——**禁止在 AccessLogFilter 上追加仅 GORM 使用的字段**(会造成双字段集静默分叉)。`GetDailyTrend` 的日期格式化拆到 dialect 文件:`dailyTrendDateSQL()` 返回 PG `to_char(created_at,'YYYY-MM-DD')` / SQLite `strftime('%Y-%m-%d', created_at)`。`userAgentGroupCount` 用现有 `analyticsrepo.ParseBrowserName` 语义改为 SQL 侧 `CASE` 或复用 helper——实现时对照 `access_log_stats.go` 的浏览器判定逻辑,保持统计口径一致。 + +- [ ] **Step 3: 单测追加**——`TestGormUserAccessLogCountList`、`TestGormObservabilityInsertList`(sqlite AutoMigrate 对应模型,断言写入/查询/删除)。 +- [ ] **Step 4: 运行** `go test ./internal/repository/logstore/` PASS。 +- [ ] **Step 5: 提交** `git add internal/repository/logstore/ && git commit -m "feat(logstore): GORM observability and user access log store"` + +### Task 5: CH 包装实现 + hooks 注册表迁入 logstore + +**Files:** +- Create: `internal/repository/logstore/clickhouse_store.go` +- Create: `internal/repository/logstore/hooks.go` +- Modify: `internal/repository/openflare_access_log_store.go`、`internal/repository/openflare_observability_store.go`(删除,被吸收) + +**Interfaces:** +- Consumes: `analyticsrepo.*` 全部现成函数、`db.ChConn`/`db.ChDB`。 +- Produces: `newClickHouseStore() *clickhouseLogStore`;`SetAccessLogHooks`/`SetObservabilityHooks`/`currentAccessLogHooks`/`currentObservabilityHooks`。 + +- [ ] **Step 1: hooks.go** + +```go +package logstore + +import ( + "sync" + + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" +) + +// AccessLogHooks 节点访问日志异步入队回调(由 chwriter 装配)。 +type AccessLogHooks struct { + QueueNodeAccessLogs func(logs []analyticsmodel.NodeAccessLog) +} + +// ObservabilityHooks 可观测异步入队回调(由 chwriter 装配)。 +type ObservabilityHooks struct { + QueueMetricSnapshot func(record analyticsmodel.NodeMetricSnapshot) + QueueEdgeHealth func(record analyticsmodel.NodeEdgeHealth) + QueueNodeObsFrps func(record analyticsmodel.NodeObsFrps) + QueueNodeObsFrpc func(record analyticsmodel.NodeObsFrpc) +} + +var ( + hooksMu sync.RWMutex + accessLogHooks AccessLogHooks + observabilityHooks ObservabilityHooks +) + +func SetAccessLogHooks(h AccessLogHooks) { + hooksMu.Lock() + accessLogHooks = h + hooksMu.Unlock() +} + +func SetObservabilityHooks(h ObservabilityHooks) { + hooksMu.Lock() + observabilityHooks = h + hooksMu.Unlock() +} + +func currentAccessLogHooks() AccessLogHooks { + hooksMu.RLock() + defer hooksMu.RUnlock() + return accessLogHooks +} + +func currentObservabilityHooks() ObservabilityHooks { + hooksMu.RLock() + defer hooksMu.RUnlock() + return observabilityHooks +} +``` + +> 旧 `AccessLogInsertHooks`/`ObservabilityInsertHooks` 及 `SetAccessLogInsertHooks` 等在 repository 包删除,chwriter 改为调用 `logstore.SetAccessLogHooks`(Task 9)。 + +- [ ] **Step 2: clickhouse_store.go——逐方法委托 analyticsrepo(仅列代表,全部方法照此)** + +```go +package logstore + +import ( + "context" + "errors" + "time" + + db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" +) + +type clickhouseLogStore struct{} + +func newClickHouseStore() *clickhouseLogStore { return &clickhouseLogStore{} } + +func chConnErr() error { + if !db.ChConnReady() { + return errors.New("clickhouse connection is not initialized") + } + return nil +} + +// ---- AccessLogStore ---- + +func (s *clickhouseLogStore) InsertBatch(ctx context.Context, records []*model.OpenFlareAccessLog) error { + if err := s.ensureWritable(ctx); err != nil { + return err + } + rows := make([]analyticsmodel.NodeAccessLog, 0, len(records)) + for _, r := range records { + if r == nil { + continue + } + rows = append(rows, toAnalyticsNodeAccessLog(r)) + } + if h := currentAccessLogHooks().QueueNodeAccessLogs; h != nil { + h(rows) + } + return nil +} + +func (s *clickhouseLogStore) ensureWritable(ctx context.Context) error { + if Migrating(ctx) { + return ErrMigrating + } + return nil +} + +func (s *clickhouseLogStore) BatchInsertNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { + if err := s.ensureWritable(ctx); err != nil { + return err + } + return analyticsrepo.BatchInsertNodeAccessLogs(ctx, rows) +} + +func (s *clickhouseLogStore) List(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { + rows, err := analyticsrepo.ListNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) + if err != nil { + return nil, err + } + return fromAnalyticsNodeAccessLogs(rows), nil +} + +func (s *clickhouseLogStore) Count(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, int64, int64, error) { + return analyticsrepo.CountNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) +} + +func (s *clickhouseLogStore) RegionCounts(ctx context.Context, nodeID string, since time.Time, limit int) ([]*model.OpenFlareAccessLogRegionCount, error) { + rows, err := analyticsrepo.RegionCountsNodeAccessLogs(ctx, nodeID, since, limit) + if err != nil { + return nil, err + } + out := make([]*model.OpenFlareAccessLogRegionCount, len(rows)) + for i, r := range rows { + out[i] = &model.OpenFlareAccessLogRegionCount{Region: r.Region, Count: r.Count} + } + return out, nil +} + +func (s *clickhouseLogStore) BucketAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketAggregate, error) { + return analyticsrepo.BucketAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) +} + +func (s *clickhouseLogStore) CountBuckets(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) (int64, error) { + return analyticsrepo.CountBucketAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) +} + +func (s *clickhouseLogStore) BucketDimensions(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogBucketDimension, error) { + return analyticsrepo.BucketDimensionsNodeAccessLogs(ctx, toNodeAccessLogFilter(query), column, bucketSeconds) +} + +func (s *clickhouseLogStore) IPAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery, exactRemoteAddr bool) ([]analyticsmodel.NodeAccessLogIPAggregate, error) { + return analyticsrepo.IPAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), exactRemoteAddr) +} + +func (s *clickhouseLogStore) IPSummaries(ctx context.Context, query model.OpenFlareAccessLogQuery, recentSince time.Time) ([]analyticsmodel.NodeAccessLogIPSummary, error) { + return analyticsrepo.IPSummariesNodeAccessLogs(ctx, toNodeAccessLogFilter(query), recentSince) +} + +func (s *clickhouseLogStore) CountIPSummaries(ctx context.Context, query model.OpenFlareAccessLogQuery) (int64, error) { + return analyticsrepo.CountIPSummaryNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) +} + +func (s *clickhouseLogStore) WAFIPAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]analyticsmodel.NodeAccessLogWAFIPAggregate, error) { + return analyticsrepo.IPAggregatesForWAFNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) +} + +func (s *clickhouseLogStore) IPTrend(ctx context.Context, query model.OpenFlareAccessLogQuery, bucketSeconds int64) ([]analyticsmodel.NodeAccessLogIPTrend, error) { + return analyticsrepo.IPTrendNodeAccessLogs(ctx, toNodeAccessLogFilter(query), bucketSeconds) +} + +func (s *clickhouseLogStore) TrafficSummary(ctx context.Context, query model.OpenFlareAccessLogQuery) (model.OpenFlareAccessLogTrafficSummary, error) { + row, err := analyticsrepo.TrafficSummaryNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) + if err != nil { + return model.OpenFlareAccessLogTrafficSummary{}, err + } + return model.OpenFlareAccessLogTrafficSummary{ + RequestCount: int64(row.RequestCount), + ErrorCount: int64(row.ErrorCount), + UniqueIPCount: int64(row.UniqueIPCount), + BytesSent: int64(row.BytesSent), + RequestLength: int64(row.RequestLength), + NodeCount: int64(row.NodeCount), + }, nil +} + +func (s *clickhouseLogStore) ValueCounts(ctx context.Context, query model.OpenFlareAccessLogQuery, column string, limit int) ([]model.OpenFlareAccessLogValueCount, error) { + rows, err := analyticsrepo.ValueCountsNodeAccessLogs(ctx, toNodeAccessLogFilter(query), column, limit) + if err != nil { + return nil, err + } + out := make([]model.OpenFlareAccessLogValueCount, len(rows)) + for i, r := range rows { + out[i] = model.OpenFlareAccessLogValueCount{Value: r.Value, Count: int64(r.Count)} + } + return out, nil +} + +func (s *clickhouseLogStore) NodeAggregates(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]model.OpenFlareAccessLogNodeAggregate, error) { + rows, err := analyticsrepo.NodeAggregatesNodeAccessLogs(ctx, toNodeAccessLogFilter(query)) + if err != nil { + return nil, err + } + out := make([]model.OpenFlareAccessLogNodeAggregate, len(rows)) + for i, r := range rows { + out[i] = model.OpenFlareAccessLogNodeAggregate{NodeID: r.NodeID, RequestCount: int64(r.RequestCount), ErrorCount: int64(r.ErrorCount), UniqueIPCount: int64(r.UniqueIPCount)} + } + return out, nil +} + +func (s *clickhouseLogStore) DeleteAll(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + return analyticsrepo.DeleteAllNodeAccessLogs(ctx) +} + +func (s *clickhouseLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + return analyticsrepo.DeleteNodeAccessLogsBefore(ctx, cutoff) +} + +func (s *clickhouseLogStore) DeleteByNodeBefore(ctx context.Context, nodeID string, before time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + return analyticsrepo.DeleteNodeAccessLogsByNodeBefore(ctx, nodeID, before) +} + +// ---- ObservabilityStore(entry=ensureWritable+hook;flush/query/delete 委托 analyticsrepo) +// InsertMetricSnapshot / ListMetricSnapshots / DeleteAllMetricSnapshots / DeleteMetricSnapshotsBefore / BatchInsertNodeMetricSnapshots +// ...(同构,参照旧 clickhouseObservabilityStore 委托) +// ---- UserAccessLogStore +// BatchInsert -> analyticsrepo.BatchInsert +// Count/List -> analyticsrepo.CountAccessLogs / ListAccessLogs +// GetDailyTrend / GetBrowserDistribution / GetTopActiveUsers -> analyticsrepo.GetDailyTrend / GetBrowserDistribution / GetTopActiveUsers +``` + +> 转换函数 `toAnalyticsNodeAccessLog`/`fromAnalyticsNodeAccessLogs`/`toNodeAccessLogFilter`/`toAnalyticsNodeMetricSnapshot` 等集中放 `postgres_store.go` 或本文件共享区域(两个实现共用)。 + +- [ ] **Step 3: 删除旧 store 文件**——删 `internal/repository/openflare_access_log_store.go`、`internal/repository/openflare_observability_store.go`;其中的 memory store 测试替身迁到 `logstore/memory_store_test.go`(保留 `NewMemoryAccessLogStore` 等价物供 repository 测试)。 +- [ ] **Step 4: 编译 + 测试** `go build ./internal/...`;`go test ./internal/repository/...` 修复引用。 +- [ ] **Step 5: 提交** `git add internal/repository/logstore/ internal/repository/ && git commit -m "refactor(logstore): wrap ClickHouse analytics repo behind interface"` + +### Task 6: repository 公开函数改委托 logstore + +**Files:** +- Modify: `internal/repository/openflare_access_log.go`(函数体改为 `logstore.Active(ctx)` 委托) +- Modify: `internal/repository/openflare_observability.go`(同上) + +**Interfaces:** +- Consumes: `logstore.Active`、`logstore.Store` 字段。 +- Produces: 保留原公开函数签名,行为不变(CH 激活时与现状一致)。 + +- [ ] **Step 1: 改写 `openflare_access_log.go` 各函数** + +```go +package repository + +import ( + "context" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/model/analytics" // 若类型别名仍需要 + "github.com/Rain-kl/Wavelet/internal/repository/logstore" +) + +// ListOpenFlareAccessLogs lists access logs matching the query. +func ListOpenFlareAccessLogs(ctx context.Context, query model.OpenFlareAccessLogQuery) ([]*model.OpenFlareAccessLog, error) { + s, err := logstore.Active(ctx) + if err != nil { + return nil, err + } + return s.AccessLogs.List(ctx, query) +} +``` + +对同文件其余函数(`ListOpenFlareAccessLogWAFIPAggregates`、`InsertOpenFlareAccessLogsBatch`、`CountOpenFlareAccessLogs`、`TrafficSummaryOpenFlareAccessLogs`、`RegionCountsOpenFlareAccessLogs`、`BucketAggregates*`、`CountBuckets*`、`BucketDimensions*`、`IPAggregates*`、`IPSummaries*`、`CountIPSummaries*`、`IPTrend*`、`ValueCounts*`、`NodeAggregates*`、`Delete*`)逐一委托到 `s.AccessLogs` 对应方法;`InsertOpenFlareAccessLogsBatch` → `s.AccessLogs.InsertBatch`。**保留行类型别名**(`openFlareAccessLogBucketAggregateRow` 等)供调用方编译。 + +- [ ] **Step 2: 改写 `openflare_observability.go`**——`InsertOpenFlareMetricSnapshot` → `s.Observability.InsertMetricSnapshot`;`ListMetricSnapshots*`/`Delete*` 同理;健康事件(`ReconcileOpenFlareHealthEvents` 等**主库表**逻辑)保持原实现不动。 +- [ ] **Step 3: 编译 + 测试** `go build ./internal/...`、`go test ./internal/repository/...`(旧测试若引用 memory store 替换为 logstore 测试替身)。 +- [ ] **Step 4: 提交** `git add internal/repository/ && git commit -m "refactor(repository): delegate log CRUD to logstore"` + +### Task 7: import-lint 测试(代码级约束验收) + +**Files:** +- Create: `internal/repository/logstore/imports_test.go` + +- [ ] **Step 1: 写测试** + +```go +package logstore + +import ( + "os/exec" + "strings" + "testing" +) + +// forbiddenImports 上层应用禁止直接触碰的底层日志实现。 +var forbiddenImports = []string{ + "github.com/Rain-kl/Wavelet/internal/repository/analytics", +} + +// allowedInfraPersistence 允许 apps 引入的 infra/persistence 子包。 +// batchwriter=批量写入框架;idgen=snowflake ID 生成工具(apps 合法使用,非日志后端访问)。 +var allowedInfraPersistence = []string{ + "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter", + "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen", +} + +func TestAppsMustNotImportLogBackendDirectly(t *testing.T) { + out, err := exec.Command("go", "list", "-deps", "./internal/apps/...").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, line := range strings.Split(string(out), "\n") { + pkg := strings.TrimSpace(line) + if pkg == "" { + continue + } + for _, forbidden := range forbiddenImports { + if pkg == forbidden { + t.Errorf("internal/apps must not import %s", forbidden) + } + } + if strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/internal/infra/persistence/") { + allowed := false + for _, a := range allowedInfraPersistence { + if pkg == a || strings.HasPrefix(pkg, a+"/") { + allowed = true + break + } + } + if !allowed { + t.Errorf("internal/apps must not import infra/persistence subpackage: %s", pkg) + } + } + } +} +``` + +> 说明:`go list -deps` 在测试工作目录执行,先 `t.Chdir` 到仓库根(`../../..`)再运行,避免依赖 `go test` 的临时目录。若 `internal/apps/admin/logs` 等仍 import analyticsrepo,本测试失败——正好驱动 Task 9。 + +- [ ] **Step 2: 运行** `go test ./internal/repository/logstore/ -run TestAppsMustNotImportLogBackendDirectly -v`——预期当前**失败**(列出违规包)。 +- [ ] **Step 3: 暂不提交**——本测试在 apps 改造完成前保持 RED(预期失败列出违规包)。Task 9 完成 apps 改造、本测试转绿后,随 Task 9 一并提交(提交信息:`test(logstore): enforce apps must not import log backend directly`)。 + +### Task 8: 系统配置 key + 启动校验 + key 保护 + +**Files:** +- Modify: `internal/model/system_configs.go`(新增 key 常量) +- Modify: `internal/platform/bootstrap/bootstrap.go`(`Init` 加校验与 seed) +- Modify: `internal/apps/admin/system_config/routers.go`(受保护 key 拒绝修改) +- Modify: `internal/apps/openflare/option/validate.go`(同) +- Create: `internal/platform/bootstrap/bootstrap_test.go`(追加校验测试) + +**Interfaces:** +- Consumes: `config.Config.Database.Enabled`、`config.Config.ClickHouse.Enabled`、`repository.GetSystemConfigByKey`、`repository.UpdateSystemConfigFields`。 +- Produces: `model.ConfigKeyLogDatabase = "log_database"`、`model.ConfigKeyLogDBMigration = "log_db_migration"`、`model.ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres"`、`model.ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite"`、`model.ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse"`。 + +- [ ] **Step 1: 新增 key 常量(system_configs.go)** + +```go +// 日志数据库解耦 +ConfigKeyLogDatabase = "log_database" // 当前日志主库:postgres|sqlite|clickhouse(仅迁移任务写入) +ConfigKeyLogDBMigration = "log_db_migration" // 迁移冻结标记:"migrating" 或空 +ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres" // PostgreSQL 日志保留天数 +ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite" // SQLite 日志保留天数 +ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse" // ClickHouse 日志保留天数 +``` + +- [ ] **Step 2: bootstrap 校验 + seed(bootstrap.go `Init` 内,`initRuntimeOnce.Do` 开头)** + +```go +// validateAndSeedLogDatabase 校验日志主库标记与运行配置的一致性,首次启动 seed。 +func validateAndSeedLogDatabase(ctx context.Context) error { + cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) + if err != nil { + return fmt.Errorf("读取日志主库配置失败: %w", err) + } + current := cfg.Value + if current == "" { + // 首次启动 seed:CH 启用 → clickhouse;否则随主库。 + current = "sqlite" + if config.Config.Database.Enabled { + current = "postgres" + } + if config.Config.ClickHouse.Enabled { + current = "clickhouse" + } + if err := repository.UpdateSystemConfigFields(ctx, &model.SystemConfig{Key: model.ConfigKeyLogDatabase}, map[string]any{"value": current}); err != nil { + return fmt.Errorf("初始化日志主库配置失败: %w", err) + } + return nil + } + switch current { + case "clickhouse": + if !config.Config.ClickHouse.Enabled { + return errors.New("当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse") + } + case "postgres": + if !config.Config.Database.Enabled { + return errors.New("当前日志主库为 PostgreSQL 但 PostgreSQL 未启用(当前为 SQLite 主库)。请运行『切换日志数据库』迁回 SQLite 或启用 PostgreSQL") + } + case "sqlite": + if config.Config.Database.Enabled { + return errors.New("当前日志主库为 SQLite 但当前主库为 PostgreSQL。请运行『切换日志数据库』迁移到 PostgreSQL") + } + default: + return fmt.Errorf("未知的日志主库配置: %s", current) + } + return nil +} +``` + +在 `Init` 的 `initRuntimeOnce.Do` 内最先调用:`if err := validateAndSeedLogDatabase(ctx); err != nil { logger.ErrorF(...); log.Fatalf(...) }`(或按项目既有致命启动错误处理方式)。 + +- [ ] **Step 3: key 保护(admin system-config 更新路径)** + +`internal/apps/admin/system_config/routers.go` 的 `UpdateSystemConfig` 与 `internal/apps/openflare/option/validate.go` 增加: + +```go +// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。 +var protectedConfigKeys = map[string]bool{ + model.ConfigKeyLogDatabase: true, + model.ConfigKeyLogDBMigration: true, +} + +func isProtectedConfigKey(key string) bool { return protectedConfigKeys[key] } +``` + +更新处理:命中保护 key 时返回业务错误(`response.AbortBadRequest(c, "该配置项由系统任务管理,禁止手动修改")`),且不写库。 + +- [ ] **Step 4: 单测**——`bootstrap_test.go` 三态校验(clickhouse 未启用 / postgres 但 sqlite 主库 / sqlite 但 postgres 主库)各自返回明确错误;seed 缺失时写入正确默认值。 +- [ ] **Step 5: 运行** `go test ./internal/platform/bootstrap/ ./internal/model/ ./internal/apps/admin/system_config/` PASS。 +- [ ] **Step 6: 提交** `git add internal/model/system_configs.go internal/platform/bootstrap/ internal/apps/admin/system_config/ internal/apps/openflare/option/ && git commit -m "feat(config): log database marker, boot validation, and protected keys"` + +### Task 9: apps 层改走 logstore(消除 import-lint 违规) + +**Files:** +- Modify: `internal/apps/risk_control/logics.go`、`internal/apps/openflare/chwriter/writer.go` +- Modify: `internal/apps/openflare/tasks/database_cleanup.go`(本任务只改 import;清理合并到 M2) +- Modify: `internal/apps/openflare/observability/access_log_logics.go`(仅解析 helper 保留 analyticsrepo 合法引用则不动;若违规则把 `ParseDeviceType`/`ParseBrowserName`/`ParseOSName` 迁到 `model/analytics` 或 `internal/util`) +- Modify: `internal/apps/admin/logs/routers.go`、`internal/apps/admin/status/clickhouse.go` +- Test: `internal/repository/logstore/imports_test.go`(回归) + +**Interfaces:** +- Consumes: `logstore.Active`、`logstore.Migrating`、`logstore.ErrMigrating`、`logstore.SetAccessLogHooks`/`SetObservabilityHooks`。 + +- [ ] **Step 1: chwriter flush func 改为 logstore** + +`writer.go` 中 5 处 `analyticsrepo.BatchInsertNode*` → `logstore.Active(ctx).Observability/AccessLogs` 对应 flush 方法(或包级 helper): + +```go +func flushNodeAccessLogs(ctx context.Context, rows []analyticsmodel.NodeAccessLog) error { + s, err := logstore.Active(ctx) + if err != nil { + return err + } + return s.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows) +} +``` + +`Init` 内 `if !config.Config.ClickHouse.Enabled { return }` 改为 `if logstore.Active(ctx) == nil ...` 或直接始终初始化 writer(writer flush 走 logstore,激活库由 logstore 决定);`wireModelInsertHooks` 改为调用 `logstore.SetAccessLogHooks`/`logstore.SetObservabilityHooks`。 + +- [ ] **Step 2: risk_control flush 与冻结** + +`logics.go`:flush func 中 `analyticsrepo.BatchInsert` → `logstore.Active(ctx).UserAccessLogs.BatchInsert`;`InitLogWriter` 的 CH 开关条件移除,改为由 logstore 激活库决定(PG/SQLite 也启用该 writer);middleware 入队前: + +```go +if logstore.Migrating(c.Request.Context()) { + logger.WarnF(c.Request.Context(), "[RiskControl] log DB migrating, skip audit log") + return // 不阻断业务请求 +} +``` + +- [ ] **Step 3: admin/logs 改走 logstore** + +`routers.go` 中 `analyticsrepo.ListAccessLogs/CountAccessLogs/GetDailyTrend/GetBrowserDistribution/GetTopActiveUsers` → `logstore.Active(ctx).UserAccessLogs.*`;`config.Config.ClickHouse.Enabled || !db.ChConnReady()` 的守卫改为按激活库判断(`logstore.Active(ctx)` 成功即可用),错误文案从「ClickHouse 存储服务未启用」改为「日志存储未启用」。 + +- [ ] **Step 4: admin/status 端点骨架** + +`clickhouse.go` 改为读取 `logstore.Active` 与激活库名,返回统一结构(M3 Task 16 完成前端与完整字段): + +```go +type LogDatabaseStatus struct { + ActiveDatabase string `json:"active_database"` + Migration string `json:"migration"` // idle | migrating + RetentionDays map[string]int `json:"retention_days"` + AvailableTargets []string `json:"available_targets"` +} +``` + +CH 激活时保留 `GetClickHouseOperationalStats` 与 `collectBatchWriterStats`。 + +- [ ] **Step 5: database_cleanup.go 临时保留 import 但标记 TODO(M2 Task 13 迁移)**——若 import-lint 在 Task 7 已注册,本任务先让 `database_cleanup.go` 改为经 repository 公开函数(其逻辑已走 logstore),并同步 `access_log_logics.go` 解析 helper(迁 `ParseBrowserName` 等为 `model/analytics` 纯函数,analyticsrepo 内部复用)。 +- [ ] **Step 6: 运行 import-lint 回归** `go test ./internal/repository/logstore/ -run TestAppsMustNotImportLogBackendDirectly -v` 期望 **PASS**。 +- [ ] **Step 7: 全量编译** `go build ./internal/...`、`go test ./internal/apps/...` 修复。 +- [ ] **Step 8: 提交** `git add internal/apps/ && git commit -m "refactor(apps): route log reads/writes through logstore"` + +### Task 10: bootstrap 装配 logstore + +**Files:** +- Modify: `internal/platform/bootstrap/bootstrap.go` +- Modify: `internal/cmd/all.go`、`api.go`、`worker.go`、`root.go`(如有必要) + +**Interfaces:** +- Consumes: `logstore.SetConfigReader`、`logstore.Init`。 +- Produces: 运行期 `logstore` 激活 store 可解析。 + +- [ ] **Step 1: 装配 config reader + Init** + +`bootstrap.Init` 的 `initRuntimeOnce.Do` 内、校验之后: + +```go +logstore.SetConfigReader(func(ctx context.Context, key string) (string, error) { + cfg, err := repository.GetSystemConfigByKey(ctx, key) + if err != nil { + return "", err + } + return cfg.Value, nil +}) +logstore.Init(ctx) +``` + +- [ ] **Step 2: worker 进程也需要 Init**——确认 `cmd/worker.go` 与 `cmd/all.go` 都调用 `bootstrap.Init`(现 API 分支启动 writer;worker 迁移任务需能读配置与激活 store,`logstore.Init` 必须在两种进程都执行)。 +- [ ] **Step 3: 测试** `go test ./internal/platform/bootstrap/`;`go build ./cmd/...`。 +- [ ] **Step 4: 提交** `git add internal/platform/bootstrap/ internal/cmd/ && git commit -m "feat(bootstrap): wire logstore config reader and init"` + +--- + +## M2:建表与清理 + +### Task 10b: 小时级聚合读经 logstore(PG 实时计算 / CH 读 rollup 表) + +**Files:** +- Modify: `internal/repository/logstore/logstore.go`(`ObservabilityStore` 增 3 个方法) +- Modify: `internal/repository/logstore/postgres_store.go`(PG 按小时从原始表实时聚合) +- Modify: `internal/repository/logstore/clickhouse_store.go`(委托 analyticsrepo rollup 读 + 现有 raw 兜底逻辑) +- Modify: `internal/repository/openflare_observability.go`(3 个 `ListOpenFlare*HourlySince` 改委托 logstore) +- Modify: `internal/repository/logstore/imports_test.go`(若 `internal/repository` 不再直接 import analyticsrepo,可移除其对 `allowedAnalyticsDelegation` 的豁免) + +**Interfaces:** +- Consumes: Task 3/4 GORM store、Task 5 CH store、`analyticsrepo.ListNodeTrafficHourly`/`ListAccessLogHourly`/`ListNodeMetricHourly` 及 `mergeNodeMetricHourlyPreferRollup`/`listNodeMetricHourlyFromRaw` 语义。 +- Produces: `ObservabilityStore.ListTrafficHourly(ctx, nodeID, since) ([]analyticsmodel.NodeTrafficHourly, error)`、`ListAccessLogHourly(...)`、`ListMetricHourly(...)`。 + +- [ ] **Step 1: 接口加方法**(logstore.go) +- [ ] **Step 2: CH 实现委托 analyticsrepo**(rollup 表 + raw 兜底,逐行复制现有逻辑) +- [ ] **Step 3: PG 实现按小时实时聚合**——`date_trunc('hour', logged_at/captured_at)` 分组(方言 `timeBucketSQL(col, 3600)` 复用),请求/错误/字节数与 CH rollup 同字段;`ListMetricHourly` 用 `avg(cpu)/max-min 计数器` 近似同 CH `mergeNodeMetricHourlyPreferRollup` 口径。 +- [ ] **Step 4: repository 门面 3 个函数改委托 logstore**;若门面不再 import analyticsrepo,收紧 lint 豁免。 +- [ ] **Step 5: 测试**——PG/SQLite 实时聚合与 CH rollup 口径一致性(sqlite 写原始行断言小时桶输出);CH 委托回归。 +- [ ] **Step 6: 提交** `git add internal/repository/ && git commit -m "feat(logstore): hourly rollup reads with PG real-time aggregation"` + +--- +### Task 11: goose 双方言建表迁移(6 张原始日志表) + +**Files:** +- Create: `internal/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql` +- Create: `internal/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql` + +**Interfaces:** +- Consumes: database-migration 技能规则(双方言同版本号、无物理外键、默认值与 Go 零值一致)。 +- Produces: PG/SQLite 各 6 张日志表(`w_user_access_logs`、`of_node_access_logs`、`of_node_metric_snapshots`、`of_node_edge_health`、`of_node_obs_frps`、`of_node_obs_frpc`)。 + +- [ ] **Step 1: PG 建表(含分区)** + +```sql +-- +goose Up +-- 节点访问日志:按月 RANGE 分区,复合主键 (id, logged_at) 满足分区键进唯一索引要求。 +CREATE TABLE of_node_access_logs ( + id BIGINT NOT NULL, + node_id VARCHAR(64) NOT NULL, + logged_at TIMESTAMPTZ NOT NULL, + remote_addr VARCHAR(128) NOT NULL DEFAULT '', + region VARCHAR(128) NOT NULL DEFAULT '', + host VARCHAR(255) NOT NULL DEFAULT '', + path VARCHAR(2048) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + cache_status VARCHAR(64) NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + bytes_sent BIGINT NOT NULL DEFAULT 0, + request_length BIGINT NOT NULL DEFAULT 0, + request_time_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, logged_at) +) PARTITION BY RANGE (logged_at); + +CREATE INDEX idx_of_node_access_logs_node_id ON of_node_access_logs (node_id, logged_at DESC); +CREATE INDEX idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); +CREATE INDEX idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); +CREATE INDEX idx_of_node_access_logs_status_code ON of_node_access_logs (status_code, logged_at DESC); + +-- 用户访问日志:按月分区。 +CREATE TABLE w_user_access_logs ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + path VARCHAR(2048) NOT NULL DEFAULT '', + method VARCHAR(16) NOT NULL DEFAULT '', + ip VARCHAR(128) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + headers TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + latency BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +CREATE INDEX idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC); + +-- 可观测 4 表:普通表 + 索引。 +CREATE TABLE of_node_metric_snapshots ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + captured_at TIMESTAMPTZ NOT NULL, + cpu_usage_percent DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_used_bytes BIGINT NOT NULL DEFAULT 0, + memory_total_bytes BIGINT NOT NULL DEFAULT 0, + storage_used_bytes BIGINT NOT NULL DEFAULT 0, + storage_total_bytes BIGINT NOT NULL DEFAULT 0, + disk_read_bytes BIGINT NOT NULL DEFAULT 0, + disk_write_bytes BIGINT NOT NULL DEFAULT 0, + network_rx_bytes BIGINT NOT NULL DEFAULT 0, + network_tx_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_of_node_metric_snapshots_node ON of_node_metric_snapshots (node_id, captured_at DESC); + +CREATE TABLE of_node_edge_health ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + captured_at TIMESTAMPTZ NOT NULL, + status VARCHAR(64) NOT NULL DEFAULT '', + connections BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_of_node_edge_health_node ON of_node_edge_health (node_id, captured_at DESC); + +CREATE TABLE of_node_obs_frps ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + captured_at TIMESTAMPTZ NOT NULL, + frps_connections INTEGER NOT NULL DEFAULT 0, + frps_proxy_count INTEGER NOT NULL DEFAULT 0, + frps_client_count INTEGER NOT NULL DEFAULT 0, + frps_proxies TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_of_node_obs_frps_node ON of_node_obs_frps (node_id, captured_at DESC); + +CREATE TABLE of_node_obs_frpc ( + id BIGINT NOT NULL PRIMARY KEY, + node_id VARCHAR(64) NOT NULL, + captured_at TIMESTAMPTZ NOT NULL, + tunnel_status VARCHAR(16) NOT NULL DEFAULT '', + connected_relays_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_of_node_obs_frpc_node ON of_node_obs_frpc (node_id, captured_at DESC); + +-- 分区预建:创建未来 3 个月与当前月分区(当月及下两个月)。 +DO $$ +DECLARE + d date; +BEGIN + FOR d IN SELECT generate_series(date_trunc('month', now())::date, (date_trunc('month', now()) + interval '2 months')::date, interval '1 month')::date + LOOP + EXECUTE format('CREATE TABLE IF NOT EXISTS of_node_access_logs_%s PARTITION OF of_node_access_logs FOR VALUES FROM (%L) TO (%L)', + to_char(d, 'YYYYMM'), d, d + interval '1 month'); + EXECUTE format('CREATE TABLE IF NOT EXISTS w_user_access_logs_%s PARTITION OF w_user_access_logs FOR VALUES FROM (%L) TO (%L)', + to_char(d, 'YYYYMM'), d, d + interval '1 month'); + END LOOP; +END $$; + +-- +goose Down +DROP TABLE IF EXISTS w_user_access_logs; +DROP TABLE IF EXISTS of_node_access_logs; +DROP TABLE IF EXISTS of_node_metric_snapshots; +DROP TABLE IF EXISTS of_node_edge_health; +DROP TABLE IF EXISTS of_node_obs_frps; +DROP TABLE IF EXISTS of_node_obs_frpc; +``` + +- [ ] **Step 2: SQLite 建表(普通表,同语义)** + +```sql +-- +goose Up +CREATE TABLE IF NOT EXISTS of_node_access_logs ( + id INTEGER PRIMARY KEY, + node_id TEXT NOT NULL DEFAULT '', + logged_at DATETIME NOT NULL, + remote_addr TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + cache_status TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + request_length INTEGER NOT NULL DEFAULT 0, + request_time_ms INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_node ON of_node_access_logs (node_id, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); +CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); +-- 其余 5 表同构(w_user_access_logs 主键 id;可观测表 id INTEGER PRIMARY KEY + (node_id, captured_at DESC) 索引) + +-- +goose Down +DROP TABLE IF EXISTS of_node_access_logs; +DROP TABLE IF EXISTS w_user_access_logs; +DROP TABLE IF EXISTS of_node_metric_snapshots; +DROP TABLE IF EXISTS of_node_edge_health; +DROP TABLE IF EXISTS of_node_obs_frps; +DROP TABLE IF EXISTS of_node_obs_frpc; +``` + +- [ ] **Step 3: 验证 goose** `go test ./internal/infra/persistence/migrator`(空库 Up 全量)。 +- [ ] **Step 4: 提交** `git add internal/infra/persistence/migrator/goose/ && git commit -m "feat(migrate): create log tables in postgres and sqlite"` + +### Task 12: 保留时间配置 + 旧 key 下线 + +**Files:** +- Create: `internal/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql` +- Create: `internal/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql` +- Modify: `internal/model/system_configs.go`(删除旧 key 常量或标记废弃) +- Modify: `internal/testhelper/test_helper.go`(seed 同步) + +**Interfaces:** +- Produces: 3 个 business 配置(默认 90);旧 `database_auto_cleanup_enabled`/`database_auto_cleanup_retention_days` 从 `system_configs` 删除。 + +- [ ] **Step 1: PG 迁移** + +```sql +-- +goose Up +INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES + ('log_retention_days_postgres', '90', 'business', 0, 'PostgreSQL 日志保留天数(访问日志与可观测统一)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_retention_days_sqlite', '90', 'business', 0, 'SQLite 日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_retention_days_clickhouse','90', 'business', 0, 'ClickHouse 日志保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +DELETE FROM system_configs WHERE key IN ('database_auto_cleanup_enabled', 'database_auto_cleanup_retention_days'); + +-- +goose Down +INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES + ('database_auto_cleanup_enabled', 'true', 'business', 0, '数据库自动清理开关', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('database_auto_cleanup_retention_days', '30', 'business', 0, '数据库保留天数', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; +DELETE FROM system_configs WHERE key IN ('log_retention_days_postgres', 'log_retention_days_sqlite', 'log_retention_days_clickhouse'); +``` + +- [ ] **Step 2: SQLite 同版本号镜像**(`INSERT OR IGNORE` / `DELETE`,语义一致)。 +- [ ] **Step 3: model 常量更新**——旧 key 常量删除;`validate.go` 中 `validateDatabaseCleanupOption` 替换为 `validateLogRetentionOption`(3 个新 key,值 ≥1 整数)。 +- [ ] **Step 4: testhelper seed 同步**——`seedDefaultConfigs` 增 3 个新 key、删旧 key(含公共 key 列表如有)。 +- [ ] **Step 5: 验证** `go test ./internal/infra/persistence/migrator ./internal/apps/config ./internal/apps/admin/system_config ./internal/testhelper`。 +- [ ] **Step 6: 提交** `git add internal/ && git commit -m "feat(config): per-store log retention settings, drop legacy cleanup config"` + +### Task 13: CleanupStore + system_cleanup 日志清理步骤 + PG 分区预建 + +> 含 Task 11 审查跟进:PG 分区表仅在建表迁移时预建当前+2 月;`CleanupExpired` 每次运行时必须先确保「当前月 + 未来 2 个月」的分区存在(幂等 `CREATE TABLE IF NOT EXISTS ... PARTITION OF`),否则 3 个月后新写入会报 "no partition of relation found"。在 `CleanupStore`(或 logstore 包内 `EnsurePartitions(ctx)`)实现,PG 方言执行、SQLite/CH 为 no-op;`system_cleanup` 每日调用保证分区持续存在。 + +**Files:** +- Create: `internal/repository/logstore/cleanup.go` +- Modify: `internal/apps/upload/task/cleanup.go`(追加日志清理步骤) +- Create: `internal/repository/logstore/cleanup_test.go` + +**Interfaces:** +- Consumes: `model.ConfigKeyLogRetentionDays*`、`logstore.Active`。 +- Produces: `CleanupExpired(ctx) (*CleanupSummary, error)`(repository 层入口,`system_cleanup` 调用)。 + +- [ ] **Step 1: cleanup.go** + +```go +package logstore + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" +) + +// CleanupSummary 汇总本次清理结果。 +type CleanupSummary struct { + ActiveDatabase string `json:"active_database"` + RetentionDays int `json:"retention_days"` + Deleted int64 `json:"deleted"` + Tables []string `json:"tables"` +} + +// retentionDaysForActive 按当前激活库读取保留天数(默认 90)。 +func retentionDaysForActive(ctx context.Context) int { + key := model.ConfigKeyLogRetentionDaysPostgres + if dbName, _ := resolveDatabase(ctx); dbName == "sqlite" { + key = model.ConfigKeyLogRetentionDaysSQLite + } else if dbName == "clickhouse" { + key = model.ConfigKeyLogRetentionDaysClickHouse + } + v, err := getConfig(ctx, key) + if err != nil { + return 90 + } + days, perr := strconv.Atoi(v) + if perr != nil || days <= 0 { + return 90 + } + return days +} + +// CleanupExpired 按当前激活库保留天数清理过期日志(每日由 system_cleanup 调用)。 +func CleanupExpired(ctx context.Context) (*CleanupSummary, error) { + s, err := Active(ctx) + if err != nil { + return nil, err + } + days := retentionDaysForActive(ctx) + cutoff := time.Now().AddDate(0, 0, -days) + summary := &CleanupSummary{RetentionDays: days, Tables: []string{}} + summary.ActiveDatabase, _ = resolveDatabase(ctx) + + if err := cleanupTable(ctx, s, "node_access_logs", func() (int64, error) { + return s.AccessLogs.DeleteBefore(ctx, cutoff) + }, summary); err != nil { + return nil, err + } + if err := cleanupTable(ctx, s, "metric_snapshots", func() (int64, error) { + return s.Observability.DeleteMetricSnapshotsBefore(ctx, cutoff) + }, summary); err != nil { + return nil, err + } + // edge_health / obs_frps / obs_frpc 同构 + return summary, nil +} + +func cleanupTable(ctx context.Context, s *Store, name string, fn func() (int64, error), summary *CleanupSummary) error { + n, err := fn() + if err != nil { + return fmt.Errorf("cleanup %s: %w", name, err) + } + summary.Deleted += n + summary.Tables = append(summary.Tables, name) + return nil +} +``` + +> PG 实现优化(可选,首版用 DeleteBefore 即可):`DeleteBefore` 在 PG 分区表上命中 `logged_at` 分区键,按月 DROP 整分区后再 DELETE 不满月——M1 Task 3 的 `DeleteBefore` 已按 `logged_at < cutoff` 实现,满足正确性;后续再优化为 DROP PARTITION。CH 实现:`DeleteNodeAccessLogsBefore` 已做 TTL materialize;保留天数变化时 `clickhouseLogStore.DeleteBefore` 增加 `ALTER TABLE ... MODIFY TTL`(见 M4 优化项,可延后)。 + +- [ ] **Step 2: system_cleanup 追加步骤(upload/task/cleanup.go)** + +在现有清理步骤之后追加: + +```go +task.AppendLog(ctx, "开始清理过期日志(按当前日志库保留天数)...") +summary, err := logstore.CleanupExpired(ctx) +if err != nil { + task.AppendLog(ctx, "清理过期日志失败: %v", err) +} else if summary.Deleted == 0 { + task.AppendLog(ctx, "没有需要清理的过期日志 (保留 %d 天)", summary.RetentionDays) +} else { + task.AppendLog(ctx, "日志清理完成:保留 %d 天,删除 %d 条", summary.RetentionDays, summary.Deleted) +} +``` + +(`internal/apps/upload/task/cleanup.go` import `internal/repository/logstore`——upload/task 属 apps 层,import logstore 合法。) + +- [ ] **Step 3: 单测(cleanup_test.go)**——sqlite store 写入 40 天前/昨天各 1 条,`CleanupExpired` 用 `SetConfigReader` 注入 `log_retention_days_sqlite=30`,断言 40 天前的被删、昨天的保留。 +- [ ] **Step 4: 运行** `go test ./internal/repository/logstore/ ./internal/apps/upload/task/`。 +- [ ] **Step 5: 提交** `git add internal/repository/logstore/ internal/apps/upload/task/ && git commit -m "feat(cleanup): log retention cleanup in system_cleanup task"` + +### Task 14: 下线 of_database_auto_cleanup + +**Files:** +- Create: `internal/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql`、`sqlite/202608080003_...` +- Modify: `internal/apps/openflare/async_tasks.go`(删除 `DatabaseAutoCleanupTask`/`DatabaseAutoCleanupMeta`/`DatabaseAutoCleanupHandler`) +- Modify: `internal/infra/task/handlers/register.go`(注销) +- Modify: `internal/apps/openflare/tasks/database_cleanup.go`(删除;清理能力已并入 system_cleanup) + +**Interfaces:** +- Consumes: Task 13 完成。 +- Produces: `of_database_auto_cleanup` 从 schedule 与任务注册中消失。 + +- [ ] **Step 1: goose 删 schedule** + +```sql +-- +goose Up +DELETE FROM w_schedules WHERE task_type = 'of_database_auto_cleanup'; +-- +goose Down +INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (102, 'OpenFlare 可观测数据自动清理', 'of_database_auto_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; +``` + +- [ ] **Step 2: 注销任务与删除文件**——`register.go` 移除对应两行;`async_tasks.go` 删除常量/元数据/Handler;删除 `tasks/database_cleanup.go`。 +- [ ] **Step 3: 前端清理**——搜索前端对 `of_database_auto_cleanup` / `database_auto_cleanup_*` 引用并删除(任务页硬编码列表如有)。 +- [ ] **Step 4: 验证** `go build ./internal/...`、`go test ./internal/infra/persistence/migrator ./internal/infra/task/`。 +- [ ] **Step 5: 提交** `git add internal/ frontend/ && git commit -m "chore(cleanup): decommission of_database_auto_cleanup task and schedule"` + +--- + +## M3:迁移任务与展示 + +### Task 15: 「切换日志数据库」任务 Handler + +**Files:** +- Create: `internal/apps/openflare/tasks/log_db_switch.go` +- Create: `internal/apps/openflare/tasks/log_db_switch_test.go` +- Modify: `internal/apps/openflare/async_tasks.go`(注册元数据) +- Modify: `internal/infra/task/handlers/register.go`(注册 Handler) + +**Interfaces:** +- Consumes: `logstore.Active`/`logstore.Migrating`、`repository.UpdateSystemConfigFields`、`model.ConfigKeyLogDatabase`/`ConfigKeyLogDBMigration`、`analyticsmodel.*`、`config.Config`。 +- Produces: Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,参数 `target`。 + +- [ ] **Step 1: 元数据(async_tasks.go)** + +```go +// LogDBSwitchTask 切换日志数据库任务标识。 +const ( + LogDBSwitchTask = "openflare:log_db_switch" + TaskTypeLogDBSwitch = "of_log_db_switch" +) + +var LogDBSwitchMeta = task.TaskMeta{ + Type: TaskTypeLogDBSwitch, + AsynqTask: LogDBSwitchTask, + Name: "切换日志数据库", + Description: "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)", + SupportsTime: false, + MaxRetry: task.DefaultMaxRetry, + Queue: task.QueueDefault, + Retryable: true, + Params: []task.TaskParam{ + {Name: "target", Label: "目标日志库", Type: "string", Required: true, + Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"}, + }, +} +``` + +- [ ] **Step 2: Handler(log_db_switch.go)** + +```go +package tasks + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/internal/model" + analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/internal/repository/logstore" + "github.com/Rain-kl/Wavelet/pkg/logger" +) + +const copyBatchSize = 1000 + +type logDBSwitchPayload struct { + Target string `json:"target"` +} + +// LogDBSwitchHandler 切换日志数据库任务处理器。 +type LogDBSwitchHandler struct{} + +// ValidatePayload 校验并规范化参数。 +func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) { + var p logDBSwitchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, fmt.Errorf("参数解析失败: %w", err) + } + p.Target = normalizeTarget(p.Target) + if !validTarget(p.Target) { + return nil, fmt.Errorf("目标日志库不合法: %s", p.Target) + } + out, err := json.Marshal(p) + if err != nil { + return nil, err + } + return out, nil +} + +func normalizeTarget(v string) string { + switch v { + case "postgres", "postgresql": + return "postgres" + case "sqlite", "sqlite3": + return "sqlite" + case "clickhouse", "ch": + return "clickhouse" + } + return v +} + +func validTarget(v string) bool { + return v == "postgres" || v == "sqlite" || v == "clickhouse" +} + +// Execute 执行迁移。 +func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + var p logDBSwitchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, fmt.Errorf("参数解析失败: %w", err) + } + p.Target = normalizeTarget(p.Target) + if err := validateSwitch(ctx, p.Target); err != nil { + return nil, err + } + + source, _ := currentLogDatabase(ctx) + task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target) + if err := setMigrationFlag(ctx, "migrating"); err != nil { + return nil, err + } + defer func() { _ = setMigrationFlag(ctx, "") }() // 失败也清除,保持源库可写 + + src, err := logstore.Active(ctx) + if err != nil { + return nil, err + } + dst, err := buildTargetStore(ctx, p.Target) + if err != nil { + return nil, err + } + + // 清空目标库日志表(幂等重试前提)。 + if err := clearTargetLogTables(ctx, dst, p.Target); err != nil { + return nil, err + } + + // 逐表复制。 + if err := copyAccessLogs(ctx, src, dst); err != nil { + return nil, err + } + if err := copyObservability(ctx, src, dst); err != nil { + return nil, err + } + + // 翻转主库标记。 + if err := flipLogDatabase(ctx, p.Target); err != nil { + return nil, err + } + task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target) + return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil +} +``` + +- [ ] **Step 3: 辅助函数(同文件)** + +```go +func validateSwitch(ctx context.Context, target string) error { + source, err := currentLogDatabase(ctx) + if err != nil { + return err + } + if source == target { + return errors.New("目标日志库与当前日志库相同,无需迁移") + } + switch target { + case "clickhouse": + if !config.Config.ClickHouse.Enabled { + return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse") + } + case "postgres": + if !config.Config.Database.Enabled { + return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL") + } + case "sqlite": + if config.Config.Database.Enabled { + return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite") + } + } + return nil +} + +func currentLogDatabase(ctx context.Context) (string, error) { + cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) + if err != nil { + return "", fmt.Errorf("读取日志主库失败: %w", err) + } + if cfg.Value == "" { + return "", errors.New("日志主库配置为空") + } + return cfg.Value, nil +} + +func setMigrationFlag(ctx context.Context, v string) error { + // 必须用 SaveOrUpdateSystemConfig:UpdateSystemConfigFields 缺行时静默 no-op, + // 且不失效 RAM 配置缓存(TTL=-1 永不过期),会导致冻结/翻转不生效、进程间脑裂。 + return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDBMigration, v) +} + +func flipLogDatabase(ctx context.Context, target string) error { + return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, target) +} + +// buildTargetStore 构造目标库 Store(不经过 Active 缓存,直接 Build)。 +func buildTargetStore(ctx context.Context, database string) (*logstore.Store, error) { + return logstore.Build(ctx, database) +} + +func clearTargetLogTables(ctx context.Context, dst *logstore.Store, target string) error { + // 依次清空 6 张表:AccessLogs.DeleteAll、Observability.DeleteAll*(SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。 + if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil { + return fmt.Errorf("清空目标访问日志失败: %w", err) + } + for _, fn := range []func(context.Context) (int64, error){ + dst.Observability.DeleteAllMetricSnapshots, + dst.Observability.DeleteAllEdgeHealth, + dst.Observability.DeleteAllNodeObservationFrps, + dst.Observability.DeleteAllNodeObservationFrpc, + } { + if _, err := fn(ctx); err != nil { + return err + } + } + return nil +} + +// copyAccessLogs 从 src 复制节点访问日志到 dst。 +func copyAccessLogs(ctx context.Context, src, dst *logstore.Store) error { + // 注意:迁移期间 src 已冻结,但复制读取不受冻结影响;每批按 id 升序扫描。 + var lastID uint64 + for { + rows, err := listNodeAccessLogsByID(ctx, src, lastID, copyBatchSize) + if err != nil { + return err + } + if len(rows) == 0 { + break + } + if err := dst.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows); err != nil { + return fmt.Errorf("写入目标访问日志失败(批 %d): %w", lastID, err) + } + task.AppendLog(ctx, "已复制访问日志 %d 条(截至 id=%d)", len(rows), rows[len(rows)-1].ID) + lastID = rows[len(rows)-1].ID + if len(rows) < copyBatchSize { + break + } + } + return nil +} +``` + +> `ListForMigration` 已在 Task 2 接口定义:GORM 实现 `Where("id > ?", afterID).Order("id ASC").Limit(limit)`;CH 实现原生 SQL `SELECT ... FROM of_node_access_logs WHERE id > ? ORDER BY id LIMIT ?`。可观测 4 表的 `*ForMigration` 同理(按各自表名/模型)。 + +```go +// copyObservability 复制 4 张可观测表。 +func copyObservability(ctx context.Context, src, dst *logstore.Store) error { + for _, c := range []struct { + name string + read func(ctx context.Context, afterID uint64, limit int) (int, error) + }{ + {"metric_snapshots", func(ctx context.Context, afterID uint64, limit int) (int, error) { + rows, err := src.Observability.ListMetricSnapshotsForMigration(ctx, afterID, limit) + if err != nil || len(rows) == 0 { + return len(rows), err + } + return len(rows), dst.Observability.BatchInsertNodeMetricSnapshots(ctx, rows) + }}, + // edge_health / obs_frps / obs_frpc 同构,调用各自 ForMigration/BatchInsert 对。 + } { + var lastID uint64 + for { + n, err := c.read(ctx, lastID, copyBatchSize) + if err != nil { + return fmt.Errorf("复制 %s 失败: %w", c.name, err) + } + if n == 0 { + break + } + task.AppendLog(ctx, "已复制 %s %d 条", c.name, n) + if n < copyBatchSize { + break + } + lastID += uint64(n) // 近似游标;实现时改为每批最后一条 id 更精确 + } + } + return nil +} +``` + +- [ ] **Step 4: 注册**——`register.go` 加 `task.RegisterHandler(openflare.LogDBSwitchTask, &openflare.LogDBSwitchHandler{})` + `task.RegisterTaskMeta(openflare.LogDBSwitchMeta)`。 +- [ ] **Step 5: 单测(log_db_switch_test.go)**——sqlite↔sqlite 模拟(源 store 写入 3 条,目标 store 空库),执行 `copyAccessLogs` 断言 ID 保留、数量一致;`validateSwitch` 各非法组合报错;`ValidatePayload` 归一化。 +- [ ] **Step 6: 运行** `go test ./internal/apps/openflare/tasks/ ./internal/infra/task/`。 +- [ ] **Step 7: 提交** `git add internal/apps/openflare/ internal/infra/task/ && git commit -m "feat(task): add switch log database migration task"` + +### Task 16: 日志库状态端点 + +**Files:** +- Modify: `internal/apps/admin/status/clickhouse.go`(改造为 `log-database` 状态端点,保留旧路径兼容或重命名 + 路由更新) +- Modify: `internal/router/v1/admin.go`(路由注册) +- Modify: `internal/apps/admin/status/swagger` 注释 + +**Interfaces:** +- Consumes: `logstore.Active`、`logstore.Migrating`、`repository.GetIntByKey`(3 个保留配置)、`config.Config`。 +- Produces: `GET /api/v1/admin/status/log-database` 返回 `LogDatabaseStatus`。 + +- [ ] **Step 1: 实现状态结构(改造 clickhouse.go)** + +```go +// GetLogDatabaseStatus 返回当前日志库状态。 +// @Summary 获取日志数据库状态 +// @Description 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=status.LogDatabaseStatus} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/status/log-database [get] +func GetLogDatabaseStatus(c *gin.Context) { + ctx := c.Request.Context() + s, err := logstore.Active(ctx) + if err != nil { + response.AbortInternal(c, "日志存储初始化失败") + return + } + activeDB, _ := logstore.ActiveDatabase(ctx) // provider 增加 ActiveDatabase(ctx) 返回当前库名 + migration := "idle" + if logstore.Migrating(ctx) { + migration = "migrating" + } + out := LogDatabaseStatus{ + ActiveDatabase: activeDB, + Migration: migration, + RetentionDays: map[string]int{ + "postgres": retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres), + "sqlite": retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite), + "clickhouse": retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse), + }, + AvailableTargets: availableTargets(ctx), + } + if activeDB == "clickhouse" { + stats, err := analyticsrepo.GetClickHouseOperationalStats(ctx) // 经 logstore StatusStore 暴露 + if err == nil { + stats.BatchWriters = collectBatchWriterStats() + out.ClickHouse = stats + } + } + c.JSON(http.StatusOK, response.OK(out)) +} +``` + +> `logstore.ActiveDatabase(ctx)` 与 `logstore.Build(ctx, database)`(Task 15 用到)需在 provider 增加并实现;`analyticsrepo.GetClickHouseOperationalStats` 改为经 `logstore.StatusStore` 暴露,避免 admin/status import analyticsrepo(违反 import-lint)。 + +- [ ] **Step 2: 路由**——`internal/router/v1/admin.go` 将 `/status/clickhouse` 替换/新增为 `/status/log-database`;旧路径保留 301 或删除(实现时选删除并同步前端)。 +- [ ] **Step 3: 单测**——`logstore.ActiveDatabase`/`Build` 分支测试;`availableTargets`(当前=clickhouse → 主库;当前=主库 → clickhouse)。 +- [ ] **Step 4: swagger** `make swagger`。 +- [ ] **Step 5: 验证** `go test ./internal/apps/admin/status/`、`go build ./internal/...`。 +- [ ] **Step 6: 提交** `git add internal/apps/admin/ internal/router/ && git commit -m "feat(status): log database status endpoint"` + +### Task 17: 前端——任务参数、业务配置、状态展示 + +**Files:** +- Modify: `frontend/lib/services/admin/*`(任务/状态类型,若需) +- Modify: `frontend/components/common/settings/operation-tab.tsx` 或业务配置分组(「日志保留时间」) +- Modify: 任务管理页组件(`frontend/.../tasks.tsx` 或等价文件)——展示当前日志主库 + 迁移状态 + 「切换日志数据库」参数下拉 +- Modify: 状态页/仪表盘(日志库状态卡片) + +**Interfaces:** +- Consumes: 现有 Admin 任务派发 API、`/api/v1/admin/status/log-database`、`AdminService.updateSystemConfig`。 + +- [ ] **Step 1: 业务配置分组**——在 `/admin/settings` 业务配置 Tab 新增「日志保留时间」:3 个 `Input type="number"`(PG/SQLite/CH),保存调 `AdminService.updateSystemConfig`,成功后 invalidate `["admin","system-configs"]`,Sonner toast。 +- [ ] **Step 2: 任务管理页**——「切换日志数据库」出现在任务列表;参数 `target` 下拉按状态端点 `available_targets` 渲染(显示「PostgreSQL(主库)」/「SQLite(主库)」/「ClickHouse」);任务卡片显示 `active_database` 与迁移状态徽标。 +- [ ] **Step 3: 状态卡片**——仪表盘或任务页展示当前日志主库、保留天数、迁移中提示。 +- [ ] **Step 4: 验证** `cd frontend && pnpm build`(或 `pnpm lint`)。 +- [ ] **Step 5: 提交** `git add frontend/ && git commit -m "feat(frontend): log database status, retention settings, and switch task UI"` + +--- + +## M4:收尾与全量验证 + +### Task 18: 全量验证、文档与 changelog + +**Files:** +- Modify: `docs/changelog/index.md`(`[Unreleased]` 中文条目) +- Modify: `docs/design/`(如需要,日志数据库解耦设计说明) +- 全局验证 + +- [ ] **Step 1: 全量检查** 运行: + - `go build ./...` + - `go test ./...` + - `make code-check` + - `make swagger`(若 API 有变) + - `make format` + - goose 三套空库 Up 验证(`go test ./internal/infra/persistence/migrator`) +- [ ] **Step 2: changelog**——在 `docs/changelog/index.md` 的 `[Unreleased]` 增加合并条目: + +```markdown +- 日志存储解耦:新增日志存储抽象(`internal/repository/logstore`),ClickHouse 变为可选项,不启用时由 PostgreSQL/SQLite 承担全部日志功能;新增「切换日志数据库」任务支持 PostgreSQL/SQLite 与 ClickHouse 间数据迁移(迁移期间冻结日志写入,成功后自动切换主库并保留源数据);日志保留时间改为按存储库在业务配置中设置(`log_retention_days_*`),过期清理并入系统垃圾清理每日任务。 +``` + +- [ ] **Step 3: 设计文档归档**——确认 `docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md` 与计划一致;实现偏差在 spec 或 changelog 标注。 +- [ ] **Step 4: 提交** `git add docs/ && git commit -m "docs: log database decoupling changelog and design notes"` + +--- + +## 自检记录(writing-plans self-review) + +- **规格覆盖**:M1 Task 1-10 覆盖规格第 4 节(包结构/接口/约束/标记校验);M2 Task 11-14 覆盖第 5、6 节(表/优化/清理);M3 Task 15-17 覆盖第 7、8 节(迁移任务/API/前端);M4 Task 18 覆盖第 9 节(测试验证)与文档。 +- **已知实现决策(由实现者按此执行,避免歧义)**: + 1. `logstore` 不 import `internal/repository`(防循环);配置读取经 bootstrap 注入 `SetConfigReader`。 + 2. 迁移复制按 id 升序扫描:`AccessLogStore.ListForMigration` + 可观测 4 个 `*ForMigration`(Task 2 已定义),CH 与 GORM 各自实现;`copyObservability` 用每批最后一条 id 作为下一批游标(实现时修正计划里 `lastID += n` 的近似写法)。 + 3. `logstore.Build(ctx, database)` 导出供迁移任务构造目标 store;`ActiveDatabase(ctx)` 供状态端点。 + 4. admin/status 不直接 import analyticsrepo——CH 运行指标经 `logstore.StatusStore` 暴露。 + 5. 解析 helper(`ParseBrowserName` 等)迁至 `model/analytics` 纯函数,apps 不再依赖 analyticsrepo。 + 6. 迁移期间源库冻结由 logstore 各实现 `ensureWritable` 统一保证;risk_control 审计中间件在冻结期跳过写日志但不阻断请求。 + 7. 失败回退:`defer setMigrationFlag("")` 保证失败后源库恢复可写;重试时先清空目标再复制(幂等)。 diff --git a/docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md b/docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md new file mode 100644 index 00000000..2d46b6d7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-log-database-decoupling-design.md @@ -0,0 +1,177 @@ +# 日志数据库解耦设计(ClickHouse 可选化) + +> 状态:已与用户逐段确认,待用户复核。 +> 日期:2026-08-08 + +## 1. 背景与目标 + +当前系统日志/分析(访问日志、可观测时序)完全绑定 ClickHouse:`internal/repository/analytics` 直接操作 `db.ChConn`/`db.ChDB`,apps 层(`chwriter`、`risk_control`、`admin/logs`、`admin/status`)依赖 `config.ClickHouse.Enabled` 判断可用性。业务流量小、主机性能低时 ClickHouse 负担大。 + +目标: + +1. **解耦**:ClickHouse 变为可选项;不启用时,主库(PostgreSQL;禁用时 SQLite)完整承接全部日志功能(写入、查询、聚合、清理)。 +2. **代码级约束**:上层应用写日志不能直接调用底层库(`analyticsrepo` / `db.ChConn`),用接口 + import-lint 测试保证,而非 AGENTS.md 口头约束。 +3. **可迁移**:提供用户触发的「切换日志数据库」任务,支持 PostgreSQL/SQLite ↔ ClickHouse 数据迁移。 +4. **表结构**:CH 日志表迁入 PG/SQLite;CH 保持只有日志表的 SQL 脚本;PG/SQLite 包含全部表。 + +## 2. 现状要点 + +- 连接:`internal/infra/persistence/clickhouse.go`(`ChConn` 原生批量写 + `ChDB` GORM 查询),`init()` 依据 `clickhouse.enabled`。 +- 分析域:`internal/repository/analytics/` 直接读写 CH;apps 通过 `batchwriter` 异步 flush(`chwriter`、`risk_control`)。 +- 已有抽象雏形:`internal/repository/openflare_access_log_store.go` / `openflare_observability_store.go` 中的未导出 `accessLogStore` / `observabilityStore` 接口,默认 `clickhouseAccessLogStore{}`,测试可换 memory 实现——默认写死 CH、不可配置切换、接口未导出。 +- 迁移:主库 goose(`goose/postgres` + `goose/sqlite` 双方言)与 CH 单方言(`goose/clickhouse`)分离。 +- 历史:PG/SQLite 曾有过 `of_node_metric_snapshots`、`of_node_access_logs` 等观测表(`202606190010_create_of_observability_tables.sql`),后由 `202606200005_drop_of_node_observability_timeseries.sql` 删除(迁去 CH)。**旧 DDL 可复活改造**。 +- 任务:Asynq + `task.RegisterHandler`/`RegisterTaskMeta`;`system_cleanup`(系统垃圾清理)每日任务已存在;`of_database_auto_cleanup`(可观测清理,schedule id=102)存在。 +- 系统配置:`system_configs` 表(key/type/visibility),现有 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`(business)。 + +## 3. 已确认的核心决策 + +| # | 决策 | +|---|---| +| 1 | 范围:CH 不启用时,PG(或 SQLite)承担**全部**日志功能;聚合在 PG/SQLite 查询时实时计算,不物理建 MV 同构表。 | +| 2 | 实现:接口定义在 repository 层;PG 用 GORM 全新实现;CH 保留现有原生批量优化(`PrepareBatch`)包进同一接口。 | +| 3 | SQLite 是一等公民:`log_database` ∈ {`postgres`, `sqlite`, `clickhouse`};迁移方向 PG→CH、SQLite→CH、CH→PG、CH→SQLite。 | +| 4 | 日志库只有两种合法状态:**随主库**(`database.enabled` → postgres,否则 sqlite)或 **clickhouse**;不存在主库 PG + 日志 SQLite 的组合。 | +| 5 | 迁移任务「切换日志数据库」:纯复制、**源数据不删除**、可重试;迁移期间**冻结日志写入**(拒绝,不排队积压);全部成功才翻转主库标记。 | +| 6 | 清理统一到 `system_cleanup`(每日一次,日志过期无需实时);保留时间按**存储库**配置(`type=business`)。 | + +## 4. 包结构与接口(方案一) + +新增 `internal/repository/logstore/`,职责唯一:日志存储抽象。 + +``` +internal/repository/logstore/ +├── logstore.go # 导出接口:AccessLogStore / ObservabilityStore / UserAccessLogStore / CleanupStore / StatusStore +├── provider.go # Open(ctx) 按当前日志主库返回实现;ActiveDatabase() 供状态/UI;测试可注入 +├── postgres_store.go # GORM 实现(PG 与 SQLite 共用一套,方言差异只在 goose DDL + dialect_* 小文件) +├── dialect_postgres.go # PG 方言 SQL 片段(date_trunc / FILTER / 分区清理) +├── dialect_sqlite.go # SQLite 方言 SQL 片段(strftime / unixepoch) +└── clickhouse_store.go # 把现有 analyticsrepo 原生批量 + GORM 查询包进接口(零性能损耗) +``` + +- **接口划分**(避免 40+ 方法巨型接口,合成 `logstore.Store` 结构体持有): + - `AccessLogStore`:节点访问日志的 InsertBatch / List / Count / RegionCounts / BucketAggregates / CountBuckets / BucketDimensions / IPAggregates / IPSummaries / CountIPSummaries / WAFIPAggregates / IPTrend / TrafficSummary / ValueCounts / NodeAggregates / DeleteAll / DeleteBefore / DeleteByNodeBefore。 + - `ObservabilityStore`:4 表(metric snapshots / edge health / frps / frpc)的 Insert / List / Delete。 + - `UserAccessLogStore`:`w_user_access_logs` 的 BatchInsert / Count / List / 统计(DailyTrend / BrowserDistribution / TopActiveUsers 等)。 + - `CleanupStore`:按保留天数清理过期数据(PG=分区 DROP + 分批 DELETE;SQLite=分批 DELETE;CH=MODIFY TTL + materialize)。 + - `StatusStore`:当前库状态、CH 运行指标(激活时)、GORM 写入器状态。 +- **消费面**:`internal/repository` 现有公开函数(`ListOpenFlareAccessLogs`、`InsertOpenFlareAccessLogsBatch`、`InsertOpenFlareMetricSnapshot` 等)**保留签名、改为一行委托 `logstore`**,apps 调用面几乎不动;apps 里现有 `analyticsrepo` 直连(`risk_control`、`chwriter`、`tasks/database_cleanup.go`、`observability/access_log_logics.go`、`admin/logs`、`admin/status`)全部改走 repository/logstore。 +- **import-lint 测试**:新增 `go test`,扫描 `internal/apps/**` 的 import,发现 `internal/repository/analytics` 或 `internal/infra/persistence`(`batchwriter` 白名单除外)即失败。这是「代码层面规避」的验收。 +- `analyticsrepo` 保留,仅被 `logstore/clickhouse_store.go` 引用(CH 实现细节)。 + +### 主库标记与启动校验 + +- `system_configs` 新增内部 key: + - `log_database`(`postgres`/`sqlite`/`clickhouse`):当前日志主库,仅迁移任务写入。 + - `log_db_migration`(`"migrating"`/空):迁移冻结标记,仅迁移任务写入。 +- **首次 seed**(bootstrap Go 侧,因依赖运行时主库选择):key 缺失时,`clickhouse.enabled` → `clickhouse`(保持现状、不丢现有 CH 数据);否则 → 当前主库(`database.enabled` → `postgres`,否则 `sqlite`)。 +- **启动校验**(bootstrap): + - `log_database=clickhouse` 但 `clickhouse.enabled=false` → 启动报错:「当前日志主库为 ClickHouse 但 ClickHouse 未启用。请先重新启用 ClickHouse 配置并启动,在任务管理运行『切换日志数据库』迁移到 PostgreSQL/SQLite 后再禁用 ClickHouse」。 + - `log_database=postgres` 但 `database.enabled=false`,或 `log_database=sqlite` 但 `database.enabled=true` → 启动报错(违反「随主库或随 CH」规则)。 +- **key 保护**:`log_database`、`log_db_migration` 在配置更新接口(admin system-configs / option 校验)拒绝修改;仅迁移任务可写;启动校验兜底被篡改组合。 +- **热切换**:`logstore` 通过系统配置缓存(Redis,更新即失效)读取 `log_database`;翻转后 API 进程自动切到新实现,无需自定义跨进程协议。 + +## 5. PG/SQLite 表结构与优化 + +**新建原始日志表(PG + SQLite 双方言 goose,同版本号)**——只建原始表,**不建** CH 物化视图/聚合表(`of_access_log_hourly`、`of_node_metric_capacity_hourly` 等),PG/SQLite 查询时实时聚合: + +| 表 | 说明 | +|---|---| +| `w_user_access_logs` | 用户访问日志 | +| `of_node_access_logs` | 节点访问日志(含 user_agent/cache_status/bytes_sent/request_length/request_time_ms 现行列) | +| `of_node_metric_snapshots` | 资源指标 | +| `of_node_edge_health` | 边缘健康 | +| `of_node_obs_frps` | FRPS 观测 | +| `of_node_obs_frpc` | FRPC 观测 | + +- **ID**:沿用 snowflake uint64(DDL 用 BIGINT,与 CH UInt64 对齐);不换自增,保证迁移 ID 原样保留、无冲突。 +- **时间**:PG `TIMESTAMPTZ`;SQLite `DATETIME`。 +- **复合主键**:分区表主键 `(id, 时间列)`(满足 PG 分区键进唯一索引要求)。 + +### PG 优化 + +1. **分区**:仅 `of_node_access_logs`、`w_user_access_logs` 两个高频表用 PG 原生 `PARTITION BY RANGE` **按月分区**;可观测 4 表数据量小,普通表 + 索引。SQLite 无原生分区 → 普通表 + 组合索引(方言差异只留在 goose DDL,运行时 GORM 代码共用)。 +2. **批量写入**:PG/SQLite 统一 GORM `CreateInBatches`(批次 500–1000);CH 维持原生 `PrepareBatch`。 +3. **索引**: + - `of_node_access_logs`:`(logged_at DESC)`、`(node_id, logged_at DESC)`、`(host, logged_at DESC)`; + - `w_user_access_logs`:`(created_at DESC)`、`(user_id, created_at DESC)`; + - 可观测表:`(node_id, captured_at DESC)`。 +4. **聚合查询重写**:PG 用 `date_trunc` / `count(DISTINCT)` / `FILTER (WHERE ...)` 等价替换 CH 的 `toStartOfHour` / `uniqExact` / `countIf`;SQLite 用 `strftime` / `unixepoch`。时间分桶等少量方言 SQL 拆到 `dialect_postgres.go` / `dialect_sqlite.go`,store 主体方言中立。 + +### goose 迁移 + +- PG/SQLite 各新增一组建表迁移(复活并改造 `202606190010` 旧 DDL,按 database-migration 技能双方言、同版本号规则)。 +- CH 目录不动(本来就只有日志表脚本,满足「CH 保持只有日志表 SQL」)。 + +## 6. 清理(并入 system_cleanup) + +- 日志过期清理并入 `system_cleanup`(系统垃圾清理)每日任务;`of_database_auto_cleanup` 专用 schedule(id=102)与任务下线。 +- 新增 `type=business` 配置(替换旧 `database_auto_cleanup_enabled` / `database_auto_cleanup_retention_days`): + - `log_retention_days_postgres`(默认 90) + - `log_retention_days_sqlite`(默认 90) + - `log_retention_days_clickhouse`(默认 90) +- `CleanupStore` 按当前生效库读取对应值执行: + - PG:分区 DROP(整月)+ 分批 DELETE(不满月); + - SQLite:分批 DELETE; + - CH:`ALTER TABLE ... MODIFY TTL toDateTime(...) + INTERVAL N DAY` + materialize(保留期由配置驱动,不再依赖 DDL 写死)。 +- 旧 key `database_auto_cleanup_*` 由 goose 迁移删除,前端同步清理。 + +## 7. 迁移任务「切换日志数据库」 + +**元数据**:Asynq `openflare:log_db_switch`,管理类型 `of_log_db_switch`,名称「切换日志数据库」,参数 `target`(`postgres`/`sqlite`/`clickhouse`),`Retryable: true`。UI 按当前日志主库只展示合法目标(当前=CH → 「主库」;当前=主库 → 「ClickHouse」)。 + +**执行流程(worker 进程)**: + +1. **校验**:`target == 当前主库` → 拒绝;`target=clickhouse` 但 CH 未启用 / `target=postgres` 但 `database.enabled=false` / `target=sqlite` 但 `database.enabled=true` → 拒绝。 +2. **写冻结**:写 `log_db_migration = "migrating"`;先让 batchwriter 把在途批次 flush 完;此后 API 进程所有日志写入路径(`risk_control`、`chwriter` 队列、agent 上报落库)检查该 key → 返回明确错误(HTTP 503「日志数据库迁移中,暂不可写」),不排队积压。 +3. **复制**:6 张原始日志表逐表、按 id 分批(每批 ~1000)读源 → 写目标(CH→主库用 GORM `CreateInBatches`;主库→CH 用原生 `PrepareBatch`);ID 原样保留;每表/每批 `task.AppendLog` 进度。 + - **幂等前提**:开始复制前**清空目标库日志表**(任务参数「覆盖目标库已有日志」默认开启;目标库通常为空,仅「切回去」场景有旧数据)——保证失败重试可重跑不重复。 +4. **翻转**:全部成功 → 更新 `log_database = target`、清除迁移标记 → `logstore` 缓存失效自动切到新实现 → 写入恢复(走新库)。 +5. **失败**:返回错误触发 Asynq 重试;**失败时清除迁移标记**,写入继续走源库(不丢功能);重试时重新清空目标 + 复制。 + +**双进程一致性**:迁移标记与主库标记落在 `system_configs`(Redis 缓存,worker 更新后 API 进程自动失效重读)。 + +## 8. API 与前端 + +**后端**: + +- `GET /api/v1/admin/status/log-database`(改造现有 `/clickhouse` 状态端点):返回当前日志主库、迁移状态(`idle`/`migrating`)、各库保留天数、当前合法迁移目标;CH 为主时附带现有 CH 运行指标,主库为主时附带 GORM 写入器状态。 +- 任务「切换日志数据库」走现有任务管理通用派发 API(`RegisterTaskMeta` + Params),无需新派发接口;执行记录/进度复用任务框架。 +- 系统配置:新增 3 个 `log_retention_days_*`(business)图形化 + 参数表可见;新增内部 `log_database`、`log_db_migration`(system、visibility=0、受保护);下线 `database_auto_cleanup_*`。 + +**前端**: + +- 任务管理页:出现「切换日志数据库」,参数下拉只显示合法目标;页面展示当前日志主库与迁移状态。 +- `/admin/settings` 业务配置:新增「日志保留时间」分组(PG/SQLite/CH 三个数字输入)。 +- 状态/仪表盘:日志库状态卡片(当前库 + 迁移中提示)。 + +## 9. 测试与验证 + +- **import-lint 测试**:`internal/apps/**` 不得 import `internal/repository/analytics`、`internal/infra/persistence`(`batchwriter` 白名单除外),违规即失败。 +- **logstore 单测**:GORM 实现用 SQLite 全量跑;PG 专属(分区 DROP 等)走既有集成测试路径;CH 实现复用现有 analyticsrepo 测试。 +- **迁移任务测试**:目标/组合校验、批处理与 ID 保留、清空目标、翻转标记、失败清标记回退、冻结期写入拒绝——用 memory/sqlite 双端模拟,不依赖真实 CH。 +- **清理测试**:`system_cleanup` 日志清理步骤(PG 分区 DROP / SQLite 分批 DELETE / CH TTL 修改)与保留配置读取。 +- **迁移验证**:goose 空库 Up 全量(PG/SQLite/CH 三套)、`go test ./...`、`make swagger`(API 变更)、`make code-check`、`make format`。 + +## 10. 非目标(YAGNI) + +- 不在 PG/SQLite 物理建聚合/物化视图表(查询实时聚合)。 +- 不做 PG ↔ SQLite 日志互迁(非法组合,启动校验拒绝)。 +- 迁移成功不自动删除源库数据(保留,后续提供手动清理入口)。 +- 不引入 PG COPY 协议(GORM `CreateInBatches` 对低流量足够)。 +- 不引入自定义跨进程迁移协议(`system_configs` + Redis 缓存即可)。 + +## 11. 里程碑建议(供实现计划分解) + +1. **M1 抽象与改造**:`logstore` 接口 + PG/SQLite 实现 + `clickhouse_store` 包装 + import-lint 测试 + repository 委托改造 + apps 直连改造 + `log_database`/`log_db_migration` key 与启动校验。 +2. **M2 表与清理**:goose 双方言建表迁移 + 保留配置 key + `system_cleanup` 日志清理步骤 + 下线 `of_database_auto_cleanup` 与旧配置。 +3. **M3 迁移任务与展示**:迁移任务 Handler + 状态端点 + 任务管理页/业务配置前端 + 日志库状态卡片。 +4. **M4 收尾**:全量验证(goose 三套、单测、`make code-check`/`swagger`/`format`)、文档同步(中文)、changelog `[Unreleased]`。 + +## 12. 实现归档说明(Task 18,2026-08-08) + +- 设计稿第 4 节 provider 入口写作 `Open(ctx)`,实现命名为 `Active(ctx)`(按 `log_database` 解析并缓存,配置翻转后重建),另导出 `Build(ctx, database)` / `BuildForMigration(ctx, database)` 供迁移任务构造目标库 store;`ActiveDatabase(ctx)` 供状态端点。 +- 设计稿第 4 节列出的 `CleanupStore` 接口未单独落地:清理实现为包级 `CleanupExpired(ctx)`(按当前激活库保留天数删除过期日志并预建 PG 分区),由 `system_cleanup` 每日任务调用。 +- 设计稿第 4 节列举的 `tasks/database_cleanup.go` 已随 M2 下线(`of_database_auto_cleanup` 配置与前端 UI 一并移除),日志清理职责并入 `system_cleanup`。 +- 迁移复制按 id 升序分页,`copyObservability` 以每批最后一条 id 作为下一批游标(修正计划中 `lastID += n` 的近似写法);失败回退由 `defer setMigrationFlag("")` 保证源库恢复可写,重试前先清空目标库保证幂等。 +- 其余实现决策(`SetConfigReader` 注入、`ensureWritable` 统一冻结、解析 helper 迁至 `model/analytics` 等)见计划「自检记录」,与本文档一致。 diff --git a/docs/swagger.json b/docs/swagger.json index bb4bb3c9..6d9fa916 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1017,7 +1017,7 @@ "SessionCookie": [] } ], - "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", + "description": "分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错)", "produces": [ "application/json" ], @@ -1112,7 +1112,7 @@ "SessionCookie": [] } ], - "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", + "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错)", "produces": [ "application/json" ], @@ -1140,7 +1140,7 @@ } }, "400": { - "description": "ClickHouse 未启用", + "description": "日志存储未启用", "schema": { "$ref": "#/definitions/response.Any" } @@ -1870,21 +1870,21 @@ } } }, - "/api/v1/admin/status/clickhouse": { + "/api/v1/admin/status/log-database": { "get": { "security": [ { "SessionCookie": [] } ], - "description": "返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限", + "description": "返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "获取 ClickHouse 运行指标", + "summary": "获取日志数据库状态", "responses": { "200": { "description": "获取成功", @@ -1897,19 +1897,13 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/analytics.ClickHouseOperationalStats" + "$ref": "#/definitions/status.LogDatabaseStatus" } } } ] } }, - "400": { - "description": "ClickHouse 未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, "401": { "description": "未登录", "schema": { @@ -2386,6 +2380,18 @@ "name": "task_type", "in": "query" }, + { + "type": "string", + "description": "任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先)", + "name": "task_type_prefix", + "in": "query" + }, + { + "type": "string", + "description": "逗号分隔的精确任务类型列表(IN 筛选,优先于前缀)", + "name": "task_types", + "in": "query" + }, { "type": "integer", "default": 1, @@ -8298,80 +8304,6 @@ } } }, - "/api/v1/d/option/database/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "清理可观测性数据库", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/option.databaseCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.databaseCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/d/option/geoip/lookup": { "post": { "security": [ @@ -14899,33 +14831,26 @@ } } }, - "analytics.ClickHouseOperationalStats": { + "analytics.BatchWriterStats": { "type": "object", "properties": { - "active_parts": { + "cap": { "type": "integer" }, - "async_insert_bytes": { + "depth": { "type": "integer" }, - "async_insert_queue": { + "drops": { "type": "integer" }, - "batch_writers": { - "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", - "type": "array", - "items": { - "$ref": "#/definitions/batchwriter.Stats" - } + "flush_errors": { + "type": "integer" }, - "database": { + "name": { "type": "string" }, - "pending_mutations": { - "type": "integer" - }, - "total_rows": { - "type": "integer" + "running": { + "type": "boolean" } } }, @@ -15017,29 +14942,6 @@ } } }, - "batchwriter.Stats": { - "type": "object", - "properties": { - "cap": { - "type": "integer" - }, - "depth": { - "type": "integer" - }, - "drops": { - "type": "integer" - }, - "flush_errors": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "running": { - "type": "boolean" - } - } - }, "cache.updateCacheConfigRequest": { "type": "object", "required": [ @@ -15098,6 +15000,9 @@ "id": { "type": "integer" }, + "zone_domain": { + "type": "string" + }, "zone_id": { "type": "integer" } @@ -15819,6 +15724,36 @@ } } }, + "github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats": { + "type": "object", + "properties": { + "active_parts": { + "type": "integer" + }, + "async_insert_bytes": { + "type": "integer" + }, + "async_insert_queue": { + "type": "integer" + }, + "batch_writers": { + "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", + "type": "array", + "items": { + "$ref": "#/definitions/analytics.BatchWriterStats" + } + }, + "database": { + "type": "string" + }, + "pending_mutations": { + "type": "integer" + }, + "total_rows": { + "type": "integer" + } + } + }, "github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta": { "type": "object", "properties": { @@ -18474,46 +18409,6 @@ } } }, - "option.databaseCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - } - } - }, - "option.databaseCleanupResult": { - "type": "object", - "properties": { - "cleanup_mode": { - "type": "string" - }, - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "eligible_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - }, - "table_ttl_days": { - "type": "integer" - }, - "target": { - "type": "string" - }, - "target_label": { - "type": "string" - } - } - }, "option.geoIPLookupRequest": { "type": "object", "properties": { @@ -19852,6 +19747,33 @@ } } }, + "status.LogDatabaseStatus": { + "type": "object", + "properties": { + "active_database": { + "type": "string" + }, + "available_targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "clickhouse": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats" + }, + "migration": { + "description": "idle | migrating", + "type": "string" + }, + "retention_days": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, "status.SystemStatusResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ce4e3e56..d602f080 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -169,26 +169,20 @@ definitions: $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup' type: array type: object - analytics.ClickHouseOperationalStats: + analytics.BatchWriterStats: properties: - active_parts: + cap: type: integer - async_insert_bytes: + depth: type: integer - async_insert_queue: + drops: type: integer - batch_writers: - description: BatchWriters reports in-process queue depth/drops/flush errors - for CH writers. - items: - $ref: '#/definitions/batchwriter.Stats' - type: array - database: + flush_errors: + type: integer + name: type: string - pending_mutations: - type: integer - total_rows: - type: integer + running: + type: boolean type: object apply_log.CleanupInput: properties: @@ -247,21 +241,6 @@ definitions: is_active: type: boolean type: object - batchwriter.Stats: - properties: - cap: - type: integer - depth: - type: integer - drops: - type: integer - flush_errors: - type: integer - name: - type: string - running: - type: boolean - type: object cache.updateCacheConfigRequest: properties: lru_enabled: @@ -301,6 +280,8 @@ definitions: type: string id: type: integer + zone_domain: + type: string zone_id: type: integer type: object @@ -774,6 +755,27 @@ definitions: token: type: string type: object + github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats: + properties: + active_parts: + type: integer + async_insert_bytes: + type: integer + async_insert_queue: + type: integer + batch_writers: + description: BatchWriters reports in-process queue depth/drops/flush errors + for CH writers. + items: + $ref: '#/definitions/analytics.BatchWriterStats' + type: array + database: + type: string + pending_mutations: + type: integer + total_rows: + type: integer + type: object github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta: properties: checksum: @@ -2533,32 +2535,6 @@ definitions: window_started_at: type: string type: object - option.databaseCleanupInput: - properties: - retention_days: - type: integer - target: - type: string - type: object - option.databaseCleanupResult: - properties: - cleanup_mode: - type: string - delete_all: - type: boolean - deleted_count: - type: integer - eligible_count: - type: integer - retention_days: - type: integer - table_ttl_days: - type: integer - target: - type: string - target_label: - type: string - type: object option.geoIPLookupRequest: properties: ip: @@ -3442,6 +3418,24 @@ definitions: version: type: string type: object + status.LogDatabaseStatus: + properties: + active_database: + type: string + available_targets: + items: + type: string + type: array + clickhouse: + $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats' + migration: + description: idle | migrating + type: string + retention_days: + additionalProperties: + type: integer + type: object + type: object status.SystemStatusResponse: properties: alloc: @@ -4935,7 +4929,7 @@ paths: - admin /api/v1/admin/logs/access: get: - description: 分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错) + description: 分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错) parameters: - default: 1 description: 页码 @@ -4994,7 +4988,7 @@ paths: - admin /api/v1/admin/logs/analytics: get: - description: 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错) + description: 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错) produces: - application/json responses: @@ -5008,7 +5002,7 @@ paths: $ref: '#/definitions/logs.logsAnalyticsResponse' type: object "400": - description: ClickHouse 未启用 + description: 日志存储未启用 schema: $ref: '#/definitions/response.Any' "401": @@ -5434,9 +5428,9 @@ paths: summary: 获取系统状态信息 tags: - admin - /api/v1/admin/status/clickhouse: + /api/v1/admin/status/log-database: get: - description: 返回 ClickHouse parts、mutation、async_insert 队列及进程内 batch writer 指标,需要管理员权限 + description: 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 produces: - application/json responses: @@ -5447,12 +5441,8 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/analytics.ClickHouseOperationalStats' + $ref: '#/definitions/status.LogDatabaseStatus' type: object - "400": - description: ClickHouse 未启用 - schema: - $ref: '#/definitions/response.Any' "401": description: 未登录 schema: @@ -5467,7 +5457,7 @@ paths: $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 获取 ClickHouse 运行指标 + summary: 获取日志数据库状态 tags: - admin /api/v1/admin/system-configs: @@ -5738,6 +5728,14 @@ paths: in: query name: task_type type: string + - description: 任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先) + in: query + name: task_type_prefix + type: string + - description: 逗号分隔的精确任务类型列表(IN 筛选,优先于前缀) + in: query + name: task_types + type: string - default: 1 description: 页码 in: query @@ -9285,50 +9283,6 @@ paths: summary: 列出 OpenFlare 配置项 tags: - openflare-option - /api/v1/d/option/database/cleanup: - post: - consumes: - - application/json - description: 按目标与保留天数清理可观测性相关数据表,需要管理员权限 - parameters: - - description: 清理参数 - in: body - name: request - schema: - $ref: '#/definitions/option.databaseCleanupInput' - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/option.databaseCleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理可观测性数据库 - tags: - - openflare-option /api/v1/d/option/geoip/lookup: post: consumes: diff --git a/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts b/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts index b274e16b..733081b9 100644 --- a/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts +++ b/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts @@ -18,8 +18,6 @@ export type OpenFlareOpsFields = { uptime_kuma_retry: string; uptime_kuma_retry_interval: string; uptime_kuma_timeout: string; - database_auto_cleanup_enabled: boolean; - database_auto_cleanup_retention_days: string; pages_max_package_size_mb: string; pages_max_history_count: string; }; @@ -42,8 +40,6 @@ export const defaultOpenFlareOpsFields: OpenFlareOpsFields = { uptime_kuma_retry: '0', uptime_kuma_retry_interval: '60', uptime_kuma_timeout: '48', - database_auto_cleanup_enabled: false, - database_auto_cleanup_retention_days: '30', pages_max_package_size_mb: '100', pages_max_history_count: '20', }; @@ -88,12 +84,6 @@ export function mapOptionsToOpsFields( uptime_kuma_retry: optionMap.uptime_kuma_retry ?? '0', uptime_kuma_retry_interval: optionMap.uptime_kuma_retry_interval ?? '60', uptime_kuma_timeout: optionMap.uptime_kuma_timeout ?? '48', - database_auto_cleanup_enabled: toBoolean( - optionMap.database_auto_cleanup_enabled, - false, - ), - database_auto_cleanup_retention_days: - optionMap.database_auto_cleanup_retention_days ?? '30', pages_max_package_size_mb: optionMap.pages_max_package_size_mb ?? '100', pages_max_history_count: optionMap.pages_max_history_count ?? '20', }; @@ -165,16 +155,6 @@ export function validateUptimeKumaFields(fields: OpenFlareOpsFields) { throw new Error('请求超时必须为正整数。'); } -export function validateDatabaseAutoCleanup(fields: OpenFlareOpsFields) { - const retentionDays = Number.parseInt( - fields.database_auto_cleanup_retention_days, - 10, - ); - if (Number.isNaN(retentionDays) || retentionDays < 1) { - throw new Error('自动清理保留天数至少为 1 天。'); - } -} - export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] { validateAgentFields(fields); return [ @@ -220,22 +200,6 @@ export function uptimeKumaOptionEntries( ]; } -export function databaseAutoCleanupEntries( - fields: OpenFlareOpsFields, -): OptionItem[] { - validateDatabaseAutoCleanup(fields); - return [ - { - key: 'database_auto_cleanup_enabled', - value: String(fields.database_auto_cleanup_enabled), - }, - { - key: 'database_auto_cleanup_retention_days', - value: fields.database_auto_cleanup_retention_days, - }, - ]; -} - export function validatePagesFields(fields: OpenFlareOpsFields) { const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10); const historyCount = Number.parseInt(fields.pages_max_history_count, 10); diff --git a/frontend/app/(main)/admin/settings/components/openflare-ops.tsx b/frontend/app/(main)/admin/settings/components/openflare-ops.tsx index c81f6218..77345bba 100644 --- a/frontend/app/(main)/admin/settings/components/openflare-ops.tsx +++ b/frontend/app/(main)/admin/settings/components/openflare-ops.tsx @@ -11,20 +11,8 @@ import { RotateCw, Save, Server, - Trash2, } from 'lucide-react'; import { toast } from 'sonner'; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Card, @@ -46,7 +34,6 @@ import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; -import type { DatabaseCleanupTarget } from '@/lib/services/openflare'; import { NodeService, OptionService, @@ -57,7 +44,6 @@ import { import { agentOptionEntries, buildDiscoveryCommand, - databaseAutoCleanupEntries, defaultOpenFlareOpsFields, formatDurationLabel, getBrowserOrigin, @@ -72,31 +58,6 @@ import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal'; const optionsQueryKey = ['openflare', 'options'] as const; const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const; -const cleanupTargets: Array<{ - target: DatabaseCleanupTarget; - label: string; - description: string; -}> = [ - { - target: 'node_access_logs', - label: '访问日志', - description: - '清理 node_access_logs,影响访问明细与 IP 汇总;表 TTL 为 90 天。', - }, - { - target: 'node_metric_snapshots', - label: '性能快照', - description: - '清理 node_metric_snapshots,影响节点资源趋势;表 TTL 为 30 天。', - }, - { - target: 'node_edge_health', - label: 'OpenResty 健康', - description: - '清理 node_edge_health(OpenResty 连接/健康快照);表 TTL 为 30 天。业务流量请清理访问日志。', - }, -]; - async function copyText(value: string) { await navigator.clipboard.writeText(value); } @@ -109,11 +70,6 @@ export function OpenFlareOpsSettings() { const [savingSection, setSavingSection] = useState(null); const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8'); const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false); - const [cleanupTarget, setCleanupTarget] = useState<{ - target: DatabaseCleanupTarget; - label: string; - } | null>(null); - const [cleanupRetentionDays, setCleanupRetentionDays] = useState(''); const optionsQuery = useQuery({ queryKey: optionsQueryKey, @@ -194,25 +150,6 @@ export function OpenFlareOpsSettings() { toast.error(error instanceof Error ? error.message : '同步失败'), }); - const cleanupMutation = useMutation({ - mutationFn: (payload: { - target: DatabaseCleanupTarget; - retention_days?: number; - }) => OptionService.cleanupDatabase(payload), - onSuccess: (result) => { - setCleanupTarget(null); - setCleanupRetentionDays(''); - toast.success( - result.delete_all - ? `已清空${result.target_label},共删除 ${result.deleted_count} 条。` - : `已清理${result.target_label},共删除 ${result.deleted_count} 条。`, - ); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : '清理失败'); - }, - }); - const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryCommand = useMemo(() => { if (!fields.server_address || !discoveryToken) return ''; @@ -248,17 +185,6 @@ export function OpenFlareOpsSettings() { } }; - const saveDatabaseAutoCleanup = () => { - try { - saveMutation.mutate({ - section: 'database-auto', - entries: databaseAutoCleanupEntries(fields), - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : '参数校验失败'); - } - }; - const savePagesSettings = () => { try { saveMutation.mutate({ @@ -690,86 +616,6 @@ export function OpenFlareOpsSettings() { -
- - -
- 数据库自动清理 - - 每天凌晨 3 点物化 ClickHouse 表 TTL;访问日志至少保留 90 - 天,其它观测数据至少保留 30 天。 - -
- -
- - - updateField('database_auto_cleanup_enabled', value) - } - /> -
- - updateField('database_auto_cleanup_retention_days', value) - } - /> -

- 小于表 TTL 时自动按下限执行:访问日志 90 天,其它观测数据 30 - 天。 -

-
-
-
- - - - 手动数据清理 - - 按表 TTL 清理;输入天数不能小于对应表 - TTL,留空时将删除该类数据的全部历史记录。 - - - - {cleanupTargets.map((item) => ( -
-
-

{item.label}

-

- {item.description} -

-
- -
- ))} -
-
-
- - - !open && setCleanupTarget(null)} - > - - - 清理{cleanupTarget?.label} - - 输入保留天数后会按该表 TTL 物化过期数据;小于表 TTL - 的天数会被拒绝。留空则删除全部历史记录,操作不可恢复。 - - - - - - 取消 - - { - event.preventDefault(); - if (!cleanupTarget) return; - const trimmed = cleanupRetentionDays.trim(); - if (trimmed !== '') { - const retentionDays = Number.parseInt(trimmed, 10); - if (Number.isNaN(retentionDays) || retentionDays < 1) { - toast.error('保留天数至少为 1 天'); - return; - } - cleanupMutation.mutate({ - target: cleanupTarget.target, - retention_days: retentionDays, - }); - return; - } - cleanupMutation.mutate({ target: cleanupTarget.target }); - }} - > - {cleanupMutation.isPending ? '清理中...' : '确认清理'} - - - - ); } diff --git a/frontend/app/(main)/admin/tasks/components/task-manager.tsx b/frontend/app/(main)/admin/tasks/components/task-manager.tsx index b25b1403..787578a5 100644 --- a/frontend/app/(main)/admin/tasks/components/task-manager.tsx +++ b/frontend/app/(main)/admin/tasks/components/task-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -8,6 +8,13 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Spinner } from '@/components/ui/spinner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Dialog, DialogContent, @@ -19,12 +26,17 @@ import { import { Calendar as CalendarIcon, Clock, + Database, Info, Layers, Play, } from 'lucide-react'; -import type { DispatchTaskRequest, TaskMeta } from '@/lib/services/admin'; +import type { + DispatchTaskRequest, + LogDatabaseStatus, + TaskMeta, +} from '@/lib/services/admin'; import services from '@/lib/services'; import { buildTaskPayload } from '@/lib/task-param-utils'; import { ErrorInline } from '@/components/layout/error'; @@ -68,6 +80,18 @@ const TASK_CONFIGS: Record< gradient: 'from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500', }, + of_log_db_switch: { + icon: Database, + color: 'text-teal-600 dark:text-teal-400', + gradient: + 'from-teal-500/10 via-teal-500/5 to-transparent border-teal-200/50 dark:border-teal-800/50 hover:border-teal-400 dark:hover:border-teal-500', + }, +}; + +const LOG_DATABASE_LABELS: Record = { + postgres: 'PostgreSQL(主库)', + sqlite: 'SQLite(主库)', + clickhouse: 'ClickHouse', }; const DEFAULT_TASK_CONFIG = { @@ -192,9 +216,38 @@ export function TaskManager() { } }, []); + const [logDbStatus, setLogDbStatus] = useState( + null, + ); + + // 日志库状态用于「切换日志数据库」卡片与 target 下拉;获取失败不阻塞任务列表。 + const fetchLogDbStatus = useCallback(async () => { + try { + const data = await services.adminStatus.getLogDatabaseStatus(); + setLogDbStatus(data); + } catch { + setLogDbStatus(null); + } + }, []); + useEffect(() => { fetchTaskTypes(); - }, [fetchTaskTypes]); + fetchLogDbStatus(); + }, [fetchTaskTypes, fetchLogDbStatus]); + + const availableLogDbTargets = useMemo( + () => logDbStatus?.available_targets ?? [], + [logDbStatus], + ); + + const retentionSummary = useMemo(() => { + const days = logDbStatus?.retention_days ?? {}; + const parts: string[] = []; + if (days.postgres != null) parts.push(`PG ${days.postgres}`); + if (days.sqlite != null) parts.push(`SQLite ${days.sqlite}`); + if (days.clickhouse != null) parts.push(`CH ${days.clickhouse}`); + return parts.join(' / '); + }, [logDbStatus]); useEffect(() => { if (selectedTaskType) { @@ -344,6 +397,45 @@ export function TaskManager() { + {task.type === 'of_log_db_switch' && logDbStatus && ( +
+
+ + 日志主库 + + + {LOG_DATABASE_LABELS[logDbStatus.active_database] || + logDbStatus.active_database} + +
+
+ + 保留天数 + + + {retentionSummary || '-'} + +
+
+ + 迁移状态 + + + {logDbStatus.migration === 'migrating' + ? '迁移中' + : '空闲'} + +
+
+ )} +