refactor(structure): group platform, infra, and shared packages

Reorganize internal packages into platform/infra/shared layers and update
imports, docs, and seed-count tests to match current system configs.
This commit is contained in:
ryan
2026-07-24 15:41:59 +08:00
parent 68d730a388
commit 33a1c32cf8
470 changed files with 646 additions and 646 deletions
+299
View File
@@ -0,0 +1,299 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package config 负责应用配置的加载、解析与环境变量覆盖。
package config
import (
"encoding/json"
"flag"
"log"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
)
// 默认队列优先级
const (
webhookQueuePriority = 10
whitelistQueuePriority = 5
defaultQueuePriority = 3
)
// Config 全局配置单例,初始化后不可变
var Config *configModel
// findConfigPath searches upward for the config file to handle tests running in subdirectories.
func findConfigPath(configPath string) string {
if _, err := os.Stat(configPath); err == nil {
return configPath
}
dir := "."
for i := 0; i < 5; i++ {
dir += "/.."
path := dir + "/" + configPath
if _, err := os.Stat(path); err == nil {
return path
}
}
return configPath
}
// isTest checks if the current execution context is within 'go test'.
func isTest() bool {
if flag.Lookup("test.v") != nil {
return true
}
for _, arg := range os.Args {
if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") {
return true
}
}
return false
}
func init() {
// 加载配置文件路径
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = findConfigPath("config.yaml")
}
// 设置配置文件
viper.SetConfigFile(configPath)
viper.AutomaticEnv()
// 读取配置文件(可选:找不到文件时使用空默认值 + 环境变量)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// 文件存在但读取/解析失败
if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
log.Fatalf("[Config] read config failed: %v\n", err)
}
}
log.Println("[Config] no config file found, using environment variables only")
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader("")); err != nil {
log.Fatalf("[Config] failed to init empty config: %v\n", err)
}
}
// 解析配置到结构体
var c configModel
if err := viper.Unmarshal(&c); err != nil {
log.Fatalf("[Config] parse config failed: %v\n", err)
}
applyDefaults(&c)
// 环境变量覆盖(优先级高于 config.yaml)
applyEnvOverrides(&c)
applyDefaults(&c)
// Disable standard DB/Redis/ClickHouse initializations during tests to prevent connection attempts.
if isTest() {
c.Database.Enabled = false
c.Database.SQLitePath = ":memory:"
c.Redis.Enabled = false
}
// 设置全局配置
Config = &c
// 打印配置
printConfig(&c)
}
func applyDefaults(c *configModel) {
if c.App.SessionAge <= 0 {
c.App.SessionAge = 86400
}
if c.Otel.TracerName == "" {
c.Otel.TracerName = "github.com/Rain-kl/OpenFlare"
}
applyClickHouseDefaults(c)
}
func applyClickHouseDefaults(c *configModel) {
// Tests disable ClickHouse by default to avoid accidental connections.
// Opt in with CLICKHOUSE_ENABLED=true for live integration tests (e.g. -tags live_ch).
if isTest() {
if v, ok := os.LookupEnv("CLICKHOUSE_ENABLED"); !ok {
c.ClickHouse.Enabled = false
return
} else if b, err := strconv.ParseBool(v); err != nil || !b {
c.ClickHouse.Enabled = false
return
}
// Keep Enabled=true from env and continue applying host/pool defaults.
}
if !c.ClickHouse.Enabled {
c.ClickHouse.Enabled = true
}
if c.ClickHouse.Database == "" {
c.ClickHouse.Database = "openflare"
}
if len(c.ClickHouse.Hosts) == 0 {
c.ClickHouse.Hosts = []string{"127.0.0.1:9000"}
}
if c.ClickHouse.Username == "" {
c.ClickHouse.Username = "default"
}
// Pool / buffer defaults target small control-plane hosts (e.g. 3c6g):
// oversized open/idle pools waste RAM and amplify concurrent CH pressure;
// large block buffers add client memory without helping our small batch inserts.
if c.ClickHouse.MaxIdleConn <= 0 {
c.ClickHouse.MaxIdleConn = 8
}
if c.ClickHouse.MaxOpenConn <= 0 {
c.ClickHouse.MaxOpenConn = 16
}
if c.ClickHouse.ConnMaxLifetime <= 0 {
c.ClickHouse.ConnMaxLifetime = 3600
}
if c.ClickHouse.DialTimeout <= 0 {
c.ClickHouse.DialTimeout = 5
}
if c.ClickHouse.BlockBufferSize == 0 {
c.ClickHouse.BlockBufferSize = 32
}
}
// ─── 环境变量覆盖层 ────────────────────────────────────────────────────────────
// 环境变量优先级高于 config.yaml,未设置则保留 yaml 中的值。
func envStr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
func envInt64(key string, fallback int64) int64 {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return fallback
}
func envFloat64(key string, fallback float64) float64 {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
// applyEnvOverrides 将环境变量值覆盖到配置结构体上(仅当环境变量已设置时生效)
func applyEnvOverrides(c *configModel) {
// ─── App ───
c.App.AppName = envStr("APP_NAME", c.App.AppName)
c.App.Env = envStr("APP_ENV", c.App.Env)
c.App.Addr = envStr("APP_ADDR", c.App.Addr)
c.App.NodeID = envInt64("APP_NODE_ID", c.App.NodeID)
c.App.APIPrefix = envStr("APP_API_PREFIX", c.App.APIPrefix)
c.App.GracefulShutdownTimeout = envInt("APP_GRACEFUL_SHUTDOWN_TIMEOUT", c.App.GracefulShutdownTimeout)
c.App.SessionCookieName = envStr("APP_SESSION_COOKIE_NAME", c.App.SessionCookieName)
c.App.SessionSecret = envStr("APP_SESSION_SECRET", c.App.SessionSecret)
c.App.SessionDomain = envStr("APP_SESSION_DOMAIN", c.App.SessionDomain)
c.App.SessionAge = envInt("APP_SESSION_AGE", c.App.SessionAge)
c.App.SessionHTTPOnly = envBool("APP_SESSION_HTTP_ONLY", c.App.SessionHTTPOnly)
c.App.SessionSecure = envBool("APP_SESSION_SECURE", c.App.SessionSecure)
// ─── Database ───
c.Database.Host = envStr("DB_HOST", c.Database.Host)
c.Database.Port = envInt("DB_PORT", c.Database.Port)
c.Database.Username = envStr("DB_USERNAME", c.Database.Username)
c.Database.Password = envStr("DB_PASSWORD", c.Database.Password)
c.Database.Database = envStr("DB_NAME", c.Database.Database)
c.Database.SSLMode = envStr("DB_SSL_MODE", c.Database.SSLMode)
c.Database.TimeZone = envStr("DB_TIMEZONE", c.Database.TimeZone)
c.Database.LogLevel = envStr("DB_LOG_LEVEL", c.Database.LogLevel)
c.Database.MaxIdleConn = envInt("DB_MAX_IDLE_CONN", c.Database.MaxIdleConn)
c.Database.MaxOpenConn = envInt("DB_MAX_OPEN_CONN", c.Database.MaxOpenConn)
// 当 DB_HOST 环境变量已设置时自动启用数据库
if _, ok := os.LookupEnv("DB_HOST"); ok {
c.Database.Enabled = true
}
c.Database.Enabled = envBool("DB_ENABLED", c.Database.Enabled)
c.Database.SQLitePath = envStr("SQLITE_PATH", c.Database.SQLitePath)
// ─── Redis ───
if v, ok := os.LookupEnv("REDIS_ADDR"); ok {
c.Redis.Addrs = []string{v}
c.Redis.Enabled = true // 当 REDIS_ADDR 已设置时自动启用
}
c.Redis.Enabled = envBool("REDIS_ENABLED", c.Redis.Enabled)
c.Redis.Username = envStr("REDIS_USERNAME", c.Redis.Username)
c.Redis.Password = envStr("REDIS_PASSWORD", c.Redis.Password)
c.Redis.DB = envInt("REDIS_DB", c.Redis.DB)
c.Redis.KeyPrefix = envStr("REDIS_KEY_PREFIX", c.Redis.KeyPrefix)
c.Redis.PoolSize = envInt("REDIS_POOL_SIZE", c.Redis.PoolSize)
c.Redis.MaintNotifications = envBool("REDIS_MAINT_NOTIFICATIONS", c.Redis.MaintNotifications)
// ─── ClickHouse ───
if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok {
c.ClickHouse.Hosts = []string{v}
c.ClickHouse.Enabled = true
}
c.ClickHouse.Enabled = envBool("CLICKHOUSE_ENABLED", c.ClickHouse.Enabled)
c.ClickHouse.Username = envStr("CLICKHOUSE_USERNAME", c.ClickHouse.Username)
c.ClickHouse.Password = envStr("CLICKHOUSE_PASSWORD", c.ClickHouse.Password)
c.ClickHouse.Database = envStr("CLICKHOUSE_NAME", c.ClickHouse.Database)
// ─── Log ───
c.Log.Level = envStr("LOG_LEVEL", c.Log.Level)
c.Log.Format = envStr("LOG_FORMAT", c.Log.Format)
c.Log.Output = envStr("LOG_OUTPUT", c.Log.Output)
// ─── OTel ───
c.Otel.SamplingRate = envFloat64("OTEL_SAMPLING_RATE", c.Otel.SamplingRate)
c.Otel.TracerName = envStr("OTEL_TRACER_NAME", c.Otel.TracerName)
// ─── Worker ───
c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency)
c.Worker.StrictPriority = envBool("WORKER_STRICT_PRIORITY", c.Worker.StrictPriority)
// 无 yaml 且无环境变量时,使用代码级默认队列
if len(c.Worker.Queues) == 0 {
c.Worker.Queues = []QueueConfig{
{Name: "webhook", Priority: webhookQueuePriority},
{Name: "whitelist_only", Priority: whitelistQueuePriority},
{Name: "default", Priority: defaultQueuePriority},
}
}
}
// printConfig 打印配置内容
func printConfig(c *configModel) {
configJSON, err := json.MarshalIndent(c, "", " ")
if err != nil {
log.Printf("[Config] failed to marshal config: %v\n", err)
return
}
log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON))
}
+14
View File
@@ -0,0 +1,14 @@
package config
import "testing"
func TestApplyEnvOverridesRedisMaintNotifications(t *testing.T) {
t.Setenv("REDIS_MAINT_NOTIFICATIONS", "true")
cfg := &configModel{}
applyEnvOverrides(cfg)
if !cfg.Redis.MaintNotifications {
t.Fatal("REDIS_MAINT_NOTIFICATIONS=true was not applied")
}
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config
import "time"
type configModel struct {
App appConfig `mapstructure:"app"`
Database databaseConfig `mapstructure:"database"`
Redis redisConfig `mapstructure:"redis"`
Log logConfig `mapstructure:"log"`
Scheduler schedulerConfig `mapstructure:"scheduler"`
Worker workerConfig `mapstructure:"worker"`
ClickHouse clickHouseConfig `mapstructure:"clickhouse"`
Otel otelConfig `mapstructure:"otel"`
}
// appConfig 应用基本配置
type appConfig struct {
AppName string `mapstructure:"app_name"`
Env string `mapstructure:"env"`
Addr string `mapstructure:"addr"`
NodeID int64 `mapstructure:"node_id"`
APIPrefix string `mapstructure:"api_prefix"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
SessionCookieName string `mapstructure:"session_cookie_name"`
SessionSecret string `mapstructure:"session_secret"`
SessionDomain string `mapstructure:"session_domain"`
SessionAge int `mapstructure:"session_age"`
SessionHTTPOnly bool `mapstructure:"session_http_only"`
SessionSecure bool `mapstructure:"session_secure"`
}
// IsProduction 检查当前环境是否为生产环境
func (a *appConfig) IsProduction() bool {
return a.Env == "production"
}
// databaseConfig 数据库配置
type databaseConfig struct {
Enabled bool `mapstructure:"enabled"`
SQLitePath string `mapstructure:"sqlite_path"` // PostgreSQL 禁用时的 SQLite 文件路径
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxIdleConn int `mapstructure:"max_idle_conn"`
MaxOpenConn int `mapstructure:"max_open_conn"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
LogLevel string `mapstructure:"log_level"`
SSLMode string `mapstructure:"ssl_mode"`
TimeZone string `mapstructure:"time_zone"`
ApplicationName string `mapstructure:"application_name"`
SearchPath string `mapstructure:"search_path"`
PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"`
StatementCacheCapacity int `mapstructure:"statement_cache_capacity"`
DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"`
Replicas []databaseReplicaConfig `mapstructure:"replicas"`
SlowThreshold time.Duration `mapstructure:"slow_threshold"`
}
// databaseReplicaConfig 只读副本配置
type databaseReplicaConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// clickHouseConfig ClickHouse 原生客户端配置。
// 连接池 / block_buffer 默认值按小型控制面主机(如 3c6g)收敛,见 applyClickHouseDefaults。
type clickHouseConfig struct {
Enabled bool `mapstructure:"enabled"`
Hosts []string `mapstructure:"hosts"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxIdleConn int `mapstructure:"max_idle_conn"` // 默认 8
MaxOpenConn int `mapstructure:"max_open_conn"` // 默认 16
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // 秒
DialTimeout int `mapstructure:"dial_timeout"` // 秒
BlockBufferSize uint8 `mapstructure:"block_buffer_size"` // 默认 32
}
// redisConfig Redis配置
type redisConfig struct {
Enabled bool `mapstructure:"enabled"`
Addrs []string `mapstructure:"addrs"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
ClusterMode bool `mapstructure:"cluster_mode"`
MasterName string `mapstructure:"master_name"`
KeyPrefix string `mapstructure:"key_prefix"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConn int `mapstructure:"min_idle_conn"`
DialTimeout int `mapstructure:"dial_timeout"`
ReadTimeout int `mapstructure:"read_timeout"`
WriteTimeout int `mapstructure:"write_timeout"`
MaxRetries int `mapstructure:"max_retries"`
PoolTimeout int `mapstructure:"pool_timeout"`
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
MaintNotifications bool `mapstructure:"maint_notifications"`
}
// logConfig 日志配置
type logConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
Output string `mapstructure:"output"`
FilePath string `mapstructure:"file_path"`
MaxSize int `mapstructure:"max_size"`
MaxAge int `mapstructure:"max_age"`
MaxBackups int `mapstructure:"max_backups"`
Compress bool `mapstructure:"compress"`
}
// schedulerConfig 定时任务配置
type schedulerConfig struct {
}
// workerConfig 工作配置
type workerConfig struct {
Concurrency int `mapstructure:"concurrency"`
StrictPriority bool `mapstructure:"strict_priority"`
Queues []QueueConfig `mapstructure:"queues"`
}
// QueueConfig 队列配置
type QueueConfig struct {
Name string `mapstructure:"name"`
Priority int `mapstructure:"priority"`
}
// otelConfig OpenTelemetry 配置
type otelConfig struct {
SamplingRate float64 `mapstructure:"sampling_rate"`
TracerName string `mapstructure:"tracer_name"`
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package diskcache wraps the generic pkg/cache/disk to provide database configuration integration.
package diskcache
import (
"context"
"strconv"
"sync"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
pkgcache "github.com/Rain-kl/Wavelet/pkg/cache/disk"
)
// Status represents the runtime cache statistics.
type Status = pkgcache.Status
const (
defaultCacheDir = "uploads/diskcache"
defaultMaxSizeMB = 100
defaultTTLMinutes = 60
defaultCleanupInterval = 10
// DefaultExpiration applies the cache-wide default TTL.
DefaultExpiration = pkgcache.DefaultExpiration
// NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply.
NoExpiration = pkgcache.NoExpiration
)
// ErrCacheMiss represents a cache miss.
var ErrCacheMiss = pkgcache.ErrCacheMiss
// DiskCache is a wrapper around the generic pkg/diskcache that integrates with the DB for configs.
type DiskCache struct {
*pkgcache.Cache
}
var (
globalCache *DiskCache
globalCacheOnce sync.Once
)
// GetGlobalCache returns the global singleton DiskCache instance.
func GetGlobalCache() *DiskCache {
globalCacheOnce.Do(func() {
pureCache := pkgcache.New(defaultCacheDir)
globalCache = &DiskCache{pureCache}
// Load initial configs from database
globalCache.ReloadConfig(context.Background())
// Start background routine to clean expired items every 10 minutes
go globalCache.StartCleanupWorker(defaultCleanupInterval * time.Minute)
})
return globalCache
}
// New creates a new DiskCache wrapper.
func New(basePath string) *DiskCache {
return &DiskCache{pkgcache.New(basePath)}
}
// ReloadConfig reloads policies from database configs dynamically.
func (c *DiskCache) ReloadConfig(ctx context.Context) {
// Ensure DB is initialized before querying
if db.DB(ctx) == nil {
return
}
// 1. Max Size
maxSizeMB := int64(defaultMaxSizeMB)
if scMaxSize, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheMaxSizeMB); err == nil && scMaxSize.Value != "" {
if val, err := strconv.ParseInt(scMaxSize.Value, 10, 64); err == nil && val > 0 {
maxSizeMB = val
}
}
// 2. Default TTL
ttlMinutes := int64(defaultTTLMinutes)
if scTTL, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheTTLMinutes); err == nil && scTTL.Value != "" {
if val, err := strconv.ParseInt(scTTL.Value, 10, 64); err == nil && val >= 0 {
ttlMinutes = val
}
}
// 3. LRU Enabled
lruEnabled := true
if scLRU, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheLRUEnabled); err == nil && scLRU.Value != "" {
if val, err := strconv.ParseBool(scLRU.Value); err == nil {
lruEnabled = val
}
}
c.UpdatePolicy(maxSizeMB, ttlMinutes, lruEnabled)
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package diskcache
import (
"context"
"os"
"testing"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
)
func TestDiskCacheReloadConfig(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
testDir := "uploads/test_diskcache_reload"
defer func() { _ = os.RemoveAll(testDir) }()
_ = os.RemoveAll(testDir)
c := New(testDir)
defer func() { _ = c.Clear() }()
// Update DB config values
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheMaxSizeMB).Update("value", "250")
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheTTLMinutes).Update("value", "120")
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheLRUEnabled).Update("value", "false")
// Invalidate Redis config cache to force DB reload
if db.Redis != nil {
db.Redis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey))
}
// Reload config
c.ReloadConfig(context.Background())
status := c.Status()
if status.MaxSizeMB != 250 {
t.Errorf("expected MaxSizeMB to be 250, got %d", status.MaxSizeMB)
}
if status.TTLMinutes != 120 {
t.Errorf("expected TTLMinutes to be 120, got %d", status.TTLMinutes)
}
if status.LRUEnabled != false {
t.Errorf("expected LRUEnabled to be false, got %t", status.LRUEnabled)
}
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package objectstore provides dynamically configured file storage backends.
package objectstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"gorm.io/gorm"
)
// Driver identifies a supported storage backend.
type Driver string
const (
// DriverLocal stores files on the local filesystem.
DriverLocal Driver = "local"
// DriverS3 stores files in an S3-compatible object store.
DriverS3 Driver = "s3"
// DriverR2 stores files in Cloudflare R2.
DriverR2 Driver = "r2"
// DriverMinIO stores files in MinIO.
DriverMinIO Driver = "minio"
// DriverOSS stores files in Aliyun OSS.
DriverOSS Driver = "oss"
// DriverWebDAV stores files through WebDAV.
DriverWebDAV Driver = "webdav"
// ConfigMask replaces secrets returned to the frontend.
ConfigMask = "******"
)
// LocalConfig configures local filesystem storage.
type LocalConfig struct {
Root string `json:"root"`
}
// ObjectConfig configures S3-compatible or OSS object storage.
type ObjectConfig struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
AccountID string `json:"account_id,omitempty"`
PathStyle bool `json:"path_style"`
KeyPrefix string `json:"key_prefix"`
CDNURL string `json:"cdn_url"`
}
// WebDAVConfig configures WebDAV storage.
type WebDAVConfig struct {
Endpoint string `json:"endpoint"`
Username string `json:"username"`
Password string `json:"password"`
BasePath string `json:"base_path"`
}
// Config contains all storage backends and the currently active driver.
type Config struct {
Driver Driver `json:"driver"`
Local LocalConfig `json:"local"`
S3 ObjectConfig `json:"s3"`
R2 ObjectConfig `json:"r2"`
MinIO ObjectConfig `json:"minio"`
OSS ObjectConfig `json:"oss"`
WebDAV WebDAVConfig `json:"webdav"`
}
// DefaultConfig returns the local-storage default configuration.
func DefaultConfig() Config {
return Config{
Driver: DriverLocal,
Local: LocalConfig{Root: "."},
S3: ObjectConfig{Region: "us-east-1"},
R2: ObjectConfig{Region: "auto"},
MinIO: ObjectConfig{Region: "us-east-1", PathStyle: true},
}
}
// LoadConfig loads the active storage configuration.
func LoadConfig(ctx context.Context) (Config, error) {
pubSubOnce.Do(startPubSubListener)
cacheMutex.RLock()
isCacheValid := time.Since(lastChecked) < 5*time.Second && activeConfigJSON != ""
configJSON := activeConfigJSON
cacheMutex.RUnlock()
if isCacheValid {
cfg := DefaultConfig()
if strings.TrimSpace(configJSON) != "" {
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return Config{}, fmt.Errorf("parse storage config from cache: %w", err)
}
}
return cfg, nil
}
return loadConfigByKey(ctx, model.ConfigKeyStorageConfig, DefaultConfig())
}
func loadConfigByKey(ctx context.Context, key string, fallback Config) (Config, error) {
sc, err := repository.GetSystemConfigByKey(ctx, key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fallback, nil
}
return Config{}, err
}
if strings.TrimSpace(sc.Value) == "" {
return fallback, nil
}
if err := json.Unmarshal([]byte(sc.Value), &fallback); err != nil {
return Config{}, fmt.Errorf("parse %s: %w", key, err)
}
return fallback, nil
}
// ValidateConfig validates the selected backend configuration.
func ValidateConfig(cfg Config) error {
switch cfg.Driver {
case DriverLocal:
if strings.TrimSpace(cfg.Local.Root) == "" {
return errors.New("local root is required")
}
case DriverS3:
return validateObjectConfig(cfg.S3, false)
case DriverR2:
if strings.TrimSpace(cfg.R2.AccountID) == "" && strings.TrimSpace(cfg.R2.Endpoint) == "" {
return errors.New("R2 account ID or endpoint is required")
}
return validateObjectConfig(cfg.R2, false)
case DriverMinIO:
if strings.TrimSpace(cfg.MinIO.Endpoint) == "" {
return errors.New("MinIO endpoint is required")
}
return validateObjectConfig(cfg.MinIO, true)
case DriverOSS:
if strings.TrimSpace(cfg.OSS.Endpoint) == "" {
return errors.New("OSS endpoint is required")
}
return validateObjectConfig(cfg.OSS, true)
case DriverWebDAV:
if strings.TrimSpace(cfg.WebDAV.Endpoint) == "" {
return errors.New("WebDAV endpoint is required")
}
default:
return fmt.Errorf("unsupported storage driver %q", cfg.Driver)
}
return nil
}
func validateObjectConfig(cfg ObjectConfig, endpointRequired bool) error {
if endpointRequired && strings.TrimSpace(cfg.Endpoint) == "" {
return errors.New("endpoint is required")
}
if strings.TrimSpace(cfg.Region) == "" {
return errors.New("region is required")
}
if strings.TrimSpace(cfg.Bucket) == "" {
return errors.New("bucket is required")
}
if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
return errors.New("access key ID and secret access key are required")
}
return nil
}
// SaveActiveConfig persists the active storage configuration.
func SaveActiveConfig(ctx context.Context, cfg Config) error {
return saveSystemConfig(ctx, model.ConfigKeyStorageConfig, cfg, "文件存储驱动及连接配置(JSON)")
}
func saveSystemConfig(ctx context.Context, key string, value any, description string) error {
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
return upsertSystemConfig(ctx, tx, key, value, description)
})
if err == nil && key == model.ConfigKeyStorageConfig {
ResetCache()
PublishCacheInvalidation(ctx)
}
return err
}
func upsertSystemConfig(ctx context.Context, tx *gorm.DB, key string, value any, description string) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshal %s: %w", key, err)
}
sc := model.SystemConfig{
Key: key,
Value: string(data),
Type: "system",
Visibility: model.ConfigVisibilityHidden,
Description: description,
}
if err := tx.Where("key = ?", key).
Assign(map[string]any{"value": sc.Value, "description": description, "visibility": model.ConfigVisibilityHidden}).
FirstOrCreate(&sc).Error; err != nil {
return err
}
return repository.InvalidateSystemConfigCache(ctx, key)
}
// MergeMaskedSecrets restores unchanged secrets from the current configuration.
func MergeMaskedSecrets(next, current Config) Config {
mergeObjectSecret := func(dst *ObjectConfig, src ObjectConfig) {
if dst.AccessKeyID == ConfigMask {
dst.AccessKeyID = src.AccessKeyID
}
if dst.SecretAccessKey == ConfigMask {
dst.SecretAccessKey = src.SecretAccessKey
}
}
mergeObjectSecret(&next.S3, current.S3)
mergeObjectSecret(&next.R2, current.R2)
mergeObjectSecret(&next.MinIO, current.MinIO)
mergeObjectSecret(&next.OSS, current.OSS)
if next.WebDAV.Password == ConfigMask {
next.WebDAV.Password = current.WebDAV.Password
}
return next
}
// MaskSecrets replaces stored credentials with placeholders for API responses.
func MaskSecrets(cfg Config) Config {
maskObject := func(value *ObjectConfig) {
if value.AccessKeyID != "" {
value.AccessKeyID = ConfigMask
}
if value.SecretAccessKey != "" {
value.SecretAccessKey = ConfigMask
}
}
maskObject(&cfg.S3)
maskObject(&cfg.R2)
maskObject(&cfg.MinIO)
maskObject(&cfg.OSS)
if cfg.WebDAV.Password != "" {
cfg.WebDAV.Password = ConfigMask
}
return cfg
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/Rain-kl/Wavelet/pkg/httppool"
)
func getHTTPObject(ctx context.Context, baseURL, key string) (*Object, error) {
objectURL, err := url.JoinPath(baseURL, key)
if err != nil {
return nil, fmt.Errorf("build CDN object URL: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, objectURL, nil)
if err != nil {
return nil, fmt.Errorf("create CDN request: %w", err)
}
const cdnRequestTimeout = 30 * time.Second
client := httppool.NewClient(cdnRequestTimeout)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("get CDN object: %w", err)
}
if response.StatusCode != http.StatusOK {
_ = response.Body.Close()
return nil, fmt.Errorf("get CDN object: unexpected status %d", response.StatusCode)
}
contentType := response.Header.Get("Content-Type")
if contentType == "" {
contentType = defaultContentType
}
return &Object{
Body: response.Body,
ContentLength: response.ContentLength,
ContentType: contentType,
}, nil
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
)
type localBackend struct {
root string
}
func newLocalBackend(cfg LocalConfig) (*localBackend, error) {
root := filepath.Clean(cfg.Root)
if root == "" {
return nil, fmt.Errorf("local root is required")
}
return &localBackend{root: root}, nil
}
func (b *localBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) {
path, err := b.path(key)
if err != nil {
return PutResult{}, err
}
if err := os.MkdirAll(filepath.Dir(path), storageDirPerm); err != nil {
return PutResult{}, err
}
file, err := os.OpenFile( //nolint:gosec // path is constrained to the configured storage root.
path,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
storageFilePerm,
)
if err != nil {
return PutResult{}, err
}
if _, err := io.Copy(file, body); err != nil {
_ = file.Close()
_ = os.Remove(path)
return PutResult{}, err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return PutResult{}, err
}
return PutResult{Key: filepath.ToSlash(key)}, nil
}
func (b *localBackend) Get(_ context.Context, key string) (*Object, error) {
path, err := b.path(key)
if err != nil {
return nil, err
}
file, err := os.Open(path) //nolint:gosec // path is constrained to the configured storage root.
if err != nil {
return nil, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = defaultContentType
}
return &Object{Body: file, ContentLength: info.Size(), ContentType: contentType}, nil
}
func (b *localBackend) Delete(_ context.Context, key string) error {
path, err := b.path(key)
if err != nil {
return err
}
err = os.Remove(path)
if os.IsNotExist(err) {
return nil
}
return err
}
func (b *localBackend) Test(_ context.Context) error {
return os.MkdirAll(b.root, storageDirPerm)
}
func (b *localBackend) path(key string) (string, error) {
if filepath.IsAbs(key) {
cleanPath := filepath.Clean(key)
absRoot, err := filepath.Abs(b.root)
if err != nil {
return "", err
}
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", err
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("storage key escapes local root")
}
return cleanPath, nil
}
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/")))
if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") {
return "", fmt.Errorf("invalid local storage key %q", key)
}
path := filepath.Join(b.root, cleanKey)
rel, err := filepath.Rel(b.root, path)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("storage key escapes local root")
}
return path, nil
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"bytes"
"context"
"io"
"testing"
)
func TestLocalBackendRoundTrip(t *testing.T) {
backend, err := newLocalBackend(LocalConfig{Root: t.TempDir()})
if err != nil {
t.Fatalf("newLocalBackend() returned error: %v", err)
}
ctx := context.Background()
const key = "uploads/2026/06/13/test.txt"
const content = "wavelet storage"
storedResult, err := backend.Put(ctx, key, bytes.NewBufferString(content), int64(len(content)), "text/plain")
if err != nil {
t.Fatalf("Put(%q) returned error: %v", key, err)
}
if storedResult.Key != key {
t.Errorf("Put(%q) key = %q, want %q", key, storedResult.Key, key)
}
object, err := backend.Get(ctx, key)
if err != nil {
t.Fatalf("Get(%q) returned error: %v", key, err)
}
got, err := io.ReadAll(object.Body)
if err != nil {
t.Fatalf("ReadAll(Get(%q)) returned error: %v", key, err)
}
if err := object.Body.Close(); err != nil {
t.Fatalf("Close(Get(%q)) returned error: %v", key, err)
}
if string(got) != content {
t.Errorf("Get(%q) content = %q, want %q", key, got, content)
}
if err := backend.Delete(ctx, key); err != nil {
t.Fatalf("Delete(%q) returned error: %v", key, err)
}
if _, err := backend.Get(ctx, key); err == nil {
t.Errorf("Get(%q) after Delete() returned nil error", key)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"fmt"
"io"
"strings"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
type ossBackend struct {
client *oss.Client
bucket string
keyPrefix string
cdnURL string
}
func newOSSBackend(cfg ObjectConfig) (*ossBackend, error) {
options := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey)).
WithRegion(cfg.Region)
if cfg.Endpoint != "" {
options.WithEndpoint(cfg.Endpoint)
}
return &ossBackend{
client: oss.NewClient(options),
bucket: cfg.Bucket,
keyPrefix: strings.Trim(cfg.KeyPrefix, "/"),
cdnURL: strings.TrimRight(cfg.CDNURL, "/"),
}, nil
}
func (b *ossBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) {
key = b.key(key)
_, err := b.client.PutObject(ctx, &oss.PutObjectRequest{
Bucket: oss.Ptr(b.bucket),
Key: oss.Ptr(key),
Body: body,
})
if err != nil {
return PutResult{}, fmt.Errorf("put OSS object: %w", err)
}
return PutResult{Key: key, Bucket: b.bucket}, nil
}
func (b *ossBackend) Get(ctx context.Context, key string) (*Object, error) {
key = b.key(key)
if b.cdnURL != "" {
return getHTTPObject(ctx, b.cdnURL, key)
}
output, err := b.client.GetObject(ctx, &oss.GetObjectRequest{
Bucket: oss.Ptr(b.bucket),
Key: oss.Ptr(key),
})
if err != nil {
return nil, fmt.Errorf("get OSS object: %w", err)
}
contentType := defaultContentType
if output.ContentType != nil {
contentType = *output.ContentType
}
return &Object{Body: output.Body, ContentLength: output.ContentLength, ContentType: contentType}, nil
}
func (b *ossBackend) Delete(ctx context.Context, key string) error {
_, err := b.client.DeleteObject(ctx, &oss.DeleteObjectRequest{
Bucket: oss.Ptr(b.bucket),
Key: oss.Ptr(b.key(key)),
})
if err != nil {
return fmt.Errorf("delete OSS object: %w", err)
}
return nil
}
func (b *ossBackend) Test(ctx context.Context) error {
ok, err := b.client.IsBucketExist(ctx, b.bucket)
if err != nil {
return fmt.Errorf("access OSS bucket: %w", err)
}
if !ok {
return fmt.Errorf("OSS bucket %q does not exist", b.bucket)
}
return nil
}
func (b *ossBackend) key(key string) string {
key = strings.TrimLeft(key, "/")
if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") {
return key
}
return b.keyPrefix + "/" + key
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"fmt"
"io"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type s3Backend struct {
client *s3.Client
bucket string
keyPrefix string
cdnURL string
}
func newS3Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKeyID,
cfg.SecretAccessKey,
"",
)),
)
if err != nil {
return nil, fmt.Errorf("load S3 config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(options *s3.Options) {
if cfg.Endpoint != "" {
options.BaseEndpoint = aws.String(strings.TrimRight(cfg.Endpoint, "/"))
}
options.UsePathStyle = cfg.PathStyle
})
return &s3Backend{
client: client,
bucket: cfg.Bucket,
keyPrefix: strings.Trim(cfg.KeyPrefix, "/"),
cdnURL: strings.TrimRight(cfg.CDNURL, "/"),
}, nil
}
func newR2Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) {
if cfg.Endpoint == "" {
cfg.Endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
}
cfg.Region = "auto"
return newS3Backend(ctx, cfg)
}
func (b *s3Backend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) {
key = b.key(key)
_, err := b.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(key),
Body: body,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
if err != nil {
return PutResult{}, fmt.Errorf("put S3 object: %w", err)
}
return PutResult{Key: key, Bucket: b.bucket}, nil
}
func (b *s3Backend) Get(ctx context.Context, key string) (*Object, error) {
key = b.key(key)
if b.cdnURL != "" {
return getHTTPObject(ctx, b.cdnURL, key)
}
output, err := b.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("get S3 object: %w", err)
}
contentType := defaultContentType
if output.ContentType != nil {
contentType = *output.ContentType
}
var size int64
if output.ContentLength != nil {
size = *output.ContentLength
}
return &Object{Body: output.Body, ContentLength: size, ContentType: contentType}, nil
}
func (b *s3Backend) Delete(ctx context.Context, key string) error {
_, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
if err != nil {
return fmt.Errorf("delete S3 object: %w", err)
}
return nil
}
func (b *s3Backend) Test(ctx context.Context) error {
_, err := b.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(b.bucket)})
if err != nil {
return fmt.Errorf("access S3 bucket: %w", err)
}
return nil
}
func (b *s3Backend) key(key string) string {
key = strings.TrimLeft(key, "/")
if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") {
return key
}
return b.keyPrefix + "/" + key
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"gorm.io/gorm"
)
const (
defaultContentType = "application/octet-stream"
storageDirPerm = 0o750
storageFilePerm = 0o600
)
// Object describes a readable stored object.
type Object struct {
CachePath string
Body io.ReadCloser
ContentLength int64
ContentType string
}
// PutResult describes the result of a successful Put operation.
type PutResult struct {
Key string
Bucket string
}
// Backend defines storage operations used by the upload domain.
type Backend interface {
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error)
Get(ctx context.Context, key string) (*Object, error)
Delete(ctx context.Context, key string) error
Test(ctx context.Context) error
}
var (
// IsEnabledFunc preserves the legacy S3 test hook while tests migrate to backend injection.
IsEnabledFunc = func() bool { return false }
mockBackend Backend
activeBackend Backend
activeDriver Driver
activeConfigJSON string
lastChecked time.Time
cacheMutex sync.RWMutex
)
// ConfigInvalidationChannel is the Redis pub/sub channel used to evict storage caches cluster-wide.
const ConfigInvalidationChannel = "storage:config_invalidation"
var pubSubOnce sync.Once
// ResetCache clears the local cache for storage configuration and client singletons.
func ResetCache() {
cacheMutex.Lock()
defer cacheMutex.Unlock()
activeBackend = nil
activeDriver = ""
activeConfigJSON = ""
lastChecked = time.Time{}
}
// PublishCacheInvalidation broadcasts cache eviction to all nodes in the cluster via Redis.
func PublishCacheInvalidation(ctx context.Context) {
if db.Redis != nil {
_ = db.Redis.Publish(ctx, ConfigInvalidationChannel, "reset").Err()
}
}
// startPubSubListener starts the background subscriber for cache invalidations.
func startPubSubListener() {
if db.Redis == nil {
return
}
go func() {
pubsub := db.Redis.Subscribe(context.Background(), ConfigInvalidationChannel)
defer func() {
_ = pubsub.Close()
}()
ch := pubsub.Channel()
for range ch {
ResetCache()
}
}()
}
// Active returns the configured active driver and backend, using an in-memory cache with 5s TTL.
func Active(ctx context.Context) (Driver, Backend, error) {
if IsEnabledFunc() && mockBackend != nil {
return DriverS3, mockBackend, nil
}
pubSubOnce.Do(startPubSubListener)
cacheMutex.RLock()
isCacheValid := time.Since(lastChecked) < 5*time.Second && activeBackend != nil
if isCacheValid {
d, b := activeDriver, activeBackend
cacheMutex.RUnlock()
return d, b, nil
}
cacheMutex.RUnlock()
cacheMutex.Lock()
defer cacheMutex.Unlock()
// Double-check under write lock
if time.Since(lastChecked) < 5*time.Second && activeBackend != nil {
return activeDriver, activeBackend, nil
}
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyStorageConfig)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, err
}
lastChecked = time.Now()
// Reuse existing backend client singleton if configuration JSON matches
if sc.Value == activeConfigJSON && activeBackend != nil {
return activeDriver, activeBackend, nil
}
cfg := DefaultConfig()
if strings.TrimSpace(sc.Value) != "" {
if err := json.Unmarshal([]byte(sc.Value), &cfg); err != nil {
return "", nil, fmt.Errorf("parse storage config: %w", err)
}
}
backend, err := NewBackend(ctx, cfg, cfg.Driver)
if err != nil {
return "", nil, err
}
activeDriver = cfg.Driver
activeBackend = backend
activeConfigJSON = sc.Value
return activeDriver, activeBackend, nil
}
type functionBackend struct {
put func(context.Context, string, io.Reader, int64, string) error
get func(context.Context, string) (*Object, error)
delete func(context.Context, string) error
}
func (b *functionBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) {
if err := b.put(ctx, key, body, size, contentType); err != nil {
return PutResult{}, err
}
return PutResult{Key: key}, nil
}
func (b *functionBackend) Get(ctx context.Context, key string) (*Object, error) {
return b.get(ctx, key)
}
func (b *functionBackend) Delete(ctx context.Context, key string) error {
return b.delete(ctx, key)
}
func (b *functionBackend) Test(context.Context) error {
return nil
}
// MockStorage replaces object operations for package tests and returns a restore function.
func MockStorage(
put func(context.Context, string, io.Reader, int64, string) error,
get func(context.Context, string) (*Object, error),
deleteObject func(context.Context, string) error,
) func() {
previous := mockBackend
mockBackend = &functionBackend{put: put, get: get, delete: deleteObject}
return func() {
mockBackend = previous
}
}
// NewBackend constructs a concrete backend from configuration.
func NewBackend(ctx context.Context, cfg Config, driver Driver) (Backend, error) {
if driver == DriverS3 && mockBackend != nil {
return mockBackend, nil
}
switch driver {
case DriverLocal:
return newLocalBackend(cfg.Local)
case DriverS3:
return newS3Backend(ctx, cfg.S3)
case DriverR2:
return newR2Backend(ctx, cfg.R2)
case DriverMinIO:
return newS3Backend(ctx, cfg.MinIO)
case DriverOSS:
return newOSSBackend(cfg.OSS)
case DriverWebDAV:
return newWebDAVBackend(cfg.WebDAV)
default:
return nil, fmt.Errorf("unsupported storage driver %q", driver)
}
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"encoding/json"
"io"
"sync"
"testing"
"time"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestStorageCache(t *testing.T) {
// 1. Reset cache
ResetCache()
if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() {
t.Fatal("ResetCache did not clear cache variables")
}
// 2. Set up cache manually
expectedConfig := Config{
Driver: DriverLocal,
Local: LocalConfig{Root: "/tmp/wavelet-test"},
}
cfgJSON, err := json.Marshal(expectedConfig)
if err != nil {
t.Fatalf("Marshal config failed: %v", err)
}
cacheMutex.Lock()
activeConfigJSON = string(cfgJSON)
lastChecked = time.Now()
cacheMutex.Unlock()
// 3. Call LoadConfig and verify it loads from cache (doesn't hit database, which would fail/panic because DB is not initialized)
ctx := context.Background()
loadedCfg, err := LoadConfig(ctx)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
if loadedCfg.Driver != expectedConfig.Driver || loadedCfg.Local.Root != expectedConfig.Local.Root {
t.Errorf("Loaded config %+v, expected %+v", loadedCfg, expectedConfig)
}
// 4. Test Active() returns cached driver and backend
mockBnd := &functionBackend{
put: func(context.Context, string, io.Reader, int64, string) error { return nil },
get: func(context.Context, string) (*Object, error) { return nil, nil },
delete: func(context.Context, string) error { return nil },
}
cacheMutex.Lock()
activeBackend = mockBnd
activeDriver = DriverLocal
cacheMutex.Unlock()
drv, bnd, err := Active(ctx)
if err != nil {
t.Fatalf("Active failed: %v", err)
}
if drv != DriverLocal || bnd != mockBnd {
t.Errorf("Active returned driver %v, backend %v; expected %v, %v", drv, bnd, DriverLocal, mockBnd)
}
// 5. Test ResetCache again
ResetCache()
if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() {
t.Fatal("ResetCache did not clear cache variables after setting them")
}
}
func TestStorageCachePubSub(t *testing.T) {
// 1. Start miniredis
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("Failed to run miniredis: %v", err)
}
defer mr.Close()
// 2. Initialize Redis client
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
defer rdb.Close()
// 3. Set db.Redis to our client
oldRedis := db.Redis
db.Redis = rdb
defer func() {
db.Redis = oldRedis
}()
// Reset cache and set some cached config
ResetCache()
cacheMutex.Lock()
activeConfigJSON = "some_config"
lastChecked = time.Now()
cacheMutex.Unlock()
// 4. Force trigger lazy initialization of subscription
// Reset the once guard so it runs the listener
pubSubOnce = sync.Once{}
ctx := context.Background()
// Create mock backend for Active call
mockBnd := &functionBackend{
put: func(context.Context, string, io.Reader, int64, string) error { return nil },
get: func(context.Context, string) (*Object, error) { return nil, nil },
delete: func(context.Context, string) error { return nil },
}
cacheMutex.Lock()
activeBackend = mockBnd
activeDriver = DriverLocal
cacheMutex.Unlock()
_, _, _ = Active(ctx) // This calls startPubSubListener()
// Allow some time for subscriber connection
time.Sleep(100 * time.Millisecond)
// 5. Publish cache invalidation
PublishCacheInvalidation(ctx)
// Allow message propagation
time.Sleep(100 * time.Millisecond)
// 6. Verify cache was cleared
cacheMutex.RLock()
configJSON := activeConfigJSON
cacheMutex.RUnlock()
if configJSON != "" {
t.Error("Memory cache was not cleared after Redis Pub/Sub broadcast")
}
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package objectstore
import (
"context"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/Rain-kl/Wavelet/pkg/httppool"
"github.com/studio-b12/gowebdav"
)
type contextTransport struct {
ctx context.Context
parent http.RoundTripper
}
func (t *contextTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.parent.RoundTrip(req.WithContext(t.ctx))
}
type webDAVBackend struct {
endpoint string
username string
password string
basePath string
}
func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) {
return &webDAVBackend{
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
username: cfg.Username,
password: cfg.Password,
basePath: strings.Trim(cfg.BasePath, "/"),
}, nil
}
func (b *webDAVBackend) newClient(ctx context.Context) *gowebdav.Client {
client := gowebdav.NewClient(b.endpoint, b.username, b.password)
client.SetTransport(&contextTransport{
ctx: ctx,
parent: httppool.DefaultTransport(),
})
return client
}
func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) {
key = b.key(key)
client := b.newClient(ctx)
if dir := path.Dir(key); dir != "." && dir != "/" {
if err := client.MkdirAll(dir, storageDirPerm); err != nil {
return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err)
}
}
if err := client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil {
return PutResult{}, fmt.Errorf("put WebDAV object: %w", err)
}
return PutResult{Key: key}, nil
}
func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
key = b.key(key)
client := b.newClient(ctx)
info, err := client.Stat(key)
if err != nil {
return nil, fmt.Errorf("stat WebDAV object: %w", err)
}
body, err := client.ReadStream(key)
if err != nil {
return nil, fmt.Errorf("get WebDAV object: %w", err)
}
contentType := defaultContentType
if typed, ok := info.(interface{ ContentType() string }); ok && typed.ContentType() != "" {
contentType = typed.ContentType()
}
return &Object{Body: body, ContentLength: info.Size(), ContentType: contentType}, nil
}
func (b *webDAVBackend) Delete(ctx context.Context, key string) error {
client := b.newClient(ctx)
if err := client.Remove(b.key(key)); err != nil {
return fmt.Errorf("delete WebDAV object: %w", err)
}
return nil
}
func (b *webDAVBackend) Test(ctx context.Context) error {
client := b.newClient(ctx)
if err := client.Connect(); err != nil {
return fmt.Errorf("connect WebDAV: %w", err)
}
return nil
}
func (b *webDAVBackend) key(key string) string {
return "/" + path.Join(b.basePath, strings.TrimLeft(key, "/"))
}
@@ -0,0 +1,69 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package batchwriter
import (
"fmt"
"time"
)
const (
defaultQueueSize = 10_000
defaultMaxBatchSize = 1_000
defaultMinBatchSize = 50
defaultFlushEvery = time.Second
)
// Config controls queue capacity and flush thresholds for a Writer instance.
type Config struct {
// Name identifies the writer in logs and diagnostics. Optional.
Name string
// QueueSize is the buffered channel capacity.
QueueSize int
// MaxBatchSize triggers a flush when the in-memory batch reaches this count.
MaxBatchSize int
// MinBatchSize is the minimum in-memory batch size for time-based flushes.
// Zero disables the threshold and preserves legacy interval flush behavior.
// When set, interval flushes below this size are skipped unless MaxFlushWait elapses.
MinBatchSize int
// FlushInterval is how often the worker checks whether a time-based flush should run.
FlushInterval time.Duration
// MaxFlushWait forces a flush of any non-empty batch once the oldest item has waited
// this long, even if MinBatchSize has not been reached. Zero disables the force path.
MaxFlushWait time.Duration
}
// DefaultConfig returns production-friendly defaults aligned with audit log batching.
func DefaultConfig() Config {
return Config{
QueueSize: defaultQueueSize,
MaxBatchSize: defaultMaxBatchSize,
MinBatchSize: defaultMinBatchSize,
FlushInterval: defaultFlushEvery,
}
}
func (c Config) validate() error {
if c.QueueSize <= 0 {
return fmt.Errorf("batchwriter: queue size must be positive")
}
if c.MaxBatchSize <= 0 {
return fmt.Errorf("batchwriter: max batch size must be positive")
}
if c.MinBatchSize < 0 {
return fmt.Errorf("batchwriter: min batch size must be non-negative")
}
if c.FlushInterval <= 0 {
return fmt.Errorf("batchwriter: flush interval must be positive")
}
if c.MaxFlushWait < 0 {
return fmt.Errorf("batchwriter: max flush wait must be non-negative")
}
return nil
}
@@ -0,0 +1,8 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package batchwriter
import "errors"
var errNilFlushFunc = errors.New("batchwriter: flush func is required")
@@ -0,0 +1,264 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package batchwriter provides a reusable buffered batch writer for high-throughput
// append-only sinks such as ClickHouse. Each business domain should own an independent
// Writer instance with its own queue, flush callback, and tuning parameters.
package batchwriter
import (
"context"
"sync"
"sync/atomic"
"time"
)
// FlushFunc persists a batch of queued items. It is invoked from the worker goroutine.
type FlushFunc[T any] func(ctx context.Context, items []T) error
// FlushErrorHandler is called when FlushFunc returns an error after optional retries.
// The batch is discarded after the handler returns; the worker continues processing.
// Handlers receive the failed items so callers can release dedup keys or re-queue.
type FlushErrorHandler[T any] func(ctx context.Context, items []T, err error)
// Stats is a point-in-time snapshot of Writer queue and failure counters.
type Stats struct {
Name string `json:"name"`
Depth int `json:"depth"`
Cap int `json:"cap"`
Drops int64 `json:"drops"`
FlushErrors int64 `json:"flush_errors"`
Running bool `json:"running"`
}
// Writer buffers items and flushes them by size or interval.
type Writer[T any] struct {
cfg Config
flush FlushFunc[T]
onFlushError FlushErrorHandler[T]
onDrop func(T)
startOnce sync.Once
stopOnce sync.Once
mu sync.RWMutex
ch chan T
workerCtx context.Context
done chan struct{}
drops atomic.Int64
flushErrors atomic.Int64
}
// Option configures optional Writer callbacks.
type Option[T any] func(*Writer[T])
// WithFlushErrorHandler registers a callback for flush failures.
func WithFlushErrorHandler[T any](handler FlushErrorHandler[T]) Option[T] {
return func(w *Writer[T]) {
w.onFlushError = handler
}
}
// WithDropHandler registers a callback when TryEnqueue cannot accept an item.
func WithDropHandler[T any](handler func(T)) Option[T] {
return func(w *Writer[T]) {
w.onDrop = handler
}
}
// New creates a Writer. Call Start before enqueueing items.
func New[T any](cfg Config, flush FlushFunc[T], opts ...Option[T]) (*Writer[T], error) {
if flush == nil {
return nil, errNilFlushFunc
}
if err := cfg.validate(); err != nil {
return nil, err
}
w := &Writer[T]{
cfg: cfg,
flush: flush,
done: make(chan struct{}),
}
for _, opt := range opts {
opt(w)
}
return w, nil
}
// Start launches the background worker. It is safe to call at most once.
func (w *Writer[T]) Start(parent context.Context) {
w.startOnce.Do(func() {
w.mu.Lock()
defer w.mu.Unlock()
w.ch = make(chan T, w.cfg.QueueSize)
w.workerCtx = context.WithoutCancel(parent)
go w.run()
})
}
// Stop closes the queue and waits until the worker drains pending items and exits.
func (w *Writer[T]) Stop(ctx context.Context) error {
w.mu.RLock()
ch := w.ch
done := w.done
w.mu.RUnlock()
if ch == nil {
return nil
}
w.stopOnce.Do(func() {
close(ch)
})
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Running reports whether Start has been called and Stop has not completed.
func (w *Writer[T]) Running() bool {
w.mu.RLock()
defer w.mu.RUnlock()
if w.ch == nil {
return false
}
select {
case <-w.done:
return false
default:
return true
}
}
// TryEnqueue adds one item without blocking. It returns false when the writer is not
// running or the queue is full.
func (w *Writer[T]) TryEnqueue(item T) bool {
w.mu.RLock()
ch := w.ch
w.mu.RUnlock()
if ch == nil {
w.notifyDrop(item)
return false
}
select {
case ch <- item:
return true
default:
w.notifyDrop(item)
return false
}
}
// IsFull reports whether the queue has no remaining capacity.
func (w *Writer[T]) IsFull() bool {
w.mu.RLock()
defer w.mu.RUnlock()
if w.ch == nil {
return false
}
return len(w.ch) >= cap(w.ch)
}
// Len returns the current queue depth.
func (w *Writer[T]) Len() int {
w.mu.RLock()
defer w.mu.RUnlock()
if w.ch == nil {
return 0
}
return len(w.ch)
}
// Cap returns the queue capacity.
func (w *Writer[T]) Cap() int {
return w.cfg.QueueSize
}
// Stats returns a point-in-time snapshot of queue depth and failure counters.
func (w *Writer[T]) Stats() Stats {
return Stats{
Name: w.cfg.Name,
Depth: w.Len(),
Cap: w.Cap(),
Drops: w.drops.Load(),
FlushErrors: w.flushErrors.Load(),
Running: w.Running(),
}
}
func (w *Writer[T]) run() {
ticker := time.NewTicker(w.cfg.FlushInterval)
defer ticker.Stop()
batch := make([]T, 0, w.cfg.MaxBatchSize)
var batchStartedAt time.Time
flush := func() {
if len(batch) == 0 {
return
}
items := append([]T(nil), batch...)
if err := w.flush(w.workerCtx, items); err != nil {
w.flushErrors.Add(1)
if w.onFlushError != nil {
w.onFlushError(w.workerCtx, items, err)
}
}
batch = batch[:0]
batchStartedAt = time.Time{}
}
defer func() {
flush()
close(w.done)
}()
for {
select {
case item, ok := <-w.ch:
if !ok {
return
}
if len(batch) == 0 {
batchStartedAt = time.Now()
}
batch = append(batch, item)
if len(batch) >= w.cfg.MaxBatchSize {
flush()
}
case <-ticker.C:
if w.shouldFlushOnInterval(len(batch), batchStartedAt, time.Now()) {
flush()
}
}
}
}
func (w *Writer[T]) shouldFlushOnInterval(batchLen int, batchStartedAt time.Time, now time.Time) bool {
if batchLen == 0 {
return false
}
if w.cfg.MinBatchSize == 0 || batchLen >= w.cfg.MinBatchSize {
return true
}
if w.cfg.MaxFlushWait <= 0 || batchStartedAt.IsZero() {
return false
}
return !now.Before(batchStartedAt.Add(w.cfg.MaxFlushWait))
}
func (w *Writer[T]) notifyDrop(item T) {
w.drops.Add(1)
if w.onDrop == nil {
return
}
w.onDrop(item)
}
@@ -0,0 +1,495 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package batchwriter
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
)
func TestNewRejectsInvalidConfig(t *testing.T) {
t.Parallel()
_, err := New[int](Config{}, func(context.Context, []int) error { return nil })
if err == nil {
t.Fatal("New() = nil, want validation error")
}
}
func TestNewRejectsNilFlushFunc(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
_, err := New[int](cfg, nil)
if !errors.Is(err, errNilFlushFunc) {
t.Fatalf("New() error = %v, want %v", err, errNilFlushFunc)
}
}
func TestWriterFlushesOnMaxBatchSize(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
batches [][]int
)
cfg := DefaultConfig()
cfg.MaxBatchSize = 3
cfg.FlushInterval = time.Hour
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
mu.Lock()
defer mu.Unlock()
batches = append(batches, append([]int(nil), items...))
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
})
for i := range 3 {
if !writer.TryEnqueue(i + 1) {
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
}
}
deadline := time.Now().Add(time.Second)
for {
mu.Lock()
ready := len(batches) == 1
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
got := batches
mu.Unlock()
want := [][]int{{1, 2, 3}}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("flush batches mismatch (-want +got):\n%s", diff)
}
}
func TestWriterFlushesOnInterval(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
batch []int
)
cfg := DefaultConfig()
cfg.MaxBatchSize = 100
cfg.MinBatchSize = 0
cfg.FlushInterval = 20 * time.Millisecond
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
mu.Lock()
defer mu.Unlock()
batch = append([]int(nil), items...)
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
})
if !writer.TryEnqueue(42) {
t.Fatal("TryEnqueue() = false, want true")
}
deadline := time.Now().Add(time.Second)
for {
mu.Lock()
ready := len(batch) == 1
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
got := batch
mu.Unlock()
want := []int{42}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("interval flush mismatch (-want +got):\n%s", diff)
}
}
func TestWriterTryEnqueueDropsWhenFull(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.QueueSize = 1
cfg.MaxBatchSize = 10
cfg.FlushInterval = time.Hour
var dropped int
writer, err := New[int](cfg, func(context.Context, []int) error { return nil }, WithDropHandler[int](func(int) {
dropped++
}))
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = writer.Stop(stopCtx)
})
if !writer.TryEnqueue(1) {
t.Fatal("TryEnqueue(1) = false, want true")
}
if writer.TryEnqueue(2) {
t.Fatal("TryEnqueue(2) = true, want false")
}
if !writer.IsFull() {
t.Fatal("IsFull() = false, want true")
}
if dropped != 1 {
t.Fatalf("dropped = %d, want 1", dropped)
}
}
func TestWriterStopDrainsQueuedItems(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.MaxBatchSize = 10
cfg.FlushInterval = time.Hour
var flushed []int
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
flushed = append(flushed, items...)
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
for i := range 2 {
if !writer.TryEnqueue(i + 1) {
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
}
}
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
want := []int{1, 2}
if diff := cmp.Diff(want, flushed); diff != "" {
t.Fatalf("Stop() drain mismatch (-want +got):\n%s", diff)
}
if writer.Running() {
t.Fatal("Running() = true after Stop(), want false")
}
}
func TestWriterSkipsIntervalFlushBelowMinBatchSize(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
batch []int
)
cfg := DefaultConfig()
cfg.MaxBatchSize = 100
cfg.MinBatchSize = 5
cfg.FlushInterval = 20 * time.Millisecond
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
mu.Lock()
defer mu.Unlock()
batch = append([]int(nil), items...)
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
})
for i := range 3 {
if !writer.TryEnqueue(i + 1) {
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
}
}
time.Sleep(100 * time.Millisecond)
mu.Lock()
got := batch
mu.Unlock()
if len(got) != 0 {
t.Fatalf("interval flush with below-min batch = %v, want no flush", got)
}
}
func TestWriterFlushesOnIntervalWhenMinBatchSizeReached(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
batch []int
)
cfg := DefaultConfig()
cfg.MaxBatchSize = 100
cfg.MinBatchSize = 3
cfg.FlushInterval = 20 * time.Millisecond
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
mu.Lock()
defer mu.Unlock()
batch = append([]int(nil), items...)
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
})
for i := range 3 {
if !writer.TryEnqueue(i + 1) {
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
}
}
deadline := time.Now().Add(time.Second)
for {
mu.Lock()
ready := len(batch) == 3
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
got := batch
mu.Unlock()
want := []int{1, 2, 3}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("interval flush at min batch size mismatch (-want +got):\n%s", diff)
}
}
func TestWriterForcesFlushAfterMaxFlushWait(t *testing.T) {
t.Parallel()
var (
mu sync.Mutex
batch []int
)
cfg := DefaultConfig()
cfg.MaxBatchSize = 100
cfg.MinBatchSize = 50
cfg.FlushInterval = 20 * time.Millisecond
cfg.MaxFlushWait = 80 * time.Millisecond
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
mu.Lock()
defer mu.Unlock()
batch = append([]int(nil), items...)
return nil
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := writer.Stop(stopCtx); err != nil {
t.Fatalf("Stop() error = %v", err)
}
})
if !writer.TryEnqueue(1) {
t.Fatal("TryEnqueue(1) = false, want true")
}
deadline := time.Now().Add(time.Second)
for {
mu.Lock()
ready := len(batch) == 1
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
got := batch
mu.Unlock()
if diff := cmp.Diff([]int{1}, got); diff != "" {
t.Fatalf("max flush wait mismatch (-want +got):\n%s", diff)
}
}
func TestWriterInvokesFlushErrorHandler(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Name = "test-flush-err"
cfg.MaxBatchSize = 1
cfg.FlushInterval = time.Hour
flushErr := errors.New("flush failed")
var (
mu sync.Mutex
errCount int
gotItems []int
)
writer, err := New[int](cfg, func(context.Context, []int) error {
return flushErr
}, WithFlushErrorHandler[int](func(_ context.Context, items []int, err error) {
mu.Lock()
defer mu.Unlock()
errCount++
gotItems = append([]int(nil), items...)
if !errors.Is(err, flushErr) {
t.Errorf("flush error = %v, want %v", err, flushErr)
}
}))
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = writer.Stop(stopCtx)
})
if !writer.TryEnqueue(7) {
t.Fatal("TryEnqueue() = false, want true")
}
deadline := time.Now().Add(time.Second)
for {
mu.Lock()
ready := errCount == 1
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
gotCount := errCount
items := gotItems
mu.Unlock()
if gotCount != 1 {
t.Fatalf("flush error handler count = %d, want 1", gotCount)
}
if diff := cmp.Diff([]int{7}, items); diff != "" {
t.Fatalf("flush error handler items mismatch (-want +got):\n%s", diff)
}
stats := writer.Stats()
if stats.FlushErrors != 1 {
t.Fatalf("Stats().FlushErrors = %d, want 1", stats.FlushErrors)
}
if stats.Name != "test-flush-err" {
t.Fatalf("Stats().Name = %q, want test-flush-err", stats.Name)
}
}
func TestWriterStatsTracksDrops(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Name = "test-drops"
cfg.QueueSize = 1
cfg.MaxBatchSize = 10
cfg.FlushInterval = time.Hour
writer, err := New[int](cfg, func(context.Context, []int) error { return nil })
if err != nil {
t.Fatalf("New() error = %v", err)
}
writer.Start(context.Background())
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = writer.Stop(stopCtx)
})
if !writer.TryEnqueue(1) {
t.Fatal("TryEnqueue(1) = false, want true")
}
if writer.TryEnqueue(2) {
t.Fatal("TryEnqueue(2) = true, want false")
}
stats := writer.Stats()
if stats.Drops != 1 {
t.Fatalf("Stats().Drops = %d, want 1", stats.Drops)
}
if stats.Cap != 1 {
t.Fatalf("Stats().Cap = %d, want 1", stats.Cap)
}
if !stats.Running {
t.Fatal("Stats().Running = false, want true")
}
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package db 提供数据库连接与基础设施
package db
import (
"context"
"log"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/Rain-kl/Wavelet/internal/infra/config"
)
const (
clickhouseMaxExecTime = 60 // ClickHouse 最大执行时间(秒)
clickhouseReadTimeoutFactor = 2 // ReadTimeout 为 DialTimeout 的倍数
// async_insert 仅挂在运行时 ChConn(写路径)上,不进入 migrator OpenDB:
// 迁移/DDL 需要同步可见结果,且不应走异步 insert 缓冲。
//
// 为何启用:batchwriter 仍可能在短间隔内写出相对小的块;服务端 async_insert
// 把多次 INSERT 合并成更大 part,减轻 3c6g 上 background merge 的 CPU 压力。
// wait_for_async_insert=1:调用方在 flush 返回前等待落盘,避免进程崩溃丢批。
// max_data_size / busy_timeout:约 10MB 或 ~2s 触发刷出,在延迟与 part 数之间折中。
clickhouseAsyncInsertMaxDataSize = 10_000_000
clickhouseAsyncInsertBusyTimeoutMs = 2000
)
var (
// ChConn ClickHouse 原生连接实例,用于批量写入与查询
ChConn driver.Conn
)
func init() {
if !config.Config.ClickHouse.Enabled {
return
}
cfg := config.Config.ClickHouse
if cfg.Database == "" {
log.Fatalf("[ClickHouse] database name is required (expected: openflare)\n")
}
opts := buildClickHouseOptions()
var err error
ChConn, err = clickhouse.Open(opts)
if err != nil {
log.Fatalf("[ClickHouse] init connection failed: %v\n", err)
}
if err = ChConn.Ping(context.Background()); err != nil {
log.Fatalf("[ClickHouse] ping failed: %v\n", err)
}
log.Println("[ClickHouse] connection established successfully")
}
// buildClickHouseOptions builds the runtime native client options (queries + batch inserts).
// Migrator uses a separate clickhouse.OpenDB path without async_insert settings.
func buildClickHouseOptions() *clickhouse.Options {
cfg := config.Config.ClickHouse
return &clickhouse.Options{
Addr: cfg.Hosts,
Auth: clickhouse.Auth{
Database: cfg.Database,
Username: cfg.Username,
Password: cfg.Password,
},
Settings: clickhouse.Settings{
"max_execution_time": clickhouseMaxExecTime,
"async_insert": 1,
"wait_for_async_insert": 1,
"async_insert_max_data_size": clickhouseAsyncInsertMaxDataSize,
"async_insert_busy_timeout_ms": clickhouseAsyncInsertBusyTimeoutMs,
},
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
MaxOpenConns: cfg.MaxOpenConn,
MaxIdleConns: cfg.MaxIdleConn,
ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second,
ReadTimeout: time.Duration(cfg.DialTimeout*clickhouseReadTimeoutFactor) * time.Second,
BlockBufferSize: cfg.BlockBufferSize,
}
}
// ChConnReady reports whether the native ClickHouse connection is initialized.
func ChConnReady() bool {
return ChConn != nil
}
// SetChConnForTest sets the package-level native ClickHouse connection for testing.
func SetChConnForTest(c driver.Conn) {
ChConn = c
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package db
const (
errRedisHashSetFailed = "failed to set redis hash: %w"
errRedisHashDeleteFailed = "failed to delete redis hash field: %w"
errUnmarshalDataFailed = "failed to unmarshal data: %w"
errMarshalDataFailed = "failed to marshal data: %w"
errRedisKeySetFailed = "failed to set redis key: %w"
)
@@ -0,0 +1,46 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package idgen 提供分布式 ID 生成器
package idgen
import (
"fmt"
"log"
"github.com/Rain-kl/Wavelet/internal/infra/config"
"github.com/bwmarrin/snowflake"
)
// 2025-12-01 00:00:00 UTC 的毫秒时间戳
const epoch int64 = 1764547200000
const maxNegativeIDRetries = 3
var node *snowflake.Node
func init() {
snowflake.Epoch = epoch
nodeID := config.Config.App.NodeID
var err error
node, err = snowflake.NewNode(nodeID)
if err != nil {
log.Fatalf("[Snowflake] init failed: %v\n", err)
}
log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID)
}
// NextUint64ID 生成下一个分布式唯一 ID。
// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。
func NextUint64ID() uint64 {
for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ {
id := node.Generate().Int64()
if id >= 0 {
return uint64(id)
}
log.Printf("[Snowflake] generated negative ID: %d (attempt %d/%d)", id, attempt, maxNegativeIDRetries)
}
panic(fmt.Sprintf("[Snowflake] generated negative ID after %d attempts", maxNegativeIDRetries))
}
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package idgen
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNextUint64ID(t *testing.T) {
id := NextUint64ID()
assert.NotZero(t, id)
}
@@ -0,0 +1,107 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"context"
"database/sql"
"embed"
"io/fs"
"log"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/Rain-kl/Wavelet/internal/infra/config"
"github.com/pressly/goose/v3"
)
const (
clickhouseMigrationDir = "goose/clickhouse"
clickhouseGooseVersionTable = "goose_clickhouse_version"
clickhouseMaxExecTime = 60
clickhouseReadTimeoutFactor = 2
)
// clickhouseMigrationFS contains SQL migrations under goose/clickhouse.
//
//go:embed goose/clickhouse/*.sql
var clickhouseMigrationFS embed.FS
// MigrateClickHouse runs goose migrations against ClickHouse when enabled.
func MigrateClickHouse() Report {
if !config.Config.ClickHouse.Enabled {
return Report{Backend: "ClickHouse"}
}
cfg := config.Config.ClickHouse
sqlDB := clickhouse.OpenDB(&clickhouse.Options{
Addr: cfg.Hosts,
Auth: clickhouse.Auth{
Database: cfg.Database,
Username: cfg.Username,
Password: cfg.Password,
},
Settings: clickhouse.Settings{
"max_execution_time": clickhouseMaxExecTime,
},
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
MaxOpenConns: cfg.MaxOpenConn,
MaxIdleConns: cfg.MaxIdleConn,
ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second,
ReadTimeout: time.Duration(cfg.DialTimeout*clickhouseReadTimeoutFactor) * time.Second,
BlockBufferSize: cfg.BlockBufferSize,
})
subFS, err := fs.Sub(clickhouseMigrationFS, "goose/clickhouse")
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get sub fs failed: %v\n", err)
}
provider, err := goose.NewProvider(
"clickhouse",
sqlDB,
subFS,
goose.WithTableName(clickhouseGooseVersionTable),
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err)
}
previousVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get goose version failed: %v\n", err)
}
if _, err := provider.Up(context.Background()); err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err)
}
currentVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err)
}
closeClickHouseDB(sqlDB)
log.Println("[ClickHouse] goose migrate success")
return Report{
Backend: "ClickHouse",
Enabled: true,
Version: currentVersion,
Applied: currentVersion != previousVersion,
}
}
func closeClickHouseDB(sqlDB *sql.DB) {
if err := sqlDB.Close(); err != nil {
log.Printf("[ClickHouse] close sql db failed: %v\n", err)
}
}
@@ -0,0 +1,55 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package migrator
import (
"testing"
"github.com/Rain-kl/Wavelet/internal/infra/config"
"github.com/pressly/goose/v3"
)
func TestClickHouseMigrationFilesEmbedded(t *testing.T) {
entries, err := clickhouseMigrationFS.ReadDir(clickhouseMigrationDir)
if err != nil {
t.Fatalf("ReadDir(%q) error = %v", clickhouseMigrationDir, err)
}
if len(entries) == 0 {
t.Fatal("expected embedded ClickHouse migrations, got none")
}
expected := map[string]bool{
"202606190001_create_user_access_logs.sql": false,
"202606200001_create_node_access_logs.sql": false,
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := expected[entry.Name()]; ok {
expected[entry.Name()] = true
}
}
for name, found := range expected {
if !found {
t.Fatalf("expected %s in embedded migrations", name)
}
}
}
func TestClickHouseGooseDialect(t *testing.T) {
if err := goose.SetDialect("clickhouse"); err != nil {
t.Fatalf("SetDialect(clickhouse) error = %v", err)
}
}
func TestMigrateClickHouseSkipsWhenDisabled(t *testing.T) {
previousEnabled := config.Config.ClickHouse.Enabled
config.Config.ClickHouse.Enabled = false
t.Cleanup(func() {
config.Config.ClickHouse.Enabled = previousEnabled
})
MigrateClickHouse()
}
@@ -0,0 +1,21 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_user_access_logs
(
id UInt64,
user_id UInt64,
path String,
method String,
ip String,
user_agent String,
headers String,
status Int32,
latency Int64,
created_at DateTime
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(created_at)
ORDER BY (created_at, ip, user_id)
SETTINGS index_granularity = 8192;
-- +goose Down
DROP TABLE IF EXISTS w_user_access_logs;
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_node_access_logs
(
id UInt64,
node_id String,
logged_at DateTime64(3, 'UTC'),
remote_addr String,
region String,
host String,
path String,
status_code Int32,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(logged_at)
ORDER BY (node_id, logged_at, remote_addr, host, path, status_code)
SETTINGS index_granularity = 8192;
-- +goose Down
DROP TABLE IF EXISTS of_node_access_logs;
@@ -0,0 +1,92 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_node_metric_snapshots
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
cpu_usage_percent Float64,
memory_used_bytes Int64,
memory_total_bytes Int64,
storage_used_bytes Int64,
storage_total_bytes Int64,
disk_read_bytes Int64,
disk_write_bytes Int64,
network_rx_bytes Int64,
network_tx_bytes Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_request_reports
(
id UInt64,
node_id String,
window_started_at DateTime64(3, 'UTC'),
window_ended_at DateTime64(3, 'UTC'),
request_count Int64,
error_count Int64,
unique_visitor_count Int64,
status_codes_json String,
top_domains_json String,
source_countries_json String,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(window_ended_at)
ORDER BY (node_id, window_ended_at, window_started_at, id)
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_obs_openresty
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
openresty_rx_bytes Int64,
openresty_tx_bytes Int64,
openresty_connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_obs_frps
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
frps_connections Int32,
frps_proxy_count Int32,
frps_client_count Int32,
frps_proxies String,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_obs_frpc
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
tunnel_status String,
connected_relays_count Int32,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
SETTINGS index_granularity = 8192;
-- +goose Down
DROP TABLE IF EXISTS of_node_obs_frpc;
DROP TABLE IF EXISTS of_node_obs_frps;
DROP TABLE IF EXISTS of_node_obs_openresty;
DROP TABLE IF EXISTS of_node_request_reports;
DROP TABLE IF EXISTS of_node_metric_snapshots;
@@ -0,0 +1,19 @@
-- +goose Up
-- Add TTL policies to analytics tables so ClickHouse can expire rows automatically.
ALTER TABLE w_user_access_logs MODIFY TTL created_at + INTERVAL 180 DAY;
-- DateTime64 columns must be cast for TTL (ClickHouse requires DateTime/Date in TTL expr).
ALTER TABLE of_node_access_logs MODIFY TTL toDateTime(logged_at) + INTERVAL 90 DAY;
ALTER TABLE of_node_metric_snapshots MODIFY TTL toDateTime(captured_at) + INTERVAL 30 DAY;
ALTER TABLE of_node_request_reports MODIFY TTL toDateTime(window_ended_at) + INTERVAL 30 DAY;
ALTER TABLE of_node_obs_openresty MODIFY TTL toDateTime(captured_at) + INTERVAL 30 DAY;
ALTER TABLE of_node_obs_frps MODIFY TTL toDateTime(captured_at) + INTERVAL 30 DAY;
ALTER TABLE of_node_obs_frpc MODIFY TTL toDateTime(captured_at) + INTERVAL 30 DAY;
-- +goose Down
-- TTL changes cannot be safely reversed without recreating tables.
@@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_node_traffic_hourly
(
node_id String,
hour DateTime,
request_count UInt64,
error_count UInt64,
unique_visitor_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour);
CREATE MATERIALIZED VIEW IF NOT EXISTS of_node_traffic_hourly_mv
TO of_node_traffic_hourly
AS
SELECT
node_id,
toStartOfHour(window_ended_at) AS hour,
sum(request_count) AS request_count,
sum(error_count) AS error_count,
sum(unique_visitor_count) AS unique_visitor_count
FROM of_node_request_reports
GROUP BY node_id, hour;
-- +goose Down
DROP VIEW IF EXISTS of_node_traffic_hourly_mv;
DROP TABLE IF EXISTS of_node_traffic_hourly;
@@ -0,0 +1,80 @@
-- +goose Up
-- Hourly capacity rollups (avg CPU/memory + counter min/max for in-hour delta approximation).
-- Network/disk counters are cumulative; max-min within an hour approximates that hour's delta
-- (cross-hour continuity is intentionally approximate for dashboard trends).
CREATE TABLE IF NOT EXISTS of_node_metric_capacity_hourly
(
node_id String,
hour DateTime,
cpu_usage_sum SimpleAggregateFunction(sum, Float64),
cpu_usage_count SimpleAggregateFunction(sum, UInt64),
memory_usage_sum SimpleAggregateFunction(sum, Float64),
memory_usage_count SimpleAggregateFunction(sum, UInt64),
network_rx_min SimpleAggregateFunction(min, Int64),
network_rx_max SimpleAggregateFunction(max, Int64),
network_tx_min SimpleAggregateFunction(min, Int64),
network_tx_max SimpleAggregateFunction(max, Int64),
disk_read_min SimpleAggregateFunction(min, Int64),
disk_read_max SimpleAggregateFunction(max, Int64),
disk_write_min SimpleAggregateFunction(min, Int64),
disk_write_max SimpleAggregateFunction(max, Int64)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour)
TTL hour + INTERVAL 30 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_node_metric_capacity_hourly_mv
TO of_node_metric_capacity_hourly
AS
SELECT
node_id,
toStartOfHour(captured_at) AS hour,
sum(cpu_usage_percent) AS cpu_usage_sum,
toUInt64(count()) AS cpu_usage_count,
sum(if(memory_total_bytes > 0, (memory_used_bytes * 100.0) / memory_total_bytes, 0)) AS memory_usage_sum,
toUInt64(countIf(memory_total_bytes > 0)) AS memory_usage_count,
min(network_rx_bytes) AS network_rx_min,
max(network_rx_bytes) AS network_rx_max,
min(network_tx_bytes) AS network_tx_min,
max(network_tx_bytes) AS network_tx_max,
min(disk_read_bytes) AS disk_read_min,
max(disk_read_bytes) AS disk_read_max,
min(disk_write_bytes) AS disk_write_min,
max(disk_write_bytes) AS disk_write_max
FROM of_node_metric_snapshots
GROUP BY node_id, hour;
-- Hourly OpenResty counter rollups (min/max per node-hour for delta approximation).
CREATE TABLE IF NOT EXISTS of_node_openresty_hourly
(
node_id String,
hour DateTime,
openresty_rx_min SimpleAggregateFunction(min, Int64),
openresty_rx_max SimpleAggregateFunction(max, Int64),
openresty_tx_min SimpleAggregateFunction(min, Int64),
openresty_tx_max SimpleAggregateFunction(max, Int64)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour)
TTL hour + INTERVAL 30 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_node_openresty_hourly_mv
TO of_node_openresty_hourly
AS
SELECT
node_id,
toStartOfHour(captured_at) AS hour,
min(openresty_rx_bytes) AS openresty_rx_min,
max(openresty_rx_bytes) AS openresty_rx_max,
min(openresty_tx_bytes) AS openresty_tx_min,
max(openresty_tx_bytes) AS openresty_tx_max
FROM of_node_obs_openresty
GROUP BY node_id, hour;
-- +goose Down
DROP VIEW IF EXISTS of_node_openresty_hourly_mv;
DROP TABLE IF EXISTS of_node_openresty_hourly;
DROP VIEW IF EXISTS of_node_metric_capacity_hourly_mv;
DROP TABLE IF EXISTS of_node_metric_capacity_hourly;
@@ -0,0 +1,42 @@
-- +goose Up
-- Hourly traffic rollups: 30d TTL + UV aggregation semantics.
--
-- unique_visitor_count on of_node_request_reports is per short report window
-- (agent local distinct count for that window only). Summing those values in the
-- MV (and again via SummingMergeTree part merges) invents a "true UV" number that
-- double-counts visitors across windows. Prefer max() as a peak-window estimate;
-- still NOT distinct visitors across the hour — UI/API must not overclaim.
ALTER TABLE of_node_traffic_hourly
MODIFY TTL toDateTime(hour) + INTERVAL 30 DAY;
DROP VIEW IF EXISTS of_node_traffic_hourly_mv;
CREATE MATERIALIZED VIEW of_node_traffic_hourly_mv
TO of_node_traffic_hourly
AS
SELECT
node_id,
toStartOfHour(window_ended_at) AS hour,
sum(request_count) AS request_count,
sum(error_count) AS error_count,
-- Peak per-window UV estimate for the hour; not true cross-window distinct UV.
max(unique_visitor_count) AS unique_visitor_count
FROM of_node_request_reports
GROUP BY node_id, hour;
-- +goose Down
-- TTL reverse is not safe without table rewrite; restore prior MV definition only.
DROP VIEW IF EXISTS of_node_traffic_hourly_mv;
CREATE MATERIALIZED VIEW of_node_traffic_hourly_mv
TO of_node_traffic_hourly
AS
SELECT
node_id,
toStartOfHour(window_ended_at) AS hour,
sum(request_count) AS request_count,
sum(error_count) AS error_count,
sum(unique_visitor_count) AS unique_visitor_count
FROM of_node_request_reports
GROUP BY node_id, hour;
@@ -0,0 +1,66 @@
-- +goose Up
-- One-time historical backfill for hours not yet present in rollup tables.
-- MV only ingests rows after creation; without this, 24h charts rely on raw merge forever.
-- ANTI JOIN avoids double-counting hours already filled by the live MV.
INSERT INTO of_node_metric_capacity_hourly
SELECT
s.node_id,
toStartOfHour(s.captured_at) AS hour,
sum(s.cpu_usage_percent) AS cpu_usage_sum,
toUInt64(count()) AS cpu_usage_count,
sum(if(s.memory_total_bytes > 0, (s.memory_used_bytes * 100.0) / s.memory_total_bytes, 0)) AS memory_usage_sum,
toUInt64(countIf(s.memory_total_bytes > 0)) AS memory_usage_count,
min(s.network_rx_bytes) AS network_rx_min,
max(s.network_rx_bytes) AS network_rx_max,
min(s.network_tx_bytes) AS network_tx_min,
max(s.network_tx_bytes) AS network_tx_max,
min(s.disk_read_bytes) AS disk_read_min,
max(s.disk_read_bytes) AS disk_read_max,
min(s.disk_write_bytes) AS disk_write_min,
max(s.disk_write_bytes) AS disk_write_max
FROM of_node_metric_snapshots AS s
ANTI JOIN
(
SELECT
node_id,
hour
FROM of_node_metric_capacity_hourly
GROUP BY
node_id,
hour
) AS existing
ON s.node_id = existing.node_id AND toStartOfHour(s.captured_at) = existing.hour
WHERE s.captured_at >= now() - INTERVAL 30 DAY
GROUP BY
s.node_id,
hour;
INSERT INTO of_node_openresty_hourly
SELECT
s.node_id,
toStartOfHour(s.captured_at) AS hour,
min(s.openresty_rx_bytes) AS openresty_rx_min,
max(s.openresty_rx_bytes) AS openresty_rx_max,
min(s.openresty_tx_bytes) AS openresty_tx_min,
max(s.openresty_tx_bytes) AS openresty_tx_max
FROM of_node_obs_openresty AS s
ANTI JOIN
(
SELECT
node_id,
hour
FROM of_node_openresty_hourly
GROUP BY
node_id,
hour
) AS existing
ON s.node_id = existing.node_id AND toStartOfHour(s.captured_at) = existing.hour
WHERE s.captured_at >= now() - INTERVAL 30 DAY
GROUP BY
s.node_id,
hour;
-- +goose Down
-- Backfill is additive; down does not remove historical rollup rows (TTL still applies).
SELECT 1;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE of_node_access_logs ADD COLUMN IF NOT EXISTS bytes_sent UInt64;
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS bytes_sent;
@@ -0,0 +1,11 @@
-- +goose Up
-- L1 access log: request body/header length (接收数据) and optional request duration.
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS request_length UInt64 DEFAULT 0;
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS request_time_ms UInt32 DEFAULT 0;
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS request_time_ms;
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS request_length;
@@ -0,0 +1,149 @@
-- +goose Up
-- M5: L2 edge health table, L1 access-log hourly rollup, drop deprecated pre-aggregation tables.
CREATE TABLE IF NOT EXISTS of_node_edge_health
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
status LowCardinality(String),
connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
-- Hourly business traffic from access logs (Server-side only; Agent never writes this).
-- SummingMergeTree merges request/error/bytes; UV is not stored (query raw for exact UV).
CREATE TABLE IF NOT EXISTS of_access_log_hourly
(
node_id String,
hour DateTime('UTC'),
host String,
request_count UInt64,
error_count UInt64,
bytes_sent UInt64,
request_length UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour, host)
TTL hour + INTERVAL 90 DAY
SETTINGS index_granularity = 8192;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_access_log_hourly_mv
TO of_access_log_hourly
AS
SELECT
node_id,
toStartOfHour(logged_at) AS hour,
host,
toUInt64(count()) AS request_count,
toUInt64(countIf(status_code >= 500)) AS error_count,
sum(bytes_sent) AS bytes_sent,
sum(request_length) AS request_length
FROM of_node_access_logs
GROUP BY node_id, hour, host;
-- Deprecated pre-aggregation paths (business traffic is access logs; OR throughput is not authoritative).
DROP VIEW IF EXISTS of_node_traffic_hourly_mv;
DROP TABLE IF EXISTS of_node_traffic_hourly;
DROP VIEW IF EXISTS of_node_openresty_hourly_mv;
DROP TABLE IF EXISTS of_node_openresty_hourly;
DROP TABLE IF EXISTS of_node_request_reports;
DROP TABLE IF EXISTS of_node_obs_openresty;
-- +goose Down
CREATE TABLE IF NOT EXISTS of_node_obs_openresty
(
id UInt64,
node_id String,
captured_at DateTime64(3, 'UTC'),
openresty_rx_bytes Int64,
openresty_tx_bytes Int64,
openresty_connections Int64,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(captured_at)
ORDER BY (node_id, captured_at, id)
TTL toDateTime(captured_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_request_reports
(
id UInt64,
node_id String,
window_started_at DateTime64(3, 'UTC'),
window_ended_at DateTime64(3, 'UTC'),
request_count Int64,
error_count Int64,
unique_visitor_count Int64,
status_codes_json String,
top_domains_json String,
source_countries_json String,
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(window_ended_at)
ORDER BY (node_id, window_ended_at, window_started_at, id)
TTL toDateTime(window_ended_at) + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
CREATE TABLE IF NOT EXISTS of_node_traffic_hourly
(
node_id String,
hour DateTime,
request_count UInt64,
error_count UInt64,
unique_visitor_count UInt64
)
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour);
CREATE MATERIALIZED VIEW IF NOT EXISTS of_node_traffic_hourly_mv
TO of_node_traffic_hourly
AS
SELECT
node_id,
toStartOfHour(window_ended_at) AS hour,
sum(request_count) AS request_count,
sum(error_count) AS error_count,
max(unique_visitor_count) AS unique_visitor_count
FROM of_node_request_reports
GROUP BY node_id, hour;
CREATE TABLE IF NOT EXISTS of_node_openresty_hourly
(
node_id String,
hour DateTime,
openresty_rx_min SimpleAggregateFunction(min, Int64),
openresty_rx_max SimpleAggregateFunction(max, Int64),
openresty_tx_min SimpleAggregateFunction(min, Int64),
openresty_tx_max SimpleAggregateFunction(max, Int64)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (node_id, hour)
TTL hour + INTERVAL 30 DAY;
CREATE MATERIALIZED VIEW IF NOT EXISTS of_node_openresty_hourly_mv
TO of_node_openresty_hourly
AS
SELECT
node_id,
toStartOfHour(captured_at) AS hour,
min(openresty_rx_bytes) AS openresty_rx_min,
max(openresty_rx_bytes) AS openresty_rx_max,
min(openresty_tx_bytes) AS openresty_tx_min,
max(openresty_tx_bytes) AS openresty_tx_max
FROM of_node_obs_openresty
GROUP BY node_id, hour;
DROP VIEW IF EXISTS of_access_log_hourly_mv;
DROP TABLE IF EXISTS of_access_log_hourly;
DROP TABLE IF EXISTS of_node_edge_health;
@@ -0,0 +1,40 @@
-- +goose Up
-- One-time historical backfill for of_access_log_hourly.
-- MV only ingests rows after creation; without this, 24h charts fall back to raw access logs.
-- ANTI JOIN avoids double-counting (node_id, hour, host) already filled by the live MV.
-- UV is intentionally NOT stored in of_access_log_hourly (SummingMergeTree counts only).
INSERT INTO of_access_log_hourly
SELECT
s.node_id,
toStartOfHour(s.logged_at) AS hour,
s.host,
toUInt64(count()) AS request_count,
toUInt64(countIf(s.status_code >= 500)) AS error_count,
sum(s.bytes_sent) AS bytes_sent,
sum(s.request_length) AS request_length
FROM of_node_access_logs AS s
ANTI JOIN
(
SELECT
node_id,
hour,
host
FROM of_access_log_hourly
GROUP BY
node_id,
hour,
host
) AS existing
ON s.node_id = existing.node_id
AND toStartOfHour(s.logged_at) = existing.hour
AND s.host = existing.host
WHERE s.logged_at >= now() - INTERVAL 90 DAY
GROUP BY
s.node_id,
hour,
s.host;
-- +goose Down
-- Backfill is additive; down does not remove historical rollup rows (TTL still applies).
SELECT 1;
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS user_agent String DEFAULT '';
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS user_agent;
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS cache_status;
@@ -0,0 +1,7 @@
-- +goose Up
-- Historical Wavelet→OpenFlare table rename bridge (formerly Go migration).
-- Fresh installs and current of_* schemas need no action.
SELECT 1;
-- +goose Down
SELECT 1;
@@ -0,0 +1,184 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY,
username VARCHAR(64) UNIQUE,
password VARCHAR(255),
nickname VARCHAR(255),
email VARCHAR(255),
avatar_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
bio VARCHAR(500),
phone VARCHAR(32),
gender VARCHAR(16),
website VARCHAR(255),
location VARCHAR(255),
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_users_is_active ON users (is_active);
CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at);
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at);
CREATE TABLE IF NOT EXISTS auth_sources (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL,
display_name VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT FALSE,
client_id VARCHAR(255),
client_secret VARCHAR(1024),
openid_discovery_url VARCHAR(1024),
scopes VARCHAR(255),
icon_url VARCHAR(1024),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_auth_sources_is_active ON auth_sources (is_active);
CREATE TABLE IF NOT EXISTS external_accounts (
id BIGSERIAL PRIMARY KEY,
auth_source_id BIGINT,
user_id BIGINT NOT NULL,
external_id VARCHAR(255) NOT NULL,
external_username VARCHAR(255),
email VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_external_accounts_auth_source_id ON external_accounts (auth_source_id);
CREATE INDEX IF NOT EXISTS idx_external_accounts_user_id ON external_accounts (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_external_accounts_source_external ON external_accounts (auth_source_id, external_id);
CREATE TABLE IF NOT EXISTS system_configs (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(32) NOT NULL DEFAULT 'system',
visibility INTEGER NOT NULL DEFAULT 0,
description VARCHAR(255),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS uploads (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
extension VARCHAR(50) NOT NULL,
hash VARCHAR(64),
storage_driver VARCHAR(50) NOT NULL,
type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads (user_id);
CREATE INDEX IF NOT EXISTS idx_uploads_file_path ON uploads (file_path);
CREATE INDEX IF NOT EXISTS idx_uploads_hash ON uploads (hash);
CREATE INDEX IF NOT EXISTS idx_uploads_type ON uploads (type);
CREATE TABLE IF NOT EXISTS access_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
masked_token VARCHAR(64) NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_access_tokens_user_id ON access_tokens (user_id);
CREATE TABLE IF NOT EXISTS task_executions (
id BIGINT PRIMARY KEY,
task_id VARCHAR(128) NOT NULL UNIQUE,
task_type VARCHAR(64) NOT NULL,
task_name VARCHAR(128),
status VARCHAR(32) NOT NULL,
retryable BOOLEAN NOT NULL DEFAULT FALSE,
max_retry INTEGER NOT NULL DEFAULT 0,
retry_count INTEGER NOT NULL DEFAULT 0,
log TEXT,
error_message TEXT,
result TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
duration BIGINT,
payload TEXT,
triggered_by VARCHAR(32) NOT NULL DEFAULT 'system',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_task_executions_task_type ON task_executions (task_type);
CREATE INDEX IF NOT EXISTS idx_task_executions_status ON task_executions (status);
CREATE INDEX IF NOT EXISTS idx_task_executions_started_at ON task_executions (started_at);
CREATE INDEX IF NOT EXISTS idx_task_executions_created_at ON task_executions (created_at);
CREATE TABLE IF NOT EXISTS templates (
id BIGSERIAL PRIMARY KEY,
key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'email',
subject VARCHAR(255),
content TEXT NOT NULL,
description VARCHAR(255),
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_templates_is_system ON templates (is_system);
CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at);
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at);
INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES
('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('site_name', 'OpenFlare', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'false', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'false', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('update_upstream_repository', 'Rain-kl/OpenFlare', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
INSERT INTO users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (1, 'admin', '12345678', 'Administrator', '', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
INSERT INTO templates (key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES
('login_email', '登录验证码邮件', 'email', 'OpenFlare 登录验证码', '<h3>OpenFlare 登录验证</h3><p>您的登录验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('register_email', '注册验证码邮件', 'email', 'OpenFlare 注册验证码', '<h3>OpenFlare 注册验证</h3><p>您的注册验证码为:<strong>{{.Code}}</strong>,5分钟内有效,请勿泄露给他人。</p>', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS templates;
DROP TABLE IF EXISTS task_executions;
DROP TABLE IF EXISTS access_tokens;
DROP TABLE IF EXISTS uploads;
DROP TABLE IF EXISTS system_configs;
DROP TABLE IF EXISTS external_accounts;
DROP TABLE IF EXISTS auth_sources;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,20 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS schedules (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
task_type VARCHAR(64) NOT NULL,
cron VARCHAR(64) NOT NULL,
payload TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_schedules_is_active ON schedules (is_active);
-- Seed initial cleanup task
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS schedules;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE access_tokens ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- +goose Down
ALTER TABLE access_tokens DROP COLUMN IF EXISTS is_admin;
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE access_tokens DROP COLUMN IF EXISTS last_used_at;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE access_tokens ADD COLUMN last_used_at TIMESTAMPTZ;
-- +goose StatementEnd
@@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE schedules ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY (START WITH 100);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE schedules ALTER COLUMN id DROP IDENTITY IF EXISTS;
-- +goose StatementEnd
@@ -0,0 +1,66 @@
-- +goose Up
ALTER TABLE users RENAME TO w_users;
ALTER TABLE auth_sources RENAME TO w_auth_sources;
ALTER TABLE external_accounts RENAME TO w_external_accounts;
ALTER TABLE system_configs RENAME TO w_system_configs;
ALTER TABLE uploads RENAME TO w_uploads;
ALTER TABLE access_tokens RENAME TO w_access_tokens;
ALTER TABLE task_executions RENAME TO w_task_executions;
ALTER TABLE templates RENAME TO w_templates;
ALTER TABLE schedules RENAME TO w_schedules;
-- Rename indexes for consistency
ALTER INDEX IF EXISTS idx_users_email RENAME TO idx_w_users_email;
ALTER INDEX IF EXISTS idx_users_is_active RENAME TO idx_w_users_is_active;
ALTER INDEX IF EXISTS idx_users_last_login_at RENAME TO idx_w_users_last_login_at;
ALTER INDEX IF EXISTS idx_users_created_at RENAME TO idx_w_users_created_at;
ALTER INDEX IF EXISTS idx_auth_sources_is_active RENAME TO idx_w_auth_sources_is_active;
ALTER INDEX IF EXISTS idx_external_accounts_auth_source_id RENAME TO idx_w_external_accounts_auth_source_id;
ALTER INDEX IF EXISTS idx_external_accounts_user_id RENAME TO idx_w_external_accounts_user_id;
ALTER INDEX IF EXISTS idx_external_accounts_source_external RENAME TO idx_w_external_accounts_source_external;
ALTER INDEX IF EXISTS idx_uploads_user_id RENAME TO idx_w_uploads_user_id;
ALTER INDEX IF EXISTS idx_uploads_file_path RENAME TO idx_w_uploads_file_path;
ALTER INDEX IF EXISTS idx_uploads_hash RENAME TO idx_w_uploads_hash;
ALTER INDEX IF EXISTS idx_uploads_type RENAME TO idx_w_uploads_type;
ALTER INDEX IF EXISTS idx_access_tokens_user_id RENAME TO idx_w_access_tokens_user_id;
ALTER INDEX IF EXISTS idx_task_executions_task_type RENAME TO idx_w_task_executions_task_type;
ALTER INDEX IF EXISTS idx_task_executions_status RENAME TO idx_w_task_executions_status;
ALTER INDEX IF EXISTS idx_task_executions_started_at RENAME TO idx_w_task_executions_started_at;
ALTER INDEX IF EXISTS idx_task_executions_created_at RENAME TO idx_w_task_executions_created_at;
ALTER INDEX IF EXISTS idx_templates_is_system RENAME TO idx_w_templates_is_system;
ALTER INDEX IF EXISTS idx_templates_created_at RENAME TO idx_w_templates_created_at;
ALTER INDEX IF EXISTS idx_templates_updated_at RENAME TO idx_w_templates_updated_at;
ALTER INDEX IF EXISTS idx_schedules_is_active RENAME TO idx_w_schedules_is_active;
-- +goose Down
ALTER INDEX IF EXISTS idx_w_schedules_is_active RENAME TO idx_schedules_is_active;
ALTER INDEX IF EXISTS idx_w_templates_updated_at RENAME TO idx_templates_updated_at;
ALTER INDEX IF EXISTS idx_w_templates_created_at RENAME TO idx_templates_created_at;
ALTER INDEX IF EXISTS idx_w_templates_is_system RENAME TO idx_templates_is_system;
ALTER INDEX IF EXISTS idx_w_task_executions_created_at RENAME TO idx_task_executions_created_at;
ALTER INDEX IF EXISTS idx_w_task_executions_started_at RENAME TO idx_task_executions_started_at;
ALTER INDEX IF EXISTS idx_w_task_executions_status RENAME TO idx_task_executions_status;
ALTER INDEX IF EXISTS idx_w_task_executions_task_type RENAME TO idx_task_executions_task_type;
ALTER INDEX IF EXISTS idx_w_access_tokens_user_id RENAME TO idx_access_tokens_user_id;
ALTER INDEX IF EXISTS idx_w_uploads_type RENAME TO idx_uploads_type;
ALTER INDEX IF EXISTS idx_w_uploads_hash RENAME TO idx_uploads_hash;
ALTER INDEX IF EXISTS idx_w_uploads_file_path RENAME TO idx_uploads_file_path;
ALTER INDEX IF EXISTS idx_w_uploads_user_id RENAME TO idx_uploads_user_id;
ALTER INDEX IF EXISTS idx_w_external_accounts_source_external RENAME TO idx_external_accounts_source_external;
ALTER INDEX IF EXISTS idx_w_external_accounts_user_id RENAME TO idx_external_accounts_user_id;
ALTER INDEX IF EXISTS idx_w_external_accounts_auth_source_id RENAME TO idx_external_accounts_auth_source_id;
ALTER INDEX IF EXISTS idx_w_auth_sources_is_active RENAME TO idx_auth_sources_is_active;
ALTER INDEX IF EXISTS idx_w_users_created_at RENAME TO idx_users_created_at;
ALTER INDEX IF EXISTS idx_w_users_last_login_at RENAME TO idx_users_last_login_at;
ALTER INDEX IF EXISTS idx_w_users_is_active RENAME TO idx_users_is_active;
ALTER INDEX IF EXISTS idx_w_users_email RENAME TO idx_users_email;
ALTER TABLE w_schedules RENAME TO schedules;
ALTER TABLE w_templates RENAME TO templates;
ALTER TABLE w_task_executions RENAME TO task_executions;
ALTER TABLE w_access_tokens RENAME TO access_tokens;
ALTER TABLE w_uploads RENAME TO uploads;
ALTER TABLE w_system_configs RENAME TO system_configs;
ALTER TABLE w_external_accounts RENAME TO external_accounts;
ALTER TABLE w_auth_sources RENAME TO auth_sources;
ALTER TABLE w_users RENAME TO users;
@@ -0,0 +1,7 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'file_access_whitelist';
@@ -0,0 +1,10 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('disk_cache_max_size_mb', '100', 'system', 0, '磁盘缓存最大空间大小 (MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_ttl_minutes', '60', 'system', 0, '磁盘缓存默认有效期 (分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled');
@@ -0,0 +1,8 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('login_session_ttl_hours', '0', 'system', 0, '登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours';
@@ -0,0 +1,7 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES ('update_upstream_repository', 'Rain-kl/OpenFlare', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'update_upstream_repository';
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE w_uploads ADD COLUMN access_mode INTEGER NOT NULL DEFAULT 0;
UPDATE w_uploads SET access_mode = 1 WHERE type = 'avatar';
-- +goose Down
ALTER TABLE w_uploads DROP COLUMN access_mode;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE w_system_configs ALTER COLUMN value TYPE TEXT;
-- +goose Down
ALTER TABLE w_system_configs ALTER COLUMN value TYPE VARCHAR(255);
@@ -0,0 +1,15 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES (
'storage_config',
'{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}',
'system',
0,
'文件存储驱动及连接配置(JSON)',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key = 'storage_config';
@@ -0,0 +1,34 @@
-- +goose Up
CREATE TABLE w_push_events (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
channels TEXT NOT NULL,
targets TEXT NOT NULL,
template TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled);
CREATE TABLE w_push_histories (
id BIGSERIAL PRIMARY KEY,
event_key VARCHAR(80) NOT NULL,
channel VARCHAR(50) NOT NULL,
target VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
level VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
error_msg TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key);
CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at);
-- +goose Down
DROP TABLE IF EXISTS w_push_histories;
DROP TABLE IF EXISTS w_push_events;
@@ -0,0 +1,8 @@
-- +goose Up
INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
SELECT setval(pg_get_serial_sequence('w_users', 'id'), COALESCE((SELECT MAX(id) FROM w_users), 1));
-- +goose Down
DELETE FROM w_users WHERE username = 'system';
@@ -0,0 +1,19 @@
-- +goose Up
CREATE TABLE w_push_channels (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(80) NOT NULL UNIQUE,
description VARCHAR(255),
type VARCHAR(50) NOT NULL DEFAULT 'custom',
token VARCHAR(100),
url TEXT NOT NULL,
other TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_w_push_channels_name ON w_push_channels(name);
CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled);
-- +goose Down
DROP TABLE IF EXISTS w_push_channels;
@@ -0,0 +1,11 @@
-- +goose Up
UPDATE w_schedules
SET name = '系统定期垃圾清理',
task_type = 'system_cleanup'
WHERE id = 1;
-- +goose Down
UPDATE w_schedules
SET name = '清理未使用上传',
task_type = 'cleanup_unused_uploads'
WHERE id = 1;
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE w_push_events ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT '';
CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type);
-- +goose Down
DROP INDEX IF EXISTS idx_w_push_events_task_type;
ALTER TABLE w_push_events DROP COLUMN task_type;
@@ -0,0 +1,6 @@
-- +goose Up
DELETE FROM w_system_configs WHERE key = 'push_config';
DELETE FROM w_push_events WHERE event_key = 'admin_login';
DELETE FROM w_system_configs WHERE key = 'push_global_token';
-- +goose Down
@@ -0,0 +1,9 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at);
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status);
-- +goose Down
DROP INDEX IF EXISTS idx_w_uploads_hash_file_size_status;
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
DROP INDEX IF EXISTS idx_w_uploads_status_created_at;
@@ -0,0 +1,12 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS w_upload_stats (
dimension VARCHAR(32) NOT NULL,
stat_key VARCHAR(64) NOT NULL DEFAULT '',
file_count BIGINT NOT NULL DEFAULT 0,
file_size BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (dimension, stat_key)
);
-- +goose Down
DROP TABLE IF EXISTS w_upload_stats;
@@ -0,0 +1,67 @@
-- +goose Up
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT 'total', '', COUNT(*), COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'type',
COALESCE(NULLIF(type, ''), 'generic'),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY COALESCE(NULLIF(type, ''), 'generic')
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'category',
CASE
WHEN LOWER(mime_type) LIKE 'image/%'
OR LOWER(extension) IN ('jpg', 'jpeg', 'png', 'webp', 'gif') THEN '图片'
WHEN LOWER(mime_type) LIKE 'video/%' THEN '视频'
WHEN LOWER(mime_type) LIKE 'audio/%' THEN '音频'
WHEN LOWER(extension) IN ('zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz')
OR LOWER(mime_type) LIKE '%zip%'
OR LOWER(mime_type) LIKE '%tar%'
OR LOWER(mime_type) LIKE '%gzip%' THEN '压缩包'
WHEN LOWER(extension) IN ('pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'json', 'yaml', 'yml', 'xml')
OR LOWER(mime_type) LIKE 'text/%'
OR LOWER(mime_type) = 'application/pdf' THEN '文档'
ELSE '其他'
END,
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY 2
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO w_upload_stats (dimension, stat_key, file_count, file_size)
SELECT
'trend',
TO_CHAR(created_at, 'YYYY-MM-DD'),
COUNT(*),
COALESCE(SUM(file_size), 0)
FROM w_uploads
WHERE status != 'deleted'
GROUP BY TO_CHAR(created_at, 'YYYY-MM-DD')
ON CONFLICT (dimension, stat_key) DO UPDATE SET
file_count = EXCLUDED.file_count,
file_size = EXCLUDED.file_size,
updated_at = CURRENT_TIMESTAMP;
-- +goose Down
DELETE FROM w_upload_stats;
@@ -0,0 +1,7 @@
-- +goose Up
DROP INDEX IF EXISTS idx_w_uploads_storage_driver_status;
ALTER TABLE w_uploads DROP COLUMN IF EXISTS storage_driver;
-- +goose Down
ALTER TABLE w_uploads ADD COLUMN IF NOT EXISTS storage_driver VARCHAR(50) NOT NULL DEFAULT 'local';
CREATE INDEX IF NOT EXISTS idx_w_uploads_storage_driver_status ON w_uploads (storage_driver, status);
@@ -0,0 +1,67 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_options (
key VARCHAR(128) PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
INSERT INTO of_options (key, value) VALUES
('PasswordLoginEnabled', 'true'),
('CapLoginEnabled', 'true'),
('PasswordRegisterEnabled', 'false'),
('EmailVerificationEnabled', 'false'),
('GitHubOAuthEnabled', 'false'),
('WeChatAuthEnabled', 'false'),
('SMTPPort', '587'),
('SystemName', 'OpenFlare'),
('AgentHeartbeatInterval', '10000'),
('AgentWebsocketUpgradeEnabled', 'true'),
('NodeOfflineThreshold', '120000'),
('AgentUpdateRepo', 'Rain-kl/OpenFlare'),
('GeoIPProvider', 'ipinfo'),
('DatabaseAutoCleanupEnabled', 'false'),
('DatabaseAutoCleanupRetentionDays', '30'),
('UptimeKumaEnabled', 'false'),
('UptimeKumaMonitorScope', 'all'),
('UptimeKumaSyncInterval', '5'),
('UptimeKumaInterval', '60'),
('UptimeKumaRetry', '0'),
('UptimeKumaRetryInterval', '60'),
('UptimeKumaTimeout', '48'),
('OpenRestyDefaultServerReturnStatus', '421'),
('OpenRestyWorkerProcesses', 'auto'),
('OpenRestyWorkerConnections', '4096'),
('OpenRestyWorkerRlimitNofile', '65535'),
('OpenRestyEventsUse', 'epoll'),
('OpenRestyEventsMultiAcceptEnabled', 'true'),
('OpenRestyKeepaliveTimeout', '20'),
('OpenRestyKeepaliveRequests', '1000'),
('OpenRestyClientHeaderTimeout', '15'),
('OpenRestyClientBodyTimeout', '15'),
('OpenRestyClientMaxBodySize', '64m'),
('OpenRestyLargeClientHeaderBuffers', '4 16k'),
('OpenRestySendTimeout', '30'),
('OpenRestyProxyConnectTimeout', '3'),
('OpenRestyProxySendTimeout', '60'),
('OpenRestyProxyReadTimeout', '60'),
('OpenRestyWebsocketEnabled', 'true'),
('OpenRestyHTTP3Enabled', 'true'),
('OpenRestyProxyRequestBufferingEnabled', 'false'),
('OpenRestyProxyBufferingEnabled', 'true'),
('OpenRestyProxyBuffers', '16 16k'),
('OpenRestyProxyBufferSize', '8k'),
('OpenRestyProxyBusyBuffersSize', '64k'),
('OpenRestyGzipEnabled', 'true'),
('OpenRestyGzipMinLength', '1024'),
('OpenRestyGzipCompLevel', '5'),
('OpenRestyCacheEnabled', 'false'),
('OpenRestyCacheLevels', '1:2'),
('OpenRestyCacheInactive', '30m'),
('OpenRestyCacheMaxSize', '1g'),
('OpenRestyCacheKeyTemplate', '$scheme$host$request_uri'),
('OpenRestyCacheLockEnabled', 'true'),
('OpenRestyCacheLockTimeout', '5s'),
('OpenRestyCacheUseStale', 'error timeout updating http_500 http_502 http_503 http_504')
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DROP TABLE IF EXISTS of_options;
@@ -0,0 +1,13 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_origins (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_origins_address ON of_origins (address);
-- +goose Down
DROP TABLE IF EXISTS of_origins;
@@ -0,0 +1,19 @@
-- +goose Up
CREATE TABLE of_apply_logs (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL,
result VARCHAR(32) NOT NULL,
message TEXT,
checksum VARCHAR(64) NOT NULL DEFAULT '',
main_config_checksum VARCHAR(64) NOT NULL DEFAULT '',
route_config_checksum VARCHAR(64) NOT NULL DEFAULT '',
support_file_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_of_apply_logs_node_id ON of_apply_logs(node_id);
CREATE INDEX idx_of_apply_logs_created_at ON of_apply_logs(created_at);
-- +goose Down
DROP TABLE IF EXISTS of_apply_logs;
@@ -0,0 +1,43 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_proxy_routes (
id BIGSERIAL PRIMARY KEY,
site_name VARCHAR(255) NOT NULL DEFAULT '',
domain VARCHAR(255) NOT NULL,
domains TEXT NOT NULL DEFAULT '[]',
origin_id BIGINT,
origin_url VARCHAR(2048) NOT NULL,
origin_host VARCHAR(255) NOT NULL DEFAULT '',
upstreams TEXT NOT NULL DEFAULT '[]',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
enable_https BOOLEAN NOT NULL DEFAULT FALSE,
cert_id BIGINT,
cert_ids TEXT NOT NULL DEFAULT '[]',
domain_cert_ids TEXT NOT NULL DEFAULT '[]',
redirect_http BOOLEAN NOT NULL DEFAULT FALSE,
limit_conn_per_server INTEGER NOT NULL DEFAULT 0,
limit_conn_per_ip INTEGER NOT NULL DEFAULT 0,
limit_rate VARCHAR(32) NOT NULL DEFAULT '',
cache_enabled BOOLEAN NOT NULL DEFAULT FALSE,
cache_policy VARCHAR(32) NOT NULL DEFAULT '',
cache_rules TEXT NOT NULL DEFAULT '[]',
custom_headers TEXT NOT NULL DEFAULT '[]',
basic_auth_enabled BOOLEAN NOT NULL DEFAULT FALSE,
basic_auth_username VARCHAR(255) NOT NULL DEFAULT '',
basic_auth_password VARCHAR(255) NOT NULL DEFAULT '',
remark VARCHAR(255) NOT NULL DEFAULT '',
upstream_type VARCHAR(32) NOT NULL DEFAULT 'direct',
tunnel_node_id BIGINT,
tunnel_target_addr VARCHAR(512) NOT NULL DEFAULT '',
tunnel_target_protocol VARCHAR(16) NOT NULL DEFAULT '',
pages_project_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_proxy_routes_domain ON of_proxy_routes (domain);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_proxy_routes_site_name ON of_proxy_routes (site_name);
CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_origin_id ON of_proxy_routes (origin_id);
CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_tunnel_node_id ON of_proxy_routes (tunnel_node_id);
CREATE INDEX IF NOT EXISTS idx_of_proxy_routes_pages_project_id ON of_proxy_routes (pages_project_id);
-- +goose Down
DROP TABLE IF EXISTS of_proxy_routes;
@@ -0,0 +1,44 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_nodes (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
ip_manual_override BOOLEAN NOT NULL DEFAULT FALSE,
geo_name VARCHAR(128) NOT NULL DEFAULT '',
geo_latitude DOUBLE PRECISION,
geo_longitude DOUBLE PRECISION,
geo_manual_override BOOLEAN NOT NULL DEFAULT FALSE,
access_token VARCHAR(128) NOT NULL DEFAULT '',
auto_update_enabled BOOLEAN NOT NULL DEFAULT FALSE,
update_requested BOOLEAN NOT NULL DEFAULT FALSE,
update_channel VARCHAR(16) NOT NULL DEFAULT 'stable',
update_tag VARCHAR(64) NOT NULL DEFAULT '',
restart_openresty_requested BOOLEAN NOT NULL DEFAULT FALSE,
version VARCHAR(64) NOT NULL DEFAULT '',
ext_version VARCHAR(64) NOT NULL DEFAULT '',
openresty_status VARCHAR(16) NOT NULL DEFAULT 'unknown',
openresty_message TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'offline',
current_version VARCHAR(32) NOT NULL DEFAULT '',
last_seen_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
node_type VARCHAR(32) NOT NULL DEFAULT 'edge_node',
relay_bind_port INTEGER NOT NULL DEFAULT 0,
relay_vhost_http_port INTEGER NOT NULL DEFAULT 0,
relay_auth_token VARCHAR(128) NOT NULL DEFAULT '',
relay_agent_access_addr VARCHAR(255) NOT NULL DEFAULT '',
relay_client_access_addr VARCHAR(255) NOT NULL DEFAULT '',
relay_client_proxy_url VARCHAR(512) NOT NULL DEFAULT '',
capabilities_json TEXT NOT NULL DEFAULT '[]',
relay_status VARCHAR(16) NOT NULL DEFAULT 'unknown',
relay_web_server_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_nodes_node_id ON of_nodes (node_id);
CREATE INDEX IF NOT EXISTS idx_of_nodes_access_token ON of_nodes (access_token);
-- +goose Down
DROP TABLE IF EXISTS of_nodes;
@@ -0,0 +1,60 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_waf_rule_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_global BOOLEAN NOT NULL DEFAULT FALSE,
block_status_code INTEGER NOT NULL DEFAULT 418,
block_response_body TEXT NOT NULL DEFAULT '',
ip_whitelist TEXT NOT NULL DEFAULT '[]',
ip_blacklist TEXT NOT NULL DEFAULT '[]',
ip_whitelist_groups TEXT NOT NULL DEFAULT '[]',
ip_blacklist_groups TEXT NOT NULL DEFAULT '[]',
country_whitelist TEXT NOT NULL DEFAULT '[]',
country_blacklist TEXT NOT NULL DEFAULT '[]',
region_whitelist TEXT NOT NULL DEFAULT '[]',
region_blacklist TEXT NOT NULL DEFAULT '[]',
pow_enabled BOOLEAN NOT NULL DEFAULT FALSE,
pow_config TEXT NOT NULL DEFAULT '{}',
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_of_waf_rule_groups_is_global ON of_waf_rule_groups (is_global);
CREATE TABLE IF NOT EXISTS of_waf_ip_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
ip_list TEXT NOT NULL DEFAULT '[]',
auto_config TEXT NOT NULL DEFAULT '{}',
ext_ips TEXT NOT NULL DEFAULT '[]',
subscription_url VARCHAR(2048) NOT NULL DEFAULT '',
subscription_format VARCHAR(32) NOT NULL DEFAULT 'text',
subscription_mapping_rule VARCHAR(255) NOT NULL DEFAULT '',
sync_interval_minutes INTEGER NOT NULL DEFAULT 1440,
last_synced_at TIMESTAMPTZ,
next_sync_at TIMESTAMPTZ,
last_sync_status VARCHAR(32) NOT NULL DEFAULT '',
last_sync_message TEXT NOT NULL DEFAULT '',
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_type ON of_waf_ip_groups (type);
CREATE INDEX IF NOT EXISTS idx_of_waf_ip_groups_next_sync_at ON of_waf_ip_groups (next_sync_at);
CREATE TABLE IF NOT EXISTS of_waf_rule_group_bindings (
id BIGSERIAL PRIMARY KEY,
rule_group_id BIGINT NOT NULL,
proxy_route_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_waf_group_route ON of_waf_rule_group_bindings (rule_group_id, proxy_route_id);
CREATE INDEX IF NOT EXISTS idx_of_waf_rule_group_bindings_proxy_route_id ON of_waf_rule_group_bindings (proxy_route_id);
-- +goose Down
DROP TABLE IF EXISTS of_waf_rule_group_bindings;
DROP TABLE IF EXISTS of_waf_ip_groups;
DROP TABLE IF EXISTS of_waf_rule_groups;
@@ -0,0 +1,61 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_tls_certificates (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
remark VARCHAR(255) NOT NULL DEFAULT '',
provider VARCHAR(64) NOT NULL DEFAULT 'upload',
acme_account_id BIGINT NOT NULL DEFAULT 0,
dns_account_id BIGINT NOT NULL DEFAULT 0,
key_algorithm VARCHAR(32) NOT NULL DEFAULT '',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
primary_domain VARCHAR(255) NOT NULL DEFAULT '',
other_domains TEXT NOT NULL DEFAULT '',
disable_cname BOOLEAN NOT NULL DEFAULT FALSE,
skip_dns BOOLEAN NOT NULL DEFAULT FALSE,
dns1 VARCHAR(128) NOT NULL DEFAULT '',
dns2 VARCHAR(128) NOT NULL DEFAULT '',
apply_status VARCHAR(64) NOT NULL DEFAULT 'ready',
apply_message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_tls_certificates_name ON of_tls_certificates (name);
CREATE TABLE IF NOT EXISTS of_managed_domains (
id BIGSERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
cert_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_managed_domains_domain ON of_managed_domains (domain);
CREATE TABLE IF NOT EXISTS of_dns_accounts (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(64) NOT NULL,
"authorization" TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS of_acme_accounts (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL DEFAULT '',
url VARCHAR(255) NOT NULL DEFAULT '',
private_key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +goose Down
DROP TABLE IF EXISTS of_managed_domains;
DROP TABLE IF EXISTS of_tls_certificates;
DROP TABLE IF EXISTS of_dns_accounts;
DROP TABLE IF EXISTS of_acme_accounts;
@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_config_versions (
id BIGSERIAL PRIMARY KEY,
version VARCHAR(32) NOT NULL,
snapshot_json TEXT NOT NULL,
main_config TEXT NOT NULL DEFAULT '',
rendered_config TEXT NOT NULL,
support_files_json TEXT NOT NULL DEFAULT '[]',
checksum VARCHAR(64) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_config_versions_version ON of_config_versions (version);
CREATE INDEX IF NOT EXISTS idx_of_config_versions_is_active ON of_config_versions (is_active);
-- +goose Down
DROP TABLE IF EXISTS of_config_versions;
@@ -0,0 +1,53 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_pages_projects (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(128) NOT NULL,
description TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
spa_fallback_enabled BOOLEAN NOT NULL DEFAULT FALSE,
spa_fallback_path VARCHAR(512) NOT NULL DEFAULT '/index.html',
api_proxy_enabled BOOLEAN NOT NULL DEFAULT FALSE,
api_proxy_path VARCHAR(255) NOT NULL DEFAULT '',
api_proxy_pass VARCHAR(2048) NOT NULL DEFAULT '',
api_proxy_rewrite VARCHAR(255) NOT NULL DEFAULT '',
active_deployment_id BIGINT,
root_dir VARCHAR(512) NOT NULL DEFAULT '',
entry_file VARCHAR(512) NOT NULL DEFAULT 'index.html',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_pages_projects_slug ON of_pages_projects (slug);
CREATE INDEX IF NOT EXISTS idx_of_pages_projects_active_deployment_id ON of_pages_projects (active_deployment_id);
CREATE TABLE IF NOT EXISTS of_pages_deployments (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
deployment_number INTEGER NOT NULL,
checksum VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'uploaded',
artifact_path VARCHAR(2048) NOT NULL,
file_count INTEGER NOT NULL DEFAULT 0,
total_size BIGINT NOT NULL DEFAULT 0,
created_by VARCHAR(64) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
activated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_project_id ON of_pages_deployments (project_id);
CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_checksum ON of_pages_deployments (checksum);
CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_status ON of_pages_deployments (status);
CREATE TABLE IF NOT EXISTS of_pages_deployment_files (
id BIGSERIAL PRIMARY KEY,
deployment_id BIGINT NOT NULL,
path VARCHAR(2048) NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_of_pages_deployment_files_deployment_id ON of_pages_deployment_files (deployment_id);
-- +goose Down
DROP TABLE IF EXISTS of_pages_deployment_files;
DROP TABLE IF EXISTS of_pages_deployments;
DROP TABLE IF EXISTS of_pages_projects;
@@ -0,0 +1,136 @@
-- +goose Up
CREATE TABLE of_node_system_profiles (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
hostname VARCHAR(255) NOT NULL DEFAULT '',
os_name VARCHAR(128) NOT NULL DEFAULT '',
os_version VARCHAR(128) NOT NULL DEFAULT '',
kernel_version VARCHAR(128) NOT NULL DEFAULT '',
architecture VARCHAR(64) NOT NULL DEFAULT '',
cpu_model VARCHAR(255) NOT NULL DEFAULT '',
cpu_cores INTEGER NOT NULL DEFAULT 0,
total_memory_bytes BIGINT NOT NULL DEFAULT 0,
total_disk_bytes BIGINT NOT NULL DEFAULT 0,
uptime_seconds BIGINT NOT NULL DEFAULT 0,
reported_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_of_node_system_profiles_node_id ON of_node_system_profiles (node_id);
CREATE INDEX idx_of_node_system_profiles_reported_at ON of_node_system_profiles (reported_at);
CREATE TABLE of_node_metric_snapshots (
id BIGSERIAL 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_id ON of_node_metric_snapshots (node_id);
CREATE INDEX idx_of_node_metric_snapshots_captured_at ON of_node_metric_snapshots (captured_at);
CREATE TABLE of_node_request_reports (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
window_started_at TIMESTAMPTZ NOT NULL,
window_ended_at TIMESTAMPTZ NOT NULL,
request_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
unique_visitor_count BIGINT NOT NULL DEFAULT 0,
status_codes_json TEXT,
top_domains_json TEXT,
source_countries_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_request_reports_node_id ON of_node_request_reports (node_id);
CREATE INDEX idx_of_node_request_reports_window_started_at ON of_node_request_reports (window_started_at);
CREATE INDEX idx_of_node_request_reports_window_ended_at ON of_node_request_reports (window_ended_at);
CREATE TABLE of_node_health_events (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
message TEXT,
first_triggered_at TIMESTAMPTZ NOT NULL,
last_triggered_at TIMESTAMPTZ NOT NULL,
reported_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_health_events_node_id ON of_node_health_events (node_id);
CREATE INDEX idx_of_node_health_events_event_type ON of_node_health_events (event_type);
CREATE INDEX idx_of_node_health_events_status ON of_node_health_events (status);
CREATE INDEX idx_of_node_health_events_first_triggered_at ON of_node_health_events (first_triggered_at);
CREATE INDEX idx_of_node_health_events_last_triggered_at ON of_node_health_events (last_triggered_at);
CREATE INDEX idx_of_node_health_events_reported_at ON of_node_health_events (reported_at);
CREATE INDEX idx_of_node_health_events_resolved_at ON of_node_health_events (resolved_at);
CREATE TABLE of_node_obs_openresty (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
captured_at TIMESTAMPTZ NOT NULL,
openresty_rx_bytes BIGINT NOT NULL DEFAULT 0,
openresty_tx_bytes BIGINT NOT NULL DEFAULT 0,
openresty_connections BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_obs_openresty_node_id ON of_node_obs_openresty (node_id);
CREATE INDEX idx_of_node_obs_openresty_captured_at ON of_node_obs_openresty (captured_at);
CREATE TABLE of_node_obs_frps (
id BIGSERIAL 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,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_obs_frps_node_id ON of_node_obs_frps (node_id);
CREATE INDEX idx_of_node_obs_frps_captured_at ON of_node_obs_frps (captured_at);
CREATE TABLE of_node_access_logs (
id BIGSERIAL PRIMARY KEY,
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 '',
status_code INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_access_logs_node_id ON of_node_access_logs (node_id);
CREATE INDEX idx_of_node_access_logs_logged_at ON of_node_access_logs (logged_at);
CREATE INDEX idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr);
CREATE INDEX idx_of_node_access_logs_host ON of_node_access_logs (host);
CREATE INDEX idx_of_node_access_logs_status_code ON of_node_access_logs (status_code);
-- +goose Down
DROP TABLE IF EXISTS of_node_access_logs;
DROP TABLE IF EXISTS of_node_obs_frps;
DROP TABLE IF EXISTS of_node_obs_openresty;
DROP TABLE IF EXISTS of_node_health_events;
DROP TABLE IF EXISTS of_node_request_reports;
DROP TABLE IF EXISTS of_node_metric_snapshots;
DROP TABLE IF EXISTS of_node_system_profiles;
@@ -0,0 +1,5 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_of_node_access_logs_node_id_logged_at ON of_node_access_logs (node_id, logged_at);
-- +goose Down
DROP INDEX IF EXISTS idx_of_node_access_logs_node_id_logged_at;
@@ -0,0 +1,15 @@
-- +goose Up
CREATE TABLE of_node_obs_frpc (
id BIGSERIAL 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_id ON of_node_obs_frpc (node_id);
CREATE INDEX idx_of_node_obs_frpc_captured_at ON of_node_obs_frpc (captured_at);
-- +goose Down
DROP TABLE IF EXISTS of_node_obs_frpc;
@@ -0,0 +1,11 @@
-- +goose Up
INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
VALUES
(101, 'OpenFlare SSL 自动续期', 'of_ssl_renew', '0 0 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(102, 'OpenFlare 可观测数据自动清理', 'of_database_auto_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(103, 'OpenFlare WAF IP 组同步', 'of_waf_ip_group_sync', '*/5 * * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(104, 'OpenFlare Uptime Kuma 同步', 'of_uptime_kuma_sync', '* * * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO NOTHING;
-- +goose Down
DELETE FROM w_schedules WHERE id IN (101, 102, 103, 104);
@@ -0,0 +1,14 @@
-- +goose Up
ALTER TABLE of_pages_deployments
ADD COLUMN IF NOT EXISTS upload_id BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_of_pages_deployments_upload_id ON of_pages_deployments (upload_id);
ALTER TABLE of_pages_deployments
ALTER COLUMN artifact_path SET DEFAULT '';
-- +goose Down
DROP INDEX IF EXISTS idx_of_pages_deployments_upload_id;
ALTER TABLE of_pages_deployments
DROP COLUMN IF EXISTS upload_id;
@@ -0,0 +1,31 @@
-- +goose Up
UPDATE w_system_configs
SET value = 'OpenFlare', updated_at = CURRENT_TIMESTAMP
WHERE key = 'site_name' AND value = 'Wavelet';
UPDATE w_system_configs
SET value = 'Rain-kl/OpenFlare', updated_at = CURRENT_TIMESTAMP
WHERE key = 'update_upstream_repository' AND value = 'Rain-kl/Wavelet';
UPDATE w_templates
SET
subject = REPLACE(subject, 'Wavelet', 'OpenFlare'),
content = REPLACE(content, 'Wavelet', 'OpenFlare'),
updated_at = CURRENT_TIMESTAMP
WHERE key IN ('login_email', 'register_email');
-- +goose Down
UPDATE w_system_configs
SET value = 'Wavelet', updated_at = CURRENT_TIMESTAMP
WHERE key = 'site_name' AND value = 'OpenFlare';
UPDATE w_system_configs
SET value = 'Rain-kl/Wavelet', updated_at = CURRENT_TIMESTAMP
WHERE key = 'update_upstream_repository' AND value = 'Rain-kl/OpenFlare';
UPDATE w_templates
SET
subject = REPLACE(subject, 'OpenFlare', 'Wavelet'),
content = REPLACE(content, 'OpenFlare', 'Wavelet'),
updated_at = CURRENT_TIMESTAMP
WHERE key IN ('login_email', 'register_email');
@@ -0,0 +1,17 @@
-- +goose Up
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'true';
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'true';
-- +goose Down
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'false';
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'false';
@@ -0,0 +1,20 @@
-- +goose Up
DELETE FROM of_options
WHERE key IN (
'GlobalApiRateLimitNum',
'GlobalApiRateLimitDuration',
'GlobalWebRateLimitNum',
'GlobalWebRateLimitDuration',
'CriticalRateLimitNum',
'CriticalRateLimitDuration'
);
-- +goose Down
INSERT INTO of_options (key, value) VALUES
('GlobalApiRateLimitNum', '300'),
('GlobalApiRateLimitDuration', '180'),
('GlobalWebRateLimitNum', '300'),
('GlobalWebRateLimitDuration', '180'),
('CriticalRateLimitNum', '100'),
('CriticalRateLimitDuration', '1200')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,28 @@
-- +goose Up
DROP INDEX IF EXISTS idx_of_node_access_logs_node_id_logged_at;
DROP INDEX IF EXISTS idx_of_node_access_logs_status_code;
DROP INDEX IF EXISTS idx_of_node_access_logs_host;
DROP INDEX IF EXISTS idx_of_node_access_logs_remote_addr;
DROP INDEX IF EXISTS idx_of_node_access_logs_logged_at;
DROP INDEX IF EXISTS idx_of_node_access_logs_node_id;
DROP TABLE IF EXISTS of_node_access_logs;
-- +goose Down
CREATE TABLE of_node_access_logs (
id BIGSERIAL PRIMARY KEY,
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 '',
status_code INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_access_logs_node_id ON of_node_access_logs (node_id);
CREATE INDEX idx_of_node_access_logs_logged_at ON of_node_access_logs (logged_at);
CREATE INDEX idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr);
CREATE INDEX idx_of_node_access_logs_host ON of_node_access_logs (host);
CREATE INDEX idx_of_node_access_logs_status_code ON of_node_access_logs (status_code);
CREATE INDEX idx_of_node_access_logs_node_id_logged_at ON of_node_access_logs (node_id, logged_at);
@@ -0,0 +1,98 @@
-- +goose Up
DROP INDEX IF EXISTS idx_of_node_obs_frpc_captured_at;
DROP INDEX IF EXISTS idx_of_node_obs_frpc_node_id;
DROP TABLE IF EXISTS of_node_obs_frpc;
DROP INDEX IF EXISTS idx_of_node_obs_frps_captured_at;
DROP INDEX IF EXISTS idx_of_node_obs_frps_node_id;
DROP TABLE IF EXISTS of_node_obs_frps;
DROP INDEX IF EXISTS idx_of_node_obs_openresty_captured_at;
DROP INDEX IF EXISTS idx_of_node_obs_openresty_node_id;
DROP TABLE IF EXISTS of_node_obs_openresty;
DROP INDEX IF EXISTS idx_of_node_request_reports_window_ended_at;
DROP INDEX IF EXISTS idx_of_node_request_reports_window_started_at;
DROP INDEX IF EXISTS idx_of_node_request_reports_node_id;
DROP TABLE IF EXISTS of_node_request_reports;
DROP INDEX IF EXISTS idx_of_node_metric_snapshots_captured_at;
DROP INDEX IF EXISTS idx_of_node_metric_snapshots_node_id;
DROP TABLE IF EXISTS of_node_metric_snapshots;
-- +goose Down
CREATE TABLE of_node_metric_snapshots (
id BIGSERIAL 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_id ON of_node_metric_snapshots (node_id);
CREATE INDEX idx_of_node_metric_snapshots_captured_at ON of_node_metric_snapshots (captured_at);
CREATE TABLE of_node_request_reports (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
window_started_at TIMESTAMPTZ NOT NULL,
window_ended_at TIMESTAMPTZ NOT NULL,
request_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
unique_visitor_count BIGINT NOT NULL DEFAULT 0,
status_codes_json TEXT,
top_domains_json TEXT,
source_countries_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_request_reports_node_id ON of_node_request_reports (node_id);
CREATE INDEX idx_of_node_request_reports_window_started_at ON of_node_request_reports (window_started_at);
CREATE INDEX idx_of_node_request_reports_window_ended_at ON of_node_request_reports (window_ended_at);
CREATE TABLE of_node_obs_openresty (
id BIGSERIAL PRIMARY KEY,
node_id VARCHAR(64) NOT NULL,
captured_at TIMESTAMPTZ NOT NULL,
openresty_rx_bytes BIGINT NOT NULL DEFAULT 0,
openresty_tx_bytes BIGINT NOT NULL DEFAULT 0,
openresty_connections BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_obs_openresty_node_id ON of_node_obs_openresty (node_id);
CREATE INDEX idx_of_node_obs_openresty_captured_at ON of_node_obs_openresty (captured_at);
CREATE TABLE of_node_obs_frps (
id BIGSERIAL 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,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_of_node_obs_frps_node_id ON of_node_obs_frps (node_id);
CREATE INDEX idx_of_node_obs_frps_captured_at ON of_node_obs_frps (captured_at);
CREATE TABLE of_node_obs_frpc (
id BIGSERIAL 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_id ON of_node_obs_frpc (node_id);
CREATE INDEX idx_of_node_obs_frpc_captured_at ON of_node_obs_frpc (captured_at);
@@ -0,0 +1,7 @@
-- +goose Up
-- Historical copy from legacy_* tables into of_* / w_* (formerly Go migration).
-- Already-applied environments keep their data; new installs have no legacy_* sources.
SELECT 1;
-- +goose Down
SELECT 1;
@@ -0,0 +1,9 @@
-- +goose Up
SELECT setval(
pg_get_serial_sequence('of_waf_rule_group_bindings', 'id'),
GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0), 1),
COALESCE((SELECT MAX(id) FROM of_waf_rule_group_bindings), 0) > 0
);
-- +goose Down
SELECT 1;
@@ -0,0 +1,22 @@
-- +goose Up
DELETE FROM of_options
WHERE key IN (
'GitHubOAuthEnabled',
'GitHubClientId',
'GitHubClientSecret',
'WeChatAuthEnabled',
'WeChatServerAddress',
'WeChatServerToken',
'WeChatAccountQRCodeImageURL'
);
-- +goose Down
INSERT INTO of_options (key, value) VALUES
('GitHubOAuthEnabled', 'false'),
('GitHubClientId', ''),
('GitHubClientSecret', ''),
('WeChatAuthEnabled', 'false'),
('WeChatServerAddress', ''),
('WeChatServerToken', ''),
('WeChatAccountQRCodeImageURL', '')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,5 @@
-- +goose Up
DELETE FROM of_options WHERE key = 'Notice';
-- +goose Down
INSERT OR IGNORE INTO of_options (key, value) VALUES ('Notice', '');
@@ -0,0 +1,9 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('relay_frps_web_ui_enabled', 'false', 'business', 0, '是否启用 FRPS 内置 Web 管理界面', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('relay_frps_web_ui_port', '17500', 'business', 0, 'FRPS 内置 Web 管理界面端口', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('relay_frps_web_ui_enabled', 'relay_frps_web_ui_port');
@@ -0,0 +1,347 @@
-- +goose Up
-- 将 of_options 配置迁移到 w_system_configs(仅迁移新配置,已存在的不重复)
-- Agent 相关配置 (business, visibility=0)
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'agent_discovery_token', COALESCE(value, ''), 'business', 0, 'Agent 发现令牌'
FROM of_options WHERE key = 'AgentDiscoveryToken'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'agent_heartbeat_interval', value, 'business', 0, 'Agent 心跳间隔(毫秒)'
FROM of_options WHERE key = 'AgentHeartbeatInterval'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'agent_websocket_upgrade_enabled', value, 'business', 0, 'Agent WebSocket 升级开关'
FROM of_options WHERE key = 'AgentWebsocketUpgradeEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'node_offline_threshold', value, 'business', 0, '节点离线阈值(毫秒)'
FROM of_options WHERE key = 'NodeOfflineThreshold'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'agent_update_repo', value, 'business', 0, 'Agent 更新仓库'
FROM of_options WHERE key = 'AgentUpdateRepo'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- 系统功能配置 (business, visibility=0)
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'geoip_provider', value, 'business', 0, 'GeoIP 服务商'
FROM of_options WHERE key = 'GeoIPProvider'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'database_auto_cleanup_enabled', value, 'business', 0, '数据库自动清理开关'
FROM of_options WHERE key = 'DatabaseAutoCleanupEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'database_auto_cleanup_retention_days', value, 'business', 0, '数据库保留天数'
FROM of_options WHERE key = 'DatabaseAutoCleanupRetentionDays'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- UptimeKuma 集成配置 (business, visibility=0)
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_enabled', value, 'business', 0, 'UptimeKuma 集成开关'
FROM of_options WHERE key = 'UptimeKumaEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_url', COALESCE(value, ''), 'business', 0, 'UptimeKuma URL'
FROM of_options WHERE key = 'UptimeKumaUrl'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_username', COALESCE(value, ''), 'business', 0, 'UptimeKuma 用户名'
FROM of_options WHERE key = 'UptimeKumaUsername'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_password', COALESCE(value, ''), 'business', 0, 'UptimeKuma 密码'
FROM of_options WHERE key = 'UptimeKumaPassword'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_monitor_scope', value, 'business', 0, 'UptimeKuma 监控范围'
FROM of_options WHERE key = 'UptimeKumaMonitorScope'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_selected_sites', COALESCE(value, ''), 'business', 0, 'UptimeKuma 选定站点'
FROM of_options WHERE key = 'UptimeKumaSelectedSites'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_sync_interval', value, 'business', 0, 'UptimeKuma 同步间隔(分钟)'
FROM of_options WHERE key = 'UptimeKumaSyncInterval'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_interval', value, 'business', 0, 'UptimeKuma 监控间隔(秒)'
FROM of_options WHERE key = 'UptimeKumaInterval'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_retry', value, 'business', 0, 'UptimeKuma 重试次数'
FROM of_options WHERE key = 'UptimeKumaRetry'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_retry_interval', value, 'business', 0, 'UptimeKuma 重试间隔(秒)'
FROM of_options WHERE key = 'UptimeKumaRetryInterval'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'uptime_kuma_timeout', value, 'business', 0, 'UptimeKuma 超时(秒)'
FROM of_options WHERE key = 'UptimeKumaTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- OpenResty 配置(全部 business 类型,visibility=0)
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_default_server_return_status', value, 'business', 0, '默认服务器返回状态码'
FROM of_options WHERE key = 'OpenRestyDefaultServerReturnStatus'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_worker_processes', value, 'business', 0, 'Worker 进程数'
FROM of_options WHERE key = 'OpenRestyWorkerProcesses'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_worker_connections', value, 'business', 0, 'Worker 连接数'
FROM of_options WHERE key = 'OpenRestyWorkerConnections'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_worker_rlimit_nofile', value, 'business', 0, 'Worker 文件描述符限制'
FROM of_options WHERE key = 'OpenRestyWorkerRlimitNofile'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_events_use', value, 'business', 0, '事件模型'
FROM of_options WHERE key = 'OpenRestyEventsUse'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_events_multi_accept_enabled', value, 'business', 0, '多路接受开关'
FROM of_options WHERE key = 'OpenRestyEventsMultiAcceptEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_keepalive_timeout', value, 'business', 0, 'Keepalive 超时(秒)'
FROM of_options WHERE key = 'OpenRestyKeepaliveTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_keepalive_requests', value, 'business', 0, 'Keepalive 请求数'
FROM of_options WHERE key = 'OpenRestyKeepaliveRequests'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_client_header_timeout', value, 'business', 0, '客户端头超时(秒)'
FROM of_options WHERE key = 'OpenRestyClientHeaderTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_client_body_timeout', value, 'business', 0, '客户端体超时(秒)'
FROM of_options WHERE key = 'OpenRestyClientBodyTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_client_max_body_size', value, 'business', 0, '客户端最大体大小'
FROM of_options WHERE key = 'OpenRestyClientMaxBodySize'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_large_client_header_buffers', value, 'business', 0, '大客户端头缓冲区'
FROM of_options WHERE key = 'OpenRestyLargeClientHeaderBuffers'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_send_timeout', value, 'business', 0, '发送超时(秒)'
FROM of_options WHERE key = 'OpenRestySendTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_resolvers', COALESCE(value, ''), 'business', 0, 'DNS 解析器'
FROM of_options WHERE key = 'OpenRestyResolvers'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_connect_timeout', value, 'business', 0, '代理连接超时(秒)'
FROM of_options WHERE key = 'OpenRestyProxyConnectTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_send_timeout', value, 'business', 0, '代理发送超时(秒)'
FROM of_options WHERE key = 'OpenRestyProxySendTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_read_timeout', value, 'business', 0, '代理读取超时(秒)'
FROM of_options WHERE key = 'OpenRestyProxyReadTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_websocket_enabled', value, 'business', 0, 'WebSocket 支持开关'
FROM of_options WHERE key = 'OpenRestyWebsocketEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_http3_enabled', value, 'business', 0, 'HTTP/3 支持开关'
FROM of_options WHERE key = 'OpenRestyHTTP3Enabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_request_buffering_enabled', value, 'business', 0, '代理请求缓冲开关'
FROM of_options WHERE key = 'OpenRestyProxyRequestBufferingEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_buffering_enabled', value, 'business', 0, '代理响应缓冲开关'
FROM of_options WHERE key = 'OpenRestyProxyBufferingEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_buffers', value, 'business', 0, '代理缓冲区'
FROM of_options WHERE key = 'OpenRestyProxyBuffers'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_buffer_size', value, 'business', 0, '代理缓冲区大小'
FROM of_options WHERE key = 'OpenRestyProxyBufferSize'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_proxy_busy_buffers_size', value, 'business', 0, '代理繁忙缓冲区大小'
FROM of_options WHERE key = 'OpenRestyProxyBusyBuffersSize'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_gzip_enabled', value, 'business', 0, 'Gzip 压缩开关'
FROM of_options WHERE key = 'OpenRestyGzipEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_gzip_min_length', value, 'business', 0, 'Gzip 最小长度'
FROM of_options WHERE key = 'OpenRestyGzipMinLength'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_gzip_comp_level', value, 'business', 0, 'Gzip 压缩级别'
FROM of_options WHERE key = 'OpenRestyGzipCompLevel'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_enabled', value, 'business', 0, '缓存开关'
FROM of_options WHERE key = 'OpenRestyCacheEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_path', COALESCE(value, ''), 'business', 0, '缓存路径'
FROM of_options WHERE key = 'OpenRestyCachePath'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_levels', value, 'business', 0, '缓存层级'
FROM of_options WHERE key = 'OpenRestyCacheLevels'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_inactive', value, 'business', 0, '缓存不活跃时间'
FROM of_options WHERE key = 'OpenRestyCacheInactive'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_max_size', value, 'business', 0, '缓存最大大小'
FROM of_options WHERE key = 'OpenRestyCacheMaxSize'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_key_template', value, 'business', 0, '缓存键模板'
FROM of_options WHERE key = 'OpenRestyCacheKeyTemplate'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_lock_enabled', value, 'business', 0, '缓存锁开关'
FROM of_options WHERE key = 'OpenRestyCacheLockEnabled'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_lock_timeout', value, 'business', 0, '缓存锁超时'
FROM of_options WHERE key = 'OpenRestyCacheLockTimeout'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_cache_use_stale', value, 'business', 0, '缓存失效策略'
FROM of_options WHERE key = 'OpenRestyCacheUseStale'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
INSERT INTO w_system_configs (key, value, type, visibility, description)
SELECT 'openresty_main_config_template', value, 'business', 0, '主配置模板'
FROM of_options WHERE key = 'OpenRestyMainConfigTemplate'
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
-- +goose Down
-- 回滚时删除所有迁移的配置项
DELETE FROM w_system_configs WHERE key IN (
'agent_discovery_token',
'agent_heartbeat_interval',
'agent_websocket_upgrade_enabled',
'node_offline_threshold',
'agent_update_repo',
'geoip_provider',
'database_auto_cleanup_enabled',
'database_auto_cleanup_retention_days',
'uptime_kuma_enabled',
'uptime_kuma_url',
'uptime_kuma_username',
'uptime_kuma_password',
'uptime_kuma_monitor_scope',
'uptime_kuma_selected_sites',
'uptime_kuma_sync_interval',
'uptime_kuma_interval',
'uptime_kuma_retry',
'uptime_kuma_retry_interval',
'uptime_kuma_timeout',
'openresty_default_server_return_status',
'openresty_worker_processes',
'openresty_worker_connections',
'openresty_worker_rlimit_nofile',
'openresty_events_use',
'openresty_events_multi_accept_enabled',
'openresty_keepalive_timeout',
'openresty_keepalive_requests',
'openresty_client_header_timeout',
'openresty_client_body_timeout',
'openresty_client_max_body_size',
'openresty_large_client_header_buffers',
'openresty_send_timeout',
'openresty_resolvers',
'openresty_proxy_connect_timeout',
'openresty_proxy_send_timeout',
'openresty_proxy_read_timeout',
'openresty_websocket_enabled',
'openresty_http3_enabled',
'openresty_proxy_request_buffering_enabled',
'openresty_proxy_buffering_enabled',
'openresty_proxy_buffers',
'openresty_proxy_buffer_size',
'openresty_proxy_busy_buffers_size',
'openresty_gzip_enabled',
'openresty_gzip_min_length',
'openresty_gzip_comp_level',
'openresty_cache_enabled',
'openresty_cache_path',
'openresty_cache_levels',
'openresty_cache_inactive',
'openresty_cache_max_size',
'openresty_cache_key_template',
'openresty_cache_lock_enabled',
'openresty_cache_lock_timeout',
'openresty_cache_use_stale',
'openresty_main_config_template'
);
@@ -0,0 +1,10 @@
-- +goose Up
-- of_options 配置已于 202606220004 迁移至 w_system_configs,删除遗留表。
DROP TABLE IF EXISTS of_options;
-- +goose Down
-- 回滚时重建空表结构(数据无法恢复,迁移来源已为 w_system_configs)。
CREATE TABLE IF NOT EXISTS of_options (
key VARCHAR(128) PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
@@ -0,0 +1,19 @@
-- +goose Up
-- Remove old PRIMARY KEY on id, set version as PRIMARY KEY, drop id column
-- 1. 移除旧主键约束
ALTER TABLE of_config_versions DROP CONSTRAINT IF EXISTS of_config_versions_pkey;
-- 2. 移除原先在 version 字段的唯一索引(因为 version 变成主键,主键隐含唯一性)
DROP INDEX IF EXISTS idx_of_config_versions_version;
-- 3. 将 version 设为新主键
ALTER TABLE of_config_versions ADD PRIMARY KEY (version);
-- 4. 彻底删除 id 列
ALTER TABLE of_config_versions DROP COLUMN IF EXISTS id;
-- +goose Down
ALTER TABLE of_config_versions DROP CONSTRAINT IF EXISTS of_config_versions_pkey;
ALTER TABLE of_config_versions ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY;
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_config_versions_version ON of_config_versions (version);
@@ -0,0 +1,20 @@
-- +goose Up
SELECT setval(pg_get_serial_sequence('w_users', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_users), 0), 1), COALESCE((SELECT MAX(id) FROM w_users), 0) > 0);
SELECT setval(pg_get_serial_sequence('w_auth_sources', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_auth_sources), 0), 1), COALESCE((SELECT MAX(id) FROM w_auth_sources), 0) > 0);
SELECT setval(pg_get_serial_sequence('w_external_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM w_external_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM w_external_accounts), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_origins', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_origins), 0), 1), COALESCE((SELECT MAX(id) FROM of_origins), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_apply_logs', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_apply_logs), 0), 1), COALESCE((SELECT MAX(id) FROM of_apply_logs), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_proxy_routes', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_proxy_routes), 0), 1), COALESCE((SELECT MAX(id) FROM of_proxy_routes), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_nodes', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_nodes), 0), 1), COALESCE((SELECT MAX(id) FROM of_nodes), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_waf_rule_groups', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_rule_groups), 0), 1), COALESCE((SELECT MAX(id) FROM of_waf_rule_groups), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_waf_ip_groups', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_waf_ip_groups), 0), 1), COALESCE((SELECT MAX(id) FROM of_waf_ip_groups), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_tls_certificates', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_tls_certificates), 0), 1), COALESCE((SELECT MAX(id) FROM of_tls_certificates), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_managed_domains', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_managed_domains), 0), 1), COALESCE((SELECT MAX(id) FROM of_managed_domains), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_dns_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_dns_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM of_dns_accounts), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_acme_accounts', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_acme_accounts), 0), 1), COALESCE((SELECT MAX(id) FROM of_acme_accounts), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_pages_projects', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_projects), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_projects), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_pages_deployments', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_deployments), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_deployments), 0) > 0);
SELECT setval(pg_get_serial_sequence('of_pages_deployment_files', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM of_pages_deployment_files), 0), 1), COALESCE((SELECT MAX(id) FROM of_pages_deployment_files), 0) > 0);
-- +goose Down
SELECT 1;
@@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS of_zones (
id BIGSERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zones_domain ON of_zones (domain);
CREATE TABLE IF NOT EXISTS of_zone_domains (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL,
proxy_route_id BIGINT,
domain VARCHAR(255) NOT NULL,
cert_id BIGINT,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_zone_domains_domain ON of_zone_domains (domain);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_zone_id ON of_zone_domains (zone_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id);
CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_id);
-- +goose Down
DROP TABLE IF EXISTS of_zone_domains;
DROP TABLE IF EXISTS of_zones;
@@ -0,0 +1,8 @@
-- +goose Up
-- Marker: Zone 域名从旧路由列 / of_managed_domains 的导入由 migrator.Migrate()
-- 在全部 goose SQL 应用后自动执行(publicsuffix 根域解析无法用纯 SQL 正确完成)。
-- 本文件仅占位版本号,保证升级链路有序:建表 → 导入 → 删旧列。
SELECT 1;
-- +goose Down
SELECT 1;
@@ -0,0 +1,35 @@
-- +goose Up
-- 第二阶段:删除反代路由冗余域名/证书列与托管域名表。
-- 执行前必须完成 migrate-zones 且配置预览验收通过。
DROP INDEX IF EXISTS idx_of_proxy_routes_domain;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS domain;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS domains;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS cert_id;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS cert_ids;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS domain_cert_ids;
DROP TABLE IF EXISTS of_managed_domains;
-- +goose Down
-- 仅恢复开发库结构,不回填历史域名/证书数据。
CREATE TABLE IF NOT EXISTS of_managed_domains (
id BIGSERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
cert_id BIGINT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_managed_domains_domain ON of_managed_domains (domain);
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS domain VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS domains TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS cert_id BIGINT;
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS cert_ids TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS domain_cert_ids TEXT NOT NULL DEFAULT '[]';
CREATE UNIQUE INDEX IF NOT EXISTS idx_of_proxy_routes_domain ON of_proxy_routes (domain);
@@ -0,0 +1,16 @@
-- +goose Up
-- 移除业务实体上未再使用的备注列(证书与源站备注保留)。
ALTER TABLE of_zones DROP COLUMN IF EXISTS remark;
ALTER TABLE of_zone_domains DROP COLUMN IF EXISTS remark;
ALTER TABLE of_proxy_routes DROP COLUMN IF EXISTS remark;
ALTER TABLE of_waf_rule_groups DROP COLUMN IF EXISTS remark;
ALTER TABLE of_waf_ip_groups DROP COLUMN IF EXISTS remark;
-- +goose Down
ALTER TABLE of_zones ADD COLUMN IF NOT EXISTS remark VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE of_zone_domains ADD COLUMN IF NOT EXISTS remark VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE of_proxy_routes ADD COLUMN IF NOT EXISTS remark VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE of_waf_rule_groups ADD COLUMN IF NOT EXISTS remark VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE of_waf_ip_groups ADD COLUMN IF NOT EXISTS remark VARCHAR(255) NOT NULL DEFAULT '';
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE of_waf_rule_groups
ADD COLUMN graph TEXT NOT NULL DEFAULT '',
ADD COLUMN revision BIGINT NOT NULL DEFAULT 1;
ALTER TABLE of_waf_rule_group_bindings
ADD COLUMN sequence INTEGER NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE of_waf_rule_group_bindings DROP COLUMN sequence;
ALTER TABLE of_waf_rule_groups DROP COLUMN revision, DROP COLUMN graph;
@@ -0,0 +1,17 @@
-- +goose Up
UPDATE of_waf_rule_groups
SET graph = '{"schema_version":1,"nodes":[{"id":"start","type":"start","position":{"x":0,"y":0},"config":{}},{"id":"allow","type":"allow","position":{"x":320,"y":0},"config":{}}],"edges":[{"id":"start-allow","source":"start","source_handle":"next","target":"allow"}]}',
revision = 1;
WITH ordered AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY proxy_route_id ORDER BY id) - 1 AS new_sequence
FROM of_waf_rule_group_bindings
)
UPDATE of_waf_rule_group_bindings AS binding
SET sequence = ordered.new_sequence
FROM ordered
WHERE binding.id = ordered.id;
-- +goose Down
UPDATE of_waf_rule_group_bindings SET sequence = 0;
UPDATE of_waf_rule_groups SET revision = 1;
@@ -0,0 +1,27 @@
-- +goose Up
ALTER TABLE of_waf_rule_groups DROP COLUMN block_status_code;
ALTER TABLE of_waf_rule_groups DROP COLUMN block_response_body;
ALTER TABLE of_waf_rule_groups DROP COLUMN ip_whitelist;
ALTER TABLE of_waf_rule_groups DROP COLUMN ip_blacklist;
ALTER TABLE of_waf_rule_groups DROP COLUMN ip_whitelist_groups;
ALTER TABLE of_waf_rule_groups DROP COLUMN ip_blacklist_groups;
ALTER TABLE of_waf_rule_groups DROP COLUMN country_whitelist;
ALTER TABLE of_waf_rule_groups DROP COLUMN country_blacklist;
ALTER TABLE of_waf_rule_groups DROP COLUMN region_whitelist;
ALTER TABLE of_waf_rule_groups DROP COLUMN region_blacklist;
ALTER TABLE of_waf_rule_groups DROP COLUMN pow_enabled;
ALTER TABLE of_waf_rule_groups DROP COLUMN pow_config;
-- +goose Down
ALTER TABLE of_waf_rule_groups ADD COLUMN block_status_code INTEGER NOT NULL DEFAULT 418;
ALTER TABLE of_waf_rule_groups ADD COLUMN block_response_body TEXT NOT NULL DEFAULT '';
ALTER TABLE of_waf_rule_groups ADD COLUMN ip_whitelist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN ip_blacklist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN ip_whitelist_groups TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN ip_blacklist_groups TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN country_whitelist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN country_blacklist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN region_whitelist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN region_blacklist TEXT NOT NULL DEFAULT '[]';
ALTER TABLE of_waf_rule_groups ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE of_waf_rule_groups ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}';
@@ -0,0 +1,9 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('pages_max_package_size_mb', '100', 'business', 0, 'Pages 部署包上传大小上限(MiB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('pages_max_history_count', '20', 'business', 0, 'Pages 每个项目最大历史部署保留数(0 表示不限制)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN ('pages_max_package_size_mb', 'pages_max_history_count');
@@ -0,0 +1,27 @@
-- +goose Up
-- 将仍为历史默认值的心跳/离线配置升级为新默认:心跳 3s、离线 60s。
-- 已由管理员改成其他值的配置不受影响。
UPDATE w_system_configs
SET value = '3000',
updated_at = CURRENT_TIMESTAMP
WHERE key = 'agent_heartbeat_interval'
AND value = '10000';
UPDATE w_system_configs
SET value = '60000',
updated_at = CURRENT_TIMESTAMP
WHERE key = 'node_offline_threshold'
AND value = '120000';
-- +goose Down
UPDATE w_system_configs
SET value = '10000',
updated_at = CURRENT_TIMESTAMP
WHERE key = 'agent_heartbeat_interval'
AND value = '3000';
UPDATE w_system_configs
SET value = '120000',
updated_at = CURRENT_TIMESTAMP
WHERE key = 'node_offline_threshold'
AND value = '60000';
@@ -0,0 +1,14 @@
-- +goose Up
INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at)
VALUES
('openresty_default_limit_conn_per_server', '0', 'business', 0, '默认站点并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_conn_per_ip', '0', 'business', 0, '默认单 IP 并发连接上限(0 关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('openresty_default_limit_rate', '', 'business', 0, '默认单请求带宽限速(空关闭)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (key) DO NOTHING;
-- +goose Down
DELETE FROM w_system_configs WHERE key IN (
'openresty_default_limit_conn_per_server',
'openresty_default_limit_conn_per_ip',
'openresty_default_limit_rate'
);

Some files were not shown because too many files have changed in this diff Show More