package service import ( "context" "fmt" "strconv" "strings" ) // 通知渠道配置键,前缀 notify_ch__;标注「密文」的键 AES-GCM 加密落库。 const ( settingChWebhookEnabled = "notify_ch_webhook_enabled" // "1"/"0" settingChWebhookURL = "notify_ch_webhook_url" settingChWebhookBodyTpl = "notify_ch_webhook_body_tpl" settingChNtfyEnabled = "notify_ch_ntfy_enabled" settingChNtfyServer = "notify_ch_ntfy_server" settingChNtfyTopic = "notify_ch_ntfy_topic" settingChNtfyToken = "notify_ch_ntfy_token" // 密文 settingChBarkEnabled = "notify_ch_bark_enabled" settingChBarkServer = "notify_ch_bark_server" settingChBarkDeviceKey = "notify_ch_bark_device_key" // 密文 settingChSMTPEnabled = "notify_ch_smtp_enabled" settingChSMTPHost = "notify_ch_smtp_host" settingChSMTPPort = "notify_ch_smtp_port" settingChSMTPUsername = "notify_ch_smtp_username" settingChSMTPPassword = "notify_ch_smtp_password" // 密文 settingChSMTPFrom = "notify_ch_smtp_from" settingChSMTPTo = "notify_ch_smtp_to" ) // ErrUnknownNotifyChannel 标记未知渠道类型,api 层映射 400。 var ErrUnknownNotifyChannel = fmt.Errorf("未知通知渠道类型") // NotifyChannelView 是单渠道的脱敏视图:密文字段只回是否已配置与尾 4 位。 type NotifyChannelView struct { Type string `json:"type"` Enabled bool `json:"enabled"` // webhook URL string `json:"url,omitempty"` BodyTemplate string `json:"bodyTemplate,omitempty"` // ntfy / bark 共用 Server Server string `json:"server,omitempty"` Topic string `json:"topic,omitempty"` // smtp Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` Username string `json:"username,omitempty"` From string `json:"from,omitempty"` To string `json:"to,omitempty"` // 密文字段状态(ntfy token / bark device key / smtp password) SecretSet bool `json:"secretSet"` SecretTail string `json:"secretTail,omitempty"` } // UpdateNotifyChannelInput 是单渠道更新入参;Secret 为 nil 沿用已存密文,空串清除。 type UpdateNotifyChannelInput struct { Enabled bool URL string BodyTemplate string Server string Topic string Host string Port int Username string From string To string Secret *string } // notifyChannelKeys 返回渠道的(启用键, 明文键列表, 密文键);未知类型返回启用键空串。 func notifyChannelKeys(chType string) (enabled string, plain []string, secret string) { switch chType { case ChannelWebhook: return settingChWebhookEnabled, []string{settingChWebhookURL, settingChWebhookBodyTpl}, "" case ChannelNtfy: return settingChNtfyEnabled, []string{settingChNtfyServer, settingChNtfyTopic}, settingChNtfyToken case ChannelBark: return settingChBarkEnabled, []string{settingChBarkServer}, settingChBarkDeviceKey case ChannelSMTP: return settingChSMTPEnabled, []string{settingChSMTPHost, settingChSMTPPort, settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo}, settingChSMTPPassword } return "", nil, "" } // NotifyChannels 返回全部新增渠道的脱敏视图(不含 telegram,其沿用独立接口)。 func (s *SettingService) NotifyChannels(ctx context.Context) ([]NotifyChannelView, error) { out := make([]NotifyChannelView, 0, len(NotifyChannelTypes)) for _, t := range NotifyChannelTypes { v, err := s.notifyChannelView(ctx, t) if err != nil { return nil, err } out = append(out, v) } return out, nil } // notifyChannelView 组装单渠道脱敏视图。 func (s *SettingService) notifyChannelView(ctx context.Context, chType string) (NotifyChannelView, error) { enabledKey, plain, secretKey := notifyChannelKeys(chType) vals, err := s.getMany(ctx, append(plain, enabledKey)...) if err != nil { return NotifyChannelView{}, err } view := NotifyChannelView{Type: chType, Enabled: vals[enabledKey] == "1"} fillChannelView(&view, chType, vals) if secretKey == "" { return view, nil } secret, err := s.decryptSetting(ctx, secretKey) if err != nil { return NotifyChannelView{}, err } view.SecretSet = secret != "" view.SecretTail = tokenTail(secret) return view, nil } // fillChannelView 把明文键值填入视图对应字段。 func fillChannelView(v *NotifyChannelView, chType string, vals map[string]string) { switch chType { case ChannelWebhook: v.URL, v.BodyTemplate = vals[settingChWebhookURL], vals[settingChWebhookBodyTpl] case ChannelNtfy: v.Server, v.Topic = vals[settingChNtfyServer], vals[settingChNtfyTopic] case ChannelBark: v.Server = vals[settingChBarkServer] case ChannelSMTP: v.Host, v.Username = vals[settingChSMTPHost], vals[settingChSMTPUsername] v.From, v.To = vals[settingChSMTPFrom], vals[settingChSMTPTo] v.Port, _ = strconv.Atoi(vals[settingChSMTPPort]) } } // UpdateNotifyChannel 保存单渠道配置;字段先经 validateChannelInput 校验。 func (s *SettingService) UpdateNotifyChannel(ctx context.Context, chType string, in UpdateNotifyChannelInput) error { enabledKey, _, secretKey := notifyChannelKeys(chType) if enabledKey == "" { return ErrUnknownNotifyChannel } if err := validateChannelInput(chType, in); err != nil { return err } pairs := channelPlainPairs(chType, in) pairs[enabledKey] = boolSetting(in.Enabled) for k, v := range pairs { if err := s.set(ctx, k, v); err != nil { return err } } if secretKey == "" || in.Secret == nil { return nil } return s.encryptSetting(ctx, secretKey, *in.Secret) } // channelPlainPairs 返回渠道明文键与入参值的对应关系。 func channelPlainPairs(chType string, in UpdateNotifyChannelInput) map[string]string { switch chType { case ChannelWebhook: return map[string]string{settingChWebhookURL: in.URL, settingChWebhookBodyTpl: in.BodyTemplate} case ChannelNtfy: return map[string]string{settingChNtfyServer: in.Server, settingChNtfyTopic: in.Topic} case ChannelBark: return map[string]string{settingChBarkServer: in.Server} case ChannelSMTP: return map[string]string{settingChSMTPHost: in.Host, settingChSMTPPort: strconv.Itoa(in.Port), settingChSMTPUsername: in.Username, settingChSMTPFrom: in.From, settingChSMTPTo: in.To} } return nil } // validateChannelInput 按渠道校验入参;仅在启用时强校验必填,便于「先存草稿后启用」。 func validateChannelInput(chType string, in UpdateNotifyChannelInput) error { if !in.Enabled { return validateChannelURLs(chType, in) } switch chType { case ChannelWebhook: if strings.TrimSpace(in.URL) == "" { return fmt.Errorf("启用 Webhook 须填写 URL") } case ChannelNtfy: if strings.TrimSpace(in.Topic) == "" { return fmt.Errorf("启用 ntfy 须填写 topic") } case ChannelSMTP: if in.Host == "" || in.Port <= 0 || in.Port > 65535 || in.From == "" || in.To == "" { return fmt.Errorf("启用 SMTP 须填写主机、端口(1-65535)、发件人与收件人") } } return validateChannelURLs(chType, in) } // validateChannelURLs 校验渠道内的 URL 形字段(非空才校验)。 func validateChannelURLs(chType string, in UpdateNotifyChannelInput) error { if chType == ChannelWebhook && in.URL != "" { return validNotifyURL(in.URL) } if (chType == ChannelNtfy || chType == ChannelBark) && in.Server != "" { return validNotifyURL(in.Server) } return nil } // boolSetting 把布尔序列化为设置值 "1"/"0"。 func boolSetting(b bool) string { if b { return "1" } return "0" } // encryptSetting 加密保存设置值;空串直接落空值表示清除。 func (s *SettingService) encryptSetting(ctx context.Context, key, value string) error { if value == "" { return s.set(ctx, key, "") } enc, err := s.cipher.EncryptString(value) if err != nil { return fmt.Errorf("encrypt setting %s: %w", key, err) } return s.set(ctx, key, enc) } // decryptSetting 读取并解密设置值;未配置返回空串。 func (s *SettingService) decryptSetting(ctx context.Context, key string) (string, error) { enc, err := s.get(ctx, key) if err != nil || enc == "" { return "", err } val, err := s.cipher.DecryptString(enc) if err != nil { return "", fmt.Errorf("decrypt setting %s: %w", key, err) } return val, nil } // webhookChannel 读取解密后的 Webhook 配置,供发送端消费。 func (s *SettingService) webhookChannel(ctx context.Context) (WebhookChannel, error) { vals, err := s.getMany(ctx, settingChWebhookEnabled, settingChWebhookURL, settingChWebhookBodyTpl) if err != nil { return WebhookChannel{}, err } return WebhookChannel{ Enabled: vals[settingChWebhookEnabled] == "1", URL: vals[settingChWebhookURL], BodyTemplate: vals[settingChWebhookBodyTpl], }, nil } // ntfyChannel 读取解密后的 ntfy 配置。 func (s *SettingService) ntfyChannel(ctx context.Context) (NtfyChannel, error) { vals, err := s.getMany(ctx, settingChNtfyEnabled, settingChNtfyServer, settingChNtfyTopic) if err != nil { return NtfyChannel{}, err } token, err := s.decryptSetting(ctx, settingChNtfyToken) if err != nil { return NtfyChannel{}, err } return NtfyChannel{ Enabled: vals[settingChNtfyEnabled] == "1", Server: vals[settingChNtfyServer], Topic: vals[settingChNtfyTopic], Token: token, }, nil } // barkChannel 读取解密后的 Bark 配置。 func (s *SettingService) barkChannel(ctx context.Context) (BarkChannel, error) { vals, err := s.getMany(ctx, settingChBarkEnabled, settingChBarkServer) if err != nil { return BarkChannel{}, err } key, err := s.decryptSetting(ctx, settingChBarkDeviceKey) if err != nil { return BarkChannel{}, err } return BarkChannel{ Enabled: vals[settingChBarkEnabled] == "1", Server: vals[settingChBarkServer], DeviceKey: key, }, nil } // smtpChannel 读取解密后的 SMTP 配置。 func (s *SettingService) smtpChannel(ctx context.Context) (SMTPChannel, error) { vals, err := s.getMany(ctx, settingChSMTPEnabled, settingChSMTPHost, settingChSMTPPort, settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo) if err != nil { return SMTPChannel{}, err } password, err := s.decryptSetting(ctx, settingChSMTPPassword) if err != nil { return SMTPChannel{}, err } port, _ := strconv.Atoi(vals[settingChSMTPPort]) return SMTPChannel{ Enabled: vals[settingChSMTPEnabled] == "1", Host: vals[settingChSMTPHost], Port: port, Username: vals[settingChSMTPUsername], Password: password, From: vals[settingChSMTPFrom], To: vals[settingChSMTPTo], }, nil }