mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+21
@@ -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;
|
||||
+20
@@ -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;
|
||||
+92
@@ -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;
|
||||
+19
@@ -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.
|
||||
+28
@@ -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;
|
||||
+80
@@ -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;
|
||||
+42
@@ -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;
|
||||
+66
@@ -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;
|
||||
+5
@@ -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;
|
||||
+11
@@ -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;
|
||||
+149
@@ -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;
|
||||
+40
@@ -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;
|
||||
+6
@@ -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;
|
||||
+6
@@ -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;
|
||||
+5
@@ -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;
|
||||
+9
@@ -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
|
||||
+9
@@ -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
|
||||
+66
@@ -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;
|
||||
+7
@@ -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';
|
||||
+10
@@ -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');
|
||||
+8
@@ -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';
|
||||
+7
@@ -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';
|
||||
+6
@@ -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;
|
||||
+5
@@ -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);
|
||||
+15
@@ -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';
|
||||
+34
@@ -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';
|
||||
+19
@@ -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;
|
||||
+11
@@ -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;
|
||||
+7
@@ -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
|
||||
+9
@@ -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;
|
||||
+12
@@ -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;
|
||||
+67
@@ -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;
|
||||
+7
@@ -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;
|
||||
+19
@@ -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;
|
||||
+43
@@ -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;
|
||||
+60
@@ -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;
|
||||
+61
@@ -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;
|
||||
+18
@@ -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;
|
||||
+53
@@ -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;
|
||||
+136
@@ -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;
|
||||
+5
@@ -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;
|
||||
+15
@@ -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;
|
||||
+11
@@ -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);
|
||||
+14
@@ -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;
|
||||
+31
@@ -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');
|
||||
+17
@@ -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';
|
||||
+20
@@ -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;
|
||||
+28
@@ -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);
|
||||
+98
@@ -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);
|
||||
+7
@@ -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;
|
||||
+9
@@ -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;
|
||||
+22
@@ -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;
|
||||
+5
@@ -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', '');
|
||||
+9
@@ -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');
|
||||
+347
@@ -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 ''
|
||||
);
|
||||
+19
@@ -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);
|
||||
+20
@@ -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;
|
||||
+28
@@ -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;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
-- Marker: Zone 域名从旧路由列 / of_managed_domains 的导入由 migrator.Migrate()
|
||||
-- 在全部 goose SQL 应用后自动执行(publicsuffix 根域解析无法用纯 SQL 正确完成)。
|
||||
-- 本文件仅占位版本号,保证升级链路有序:建表 → 导入 → 删旧列。
|
||||
SELECT 1;
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
+35
@@ -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);
|
||||
+16
@@ -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 '';
|
||||
+11
@@ -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;
|
||||
+17
@@ -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;
|
||||
+27
@@ -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 '{}';
|
||||
+9
@@ -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');
|
||||
+27
@@ -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';
|
||||
+14
@@ -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
Reference in New Issue
Block a user