spark-server/internal/bootstrap/config.go
DESKTOP-L8OUL9F\gfl0034 5fda6be8ec feat(squarebot): 接入媒体引导与 Bot 附件/锁
完善 bootstrap 媒体装配、配置开关,并补充 squarebot 附件与锁逻辑。
2026-09-24 23:37:44 +08:00

428 lines
12 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"`
Admin AdminConfig `yaml:"admin"`
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"`
Media MediaConfig `yaml:"media"`
SquareBot SquareBotConfig `yaml:"square_bot"`
SMS SMSConfig `yaml:"sms"`
}
// AdminConfig is popadmin JWT validation (separate from client jwt).
type AdminConfig struct {
JWTSecret string `yaml:"jwt_secret"`
}
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"`
PublicEndpoint string `yaml:"public_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"`
}
// MediaConfig is server-side media detect (users) + compress (bots).
// Client App still compresses user uploads; see docs/media.md.
type MediaConfig struct {
Enabled bool `yaml:"enabled"`
UserReject bool `yaml:"user_reject"` // false=log only; true=return 40005
BotCompress bool `yaml:"bot_compress"`
ImageMaxEdge int `yaml:"image_max_edge"`
VideoMaxEdge int `yaml:"video_max_edge"`
AvatarMaxEdge int `yaml:"avatar_max_edge"`
ImageMaxBytes int64 `yaml:"image_max_bytes"`
VideoMaxBytes int64 `yaml:"video_max_bytes"`
AvatarMaxBytes int64 `yaml:"avatar_max_bytes"`
JPEGQuality int `yaml:"jpeg_quality"`
VideoCRF int `yaml:"video_crf"`
FFmpegPath string `yaml:"ffmpeg_path"`
}
// SquareBotConfig drives the overseas media RSS publisher on the square feed.
type SquareBotConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
TTL time.Duration `yaml:"ttl"`
Feeds []string `yaml:"feeds"`
BotIndexes []int `yaml:"bot_indexes"`
}
type MessageConfig struct {
MaxContentSize int `yaml:"max_content_size"`
SyncLimit int `yaml:"sync_limit"`
MsgIDExpireMS int64 `yaml:"msgid_expire_ms"`
}
type SMSConfig struct {
SMTP SMTPConfig `yaml:"smtp"`
AliyunSMS AliyunSMSConfig `yaml:"aliyun_sms"`
}
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
}
type AliyunSMSConfig struct {
AccessKeyID string `yaml:"access_key_id"`
AccessKeySecret string `yaml:"access_key_secret"`
SignName string `yaml:"sign_name"`
Templates AliyunSMSTemplatesConfig `yaml:"templates"`
}
type AliyunSMSTemplatesConfig struct {
Login AliyunCarrierTemplates `yaml:"login"`
AccountDelete AliyunCarrierTemplates `yaml:"account_delete"`
}
type AliyunCarrierTemplates struct {
Mobile string `yaml:"mobile"`
Unicom string `yaml:"unicom"`
Telecom string `yaml:"telecom"`
}
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"`
PairKeyMode string `yaml:"pair_key_mode"`
}
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_ADMIN_JWT_SECRET"); v != "" {
cfg.Admin.JWTSecret = v
}
if strings.TrimSpace(cfg.Admin.JWTSecret) == "" {
cfg.Admin.JWTSecret = "wyrs-admin-jwt-secret-change-in-production-2025"
}
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 strings.TrimSpace(cfg.JWT.PairKeyMode) == "" {
cfg.JWT.PairKeyMode = "triple"
}
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_PUBLIC_ENDPOINT"); v != "" {
cfg.ObjectStorage.PublicEndpoint = 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 v := os.Getenv("SPARK_SMTP_PASSWORD"); v != "" {
cfg.SMS.SMTP.Password = v
}
if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_ID"); v != "" {
cfg.SMS.AliyunSMS.AccessKeyID = v
}
if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_SECRET"); v != "" {
cfg.SMS.AliyunSMS.AccessKeySecret = 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
}
applyMediaDefaults(&cfg.Media)
if cfg.SquareBot.Interval <= 0 {
cfg.SquareBot.Interval = 5 * time.Minute
}
if cfg.SquareBot.TTL <= 0 {
cfg.SquareBot.TTL = 15 * 24 * time.Hour
}
}
func applyMediaDefaults(m *MediaConfig) {
if m == nil {
return
}
// Numeric/path defaults only. Toggle via YAML media.enabled / media.bot_compress
// (configs ship with enabled: true). Explicit enabled: false stays off.
if m.ImageMaxEdge <= 0 {
m.ImageMaxEdge = 1080
}
if m.VideoMaxEdge <= 0 {
m.VideoMaxEdge = 720
}
if m.AvatarMaxEdge <= 0 {
m.AvatarMaxEdge = 200
}
if m.ImageMaxBytes <= 0 {
m.ImageMaxBytes = 10 << 20
}
if m.VideoMaxBytes <= 0 {
m.VideoMaxBytes = 100 << 20
}
if m.AvatarMaxBytes <= 0 {
m.AvatarMaxBytes = 2 << 20
}
if m.JPEGQuality <= 0 {
m.JPEGQuality = 85
}
if m.VideoCRF <= 0 {
m.VideoCRF = 23
}
if strings.TrimSpace(m.FFmpegPath) == "" {
m.FFmpegPath = "ffmpeg"
}
}
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 strings.TrimSpace(cfg.Admin.JWTSecret) == "" ||
strings.TrimSpace(cfg.Admin.JWTSecret) == "wyrs-admin-jwt-secret-change-in-production-2025" {
return fmt.Errorf("prod: admin.jwt_secret must be set via config or SPARK_ADMIN_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")
}
}
if strings.TrimSpace(cfg.SMS.SMTP.Host) == "" {
return fmt.Errorf("prod: sms.smtp.host is required")
}
if strings.TrimSpace(cfg.SMS.SMTP.Username) == "" || strings.TrimSpace(cfg.SMS.SMTP.Password) == "" {
return fmt.Errorf("prod: sms.smtp.username and password are required")
}
return nil
}