spark-server/internal/bootstrap/config.go
zhr ae9a71034d feat: implement Spark IM V3 modular monolith
Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:11 +08:00

287 lines
8.0 KiB
Go

// Package bootstrap loads config and owns process lifecycle.
package bootstrap
import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Env string `yaml:"env"`
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
ID IDConfig `yaml:"id"`
MySQL MySQLConfig `yaml:"mysql"`
Redis RedisConfig `yaml:"redis"`
JWT JWTConfig `yaml:"jwt"`
TaskPool TaskPoolConfig `yaml:"taskpool"`
Deliver DeliverConfig `yaml:"deliver"`
Session SessionConfig `yaml:"session"`
RateLimit RateLimitConfig `yaml:"rate_limit"`
Message MessageConfig `yaml:"message"`
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
}
type LogConfig struct {
Level string `yaml:"level"`
Encoding string `yaml:"encoding"`
Console bool `yaml:"console"`
File LogFileConfig `yaml:"file"`
}
type LogFileConfig struct {
Path string `yaml:"path"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
MaxAgeDays int `yaml:"max_age_days"`
Compress bool `yaml:"compress"`
}
type ServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
TCP TCPConfig `yaml:"tcp"`
}
type HTTPConfig struct {
Addr string `yaml:"addr"`
}
type TCPConfig struct {
Addr string `yaml:"addr"`
ProxyProtocol bool `yaml:"proxy_protocol"`
TLS TLSConfig `yaml:"tls"`
MaxFrameSize int `yaml:"max_frame_size"`
IdleTimeout time.Duration `yaml:"idle_timeout"`
KeepAlive time.Duration `yaml:"keepalive"`
RecvBuffer int `yaml:"recv_buffer"`
SendBuffer int `yaml:"send_buffer"`
Multicore bool `yaml:"multicore"`
CompressMinSize int `yaml:"compress_min_size"`
}
type TLSConfig struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
}
type IDConfig struct {
SnowflakeWorkerID int64 `yaml:"snowflake_worker_id"`
}
type MySQLConfig struct {
DSN string `yaml:"dsn"`
SlowThreshold time.Duration `yaml:"slow_threshold"`
LogLevel string `yaml:"log_level"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time"`
}
type RedisConfig struct {
Addr string `yaml:"addr"`
Username string `yaml:"username"`
Password string `yaml:"password"`
DB int `yaml:"db"`
DeliverQueueKey string `yaml:"deliver_queue_key"`
DeliverProcessingKey string `yaml:"deliver_processing_key"`
DeliverDonePrefix string `yaml:"deliver_done_prefix"`
}
type TaskPoolConfig struct {
Size int `yaml:"size"`
Queue int `yaml:"queue"`
}
type DeliverConfig struct {
Workers int `yaml:"workers"`
TakeTimeout time.Duration `yaml:"take_timeout"`
DoneTTL time.Duration `yaml:"done_ttl"`
}
type SessionConfig struct {
OutboundQueueSize int `yaml:"outbound_queue_size"`
}
type RateLimitConfig struct {
MessagePerSecond int `yaml:"message_per_second"`
AuthPerSecond int `yaml:"auth_per_second"`
SyncInboxPerMinute int `yaml:"sync_inbox_per_minute"`
SyncMessagesPerMinute int `yaml:"sync_messages_per_minute"`
HistoryPerMinute int `yaml:"history_per_minute"`
UploadPerSecond int `yaml:"upload_per_second"`
}
type ObjectStorageConfig struct {
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
Region string `yaml:"region"`
UseSSL bool `yaml:"use_ssl"`
PresignTTL time.Duration `yaml:"presign_ttl"`
ChatExpireDays int `yaml:"chat_expire_days"`
}
type MessageConfig struct {
MaxContentSize int `yaml:"max_content_size"`
SyncLimit int `yaml:"sync_limit"`
MsgIDExpireMS int64 `yaml:"msgid_expire_ms"`
}
type JWTConfig struct {
Secret string `yaml:"secret"`
Issuer string `yaml:"issuer"`
TTL time.Duration `yaml:"ttl"`
RefreshTTL time.Duration `yaml:"refresh_ttl"`
DenylistPrefix string `yaml:"denylist_prefix"`
RefreshPrefix string `yaml:"refresh_prefix"`
}
func Load(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
cfg := &Config{}
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
const (
EnvDev = "dev"
EnvProd = "prod"
// DefaultJWTSecret is rejected when env is prod.
DefaultJWTSecret = "dev-only-change-me"
)
func (cfg *Config) Production() bool {
return cfg != nil && cfg.Env == EnvProd
}
func (cfg *Config) normalize() error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
applyDefaults(cfg)
return cfg.validate()
}
func applyDefaults(cfg *Config) {
if v := os.Getenv("SPARK_ENV"); v != "" {
cfg.Env = v
}
cfg.Env = parseEnv(cfg.Env)
if v := os.Getenv("SPARK_JWT_SECRET"); v != "" {
cfg.JWT.Secret = v
}
if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" {
cfg.Redis.Password = v
}
if cfg.JWT.Issuer == "" {
cfg.JWT.Issuer = "spark-im"
}
if cfg.JWT.TTL <= 0 {
cfg.JWT.TTL = 7 * 24 * time.Hour
}
if cfg.JWT.RefreshTTL <= 0 {
cfg.JWT.RefreshTTL = 30 * 24 * time.Hour
}
if cfg.JWT.DenylistPrefix == "" {
cfg.JWT.DenylistPrefix = "denylist:"
}
if cfg.JWT.RefreshPrefix == "" {
cfg.JWT.RefreshPrefix = "refresh:"
}
if cfg.Redis.DeliverQueueKey == "" {
cfg.Redis.DeliverQueueKey = "deliver:queue"
}
if cfg.Redis.DeliverProcessingKey == "" {
cfg.Redis.DeliverProcessingKey = "deliver:processing"
}
if cfg.Redis.DeliverDonePrefix == "" {
cfg.Redis.DeliverDonePrefix = "deliver:done:"
}
if cfg.Deliver.Workers < 1 {
cfg.Deliver.Workers = 8
}
if cfg.Deliver.TakeTimeout <= 0 {
cfg.Deliver.TakeTimeout = 5 * time.Second
}
if cfg.Deliver.DoneTTL <= 0 {
cfg.Deliver.DoneTTL = 10 * time.Minute
}
if cfg.RateLimit.AuthPerSecond < 1 {
cfg.RateLimit.AuthPerSecond = 5
}
if cfg.RateLimit.MessagePerSecond < 1 {
cfg.RateLimit.MessagePerSecond = 20
}
if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" {
cfg.ObjectStorage.Endpoint = v
}
if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" {
cfg.ObjectStorage.AccessKey = v
}
if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" {
cfg.ObjectStorage.SecretKey = v
}
if v := os.Getenv("SPARK_S3_BUCKET"); v != "" {
cfg.ObjectStorage.Bucket = v
}
if cfg.ObjectStorage.Bucket == "" {
cfg.ObjectStorage.Bucket = "spark"
}
if cfg.ObjectStorage.Region == "" {
cfg.ObjectStorage.Region = "us-east-1"
}
if cfg.ObjectStorage.PresignTTL <= 0 {
cfg.ObjectStorage.PresignTTL = 15 * time.Minute
}
if cfg.ObjectStorage.ChatExpireDays < 1 {
cfg.ObjectStorage.ChatExpireDays = 365
}
if cfg.RateLimit.UploadPerSecond < 1 {
cfg.RateLimit.UploadPerSecond = 10
}
}
func parseEnv(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "prod", "production":
return EnvProd
default:
return EnvDev
}
}
func (cfg *Config) validate() error {
if !cfg.Production() {
return nil
}
if strings.TrimSpace(cfg.MySQL.DSN) == "" {
return fmt.Errorf("prod: mysql.dsn is required")
}
if strings.TrimSpace(cfg.Redis.Addr) == "" {
return fmt.Errorf("prod: redis.addr is required")
}
if strings.TrimSpace(cfg.JWT.Secret) == "" || strings.TrimSpace(cfg.JWT.Secret) == DefaultJWTSecret {
return fmt.Errorf("prod: jwt.secret must be set via config or SPARK_JWT_SECRET")
}
if ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" {
if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" {
return fmt.Errorf("prod: object_storage access_key and secret_key are required")
}
}
return nil
}