Files
oci-portal/internal/service/setting.go
T

347 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
st := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
if err := s.db.WithContext(ctx).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))
}