# 日志数据库解耦(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) { t.Chdir("../../..") out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./internal/apps/...").Output() if err != nil { t.Fatalf("go list: %v", err) } for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) == 0 { continue } pkg := fields[0] if !strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/internal/apps") { continue } for _, imp := range fields[1:] { for _, forbidden := range forbiddenImports { if imp == forbidden && !allowedAnalyticsDelegation[pkg] { t.Errorf("%s must not import forbidden log backend %s", pkg, forbidden) } } if strings.HasPrefix(imp, "github.com/Rain-kl/Wavelet/internal/infra/persistence/") { allowed := false for _, a := range allowedInfraPersistence { if imp == a || strings.HasPrefix(imp, a+"/") { allowed = true break } } if !allowed { t.Errorf("%s must not import infra/persistence subpackage directly: %s", pkg, imp) } } } } } ``` > 说明:`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, "") }() // 失败也清除,保持源库可写 if err := drainLogWriters(ctx); err != nil { return nil, fmt.Errorf("排空日志写入队列失败: %w", err) } 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 := copyUserAccessLogs(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、UserAccessLogs.DeleteAll、Observability.DeleteAll*(SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。 if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil { return fmt.Errorf("清空目标访问日志失败: %w", err) } if _, err := dst.UserAccessLogs.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("")` 保证失败后源库恢复可写;重试时先清空目标再复制(幂等)。