diff --git a/agent/app/repo/alert.go b/agent/app/repo/alert.go index e62d1fa02..015a9b0eb 100644 --- a/agent/app/repo/alert.go +++ b/agent/app/repo/alert.go @@ -1,6 +1,9 @@ package repo import ( + "encoding/json" + "strings" + "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" @@ -353,56 +356,203 @@ var singletonTypes = map[string]bool{ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { tx := global.AlertDB.Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + var oldConfigs []model.AlertConfig - _ = tx.Find(&oldConfigs).Error + if err := tx.Find(&oldConfigs).Error; err != nil { + tx.Rollback() + return err + } + + usedConfigIDs, err := loadUsedAlertConfigIDs(tx) + if err != nil { + tx.Rollback() + return err + } + oldConfigMap := make(map[string]uint) - nonSingletonTypes := make(map[string]struct{}) + oldConfigByType := make(map[string][]model.AlertConfig) + oldConfigByKey := make(map[string][]model.AlertConfig) + consumedConfigIDs := make(map[uint]struct{}) for _, item := range oldConfigs { if singletonTypes[item.Type] { oldConfigMap[item.Type] = item.ID continue } - nonSingletonTypes[item.Type] = struct{}{} - } - for _, item := range data { - if !singletonTypes[item.Type] { - nonSingletonTypes[item.Type] = struct{}{} - } - } - for itemType := range nonSingletonTypes { - if err := tx.Where("type = ?", itemType).Delete(&model.AlertConfig{}).Error; err != nil { - tx.Rollback() - return err - } + oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item) + oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item) } for _, item := range data { if singletonTypes[item.Type] { if val, ok := oldConfigMap[item.Type]; ok { item.ID = val delete(oldConfigMap, item.Type) + consumedConfigIDs[item.ID] = struct{}{} } else { item.ID = 0 } - if err := tx.Model(model.AlertConfig{}).Where("id = ?", item.ID).Save(&item).Error; err != nil { + if item.ID == 0 { + if err := tx.Create(&item).Error; err != nil { + tx.Rollback() + return err + } + } else if err := tx.Save(&item).Error; err != nil { tx.Rollback() return err } continue } + + key := alertConfigSyncKey(item) + if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok { + item.ID = matched.ID + consumedConfigIDs[item.ID] = struct{}{} + if err := tx.Save(&item).Error; err != nil { + tx.Rollback() + return err + } + deleteAlertConfigByID(oldConfigByType, matched.ID) + continue + } + + if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok { + item.ID = matched.ID + consumedConfigIDs[item.ID] = struct{}{} + if err := tx.Save(&item).Error; err != nil { + tx.Rollback() + return err + } + continue + } + item.ID = 0 if err := tx.Create(&item).Error; err != nil { tx.Rollback() return err } } - for _, id := range oldConfigMap { - if err := tx.Where("id = ?", id).Delete(&model.AlertConfig{}).Error; err != nil { + for _, item := range oldConfigs { + if _, used := usedConfigIDs[item.ID]; used { + continue + } + if _, kept := consumedConfigIDs[item.ID]; kept { + continue + } + if err := tx.Where("id = ?", item.ID).Delete(&model.AlertConfig{}).Error; err != nil { tx.Rollback() return err } } if err := tx.Commit().Error; err != nil { + tx.Rollback() return err } return nil } + +func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) { + var alerts []model.Alert + if err := tx.Select("method").Find(&alerts).Error; err != nil { + return nil, err + } + + usedIDs := make(map[uint]struct{}) + for _, alert := range alerts { + for _, item := range strings.Split(alert.Method, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + id, err := strconv.ParseUint(item, 10, 64) + if err != nil { + continue + } + usedIDs[uint(id)] = struct{}{} + } + } + return usedIDs, nil +} + +func alertConfigSyncKey(item model.AlertConfig) string { + return item.Type + "::" + normalizeAlertConfigJSON(item.Config) +} + +func normalizeAlertConfigJSON(config string) string { + trimmed := strings.TrimSpace(config) + if trimmed == "" { + return "" + } + + var data any + if err := json.Unmarshal([]byte(trimmed), &data); err != nil { + return trimmed + } + buf, err := json.Marshal(data) + if err != nil { + return trimmed + } + return string(buf) +} + +func popAlertConfigByKey(configMap map[string][]model.AlertConfig, key string) (model.AlertConfig, bool) { + items := configMap[key] + if len(items) == 0 { + return model.AlertConfig{}, false + } + + item := items[0] + if len(items) == 1 { + delete(configMap, key) + } else { + configMap[key] = items[1:] + } + return item, true +} + +func popUnusedAlertConfigByType(configMap map[string][]model.AlertConfig, usedConfigIDs map[uint]struct{}, configType string) (model.AlertConfig, bool) { + items := configMap[configType] + if len(items) == 0 { + return model.AlertConfig{}, false + } + + for idx, item := range items { + if _, used := usedConfigIDs[item.ID]; used { + continue + } + if idx == 0 { + if len(items) == 1 { + delete(configMap, configType) + } else { + configMap[configType] = items[1:] + } + } else { + configMap[configType] = append(items[:idx], items[idx+1:]...) + } + return item, true + } + return model.AlertConfig{}, false +} + +func deleteAlertConfigByID(configMap map[string][]model.AlertConfig, id uint) { + for key, items := range configMap { + for idx, item := range items { + if item.ID != id { + continue + } + if len(items) == 1 { + delete(configMap, key) + } else { + configMap[key] = append(items[:idx], items[idx+1:]...) + } + return + } + } +} diff --git a/agent/app/service/alert.go b/agent/app/service/alert.go index 3d847c78b..f8a8c6950 100644 --- a/agent/app/service/alert.go +++ b/agent/app/service/alert.go @@ -512,6 +512,9 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri if err := a.checkAlertConfigDisplayNameUnique(req); err != nil { return err } + if err := a.checkAlertConfigSMSPhoneUnique(req); err != nil { + return err + } if req.ID != 0 { upMap := make(map[string]interface{}) upMap["id"] = req.ID @@ -538,6 +541,29 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri return nil } +func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error { + if req.Type != constant.SMSConfig { + return nil + } + + phone := alertConfigSMSPhone(req.Config) + configs, err := alertRepo.AlertConfigList(alertRepo.WithByType(req.Type)) + if err != nil { + return err + } + + for _, config := range configs { + if req.ID != 0 && config.ID == req.ID { + continue + } + if alertConfigSMSPhone(config.Config) == phone { + return buserr.New("ErrAlertConfigPhoneExist") + } + } + + return nil +} + func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error { displayName := alertConfigDisplayName(req.Type, req.Config) if displayName == "" { @@ -604,7 +630,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error func alertConfigDisplayName(configType, configData string) string { switch configType { - case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark: + case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS: var cfg struct { DisplayName string `json:"displayName"` } @@ -617,6 +643,16 @@ func alertConfigDisplayName(configType, configData string) string { } } +func alertConfigSMSPhone(configData string) string { + var cfg struct { + Phone string `json:"phone"` + } + if err := json.Unmarshal([]byte(configData), &cfg); err != nil { + return "" + } + return strings.TrimSpace(cfg.Phone) +} + func (a AlertService) DeleteAlertConfig(id uint) error { _, err := alertRepo.GetConfigById(id) if err != nil { diff --git a/agent/i18n/lang/en.yaml b/agent/i18n/lang/en.yaml index 520e03568..f3b0dbd10 100644 --- a/agent/i18n/lang/en.yaml +++ b/agent/i18n/lang/en.yaml @@ -78,6 +78,7 @@ ErrBackupCheck: 'Backup account connectivity test failed: {{ .err }}' ErrBackupLocalDelete: 'Local backup accounts cannot be deleted' ErrBackupLocalCreate: 'Local backup accounts cannot be created' ErrAlertConfigInUse: 'Alert configuration is in use and cannot be deleted' +ErrAlertConfigPhoneExist: 'Phone number already exists' #app ErrPortInUsed: '{{ .detail }} port is already occupied!' diff --git a/agent/i18n/lang/es-ES.yaml b/agent/i18n/lang/es-ES.yaml index baa70e4ed..8fa23c76f 100644 --- a/agent/i18n/lang/es-ES.yaml +++ b/agent/i18n/lang/es-ES.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: 'Conexión de respaldo falló: {{ .err }}' ErrBackupLocalDelete: 'No se puede eliminar cuentas de respaldo locales' ErrBackupLocalCreate: 'No se pueden crear cuentas de respaldo locales' ErrAlertConfigInUse: 'La configuración de alertas está en uso y no se puede eliminar' +ErrAlertConfigPhoneExist: 'El numero de telefono ya existe' ErrPortInUsed: 'El puerto {{ .detail }} ya está ocupado' ErrAppLimit: 'El número de aplicaciones instaladas ha superado el límite' ErrNotInstall: 'Aplicación no instalada' diff --git a/agent/i18n/lang/ja.yaml b/agent/i18n/lang/ja.yaml index f2eefde1c..1a3ac5a2c 100644 --- a/agent/i18n/lang/ja.yaml +++ b/agent/i18n/lang/ja.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: '接続テストに失敗しました: {{ .err }}' ErrBackupLocalDelete: 'ローカルバックアップは削除できません' ErrBackupLocalCreate: 'ローカルバックアップは作成できません' ErrAlertConfigInUse: 'アラート設定は使用中のため削除できません' +ErrAlertConfigPhoneExist: '電話番号はすでに存在します' ErrPortInUsed: '{{ .detail }} ポートはすでに使用されています' ErrAppLimit: 'インストールされているアプリケーションの数が制限を超えました' ErrNotInstall: 'アプリケーションがインストールされていません' diff --git a/agent/i18n/lang/ko.yaml b/agent/i18n/lang/ko.yaml index 853974c60..0c04a83b1 100644 --- a/agent/i18n/lang/ko.yaml +++ b/agent/i18n/lang/ko.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: '연결 테스트 실패: {{ .err }}' ErrBackupLocalDelete: '로컬 백업은 삭제할 수 없습니다' ErrBackupLocalCreate: '로컬 백업은 만들 수 없습니다' ErrAlertConfigInUse: '경고 설정이 사용 중이므로 삭제할 수 없습니다' +ErrAlertConfigPhoneExist: '전화번호가 이미 존재합니다' ErrPortInUsed: '{{ .detail }} 포트가 이미 사용 중입니다' ErrAppLimit: '설치된 애플리케이션 수가 한도를 초과했습니다' ErrNotInstall: '응용 프로그램이 설치되지 않았습니다' diff --git a/agent/i18n/lang/ms.yaml b/agent/i18n/lang/ms.yaml index a4ac2aea7..d26c77439 100644 --- a/agent/i18n/lang/ms.yaml +++ b/agent/i18n/lang/ms.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: 'Ujian sambungan gagal: {{ .err }}' ErrBackupLocalDelete: 'Tidak boleh padam sandaran tempatan' ErrBackupLocalCreate: 'Tidak boleh buat sandaran tempatan' ErrAlertConfigInUse: 'Konfigurasi amaran sedang digunakan dan tidak boleh dipadamkan' +ErrAlertConfigPhoneExist: 'Nombor telefon sudah wujud' ErrPortInUsed: 'Port {{ .detail }} sudah diduduki' ErrAppLimit: 'Bilangan aplikasi yang dipasang telah melebihi had' ErrNotInstall: 'Aplikasi tidak dipasang' diff --git a/agent/i18n/lang/pt-BR.yaml b/agent/i18n/lang/pt-BR.yaml index a00023cd7..70b1798a9 100644 --- a/agent/i18n/lang/pt-BR.yaml +++ b/agent/i18n/lang/pt-BR.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: 'Teste de conexão falhou: {{ .err }}' ErrBackupLocalDelete: 'Não é permitido excluir contas locais' ErrBackupLocalCreate: 'Não é permitido criar contas locais' ErrAlertConfigInUse: 'A configuração de alerta está em uso e não pode ser excluída' +ErrAlertConfigPhoneExist: 'O numero de telefone ja existe' ErrPortInUsed: 'A porta {{ .detail }} já está ocupada' ErrAppLimit: 'O número de aplicativos instalados excedeu o limite' ErrNotInstall: 'Aplicativo não instalado' diff --git a/agent/i18n/lang/ru.yaml b/agent/i18n/lang/ru.yaml index 81721dbce..aecdb256b 100644 --- a/agent/i18n/lang/ru.yaml +++ b/agent/i18n/lang/ru.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: 'Проверка подключения не удалась: {{ ErrBackupLocalDelete: 'Нельзя удалить локальные бэкапы' ErrBackupLocalCreate: 'Нельзя создать локальные бэкапы' ErrAlertConfigInUse: 'Конфигурация оповещений используется и не может быть удалена' +ErrAlertConfigPhoneExist: 'Номер телефона уже существует' ErrPortInUsed: '{{ .detail }} порт уже занят' ErrAppLimit: 'Количество установленных приложений превысило лимит' ErrNotInstall: 'Приложение не установлено' diff --git a/agent/i18n/lang/tr.yaml b/agent/i18n/lang/tr.yaml index 3f8ac9c7e..6fd8dd047 100644 --- a/agent/i18n/lang/tr.yaml +++ b/agent/i18n/lang/tr.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: 'Bağlantı testi başarısız: {{ .err }}' ErrBackupLocalDelete: 'Yerel yedek silme yok' ErrBackupLocalCreate: 'Yerel yedek oluşturma yok' ErrAlertConfigInUse: 'Uyarı yapılandırması kullanımda ve silinemez' +ErrAlertConfigPhoneExist: 'Telefon numarasi zaten mevcut' ErrPortInUsed: '{{ .detail }} portu zaten kullanılıyor' ErrAppLimit: 'Yüklenen uygulama sayısı sınırı aştı' ErrNotInstall: 'Uygulama yüklenmedi' diff --git a/agent/i18n/lang/zh-Hant.yaml b/agent/i18n/lang/zh-Hant.yaml index 1d6a9864f..e8e1c270a 100644 --- a/agent/i18n/lang/zh-Hant.yaml +++ b/agent/i18n/lang/zh-Hant.yaml @@ -79,6 +79,7 @@ ErrBackupCheck: '備份帳號測試連線失敗{{ .err }}' ErrBackupLocalDelete: '暫時不支援刪除本機伺服器備份帳號' ErrBackupLocalCreate: '暫時不支援建立本機伺服器備份帳號' ErrAlertConfigInUse: '告警配置正在使用中,無法刪除' +ErrAlertConfigPhoneExist: '手機號碼已存在' ErrPortInUsed: '{{ .detail }} 連接埠已被佔用!' ErrAppLimit: '應用程式超出安裝數量限制' ErrNotInstall: '應用程式未安裝' diff --git a/agent/i18n/lang/zh.yaml b/agent/i18n/lang/zh.yaml index 4b4b3a9c2..395e5f803 100644 --- a/agent/i18n/lang/zh.yaml +++ b/agent/i18n/lang/zh.yaml @@ -78,6 +78,7 @@ ErrBackupCheck: "备份账号测试连接失败 {{ .err }}" ErrBackupLocalDelete: "暂不支持删除本地服务器备份账号" ErrBackupLocalCreate: "暂不支持创建本地服务器备份账号" ErrAlertConfigInUse: "告警配置正在使用中,无法删除" +ErrAlertConfigPhoneExist: "手机号已存在" #app ErrPortInUsed: "{{ .detail }} 端口已被占用!" diff --git a/frontend/src/api/modules/alert.ts b/frontend/src/api/modules/alert.ts index 181187f53..78f60dfc5 100644 --- a/frontend/src/api/modules/alert.ts +++ b/frontend/src/api/modules/alert.ts @@ -9,7 +9,7 @@ const alertConfigHiddenTypes = ['sms']; const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => { const globalStore = GlobalStore(); const types = new Set(excludeTypes); - if (globalStore.isIntl || globalStore.isEE) { + if (globalStore.isIntl || globalStore.isEE || !globalStore.isProductPro) { alertConfigHiddenTypes.forEach((type) => types.add(type)); } return Array.from(types); diff --git a/frontend/src/views/setting/alert/dash/task/index.vue b/frontend/src/views/setting/alert/dash/task/index.vue index 81954818d..dba77f910 100644 --- a/frontend/src/views/setting/alert/dash/task/index.vue +++ b/frontend/src/views/setting/alert/dash/task/index.vue @@ -275,15 +275,15 @@ class="flex items-center flex-row md:flex-nowrap sm:flex-nowrap flex-wrap justify-between gap-2 w-full" > - - + + - + {{ $t('xpack.alert.loginFail') }} - + @@ -398,7 +398,7 @@ import { routerToName } from '@/utils/router'; import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate'; import { useGlobalStore } from '@/composables/useGlobalStore'; -const { isMaster, isProductPro, isEE } = useGlobalStore(); +const { isMaster, isProductPro, isEE, isIntl } = useGlobalStore(); const alertConfigs = ref([]); const loadAlertConfigs = async () => { @@ -423,7 +423,7 @@ const configOptions = computed(() => { type: c.type, disabled: c.status !== 'Enable' || - (!isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)), + ((isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(c.type)), })) .sort((a, b) => Number(a.disabled) - Number(b.disabled)); }); diff --git a/frontend/src/views/setting/alert/setting/drawer/index.vue b/frontend/src/views/setting/alert/setting/drawer/index.vue index 03c8fcc9d..be9ee76f6 100644 --- a/frontend/src/views/setting/alert/setting/drawer/index.vue +++ b/frontend/src/views/setting/alert/setting/drawer/index.vue @@ -156,7 +156,7 @@ const emailRules = { const smsRules = { smsDisplayName: [Rules.requiredInput, { validator: checkSmsDisplayNameDuplicate, trigger: 'blur' }], - smsPhone: [Rules.requiredInput, Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }], + smsPhone: [Rules.phone, { validator: checkPhoneDuplicate, trigger: 'blur' }], smsDailyAlertNum: [Rules.integerNumber, checkNumberRange(20, 100)], }; @@ -302,10 +302,6 @@ function checkSmsDisplayNameDuplicate(_rule: unknown, value: string, callback: ( function checkPhoneDuplicate(_rule: unknown, value: string, callback: (error?: Error) => void) { const currentValue = normalizeDisplayName(value); - if (!currentValue) { - callback(); - return; - } const duplicated = alertConfigs.value.some((item) => { if (item.type !== 'sms') { diff --git a/frontend/src/views/setting/alert/setting/index.vue b/frontend/src/views/setting/alert/setting/index.vue index f387bcb48..005ae8ada 100644 --- a/frontend/src/views/setting/alert/setting/index.vue +++ b/frontend/src/views/setting/alert/setting/index.vue @@ -151,7 +151,7 @@ import { MsgSuccess } from '@/utils/message'; import AlertDrawer from '@/views/setting/alert/setting/drawer/index.vue'; import { Alert } from '@/api/interface/alert'; -const { docsUrl, isMaster, isMobile, isProductPro, isEE } = useGlobalStore(); +const { docsUrl, isMaster, isMobile, isProductPro, isEE, isIntl } = useGlobalStore(); const loading = ref(false); const alertDrawerRef = ref(); @@ -483,7 +483,7 @@ const buttons = computed(() => [ openEditDrawer(row); }, disabled: (row: Alert.AlertConfigInfo) => - !isProductPro.value && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type), + (isIntl.value || !isProductPro.value) && ['weCom', 'dingTalk', 'feiShu', 'sms'].includes(row.type), }, { label: i18n.global.t('commons.button.delete'),