Files
geo/server/internal/ops/config/config.go
T
root 7b4d7ccf68 feat(ops): add job center for cross-source job operations
Provide a unified ops console for inspecting, retrying and cancelling
jobs across generation, template/kol assist, knowledge parse, desktop
publish/task, compliance review and monitoring collect sources. Wires
RabbitMQ for retry republish and consolidates the desktop_publish_jobs
columns into the base migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:56:39 +08:00

540 lines
13 KiB
Go

package config
import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"time"
"github.com/mitchellh/mapstructure"
"go.uber.org/zap"
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
configruntime "github.com/geo-platform/tenant-api/internal/shared/config/runtime"
runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env"
runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file"
)
type Config struct {
Server sharedconfig.ServerConfig `mapstructure:"server"`
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
Log sharedconfig.LogConfig `mapstructure:"log"`
JWT JWTConfig `mapstructure:"jwt"`
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
IPRegion IPRegionConfig `mapstructure:"ip_region"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessTTL time.Duration `mapstructure:"access_ttl"`
}
type DefaultAdminConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
DisplayName string `mapstructure:"display_name"`
Email string `mapstructure:"email"`
}
type AdminUsersConfig struct {
DefaultPlanCode string `mapstructure:"default_plan_code"`
}
type IPRegionConfig struct {
V4XDBPath string `mapstructure:"v4_xdb_path"`
V6XDBPath string `mapstructure:"v6_xdb_path"`
}
type Store struct {
path string
mu sync.RWMutex
cfg *Config
runtime configruntime.Config
watchedFiles map[string]fileSnapshot
updates chan struct{}
}
type ReloadEvent struct {
Previous *Config
Current *Config
Changes []FieldChange
}
type FieldChange struct {
Path string
Hot bool
}
func Load(path string) (*Config, error) {
cfg, err := load(path)
if err != nil {
return nil, err
}
return cfg, nil
}
func NewStore(path string) (*Store, error) {
cfg, err := load(path)
if err != nil {
return nil, err
}
store := &Store{
path: path,
cfg: cfg,
watchedFiles: snapshotFiles(watchFiles(path)),
updates: make(chan struct{}, 1),
}
store.runtime = store.newRuntimeConfig()
return store, nil
}
func (s *Store) Current() *Config {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error {
if s == nil {
return fmt.Errorf("ops config store is nil")
}
if s.runtime == nil {
s.runtime = s.newRuntimeConfig()
}
if err := s.runtime.Load(); err != nil {
return err
}
if err := s.runtime.Watch("", func(string, configruntime.Value) {
s.notifyReload()
}); err != nil {
_ = s.runtime.Close()
return err
}
if logger != nil {
logger.Info("ops config hot reload watcher started", zap.String("file", s.path))
}
for {
select {
case <-ctx.Done():
return nil
case <-s.updates:
s.reload(logger, onReload)
}
}
}
func (s *Store) Close() {
if s == nil {
return
}
if s.runtime != nil {
_ = s.runtime.Close()
}
}
func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) {
next, err := load(s.path)
if err != nil {
if logger != nil {
logger.Warn("ops config reload rejected", zap.Error(err))
}
return
}
s.mu.Lock()
previous := s.cfg
s.cfg = next
s.watchedFiles = snapshotFiles(watchFiles(s.path))
s.mu.Unlock()
event := ReloadEvent{
Previous: previous,
Current: next,
Changes: Diff(previous, next),
}
if onReload != nil {
onReload(event)
}
if logger != nil {
restartRequired := RestartRequired(event.Changes)
logger.Info("ops config reloaded", zap.Bool("restart_required", restartRequired))
if restartRequired {
logger.Warn("ops config reload changed connection or listener settings; restart process to apply those fields")
}
}
}
func (s *Store) notifyReload() {
if !s.consumeFileChange() {
return
}
select {
case s.updates <- struct{}{}:
default:
}
}
func (s *Store) consumeFileChange() bool {
s.mu.Lock()
defer s.mu.Unlock()
next := snapshotFiles(watchFiles(s.path))
if len(s.watchedFiles) == 0 {
s.watchedFiles = next
return true
}
changed := !fileSnapshotsEqual(s.watchedFiles, next)
if changed {
s.watchedFiles = next
}
return changed
}
func (s *Store) newRuntimeConfig() configruntime.Config {
return configruntime.New(configruntime.WithSource(
runtimefile.NewSource(filepath.Dir(s.path), watchNames(s.path)...),
runtimeenv.NewSource("OPS"),
))
}
func Diff(previous, current *Config) []FieldChange {
if previous == nil || current == nil {
return nil
}
changes := make([]FieldChange, 0)
add := func(path string, hot bool) {
changes = append(changes, FieldChange{Path: path, Hot: hot})
}
if !reflect.DeepEqual(previous.Server, current.Server) {
add("server", false)
}
if previous.Database != current.Database {
add("database", false)
}
if previous.MonitoringDatabase != current.MonitoringDatabase {
add("monitoring_database", false)
}
if previous.Redis != current.Redis {
add("redis", false)
}
if previous.Cache != current.Cache {
add("cache", false)
}
if previous.RabbitMQ != current.RabbitMQ {
add("rabbitmq", false)
}
if previous.Log != current.Log {
add("log", false)
}
if previous.IPRegion != current.IPRegion {
add("ip_region", false)
}
if previous.JWT != current.JWT {
add("jwt", true)
}
if previous.AdminUsers != current.AdminUsers {
add("admin_users", true)
}
if previous.DefaultAdmin != current.DefaultAdmin {
add("default_admin", true)
}
return changes
}
func RestartRequired(changes []FieldChange) bool {
for _, change := range changes {
if !change.Hot {
return true
}
}
return false
}
func load(path string) (*Config, error) {
resolved := configruntime.New(configruntime.WithSource(
runtimefile.NewSource(path),
runtimeenv.NewSource("OPS"),
))
defer resolved.Close()
if err := resolved.Load(); err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
settings := defaultSettings()
if err := resolved.Scan(&settings); err != nil {
return nil, fmt.Errorf("scan config: %w", err)
}
applySharedDefaults(settings)
var cfg Config
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &cfg,
TagName: "mapstructure",
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
})
if err != nil {
return nil, err
}
if err := decoder.Decode(settings); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := applyEnvOverrides(&cfg); err != nil {
return nil, err
}
normalizeConfig(&cfg)
if cfg.JWT.Secret == "" {
return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)")
}
if cfg.MonitoringDatabase.DBName == "" {
cfg.MonitoringDatabase = cfg.Database
}
return &cfg, nil
}
func applySharedDefaults(settings map[string]any) {
cacheSettings := ensureMapSetting(settings, "cache")
if _, ok := cacheSettings["metrics_enabled"]; !ok {
cacheSettings["metrics_enabled"] = true
}
}
func ensureMapSetting(settings map[string]any, key string) map[string]any {
if settings == nil {
return map[string]any{}
}
if existing, ok := settings[key].(map[string]any); ok {
return existing
}
next := make(map[string]any)
settings[key] = next
return next
}
func normalizeConfig(cfg *Config) {
if cfg.Server.Port == 0 {
cfg.Server.Port = 8090
}
if strings.TrimSpace(cfg.Server.Mode) == "" {
cfg.Server.Mode = "debug"
}
sharedconfig.NormalizeServerConfig(&cfg.Server)
if strings.TrimSpace(cfg.Redis.Addr) == "" {
cfg.Redis.Addr = "localhost:6379"
}
sharedconfig.NormalizeRedisConfig(&cfg.Redis)
if strings.TrimSpace(cfg.Cache.Driver) == "" {
cfg.Cache.Driver = "redis"
}
sharedconfig.NormalizeCacheConfig(&cfg.Cache)
normalizeOpsRabbitMQConfig(&cfg.RabbitMQ)
if strings.TrimSpace(cfg.Log.Level) == "" {
cfg.Log.Level = "info"
}
if strings.TrimSpace(cfg.Log.Format) == "" {
cfg.Log.Format = "json"
}
if cfg.JWT.AccessTTL <= 0 {
cfg.JWT.AccessTTL = 8 * time.Hour
}
if strings.TrimSpace(cfg.DefaultAdmin.Username) == "" {
cfg.DefaultAdmin.Username = "admin"
}
if strings.TrimSpace(cfg.DefaultAdmin.DisplayName) == "" {
cfg.DefaultAdmin.DisplayName = "Administrator"
}
if strings.TrimSpace(cfg.AdminUsers.DefaultPlanCode) == "" {
cfg.AdminUsers.DefaultPlanCode = "free"
}
}
func defaultSettings() map[string]any {
return map[string]any{
"server": map[string]any{
"port": 8090,
"mode": "debug",
"trusted_proxies": sharedconfig.DefaultTrustedProxies(),
},
"redis": map[string]any{
"addr": "localhost:6379",
"db": 0,
},
"cache": map[string]any{
"driver": "redis",
},
"rabbitmq": map[string]any{},
"log": map[string]any{
"level": "info",
"format": "json",
},
"jwt": map[string]any{
"access_ttl": 8 * time.Hour,
},
"default_admin": map[string]any{
"username": "admin",
"display_name": "Administrator",
},
"admin_users": map[string]any{
"default_plan_code": "free",
},
"ip_region": map[string]any{
"v4_xdb_path": "",
"v6_xdb_path": "",
},
}
}
func applyEnvOverrides(cfg *Config) error {
if v := os.Getenv("OPS_JWT_SECRET"); v != "" {
cfg.JWT.Secret = v
}
if v := os.Getenv("OPS_DEFAULT_ADMIN_USERNAME"); v != "" {
cfg.DefaultAdmin.Username = v
}
if v := os.Getenv("OPS_DEFAULT_ADMIN_PASSWORD"); v != "" {
cfg.DefaultAdmin.Password = v
}
if v := os.Getenv("OPS_DEFAULT_ADMIN_DISPLAY_NAME"); v != "" {
cfg.DefaultAdmin.DisplayName = v
}
if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" {
cfg.DefaultAdmin.Email = v
}
if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" {
cfg.AdminUsers.DefaultPlanCode = v
}
if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" {
cfg.IPRegion.V4XDBPath = v
}
if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" {
cfg.IPRegion.V6XDBPath = v
}
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
cfg.Server.TrustedProxies = strings.Split(v, ",")
}
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
cfg.RabbitMQ.URL = v
}
return nil
}
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
if cfg == nil {
return
}
if strings.TrimSpace(cfg.GenerationExchange) == "" {
cfg.GenerationExchange = "generation.task"
}
if strings.TrimSpace(cfg.GenerationRoutingKey) == "" {
cfg.GenerationRoutingKey = "generation.task.run"
}
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
cfg.DesktopTaskEventExchange = "desktop.task.event"
}
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
}
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
cfg.DesktopDispatchPublishPrefix = "publish"
}
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
cfg.DesktopDispatchMonitorPrefix = "monitor"
}
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
cfg.TemplateAssistExchange = "template.assist"
}
if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" {
cfg.TemplateAssistRoutingKey = "template.assist.run"
}
if strings.TrimSpace(cfg.KolAssistExchange) == "" {
cfg.KolAssistExchange = "kol.assist"
}
if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" {
cfg.KolAssistRoutingKey = "kol.assist.run"
}
if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" {
cfg.ComplianceReviewExchange = "compliance.review"
}
if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" {
cfg.ComplianceReviewRoutingKey = "compliance.review.run"
}
if cfg.PublishChannelPoolSize <= 0 {
cfg.PublishChannelPoolSize = 16
}
}
type fileSnapshot struct {
size int64
modTime time.Time
exists bool
}
func snapshotFiles(files []string) map[string]fileSnapshot {
snapshots := make(map[string]fileSnapshot, len(files))
for _, file := range files {
if strings.TrimSpace(file) == "" {
continue
}
abs, err := filepath.Abs(file)
if err == nil {
file = abs
}
file = filepath.Clean(file)
info, err := os.Stat(file)
if err != nil {
snapshots[file] = fileSnapshot{}
continue
}
snapshots[file] = fileSnapshot{
size: info.Size(),
modTime: info.ModTime(),
exists: true,
}
}
return snapshots
}
func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool {
if len(a) != len(b) {
return false
}
for key, left := range a {
right, ok := b[key]
if !ok || left != right {
return false
}
}
return true
}
func watchFiles(path string) []string {
if strings.TrimSpace(path) == "" {
return nil
}
return []string{path}
}
func watchNames(path string) []string {
if strings.TrimSpace(path) == "" {
return nil
}
return []string{filepath.Base(path)}
}