package service import ( "context" "errors" "fmt" "log" "strconv" "strings" "sync/atomic" "time" "gorm.io/gorm" "oci-portal/internal/crypto" "oci-portal/internal/model" ) // Telegram 通知相关的配置键。 const ( settingTelegramEnabled = "telegram_enabled" // "1" / "0" settingTelegramBotToken = "telegram_bot_token" // AES-GCM 密文 settingTelegramChatID = "telegram_chat_id" // 明文 ) // 通知事件开关的配置键(notify_event_{kind}),值 "1"/"0"; // 键缺省(存量部署从未保存过)视为开启,只有显式 "0" 视为关闭。 // 云端事件按类别细分(旧总开关键 notify_event_log_event 已废弃,库中遗留无害)。 const ( settingNotifyEventTaskFail = "notify_event_task_fail" settingNotifyEventTaskRecover = "notify_event_task_recover" settingNotifyEventSnatchSuccess = "notify_event_snatch_success" settingNotifyEventTenantDead = "notify_event_tenant_dead" settingNotifyEventTaskStop = "notify_event_task_stop" settingNotifyEventLoginLock = "notify_event_login_lock" settingNotifyEventModelDeprecated = "notify_event_model_deprecated" settingNotifyEventLogEventInstance = "notify_event_log_event_instance" settingNotifyEventLogEventIdentity = "notify_event_log_event_identity" settingNotifyEventLogEventPolicy = "notify_event_log_event_policy" settingNotifyEventLogEventRegion = "notify_event_log_event_region" settingNotifyEventLogEventLogin = "notify_event_log_event_login" ) // 任务行为相关的配置键与取值约束(抢机连续 NotAuthenticated 熔断阈值)。 const ( settingSnatchAuthFailLimit = "snatch_auth_fail_limit" defaultSnatchAuthFailLimit = 3 minSnatchAuthFailLimit = 1 maxSnatchAuthFailLimit = 10 ) // ErrInvalidAuthFailLimit 标记抢机熔断阈值越界,api 层据此返回 400。 var ErrInvalidAuthFailLimit = fmt.Errorf("熔断阈值须在 %d-%d 之间", minSnatchAuthFailLimit, maxSnatchAuthFailLimit) // SettingService 读写系统级键值配置,敏感值经 Cipher 加密落库; // 安全设置(WAF/真实IP头/app_url)另持内存快照供每请求热路径零 IO 读取。 type SettingService struct { db *gorm.DB cipher *crypto.Cipher security atomic.Value // SecuritySettings 快照,ReloadSecurity/UpdateSecurity 刷新 envPublicURL string // PUBLIC_URL 环境变量,app_url 未配置时的回退 } // NewSettingService 组装依赖。 func NewSettingService(db *gorm.DB, cipher *crypto.Cipher) *SettingService { return &SettingService{db: db, cipher: cipher} } // get 读取键值;键不存在视为未配置,返回空串。 func (s *SettingService) get(ctx context.Context, key string) (string, error) { var st model.Setting err := s.db.WithContext(ctx).First(&st, "key = ?", key).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", fmt.Errorf("get setting %s: %w", key, err) } return st.Value, nil } // set 写入键值(key 为主键,Save 即 upsert)。 func (s *SettingService) set(ctx context.Context, key, value string) error { if err := saveSettingTx(s.db.WithContext(ctx), key, value); err != nil { return err } return nil } // settingValueTx / saveSettingTx 是事务内变体:供跨表不变量(如「至少保留一种 // 登录方式」)在同一事务与用户行锁下读写开关,与 get/set 同表同形。 func settingValueTx(tx *gorm.DB, key string) (string, error) { var st model.Setting err := tx.First(&st, "key = ?", key).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", nil } if err != nil { return "", fmt.Errorf("get setting %s: %w", key, err) } return st.Value, nil } func saveSettingTx(tx *gorm.DB, key, value string) error { st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()} if err := tx.Save(&st).Error; err != nil { return fmt.Errorf("set setting %s: %w", key, err) } return nil } // getMany 批量读取多个键;任一失败即返回错误。 func (s *SettingService) getMany(ctx context.Context, keys ...string) (map[string]string, error) { out := make(map[string]string, len(keys)) for _, key := range keys { value, err := s.get(ctx, key) if err != nil { return nil, err } out[key] = value } return out, nil } // TelegramConfig 是通知发送所需的完整配置,token 已解密,仅供服务内部消费。 type TelegramConfig struct { Enabled bool BotToken string ChatID string } // TelegramConfig 返回解密后的 Telegram 配置。 func (s *SettingService) TelegramConfig(ctx context.Context) (TelegramConfig, error) { enabled, err := s.get(ctx, settingTelegramEnabled) if err != nil { return TelegramConfig{}, err } chatID, err := s.get(ctx, settingTelegramChatID) if err != nil { return TelegramConfig{}, err } token, err := s.telegramToken(ctx) if err != nil { return TelegramConfig{}, err } return TelegramConfig{Enabled: enabled == "1", BotToken: token, ChatID: chatID}, nil } // telegramToken 读取并解密 bot token;未配置时返回空串。 func (s *SettingService) telegramToken(ctx context.Context) (string, error) { enc, err := s.get(ctx, settingTelegramBotToken) if err != nil || enc == "" { return "", err } token, err := s.cipher.DecryptString(enc) if err != nil { return "", fmt.Errorf("decrypt telegram token: %w", err) } return token, nil } // TelegramView 是设置接口的响应视图,绝不包含 token 明文。 type TelegramView struct { Enabled bool `json:"enabled"` ChatID string `json:"chatId"` TokenSet bool `json:"tokenSet"` TokenTail string `json:"tokenTail"` } // TelegramView 返回脱敏后的 Telegram 配置视图。 func (s *SettingService) TelegramView(ctx context.Context) (TelegramView, error) { cfg, err := s.TelegramConfig(ctx) if err != nil { return TelegramView{}, err } return TelegramView{ Enabled: cfg.Enabled, ChatID: cfg.ChatID, TokenSet: cfg.BotToken != "", TokenTail: tokenTail(cfg.BotToken), }, nil } // tokenTail 取 token 尾 4 位用于展示;过短时不展示,避免变相泄露。 func tokenTail(token string) string { if len(token) < 8 { return "" } return token[len(token)-4:] } // UpdateTelegramInput 是更新 Telegram 配置的输入; // BotToken 为 nil 沿用已存 token,非 nil 时覆盖(空串表示清除)。 type UpdateTelegramInput struct { Enabled bool BotToken *string ChatID string } // UpdateTelegram 保存 Telegram 配置,token 加密落库。 func (s *SettingService) UpdateTelegram(ctx context.Context, in UpdateTelegramInput) error { enabled := "0" if in.Enabled { enabled = "1" } if err := s.set(ctx, settingTelegramEnabled, enabled); err != nil { return err } if err := s.set(ctx, settingTelegramChatID, in.ChatID); err != nil { return err } if in.BotToken == nil { return nil } return s.saveTelegramToken(ctx, *in.BotToken) } // saveTelegramToken 加密保存 token;空串直接落空值表示清除。 func (s *SettingService) saveTelegramToken(ctx context.Context, token string) error { if token == "" { return s.set(ctx, settingTelegramBotToken, "") } enc, err := s.cipher.EncryptString(token) if err != nil { return fmt.Errorf("encrypt telegram token: %w", err) } return s.set(ctx, settingTelegramBotToken, enc) } // NotifyEventsView 是通知事件开关视图;零值无意义,一律由 NotifyEvents 构造。 // 云端事件按类别细分:实例生命周期 / 用户与凭据 / 策略 / 区域订阅 / 控制台登录。 type NotifyEventsView struct { TaskFail bool `json:"taskFail"` TaskRecover bool `json:"taskRecover"` SnatchSuccess bool `json:"snatchSuccess"` TenantDead bool `json:"tenantDead"` TaskStop bool `json:"taskStop"` LoginLock bool `json:"loginLock"` ModelDeprecated bool `json:"modelDeprecated"` LogEventInstance bool `json:"logEventInstance"` LogEventIdentity bool `json:"logEventIdentity"` LogEventPolicy bool `json:"logEventPolicy"` LogEventRegion bool `json:"logEventRegion"` LogEventLogin bool `json:"logEventLogin"` } // notifyEventFields 把视图字段与配置键一一对应,供读写复用。 func notifyEventFields(v *NotifyEventsView) []struct { key string on *bool } { return []struct { key string on *bool }{ {settingNotifyEventTaskFail, &v.TaskFail}, {settingNotifyEventTaskRecover, &v.TaskRecover}, {settingNotifyEventSnatchSuccess, &v.SnatchSuccess}, {settingNotifyEventTenantDead, &v.TenantDead}, {settingNotifyEventTaskStop, &v.TaskStop}, {settingNotifyEventLoginLock, &v.LoginLock}, {settingNotifyEventModelDeprecated, &v.ModelDeprecated}, {settingNotifyEventLogEventInstance, &v.LogEventInstance}, {settingNotifyEventLogEventIdentity, &v.LogEventIdentity}, {settingNotifyEventLogEventPolicy, &v.LogEventPolicy}, {settingNotifyEventLogEventRegion, &v.LogEventRegion}, {settingNotifyEventLogEventLogin, &v.LogEventLogin}, } } // NotifyEvents 返回全部通知事件开关;键缺省视为开启,兼容存量部署。 func (s *SettingService) NotifyEvents(ctx context.Context) (NotifyEventsView, error) { var view NotifyEventsView for _, f := range notifyEventFields(&view) { val, err := s.get(ctx, f.key) if err != nil { return NotifyEventsView{}, err } *f.on = val != "0" } return view, nil } // UpdateNotifyEvents 全量保存五个通知事件开关。 func (s *SettingService) UpdateNotifyEvents(ctx context.Context, in NotifyEventsView) error { for _, f := range notifyEventFields(&in) { value := "0" if *f.on { value = "1" } if err := s.set(ctx, f.key, value); err != nil { return err } } return nil } // NotifyEventEnabled 查询单个通知事件开关,供发送前过滤; // 只有显式 "0" 视为关闭,键缺省或读取失败一律按开启返回(降级不漏发),错误仅记日志。 func (s *SettingService) NotifyEventEnabled(ctx context.Context, kind string) bool { val, err := s.get(ctx, "notify_event_"+kind) if err != nil { log.Printf("notify event %s switch: %v", kind, err) return true } return val != "0" } // NotifyTemplate 读取 kind 的自定义模板;空串表示未自定义(按默认模板发送)。 func (s *SettingService) NotifyTemplate(ctx context.Context, kind string) (string, error) { return s.get(ctx, notifyTplKey(kind)) } // NotifyTemplates 返回全部通知模板视图(默认模板 + 自定义现值),顺序稳定。 func (s *SettingService) NotifyTemplates(ctx context.Context) ([]NotifyTemplateView, error) { out := make([]NotifyTemplateView, 0, len(notifyTplOrder)) for _, kind := range notifyTplOrder { def := notifyTplDefs[kind] custom, err := s.get(ctx, notifyTplKey(kind)) if err != nil { return nil, err } out = append(out, NotifyTemplateView{ Kind: kind, Label: def.Label, Vars: def.Vars, DefaultTemplate: def.Default, Template: custom, }) } return out, nil } // UpdateNotifyTemplate 保存 kind 的自定义模板;空串清除自定义(恢复默认)。 func (s *SettingService) UpdateNotifyTemplate(ctx context.Context, kind, tpl string) error { if !notifyTplValid(kind) { return fmt.Errorf("未知通知类型 %q", kind) } tpl = strings.TrimSpace(tpl) if len([]rune(tpl)) > 2000 { return fmt.Errorf("模板长度不能超过 2000 字符") } return s.set(ctx, notifyTplKey(kind), tpl) } // TaskSettingsView 是任务行为设置的读写视图。 type TaskSettingsView struct { SnatchAuthFailLimit int `json:"snatchAuthFailLimit"` } // TaskSettings 返回任务行为设置;阈值缺省或非法(非整数 / 越界脏数据)时回退默认值。 func (s *SettingService) TaskSettings(ctx context.Context) (TaskSettingsView, error) { val, err := s.get(ctx, settingSnatchAuthFailLimit) if err != nil { return TaskSettingsView{}, err } limit, err := strconv.Atoi(val) if err != nil || limit < minSnatchAuthFailLimit || limit > maxSnatchAuthFailLimit { limit = defaultSnatchAuthFailLimit } return TaskSettingsView{SnatchAuthFailLimit: limit}, nil } // UpdateTaskSettings 保存任务行为设置;阈值越界返回 ErrInvalidAuthFailLimit。 func (s *SettingService) UpdateTaskSettings(ctx context.Context, in TaskSettingsView) error { if in.SnatchAuthFailLimit < minSnatchAuthFailLimit || in.SnatchAuthFailLimit > maxSnatchAuthFailLimit { return fmt.Errorf("update task settings: %w", ErrInvalidAuthFailLimit) } return s.set(ctx, settingSnatchAuthFailLimit, strconv.Itoa(in.SnatchAuthFailLimit)) }