管理端按用户查询开启通讯录/相册/媒体拉取;服务端写入路径自动压图;新增 /v1/sign 与 /v1/energy。 Co-authored-by: Cursor <cursoragent@cursor.com>
326 lines
9.7 KiB
Go
326 lines
9.7 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 / objectstore).
|
|
// Image/avatar hard cap defaults to 150 KiB; 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) {
|
|
applyConfigEnvOverrides(cfg)
|
|
applyConfigStructDefaults(cfg)
|
|
}
|
|
|
|
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 = 150 * 1024 // 150 KiB
|
|
}
|
|
if m.VideoMaxBytes <= 0 {
|
|
m.VideoMaxBytes = 100 << 20
|
|
}
|
|
if m.AvatarMaxBytes <= 0 {
|
|
m.AvatarMaxBytes = 150 * 1024 // 150 KiB
|
|
}
|
|
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
|
|
}
|