diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..a554dae2 --- /dev/null +++ b/.env.example @@ -0,0 +1,82 @@ +# ────────────────────────────────────────────────────────────────────────────── +# wavelet — 环境变量配置模板 +# 复制此文件为 .env 并填入实际值: cp .env.example .env +# 环境变量优先级高于 config.yaml / config.docker.yaml +# ────────────────────────────────────────────────────────────────────────────── + +# ─── Docker Compose 服务端口映射 ─────────────────────────────────────────────── +APP_PORT=8000 +POSTGRES_PORT=5432 +REDIS_PORT=6379 + +# ─── PostgreSQL 容器配置(仅 docker-compose 使用)──────────────────────────── +POSTGRES_DB=wavelet +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +# ─── 时区 ───────────────────────────────────────────────────────────────────── +TZ=Asia/Shanghai + +# ─── 应用配置 ────────────────────────────────────────────────────────────────── +APP_NAME=wavelet +APP_ENV=production +APP_ADDR=:8000 +APP_NODE_ID=1 +APP_API_PREFIX=/api +# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30 +APP_SESSION_COOKIE_NAME=wavelet_session_id +APP_SESSION_SECRET=change-me-to-a-random-string-in-production +# APP_SESSION_DOMAIN= +# APP_SESSION_AGE=86400 +APP_SESSION_HTTP_ONLY=true +APP_SESSION_SECURE=true + +# ─── 数据库 ──────────────────────────────────────────────────────────────────── +DB_HOST=postgres +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD=postgres +DB_NAME=wavelet +DB_SSL_MODE=disable +DB_TIMEZONE=Asia/Shanghai +# DB_LOG_LEVEL=info +# DB_MAX_IDLE_CONN=16 +# DB_MAX_OPEN_CONN=128 + +# ─── Redis ───────────────────────────────────────────────────────────────────── +REDIS_ADDR=redis:6379 +# REDIS_USERNAME= +# REDIS_PASSWORD= +# REDIS_DB=0 +REDIS_KEY_PREFIX=wavelet: +# REDIS_POOL_SIZE=100 + +# ─── ClickHouse(可选)─────────────────────────────────────────────────────── +# CLICKHOUSE_HOST=clickhouse:9000 +# CLICKHOUSE_USERNAME=default +# CLICKHOUSE_PASSWORD= +# CLICKHOUSE_NAME=wavelet + +# ─── 日志 ────────────────────────────────────────────────────────────────────── +LOG_LEVEL=info +LOG_FORMAT=json +LOG_OUTPUT=stdout + +# ─── OpenTelemetry ───────────────────────────────────────────────────────────── +# 设为 0 关闭 tracing(无 Collector 时推荐) +OTEL_SAMPLING_RATE=0.0 + +# ─── S3 兼容存储(可选)─────────────────────────────────────────────────────── +# S3_ENDPOINT=https://.r2.cloudflarestorage.com +# S3_REGION=auto +# S3_BUCKET= +# S3_ACCESS_KEY_ID= +# S3_SECRET_ACCESS_KEY= +# S3_PATH_STYLE=false +# S3_CDN_URL= + +# ─── Worker ──────────────────────────────────────────────────────────────────── +# WORKER_CONCURRENCY=20 + +# ─── Scheduler ───────────────────────────────────────────────────────────────── +# SCHEDULER_CLEANUP_CRON=0 */2 * * * diff --git a/.gitignore b/.gitignore index 882e216a..8487b5e1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # config config.yaml +.env # frontend frontend/build @@ -48,3 +49,4 @@ s3_cache /internal/router/dist/ /frontend/out/ /.idea/ +/uploads/ diff --git a/config.docker.yaml b/config.docker.yaml new file mode 100644 index 00000000..34aa69e6 --- /dev/null +++ b/config.docker.yaml @@ -0,0 +1,120 @@ +# wavelet — Docker Compose 专用配置 +# 此文件供 docker-compose 挂载使用,主机名指向 Docker 服务名 +# 如需自定义,请复制此文件并修改,然后在 docker-compose.yml 中更新挂载路径 + +# ─── Application ──────────────────────────────────────────────────────────────── +app: + app_name: "wavelet" + env: "production" + addr: ":8000" + node_id: 1 + graceful_shutdown_timeout: 30 + session_cookie_name: "wavelet_session_id" + session_secret: "change-me-to-a-random-string-in-production" + session_domain: "" + session_age: 86400 + session_secure: true + session_http_only: true + api_prefix: "/api" + +# ─── PostgreSQL ───────────────────────────────────────────────────────────────── +database: + enabled: true + host: "postgres" # Docker 服务名 + port: 5432 + username: "postgres" + password: "postgres" + database: "wavelet" + max_idle_conn: 16 + max_open_conn: 128 + conn_max_lifetime: 1800 + conn_max_idle_time: 600 + log_level: "info" + ssl_mode: "disable" + time_zone: "Asia/Shanghai" + application_name: "wavelet-server" + prefer_simple_protocol: false + search_path: "public" + statement_cache_capacity: 256 + default_query_exec_mode: "cache_statement" + slow_threshold: 200ms + +# ─── Redis ────────────────────────────────────────────────────────────────────── +redis: + enabled: true + addrs: + - "redis:6379" # Docker 服务名 + username: "" + password: "" + db: 0 + cluster_mode: false + master_name: "" + key_prefix: "wavelet:" + pool_size: 100 + min_idle_conn: 10 + dial_timeout: 5 + read_timeout: 3 + write_timeout: 3 + max_retries: 3 + pool_timeout: 4 + conn_max_idle_time: 300 + +# ─── ClickHouse (optional) ────────────────────────────────────────────────────── +clickhouse: + enabled: false + hosts: + - "clickhouse:9000" + username: "default" + password: "" + database: "wavelet" + max_idle_conn: 10 + max_open_conn: 100 + conn_max_lifetime: 3600 + dial_timeout: 5 + block_buffer_size: 10 + +# ─── Logging ──────────────────────────────────────────────────────────────────── +log: + level: "info" + format: "json" + output: "stdout" + file_path: "" + max_size: 100 + max_age: 30 + max_backups: 10 + compress: true + +# ─── Task Scheduler (Cron) ────────────────────────────────────────────────────── +scheduler: + cleanup_unused_uploads_task_cron: "0 */2 * * *" + +# ─── Async Task Worker ────────────────────────────────────────────────────────── +worker: + concurrency: 20 + strict_priority: false + queues: + - name: webhook + priority: 10 + - name: whitelist_only + priority: 5 + - name: default + priority: 3 + +# ─── OpenTelemetry Tracing ────────────────────────────────────────────────────── +otel: + sampling_rate: 0.0 # Docker 环境默认关闭 tracing,避免无 Collector 时报错 + +# ─── S3-Compatible File Storage ───────────────────────────────────────────────── +s3: + enabled: false + endpoint: "" + region: "auto" + bucket: "" + access_key_id: "" + secret_access_key: "" + path_style: false + key_prefix: "" + cdn_url: "" + local_cache: + enabled: false + cache_dir: "./s3_cache" diff --git a/docker-compose.yml b/docker-compose.yml index 2b4e8b47..bcf2ad3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,25 @@ services: + + wavelet: + build: + context: . + dockerfile: docker/Dockerfile + container_name: wavelet-app + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Asia/Shanghai} + ports: + - "${APP_PORT:-8000}:8000" + volumes: + - ./config.docker.yaml:/app/config.yaml:ro + - ./uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + postgres: image: postgres:17-alpine container_name: wavelet-postgres @@ -18,23 +39,23 @@ services: timeout: 5s retries: 5 start_period: 10s -# -# redis: -# image: redis:7-alpine -# container_name: wavelet-redis -# restart: unless-stopped -# command: ["redis-server", "--appendonly", "yes"] -# ports: -# - "${REDIS_PORT:-6379}:6379" -# volumes: -# - ./data/redis_data:/data -# healthcheck: -# test: ["CMD", "redis-cli", "ping"] -# interval: 10s -# timeout: 5s -# retries: 5 -# start_period: 5s -# + + redis: + image: redis:7-alpine + container_name: wavelet-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - ./data/redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + # clickhouse: # image: clickhouse/clickhouse-server:25.3-alpine # container_name: wavelet-clickhouse diff --git a/internal/config/config.go b/internal/config/config.go index 67bae14f..9c7756de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ import ( "flag" "log" "os" + "strconv" "strings" "github.com/spf13/viper" @@ -80,6 +81,9 @@ func init() { log.Fatalf("[Config] parse config failed: %v\n", err) } + // 环境变量覆盖(优先级高于 config.yaml) + applyEnvOverrides(&c) + // Disable standard DB/Redis initializations during tests to prevent connection attempts. if isTest() { c.Database.Enabled = false @@ -94,6 +98,124 @@ func init() { printConfig(&c) } +// ─── 环境变量覆盖层 ──────────────────────────────────────────────────────────── +// 环境变量优先级高于 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) + + // ─── Redis ─── + if v, ok := os.LookupEnv("REDIS_ADDR"); ok { + c.Redis.Addrs = []string{v} + } + 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) + + // ─── ClickHouse ─── + if v, ok := os.LookupEnv("CLICKHOUSE_HOST"); ok { + c.ClickHouse.Hosts = []string{v} + } + 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) + + // ─── S3 ─── + c.S3.Endpoint = envStr("S3_ENDPOINT", c.S3.Endpoint) + c.S3.Region = envStr("S3_REGION", c.S3.Region) + c.S3.Bucket = envStr("S3_BUCKET", c.S3.Bucket) + c.S3.AccessKeyID = envStr("S3_ACCESS_KEY_ID", c.S3.AccessKeyID) + c.S3.SecretAccessKey = envStr("S3_SECRET_ACCESS_KEY", c.S3.SecretAccessKey) + c.S3.CdnURL = envStr("S3_CDN_URL", c.S3.CdnURL) + c.S3.PathStyle = envBool("S3_PATH_STYLE", c.S3.PathStyle) + + // ─── Worker ─── + c.Worker.Concurrency = envInt("WORKER_CONCURRENCY", c.Worker.Concurrency) + + // ─── Scheduler ─── + c.Scheduler.CleanupUnusedUploadsTaskCron = envStr( + "SCHEDULER_CLEANUP_CRON", c.Scheduler.CleanupUnusedUploadsTaskCron, + ) +} + // printConfig 打印配置内容 func printConfig(c *configModel) { configJSON, err := json.MarshalIndent(c, "", " ")