From ca58c2bcaa798d615077b9da309e5cfdaf6514c3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 4 Aug 2026 13:56:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=99=E9=A2=9D=E5=91=8A=E8=AD=A6=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E9=A1=B5=E9=9D=A2=E4=B8=8E=E5=A4=9A=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=EF=BC=9A=E9=92=89=E9=92=89/=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6/=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1/Bark=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=9A=E7=9F=A5=E9=A2=91=E7=8E=87=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 告警设置拆分为独立页面,支持钉钉/飞书/企业微信/Bark/通用Webhook 多渠道 - 通知策略:低于阈值后按间隔重复提醒,达到最大次数停止,余额恢复自动重置 - 旧 webhook 配置自动迁移为通用渠道,渠道支持测试发送 - 优化告警话术:去商户ID展示、数字千分位格式化 --- .../migrations/004_alert_channels.sql | 24 + .../database/migrations/005_alert_policy.sql | 15 + backend/internal/handler/recharge.go | 114 ++++- backend/internal/model/recharge.go | 42 +- backend/internal/router/router.go | 5 + backend/internal/service/recharge.go | 267 +++++++++-- backend/internal/service/recharge_test.go | 238 ++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/index.ts | 14 +- frontend/src/layouts/MainLayout.tsx | 3 + frontend/src/pages/AlertSettings.tsx | 441 ++++++++++++++++++ frontend/src/pages/MerchantRecharge.tsx | 117 +---- frontend/src/types/index.ts | 28 ++ 13 files changed, 1154 insertions(+), 156 deletions(-) create mode 100644 backend/internal/database/migrations/004_alert_channels.sql create mode 100644 backend/internal/database/migrations/005_alert_policy.sql create mode 100644 frontend/src/pages/AlertSettings.tsx diff --git a/backend/internal/database/migrations/004_alert_channels.sql b/backend/internal/database/migrations/004_alert_channels.sql new file mode 100644 index 0000000..65b83d1 --- /dev/null +++ b/backend/internal/database/migrations/004_alert_channels.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- 余额告警通知渠道(支持钉钉 / 飞书 / 企业微信 / Bark / 通用 Webhook) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS alert_notify_channels ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + channel_type VARCHAR(32) NOT NULL, + name VARCHAR(64) NOT NULL DEFAULT '', + config TEXT NOT NULL DEFAULT '{}', + enabled BOOLEAN NOT NULL DEFAULT true +); + +CREATE INDEX IF NOT EXISTS idx_alert_notify_channels_merchant ON alert_notify_channels (merchant_id); + +-- 历史 merchant_alert_configs.webhook_url 迁移为通用 webhook 渠道 +INSERT INTO alert_notify_channels (merchant_id, channel_type, name, config, enabled) +SELECT merchant_id, 'webhook', '原 Webhook', json_build_object('webhook_url', webhook_url), true +FROM merchant_alert_configs +WHERE webhook_url <> '' +ON CONFLICT DO NOTHING; diff --git a/backend/internal/database/migrations/005_alert_policy.sql b/backend/internal/database/migrations/005_alert_policy.sql new file mode 100644 index 0000000..6330a01 --- /dev/null +++ b/backend/internal/database/migrations/005_alert_policy.sql @@ -0,0 +1,15 @@ +-- ============================================================================ +-- 余额告警通知频率策略:通知间隔、最大通知次数与当前通知状态 +-- ============================================================================ + +ALTER TABLE merchant_alert_configs + ADD COLUMN IF NOT EXISTS notify_interval_minutes INT NOT NULL DEFAULT 60; + +ALTER TABLE merchant_alert_configs + ADD COLUMN IF NOT EXISTS max_notifications INT NOT NULL DEFAULT 5; + +ALTER TABLE merchant_alert_configs + ADD COLUMN IF NOT EXISTS last_notified_at TIMESTAMPTZ; + +ALTER TABLE merchant_alert_configs + ADD COLUMN IF NOT EXISTS notification_count INT NOT NULL DEFAULT 0; diff --git a/backend/internal/handler/recharge.go b/backend/internal/handler/recharge.go index b4e6610..d332dc0 100644 --- a/backend/internal/handler/recharge.go +++ b/backend/internal/handler/recharge.go @@ -4,6 +4,7 @@ import ( "strconv" "affiliate_dash/internal/middleware" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -113,9 +114,10 @@ func (h *RechargeHandler) GetAlertConfig(c *gin.Context) { } type alertConfigReq struct { - Enabled bool `json:"enabled"` - ThresholdPoints int64 `json:"threshold_points"` - WebhookURL string `json:"webhook_url"` + Enabled bool `json:"enabled"` + ThresholdPoints int64 `json:"threshold_points"` + NotifyIntervalMinutes int `json:"notify_interval_minutes"` + MaxNotifications int `json:"max_notifications"` } // SaveAlertConfig PUT /api/merchant/recharge/alert-config @@ -126,9 +128,10 @@ func (h *RechargeHandler) SaveAlertConfig(c *gin.Context) { return } cfg, err := h.rechargeSvc.SaveAlertConfig(middleware.GetMerchantID(c), service.SaveAlertInput{ - Enabled: req.Enabled, - ThresholdPoints: req.ThresholdPoints, - WebhookURL: req.WebhookURL, + Enabled: req.Enabled, + ThresholdPoints: req.ThresholdPoints, + NotifyIntervalMinutes: req.NotifyIntervalMinutes, + MaxNotifications: req.MaxNotifications, }) if err != nil { response.BadRequest(c, err.Error()) @@ -137,6 +140,105 @@ func (h *RechargeHandler) SaveAlertConfig(c *gin.Context) { response.OK(c, cfg) } +// ListAlertChannels GET /api/merchant/alert-channels +func (h *RechargeHandler) ListAlertChannels(c *gin.Context) { + list, err := h.rechargeSvc.ListAlertChannels(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, list) +} + +type alertChannelReq struct { + ChannelType string `json:"channel_type" binding:"required"` + Name string `json:"name"` + WebhookURL string `json:"webhook_url"` + Server string `json:"server"` + Key string `json:"key"` + Enabled *bool `json:"enabled"` +} + +func (r alertChannelReq) toInput() service.AlertChannelInput { + enabled := true + if r.Enabled != nil { + enabled = *r.Enabled + } + return service.AlertChannelInput{ + ChannelType: r.ChannelType, + Name: r.Name, + Enabled: enabled, + Config: model.AlertChannelConfig{ + WebhookURL: r.WebhookURL, + Server: r.Server, + Key: r.Key, + }, + } +} + +// CreateAlertChannel POST /api/merchant/alert-channels +func (h *RechargeHandler) CreateAlertChannel(c *gin.Context) { + var req alertChannelReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:channel_type 必填") + return + } + channel, err := h.rechargeSvc.CreateAlertChannel(middleware.GetMerchantID(c), req.toInput()) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, channel) +} + +// UpdateAlertChannel PUT /api/merchant/alert-channels/:id +func (h *RechargeHandler) UpdateAlertChannel(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "渠道 ID 无效") + return + } + var req alertChannelReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:channel_type 必填") + return + } + channel, err := h.rechargeSvc.UpdateAlertChannel(middleware.GetMerchantID(c), uint(id), req.toInput()) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, channel) +} + +// DeleteAlertChannel DELETE /api/merchant/alert-channels/:id +func (h *RechargeHandler) DeleteAlertChannel(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "渠道 ID 无效") + return + } + if err := h.rechargeSvc.DeleteAlertChannel(middleware.GetMerchantID(c), uint(id)); err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, nil) +} + +// TestAlertChannel POST /api/merchant/alert-channels/:id/test +func (h *RechargeHandler) TestAlertChannel(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if id == 0 { + response.BadRequest(c, "渠道 ID 无效") + return + } + if err := h.rechargeSvc.TestAlertChannel(middleware.GetMerchantID(c), uint(id)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + // Upload POST /api/upload 上传凭证图片(multipart 字段名 file)。 func (h *RechargeHandler) Upload(c *gin.Context) { fileHeader, err := c.FormFile("file") diff --git a/backend/internal/model/recharge.go b/backend/internal/model/recharge.go index 8b37a7f..b92d2be 100644 --- a/backend/internal/model/recharge.go +++ b/backend/internal/model/recharge.go @@ -34,7 +34,9 @@ type RechargeApplication struct { Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"` } -// MerchantAlertConfig 低余额 Webhook 告警配置(每商户一份)。 +// MerchantAlertConfig 低余额告警配置(每商户一份)。 +// 通知策略:余额低于阈值期间按 NotifyIntervalMinutes 间隔重复通知, +// 最多 MaxNotifications 次(0 表示不限);余额恢复阈值以上后重置计数。 type MerchantAlertConfig struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` @@ -43,5 +45,41 @@ type MerchantAlertConfig struct { MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"` Enabled bool `gorm:"not null;default:false" json:"enabled"` ThresholdPoints int64 `gorm:"not null;default:0" json:"threshold_points"` - WebhookURL string `gorm:"size:1024" json:"webhook_url"` + WebhookURL string `gorm:"size:1024" json:"-"` + + // 通知频率策略 + NotifyIntervalMinutes int `gorm:"not null;default:60" json:"notify_interval_minutes"` + MaxNotifications int `gorm:"not null;default:5" json:"max_notifications"` + LastNotifiedAt *time.Time `json:"last_notified_at"` + NotificationCount int `gorm:"not null;default:0" json:"notification_count"` +} + +// 告警通知渠道类型 +const ( + AlertChannelDingtalk = "dingtalk" + AlertChannelFeishu = "feishu" + AlertChannelWecom = "wecom" + AlertChannelBark = "bark" + AlertChannelWebhook = "webhook" +) + +// AlertChannelConfig 告警渠道专属配置:webhook 类渠道用 WebhookURL,Bark 用 Server+Key。 +type AlertChannelConfig struct { + WebhookURL string `json:"webhook_url,omitempty"` + Server string `json:"server,omitempty"` + Key string `json:"key,omitempty"` +} + +// AlertNotifyChannel 余额告警通知渠道,Config 为渠道专属 JSON 配置。 +type AlertNotifyChannel struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MerchantID uint `gorm:"not null;index" json:"merchant_id"` + ChannelType string `gorm:"size:32;not null" json:"channel_type"` + Name string `gorm:"size:64" json:"name"` + Config AlertChannelConfig `gorm:"type:text;serializer:json" json:"config"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index c071ec5..d78788a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -137,6 +137,11 @@ func Setup(h *Handlers) *gin.Engine { merchant.GET("/recharge/applications", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Recharge.ListApplications) merchant.GET("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.GetAlertConfig) merchant.PUT("/recharge/alert-config", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.SaveAlertConfig) + merchant.GET("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.ListAlertChannels) + merchant.POST("/alert-channels", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.CreateAlertChannel) + merchant.PUT("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.UpdateAlertChannel) + merchant.DELETE("/alert-channels/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.DeleteAlertChannel) + merchant.POST("/alert-channels/:id/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Recharge.TestAlertChannel) merchant.GET("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), h.Merchant.ListAPIClients) merchant.POST("/api-clients", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient) merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureAPI), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus) diff --git a/backend/internal/service/recharge.go b/backend/internal/service/recharge.go index de0c89d..7b0e5d2 100644 --- a/backend/internal/service/recharge.go +++ b/backend/internal/service/recharge.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" @@ -27,6 +28,9 @@ const ( RechargePointsPerCNYCent = 1 // 每 1 分人民币兑换积分 ) +// timeNow 便于测试控制通知频率判断。 +var timeNow = time.Now + type RechargeService struct { db *gorm.DB fulfill *FulfillmentService @@ -180,9 +184,10 @@ func (s *RechargeService) ReviewRecharge(in ReviewRechargeInput) (*model.Recharg } type SaveAlertInput struct { - Enabled bool - ThresholdPoints int64 - WebhookURL string + Enabled bool + ThresholdPoints int64 + NotifyIntervalMinutes int + MaxNotifications int } func (s *RechargeService) GetAlertConfig(merchantID uint) (*model.MerchantAlertConfig, error) { @@ -201,36 +206,123 @@ func (s *RechargeService) SaveAlertConfig(merchantID uint, in SaveAlertInput) (* if in.ThresholdPoints < 0 { return nil, errors.New("预警阈值不能为负") } - if in.WebhookURL == "" || len(in.WebhookURL) > 1024 { - return nil, errors.New("请填写 Webhook URL") + if in.NotifyIntervalMinutes < 1 { + return nil, errors.New("通知间隔至少 1 分钟") } - if in.Enabled { - if !strings.HasPrefix(in.WebhookURL, "http://") && !strings.HasPrefix(in.WebhookURL, "https://") { - return nil, errors.New("Webhook URL 必须以 http(s):// 开头") - } + if in.MaxNotifications < 0 { + return nil, errors.New("最大通知次数不能为负") } var cfg model.MerchantAlertConfig err := s.db.Where("merchant_id = ?", merchantID).First(&cfg).Error switch { case errors.Is(err, gorm.ErrRecordNotFound): - cfg = model.MerchantAlertConfig{MerchantID: merchantID, Enabled: in.Enabled, ThresholdPoints: in.ThresholdPoints, WebhookURL: strings.TrimSpace(in.WebhookURL)} - if err := s.db.Create(&cfg).Error; err != nil { - return nil, err - } + cfg = model.MerchantAlertConfig{MerchantID: merchantID, Enabled: in.Enabled, ThresholdPoints: in.ThresholdPoints} case err != nil: return nil, err - default: - cfg.Enabled = in.Enabled - cfg.ThresholdPoints = in.ThresholdPoints - cfg.WebhookURL = strings.TrimSpace(in.WebhookURL) - if err := s.db.Save(&cfg).Error; err != nil { - return nil, err - } + } + cfg.Enabled = in.Enabled + cfg.ThresholdPoints = in.ThresholdPoints + cfg.NotifyIntervalMinutes = in.NotifyIntervalMinutes + cfg.MaxNotifications = in.MaxNotifications + if err := s.db.Save(&cfg).Error; err != nil { + return nil, err } return &cfg, nil } -// CheckLowBalanceAndNotify 余额低于阈值时向配置的 Webhook 推送告警。 +type AlertChannelInput struct { + ChannelType string + Name string + Config model.AlertChannelConfig + Enabled bool +} + +// ValidateAlertChannelInput 校验渠道类型与配置必填项。 +func ValidateAlertChannelInput(in AlertChannelInput) error { + in.ChannelType = strings.TrimSpace(in.ChannelType) + switch in.ChannelType { + case model.AlertChannelDingtalk, model.AlertChannelFeishu, model.AlertChannelWecom, model.AlertChannelWebhook: + if !strings.HasPrefix(in.Config.WebhookURL, "https://") && !strings.HasPrefix(in.Config.WebhookURL, "http://") { + return errors.New("Webhook URL 必须以 http(s):// 开头") + } + if len(in.Config.WebhookURL) > 1024 { + return errors.New("Webhook URL 过长") + } + case model.AlertChannelBark: + if strings.TrimSpace(in.Config.Key) == "" { + return errors.New("请填写 Bark 设备 Key") + } + if in.Config.Server != "" && !strings.HasPrefix(in.Config.Server, "https://") && !strings.HasPrefix(in.Config.Server, "http://") { + return errors.New("Bark 服务器地址必须以 http(s):// 开头") + } + default: + return errors.New("不支持的渠道类型") + } + return nil +} + +func (s *RechargeService) ListAlertChannels(merchantID uint) ([]model.AlertNotifyChannel, error) { + var list []model.AlertNotifyChannel + err := s.db.Where("merchant_id = ?", merchantID).Order("id ASC").Find(&list).Error + return list, err +} + +func (s *RechargeService) CreateAlertChannel(merchantID uint, in AlertChannelInput) (*model.AlertNotifyChannel, error) { + if err := ValidateAlertChannelInput(in); err != nil { + return nil, err + } + channel := &model.AlertNotifyChannel{ + MerchantID: merchantID, + ChannelType: strings.TrimSpace(in.ChannelType), + Name: strings.TrimSpace(in.Name), + Config: in.Config, + Enabled: in.Enabled, + } + if len(channel.Name) > 64 { + return nil, errors.New("渠道名称最长 64 个字符") + } + if err := s.db.Create(channel).Error; err != nil { + return nil, err + } + return channel, nil +} + +func (s *RechargeService) UpdateAlertChannel(merchantID, channelID uint, in AlertChannelInput) (*model.AlertNotifyChannel, error) { + if err := ValidateAlertChannelInput(in); err != nil { + return nil, err + } + if len(strings.TrimSpace(in.Name)) > 64 { + return nil, errors.New("渠道名称最长 64 个字符") + } + var channel model.AlertNotifyChannel + if err := s.db.Where("id = ? AND merchant_id = ?", channelID, merchantID).First(&channel).Error; err != nil { + return nil, errors.New("通知渠道不存在") + } + channel.ChannelType = strings.TrimSpace(in.ChannelType) + channel.Name = strings.TrimSpace(in.Name) + channel.Config = in.Config + channel.Enabled = in.Enabled + if err := s.db.Save(&channel).Error; err != nil { + return nil, err + } + return &channel, nil +} + +func (s *RechargeService) DeleteAlertChannel(merchantID, channelID uint) error { + return s.db.Where("id = ? AND merchant_id = ?", channelID, merchantID).Delete(&model.AlertNotifyChannel{}).Error +} + +// TestAlertChannel 向指定渠道发送一条测试消息。 +func (s *RechargeService) TestAlertChannel(merchantID, channelID uint) error { + var channel model.AlertNotifyChannel + if err := s.db.Where("id = ? AND merchant_id = ?", channelID, merchantID).First(&channel).Error; err != nil { + return errors.New("通知渠道不存在") + } + return sendChannelMessage(channel, "余额告警测试", "这是一条测试消息:如果你收到了这条消息,说明余额告警通知渠道配置正确。") +} + +// CheckLowBalanceAndNotify 余额低于阈值时向所有已启用渠道推送告警。 +// 通知频率策略:按配置间隔重复通知,达到最大次数后停止;余额恢复阈值以上后重置计数。 func (s *RechargeService) CheckLowBalanceAndNotify(merchantID uint) { cfg, err := s.GetAlertConfig(merchantID) if err != nil || !cfg.Enabled || cfg.ThresholdPoints <= 0 { @@ -240,30 +332,139 @@ func (s *RechargeService) CheckLowBalanceAndNotify(merchantID uint) { if err != nil { return } + + // 余额已恢复:重置通知状态,等待下一轮告警 if wallet.AvailableBalance >= cfg.ThresholdPoints { + if cfg.NotificationCount > 0 || cfg.LastNotifiedAt != nil { + cfg.NotificationCount = 0 + cfg.LastNotifiedAt = nil + _ = s.db.Model(&cfg).Updates(map[string]interface{}{ + "notification_count": 0, + "last_notified_at": nil, + }).Error + } + return + } + + // 未到通知间隔:跳过 + if cfg.LastNotifiedAt != nil && timeNow().Sub(*cfg.LastNotifiedAt) < time.Duration(cfg.NotifyIntervalMinutes)*time.Minute { + return + } + // 已达到最大通知次数:停止 + if cfg.MaxNotifications > 0 && cfg.NotificationCount >= cfg.MaxNotifications { + return + } + + channels, err := s.ListAlertChannels(merchantID) + if err != nil { return } var merchant model.Merchant if err := s.db.Select("code", "name").First(&merchant, merchantID).Error; err != nil { - merchant.Name = fmt.Sprintf("商户#%d", merchantID) + merchant.Name = "未知商户" } - payload := map[string]interface{}{ - "msgtype": "text", - "text": map[string]string{ - "content": fmt.Sprintf("余额告警:商户 %s(#%d)当前积分余额 %d,已低于阈值 %d,请及时处理充值。", merchant.Name, merchantID, wallet.AvailableBalance, cfg.ThresholdPoints), - }, + content := fmt.Sprintf("【余额告警】商户「%s」当前积分余额 %s,已低于预警阈值 %s,请及时充值以免影响正常业务。", merchant.Name, formatThousands(wallet.AvailableBalance), formatThousands(cfg.ThresholdPoints)) + + now := timeNow() + sent := false + for _, channel := range channels { + if !channel.Enabled { + continue + } + if err := sendChannelMessage(channel, "余额告警", content); err != nil { + log.Printf("低余额告警推送失败 merchant=%d channel=%d: %v", merchantID, channel.ID, err) + continue + } + sent = true } - raw, _ := json.Marshal(payload) - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Post(cfg.WebhookURL, "application/json", bytes.NewReader(raw)) + // 至少一个渠道推送成功才推进通知状态,避免失败时漏掉后续重试 + if sent { + cfg.NotificationCount++ + cfg.LastNotifiedAt = &now + _ = s.db.Model(&cfg).Updates(map[string]interface{}{ + "notification_count": cfg.NotificationCount, + "last_notified_at": now, + }).Error + } +} + +// formatThousands 千分位格式化数字,便于告警文案阅读。 +func formatThousands(n int64) string { + neg := n < 0 + if neg { + n = -n + } + digits := strconv.FormatInt(n, 10) + var buf []byte + for i, c := range digits { + if i > 0 && (len(digits)-i)%3 == 0 { + buf = append(buf, ',') + } + buf = append(buf, byte(c)) + } + if neg { + return "-" + string(buf) + } + return string(buf) +} + +// sendChannelMessage 按渠道类型组装并推送消息。 +func sendChannelMessage(channel model.AlertNotifyChannel, title, content string) error { + var ( + url string + payload []byte + client = &http.Client{Timeout: 10 * time.Second} + ) + switch channel.ChannelType { + case model.AlertChannelDingtalk: + url = channel.Config.WebhookURL + payload, _ = json.Marshal(map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{"content": content}, + }) + case model.AlertChannelFeishu: + url = channel.Config.WebhookURL + payload, _ = json.Marshal(map[string]interface{}{ + "msg_type": "text", + "content": map[string]string{"text": content}, + }) + case model.AlertChannelWecom: + url = channel.Config.WebhookURL + payload, _ = json.Marshal(map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{"content": content}, + }) + case model.AlertChannelBark: + server := strings.TrimRight(channel.Config.Server, "/") + if server == "" { + server = "https://api.day.app" + } + url = server + "/push" + payload, _ = json.Marshal(map[string]string{ + "device_key": channel.Config.Key, + "title": title, + "body": content, + }) + case model.AlertChannelWebhook: + url = channel.Config.WebhookURL + payload, _ = json.Marshal(map[string]string{ + "title": title, + "content": content, + }) + default: + return errors.New("不支持的渠道类型") + } + + resp, err := client.Post(url, "application/json", bytes.NewReader(payload)) if err != nil { - log.Printf("低余额告警推送失败 merchant=%d: %v", merchantID, err) - return + return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("低余额告警推送非 2xx merchant=%d status=%d", merchantID, resp.StatusCode) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("推送返回非 2xx 状态码 %d:%s", resp.StatusCode, string(body)) } + return nil } // SaveUploadFile 保存上传的图片到 uploadDir,返回可通过 /uploads 访问的 URL。 diff --git a/backend/internal/service/recharge_test.go b/backend/internal/service/recharge_test.go index ec13506..6beecd4 100644 --- a/backend/internal/service/recharge_test.go +++ b/backend/internal/service/recharge_test.go @@ -1,10 +1,16 @@ package service import ( + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" + "time" "affiliate_dash/internal/model" + + "github.com/google/uuid" ) func TestCreateRechargeRequiresVoucher(t *testing.T) { @@ -93,6 +99,238 @@ func TestReviewRechargeCreditsWalletOnce(t *testing.T) { } } +func TestAlertChannelCRUD(t *testing.T) { + db := newServiceTestDB(t) + merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-channel", 0, -1, 100) + svc := NewRechargeService(db, NewFulfillmentService(db, nil), t.TempDir()) + + // 非法渠道类型 + if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ChannelType: "sms", Name: "短信"}); err == nil { + t.Fatalf("invalid channel type should be rejected") + } + // 缺少 webhook url + if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ChannelType: model.AlertChannelDingtalk}); err == nil || !strings.Contains(err.Error(), "Webhook URL") { + t.Fatalf("missing webhook url should be rejected, got %v", err) + } + + dingtalk, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ + ChannelType: model.AlertChannelDingtalk, + Name: "财务群", + Enabled: true, + Config: model.AlertChannelConfig{WebhookURL: "https://oapi.dingtalk.com/robot/send?access_token=test"}, + }) + if err != nil { + t.Fatalf("create dingtalk channel: %v", err) + } + bark, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ + ChannelType: model.AlertChannelBark, + Name: "老板手机", + Enabled: true, + Config: model.AlertChannelConfig{Server: "https://api.day.app", Key: "abc123"}, + }) + if err != nil { + t.Fatalf("create bark channel: %v", err) + } + if bark.Config.Key != "abc123" { + t.Fatalf("bark config should persist, got %+v", bark.Config) + } + + // 跨商户隔离 + otherMerchant, _ := seedFulfillmentMerchant(t, db, "merchant-alert-channel-other", 0, -1, 100) + if _, err := svc.UpdateAlertChannel(otherMerchant, dingtalk.ID, AlertChannelInput{ + ChannelType: model.AlertChannelDingtalk, + Config: model.AlertChannelConfig{WebhookURL: "https://oapi.dingtalk.com/robot/send?access_token=hack"}, + }); err == nil { + t.Fatalf("other merchant should not update channel") + } + if err := svc.DeleteAlertChannel(otherMerchant, dingtalk.ID); err != nil { + t.Fatalf("other merchant delete should not error (no-op): %v", err) + } + list, err := svc.ListAlertChannels(merchantID) + if err != nil || len(list) != 2 { + t.Fatalf("expected 2 channels, got %d, err=%v", len(list), err) + } +} + +func TestSendChannelMessagePayload(t *testing.T) { + got := make(chan map[string]interface{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + got <- body + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // 钉钉格式 + channel := model.AlertNotifyChannel{ChannelType: model.AlertChannelDingtalk, Config: model.AlertChannelConfig{WebhookURL: srv.URL}} + if err := sendChannelMessage(channel, "余额告警", "内容"); err != nil { + t.Fatalf("send dingtalk: %v", err) + } + body := <-got + if body["msgtype"] != "text" || body["text"].(map[string]interface{})["content"] != "内容" { + t.Fatalf("unexpected dingtalk payload: %+v", body) + } + + // 飞书格式 + channel = model.AlertNotifyChannel{ChannelType: model.AlertChannelFeishu, Config: model.AlertChannelConfig{WebhookURL: srv.URL}} + if err := sendChannelMessage(channel, "余额告警", "内容"); err != nil { + t.Fatalf("send feishu: %v", err) + } + body = <-got + if body["msg_type"] != "text" || body["content"].(map[string]interface{})["text"] != "内容" { + t.Fatalf("unexpected feishu payload: %+v", body) + } + + // Bark 格式(服务器为空时使用默认服务器会请求外部网络,这里显式指定测试服务器) + channel = model.AlertNotifyChannel{ChannelType: model.AlertChannelBark, Config: model.AlertChannelConfig{Server: srv.URL, Key: "device-1"}} + if err := sendChannelMessage(channel, "标题", "内容"); err != nil { + t.Fatalf("send bark: %v", err) + } + body = <-got + if body["device_key"] != "device-1" || body["title"] != "标题" || body["body"] != "内容" { + t.Fatalf("unexpected bark payload: %+v", body) + } +} + +func TestAlertNotificationPolicy(t *testing.T) { + db := newServiceTestDB(t) + merchantID, _ := seedFulfillmentMerchant(t, db, "merchant-alert-policy", 0, -1, 100) + fulfillment := NewFulfillmentService(db, nil) + svc := NewRechargeService(db, fulfillment, t.TempDir()) + + notified := make(chan struct{}, 16) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + notified <- struct{}{} + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if _, err := svc.CreateAlertChannel(merchantID, AlertChannelInput{ + ChannelType: model.AlertChannelWebhook, + Name: "测试", + Enabled: true, + Config: model.AlertChannelConfig{WebhookURL: srv.URL}, + }); err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := svc.SaveAlertConfig(merchantID, SaveAlertInput{ + Enabled: true, + ThresholdPoints: 100, + NotifyIntervalMinutes: 30, + MaxNotifications: 3, + }); err != nil { + t.Fatalf("save alert config: %v", err) + } + + // 固定时间基线,逐步推进 + base := time.Date(2026, 8, 4, 10, 0, 0, 0, time.Local) + advance := func(d time.Duration) { + timeNow = func() time.Time { return base.Add(d) } + } + t.Cleanup(func() { timeNow = time.Now }) + advance(0) + + // 余额 0 < 阈值 100:第一次触发 → 通知 1 次 + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + default: + t.Fatalf("first check should notify") + } + + // 10 分钟后仍在间隔内 → 不通知 + advance(10 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + t.Fatalf("within interval should not notify") + default: + } + + // 31 分钟后超出间隔 → 第二次通知 + advance(31 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + default: + t.Fatalf("after interval should notify") + } + + // 61 分钟 → 第三次通知 + advance(61 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + default: + t.Fatalf("third notification expected") + } + + // 91 分钟 → 已达最大次数 3,不再通知 + advance(91 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + t.Fatalf("max notifications reached, should stop") + default: + } + + // 充值恢复余额 ≥ 阈值 → 重置计数 + if _, err := fulfillment.AdjustWallet(WalletAdjustInput{ + MerchantID: merchantID, + ActorUserID: 1, + Amount: 500, + IdempotencyKey: "recover-" + uuid.NewString(), + }); err != nil { + t.Fatalf("adjust wallet: %v", err) + } + advance(92 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + var cfg model.MerchantAlertConfig + if err := db.Where("merchant_id = ?", merchantID).First(&cfg).Error; err != nil { + t.Fatalf("query config: %v", err) + } + if cfg.NotificationCount != 0 || cfg.LastNotifiedAt != nil { + t.Fatalf("recovery should reset notification state, got count=%d", cfg.NotificationCount) + } + + // 又扣回低余额 → 重新开始新一轮通知 + if _, err := fulfillment.AdjustWallet(WalletAdjustInput{ + MerchantID: merchantID, + ActorUserID: 1, + Amount: -500, + IdempotencyKey: "down-" + uuid.NewString(), + }); err != nil { + t.Fatalf("adjust wallet down: %v", err) + } + advance(93 * time.Minute) + svc.CheckLowBalanceAndNotify(merchantID) + select { + case <-notified: + default: + t.Fatalf("new round should notify again") + } +} + +func TestFormatThousands(t *testing.T) { + cases := []struct { + in int64 + want string + }{ + {0, "0"}, + {999, "999"}, + {1000, "1,000"}, + {990, "990"}, + {1234567, "1,234,567"}, + {-1234567, "-1,234,567"}, + } + for _, tc := range cases { + if got := formatThousands(tc.in); got != tc.want { + t.Fatalf("formatThousands(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestDetectImageExt(t *testing.T) { cases := []struct { name string diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdc561a..f5e199a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import ApiDebugger from './pages/ApiDebugger' import Delivery from './pages/Delivery' import MerchantCenter from './pages/MerchantCenter' import MerchantRecharge from './pages/MerchantRecharge' +import AlertSettings from './pages/AlertSettings' import PlatformMerchants from './pages/PlatformMerchants' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { @@ -43,6 +44,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6998b04..453aaa9 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,6 +1,8 @@ import request from './request' import type { DashboardStats, + AlertChannelInput, + AlertNotifyChannel, ApiClient, ApiCredential, CallbackCredential, @@ -108,8 +110,18 @@ export const merchantApi = { request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication), alertConfig: () => request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig), - saveAlertConfig: (data: LowBalanceAlertConfig) => + saveAlertConfig: (data: { enabled: boolean; threshold_points: number; notify_interval_minutes: number; max_notifications: number }) => request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig), + alertChannels: () => + request.get('/merchant/alert-channels').then((r) => r.data.data as AlertNotifyChannel[]), + createAlertChannel: (data: AlertChannelInput) => + request.post('/merchant/alert-channels', data).then((r) => r.data.data as AlertNotifyChannel), + updateAlertChannel: (id: number, data: AlertChannelInput) => + request.put(`/merchant/alert-channels/${id}`, data).then((r) => r.data.data as AlertNotifyChannel), + deleteAlertChannel: (id: number) => + request.delete(`/merchant/alert-channels/${id}`).then((r) => r.data.data), + testAlertChannel: (id: number) => + request.post(`/merchant/alert-channels/${id}/test`).then((r) => r.data.data), } export const uploadApi = { diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index b1ae7f7..9eb3761 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -85,6 +85,7 @@ const adminSections: SidebarSection[] = [ children: [ { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }, { key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' }, + { key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' }, ], }, { @@ -126,6 +127,7 @@ const merchantSections: SidebarSection[] = [ children: [ { key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }, { key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' }, + { key: 'merchant-alert-settings', label: '余额告警', path: '/merchant-alert-settings' }, ], }, { @@ -151,6 +153,7 @@ function getSelectedKey(pathname: string, search: string) { if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders' if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet' if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge' + if (pathname.startsWith('/merchant-alert-settings')) return 'merchant-alert-settings' if (pathname.startsWith('/merchant-members')) return 'merchant-members' if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks' if (pathname.startsWith('/merchant-api-keys')) return 'api-keys' diff --git a/frontend/src/pages/AlertSettings.tsx b/frontend/src/pages/AlertSettings.tsx new file mode 100644 index 0000000..1eb948a --- /dev/null +++ b/frontend/src/pages/AlertSettings.tsx @@ -0,0 +1,441 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + Typography, + message, +} from 'antd' +import { + BellOutlined, + PlusOutlined, + ReloadOutlined, + SendOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import { PageHeader } from '../components/PageHeader' +import { merchantApi } from '../api' +import { formatDateTime } from '../utils/time' +import type { AlertChannelType, AlertNotifyChannel, LowBalanceAlertConfig, WalletAccount } from '../types' + +const channelOptions: { value: AlertChannelType; label: string; placeholder: string }[] = [ + { value: 'dingtalk', label: '钉钉机器人', placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=...' }, + { value: 'feishu', label: '飞书机器人', placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...' }, + { value: 'wecom', label: '企业微信机器人', placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...' }, + { value: 'bark', label: 'Bark(iOS)', placeholder: '' }, + { value: 'webhook', label: '通用 Webhook', placeholder: 'https://your-server.com/notify' }, +] + +const channelConfigMap: Record = { + dingtalk: { label: '钉钉机器人', color: '#0284c7', icon: '✉️' }, + feishu: { label: '飞书机器人', color: '#0ea5e9', icon: '🚀' }, + wecom: { label: '企业微信机器人', color: '#16a34a', icon: '💼' }, + bark: { label: 'Bark (iOS)', color: '#7c3aed', icon: '📱' }, + webhook: { label: '通用 Webhook', color: '#475569', icon: '🔗' }, +} + +const { Text } = Typography + +function configSummary(record: AlertNotifyChannel) { + const cfg = record.config || {} + if (record.channel_type === 'bark') { + return `${cfg.server || 'https://api.day.app'}/${cfg.key || '-'}` + } + return cfg.webhook_url || '-' +} + +export default function AlertSettings() { + const [channels, setChannels] = useState([]) + const [wallet, setWallet] = useState(null) + const [alertConfig, setAlertConfig] = useState(null) + const [loading, setLoading] = useState(false) + const [alertForm] = Form.useForm() + const [channelForm] = Form.useForm() + const [channelOpen, setChannelOpen] = useState(false) + const [editingChannel, setEditingChannel] = useState(null) + const [saving, setSaving] = useState(false) + + const loadAll = useCallback(async () => { + setLoading(true) + try { + const [configData, channelData] = await Promise.all([ + merchantApi.alertConfig(), + merchantApi.alertChannels(), + ]) + setChannels(channelData || []) + setAlertConfig(configData) + alertForm.setFieldsValue({ + enabled: configData.enabled, + threshold_points: configData.threshold_points, + notify_interval_minutes: configData.notify_interval_minutes || 60, + max_notifications: configData.max_notifications ?? 5, + }) + merchantApi.wallet().then(setWallet).catch(() => setWallet(null)) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [alertForm]) + + useEffect(() => { + loadAll() + }, [loadAll]) + + const handleSaveConfig = async () => { + try { + const values = await alertForm.validateFields() + await merchantApi.saveAlertConfig({ + enabled: values.enabled, + threshold_points: Number(values.threshold_points), + notify_interval_minutes: Number(values.notify_interval_minutes), + max_notifications: Number(values.max_notifications), + }) + message.success('告警阈值设置已保存') + loadAll() + } catch (e) { + if (e instanceof Error) { + message.error(e.message) + } + } + } + + const openCreate = () => { + setEditingChannel(null) + channelForm.resetFields() + channelForm.setFieldsValue({ channel_type: 'dingtalk', enabled: true }) + setChannelOpen(true) + } + + const openEdit = (record: AlertNotifyChannel) => { + setEditingChannel(record) + channelForm.resetFields() + channelForm.setFieldsValue({ + channel_type: record.channel_type, + name: record.name, + webhook_url: record.config?.webhook_url, + server: record.config?.server, + key: record.config?.key, + enabled: record.enabled, + }) + setChannelOpen(true) + } + + const submitChannel = async () => { + try { + const values = await channelForm.validateFields() + setSaving(true) + const payload = { + channel_type: values.channel_type, + name: values.name, + webhook_url: values.webhook_url, + server: values.server, + key: values.key, + enabled: values.enabled, + } + if (editingChannel) { + await merchantApi.updateAlertChannel(editingChannel.id, payload) + message.success('通知渠道已更新') + } else { + await merchantApi.createAlertChannel(payload) + message.success('通知渠道已添加') + } + setChannelOpen(false) + loadAll() + } catch (e) { + if (e instanceof Error) { + message.error(e.message) + } + } finally { + setSaving(false) + } + } + + const deleteChannel = async (record: AlertNotifyChannel) => { + try { + await merchantApi.deleteAlertChannel(record.id) + message.success('通知渠道已删除') + loadAll() + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败') + } + } + + const testChannel = async (record: AlertNotifyChannel) => { + try { + await merchantApi.testAlertChannel(record.id) + message.success('测试消息已发送,请检查接收端') + } catch (e) { + message.error(e instanceof Error ? e.message : '发送失败') + } + } + + const columns: ColumnsType = [ + { + title: '渠道类型', + dataIndex: 'channel_type', + width: 160, + render: (v: string) => { + const item = channelConfigMap[v] || { label: v, color: '#475569', icon: '🔔' } + return ( + + {item.icon} {item.label} + + ) + }, + }, + { + title: '渠道名称', + dataIndex: 'name', + width: 180, + render: (v) => {v || '默认接收组'}, + }, + { + title: '通知地址 / 密钥配置', + dataIndex: 'config', + width: 420, + ellipsis: true, + render: (_, record) => { + const text = configSummary(record) + return text !== '-' ? ( + + {text} + + ) : ( + '-' + ) + }, + }, + { + title: '状态', + dataIndex: 'enabled', + width: 110, + render: (v: boolean) => + v ? ( + + + 已启用 + + ) : ( + + + 已停用 + + ), + }, + { + title: '操作', + key: 'action', + width: 200, + fixed: 'right', + render: (_, record) => ( + + + + deleteChannel(record)} + okText="删除" + okButtonProps={{ danger: true }} + > + + + + ), + }, + ] + + const channelType = Form.useWatch('channel_type', channelForm) + + return ( +
+ } loading={loading} onClick={loadAll}> + 刷新 + + } + /> + + + + + 预警阈值设置 + + } + > +
+
+
+ 当前账户积分余额 +
+ {wallet === null ? '-' : wallet.available_balance.toLocaleString('zh-CN')} + 积分 +
+
+ + + + + + + min={0} step={100000} addonAfter="积分" style={{ width: '100%' }} /> + + + + min={1} precision={0} addonAfter="分钟" style={{ width: '100%' }} /> + + + + min={0} precision={0} addonAfter="次" style={{ width: '100%' }} /> + + +
+ +
+
+
+ + 提示:余额低于设定阈值时开始告警,之后按"通知间隔"重复提醒,达到"最大通知次数"后停止;余额恢复到阈值以上后自动重置计数,重新开始新一轮告警。 + + {alertConfig?.last_notified_at && ( +
+ + 最近通知:{formatDateTime(alertConfig.last_notified_at)} · 本轮已通知 {alertConfig.notification_count ?? 0} 次 + {alertConfig.max_notifications > 0 && ` / 最多 ${alertConfig.max_notifications} 次`} + +
+ )} +
+
+ + + + 通知渠道列表({channels.length}) + +
+ } + > + +
+ + 支持钉钉 / 飞书 / 企业微信群机器人 Webhook、Bark(iOS 推送)与自定义 Webhook。添加后建议点击“测试”验证连通性。 + +
+ + + + setChannelOpen(false)} + destroyOnClose + width={560} + confirmLoading={saving} + > +
+ + + + + {channelType === 'bark' ? ( + <> + + + + + + + + ) : ( + + item.value === channelType)?.placeholder} /> + + )} + + + + + +
+ + ) +} + diff --git a/frontend/src/pages/MerchantRecharge.tsx b/frontend/src/pages/MerchantRecharge.tsx index 1a6b839..b9681ca 100644 --- a/frontend/src/pages/MerchantRecharge.tsx +++ b/frontend/src/pages/MerchantRecharge.tsx @@ -12,14 +12,12 @@ import { Popconfirm, Select, Space, - Switch, Table, Tag, Typography, message, } from 'antd' import { - BellOutlined, CheckCircleOutlined, CloseCircleOutlined, EyeOutlined, @@ -31,17 +29,15 @@ import { PageHeader } from '../components/PageHeader' import ImageUploader from '../components/ImageUploader' import { merchantApi, platformApi } from '../api' import { useAuth } from '../store/auth' -import type { LowBalanceAlertConfig, PageResult, RechargeApplication, WalletAccount } from '../types' +import type { PageResult, RechargeApplication } from '../types' import { formatDateTime } from '../utils/time' const { Text } = Typography export default function MerchantRecharge() { const { isAdmin } = useAuth() - const [wallet, setWallet] = useState(null) const [applications, setApplications] = useState>({ list: [], total: 0, page: 1, size: 10 }) const [loading, setLoading] = useState(false) - const [alertForm] = Form.useForm() const [filterForm] = Form.useForm() const [createForm] = Form.useForm() const [reviewForm] = Form.useForm() @@ -54,8 +50,6 @@ export default function MerchantRecharge() { const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({}) - const currentPointsBalance = wallet?.available_balance - const filteredList = useMemo(() => { if (!filterParams.no) { return applications.list @@ -77,45 +71,9 @@ export default function MerchantRecharge() { } }, [applications.page, applications.size, filterParams, isAdmin]) - const loadAlertConfig = useCallback(async () => { - try { - const data = await merchantApi.alertConfig() - alertForm.setFieldsValue(data) - } catch (e) { - message.error(e instanceof Error ? e.message : '告警配置加载失败') - } - }, [alertForm]) - - const loadWallet = useCallback(async () => { - try { - setWallet(await merchantApi.wallet()) - } catch { - // 钱包不可用时静默降级 - } - }, []) - useEffect(() => { loadApplications() - loadAlertConfig() - loadWallet() - }, [loadAlertConfig, loadApplications, loadWallet]) - - const handleSaveAlertConfig = async () => { - try { - const values = await alertForm.validateFields() - const newConfig: LowBalanceAlertConfig = { - enabled: values.enabled, - threshold_points: values.threshold_points, - webhook_url: values.webhook_url, - } - await merchantApi.saveAlertConfig(newConfig) - message.success('低余额告警设置已更新') - } catch (e) { - if (e instanceof Error) { - message.error(e.message) - } - } - } + }, [loadApplications]) const handleFilterSubmit = (values: { no?: string; status?: string }) => { const params = { @@ -176,7 +134,6 @@ export default function MerchantRecharge() { setReviewItem(null) reviewForm.resetFields() loadApplications() - loadWallet() } catch (e) { message.error(e instanceof Error ? e.message : '审核失败') } finally { @@ -335,7 +292,7 @@ export default function MerchantRecharge() {
} onClick={() => loadApplications()}> @@ -345,74 +302,6 @@ export default function MerchantRecharge() { /> - {/* 告警与通知配置面板 */} - - - 余额告警与 Webhook 通知设置 - - } - > -
-
-
- 当前积分余额 -
- {currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} 积分 -
-
- - - - - - - - min={0} - step={100000} - addonAfter="积分" - style={{ width: '100%' }} - /> - - - - - - -
- -
-
- - 提示:告警机器人安全设置需包含关键词「余额」。当额度低于阈值时(充值入账/调账后触发),平台将以 JSON POST 发送通知至上述 URL。 - - - - {/* 申请记录表格 Card */}